Skill Tracer
Skills › gstack › browse › tokens.ts

tokens.ts

86 lines · browse/src/routes/tokens.ts at 3f79d81

1/**
2 * Scoped-token administration: mint (POST /token), revoke (the /token/*
3 * sub-router, DELETE /token/:clientId) and list (GET /agents). Root-only.
4 */
5
6import { json, jsonError, type RouteEntry } from './table';
7import { createToken, revokeToken, listTokens, InvalidScopeError, ReservedClientIdError } from '../token-registry';
8
9export const tokenRoutes: RouteEntry[] = [
10 // ─── /token — mint scoped tokens (root-only) ──────────────────
11 {
12 method: 'POST', path: '/token', auth: 'handler', surfaces: ['local'],
13 handlerAuth: 'root token (token registry); 403 "Only the root token can mint sub-tokens" otherwise',
14 handler: async (req, _r, ctx) => {
15 if (!ctx.isRootRequest(req)) return jsonError(403, 'Only the root token can mint sub-tokens');
16 try {
17 const tokenBody = await req.json() as any;
18 if (!tokenBody.clientId) return jsonError(400, 'Missing clientId');
19 const session = createToken({
20 clientId: tokenBody.clientId,
21 scopes: tokenBody.scopes,
22 domains: tokenBody.domains,
23 tabPolicy: tokenBody.tabPolicy,
24 rateLimit: tokenBody.rateLimit,
25 expiresSeconds: tokenBody.expiresSeconds,
26 });
27 return json({
28 token: session.token,
29 expires: session.expiresAt,
30 scopes: session.scopes,
31 agent: session.clientId,
32 });
33 } catch (err) {
34 // Name the caller's typo (bad scope, negative rateLimit, reserved
35 // clientId) instead of hiding it behind the generic body error.
36 if (err instanceof InvalidScopeError || err instanceof ReservedClientIdError) {
37 return jsonError(400, err.message);
38 }
39 return jsonError(400, 'Invalid request body');
40 }
41 },
42 },
44 // ─── /token/:clientId — revoke a scoped token (root-only) ─────
45 {
46 method: 'DELETE', path: '/token/', prefix: true, auth: 'root-token', surfaces: ['local'],
47 handler: (_req, { url }, ctx) => {
48 // decodeURIComponent so CLI-encoded names (spaces, UTF-8) round-trip.
49 let clientId: string;
50 try {
51 clientId = decodeURIComponent(url.pathname.slice('/token/'.length));
52 } catch {
53 return jsonError(400, 'Malformed client ID encoding');
54 }
55 const revoked = revokeToken(clientId);
56 // Release tabs UNCONDITIONALLY: ownership outlives the token (it clears
57 // only on tab close), so a client whose token already expired can still
58 // own tabs. Gating release on a revoke hit would orphan that ownership
59 // and let a same-name re-pair inherit an authenticated tab.
60 const tabsReleased = ctx.browserManager.releaseClientTabs(clientId).length;
61 if (!revoked && tabsReleased === 0) return jsonError(404, `Agent "${clientId}" not found`);
62 console.log(`[browse] Revoked ${revoked} token(s), released ${tabsReleased} tab(s) for: ${clientId}`);
63 return json({ revoked: clientId, tokens_deleted: revoked, tabs_released: tabsReleased });
64 },
65 },
67 // ─── /agents — list connected agents (root-only) ──────────────
68 {
69 method: 'GET', path: '/agents', auth: 'root-token', surfaces: ['local'],
70 handler: () => {
71 // includeSetup: pending (unexchanged) setup keys are live grants the
72 // operator must be able to see — without them, revoking a paired-but-
73 // never-connected agent "works" while the list shows nothing.
74 const agents = listTokens({ includeSetup: true }).map(t => ({
75 clientId: t.clientId,
76 scopes: t.scopes,
77 domains: t.domains,
78 expiresAt: t.expiresAt,
79 commandCount: t.commandCount,
80 createdAt: t.createdAt,
81 pending: t.type === 'setup',
82 }));
83 return json({ agents });
84 },
85 },
86];