diff --git a/sources/app/api/api.ts b/sources/app/api/api.ts index b133965..24d6efa 100644 --- a/sources/app/api/api.ts +++ b/sources/app/api/api.ts @@ -34,22 +34,13 @@ import { } from "@/app/monitoring/metrics2"; import { activityCache } from "@/app/presence/sessionCache"; import { encryptBytes, encryptString } from "@/modules/encrypt"; -import { GitHubProfile } from "./types"; +import { Fastify, GitHubProfile } from "./types"; import { uploadImage } from "@/storage/uploadImage"; import { separateName } from "@/utils/separateName"; import { getPublicUrl } from "@/storage/files"; - - -declare module 'fastify' { - interface FastifyRequest { - userId: string; - startTime?: number; - } - interface FastifyInstance { - authenticate: any; - } -} - +import { registerAuthRoutes } from "./routes/authRoutes"; +import { registerPushRoutes } from "./routes/pushRoutes"; +import { registerSessionRoutes } from "./routes/sessionRoutes"; export async function startApi(eventRouter: EventRouter): Promise<{ app: FastifyInstance; io: Server }> { @@ -126,7 +117,6 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler); - const typed = app.withTypeProvider(); // Add metrics hooks app.addHook('onRequest', async (request, reply) => { @@ -249,127 +239,12 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify } }); - // Auth schema - typed.post('/v1/auth', { - schema: { - body: z.object({ - publicKey: z.string(), - challenge: z.string(), - signature: z.string() - }) - } - }, async (request, reply) => { - const tweetnacl = (await import("tweetnacl")).default; - const publicKey = privacyKit.decodeBase64(request.body.publicKey); - const challenge = privacyKit.decodeBase64(request.body.challenge); - const signature = privacyKit.decodeBase64(request.body.signature); - const isValid = tweetnacl.sign.detached.verify(challenge, signature, publicKey); - if (!isValid) { - return reply.code(401).send({ error: 'Invalid signature' }); - } + const typed = app.withTypeProvider() as unknown as Fastify; - // Create or update user in database - const publicKeyHex = privacyKit.encodeHex(publicKey); - const user = await db.account.upsert({ - where: { publicKey: publicKeyHex }, - update: { updatedAt: new Date() }, - create: { publicKey: publicKeyHex } - }); - - return reply.send({ - success: true, - token: await auth.createToken(user.id) - }); - }); - - typed.post('/v1/auth/request', { - schema: { - body: z.object({ - publicKey: z.string(), - }), - response: { - 200: z.union([z.object({ - state: z.literal('requested'), - }), z.object({ - state: z.literal('authorized'), - token: z.string(), - response: z.string() - })]), - 401: z.object({ - error: z.literal('Invalid public key') - }) - } - } - }, async (request, reply) => { - const tweetnacl = (await import("tweetnacl")).default; - const publicKey = privacyKit.decodeBase64(request.body.publicKey); - const isValid = tweetnacl.box.publicKeyLength === publicKey.length; - if (!isValid) { - return reply.code(401).send({ error: 'Invalid public key' }); - } - - const publicKeyHex = privacyKit.encodeHex(publicKey); - log({ module: 'auth-request' }, `Terminal auth request - publicKey hex: ${publicKeyHex}`); - - const answer = await db.terminalAuthRequest.upsert({ - where: { publicKey: publicKeyHex }, - update: {}, - create: { publicKey: publicKeyHex } - }); - - if (answer.response && answer.responseAccountId) { - const token = await auth.createToken(answer.responseAccountId!, { session: answer.id }); - return reply.send({ - state: 'authorized', - token: token, - response: answer.response - }); - } - - return reply.send({ state: 'requested' }); - }); - - // Approve auth request - typed.post('/v1/auth/response', { - preHandler: app.authenticate, - schema: { - body: z.object({ - response: z.string(), - publicKey: z.string() - }) - } - }, async (request, reply) => { - log({ module: 'auth-response' }, `Auth response endpoint hit - user: ${request.userId}, publicKey: ${request.body.publicKey.substring(0, 20)}...`); - const tweetnacl = (await import("tweetnacl")).default; - const publicKey = privacyKit.decodeBase64(request.body.publicKey); - const isValid = tweetnacl.box.publicKeyLength === publicKey.length; - if (!isValid) { - log({ module: 'auth-response' }, `Invalid public key length: ${publicKey.length}`); - return reply.code(401).send({ error: 'Invalid public key' }); - } - const publicKeyHex = privacyKit.encodeHex(publicKey); - log({ module: 'auth-response' }, `Looking for auth request with publicKey hex: ${publicKeyHex}`); - const authRequest = await db.terminalAuthRequest.findUnique({ - where: { publicKey: publicKeyHex } - }); - if (!authRequest) { - log({ module: 'auth-response' }, `Auth request not found for publicKey: ${publicKeyHex}`); - // Let's also check what auth requests exist - const allRequests = await db.terminalAuthRequest.findMany({ - take: 5, - orderBy: { createdAt: 'desc' } - }); - log({ module: 'auth-response' }, `Recent auth requests in DB: ${JSON.stringify(allRequests.map(r => ({ id: r.id, publicKey: r.publicKey.substring(0, 20) + '...', hasResponse: !!r.response })))}`); - return reply.code(404).send({ error: 'Request not found' }); - } - if (!authRequest.response) { - await db.terminalAuthRequest.update({ - where: { id: authRequest.id }, - data: { response: request.body.response, responseAccountId: request.userId } - }); - } - return reply.send({ success: true }); - }); + // Routes + registerAuthRoutes(typed); + registerPushRoutes(typed); + registerSessionRoutes(typed, eventRouter); // GitHub OAuth parameters typed.get('/v1/connect/github/params', { @@ -683,532 +558,6 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify } }); - // Account auth request - typed.post('/v1/auth/account/request', { - schema: { - body: z.object({ - publicKey: z.string(), - }), - response: { - 200: z.union([z.object({ - state: z.literal('requested'), - }), z.object({ - state: z.literal('authorized'), - token: z.string(), - response: z.string() - })]), - 401: z.object({ - error: z.literal('Invalid public key') - }) - } - } - }, async (request, reply) => { - const tweetnacl = (await import("tweetnacl")).default; - const publicKey = privacyKit.decodeBase64(request.body.publicKey); - const isValid = tweetnacl.box.publicKeyLength === publicKey.length; - if (!isValid) { - return reply.code(401).send({ error: 'Invalid public key' }); - } - - const answer = await db.accountAuthRequest.upsert({ - where: { publicKey: privacyKit.encodeHex(publicKey) }, - update: {}, - create: { publicKey: privacyKit.encodeHex(publicKey) } - }); - - if (answer.response && answer.responseAccountId) { - const token = await auth.createToken(answer.responseAccountId!); - return reply.send({ - state: 'authorized', - token: token, - response: answer.response - }); - } - - return reply.send({ state: 'requested' }); - }); - - // Approve account auth request - typed.post('/v1/auth/account/response', { - preHandler: app.authenticate, - schema: { - body: z.object({ - response: z.string(), - publicKey: z.string() - }) - } - }, async (request, reply) => { - const tweetnacl = (await import("tweetnacl")).default; - const publicKey = privacyKit.decodeBase64(request.body.publicKey); - const isValid = tweetnacl.box.publicKeyLength === publicKey.length; - if (!isValid) { - return reply.code(401).send({ error: 'Invalid public key' }); - } - const authRequest = await db.accountAuthRequest.findUnique({ - where: { publicKey: privacyKit.encodeHex(publicKey) } - }); - if (!authRequest) { - return reply.code(404).send({ error: 'Request not found' }); - } - if (!authRequest.response) { - await db.accountAuthRequest.update({ - where: { id: authRequest.id }, - data: { response: request.body.response, responseAccountId: request.userId } - }); - } - return reply.send({ success: true }); - }); - - // OpenAI Realtime ephemeral token generation - typed.post('/v1/openai/realtime-token', { - preHandler: app.authenticate, - schema: { - response: { - 200: z.object({ - token: z.string() - }), - 500: z.object({ - error: z.string() - }) - } - } - }, async (request, reply) => { - try { - // Check if OpenAI API key is configured on server - const OPENAI_API_KEY = process.env.OPENAI_API_KEY; - if (!OPENAI_API_KEY) { - return reply.code(500).send({ - error: 'OpenAI API key not configured on server' - }); - } - - // Generate ephemeral token from OpenAI - const response = await fetch('https://api.openai.com/v1/realtime/sessions', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${OPENAI_API_KEY}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: 'gpt-4o-realtime-preview-2024-12-17', - voice: 'verse', - }), - }); - - if (!response.ok) { - throw new Error(`OpenAI API error: ${response.status}`); - } - - const data = await response.json() as { - client_secret: { - value: string; - expires_at: number; - }; - id: string; - }; - - return reply.send({ - token: data.client_secret.value - }); - } catch (error) { - log({ module: 'openai', level: 'error' }, 'Failed to generate ephemeral token', error); - return reply.code(500).send({ - error: 'Failed to generate ephemeral token' - }); - } - }); - - // Sessions API - typed.get('/v1/sessions', { - preHandler: app.authenticate, - }, async (request, reply) => { - const userId = request.userId; - - const sessions = await db.session.findMany({ - where: { accountId: userId }, - orderBy: { updatedAt: 'desc' }, - take: 150, - select: { - id: true, - seq: true, - createdAt: true, - updatedAt: true, - metadata: true, - metadataVersion: true, - agentState: true, - agentStateVersion: true, - active: true, - lastActiveAt: true, - // messages: { - // orderBy: { seq: 'desc' }, - // take: 1, - // select: { - // id: true, - // seq: true, - // content: true, - // localId: true, - // createdAt: true - // } - // } - } - }); - - return reply.send({ - sessions: sessions.map((v) => { - // const lastMessage = v.messages[0]; - const sessionUpdatedAt = v.updatedAt.getTime(); - // const lastMessageCreatedAt = lastMessage ? lastMessage.createdAt.getTime() : 0; - - return { - id: v.id, - seq: v.seq, - createdAt: v.createdAt.getTime(), - updatedAt: sessionUpdatedAt, - active: v.active, - activeAt: v.lastActiveAt.getTime(), - metadata: v.metadata, - metadataVersion: v.metadataVersion, - agentState: v.agentState, - agentStateVersion: v.agentStateVersion, - lastMessage: null - }; - }) - }); - }); - - // V2 Sessions API - Active sessions only - typed.get('/v2/sessions/active', { - preHandler: app.authenticate, - schema: { - querystring: z.object({ - limit: z.coerce.number().int().min(1).max(500).default(150) - }).optional() - } - }, async (request, reply) => { - const userId = request.userId; - const limit = request.query?.limit || 150; - - const sessions = await db.session.findMany({ - where: { - accountId: userId, - active: true, - lastActiveAt: { gt: new Date(Date.now() - 1000 * 60 * 15) /* 15 minutes */ } - }, - orderBy: { lastActiveAt: 'desc' }, - take: limit, - select: { - id: true, - seq: true, - createdAt: true, - updatedAt: true, - metadata: true, - metadataVersion: true, - agentState: true, - agentStateVersion: true, - active: true, - lastActiveAt: true, - } - }); - - return reply.send({ - sessions: sessions.map((v) => ({ - id: v.id, - seq: v.seq, - createdAt: v.createdAt.getTime(), - updatedAt: v.updatedAt.getTime(), - active: v.active, - activeAt: v.lastActiveAt.getTime(), - metadata: v.metadata, - metadataVersion: v.metadataVersion, - agentState: v.agentState, - agentStateVersion: v.agentStateVersion, - })) - }); - }); - - // V2 Sessions API - Cursor-based pagination with change tracking - typed.get('/v2/sessions', { - preHandler: app.authenticate, - schema: { - querystring: z.object({ - cursor: z.string().optional(), - limit: z.coerce.number().int().min(1).max(200).default(50), - changedSince: z.coerce.number().int().positive().optional() - }).optional() - } - }, async (request, reply) => { - const userId = request.userId; - const { cursor, limit = 50, changedSince } = request.query || {}; - - // Decode cursor - simple ID-based cursor - let cursorSessionId: string | undefined; - if (cursor) { - if (cursor.startsWith('cursor_v1_')) { - cursorSessionId = cursor.substring(10); - } else { - return reply.code(400).send({ error: 'Invalid cursor format' }); - } - } - - // Build where clause - const where: Prisma.SessionWhereInput = { accountId: userId }; - - // Add changedSince filter (just a filter, doesn't affect pagination) - if (changedSince) { - where.updatedAt = { - gt: new Date(changedSince) - }; - } - - // Add cursor pagination - always by ID descending (most recent first) - if (cursorSessionId) { - where.id = { - lt: cursorSessionId // Get sessions with ID less than cursor (for desc order) - }; - } - - // Always sort by ID descending for consistent pagination - const orderBy = { id: 'desc' as const }; - - const sessions = await db.session.findMany({ - where, - orderBy, - take: limit + 1, // Fetch one extra to determine if there are more - select: { - id: true, - seq: true, - createdAt: true, - updatedAt: true, - metadata: true, - metadataVersion: true, - agentState: true, - agentStateVersion: true, - active: true, - lastActiveAt: true, - } - }); - - // Check if there are more results - const hasNext = sessions.length > limit; - const resultSessions = hasNext ? sessions.slice(0, limit) : sessions; - - // Generate next cursor - simple ID-based cursor - let nextCursor: string | null = null; - if (hasNext && resultSessions.length > 0) { - const lastSession = resultSessions[resultSessions.length - 1]; - nextCursor = `cursor_v1_${lastSession.id}`; - } - - return reply.send({ - sessions: resultSessions.map((v) => ({ - id: v.id, - seq: v.seq, - createdAt: v.createdAt.getTime(), - updatedAt: v.updatedAt.getTime(), - active: v.active, - activeAt: v.lastActiveAt.getTime(), - metadata: v.metadata, - metadataVersion: v.metadataVersion, - agentState: v.agentState, - agentStateVersion: v.agentStateVersion, - })), - nextCursor, - hasNext - }); - }); - - // Create or load session by tag - typed.post('/v1/sessions', { - schema: { - body: z.object({ - tag: z.string(), - metadata: z.string(), - agentState: z.string().nullish() - }) - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - const { tag, metadata } = request.body; - - const session = await db.session.findFirst({ - where: { - accountId: userId, - tag: tag - } - }); - if (session) { - logger.info({ module: 'session-create', sessionId: session.id, userId, tag }, `Found existing session: ${session.id} for tag ${tag}`); - return reply.send({ - session: { - id: session.id, - seq: session.seq, - metadata: session.metadata, - metadataVersion: session.metadataVersion, - agentState: session.agentState, - agentStateVersion: session.agentStateVersion, - active: session.active, - activeAt: session.lastActiveAt.getTime(), - createdAt: session.createdAt.getTime(), - updatedAt: session.updatedAt.getTime(), - lastMessage: null - } - }); - } else { - - // Resolve seq - const updSeq = await allocateUserSeq(userId); - - // Create session - logger.info({ module: 'session-create', userId, tag }, `Creating new session for user ${userId} with tag ${tag}`); - const session = await db.session.create({ - data: { - accountId: userId, - tag: tag, - metadata: metadata - } - }); - logger.info({ module: 'session-create', sessionId: session.id, userId }, `Session created: ${session.id}`); - - // Emit new session update - const updatePayload = buildNewSessionUpdate(session, updSeq, randomKeyNaked(12)); - logger.info({ - module: 'session-create', - userId, - sessionId: session.id, - updateType: 'new-session', - updatePayload: JSON.stringify(updatePayload) - }, `Emitting new-session update to all user connections`); - eventRouter.emitUpdate({ - userId, - payload: updatePayload, - recipientFilter: { type: 'all-user-authenticated-connections' } - }); - - return reply.send({ - session: { - id: session.id, - seq: session.seq, - metadata: session.metadata, - metadataVersion: session.metadataVersion, - agentState: session.agentState, - agentStateVersion: session.agentStateVersion, - active: session.active, - activeAt: session.lastActiveAt.getTime(), - createdAt: session.createdAt.getTime(), - updatedAt: session.updatedAt.getTime(), - lastMessage: null - } - }); - } - }); - - // Push Token Registration API - typed.post('/v1/push-tokens', { - schema: { - body: z.object({ - token: z.string() - }), - response: { - 200: z.object({ - success: z.literal(true) - }), - 500: z.object({ - error: z.literal('Failed to register push token') - }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - const { token } = request.body; - - try { - await db.accountPushToken.upsert({ - where: { - accountId_token: { - accountId: userId, - token: token - } - }, - update: { - updatedAt: new Date() - }, - create: { - accountId: userId, - token: token - } - }); - - return reply.send({ success: true }); - } catch (error) { - return reply.code(500).send({ error: 'Failed to register push token' }); - } - }); - - // Delete Push Token API - typed.delete('/v1/push-tokens/:token', { - schema: { - params: z.object({ - token: z.string() - }), - response: { - 200: z.object({ - success: z.literal(true) - }), - 500: z.object({ - error: z.literal('Failed to delete push token') - }) - } - }, - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - const { token } = request.params; - - try { - await db.accountPushToken.deleteMany({ - where: { - accountId: userId, - token: token - } - }); - - return reply.send({ success: true }); - } catch (error) { - return reply.code(500).send({ error: 'Failed to delete push token' }); - } - }); - - // Get Push Tokens API - typed.get('/v1/push-tokens', { - preHandler: app.authenticate - }, async (request, reply) => { - const userId = request.userId; - - try { - const tokens = await db.accountPushToken.findMany({ - where: { - accountId: userId - }, - orderBy: { - createdAt: 'desc' - } - }); - - return reply.send({ - tokens: tokens.map(t => ({ - id: t.id, - token: t.token, - createdAt: t.createdAt.getTime(), - updatedAt: t.updatedAt.getTime() - })) - }); - } catch (error) { - return reply.code(500).send({ error: 'Failed to get push tokens' }); - } - }); - typed.get('/v1/account/profile', { preHandler: app.authenticate, }, async (request, reply) => { diff --git a/sources/app/api/routes/authRoutes.ts b/sources/app/api/routes/authRoutes.ts new file mode 100644 index 0000000..ec1f106 --- /dev/null +++ b/sources/app/api/routes/authRoutes.ts @@ -0,0 +1,206 @@ +import { z } from "zod"; +import { type Fastify } from "../types"; +import * as privacyKit from "privacy-kit"; +import { db } from "@/storage/db"; +import { auth } from "@/app/auth/auth"; +import { log } from "@/utils/log"; + +export function registerAuthRoutes(app: Fastify) { + app.post('/v1/auth', { + schema: { + body: z.object({ + publicKey: z.string(), + challenge: z.string(), + signature: z.string() + }) + } + }, async (request, reply) => { + const tweetnacl = (await import("tweetnacl")).default; + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const challenge = privacyKit.decodeBase64(request.body.challenge); + const signature = privacyKit.decodeBase64(request.body.signature); + const isValid = tweetnacl.sign.detached.verify(challenge, signature, publicKey); + if (!isValid) { + return reply.code(401).send({ error: 'Invalid signature' }); + } + + // Create or update user in database + const publicKeyHex = privacyKit.encodeHex(publicKey); + const user = await db.account.upsert({ + where: { publicKey: publicKeyHex }, + update: { updatedAt: new Date() }, + create: { publicKey: publicKeyHex } + }); + + return reply.send({ + success: true, + token: await auth.createToken(user.id) + }); + }); + + app.post('/v1/auth/request', { + schema: { + body: z.object({ + publicKey: z.string(), + }), + response: { + 200: z.union([z.object({ + state: z.literal('requested'), + }), z.object({ + state: z.literal('authorized'), + token: z.string(), + response: z.string() + })]), + 401: z.object({ + error: z.literal('Invalid public key') + }) + } + } + }, async (request, reply) => { + const tweetnacl = (await import("tweetnacl")).default; + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const isValid = tweetnacl.box.publicKeyLength === publicKey.length; + if (!isValid) { + return reply.code(401).send({ error: 'Invalid public key' }); + } + + const publicKeyHex = privacyKit.encodeHex(publicKey); + log({ module: 'auth-request' }, `Terminal auth request - publicKey hex: ${publicKeyHex}`); + + const answer = await db.terminalAuthRequest.upsert({ + where: { publicKey: publicKeyHex }, + update: {}, + create: { publicKey: publicKeyHex } + }); + + if (answer.response && answer.responseAccountId) { + const token = await auth.createToken(answer.responseAccountId!, { session: answer.id }); + return reply.send({ + state: 'authorized', + token: token, + response: answer.response + }); + } + + return reply.send({ state: 'requested' }); + }); + + // Approve auth request + app.post('/v1/auth/response', { + preHandler: app.authenticate, + schema: { + body: z.object({ + response: z.string(), + publicKey: z.string() + }) + } + }, async (request, reply) => { + log({ module: 'auth-response' }, `Auth response endpoint hit - user: ${request.userId}, publicKey: ${request.body.publicKey.substring(0, 20)}...`); + const tweetnacl = (await import("tweetnacl")).default; + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const isValid = tweetnacl.box.publicKeyLength === publicKey.length; + if (!isValid) { + log({ module: 'auth-response' }, `Invalid public key length: ${publicKey.length}`); + return reply.code(401).send({ error: 'Invalid public key' }); + } + const publicKeyHex = privacyKit.encodeHex(publicKey); + log({ module: 'auth-response' }, `Looking for auth request with publicKey hex: ${publicKeyHex}`); + const authRequest = await db.terminalAuthRequest.findUnique({ + where: { publicKey: publicKeyHex } + }); + if (!authRequest) { + log({ module: 'auth-response' }, `Auth request not found for publicKey: ${publicKeyHex}`); + // Let's also check what auth requests exist + const allRequests = await db.terminalAuthRequest.findMany({ + take: 5, + orderBy: { createdAt: 'desc' } + }); + log({ module: 'auth-response' }, `Recent auth requests in DB: ${JSON.stringify(allRequests.map(r => ({ id: r.id, publicKey: r.publicKey.substring(0, 20) + '...', hasResponse: !!r.response })))}`); + return reply.code(404).send({ error: 'Request not found' }); + } + if (!authRequest.response) { + await db.terminalAuthRequest.update({ + where: { id: authRequest.id }, + data: { response: request.body.response, responseAccountId: request.userId } + }); + } + return reply.send({ success: true }); + }); + + // Account auth request + app.post('/v1/auth/account/request', { + schema: { + body: z.object({ + publicKey: z.string(), + }), + response: { + 200: z.union([z.object({ + state: z.literal('requested'), + }), z.object({ + state: z.literal('authorized'), + token: z.string(), + response: z.string() + })]), + 401: z.object({ + error: z.literal('Invalid public key') + }) + } + } + }, async (request, reply) => { + const tweetnacl = (await import("tweetnacl")).default; + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const isValid = tweetnacl.box.publicKeyLength === publicKey.length; + if (!isValid) { + return reply.code(401).send({ error: 'Invalid public key' }); + } + + const answer = await db.accountAuthRequest.upsert({ + where: { publicKey: privacyKit.encodeHex(publicKey) }, + update: {}, + create: { publicKey: privacyKit.encodeHex(publicKey) } + }); + + if (answer.response && answer.responseAccountId) { + const token = await auth.createToken(answer.responseAccountId!); + return reply.send({ + state: 'authorized', + token: token, + response: answer.response + }); + } + + return reply.send({ state: 'requested' }); + }); + + // Approve account auth request + app.post('/v1/auth/account/response', { + preHandler: app.authenticate, + schema: { + body: z.object({ + response: z.string(), + publicKey: z.string() + }) + } + }, async (request, reply) => { + const tweetnacl = (await import("tweetnacl")).default; + const publicKey = privacyKit.decodeBase64(request.body.publicKey); + const isValid = tweetnacl.box.publicKeyLength === publicKey.length; + if (!isValid) { + return reply.code(401).send({ error: 'Invalid public key' }); + } + const authRequest = await db.accountAuthRequest.findUnique({ + where: { publicKey: privacyKit.encodeHex(publicKey) } + }); + if (!authRequest) { + return reply.code(404).send({ error: 'Request not found' }); + } + if (!authRequest.response) { + await db.accountAuthRequest.update({ + where: { id: authRequest.id }, + data: { response: request.body.response, responseAccountId: request.userId } + }); + } + return reply.send({ success: true }); + }); + +} \ No newline at end of file diff --git a/sources/app/api/routes/pushRoutes.ts b/sources/app/api/routes/pushRoutes.ts new file mode 100644 index 0000000..125389f --- /dev/null +++ b/sources/app/api/routes/pushRoutes.ts @@ -0,0 +1,112 @@ +import { z } from "zod"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; + +export function registerPushRoutes(app: Fastify) { + + // Push Token Registration API + app.post('/v1/push-tokens', { + schema: { + body: z.object({ + token: z.string() + }), + response: { + 200: z.object({ + success: z.literal(true) + }), + 500: z.object({ + error: z.literal('Failed to register push token') + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { token } = request.body; + + try { + await db.accountPushToken.upsert({ + where: { + accountId_token: { + accountId: userId, + token: token + } + }, + update: { + updatedAt: new Date() + }, + create: { + accountId: userId, + token: token + } + }); + + return reply.send({ success: true }); + } catch (error) { + return reply.code(500).send({ error: 'Failed to register push token' }); + } + }); + + // Delete Push Token API + app.delete('/v1/push-tokens/:token', { + schema: { + params: z.object({ + token: z.string() + }), + response: { + 200: z.object({ + success: z.literal(true) + }), + 500: z.object({ + error: z.literal('Failed to delete push token') + }) + } + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { token } = request.params; + + try { + await db.accountPushToken.deleteMany({ + where: { + accountId: userId, + token: token + } + }); + + return reply.send({ success: true }); + } catch (error) { + return reply.code(500).send({ error: 'Failed to delete push token' }); + } + }); + + // Get Push Tokens API + app.get('/v1/push-tokens', { + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + + try { + const tokens = await db.accountPushToken.findMany({ + where: { + accountId: userId + }, + orderBy: { + createdAt: 'desc' + } + }); + + return reply.send({ + tokens: tokens.map(t => ({ + id: t.id, + token: t.token, + createdAt: t.createdAt.getTime(), + updatedAt: t.updatedAt.getTime() + })) + }); + } catch (error) { + return reply.code(500).send({ error: 'Failed to get push tokens' }); + } + }); +} \ No newline at end of file diff --git a/sources/app/api/routes/sessionRoutes.ts b/sources/app/api/routes/sessionRoutes.ts new file mode 100644 index 0000000..5ad5c58 --- /dev/null +++ b/sources/app/api/routes/sessionRoutes.ts @@ -0,0 +1,296 @@ +import { EventRouter, buildNewSessionUpdate } from "@/modules/eventRouter"; +import { type Fastify } from "../types"; +import { db } from "@/storage/db"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { log } from "@/utils/log"; +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, + }, async (request, reply) => { + const userId = request.userId; + + const sessions = await db.session.findMany({ + where: { accountId: userId }, + orderBy: { updatedAt: 'desc' }, + take: 150, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + // messages: { + // orderBy: { seq: 'desc' }, + // take: 1, + // select: { + // id: true, + // seq: true, + // content: true, + // localId: true, + // createdAt: true + // } + // } + } + }); + + return reply.send({ + sessions: sessions.map((v) => { + // const lastMessage = v.messages[0]; + const sessionUpdatedAt = v.updatedAt.getTime(); + // const lastMessageCreatedAt = lastMessage ? lastMessage.createdAt.getTime() : 0; + + return { + id: v.id, + seq: v.seq, + createdAt: v.createdAt.getTime(), + updatedAt: sessionUpdatedAt, + active: v.active, + activeAt: v.lastActiveAt.getTime(), + metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, + lastMessage: null + }; + }) + }); + }); + + // V2 Sessions API - Active sessions only + app.get('/v2/sessions/active', { + preHandler: app.authenticate, + schema: { + querystring: z.object({ + limit: z.coerce.number().int().min(1).max(500).default(150) + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const limit = request.query?.limit || 150; + + const sessions = await db.session.findMany({ + where: { + accountId: userId, + active: true, + lastActiveAt: { gt: new Date(Date.now() - 1000 * 60 * 15) /* 15 minutes */ } + }, + orderBy: { lastActiveAt: 'desc' }, + take: limit, + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + } + }); + + return reply.send({ + sessions: sessions.map((v) => ({ + id: v.id, + seq: v.seq, + createdAt: v.createdAt.getTime(), + updatedAt: v.updatedAt.getTime(), + active: v.active, + activeAt: v.lastActiveAt.getTime(), + metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, + })) + }); + }); + + // V2 Sessions API - Cursor-based pagination with change tracking + app.get('/v2/sessions', { + preHandler: app.authenticate, + schema: { + querystring: z.object({ + cursor: z.string().optional(), + limit: z.coerce.number().int().min(1).max(200).default(50), + changedSince: z.coerce.number().int().positive().optional() + }).optional() + } + }, async (request, reply) => { + const userId = request.userId; + const { cursor, limit = 50, changedSince } = request.query || {}; + + // Decode cursor - simple ID-based cursor + let cursorSessionId: string | undefined; + if (cursor) { + if (cursor.startsWith('cursor_v1_')) { + cursorSessionId = cursor.substring(10); + } else { + return reply.code(400).send({ error: 'Invalid cursor format' }); + } + } + + // Build where clause + const where: Prisma.SessionWhereInput = { accountId: userId }; + + // Add changedSince filter (just a filter, doesn't affect pagination) + if (changedSince) { + where.updatedAt = { + gt: new Date(changedSince) + }; + } + + // Add cursor pagination - always by ID descending (most recent first) + if (cursorSessionId) { + where.id = { + lt: cursorSessionId // Get sessions with ID less than cursor (for desc order) + }; + } + + // Always sort by ID descending for consistent pagination + const orderBy = { id: 'desc' as const }; + + const sessions = await db.session.findMany({ + where, + orderBy, + take: limit + 1, // Fetch one extra to determine if there are more + select: { + id: true, + seq: true, + createdAt: true, + updatedAt: true, + metadata: true, + metadataVersion: true, + agentState: true, + agentStateVersion: true, + active: true, + lastActiveAt: true, + } + }); + + // Check if there are more results + const hasNext = sessions.length > limit; + const resultSessions = hasNext ? sessions.slice(0, limit) : sessions; + + // Generate next cursor - simple ID-based cursor + let nextCursor: string | null = null; + if (hasNext && resultSessions.length > 0) { + const lastSession = resultSessions[resultSessions.length - 1]; + nextCursor = `cursor_v1_${lastSession.id}`; + } + + return reply.send({ + sessions: resultSessions.map((v) => ({ + id: v.id, + seq: v.seq, + createdAt: v.createdAt.getTime(), + updatedAt: v.updatedAt.getTime(), + active: v.active, + activeAt: v.lastActiveAt.getTime(), + metadata: v.metadata, + metadataVersion: v.metadataVersion, + agentState: v.agentState, + agentStateVersion: v.agentStateVersion, + })), + nextCursor, + hasNext + }); + }); + + // Create or load session by tag + app.post('/v1/sessions', { + schema: { + body: z.object({ + tag: z.string(), + metadata: z.string(), + agentState: z.string().nullish() + }) + }, + preHandler: app.authenticate + }, async (request, reply) => { + const userId = request.userId; + const { tag, metadata } = request.body; + + const session = await db.session.findFirst({ + where: { + accountId: userId, + tag: tag + } + }); + if (session) { + log({ module: 'session-create', sessionId: session.id, userId, tag }, `Found existing session: ${session.id} for tag ${tag}`); + return reply.send({ + session: { + id: session.id, + seq: session.seq, + metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion, + active: session.active, + activeAt: session.lastActiveAt.getTime(), + createdAt: session.createdAt.getTime(), + updatedAt: session.updatedAt.getTime(), + lastMessage: null + } + }); + } else { + + // Resolve seq + const updSeq = await allocateUserSeq(userId); + + // Create session + log({ module: 'session-create', userId, tag }, `Creating new session for user ${userId} with tag ${tag}`); + const session = await db.session.create({ + data: { + accountId: userId, + tag: tag, + metadata: metadata + } + }); + log({ module: 'session-create', sessionId: session.id, userId }, `Session created: ${session.id}`); + + // Emit new session update + const updatePayload = buildNewSessionUpdate(session, updSeq, randomKeyNaked(12)); + log({ + module: 'session-create', + userId, + sessionId: session.id, + updateType: 'new-session', + updatePayload: JSON.stringify(updatePayload) + }, `Emitting new-session update to all user connections`); + eventRouter.emitUpdate({ + userId, + payload: updatePayload, + recipientFilter: { type: 'all-user-authenticated-connections' } + }); + + return reply.send({ + session: { + id: session.id, + seq: session.seq, + metadata: session.metadata, + metadataVersion: session.metadataVersion, + agentState: session.agentState, + agentStateVersion: session.agentStateVersion, + active: session.active, + activeAt: session.lastActiveAt.getTime(), + createdAt: session.createdAt.getTime(), + updatedAt: session.updatedAt.getTime(), + lastMessage: null + } + }); + } + }); +} \ No newline at end of file diff --git a/sources/app/api/types.ts b/sources/app/api/types.ts index 64425ea..061eb68 100644 --- a/sources/app/api/types.ts +++ b/sources/app/api/types.ts @@ -39,7 +39,7 @@ export interface GitHubProfile { } export interface GitHubOrg { - + } export type Fastify = FastifyInstance< @@ -48,4 +48,14 @@ export type Fastify = FastifyInstance< ServerResponse, FastifyBaseLogger, ZodTypeProvider ->; \ No newline at end of file +>; + +declare module 'fastify' { + interface FastifyRequest { + userId: string; + startTime?: number; + } + interface FastifyInstance { + authenticate: any; + } +} \ No newline at end of file