wip: extract routes
This commit is contained in:
parent
5379043a14
commit
270042d132
@ -34,22 +34,13 @@ import {
|
|||||||
} from "@/app/monitoring/metrics2";
|
} from "@/app/monitoring/metrics2";
|
||||||
import { activityCache } from "@/app/presence/sessionCache";
|
import { activityCache } from "@/app/presence/sessionCache";
|
||||||
import { encryptBytes, encryptString } from "@/modules/encrypt";
|
import { encryptBytes, encryptString } from "@/modules/encrypt";
|
||||||
import { GitHubProfile } from "./types";
|
import { Fastify, GitHubProfile } from "./types";
|
||||||
import { uploadImage } from "@/storage/uploadImage";
|
import { uploadImage } from "@/storage/uploadImage";
|
||||||
import { separateName } from "@/utils/separateName";
|
import { separateName } from "@/utils/separateName";
|
||||||
import { getPublicUrl } from "@/storage/files";
|
import { getPublicUrl } from "@/storage/files";
|
||||||
|
import { registerAuthRoutes } from "./routes/authRoutes";
|
||||||
|
import { registerPushRoutes } from "./routes/pushRoutes";
|
||||||
declare module 'fastify' {
|
import { registerSessionRoutes } from "./routes/sessionRoutes";
|
||||||
interface FastifyRequest {
|
|
||||||
userId: string;
|
|
||||||
startTime?: number;
|
|
||||||
}
|
|
||||||
interface FastifyInstance {
|
|
||||||
authenticate: any;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function startApi(eventRouter: EventRouter): Promise<{ app: FastifyInstance; io: Server }> {
|
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.setValidatorCompiler(validatorCompiler);
|
||||||
app.setSerializerCompiler(serializerCompiler);
|
app.setSerializerCompiler(serializerCompiler);
|
||||||
const typed = app.withTypeProvider<ZodTypeProvider>();
|
|
||||||
|
|
||||||
// Add metrics hooks
|
// Add metrics hooks
|
||||||
app.addHook('onRequest', async (request, reply) => {
|
app.addHook('onRequest', async (request, reply) => {
|
||||||
@ -249,127 +239,12 @@ export async function startApi(eventRouter: EventRouter): Promise<{ app: Fastify
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auth schema
|
const typed = app.withTypeProvider<ZodTypeProvider>() as unknown as Fastify;
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create or update user in database
|
// Routes
|
||||||
const publicKeyHex = privacyKit.encodeHex(publicKey);
|
registerAuthRoutes(typed);
|
||||||
const user = await db.account.upsert({
|
registerPushRoutes(typed);
|
||||||
where: { publicKey: publicKeyHex },
|
registerSessionRoutes(typed, eventRouter);
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// GitHub OAuth parameters
|
// GitHub OAuth parameters
|
||||||
typed.get('/v1/connect/github/params', {
|
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', {
|
typed.get('/v1/account/profile', {
|
||||||
preHandler: app.authenticate,
|
preHandler: app.authenticate,
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
|
206
sources/app/api/routes/authRoutes.ts
Normal file
206
sources/app/api/routes/authRoutes.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
112
sources/app/api/routes/pushRoutes.ts
Normal file
112
sources/app/api/routes/pushRoutes.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
296
sources/app/api/routes/sessionRoutes.ts
Normal file
296
sources/app/api/routes/sessionRoutes.ts
Normal 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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -39,7 +39,7 @@ export interface GitHubProfile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubOrg {
|
export interface GitHubOrg {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Fastify = FastifyInstance<
|
export type Fastify = FastifyInstance<
|
||||||
@ -48,4 +48,14 @@ export type Fastify = FastifyInstance<
|
|||||||
ServerResponse<IncomingMessage>,
|
ServerResponse<IncomingMessage>,
|
||||||
FastifyBaseLogger,
|
FastifyBaseLogger,
|
||||||
ZodTypeProvider
|
ZodTypeProvider
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
declare module 'fastify' {
|
||||||
|
interface FastifyRequest {
|
||||||
|
userId: string;
|
||||||
|
startTime?: number;
|
||||||
|
}
|
||||||
|
interface FastifyInstance {
|
||||||
|
authenticate: any;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user