Skill Tracer
Skills › gstack › browse › table.ts

table.ts

182 lines · browse/src/routes/table.ts at 3f79d81

1/**
2 * Browse route table: owns route entries, the one auth gate, the per-kind
3 * denials, json()/jsonError() and the unmatched fallthrough. Moved from the
4 * if-chain in server.ts buildFetchHandler; handlers live in routes/<area>.ts
5 * and the ordered list is ROUTES in routes/index.ts. Add a route:
6 * { method: 'GET', path: '/thing', auth: 'root-bearer', surfaces: ['local'],
7 * handler: (req, r, ctx) => json({ ok: true }) }
8 * A 'tunnel' surface also needs the TUNNEL_PATHS literal in server.ts.
9 * Enforced by browse/test/server-route-dispatch-ratchet.test.ts.
10 */
12import type { BrowserManager } from '../browser-manager';
13import type { TokenInfo } from '../token-registry';
15/** Which HTTP listener accepted this request. */
16export type Surface = 'local' | 'tunnel';
18/**
19 * The check the gate runs before a handler. `handler` means the gate admits
20 * the request and the handler authenticates it itself; the entry's
21 * `handlerAuth` says how (used only where today's denial differs from every
22 * gate kind: POST /token, POST /pty-dispose and the /cookie-picker sub-router).
23 */
24export type AuthKind =
25 | 'none'
26 | 'root-bearer'
27 | 'root-token'
28 | 'scoped'
29 | 'extension-origin'
30 | 'root-or-sse-cookie'
31 | 'handler';
33type GatedKind = Exclude<AuthKind, 'none' | 'handler'>;
35/** Every gate-level denial, defined once per auth kind. */
36export const AUTH_DENIALS: Record<GatedKind, { status: number; error: string }> = {
37 'root-bearer': { status: 401, error: 'Unauthorized' },
38 'scoped': { status: 401, error: 'Unauthorized' },
39 'root-or-sse-cookie': { status: 401, error: 'Unauthorized' },
40 'root-token': { status: 403, error: 'Root token required' },
41 'extension-origin': { status: 403, error: 'Forbidden' },
42};
44/**
45 * What handlers may use instead of closing over buildFetchHandler locals.
46 * Auth arrives as check functions; the raw root token is only
47 * `bootstrapRootToken`, read by POST /extension-token (returns it) and
48 * /cookie-picker* (passes it to its sub-router).
49 */
50export interface RouteContext {
51 browserManager: BrowserManager;
52 startTime: number;
53 browsePort: number;
54 /** Constant-time `Authorization: Bearer <cfg.authToken>` check. */
55 validateAuth(req: Request): boolean;
56 /** Bearer token equals the token registry's root token. */
57 isRootRequest(req: Request): boolean;
58 /** Root or scoped TokenInfo for the bearer token, or null. */
59 getTokenInfo(req: Request): TokenInfo | null;
60 /** The request carries a live view-only SSE session cookie. */
61 hasSseCookie(req: Request): boolean;
62 /** Origin is the pinned gstack extension and Host is loopback. */
63 isPinnedExtensionRequest(req: Request): boolean;
64 /** A token string (header or sendBeacon body) equals cfg.authToken. */
65 isRootTokenValue(token: string | null): boolean;
66 bootstrapRootToken: string;
67 resetIdleTimer(): void;
68 terminal: {
69 readPort(): number | null;
70 grantToken(token: string, sessionId?: string): Promise<boolean>;
71 restartSession(sessionId: string): Promise<boolean>;
72 };
73 tunnel: {
74 state(): { active: boolean; url: string | null; hasListener: boolean };
75 close(): Promise<void>;
76 resolveAuthtoken(): string | null;
77 start(authtoken: string): Promise<{ ok: true; url: string } | { ok: false; stage: 'bind' | 'ngrok'; error: Error }>;
78 };
79 commands: {
80 handle(body: any, tokenInfo: TokenInfo | null): Promise<Response>;
81 handleInternal(
82 body: any,
83 tokenInfo: TokenInfo | null,
84 opts: { skipRateCheck?: boolean; skipActivity?: boolean },
85 ): Promise<{ status: number; result: string; json?: boolean }>;
86 };
89/** Per-request facts the dispatcher hands a handler. */
90export interface RouteRequest {
91 url: URL;
92 surface: Surface;
93 /** TokenInfo resolved by a 'scoped' gate; null for every other kind. */
94 tokenInfo: TokenInfo | null;
97export type RouteHandler = (req: Request, r: RouteRequest, ctx: RouteContext) => Promise<Response> | Response;
99export interface RouteEntry {
100 /** HTTP method, or '*' for any method. */
101 method: string;
102 path: string;
103 /** Match every pathname starting with `path` (sub-routers). */
104 prefix?: true;
105 auth: AuthKind;
106 surfaces: readonly Surface[];
107 /** Required when auth is 'handler': where and how the handler authenticates. */
108 handlerAuth?: string;
109 handler: RouteHandler;
112export function json(
113 body: unknown,
114 init: { status?: number; headers?: Record<string, string>; replacer?: (key: string, value: unknown) => unknown } = {},
115): Response {
116 return new Response(JSON.stringify(body, init.replacer as any), {
117 status: init.status ?? 200,
118 headers: { 'Content-Type': 'application/json', ...init.headers },
119 });
122export function jsonError(status: number, error: string, extra: Record<string, unknown> = {}): Response {
123 return json({ error, ...extra }, { status });
126/** Runs an entry's auth kind. Returns the resolved TokenInfo, or the kind's denial. */
127export function checkRouteAuth(auth: AuthKind, req: Request, ctx: RouteContext): { tokenInfo: TokenInfo | null } | Response {
128 const admitted = { tokenInfo: null };
129 switch (auth) {
130 case 'none':
131 case 'handler':
132 return admitted;
133 case 'root-bearer':
134 return ctx.validateAuth(req) ? admitted : deny(auth);
135 case 'root-or-sse-cookie':
136 return ctx.validateAuth(req) || ctx.hasSseCookie(req) ? admitted : deny(auth);
137 case 'root-token':
138 return ctx.isRootRequest(req) ? admitted : deny(auth);
139 case 'extension-origin':
140 return ctx.isPinnedExtensionRequest(req) ? admitted : deny(auth);
141 case 'scoped': {
142 const tokenInfo = ctx.getTokenInfo(req);
143 return tokenInfo ? { tokenInfo } : deny(auth);
144 }
145 }
148function deny(auth: GatedKind): Response {
149 const { status, error } = AUTH_DENIALS[auth];
150 return jsonError(status, error);
153/**
154 * The declared fallthrough for a request no entry matches (unknown path, or a
155 * known path with a method no entry accepts): the root-bearer check, then a
156 * plain-text 404.
157 */
158export const UNMATCHED_ROUTE = {
159 auth: 'root-bearer',
160 handler: () => new Response('Not found', { status: 404 }),
161} as const satisfies Pick<RouteEntry, 'auth' | 'handler'>;
163export function findRoute(routes: readonly RouteEntry[], method: string, pathname: string, surface: Surface): RouteEntry | null {
164 return routes.find(r =>
165 r.surfaces.includes(surface)
166 && (r.method === '*' || r.method === method)
167 && (r.prefix ? pathname.startsWith(r.path) : pathname === r.path),
168 ) ?? null;
171export async function dispatchRoute(
172 routes: readonly RouteEntry[],
173 req: Request,
174 url: URL,
175 surface: Surface,
176 ctx: RouteContext,
177): Promise<Response> {
178 const entry = findRoute(routes, req.method, url.pathname, surface) ?? UNMATCHED_ROUTE;
179 const gate = checkRouteAuth(entry.auth, req, ctx);
180 if (gate instanceof Response) return gate;
181 return entry.handler(req, { url, surface, tokenInfo: gate.tokenInfo }, ctx);