token-registry.ts
721 lines · browse/src/token-registry.ts at 3f79d81
1/**
2 * Token registry — per-agent scoped tokens for multi-agent browser access.
3 *
4 * Architecture:
5 * Root token (from server startup) → POST /token → scoped sub-tokens
6 * POST /connect (setup key exchange) → session token
7 *
8 * Token lifecycle:
9 * createSetupKey() → exchangeSetupKey() → session token (24h default)
10 * createToken() → direct session token (for CLI/local use)
11 * revokeToken() → immediate invalidation
12 * rotateRoot() → new root, all scoped tokens invalidated
13 *
14 * Scope categories (derived from commands.ts READ/WRITE/META sets):
15 * read — snapshot, text, html, links, forms, console, etc.
16 * write — goto, click, fill, scroll, newtab, etc.
17 * admin — eval, js, cookies, storage, useragent, state (destructive)
18 * meta — tab, diff, chain, frame, responsive
19 *
20 * Security invariants:
21 * 1. Only root token can mint sub-tokens (POST /token, POST /connect)
22 * 2. control scope denied by default — must be explicitly flagged.
23 * Registry API defaults (createToken/createSetupKey with no scopes)
24 * stay ['read','write']; the /pair ceremony explicitly grants
25 * DEFAULT_PAIR_SCOPES (read+write+admin+meta — the pairing ceremony
26 * is the trust boundary; --restrict narrows, --control must be
27 * explicit and never rides in via a scopes list)
28 * 3. chain command scope-checks each subcommand individually
29 * 4. Root token never in connection strings or pasted instructions
30 *
31 * Zero side effects on import. Safe to import from tests.
32 */
34import * as crypto from 'crypto';
35import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
37// ─── Scope Definitions ─────────────────────────────────────────
38// Derived from commands.ts, but reclassified by actual side effects.
39// The key insight (from Codex adversarial review): commands.ts READ_COMMANDS
40// includes js/eval/cookies/storage which are actually dangerous. The scope
41// model here overrides the commands.ts classification.
43/** Commands safe for read-only agents */
44export const SCOPE_READ = new Set([
45 'snapshot', 'text', 'html', 'links', 'forms', 'accessibility',
46 'console', 'network', 'perf', 'dialog', 'is', 'inspect',
47 'url', 'tabs', 'status', 'screenshot', 'pdf', 'css', 'attrs',
48 'media', 'data',
49]);
51/** Commands that modify page state or navigate */
52export const SCOPE_WRITE = new Set([
53 'goto', 'back', 'forward', 'reload',
54 'load-html',
55 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
56 'upload', 'viewport', 'newtab', 'closetab',
57 'dialog-accept', 'dialog-dismiss',
58 'download', 'scrape', 'archive',
59]);
61/** Page-level power tools — JS execution, credential access, page mutations */
62export const SCOPE_ADMIN = new Set([
63 'eval', 'js', 'cookies', 'storage',
64 'cookie', 'cookie-import', 'cookie-import-browser',
65 'header', 'useragent',
66 'style', 'cleanup', 'prettyscreenshot',
67]);
69/** Browser-wide destructive commands — can kill the server, disconnect headed mode */
70export const SCOPE_CONTROL = new Set([
71 'state', 'handoff', 'resume', 'stop', 'restart', 'connect', 'disconnect',
72]);
74/** Meta commands — generally safe but some need scope checking */
75export const SCOPE_META = new Set([
76 'tab', 'diff', 'frame', 'responsive', 'snapshot',
77 'watch', 'inbox', 'focus',
78]);
80export type ScopeCategory = 'read' | 'write' | 'admin' | 'meta' | 'control';
82const SCOPE_MAP: Record<ScopeCategory, Set<string>> = {
83 read: SCOPE_READ,
84 write: SCOPE_WRITE,
85 admin: SCOPE_ADMIN,
86 control: SCOPE_CONTROL,
87 meta: SCOPE_META,
88};
90/**
91 * Scopes granted by POST /pair when nothing narrower is requested.
92 * Deliberately full page access (b73f3644 / #907): the trust boundary is the
93 * pairing ceremony, not the scope. 'control' (browser-wide destructive ops)
94 * is the only scope that stays opt-in via the control flag. Referenced by
95 * BOTH server.ts (/pair default) and cli.ts (explicit send) so the two
96 * defaults cannot silently drift apart again.
97 */
98export const DEFAULT_PAIR_SCOPES: readonly ScopeCategory[] = ['read', 'write', 'admin', 'meta'];
100/**
101 * Typed error for caller-supplied token options (unknown scope, negative
102 * rateLimit). HTTP handlers catch it to 400 with the message at the endpoint
103 * where the typo happened — pre-fix, a bad scope sailed through /pair into a
104 * poisoned setup key and surfaced as a misleading "Invalid request body" to
105 * the remote agent at /connect.
106 */
107export class InvalidScopeError extends Error {}
109export function assertValidTokenOptions(scopes: readonly string[], rateLimit: number): void {
110 const validScopes: ScopeCategory[] = ['read', 'write', 'admin', 'meta', 'control'];
111 for (const s of scopes) {
112 if (!validScopes.includes(s as ScopeCategory)) {
113 throw new InvalidScopeError(`Invalid scope: ${s}. Valid: ${validScopes.join(', ')}`);
114 }
115 }
116 if (rateLimit < 0) throw new InvalidScopeError('rateLimit must be >= 0');
117}
119/**
120 * Typed error for a reserved or malformed clientId. `root` is the sentinel that
121 * checkScope/checkDomain/checkRate and the server command gate use to mean "the
122 * omnipotent root caller" (validateToken:360), so a scoped token carrying it
123 * would bypass every enforcement path. Empty/non-string ids collapse distinct
124 * agents together and break revoke-by-clientId. Request-path writers throw;
125 * restoreRegistry skips-and-logs so one bad state-file entry can't drop later
126 * sessions or brick boot.
127 */
128export class ReservedClientIdError extends Error {}
130export function assertValidClientId(clientId: unknown): asserts clientId is string {
131 if (typeof clientId !== 'string' || clientId.trim() === '') {
132 throw new ReservedClientIdError('clientId must be a non-empty string');
133 }
134 if (clientId.trim().toLowerCase() === 'root') {
135 throw new ReservedClientIdError("clientId 'root' is reserved");
136 }
137}
139// ─── Types ──────────────────────────────────────────────────────
141export interface TokenInfo {
142 token: string;
143 clientId: string;
144 type: 'session' | 'setup';
145 scopes: ScopeCategory[];
146 domains?: string[]; // glob patterns, e.g. ['*.myapp.com']
147 tabPolicy: 'own-only' | 'shared';
148 rateLimit: number; // requests per second (0 = unlimited)
149 expiresAt: string | null; // ISO8601, null = never
150 createdAt: string;
151 usesRemaining?: number; // for setup keys only
152 issuedSessionToken?: string; // for setup keys: the session token that was issued
153 commandCount: number; // how many commands have been executed
154}
156export interface CreateTokenOptions {
157 clientId: string;
158 scopes?: ScopeCategory[];
159 domains?: string[];
160 tabPolicy?: 'own-only' | 'shared';
161 rateLimit?: number;
162 expiresSeconds?: number | null; // null = never, default = 86400 (24h)
163}
165export interface TokenRegistryState {
166 agents: Record<string, Omit<TokenInfo, 'commandCount'>>;
167}
169// ─── Rate Limiter ───────────────────────────────────────────────
171interface RateBucket {
172 count: number;
173 windowStart: number;
174}
176const rateBuckets = new Map<string, RateBucket>();
178function checkRateLimit(clientId: string, limit: number): { allowed: boolean; retryAfterMs?: number } {
179 if (limit <= 0) return { allowed: true };
181 const now = Date.now();
182 const bucket = rateBuckets.get(clientId);
184 if (!bucket || now - bucket.windowStart >= 1000) {
185 rateBuckets.set(clientId, { count: 1, windowStart: now });
186 return { allowed: true };
187 }
189 if (bucket.count >= limit) {
190 const retryAfterMs = 1000 - (now - bucket.windowStart);
191 return { allowed: false, retryAfterMs: Math.max(retryAfterMs, 100) };
192 }
194 bucket.count++;
195 return { allowed: true };
196}
198// ─── Token Registry ─────────────────────────────────────────────
200const tokens = new Map<string, TokenInfo>();
201let rootToken: string = '';
203export function initRegistry(root: string): void {
204 // Idempotent re-init: same token is a no-op so embedders can call this
205 // alongside any prior call without fighting. Different token after init
206 // means a misconfigured caller — throw clearly rather than silently
207 // invalidate every scoped token already issued.
208 if (rootToken !== '' && rootToken !== root) {
209 throw new Error(
210 'token-registry already initialized with a different token; ' +
211 'embedders must call buildFetchHandler before any registry-mutating code path'
212 );
213 }
214 rootToken = root;
215}
217export function getRootToken(): string {
218 return rootToken;
219}
221export function isRootToken(token: string): boolean {
222 // Constant-time compare so a tunnel-reachable caller who can provoke an
223 // isRootToken() call (e.g., via the 403 "root over tunnel" rejection path)
224 // can't measure byte-by-byte string-compare timing to recover the token.
225 // Compare UTF-8 byte lengths (not JS string length) before timingSafeEqual,
226 // which throws on length-mismatched buffers. A multibyte input whose JS
227 // string length matches rootToken but whose UTF-8 byte length differs must
228 // return false on the auth path, not error out.
229 if (!rootToken) return false;
230 const tokenBytes = Buffer.byteLength(token, 'utf8');
231 const rootBytes = Buffer.byteLength(rootToken, 'utf8');
232 if (tokenBytes !== rootBytes) return false;
233 const a = Buffer.from(token, 'utf8');
234 const b = Buffer.from(rootToken, 'utf8');
235 return crypto.timingSafeEqual(a, b);
236}
238function generateToken(prefix: string): string {
239 return `${prefix}${crypto.randomBytes(24).toString('hex')}`;
240}
242/**
243 * Create a scoped session token (for direct minting via CLI or /token endpoint).
244 * Only callable by root token holder.
245 */
246export function createToken(opts: CreateTokenOptions): TokenInfo {
247 const {
248 clientId,
249 scopes = ['read', 'write'],
250 domains,
251 tabPolicy = 'own-only',
252 rateLimit = 10,
253 expiresSeconds = 86400, // 24h default
254 } = opts;
256 // Validate inputs
257 assertValidClientId(clientId);
258 assertValidTokenOptions(scopes, rateLimit);
259 if (expiresSeconds !== null && expiresSeconds !== undefined && expiresSeconds < 0) {
260 throw new Error('expiresSeconds must be >= 0 or null');
261 }
263 const token = generateToken('gsk_sess_');
264 const now = new Date();
265 const expiresAt = expiresSeconds === null
266 ? null
267 : new Date(now.getTime() + expiresSeconds * 1000).toISOString();
269 const info: TokenInfo = {
270 token,
271 clientId,
272 type: 'session',
273 scopes,
274 domains,
275 tabPolicy,
276 rateLimit,
277 expiresAt,
278 createdAt: now.toISOString(),
279 commandCount: 0,
280 };
282 // Overwrite if clientId already exists (re-pairing)
283 // First revoke the old session token (but NOT setup keys — they track their issued session)
284 for (const [t, existing] of tokens) {
285 if (existing.clientId === clientId && existing.type === 'session') {
286 tokens.delete(t);
287 break;
288 }
289 }
291 tokens.set(token, info);
292 return info;
293}
295/**
296 * Create a one-time setup key for the /pair-agent ceremony.
297 * Setup keys expire in 5 minutes and can only be exchanged once.
298 */
299export function createSetupKey(opts: Omit<CreateTokenOptions, 'clientId'> & { clientId?: string }): TokenInfo {
300 // Only validate when a clientId is supplied; an omitted one gets a safe
301 // generated `remote-<ts>` default below.
302 if (opts.clientId !== undefined) assertValidClientId(opts.clientId);
303 const scopes = opts.scopes || ['read', 'write'];
304 // ?? not ||: rateLimit 0 is documented as "unlimited" and must survive.
305 const rateLimit = opts.rateLimit ?? 10;
306 // Validate HERE, not only at exchange time in createToken — otherwise a
307 // typo mints a poisoned setup key whose failure surfaces to the wrong
308 // party (the remote agent, at /connect, as "Invalid request body").
309 assertValidTokenOptions(scopes, rateLimit);
310 const token = generateToken('gsk_setup_');
311 const now = new Date();
312 const expiresAt = new Date(now.getTime() + 5 * 60 * 1000).toISOString(); // 5 min
314 const info: TokenInfo = {
315 token,
316 clientId: opts.clientId || `remote-${Date.now()}`,
317 type: 'setup',
318 scopes,
319 domains: opts.domains,
320 tabPolicy: opts.tabPolicy || 'own-only',
321 rateLimit,
322 expiresAt,
323 createdAt: now.toISOString(),
324 usesRemaining: 1,
325 commandCount: 0,
326 };
328 tokens.set(token, info);
329 return info;
330}
332/**
333 * Exchange a setup key for a session token.
334 * Idempotent: if the same key is presented again and the prior session
335 * has 0 commands, returns the same session token (handles tunnel drops).
336 */
337export function exchangeSetupKey(setupKey: string, sessionExpiresSeconds?: number | null): TokenInfo | null {
338 const setup = tokens.get(setupKey);
339 if (!setup) return null;
340 if (setup.type !== 'setup') return null;
342 // Check expiry
343 if (setup.expiresAt && new Date(setup.expiresAt) < new Date()) {
344 tokens.delete(setupKey);
345 return null;
346 }
348 // Idempotent: if already exchanged but session has 0 commands, return existing
349 if (setup.usesRemaining === 0) {
350 if (setup.issuedSessionToken) {
351 const existing = tokens.get(setup.issuedSessionToken);
352 if (existing && existing.commandCount === 0) {
353 return existing;
354 }
355 }
356 return null; // Session used or gone — can't re-issue
357 }
359 // Consume the setup key
360 setup.usesRemaining = 0;
362 // Create the session token
363 const session = createToken({
364 clientId: setup.clientId,
365 scopes: setup.scopes,
366 domains: setup.domains,
367 tabPolicy: setup.tabPolicy,
368 rateLimit: setup.rateLimit,
369 expiresSeconds: sessionExpiresSeconds ?? 86400,
370 });
372 // Track which session token was issued from this setup key
373 setup.issuedSessionToken = session.token;
375 return session;
376}
378/**
379 * Validate a token and return its info if valid.
380 * Returns null for expired, revoked, or unknown tokens, and for unexchanged
381 * setup keys: a setup key authenticates only the /connect exchange, never a
382 * bearer request.
383 * Root token returns a special root info object.
384 */
385export function validateToken(token: string): TokenInfo | null {
386 if (isRootToken(token)) {
387 return {
388 token: rootToken,
389 clientId: 'root',
390 type: 'session',
391 scopes: ['read', 'write', 'admin', 'meta', 'control'],
392 tabPolicy: 'shared',
393 rateLimit: 0, // unlimited
394 expiresAt: null,
395 createdAt: '',
396 commandCount: 0,
397 };
398 }
400 const info = tokens.get(token);
401 if (!info || info.type !== 'session') return null;
403 // Check expiry
404 if (info.expiresAt && new Date(info.expiresAt) < new Date()) {
405 tokens.delete(token);
406 return null;
407 }
409 return info;
410}
412/**
413 * Check if a command is allowed by the token's scopes.
414 * The `chain` command is special: it's allowed if the token has meta scope,
415 * but each subcommand within chain must be individually scope-checked.
416 */
417export function checkScope(info: TokenInfo, command: string): boolean {
418 if (info.clientId === 'root') return true;
420 // Special case: chain is in SCOPE_META but requires that the caller
421 // has scopes covering ALL subcommands. The actual subcommand check
422 // happens at dispatch time, not here.
423 if (command === 'chain' && info.scopes.includes('meta')) return true;
425 for (const scope of info.scopes) {
426 if (SCOPE_MAP[scope]?.has(command)) return true;
427 }
429 return false;
430}
432/**
433 * Check if a URL is allowed by the token's domain restrictions.
434 * Returns true if no domain restrictions, or if the URL matches any glob.
435 */
436export function checkDomain(info: TokenInfo, url: string): boolean {
437 if (info.clientId === 'root') return true;
438 if (!info.domains || info.domains.length === 0) return true;
440 try {
441 const parsed = new URL(url);
442 const hostname = parsed.hostname;
444 for (const pattern of info.domains) {
445 if (matchDomainGlob(hostname, pattern)) return true;
446 }
448 return false;
449 } catch {
450 return false; // Invalid URL — deny
451 }
452}
454function matchDomainGlob(hostname: string, pattern: string): boolean {
455 // Simple glob: *.example.com matches sub.example.com
456 // Exact: example.com matches example.com only
457 if (pattern.startsWith('*.')) {
458 const suffix = pattern.slice(1); // .example.com
459 return hostname.endsWith(suffix) || hostname === pattern.slice(2);
460 }
461 return hostname === pattern;
462}
464/**
465 * Check rate limit for a client. Returns { allowed, retryAfterMs? }.
466 */
467export function checkRate(info: TokenInfo): { allowed: boolean; retryAfterMs?: number } {
468 if (info.clientId === 'root') return { allowed: true };
469 return checkRateLimit(info.clientId, info.rateLimit);
470}
472/**
473 * Record that a command was executed by this token.
474 */
475export function recordCommand(token: string): void {
476 const info = tokens.get(token);
477 if (info) info.commandCount++;
478}
480/**
481 * Revoke ALL tokens for a client ID — the session token and every setup key,
482 * spent or unspent. Deleting only the first match left two holes: a spent
483 * setup key (kept for idempotent re-exchange) shadowed the session token, so
484 * revoke reported success while the live session survived; and an unspent
485 * setup key surviving revoke let a "revoked" agent POST /connect into a
486 * fresh session. Returns the number of tokens deleted (0 = nothing found).
487 */
488export function revokeToken(clientId: string): number {
489 let deleted = 0;
490 for (const [token, info] of tokens) {
491 if (info.clientId === clientId) {
492 tokens.delete(token); // Map tolerates delete during for...of iteration
493 deleted++;
494 }
495 }
496 if (deleted > 0) rateBuckets.delete(clientId);
497 return deleted;
498}
500/**
501 * Revoke the PENDING (unspent) setup keys for a client, leaving any live
502 * session AND spent keys untouched. A re-pair always drops pending keys so a
503 * superseded broad key can never be exchanged — this closes the shadow-key
504 * hole (a reducing re-pair before the agent connects would otherwise leave the
505 * old broad key live) without touching the spent key that #2646 keeps for
506 * idempotent re-exchange on a tunnel drop. Returns the number deleted.
507 */
508export function revokeSetupKeys(clientId: string): number {
509 let deleted = 0;
510 for (const [token, info] of tokens) {
511 // usesRemaining !== 0 = still exchangeable (pending). Spent keys (0) are
512 // harmless: their session is either kept here or revoked on the reduce path.
513 if (info.clientId === clientId && info.type === 'setup' && info.usesRemaining !== 0) {
514 tokens.delete(token);
515 deleted++;
516 }
517 }
518 return deleted;
519}
521/** The live (non-expired) session token for a client, if any. */
522export function getClientSession(clientId: string): TokenInfo | null {
523 const now = new Date();
524 for (const info of tokens.values()) {
525 if (info.clientId !== clientId || info.type !== 'session') continue;
526 if (info.expiresAt && new Date(info.expiresAt) < now) continue;
527 return info;
528 }
529 return null;
530}
532/** The effective grant a re-pair is requesting, resolved to concrete values. */
533export interface ResolvedGrant {
534 scopes: ScopeCategory[];
535 domains?: string[];
536 rateLimit: number;
537 tabPolicy: 'own-only' | 'shared';
538}
540/**
541 * Does `grant` remove any capability the live `prior` session holds? Drives the
542 * /pair supersede decision: a reducing re-pair revokes the old session
543 * immediately (the narrowing must not wait for a reconnect that may never
544 * happen); a broaden/refresh leaves it working (no outage). Fails toward
545 * revocation on an unprovable domain superset — a spurious revoke costs one
546 * reconnect, a missed one leaves wide access live.
547 */
548export function grantReducesAccess(prior: TokenInfo, grant: ResolvedGrant): boolean {
549 return scopesReduced(prior.scopes, grant.scopes)
550 || domainsReduced(prior.domains, grant.domains)
551 || rateReduced(prior.rateLimit, grant.rateLimit)
552 || tabPolicyReduced(prior.tabPolicy, grant.tabPolicy);
553}
555function scopesReduced(prior: ScopeCategory[], next: ScopeCategory[]): boolean {
556 // Any scope the prior held that the new grant omits (also catches dropping 'control').
557 return prior.some(s => !next.includes(s));
558}
560function domainsReduced(prior: string[] | undefined, next: string[] | undefined): boolean {
561 const priorUnrestricted = !prior || prior.length === 0;
562 const nextUnrestricted = !next || next.length === 0;
563 if (priorUnrestricted) return !nextUnrestricted; // universe → restricted = reduce
564 if (nextUnrestricted) return false; // restricted → universe = broaden
565 // Both restricted: reduced if any host the prior allowlist admits is no longer
566 // admitted by the new one. Approximate over patterns — prior is covered iff
567 // every prior pattern is covered by some next pattern; anything unprovable
568 // counts as reduced (fail toward revocation).
569 return prior!.some(p => !next!.some(n => domainGlobCovers(n, p)));
570}
572/** Does allowlist pattern `wide` admit every host that `narrow` admits? Mirrors
573 * matchDomainGlob's suffix/exact rules. */
574function domainGlobCovers(wide: string, narrow: string): boolean {
575 if (wide === narrow) return true;
576 const wideGlob = wide.startsWith('*.');
577 if (wideGlob) {
578 const wideSuffix = wide.slice(1); // ".example.com"
579 const wideApex = wide.slice(2); // "example.com"
580 if (!narrow.startsWith('*.')) {
581 // narrow is an exact host; covered iff the wide glob matches it.
582 return narrow === wideApex || narrow.endsWith(wideSuffix);
583 }
584 // narrow is also a glob; its apex must fall under the wide suffix.
585 const narrowApex = narrow.slice(2);
586 return narrowApex === wideApex || narrowApex.endsWith(wideSuffix);
587 }
588 // wide is an exact host: covers only the identical host (handled by === above).
589 return false;
590}
592function rateReduced(prior: number, next: number): boolean {
593 const priorUnlimited = prior <= 0; // 0 = unlimited
594 const nextUnlimited = next <= 0;
595 if (priorUnlimited) return !nextUnlimited; // unlimited → capped = reduce
596 if (nextUnlimited) return false; // capped → unlimited = broaden
597 return next < prior; // both capped: a lower cap = reduce
598}
600function tabPolicyReduced(prior: 'own-only' | 'shared', next: 'own-only' | 'shared'): boolean {
601 return prior === 'shared' && next === 'own-only';
602}
604/**
605 * Rotate the root token. All scoped tokens are invalidated.
606 * Returns the new root token.
607 */
608export function rotateRoot(): string {
609 rootToken = crypto.randomUUID();
610 tokens.clear();
611 rateBuckets.clear();
612 return rootToken;
613}
615/**
616 * List all active (non-expired) scoped tokens.
617 * With includeSetup, unexchanged ("pending") setup keys are listed too —
618 * they are live grants an operator must be able to see and revoke. Spent
619 * keys stay hidden: they are re-exchange bookkeeping for a session that is
620 * already listed.
621 */
622export function listTokens(opts?: { includeSetup?: boolean }): TokenInfo[] {
623 const now = new Date();
624 const result: TokenInfo[] = [];
626 for (const [token, info] of tokens) {
627 if (info.expiresAt && new Date(info.expiresAt) < now) {
628 tokens.delete(token);
629 continue;
630 }
631 if (info.type === 'session') {
632 result.push(info);
633 } else if (opts?.includeSetup && info.type === 'setup' && info.usesRemaining !== 0) {
634 result.push(info);
635 }
636 }
638 return result;
639}
641/**
642 * Serialize the token registry for state file persistence.
643 */
644export function serializeRegistry(): TokenRegistryState {
645 const agents: TokenRegistryState['agents'] = {};
647 for (const info of tokens.values()) {
648 if (info.type === 'session') {
649 const { commandCount, ...rest } = info;
650 agents[info.clientId] = rest;
651 }
652 }
654 return { agents };
655}
657/**
658 * Restore the token registry from persisted state file data.
659 */
660export function restoreRegistry(state: TokenRegistryState): void {
661 tokens.clear();
662 const now = new Date();
664 for (const [clientId, data] of Object.entries(state.agents)) {
665 // Skip expired tokens
666 if (data.expiresAt && new Date(data.expiresAt) < now) continue;
668 // Skip-and-log rather than throw: a hand-edited or corrupt state file must
669 // not brick boot or drop every later valid session. A persisted clientId
670 // 'root' would otherwise inject a token that bypasses all scope checks.
671 try {
672 assertValidClientId(clientId);
673 } catch (err) {
674 console.warn(`[browse] restoreRegistry: skipping invalid clientId ${JSON.stringify(clientId)}: ${err instanceof Error ? err.message : String(err)}`);
675 continue;
676 }
678 tokens.set(data.token, {
679 ...data,
680 clientId,
681 commandCount: 0,
682 });
683 }
684}
686// ─── Connect endpoint rate limiter (flood protection) ─────
687//
688// Global-only cap. Setup keys are 24 random bytes (unbruteforceable), so
689// rate limiting here is not about preventing key guessing. It caps
690// bandwidth, CPU, and log-flood damage from someone who discovered the
691// ngrok URL. A legitimate pair-agent session hits /connect once, so
692// 300/min is 60x that pattern and never hit accidentally. Per-IP tracking
693// was considered and rejected: adds a bounded Map + LRU for defense
694// already adequate at the global layer.
696let connectAttempts: { ts: number }[] = [];
697const CONNECT_RATE_LIMIT = 300; // attempts per minute (~5/sec average)
698const CONNECT_WINDOW_MS = 60000;
700export function checkConnectRateLimit(): boolean {
701 const now = Date.now();
702 connectAttempts = connectAttempts.filter(a => now - a.ts < CONNECT_WINDOW_MS);
703 if (connectAttempts.length >= CONNECT_RATE_LIMIT) return false;
704 connectAttempts.push({ ts: now });
705 return true;
706}
708// Test-only reset.
709export function __resetConnectRateLimit(): void {
710 connectAttempts = [];
711}
713// Test-only reset. Zeroes the registry so a subsequent initRegistry call
714// always succeeds. Mirrors __resetConnectRateLimit. Needed by tests that
715// follow the rotateRoot() pattern — rotateRoot leaves rootToken non-empty,
716// which would otherwise trip the initRegistry mismatch guard.
717export function __resetRegistry(): void {
718 rootToken = '';
719 tokens.clear();
720 rateBuckets.clear();
721}