Files
spoon/apps/next/src/lib/agent-worker-proxy.ts
T

232 lines
7.2 KiB
TypeScript

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<Response>,
) => {
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<Response>,
) => {
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 });
}
};