agent-yes 1.236.0 → 1.237.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/{JsonlStore-CWOA3aDH.js → JsonlStore-CPGIR0Wy.js} +1 -1
  2. package/dist/SUPPORTED_CLIS-C0RwjYrz.js +11 -0
  3. package/dist/{SUPPORTED_CLIS-C7bkkXue.js → SUPPORTED_CLIS-CDrJz9Xo.js} +2 -2
  4. package/dist/{agent-yes.config-BhjzWavn.js → agent-yes.config-DPtMM0S-.js} +2 -2
  5. package/dist/{agentShare-BxAaZ-UR.js → agentShare-Cl_J3dfx.js} +9 -9
  6. package/dist/{agentYesHome-CtHb5b71.js → agentYesHome-CtmsY6I-.js} +1 -1
  7. package/dist/browser-B3dOptjL.js +311 -0
  8. package/dist/browser-BRMyG4JA.js +13514 -0
  9. package/dist/callback-CFbkvqTh.js +12 -0
  10. package/dist/{callback-B1AWp2tC.js → callback-D0_AeyN1.js} +6 -6
  11. package/dist/{callbackCore-CcbVa8r2.js → callbackCore-CvmGSvrj.js} +1 -1
  12. package/dist/{callbackCore-Me97qn68.js → callbackCore-DAYcemCC.js} +1 -1
  13. package/dist/{channels-Lsot9pyU.js → channels-BiGUYTuz.js} +2 -2
  14. package/dist/{channels-DHXLnU4r.js → channels-CMNMFJlY.js} +6 -6
  15. package/dist/channels.js +6 -310
  16. package/dist/chunk-cZBX9HZv.js +20 -0
  17. package/dist/cli.js +11 -11
  18. package/dist/{configShared-BXz2vi4Q.js → configShared-9wVlbPyC.js} +1 -1
  19. package/dist/{cwdConflictWarn-ArYYtOGy.js → cwdConflictWarn-DWHWlBxS.js} +2 -2
  20. package/dist/{cwdDeprecation-BGX20-Fe.js → cwdDeprecation-Cd8Ykm2t.js} +1 -1
  21. package/dist/{e2e-CYlKpfxa.js → e2e-CEIdJGZ7.js} +1 -1
  22. package/dist/{expose-ByT4ef7C.js → expose-DROTwbyU.js} +1 -1
  23. package/dist/{forkNested-Bai0Q7BL.js → forkNested-DqPisz9d.js} +2 -2
  24. package/dist/{globalPidIndex-Ca4sTr1b.js → globalPidIndex-D9DOSd-c.js} +1 -1
  25. package/dist/index.js +7 -7
  26. package/dist/{messageLog-zVGWLX7w.js → messageLog-C-YIzhRj.js} +1 -1
  27. package/dist/{nodeRuntime-fVRbz6w6.js → nodeRuntime-Bf88NPqj.js} +2 -2
  28. package/dist/{notifyDaemon-Cxb0-5a0.js → notifyDaemon-D_8TRVHt.js} +8 -8
  29. package/dist/{openBrowser-DL-EO8Hi.js → openBrowser-ChLcPELv.js} +1 -1
  30. package/dist/{peer-68ZwnxLs.js → peer-B5JKF4BP.js} +3 -3
  31. package/dist/peer-D5lr1Afv.js +4 -0
  32. package/dist/{pidStore-Hjba3K6Q.js → pidStore-6EZd5DUv.js} +4 -4
  33. package/dist/pidStore-bCVS2Ldt.js +6 -0
  34. package/dist/{reaper-iQXyufgp.js → reaper-DP49VCKx.js} +2 -2
  35. package/dist/{reaper-pJpMYKmj.js → reaper-IhkB_RO0.js} +1 -1
  36. package/dist/{remotes-CF1LJLxY.js → remotes-BClMgQZW.js} +3 -3
  37. package/dist/{remotes-BDdfhL6N.js → remotes-Bem6WIyU.js} +3 -3
  38. package/dist/{runningLock-BFk3Mo8K.js → runningLock-DBoONoM-.js} +1 -1
  39. package/dist/{rustBinary-CFSG7it2.js → rustBinary-Bb9qJZ5w.js} +2 -2
  40. package/dist/{schedule-CXHdiUHa.js → schedule-B8Erb3y6.js} +11 -11
  41. package/dist/{serve-DEEwIlVV.js → serve-QejbNTr-.js} +187 -43
  42. package/dist/{setup-QfDtv5tw.js → setup-Dzi5Cc3g.js} +3 -3
  43. package/dist/{share-CDER9qNq.js → share--aHaPSjt.js} +2 -2
  44. package/dist/{share-D4K0JnHg.js → share-B9u8-I3y.js} +2 -2
  45. package/dist/{spawnGate-PwzImpOw.js → spawnGate-Bo_LFgt9.js} +3 -3
  46. package/dist/spawnGate-CuWCd2im.js +5 -0
  47. package/dist/{store-BXJERNbW.js → store-Ck0XRn-K.js} +1 -1
  48. package/dist/{subcommands-_Ci82sb1.js → subcommands-DFr-5FOL.js} +26 -21
  49. package/dist/{subcommands-m2DRpvHt.js → subcommands-ymJWO2iv.js} +7 -7
  50. package/dist/terminal-vNeFNLT8.js +138 -0
  51. package/dist/terminal.js +3 -0
  52. package/dist/{todoAutomation-x6guaQXc.js → todoAutomation-Buo457VO.js} +2 -2
  53. package/dist/{todoCli-CAMVVz9u.js → todoCli-CNNR9Czu.js} +4 -4
  54. package/dist/{tray-CF8xRQqt.js → tray-D5qFaqlS.js} +2 -2
  55. package/dist/{ts-D1FDmhc-.js → ts-Dd1FK7sR.js} +11 -28
  56. package/dist/{versionChecker-DJEtU1JU.js → versionChecker-XfEBINhH.js} +2 -2
  57. package/dist/{webrtcLink-CEYNs0pt.js → webrtcLink-VQhWmKOw.js} +2 -2
  58. package/dist/{webrtcRemote-CNC_bMH6.js → webrtcRemote-DJDtCTpj.js} +3 -3
  59. package/dist/widgets.js +7 -0
  60. package/dist/{workspaceConfig-CuGjX2_R.js → workspaceConfig-B7HaQ-MI.js} +2 -2
  61. package/dist/{ws-C7B4xWQl.js → ws-Dun8gGiV.js} +9 -9
  62. package/package.json +13 -1
  63. package/ts/serve.ts +144 -5
  64. package/ts/subcommands.ts +6 -0
  65. package/ts/termToken.spec.ts +44 -0
  66. package/ts/termToken.ts +85 -0
  67. package/ts/terminal/browser.ts +607 -0
  68. package/ts/terminal.spec.ts +50 -0
  69. package/ts/terminal.ts +245 -0
  70. package/ts/widgets.ts +14 -0
  71. package/dist/SUPPORTED_CLIS-BDXbzEWF.js +0 -11
  72. package/dist/callback-Dtk0wMsl.js +0 -12
  73. package/dist/peer-CX4qQx5U.js +0 -4
  74. package/dist/pidStore-Cp3HekUh.js +0 -6
  75. package/dist/spawnGate-C21TL4f2.js +0 -5
package/ts/serve.ts CHANGED
@@ -39,6 +39,7 @@ import {
39
39
  } from "./subcommands.ts";
40
40
  import { answerAsk, listAsks } from "./askApi.ts";
41
41
  import { TYPING_BADGE } from "./badges.ts";
42
+ import { isTermToken, mintTermToken, verifyTermToken, type TermScope } from "./termToken.ts";
42
43
  import { isCallbackRevoked, loadCallbackSecretReadOnly } from "./callback.ts";
43
44
  import { CLAUDE_SESSION_PIN_ENV } from "./sessionEnv.ts";
44
45
  import { MAX_CALLBACK_MSG_BYTES, frameVisitorMessage, verifyCapability } from "./callbackCore.ts";
@@ -217,6 +218,122 @@ function checkAuth(req: Request, expectedToken: string): boolean {
217
218
  return q ? tokenEqual(q, expectedToken) : false;
218
219
  }
219
220
 
221
+ // Authorization result: the master token grants everything (as before); a scoped
222
+ // terminal-embed token (termToken.ts) grants a NARROW capability confined to one
223
+ // agent by scopedGate() below.
224
+ type AuthResult = { kind: "master" } | { kind: "scoped"; scope: TermScope };
225
+
226
+ function authorizeRequest(req: Request, expectedToken: string): AuthResult | null {
227
+ const auth = req.headers.get("authorization") ?? "";
228
+ const bearer = auth.startsWith("Bearer ") ? auth.slice(7) : null;
229
+ const provided = bearer ?? new URL(req.url).searchParams.get("token") ?? "";
230
+ if (!provided) return null;
231
+ if (tokenEqual(provided, expectedToken)) return { kind: "master" };
232
+ // A scoped token is verified against the master token (stateless HMAC).
233
+ if (isTermToken(provided)) {
234
+ const scope = verifyTermToken(expectedToken, provided);
235
+ if (scope) return { kind: "scoped", scope };
236
+ }
237
+ return null;
238
+ }
239
+
240
+ // The embeddable terminal routes. Token-gated (master OR scoped) but CORS-open so
241
+ // a cross-origin report page can reach the daemon — the token is still required to
242
+ // get a 200, and no cookies are involved, so `*` leaks nothing.
243
+ function isTermCorsPath(p: string): boolean {
244
+ return /^\/api\/(tail|size)\/.+$/.test(p) || p === "/api/send";
245
+ }
246
+
247
+ function withTermCors(req: Request, res: Response | undefined): Response | undefined {
248
+ if (!res) return res; // mux upgrade returns undefined
249
+ if (isTermCorsPath(new URL(req.url).pathname)) {
250
+ try {
251
+ res.headers.set("Access-Control-Allow-Origin", "*");
252
+ res.headers.set("Vary", "Origin");
253
+ } catch {
254
+ /* immutable headers — skip */
255
+ }
256
+ }
257
+ return res;
258
+ }
259
+
260
+ function termCorsPreflight(): Response {
261
+ return new Response(null, {
262
+ status: 204,
263
+ headers: {
264
+ "Access-Control-Allow-Origin": "*",
265
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
266
+ "Access-Control-Allow-Headers": "content-type, authorization",
267
+ "Access-Control-Max-Age": "600",
268
+ },
269
+ });
270
+ }
271
+
272
+ /** Does `keyword` resolve to the exact pid a scoped token is bound to? */
273
+ async function scopedKeywordOk(keyword: string, scope: TermScope): Promise<boolean> {
274
+ try {
275
+ const rec = await resolveOne(keyword, defaultOpts({ all: true }));
276
+ return String(rec.pid) === String(scope.pid);
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ /**
283
+ * Confine a scoped token to its capability. Returns a 403 Response when the
284
+ * request is outside the token's grant, or null when it's allowed to proceed.
285
+ * Allowed: GET /api/tail|size/<kw> for the bound pid; POST /api/send for the bound
286
+ * pid IFF the token is interactive (canSend). Everything else is denied.
287
+ */
288
+ async function scopedGate(
289
+ scope: TermScope,
290
+ method: string,
291
+ p: string,
292
+ ): Promise<Response | null> {
293
+ const forbid = (m: string) => new Response(m, { status: 403 });
294
+ const m = /^\/api\/(?:tail|size)\/(.+)$/.exec(p);
295
+ if (method === "GET" && m) {
296
+ return (await scopedKeywordOk(decodeURIComponent(m[1]!), scope))
297
+ ? null
298
+ : forbid("scoped token: not bound to this agent");
299
+ }
300
+ if (method === "POST" && p === "/api/send") {
301
+ if (!scope.canSend) return forbid("scoped token is read-only");
302
+ return null; // pid binding enforced in the /api/send handler (it has the body)
303
+ }
304
+ // Interactive viewers may renegotiate the agent's PTY size (drag-to-resize) —
305
+ // send-capable scope only, bound pid. The keyword is in the path, so bind here.
306
+ const rz = /^\/api\/resize\/(.+)$/.exec(p);
307
+ if (method === "POST" && rz) {
308
+ if (!scope.canSend) return forbid("scoped token is read-only");
309
+ return (await scopedKeywordOk(decodeURIComponent(rz[1]!), scope))
310
+ ? null
311
+ : forbid("scoped token: not bound to this agent");
312
+ }
313
+ return forbid("scoped token: route not permitted");
314
+ }
315
+
316
+ /**
317
+ * Mint a scoped terminal-embed token locally (no daemon round-trip): read the
318
+ * master token file, resolve the keyword to a concrete pid, and sign. Used by
319
+ * `ay term mint`. Throws if there's no serve token or the agent can't be resolved.
320
+ */
321
+ export async function mintScopedTermToken(
322
+ keyword: string,
323
+ opts: { ttlSec: number; canSend: boolean },
324
+ ): Promise<{ token: string; pid: string; exp: number; canSend: boolean }> {
325
+ const master = await loadTokenReadOnly();
326
+ if (!master)
327
+ throw new Error(
328
+ "no serve token at ~/.agent-yes/.serve-token — run `ay serve` once on this host to create it",
329
+ );
330
+ const rec = await resolveOne(keyword, defaultOpts({ all: true }));
331
+ const pid = String(rec.pid);
332
+ const exp = Math.floor(Date.now() / 1000) + Math.max(1, Math.floor(opts.ttlSec));
333
+ const token = mintTermToken(master, { pid, canSend: opts.canSend, exp });
334
+ return { token, pid, exp, canSend: opts.canSend };
335
+ }
336
+
220
337
  const defaultOpts = (overrides: Partial<CommonOpts> = {}): CommonOpts => ({
221
338
  all: false,
222
339
  active: false,
@@ -2019,13 +2136,24 @@ export async function cmdServe(rest: string[]): Promise<number> {
2019
2136
  req: Request,
2020
2137
  srv?: { upgrade: (r: Request, o?: unknown) => boolean },
2021
2138
  ): Promise<Response> => {
2022
- if (!checkAuth(req, token)) {
2023
- return new Response("Unauthorized", { status: 401 });
2024
- }
2025
-
2026
2139
  const url = new URL(req.url);
2027
2140
  const p = url.pathname;
2028
2141
 
2142
+ // CORS preflight for the embeddable terminal routes carries no auth header —
2143
+ // answer it before the auth gate.
2144
+ if (req.method === "OPTIONS" && isTermCorsPath(p)) return termCorsPreflight();
2145
+
2146
+ const authResult = authorizeRequest(req, token);
2147
+ if (!authResult) {
2148
+ return withTermCors(req, new Response("Unauthorized", { status: 401 }))!;
2149
+ }
2150
+ // A scoped token is confined to its bound agent + capability; the master token
2151
+ // is unrestricted (existing behavior).
2152
+ if (authResult.kind === "scoped") {
2153
+ const denied = await scopedGate(authResult.scope, req.method, p);
2154
+ if (denied) return withTermCors(req, denied)!;
2155
+ }
2156
+
2029
2157
  // GET /api/mux — WebSocket request multiplexer. HTTP/1.1 gives a browser
2030
2158
  // ~6 concurrent same-origin connections and no multiplexing, so console
2031
2159
  // bursts (a palette query fans out ~20 tail reads + a history search)
@@ -2727,6 +2855,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
2727
2855
  pid: record.pid,
2728
2856
  cols: size?.cols ?? null,
2729
2857
  rows: size?.rows ?? null,
2858
+ // Enough identity for a widget's title bar without exposing /api/ls to a
2859
+ // scoped token (it's bound to this pid anyway — no extra info leaked).
2860
+ cwd: record.cwd,
2861
+ cli: record.cli,
2730
2862
  // This host negotiates the PTY size from viewer capacities (presence
2731
2863
  // `cap`) — a capable console should report its cap instead of pushing
2732
2864
  // /api/resize directly, so viewers stop fighting last-writer-wins.
@@ -2936,6 +3068,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
2936
3068
  if (!keyword || typeof keyword !== "string") {
2937
3069
  return new Response("missing keyword", { status: 400 });
2938
3070
  }
3071
+ // A scoped interactive token may only write to the agent it's bound to
3072
+ // (the gate already confirmed canSend; bind the pid now that we have the body).
3073
+ if (authResult.kind === "scoped" && !(await scopedKeywordOk(keyword, authResult.scope))) {
3074
+ return new Response("scoped token: not bound to this agent", { status: 403 });
3075
+ }
2939
3076
  try {
2940
3077
  const record = await resolveOne(keyword, defaultOpts());
2941
3078
  if (!record.fifo_file)
@@ -3804,7 +3941,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
3804
3941
  if (req.method === "GET" && p === "/icon.svg") return serveUiFile("icon.svg", "image/svg+xml");
3805
3942
  if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
3806
3943
  if (p.startsWith("/cb/")) return handleCallback(req, p);
3807
- return apiFetch(req, srv);
3944
+ // CORS-open the embeddable terminal routes (tail/size/send) so a cross-origin
3945
+ // report page can reach this daemon; the token is still required for a 200.
3946
+ return withTermCors(req, await apiFetch(req, srv)) as Response;
3808
3947
  };
3809
3948
 
3810
3949
  const serverOpts: any = {
package/ts/subcommands.ts CHANGED
@@ -365,6 +365,7 @@ const SUBCOMMANDS = new Set([
365
365
  "todo",
366
366
  "ch",
367
367
  "channels",
368
+ "term",
368
369
  "serve",
369
370
  "schedule",
370
371
  "remote",
@@ -486,6 +487,10 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
486
487
  const { cmdCh } = await import("./channels.ts");
487
488
  return cmdCh(rest);
488
489
  }
490
+ case "term": {
491
+ const { cmdTerm } = await import("./terminal.ts");
492
+ return cmdTerm(rest);
493
+ }
489
494
  case "serve": {
490
495
  const { cmdServe } = await import("./serve.ts");
491
496
  return cmdServe(rest);
@@ -622,6 +627,7 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
622
627
  ` ay send <keyword> <msg> send a message (keyword '.' = agent in this cwd)\n` +
623
628
  ` ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n` +
624
629
  ` ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n` +
630
+ ` ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n` +
625
631
  ` ay key <keyword> <key...> send raw keystrokes (down/up/enter/esc/…) — drives menus\n` +
626
632
  ` ay select <keyword> <N> pick option N of a needs_input selection menu\n` +
627
633
  ` ay attach <keyword> interactive attach (detach: Ctrl-\\)\n` +
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isTermToken, mintTermToken, verifyTermToken } from "./termToken.ts";
3
+
4
+ const MASTER = "master-token-abc123";
5
+ const now = 1_000_000; // fixed epoch seconds for deterministic exp checks
6
+
7
+ describe("termToken", () => {
8
+ it("round-trips a valid read-only token", () => {
9
+ const tok = mintTermToken(MASTER, { pid: "4242", canSend: false, exp: now + 900 });
10
+ expect(isTermToken(tok)).toBe(true);
11
+ const scope = verifyTermToken(MASTER, tok, now);
12
+ expect(scope).toEqual({ pid: "4242", canSend: false, exp: now + 900 });
13
+ });
14
+
15
+ it("round-trips an interactive token (canSend true)", () => {
16
+ const tok = mintTermToken(MASTER, { pid: "7", canSend: true, exp: now + 60 });
17
+ expect(verifyTermToken(MASTER, tok, now)?.canSend).toBe(true);
18
+ });
19
+
20
+ it("rejects a token signed with a different master (forgery)", () => {
21
+ const tok = mintTermToken(MASTER, { pid: "7", canSend: true, exp: now + 60 });
22
+ expect(verifyTermToken("other-master", tok, now)).toBeNull();
23
+ });
24
+
25
+ it("rejects an expired token", () => {
26
+ const tok = mintTermToken(MASTER, { pid: "7", canSend: false, exp: now - 1 });
27
+ expect(verifyTermToken(MASTER, tok, now)).toBeNull();
28
+ });
29
+
30
+ it("rejects a tampered payload (pid swap) — signature no longer matches", () => {
31
+ const tok = mintTermToken(MASTER, { pid: "7", canSend: false, exp: now + 60 });
32
+ const [prefix, , sig] = tok.split(".");
33
+ const evil = Buffer.from(JSON.stringify({ p: "9999", w: 1, x: now + 60 })).toString("base64url");
34
+ expect(verifyTermToken(MASTER, `${prefix}.${evil}.${sig}`, now)).toBeNull();
35
+ });
36
+
37
+ it("rejects the master token itself and other non-scoped strings", () => {
38
+ expect(isTermToken(MASTER)).toBe(false);
39
+ expect(verifyTermToken(MASTER, MASTER, now)).toBeNull();
40
+ expect(verifyTermToken(MASTER, "ayt1.only-two-parts", now)).toBeNull();
41
+ expect(verifyTermToken(MASTER, "", now)).toBeNull();
42
+ expect(verifyTermToken(MASTER, undefined, now)).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,85 @@
1
+ // Scoped terminal-embed tokens — a capability you can safely put in a web page.
2
+ //
3
+ // The serve daemon's MASTER token (~/.agent-yes/.serve-token) is full-fleet
4
+ // read+write+spawn = RCE; it must NEVER land in an embed. A scoped token instead
5
+ // grants a NARROW capability: read (and optionally write to) exactly ONE agent
6
+ // session, and only until it expires. It is a stateless HMAC bearer token — the
7
+ // daemon verifies it with the same master token it already holds, so there is no
8
+ // server-side token store to manage or revoke (short TTL is the bound).
9
+ //
10
+ // ayt1.<payloadB64url>.<sigB64url>
11
+ // payload = {p: <pid>, w: 0|1 (write/interactive), x: <exp epoch-seconds>}
12
+ // sig = HMAC-SHA256(keyFor(masterToken), "ayt1.<payloadB64url>")
13
+ //
14
+ // `ay term mint` signs one locally (it can read the master token file); the
15
+ // daemon's checkAuth verifies it and confines the request to the bound pid.
16
+ // (ed25519 per-author signing is a stronger future form; symmetric HMAC already
17
+ // delivers short-TTL + read-only + single-session, which is what embeds need.)
18
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
19
+
20
+ export interface TermScope {
21
+ /** The agent pid this token is bound to (resolved to a concrete pid at mint time). */
22
+ pid: string;
23
+ /** Interactive: may also write to this pid's stdin (/api/send). Read-only when false. */
24
+ canSend: boolean;
25
+ /** Expiry, epoch seconds. */
26
+ exp: number;
27
+ }
28
+
29
+ const PREFIX = "ayt1";
30
+
31
+ /** Dedicated HMAC key derived from the master token (so the token isn't signed with the raw master). */
32
+ function keyFor(masterToken: string): Buffer {
33
+ return createHash("sha256")
34
+ .update("ay/term/token/v1\n" + masterToken)
35
+ .digest();
36
+ }
37
+
38
+ /** True for anything shaped like a scoped terminal token (cheap pre-check before verify). */
39
+ export function isTermToken(tok: unknown): tok is string {
40
+ return typeof tok === "string" && tok.startsWith(PREFIX + ".");
41
+ }
42
+
43
+ /** Mint a scoped token bound to `scope.pid`, signed with the master token. */
44
+ export function mintTermToken(masterToken: string, scope: TermScope): string {
45
+ const payload = Buffer.from(
46
+ JSON.stringify({ p: scope.pid, w: scope.canSend ? 1 : 0, x: Math.floor(scope.exp) }),
47
+ ).toString("base64url");
48
+ const body = `${PREFIX}.${payload}`;
49
+ const sig = createHmac("sha256", keyFor(masterToken)).update(body).digest().toString("base64url");
50
+ return `${body}.${sig}`;
51
+ }
52
+
53
+ /**
54
+ * Verify a scoped token against the master token. Returns the scope if the
55
+ * signature is valid AND it hasn't expired, else null. Fail-closed on any
56
+ * malformed input. `nowSec` is injectable for tests.
57
+ */
58
+ export function verifyTermToken(
59
+ masterToken: string,
60
+ tok: unknown,
61
+ nowSec: number = Math.floor(Date.now() / 1000),
62
+ ): TermScope | null {
63
+ if (!isTermToken(tok)) return null;
64
+ const parts = tok.split(".");
65
+ if (parts.length !== 3) return null;
66
+ const body = `${parts[0]}.${parts[1]}`;
67
+ const expected = createHmac("sha256", keyFor(masterToken)).update(body).digest();
68
+ let got: Buffer;
69
+ try {
70
+ got = Buffer.from(parts[2]!, "base64url");
71
+ } catch {
72
+ return null;
73
+ }
74
+ // constant-time compare; length check first (timingSafeEqual throws on mismatch)
75
+ if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
76
+ let obj: { p?: unknown; w?: unknown; x?: unknown };
77
+ try {
78
+ obj = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf-8"));
79
+ } catch {
80
+ return null;
81
+ }
82
+ if (typeof obj.p !== "string" || !obj.p) return null;
83
+ if (typeof obj.x !== "number" || !Number.isFinite(obj.x) || obj.x <= nowSec) return null; // expired
84
+ return { pid: obj.p, canSend: obj.w === 1, exp: obj.x };
85
+ }