wip: extract routes

This commit is contained in:
Steve Korshakov 2025-09-01 14:12:09 -07:00
parent 5379043a14
commit 270042d132
5 changed files with 635 additions and 662 deletions

View File

@ -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<ZodTypeProvider>();
// 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<ZodTypeProvider>() 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) => {

View File

@ -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 });
});
}

View File

@ -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' });
}
});
}

View File

@ -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
}
});
}
});
}

View File

@ -39,7 +39,7 @@ export interface GitHubProfile {
}
export interface GitHubOrg {
}
export type Fastify = FastifyInstance<
@ -48,4 +48,14 @@ export type Fastify = FastifyInstance<
ServerResponse<IncomingMessage>,
FastifyBaseLogger,
ZodTypeProvider
>;
>;
declare module 'fastify' {
interface FastifyRequest {
userId: string;
startTime?: number;
}
interface FastifyInstance {
authenticate: any;
}
}