ref: move account, connect and session routes
This commit is contained in:
parent
270042d132
commit
acec634e49
@ -3,9 +3,7 @@ import { log, logger } from "@/utils/log";
|
|||||||
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
|
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";
|
||||||
import { Server, Socket } from "socket.io";
|
import { Server, Socket } from "socket.io";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import * as privacyKit from "privacy-kit";
|
|
||||||
import { db } from "@/storage/db";
|
import { db } from "@/storage/db";
|
||||||
import { Account, Prisma } from "@prisma/client";
|
|
||||||
import { onShutdown } from "@/utils/shutdown";
|
import { onShutdown } from "@/utils/shutdown";
|
||||||
import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq";
|
import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq";
|
||||||
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||||
@ -14,10 +12,8 @@ import { auth } from "@/app/auth/auth";
|
|||||||
import {
|
import {
|
||||||
EventRouter,
|
EventRouter,
|
||||||
ClientConnection,
|
ClientConnection,
|
||||||
buildNewSessionUpdate,
|
|
||||||
buildNewMessageUpdate,
|
buildNewMessageUpdate,
|
||||||
buildUpdateSessionUpdate,
|
buildUpdateSessionUpdate,
|
||||||
buildUpdateAccountUpdate,
|
|
||||||
buildUpdateMachineUpdate,
|
buildUpdateMachineUpdate,
|
||||||
buildSessionActivityEphemeral,
|
buildSessionActivityEphemeral,
|
||||||
buildMachineActivityEphemeral,
|
buildMachineActivityEphemeral,
|
||||||
@ -33,14 +29,12 @@ import {
|
|||||||
httpRequestDurationHistogram
|
httpRequestDurationHistogram
|
||||||
} from "@/app/monitoring/metrics2";
|
} from "@/app/monitoring/metrics2";
|
||||||
import { activityCache } from "@/app/presence/sessionCache";
|
import { activityCache } from "@/app/presence/sessionCache";
|
||||||
import { encryptBytes, encryptString } from "@/modules/encrypt";
|
import { Fastify } from "./types";
|
||||||
import { Fastify, GitHubProfile } from "./types";
|
|
||||||
import { uploadImage } from "@/storage/uploadImage";
|
|
||||||
import { separateName } from "@/utils/separateName";
|
|
||||||
import { getPublicUrl } from "@/storage/files";
|
|
||||||
import { registerAuthRoutes } from "./routes/authRoutes";
|
import { registerAuthRoutes } from "./routes/authRoutes";
|
||||||
import { registerPushRoutes } from "./routes/pushRoutes";
|
import { registerPushRoutes } from "./routes/pushRoutes";
|
||||||
import { registerSessionRoutes } from "./routes/sessionRoutes";
|
import { registerSessionRoutes } from "./routes/sessionRoutes";
|
||||||
|
import { registerConnectRoutes } from "./routes/connectRoutes";
|
||||||
|
import { registerAccountRoutes } from "./routes/accountRoutes";
|
||||||
|
|
||||||
export async function startApi(eventRouter: EventRouter): Promise<{ app: FastifyInstance; io: Server }> {
|
export async function startApi(eventRouter: EventRouter): Promise<{ app: FastifyInstance; io: Server }> {
|
||||||
|
|
||||||
@ -177,6 +171,12 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Catch-all route for debugging 404s
|
||||||
|
app.setNotFoundHandler((request, reply) => {
|
||||||
|
log({ module: '404-handler' }, `404 - Method: ${request.method}, Path: ${request.url}, Headers: ${JSON.stringify(request.headers)}`);
|
||||||
|
reply.code(404).send({ error: 'Not found', path: request.url, method: request.method });
|
||||||
|
});
|
||||||
|
|
||||||
// Error hook for additional logging
|
// Error hook for additional logging
|
||||||
app.addHook('onError', async (request, reply, error) => {
|
app.addHook('onError', async (request, reply, error) => {
|
||||||
const method = request.method;
|
const method = request.method;
|
||||||
@ -245,666 +245,8 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
|||||||
registerAuthRoutes(typed);
|
registerAuthRoutes(typed);
|
||||||
registerPushRoutes(typed);
|
registerPushRoutes(typed);
|
||||||
registerSessionRoutes(typed, eventRouter);
|
registerSessionRoutes(typed, eventRouter);
|
||||||
|
registerAccountRoutes(typed, eventRouter);
|
||||||
// GitHub OAuth parameters
|
registerConnectRoutes(typed, eventRouter);
|
||||||
typed.get('/v1/connect/github/params', {
|
|
||||||
preHandler: app.authenticate,
|
|
||||||
schema: {
|
|
||||||
response: {
|
|
||||||
200: z.object({
|
|
||||||
url: z.string()
|
|
||||||
}),
|
|
||||||
400: z.object({
|
|
||||||
error: z.string()
|
|
||||||
}),
|
|
||||||
500: z.object({
|
|
||||||
error: z.string()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
|
||||||
const redirectUri = process.env.GITHUB_REDIRECT_URL;
|
|
||||||
|
|
||||||
if (!clientId || !redirectUri) {
|
|
||||||
return reply.code(400).send({ error: 'GitHub OAuth not configured' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate ephemeral state token (5 minutes TTL)
|
|
||||||
const state = await auth.createGithubToken(request.userId);
|
|
||||||
|
|
||||||
// Build complete OAuth URL
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
client_id: clientId,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
scope: 'read:user,user:email,read:org,codespace',
|
|
||||||
state: state
|
|
||||||
});
|
|
||||||
|
|
||||||
const url = `https://github.com/login/oauth/authorize?${params.toString()}`;
|
|
||||||
|
|
||||||
return reply.send({ url });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GitHub OAuth callback (GET for redirect from GitHub)
|
|
||||||
typed.get('/v1/connect/github/callback', {
|
|
||||||
schema: {
|
|
||||||
querystring: z.object({
|
|
||||||
code: z.string(),
|
|
||||||
state: z.string()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const { code, state } = request.query;
|
|
||||||
|
|
||||||
// Verify the state token to get userId
|
|
||||||
const tokenData = await auth.verifyGithubToken(state);
|
|
||||||
if (!tokenData) {
|
|
||||||
log({ module: 'github-oauth' }, `Invalid state token: ${state}`);
|
|
||||||
return reply.redirect('https://app.happy.engineering?error=invalid_state');
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = tokenData.userId;
|
|
||||||
const clientId = process.env.GITHUB_CLIENT_ID;
|
|
||||||
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
|
||||||
return reply.redirect('https://app.happy.engineering?error=server_config');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Exchange code for access token
|
|
||||||
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
code: code
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const tokenResponseData = await tokenResponse.json() as {
|
|
||||||
access_token?: string;
|
|
||||||
error?: string;
|
|
||||||
error_description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tokenResponseData.error) {
|
|
||||||
return reply.redirect(`https://app.happy.engineering?error=${encodeURIComponent(tokenResponseData.error)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = tokenResponseData.access_token;
|
|
||||||
|
|
||||||
// Get user info from GitHub
|
|
||||||
const userResponse = await fetch('https://api.github.com/user', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
|
||||||
'Accept': 'application/vnd.github.v3+json',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const userData = await userResponse.json() as GitHubProfile;
|
|
||||||
|
|
||||||
if (!userResponse.ok) {
|
|
||||||
return reply.redirect('https://app.happy.engineering?error=github_user_fetch_failed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store GitHub user and connect to account
|
|
||||||
const githubUser = await db.githubUser.upsert({
|
|
||||||
where: { id: userData.id.toString() },
|
|
||||||
update: {
|
|
||||||
profile: userData,
|
|
||||||
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
id: userData.id.toString(),
|
|
||||||
profile: userData,
|
|
||||||
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Avatar
|
|
||||||
log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`);
|
|
||||||
const image = await fetch(userData.avatar_url);
|
|
||||||
const imageBuffer = await image.arrayBuffer();
|
|
||||||
log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`);
|
|
||||||
const avatar = await uploadImage(userId, 'avatars', 'github', userData.avatar_url, Buffer.from(imageBuffer));
|
|
||||||
log({ module: 'github-oauth' }, `Uploaded avatar for user ${userId}: ${userData.avatar_url}`);
|
|
||||||
|
|
||||||
// Name
|
|
||||||
const name = separateName(userData.name);
|
|
||||||
log({ module: 'github-oauth' }, `Separated name for user ${userId}: ${userData.name} -> ${name.firstName} ${name.lastName}`);
|
|
||||||
|
|
||||||
// Link GitHub user to account
|
|
||||||
await db.account.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { githubUserId: githubUser.id, avatar, firstName: name.firstName, lastName: name.lastName }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send account update to all user connections
|
|
||||||
const updSeq = await allocateUserSeq(userId);
|
|
||||||
const updatePayload = buildUpdateAccountUpdate(userId, {
|
|
||||||
github: userData,
|
|
||||||
firstName: name.firstName,
|
|
||||||
lastName: name.lastName,
|
|
||||||
avatar: avatar
|
|
||||||
}, updSeq, randomKeyNaked(12));
|
|
||||||
eventRouter.emitUpdate({
|
|
||||||
userId,
|
|
||||||
payload: updatePayload,
|
|
||||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
|
||||||
});
|
|
||||||
|
|
||||||
log({ module: 'github-oauth' }, `GitHub account connected successfully for user ${userId}: ${userData.login}`);
|
|
||||||
|
|
||||||
// Redirect to app with success
|
|
||||||
return reply.redirect(`https://app.happy.engineering?github=connected&user=${encodeURIComponent(userData.login)}`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log({ module: 'github-oauth' }, `Error in GitHub GET callback: ${error}`);
|
|
||||||
return reply.redirect('https://app.happy.engineering?error=server_error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GitHub webhook handler with type safety
|
|
||||||
typed.post('/v1/connect/github/webhook', {
|
|
||||||
schema: {
|
|
||||||
headers: z.object({
|
|
||||||
'x-hub-signature-256': z.string(),
|
|
||||||
'x-github-event': z.string(),
|
|
||||||
'x-github-delivery': z.string().optional()
|
|
||||||
}).passthrough(),
|
|
||||||
body: z.any(),
|
|
||||||
response: {
|
|
||||||
200: z.object({ received: z.boolean() }),
|
|
||||||
401: z.object({ error: z.string() }),
|
|
||||||
500: z.object({ error: z.string() })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const signature = request.headers['x-hub-signature-256'];
|
|
||||||
const eventName = request.headers['x-github-event'];
|
|
||||||
const deliveryId = request.headers['x-github-delivery'];
|
|
||||||
const rawBody = (request as any).rawBody;
|
|
||||||
|
|
||||||
if (!rawBody) {
|
|
||||||
log({ module: 'github-webhook', level: 'error' },
|
|
||||||
'Raw body not available for webhook signature verification');
|
|
||||||
return reply.code(500).send({ error: 'Server configuration error' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the webhooks handler
|
|
||||||
const { getWebhooks } = await import("@/modules/github");
|
|
||||||
const webhooks = getWebhooks();
|
|
||||||
if (!webhooks) {
|
|
||||||
log({ module: 'github-webhook', level: 'error' },
|
|
||||||
'GitHub webhooks not initialized');
|
|
||||||
return reply.code(500).send({ error: 'Webhooks not configured' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Verify and handle the webhook with type safety
|
|
||||||
await webhooks.verifyAndReceive({
|
|
||||||
id: deliveryId || 'unknown',
|
|
||||||
name: eventName,
|
|
||||||
payload: typeof rawBody === 'string' ? rawBody : JSON.stringify(request.body),
|
|
||||||
signature: signature
|
|
||||||
});
|
|
||||||
|
|
||||||
// Log successful processing
|
|
||||||
log({
|
|
||||||
module: 'github-webhook',
|
|
||||||
event: eventName,
|
|
||||||
delivery: deliveryId
|
|
||||||
}, `Successfully processed ${eventName} webhook`);
|
|
||||||
|
|
||||||
return reply.send({ received: true });
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.message?.includes('signature does not match')) {
|
|
||||||
log({
|
|
||||||
module: 'github-webhook',
|
|
||||||
level: 'warn',
|
|
||||||
event: eventName,
|
|
||||||
delivery: deliveryId
|
|
||||||
}, 'Invalid webhook signature');
|
|
||||||
return reply.code(401).send({ error: 'Invalid signature' });
|
|
||||||
}
|
|
||||||
|
|
||||||
log({
|
|
||||||
module: 'github-webhook',
|
|
||||||
level: 'error',
|
|
||||||
event: eventName,
|
|
||||||
delivery: deliveryId
|
|
||||||
}, `Error processing webhook: ${error.message}`);
|
|
||||||
|
|
||||||
return reply.code(500).send({ error: 'Internal server error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GitHub disconnect endpoint
|
|
||||||
typed.delete('/v1/connect/github', {
|
|
||||||
preHandler: app.authenticate,
|
|
||||||
schema: {
|
|
||||||
response: {
|
|
||||||
200: z.object({
|
|
||||||
success: z.literal(true)
|
|
||||||
}),
|
|
||||||
404: z.object({
|
|
||||||
error: z.string()
|
|
||||||
}),
|
|
||||||
500: z.object({
|
|
||||||
error: z.string()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const userId = request.userId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get current user's GitHub connection
|
|
||||||
const user = await db.account.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { githubUserId: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.githubUserId) {
|
|
||||||
return reply.code(404).send({ error: 'GitHub account not connected' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const githubUserId = user.githubUserId;
|
|
||||||
log({ module: 'github-disconnect' }, `Disconnecting GitHub account for user ${userId}: ${githubUserId}`);
|
|
||||||
|
|
||||||
// Remove GitHub connection from account and delete GitHub user record
|
|
||||||
await db.$transaction(async (tx) => {
|
|
||||||
// Remove link from account and clear avatar
|
|
||||||
await tx.account.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
githubUserId: null,
|
|
||||||
avatar: Prisma.JsonNull
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete GitHub user record (this also deletes the token)
|
|
||||||
await tx.githubUser.delete({
|
|
||||||
where: { id: githubUserId }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send account update to all user connections
|
|
||||||
const updSeq = await allocateUserSeq(userId);
|
|
||||||
const updatePayload = buildUpdateAccountUpdate(userId, {
|
|
||||||
github: null,
|
|
||||||
avatar: null
|
|
||||||
}, updSeq, randomKeyNaked(12));
|
|
||||||
eventRouter.emitUpdate({
|
|
||||||
userId,
|
|
||||||
payload: updatePayload,
|
|
||||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
|
||||||
});
|
|
||||||
|
|
||||||
log({ module: 'github-disconnect' }, `GitHub account and avatar disconnected successfully for user ${userId}`);
|
|
||||||
|
|
||||||
return reply.send({ success: true });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
log({ module: 'github-disconnect', level: 'error' }, `Error disconnecting GitHub account: ${error}`);
|
|
||||||
return reply.code(500).send({ error: 'Failed to disconnect GitHub account' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
typed.get('/v1/account/profile', {
|
|
||||||
preHandler: app.authenticate,
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const userId = request.userId;
|
|
||||||
const user = await db.account.findUniqueOrThrow({
|
|
||||||
where: { id: userId },
|
|
||||||
select: {
|
|
||||||
firstName: true,
|
|
||||||
lastName: true,
|
|
||||||
avatar: true,
|
|
||||||
githubUser: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return reply.send({
|
|
||||||
id: userId,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
firstName: user.firstName,
|
|
||||||
lastName: user.lastName,
|
|
||||||
avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null,
|
|
||||||
github: user.githubUser ? user.githubUser.profile : null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get Account Settings API
|
|
||||||
typed.get('/v1/account/settings', {
|
|
||||||
preHandler: app.authenticate,
|
|
||||||
schema: {
|
|
||||||
response: {
|
|
||||||
200: z.object({
|
|
||||||
settings: z.string().nullable(),
|
|
||||||
settingsVersion: z.number()
|
|
||||||
}),
|
|
||||||
500: z.object({
|
|
||||||
error: z.literal('Failed to get account settings')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const user = await db.account.findUnique({
|
|
||||||
where: { id: request.userId },
|
|
||||||
select: { settings: true, settingsVersion: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return reply.code(500).send({ error: 'Failed to get account settings' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
settings: user.settings,
|
|
||||||
settingsVersion: user.settingsVersion
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
return reply.code(500).send({ error: 'Failed to get account settings' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Account Settings API
|
|
||||||
typed.post('/v1/account/settings', {
|
|
||||||
schema: {
|
|
||||||
body: z.object({
|
|
||||||
settings: z.string().nullable(),
|
|
||||||
expectedVersion: z.number().int().min(0)
|
|
||||||
}),
|
|
||||||
response: {
|
|
||||||
200: z.union([z.object({
|
|
||||||
success: z.literal(true),
|
|
||||||
version: z.number()
|
|
||||||
}), z.object({
|
|
||||||
success: z.literal(false),
|
|
||||||
error: z.literal('version-mismatch'),
|
|
||||||
currentVersion: z.number(),
|
|
||||||
currentSettings: z.string().nullable()
|
|
||||||
})]),
|
|
||||||
500: z.object({
|
|
||||||
success: z.literal(false),
|
|
||||||
error: z.literal('Failed to update account settings')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
preHandler: app.authenticate
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const userId = request.userId;
|
|
||||||
const { settings, expectedVersion } = request.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get current user data for version check
|
|
||||||
const currentUser = await db.account.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
select: { settings: true, settingsVersion: true }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!currentUser) {
|
|
||||||
return reply.code(500).send({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to update account settings'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check current version
|
|
||||||
if (currentUser.settingsVersion !== expectedVersion) {
|
|
||||||
return reply.code(200).send({
|
|
||||||
success: false,
|
|
||||||
error: 'version-mismatch',
|
|
||||||
currentVersion: currentUser.settingsVersion,
|
|
||||||
currentSettings: currentUser.settings
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update settings with version check
|
|
||||||
const { count } = await db.account.updateMany({
|
|
||||||
where: {
|
|
||||||
id: userId,
|
|
||||||
settingsVersion: expectedVersion
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
settings: settings,
|
|
||||||
settingsVersion: expectedVersion + 1,
|
|
||||||
updatedAt: new Date()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
// Re-fetch to get current version
|
|
||||||
const account = await db.account.findUnique({
|
|
||||||
where: { id: userId }
|
|
||||||
});
|
|
||||||
return reply.code(200).send({
|
|
||||||
success: false,
|
|
||||||
error: 'version-mismatch',
|
|
||||||
currentVersion: account?.settingsVersion || 0,
|
|
||||||
currentSettings: account?.settings || null
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate update for connected clients
|
|
||||||
const updSeq = await allocateUserSeq(userId);
|
|
||||||
const settingsUpdate = {
|
|
||||||
value: settings,
|
|
||||||
version: expectedVersion + 1
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send account update to all user connections
|
|
||||||
const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12));
|
|
||||||
eventRouter.emitUpdate({
|
|
||||||
userId,
|
|
||||||
payload: updatePayload,
|
|
||||||
recipientFilter: { type: 'all-user-authenticated-connections' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
success: true,
|
|
||||||
version: expectedVersion + 1
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log({ module: 'api', level: 'error' }, `Failed to update account settings: ${error}`);
|
|
||||||
return reply.code(500).send({
|
|
||||||
success: false,
|
|
||||||
error: 'Failed to update account settings'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Query Usage Reports API
|
|
||||||
typed.post('/v1/usage/query', {
|
|
||||||
schema: {
|
|
||||||
body: z.object({
|
|
||||||
sessionId: z.string().nullish(),
|
|
||||||
startTime: z.number().int().positive().nullish(),
|
|
||||||
endTime: z.number().int().positive().nullish(),
|
|
||||||
groupBy: z.enum(['hour', 'day']).nullish()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
preHandler: app.authenticate
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const userId = request.userId;
|
|
||||||
const { sessionId, startTime, endTime, groupBy } = request.body;
|
|
||||||
const actualGroupBy = groupBy || 'day';
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Build query conditions
|
|
||||||
const where: {
|
|
||||||
accountId: string;
|
|
||||||
sessionId?: string | null;
|
|
||||||
createdAt?: {
|
|
||||||
gte?: Date;
|
|
||||||
lte?: Date;
|
|
||||||
};
|
|
||||||
} = {
|
|
||||||
accountId: userId
|
|
||||||
};
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
// Verify session belongs to user
|
|
||||||
const session = await db.session.findFirst({
|
|
||||||
where: {
|
|
||||||
id: sessionId,
|
|
||||||
accountId: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (!session) {
|
|
||||||
return reply.code(404).send({ error: 'Session not found' });
|
|
||||||
}
|
|
||||||
where.sessionId = sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (startTime || endTime) {
|
|
||||||
where.createdAt = {};
|
|
||||||
if (startTime) {
|
|
||||||
where.createdAt.gte = new Date(startTime * 1000);
|
|
||||||
}
|
|
||||||
if (endTime) {
|
|
||||||
where.createdAt.lte = new Date(endTime * 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch usage reports
|
|
||||||
const reports = await db.usageReport.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Aggregate data by time period
|
|
||||||
const aggregated = new Map<string, {
|
|
||||||
tokens: Record<string, number>;
|
|
||||||
cost: Record<string, number>;
|
|
||||||
count: number;
|
|
||||||
timestamp: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
for (const report of reports) {
|
|
||||||
const data = report.data as PrismaJson.UsageReportData;
|
|
||||||
const date = new Date(report.createdAt);
|
|
||||||
|
|
||||||
// Calculate timestamp based on groupBy
|
|
||||||
let timestamp: number;
|
|
||||||
if (actualGroupBy === 'hour') {
|
|
||||||
// Round down to hour
|
|
||||||
const hourDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0, 0);
|
|
||||||
timestamp = Math.floor(hourDate.getTime() / 1000);
|
|
||||||
} else {
|
|
||||||
// Round down to day
|
|
||||||
const dayDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
|
||||||
timestamp = Math.floor(dayDate.getTime() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = timestamp.toString();
|
|
||||||
|
|
||||||
if (!aggregated.has(key)) {
|
|
||||||
aggregated.set(key, {
|
|
||||||
tokens: {},
|
|
||||||
cost: {},
|
|
||||||
count: 0,
|
|
||||||
timestamp
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const agg = aggregated.get(key)!;
|
|
||||||
agg.count++;
|
|
||||||
|
|
||||||
// Aggregate tokens
|
|
||||||
for (const [tokenKey, tokenValue] of Object.entries(data.tokens)) {
|
|
||||||
if (typeof tokenValue === 'number') {
|
|
||||||
agg.tokens[tokenKey] = (agg.tokens[tokenKey] || 0) + tokenValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate costs
|
|
||||||
for (const [costKey, costValue] of Object.entries(data.cost)) {
|
|
||||||
if (typeof costValue === 'number') {
|
|
||||||
agg.cost[costKey] = (agg.cost[costKey] || 0) + costValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to array and sort by timestamp
|
|
||||||
const result = Array.from(aggregated.values())
|
|
||||||
.map(data => ({
|
|
||||||
timestamp: data.timestamp,
|
|
||||||
tokens: data.tokens,
|
|
||||||
cost: data.cost,
|
|
||||||
reportCount: data.count
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
usage: result,
|
|
||||||
groupBy: actualGroupBy,
|
|
||||||
totalReports: reports.length
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log({ module: 'api', level: 'error' }, `Failed to query usage reports: ${error}`);
|
|
||||||
return reply.code(500).send({ error: 'Failed to query usage reports' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Messages API
|
|
||||||
typed.get('/v1/sessions/:sessionId/messages', {
|
|
||||||
schema: {
|
|
||||||
params: z.object({
|
|
||||||
sessionId: z.string()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
preHandler: app.authenticate
|
|
||||||
}, async (request, reply) => {
|
|
||||||
const userId = request.userId;
|
|
||||||
const { sessionId } = request.params;
|
|
||||||
|
|
||||||
// Verify session belongs to user
|
|
||||||
const session = await db.session.findFirst({
|
|
||||||
where: {
|
|
||||||
id: sessionId,
|
|
||||||
accountId: userId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return reply.code(404).send({ error: 'Session not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = await db.sessionMessage.findMany({
|
|
||||||
where: { sessionId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 150,
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
seq: true,
|
|
||||||
localId: true,
|
|
||||||
content: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
messages: messages.map((v) => ({
|
|
||||||
id: v.id,
|
|
||||||
seq: v.seq,
|
|
||||||
content: v.content,
|
|
||||||
localId: v.localId,
|
|
||||||
createdAt: v.createdAt.getTime(),
|
|
||||||
updatedAt: v.updatedAt.getTime()
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Machines
|
// Machines
|
||||||
|
|
||||||
@ -1107,12 +449,6 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all route for debugging 404s
|
|
||||||
app.setNotFoundHandler((request, reply) => {
|
|
||||||
log({ module: '404-handler' }, `404 - Method: ${request.method}, Path: ${request.url}, Headers: ${JSON.stringify(request.headers)}`);
|
|
||||||
reply.code(404).send({ error: 'Not found', path: request.url, method: request.method });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start
|
// Start
|
||||||
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
|
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
|
||||||
await app.listen({ port, host: '0.0.0.0' });
|
await app.listen({ port, host: '0.0.0.0' });
|
||||||
|
307
sources/app/api/routes/accountRoutes.ts
Normal file
307
sources/app/api/routes/accountRoutes.ts
Normal file
@ -0,0 +1,307 @@
|
|||||||
|
import { EventRouter, buildUpdateAccountUpdate } from "@/modules/eventRouter";
|
||||||
|
import { db } from "@/storage/db";
|
||||||
|
import { Fastify } from "../types";
|
||||||
|
import { getPublicUrl } from "@/storage/files";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||||
|
import { allocateUserSeq } from "@/storage/seq";
|
||||||
|
import { log } from "@/utils/log";
|
||||||
|
|
||||||
|
export function registerAccountRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||||
|
app.get('/v1/account/profile', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const user = await db.account.findUniqueOrThrow({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
avatar: true,
|
||||||
|
githubUser: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return reply.send({
|
||||||
|
id: userId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
avatar: user.avatar ? { ...user.avatar, url: getPublicUrl(user.avatar.path) } : null,
|
||||||
|
github: user.githubUser ? user.githubUser.profile : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Account Settings API
|
||||||
|
app.get('/v1/account/settings', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
settings: z.string().nullable(),
|
||||||
|
settingsVersion: z.number()
|
||||||
|
}),
|
||||||
|
500: z.object({
|
||||||
|
error: z.literal('Failed to get account settings')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const user = await db.account.findUnique({
|
||||||
|
where: { id: request.userId },
|
||||||
|
select: { settings: true, settingsVersion: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to get account settings' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
settings: user.settings,
|
||||||
|
settingsVersion: user.settingsVersion
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return reply.code(500).send({ error: 'Failed to get account settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update Account Settings API
|
||||||
|
app.post('/v1/account/settings', {
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
settings: z.string().nullable(),
|
||||||
|
expectedVersion: z.number().int().min(0)
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.union([z.object({
|
||||||
|
success: z.literal(true),
|
||||||
|
version: z.number()
|
||||||
|
}), z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
error: z.literal('version-mismatch'),
|
||||||
|
currentVersion: z.number(),
|
||||||
|
currentSettings: z.string().nullable()
|
||||||
|
})]),
|
||||||
|
500: z.object({
|
||||||
|
success: z.literal(false),
|
||||||
|
error: z.literal('Failed to update account settings')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { settings, expectedVersion } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current user data for version check
|
||||||
|
const currentUser = await db.account.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { settings: true, settingsVersion: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update account settings'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current version
|
||||||
|
if (currentUser.settingsVersion !== expectedVersion) {
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: false,
|
||||||
|
error: 'version-mismatch',
|
||||||
|
currentVersion: currentUser.settingsVersion,
|
||||||
|
currentSettings: currentUser.settings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update settings with version check
|
||||||
|
const { count } = await db.account.updateMany({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
settingsVersion: expectedVersion
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
settings: settings,
|
||||||
|
settingsVersion: expectedVersion + 1,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
// Re-fetch to get current version
|
||||||
|
const account = await db.account.findUnique({
|
||||||
|
where: { id: userId }
|
||||||
|
});
|
||||||
|
return reply.code(200).send({
|
||||||
|
success: false,
|
||||||
|
error: 'version-mismatch',
|
||||||
|
currentVersion: account?.settingsVersion || 0,
|
||||||
|
currentSettings: account?.settings || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate update for connected clients
|
||||||
|
const updSeq = await allocateUserSeq(userId);
|
||||||
|
const settingsUpdate = {
|
||||||
|
value: settings,
|
||||||
|
version: expectedVersion + 1
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send account update to all user connections
|
||||||
|
const updatePayload = buildUpdateAccountUpdate(userId, { settings: settingsUpdate }, updSeq, randomKeyNaked(12));
|
||||||
|
eventRouter.emitUpdate({
|
||||||
|
userId,
|
||||||
|
payload: updatePayload,
|
||||||
|
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
success: true,
|
||||||
|
version: expectedVersion + 1
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'api', level: 'error' }, `Failed to update account settings: ${error}`);
|
||||||
|
return reply.code(500).send({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to update account settings'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/v1/usage/query', {
|
||||||
|
schema: {
|
||||||
|
body: z.object({
|
||||||
|
sessionId: z.string().nullish(),
|
||||||
|
startTime: z.number().int().positive().nullish(),
|
||||||
|
endTime: z.number().int().positive().nullish(),
|
||||||
|
groupBy: z.enum(['hour', 'day']).nullish()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { sessionId, startTime, endTime, groupBy } = request.body;
|
||||||
|
const actualGroupBy = groupBy || 'day';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build query conditions
|
||||||
|
const where: {
|
||||||
|
accountId: string;
|
||||||
|
sessionId?: string | null;
|
||||||
|
createdAt?: {
|
||||||
|
gte?: Date;
|
||||||
|
lte?: Date;
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
accountId: userId
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sessionId) {
|
||||||
|
// Verify session belongs to user
|
||||||
|
const session = await db.session.findFirst({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
accountId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(404).send({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
where.sessionId = sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime || endTime) {
|
||||||
|
where.createdAt = {};
|
||||||
|
if (startTime) {
|
||||||
|
where.createdAt.gte = new Date(startTime * 1000);
|
||||||
|
}
|
||||||
|
if (endTime) {
|
||||||
|
where.createdAt.lte = new Date(endTime * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch usage reports
|
||||||
|
const reports = await db.usageReport.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggregate data by time period
|
||||||
|
const aggregated = new Map<string, {
|
||||||
|
tokens: Record<string, number>;
|
||||||
|
cost: Record<string, number>;
|
||||||
|
count: number;
|
||||||
|
timestamp: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
for (const report of reports) {
|
||||||
|
const data = report.data as PrismaJson.UsageReportData;
|
||||||
|
const date = new Date(report.createdAt);
|
||||||
|
|
||||||
|
// Calculate timestamp based on groupBy
|
||||||
|
let timestamp: number;
|
||||||
|
if (actualGroupBy === 'hour') {
|
||||||
|
// Round down to hour
|
||||||
|
const hourDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), 0, 0, 0);
|
||||||
|
timestamp = Math.floor(hourDate.getTime() / 1000);
|
||||||
|
} else {
|
||||||
|
// Round down to day
|
||||||
|
const dayDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0, 0);
|
||||||
|
timestamp = Math.floor(dayDate.getTime() / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = timestamp.toString();
|
||||||
|
|
||||||
|
if (!aggregated.has(key)) {
|
||||||
|
aggregated.set(key, {
|
||||||
|
tokens: {},
|
||||||
|
cost: {},
|
||||||
|
count: 0,
|
||||||
|
timestamp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const agg = aggregated.get(key)!;
|
||||||
|
agg.count++;
|
||||||
|
|
||||||
|
// Aggregate tokens
|
||||||
|
for (const [tokenKey, tokenValue] of Object.entries(data.tokens)) {
|
||||||
|
if (typeof tokenValue === 'number') {
|
||||||
|
agg.tokens[tokenKey] = (agg.tokens[tokenKey] || 0) + tokenValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate costs
|
||||||
|
for (const [costKey, costValue] of Object.entries(data.cost)) {
|
||||||
|
if (typeof costValue === 'number') {
|
||||||
|
agg.cost[costKey] = (agg.cost[costKey] || 0) + costValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to array and sort by timestamp
|
||||||
|
const result = Array.from(aggregated.values())
|
||||||
|
.map(data => ({
|
||||||
|
timestamp: data.timestamp,
|
||||||
|
tokens: data.tokens,
|
||||||
|
cost: data.cost,
|
||||||
|
reportCount: data.count
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
usage: result,
|
||||||
|
groupBy: actualGroupBy,
|
||||||
|
totalReports: reports.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'api', level: 'error' }, `Failed to query usage reports: ${error}`);
|
||||||
|
return reply.code(500).send({ error: 'Failed to query usage reports' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
329
sources/app/api/routes/connectRoutes.ts
Normal file
329
sources/app/api/routes/connectRoutes.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { type Fastify } from "../types";
|
||||||
|
import { auth } from "@/app/auth/auth";
|
||||||
|
import { log } from "@/utils/log";
|
||||||
|
import { db } from "@/storage/db";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { allocateUserSeq } from "@/storage/seq";
|
||||||
|
import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
||||||
|
import { buildUpdateAccountUpdate } from "@/modules/eventRouter";
|
||||||
|
import { GitHubProfile } from "../types";
|
||||||
|
import { separateName } from "@/utils/separateName";
|
||||||
|
import { uploadImage } from "@/storage/uploadImage";
|
||||||
|
import { EventRouter } from "@/modules/eventRouter";
|
||||||
|
import { encryptString } from "@/modules/encrypt";
|
||||||
|
|
||||||
|
export function registerConnectRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||||
|
|
||||||
|
// GitHub OAuth parameters
|
||||||
|
app.get('/v1/connect/github/params', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
url: z.string()
|
||||||
|
}),
|
||||||
|
400: z.object({
|
||||||
|
error: z.string()
|
||||||
|
}),
|
||||||
|
500: z.object({
|
||||||
|
error: z.string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||||
|
const redirectUri = process.env.GITHUB_REDIRECT_URL;
|
||||||
|
|
||||||
|
if (!clientId || !redirectUri) {
|
||||||
|
return reply.code(400).send({ error: 'GitHub OAuth not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate ephemeral state token (5 minutes TTL)
|
||||||
|
const state = await auth.createGithubToken(request.userId);
|
||||||
|
|
||||||
|
// Build complete OAuth URL
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
scope: 'read:user,user:email,read:org,codespace',
|
||||||
|
state: state
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `https://github.com/login/oauth/authorize?${params.toString()}`;
|
||||||
|
|
||||||
|
return reply.send({ url });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GitHub OAuth callback (GET for redirect from GitHub)
|
||||||
|
app.get('/v1/connect/github/callback', {
|
||||||
|
schema: {
|
||||||
|
querystring: z.object({
|
||||||
|
code: z.string(),
|
||||||
|
state: z.string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { code, state } = request.query;
|
||||||
|
|
||||||
|
// Verify the state token to get userId
|
||||||
|
const tokenData = await auth.verifyGithubToken(state);
|
||||||
|
if (!tokenData) {
|
||||||
|
log({ module: 'github-oauth' }, `Invalid state token: ${state}`);
|
||||||
|
return reply.redirect('https://app.happy.engineering?error=invalid_state');
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = tokenData.userId;
|
||||||
|
const clientId = process.env.GITHUB_CLIENT_ID;
|
||||||
|
const clientSecret = process.env.GITHUB_CLIENT_SECRET;
|
||||||
|
|
||||||
|
if (!clientId || !clientSecret) {
|
||||||
|
return reply.redirect('https://app.happy.engineering?error=server_config');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Exchange code for access token
|
||||||
|
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret,
|
||||||
|
code: code
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenResponseData = await tokenResponse.json() as {
|
||||||
|
access_token?: string;
|
||||||
|
error?: string;
|
||||||
|
error_description?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tokenResponseData.error) {
|
||||||
|
return reply.redirect(`https://app.happy.engineering?error=${encodeURIComponent(tokenResponseData.error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = tokenResponseData.access_token;
|
||||||
|
|
||||||
|
// Get user info from GitHub
|
||||||
|
const userResponse = await fetch('https://api.github.com/user', {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
'Accept': 'application/vnd.github.v3+json',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const userData = await userResponse.json() as GitHubProfile;
|
||||||
|
|
||||||
|
if (!userResponse.ok) {
|
||||||
|
return reply.redirect('https://app.happy.engineering?error=github_user_fetch_failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store GitHub user and connect to account
|
||||||
|
const githubUser = await db.githubUser.upsert({
|
||||||
|
where: { id: userData.id.toString() },
|
||||||
|
update: {
|
||||||
|
profile: userData,
|
||||||
|
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
id: userData.id.toString(),
|
||||||
|
profile: userData,
|
||||||
|
token: encryptString(['user', userId, 'github', 'token'], accessToken!)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Avatar
|
||||||
|
log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`);
|
||||||
|
const image = await fetch(userData.avatar_url);
|
||||||
|
const imageBuffer = await image.arrayBuffer();
|
||||||
|
log({ module: 'github-oauth' }, `Uploading avatar for user ${userId}: ${userData.avatar_url}`);
|
||||||
|
const avatar = await uploadImage(userId, 'avatars', 'github', userData.avatar_url, Buffer.from(imageBuffer));
|
||||||
|
log({ module: 'github-oauth' }, `Uploaded avatar for user ${userId}: ${userData.avatar_url}`);
|
||||||
|
|
||||||
|
// Name
|
||||||
|
const name = separateName(userData.name);
|
||||||
|
log({ module: 'github-oauth' }, `Separated name for user ${userId}: ${userData.name} -> ${name.firstName} ${name.lastName}`);
|
||||||
|
|
||||||
|
// Link GitHub user to account
|
||||||
|
await db.account.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { githubUserId: githubUser.id, avatar, firstName: name.firstName, lastName: name.lastName }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send account update to all user connections
|
||||||
|
const updSeq = await allocateUserSeq(userId);
|
||||||
|
const updatePayload = buildUpdateAccountUpdate(userId, {
|
||||||
|
github: userData,
|
||||||
|
firstName: name.firstName,
|
||||||
|
lastName: name.lastName,
|
||||||
|
avatar: avatar
|
||||||
|
}, updSeq, randomKeyNaked(12));
|
||||||
|
eventRouter.emitUpdate({
|
||||||
|
userId,
|
||||||
|
payload: updatePayload,
|
||||||
|
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||||
|
});
|
||||||
|
|
||||||
|
log({ module: 'github-oauth' }, `GitHub account connected successfully for user ${userId}: ${userData.login}`);
|
||||||
|
|
||||||
|
// Redirect to app with success
|
||||||
|
return reply.redirect(`https://app.happy.engineering?github=connected&user=${encodeURIComponent(userData.login)}`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'github-oauth' }, `Error in GitHub GET callback: ${error}`);
|
||||||
|
return reply.redirect('https://app.happy.engineering?error=server_error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GitHub webhook handler with type safety
|
||||||
|
app.post('/v1/connect/github/webhook', {
|
||||||
|
schema: {
|
||||||
|
headers: z.object({
|
||||||
|
'x-hub-signature-256': z.string(),
|
||||||
|
'x-github-event': z.string(),
|
||||||
|
'x-github-delivery': z.string().optional()
|
||||||
|
}).passthrough(),
|
||||||
|
body: z.any(),
|
||||||
|
response: {
|
||||||
|
200: z.object({ received: z.boolean() }),
|
||||||
|
401: z.object({ error: z.string() }),
|
||||||
|
500: z.object({ error: z.string() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const signature = request.headers['x-hub-signature-256'];
|
||||||
|
const eventName = request.headers['x-github-event'];
|
||||||
|
const deliveryId = request.headers['x-github-delivery'];
|
||||||
|
const rawBody = (request as any).rawBody;
|
||||||
|
|
||||||
|
if (!rawBody) {
|
||||||
|
log({ module: 'github-webhook', level: 'error' },
|
||||||
|
'Raw body not available for webhook signature verification');
|
||||||
|
return reply.code(500).send({ error: 'Server configuration error' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the webhooks handler
|
||||||
|
const { getWebhooks } = await import("@/modules/github");
|
||||||
|
const webhooks = getWebhooks();
|
||||||
|
if (!webhooks) {
|
||||||
|
log({ module: 'github-webhook', level: 'error' },
|
||||||
|
'GitHub webhooks not initialized');
|
||||||
|
return reply.code(500).send({ error: 'Webhooks not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify and handle the webhook with type safety
|
||||||
|
await webhooks.verifyAndReceive({
|
||||||
|
id: deliveryId || 'unknown',
|
||||||
|
name: eventName,
|
||||||
|
payload: typeof rawBody === 'string' ? rawBody : JSON.stringify(request.body),
|
||||||
|
signature: signature
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log successful processing
|
||||||
|
log({
|
||||||
|
module: 'github-webhook',
|
||||||
|
event: eventName,
|
||||||
|
delivery: deliveryId
|
||||||
|
}, `Successfully processed ${eventName} webhook`);
|
||||||
|
|
||||||
|
return reply.send({ received: true });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message?.includes('signature does not match')) {
|
||||||
|
log({
|
||||||
|
module: 'github-webhook',
|
||||||
|
level: 'warn',
|
||||||
|
event: eventName,
|
||||||
|
delivery: deliveryId
|
||||||
|
}, 'Invalid webhook signature');
|
||||||
|
return reply.code(401).send({ error: 'Invalid signature' });
|
||||||
|
}
|
||||||
|
|
||||||
|
log({
|
||||||
|
module: 'github-webhook',
|
||||||
|
level: 'error',
|
||||||
|
event: eventName,
|
||||||
|
delivery: deliveryId
|
||||||
|
}, `Error processing webhook: ${error.message}`);
|
||||||
|
|
||||||
|
return reply.code(500).send({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GitHub disconnect endpoint
|
||||||
|
app.delete('/v1/connect/github', {
|
||||||
|
preHandler: app.authenticate,
|
||||||
|
schema: {
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
success: z.literal(true)
|
||||||
|
}),
|
||||||
|
404: z.object({
|
||||||
|
error: z.string()
|
||||||
|
}),
|
||||||
|
500: z.object({
|
||||||
|
error: z.string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get current user's GitHub connection
|
||||||
|
const user = await db.account.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { githubUserId: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.githubUserId) {
|
||||||
|
return reply.code(404).send({ error: 'GitHub account not connected' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubUserId = user.githubUserId;
|
||||||
|
log({ module: 'github-disconnect' }, `Disconnecting GitHub account for user ${userId}: ${githubUserId}`);
|
||||||
|
|
||||||
|
// Remove GitHub connection from account and delete GitHub user record
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
// Remove link from account and clear avatar
|
||||||
|
await tx.account.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
githubUserId: null,
|
||||||
|
avatar: Prisma.JsonNull
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete GitHub user record (this also deletes the token)
|
||||||
|
await tx.githubUser.delete({
|
||||||
|
where: { id: githubUserId }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send account update to all user connections
|
||||||
|
const updSeq = await allocateUserSeq(userId);
|
||||||
|
const updatePayload = buildUpdateAccountUpdate(userId, {
|
||||||
|
github: null,
|
||||||
|
avatar: null
|
||||||
|
}, updSeq, randomKeyNaked(12));
|
||||||
|
eventRouter.emitUpdate({
|
||||||
|
userId,
|
||||||
|
payload: updatePayload,
|
||||||
|
recipientFilter: { type: 'all-user-authenticated-connections' }
|
||||||
|
});
|
||||||
|
|
||||||
|
log({ module: 'github-disconnect' }, `GitHub account and avatar disconnected successfully for user ${userId}`);
|
||||||
|
|
||||||
|
return reply.send({ success: true });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log({ module: 'github-disconnect', level: 'error' }, `Error disconnecting GitHub account: ${error}`);
|
||||||
|
return reply.code(500).send({ error: 'Failed to disconnect GitHub account' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -8,7 +8,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked";
|
|||||||
import { allocateUserSeq } from "@/storage/seq";
|
import { allocateUserSeq } from "@/storage/seq";
|
||||||
|
|
||||||
export function registerSessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
export function registerSessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
||||||
|
|
||||||
// Sessions API
|
// Sessions API
|
||||||
app.get('/v1/sessions', {
|
app.get('/v1/sessions', {
|
||||||
preHandler: app.authenticate,
|
preHandler: app.authenticate,
|
||||||
@ -293,4 +293,53 @@ export function registerSessionRoutes(app: Fastify, eventRouter: EventRouter) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/v1/sessions/:sessionId/messages', {
|
||||||
|
schema: {
|
||||||
|
params: z.object({
|
||||||
|
sessionId: z.string()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
preHandler: app.authenticate
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const userId = request.userId;
|
||||||
|
const { sessionId } = request.params;
|
||||||
|
|
||||||
|
// Verify session belongs to user
|
||||||
|
const session = await db.session.findFirst({
|
||||||
|
where: {
|
||||||
|
id: sessionId,
|
||||||
|
accountId: userId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return reply.code(404).send({ error: 'Session not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await db.sessionMessage.findMany({
|
||||||
|
where: { sessionId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 150,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
seq: true,
|
||||||
|
localId: true,
|
||||||
|
content: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
messages: messages.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
seq: v.seq,
|
||||||
|
content: v.content,
|
||||||
|
localId: v.localId,
|
||||||
|
createdAt: v.createdAt.getTime(),
|
||||||
|
updatedAt: v.updatedAt.getTime()
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user