feat(web): box terminal token mint + ownership proxy helpers
This commit is contained in:
@@ -9,6 +9,8 @@ import { fetchQuery } from 'convex/nextjs';
|
|||||||
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
|
import { buildBoxToken } from './box-terminal-token';
|
||||||
|
|
||||||
const terminalSecret = () =>
|
const terminalSecret = () =>
|
||||||
env.SPOON_AGENT_TERMINAL_SECRET ??
|
env.SPOON_AGENT_TERMINAL_SECRET ??
|
||||||
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
env.SPOON_AGENT_WORKER_INTERNAL_TOKEN ??
|
||||||
@@ -29,6 +31,18 @@ export const mintTerminalToken = (jobId: Id<'agentJobs'>) => {
|
|||||||
return { url, expiresAt };
|
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 = {
|
type RouteContext = {
|
||||||
params: Promise<{ jobId: string }> | { jobId: string };
|
params: Promise<{ jobId: string }> | { jobId: string };
|
||||||
};
|
};
|
||||||
@@ -144,3 +158,74 @@ export const withOwnedJob = async (
|
|||||||
return NextResponse.json({ error: message }, { status: 500 });
|
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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user