diff --git a/apps/next/src/lib/agent-worker-proxy.ts b/apps/next/src/lib/agent-worker-proxy.ts index 0180786..0d0b217 100644 --- a/apps/next/src/lib/agent-worker-proxy.ts +++ b/apps/next/src/lib/agent-worker-proxy.ts @@ -9,6 +9,8 @@ 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 ?? @@ -29,6 +31,18 @@ export const mintTerminalToken = (jobId: Id<'agentJobs'>) => { 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 }; }; @@ -144,3 +158,74 @@ export const withOwnedJob = async ( 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 }); + } +}; diff --git a/apps/next/src/lib/box-terminal-token.ts b/apps/next/src/lib/box-terminal-token.ts new file mode 100644 index 0000000..035c47d --- /dev/null +++ b/apps/next/src/lib/box-terminal-token.ts @@ -0,0 +1,21 @@ +import { createHmac } from 'node:crypto'; + +// Pure token builder for the box terminal connection, extracted so it can be +// unit-tested without pulling in `server-only`/Convex. Mirrors the worker's +// `verifyBoxTerminalToken` format so the worker accepts the minted token: +// `${expiresAtMs}.box.${username}.${hmacSha256Hex}` +// Returns null when the terminal feature is not configured (secret/WS base). +export const buildBoxToken = ( + username: string, + secret: string | undefined, + wsBase: string | undefined, + now: number, +): { url: string; expiresAt: number } | null => { + if (!secret || !wsBase) return null; + const expiresAt = now + 2 * 60 * 1000; + const payload = `${expiresAt}.box.${username}`; + const signature = createHmac('sha256', secret).update(payload).digest('hex'); + const token = `${payload}.${signature}`; + const url = `${wsBase.replace(/\/$/, '')}/box/terminal?token=${encodeURIComponent(token)}`; + return { url, expiresAt }; +}; diff --git a/apps/next/tests/unit/box-terminal-token.test.ts b/apps/next/tests/unit/box-terminal-token.test.ts new file mode 100644 index 0000000..0965bd6 --- /dev/null +++ b/apps/next/tests/unit/box-terminal-token.test.ts @@ -0,0 +1,49 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +import { buildBoxToken } from '../../src/lib/box-terminal-token'; + +describe('buildBoxToken', () => { + const secret = 'test-secret'; + const wsBase = 'wss://worker.example.com'; + const username = 'alice'; + const now = 1_700_000_000_000; + + it('returns null when the secret is unset', () => { + expect(buildBoxToken(username, undefined, wsBase, now)).toBeNull(); + }); + + it('returns null when the WS base is unset', () => { + expect(buildBoxToken(username, secret, undefined, now)).toBeNull(); + }); + + it('mints a 4-part box token matching the worker format', () => { + const result = buildBoxToken(username, secret, wsBase, now); + expect(result).not.toBeNull(); + const { url, expiresAt } = result!; + + expect(expiresAt).toBe(now + 2 * 60 * 1000); + + const expectedHmac = createHmac('sha256', secret) + .update(`${expiresAt}.box.${username}`) + .digest('hex'); + const expectedToken = `${expiresAt}.box.${username}.${expectedHmac}`; + + expect(url).toContain('/box/terminal?token='); + expect(url).toBe( + `${wsBase}/box/terminal?token=${encodeURIComponent(expectedToken)}`, + ); + + const token = decodeURIComponent(new URL(url).searchParams.get('token')!); + expect(token).toBe(expectedToken); + const parts = token.split('.'); + expect(parts).toHaveLength(4); + expect(parts[1]).toBe('box'); + expect(parts[2]).toBe(username); + }); + + it('strips a trailing slash from the WS base', () => { + const result = buildBoxToken(username, secret, `${wsBase}/`, now); + expect(result!.url.startsWith(`${wsBase}/box/terminal?token=`)).toBe(true); + }); +});