diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index 24d6efa..589d409 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -3,9 +3,7 @@ import { log, logger } from "@/utils/log"; import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod"; import { Server, Socket } from "socket.io"; import { z } from "zod"; -import * as privacyKit from "privacy-kit"; import { db } from "@/storage/db"; -import { Account, Prisma } from "@prisma/client"; import { onShutdown } from "@/utils/shutdown"; import { allocateSessionSeq, allocateUserSeq } from "@/storage/seq"; import { randomKeyNaked } from "@/utils/randomKeyNaked"; @@ -14,10 +12,8 @@ import { auth } from "@/app/auth/auth"; import { EventRouter, ClientConnection, - buildNewSessionUpdate, buildNewMessageUpdate, buildUpdateSessionUpdate, - buildUpdateAccountUpdate, buildUpdateMachineUpdate, buildSessionActivityEphemeral, buildMachineActivityEphemeral, @@ -33,14 +29,12 @@ import { httpRequestDurationHistogram } from "@/app/monitoring/metrics2"; import { activityCache } from "@/app/presence/sessionCache"; -import { encryptBytes, encryptString } from "@/modules/encrypt"; -import { Fastify, GitHubProfile } from "./types"; -import { uploadImage } from "@/storage/uploadImage"; -import { separateName } from "@/utils/separateName"; -import { getPublicUrl } from "@/storage/files"; +import { Fastify } from "./types"; import { registerAuthRoutes } from "./routes/authRoutes"; import { registerPushRoutes } from "./routes/pushRoutes"; 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 }> { @@ -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 app.addHook('onError', async (request, reply, error) => { const method = request.method; @@ -245,666 +245,8 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify registerAuthRoutes(typed); registerPushRoutes(typed); registerSessionRoutes(typed, eventRouter); - - // GitHub OAuth parameters - 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; - cost: Record; - 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() - })) - }); - }); + registerAccountRoutes(typed, eventRouter); + registerConnectRoutes(typed, eventRouter); // 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 const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005; await app.listen({ port, host: '0.0.0.0' }); diff --git a/sources/app/api/routes/accountRoutes.ts b/sources/app/api/routes/accountRoutes.ts new file mode 100644 index 0000000..7488a53 --- /dev/null +++ b/sources/app/api/routes/accountRoutes.ts @@ -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; + cost: Record; + 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' }); + } + }); +} \ No newline at end of file diff --git a/sources/app/api/routes/connectRoutes.ts b/sources/app/api/routes/connectRoutes.ts new file mode 100644 index 0000000..0e8e09d --- /dev/null +++ b/sources/app/api/routes/connectRoutes.ts @@ -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' }); + } + }); +} \ No newline at end of file diff --git a/sources/app/api/routes/sessionRoutes.ts b/sources/app/api/routes/sessionRoutes.ts index 5ad5c58..21d3031 100644 --- a/sources/app/api/routes/sessionRoutes.ts +++ b/sources/app/api/routes/sessionRoutes.ts @@ -8,7 +8,7 @@ import { randomKeyNaked } from "@/utils/randomKeyNaked"; import { allocateUserSeq } from "@/storage/seq"; export function registerSessionRoutes(app: Fastify, eventRouter: EventRouter) { - + // Sessions API app.get('/v1/sessions', { 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() + })) + }); + }); } \ No newline at end of file