import 'server-only'; import { createHmac } from 'node:crypto'; import { NextResponse } from 'next/server'; import { env } from '@/env'; import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server'; import { fetchQuery } from 'convex/nextjs'; import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { buildBoxToken } from './box-terminal-token'; const terminalSecret = () => env.SPOON_AGENT_TERMINAL_SECRET ?? env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? env.SPOON_WORKER_TOKEN; // Mints a short-lived, job-scoped terminal token + the worker WS URL. Returns // null when the terminal feature is not configured. The 2-minute expiry is a // connect window only; an established PTY session persists past it. export const mintTerminalToken = (jobId: Id<'agentJobs'>) => { const secret = terminalSecret(); const base = env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL; if (!secret || !base) return null; const expiresAt = Date.now() + 2 * 60 * 1000; const payload = `${expiresAt}.${jobId}`; const signature = createHmac('sha256', secret).update(payload).digest('hex'); const token = `${payload}.${signature}`; const url = `${base.replace(/\/$/, '')}/jobs/${encodeURIComponent(jobId)}/terminal?token=${encodeURIComponent(token)}`; return { url, expiresAt }; }; // User-scoped variant of mintTerminalToken for a user's persistent box. The // username is supplied by resolveBoxUsername (derived from the authed Convex // user), never from the request. Returns null when the terminal feature is not // configured. export const mintBoxTerminalToken = (username: string) => buildBoxToken( username, terminalSecret(), env.NEXT_PUBLIC_SPOON_AGENT_WORKER_WS_URL, Date.now(), ); type RouteContext = { params: Promise<{ jobId: string }> | { jobId: string }; }; export const routeJobId = async (context: RouteContext) => { const params = await context.params; return params.jobId as Id<'agentJobs'>; }; const workerToken = () => env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ?? env.SPOON_WORKER_TOKEN; export const requireOwnedJob = async (jobId: Id<'agentJobs'>) => { const token = await convexAuthNextjsToken(); if (!token) { return { ok: false as const, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }; } await fetchQuery(api.agentJobs.assertOwned, { jobId }, { token }); return { ok: true as const }; }; export const requireAuthenticatedUser = async () => { const token = await convexAuthNextjsToken(); if (!token) { return { ok: false as const, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }; } await fetchQuery(api.auth.getUser, {}, { token }); return { ok: true as const }; }; export const proxyWorkerRoot = async (path: string, init?: RequestInit) => { const token = workerToken(); if (!token) { return NextResponse.json( { error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' }, { status: 500 }, ); } const url = new URL(path, env.SPOON_AGENT_WORKER_URL); const response = await fetch(url, { ...init, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...init?.headers, }, }); const text = await response.text(); return new NextResponse(text, { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json', }, }); }; export const proxyWorker = async ( jobId: Id<'agentJobs'>, action: string, init?: RequestInit, search?: URLSearchParams, ) => { const token = workerToken(); if (!token) { return NextResponse.json( { error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' }, { status: 500 }, ); } const url = new URL( `/jobs/${encodeURIComponent(jobId)}/${action}`, env.SPOON_AGENT_WORKER_URL, ); if (search) { for (const [key, value] of search) url.searchParams.set(key, value); } const response = await fetch(url, { ...init, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...init?.headers, }, }); const text = await response.text(); return new NextResponse(text, { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json', }, }); }; export const withOwnedJob = async ( context: RouteContext, handler: (jobId: Id<'agentJobs'>) => Promise, ) => { try { const jobId = await routeJobId(context); const owned = await requireOwnedJob(jobId); if (!owned.ok) return owned.response; return await handler(jobId); } catch (error) { const message = error instanceof Error ? error.message : String(error); return NextResponse.json({ error: message }, { status: 500 }); } }; // Resolves the authed caller's own box username from Convex. This is the // security boundary: the username comes from the authed user's environment, // never from the request, so a caller can only ever reach their own box. export const resolveBoxUsername = async (): Promise< { ok: true; username: string } | { ok: false; response: NextResponse } > => { const token = await convexAuthNextjsToken(); if (!token) { return { ok: false as const, response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), }; } const mine = await fetchQuery(api.userEnvironment.getMine, {}, { token }); return { ok: true as const, username: mine.username }; }; // Mirrors proxyWorker but targets `/box/${action}` and force-sets the derived // username as `user` (overwriting any client-supplied value) so the worker only // ever operates on the caller's own box. export const proxyBox = async ( username: string, action: 'status' | 'lifecycle' | 'tree' | 'file', init?: RequestInit, search?: URLSearchParams, ) => { const token = workerToken(); if (!token) { return NextResponse.json( { error: 'SPOON_AGENT_WORKER_INTERNAL_TOKEN is not configured.' }, { status: 500 }, ); } const url = new URL(`/box/${action}`, env.SPOON_AGENT_WORKER_URL); if (search) { for (const [key, value] of search) url.searchParams.set(key, value); } url.searchParams.set('user', username); const response = await fetch(url, { ...init, headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json', ...init?.headers, }, }); const text = await response.text(); return new NextResponse(text, { status: response.status, headers: { 'content-type': response.headers.get('content-type') ?? 'application/json', }, }); }; // Convenience wrapper mirroring withOwnedJob: resolve the caller's own box // username, then run the handler; 401 when unauthenticated, 500 on throw. export const withBox = async ( handler: (username: string) => Promise, ) => { try { const resolved = await resolveBoxUsername(); if (!resolved.ok) return resolved.response; return await handler(resolved.username); } catch (error) { const message = error instanceof Error ? error.message : String(error); return NextResponse.json({ error: message }, { status: 500 }); } };