mercury-agent 0.5.0 → 0.5.2

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 (125) hide show
  1. package/README.md +452 -451
  2. package/container/Dockerfile +127 -127
  3. package/container/Dockerfile.base +109 -109
  4. package/container/Dockerfile.power +17 -17
  5. package/container/agent-package.json +8 -8
  6. package/container/build.sh +54 -54
  7. package/docs/ROADMAP.md +3 -4
  8. package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +71 -0
  9. package/docs/auth/dashboard.md +28 -28
  10. package/docs/auth/overview.md +109 -109
  11. package/docs/auth/whatsapp.md +173 -173
  12. package/docs/authoring-profiles.md +174 -174
  13. package/docs/configuration.md +54 -54
  14. package/docs/container-lifecycle.md +349 -349
  15. package/docs/{bugs/bwrap-privileged-linux-docker.md → debug/major/2026-07-02-bwrap-privileged-linux-docker.md} +21 -1
  16. package/docs/{bugs/e2big-prompt-delivery-fix.md → debug/major/2026-07-02-e2big-prompt-delivery-fix.md} +27 -1
  17. package/docs/{bugs/registry-pull-no-local-fallback.md → debug/major/2026-07-02-registry-pull-no-local-fallback.md} +27 -1
  18. package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +72 -0
  19. package/docs/deployment.md +199 -199
  20. package/docs/extensions.md +375 -375
  21. package/docs/graceful-shutdown.md +62 -62
  22. package/docs/kb-distillation.md +77 -77
  23. package/docs/media/overview.md +140 -140
  24. package/docs/media/whatsapp.md +171 -171
  25. package/docs/memory.md +137 -137
  26. package/docs/permissions.md +217 -217
  27. package/docs/pipeline.md +228 -228
  28. package/docs/prd-chat-memory.md +76 -76
  29. package/docs/prd-config-load.md +82 -82
  30. package/docs/rate-limiting.md +229 -229
  31. package/docs/runbooks/publish-checklist.md +9 -1
  32. package/docs/scheduler.md +288 -288
  33. package/docs/setup-discord.md +100 -100
  34. package/docs/setup-slack.md +119 -119
  35. package/docs/setup-whatsapp.md +94 -94
  36. package/docs/subagents.md +166 -166
  37. package/docs/web-search.md +62 -62
  38. package/examples/extensions/README.md +12 -12
  39. package/examples/extensions/charts/index.ts +13 -13
  40. package/examples/extensions/charts/skill/SKILL.md +98 -98
  41. package/examples/extensions/gws/README.md +52 -52
  42. package/examples/extensions/gws/skill/SKILL.md +57 -57
  43. package/examples/extensions/gws/skill/references/calendar.md +101 -101
  44. package/examples/extensions/gws/skill/references/docs.md +65 -65
  45. package/examples/extensions/gws/skill/references/drive.md +79 -79
  46. package/examples/extensions/gws/skill/references/gmail.md +85 -85
  47. package/examples/extensions/gws/skill/references/sheets.md +60 -60
  48. package/examples/extensions/napkin/skill/SKILL.md +728 -728
  49. package/examples/extensions/pdf/skill/LICENSE.txt +30 -30
  50. package/examples/extensions/pdf/skill/SKILL.md +314 -314
  51. package/examples/extensions/pdf/skill/forms.md +294 -294
  52. package/examples/extensions/pdf/skill/reference.md +611 -611
  53. package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -65
  54. package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -11
  55. package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -33
  56. package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -37
  57. package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -122
  58. package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -115
  59. package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -98
  60. package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -107
  61. package/examples/extensions/permission-guard/index.ts +65 -65
  62. package/examples/extensions/pinchtab/skill/SKILL.md +224 -224
  63. package/examples/extensions/pinchtab/skill/TRUST.md +69 -69
  64. package/examples/extensions/pinchtab/skill/references/api.md +297 -297
  65. package/examples/extensions/pinchtab/skill/references/env.md +45 -45
  66. package/examples/extensions/pinchtab/skill/references/profiles.md +107 -107
  67. package/examples/extensions/tradestation/host/refresh.ts +102 -102
  68. package/examples/extensions/tradestation/index.ts +153 -153
  69. package/examples/extensions/tradestation/skill/SKILL.md +67 -67
  70. package/examples/extensions/voice-synth/index.ts +94 -94
  71. package/examples/extensions/voice-synth/skill/SKILL.md +38 -38
  72. package/examples/extensions/voice-transcribe/requirements.txt +8 -8
  73. package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -179
  74. package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -53
  75. package/examples/extensions/yahoo-mail/cli/package.json +13 -13
  76. package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -78
  77. package/package.json +106 -106
  78. package/resources/agents/explore.md +50 -50
  79. package/resources/agents/worker.md +24 -24
  80. package/resources/connection-env-vars.json +25 -25
  81. package/resources/pi-extensions/subagent/agents.ts +126 -126
  82. package/resources/pi-extensions/subagent/index.ts +964 -964
  83. package/resources/profiles/coding/AGENTS.md +43 -43
  84. package/resources/profiles/coding/mercury-profile.yaml +15 -15
  85. package/resources/profiles/general/AGENTS.md +31 -31
  86. package/resources/profiles/general/mercury-profile.yaml +15 -15
  87. package/resources/profiles/research/AGENTS.md +40 -40
  88. package/resources/profiles/research/mercury-profile.yaml +15 -15
  89. package/resources/skills/config/SKILL.md +25 -25
  90. package/resources/skills/context/SKILL.md +33 -33
  91. package/resources/skills/conversation-recap/SKILL.md +19 -19
  92. package/resources/skills/mutes/SKILL.md +31 -31
  93. package/resources/skills/permissions/SKILL.md +19 -19
  94. package/resources/skills/preferences/SKILL.md +31 -31
  95. package/resources/skills/recall/SKILL.md +24 -24
  96. package/resources/skills/roles/SKILL.md +18 -18
  97. package/resources/skills/spaces/SKILL.md +18 -18
  98. package/resources/skills/tasks/SKILL.md +45 -45
  99. package/resources/templates/AGENTS.md +157 -157
  100. package/resources/templates/env.template +38 -38
  101. package/resources/templates/mercury.example.yaml +99 -99
  102. package/src/agent/container-entry.ts +1 -1
  103. package/src/agent/container-runner.ts +1345 -1346
  104. package/src/cli/mercury.ts +39 -7
  105. package/src/cli/mrctl.ts +636 -636
  106. package/src/config-file.ts +540 -540
  107. package/src/config.ts +339 -339
  108. package/src/core/api.ts +125 -125
  109. package/src/core/caller-token.ts +101 -101
  110. package/src/core/permissions.ts +228 -228
  111. package/src/core/profiles.ts +271 -271
  112. package/src/core/routes/capability.ts +70 -70
  113. package/src/core/routes/index.ts +15 -15
  114. package/src/core/runtime.ts +1530 -1530
  115. package/src/dashboard/index.html +729 -729
  116. package/src/extensions/api.ts +273 -273
  117. package/src/extensions/loader.ts +286 -286
  118. package/src/extensions/types.ts +517 -517
  119. package/src/main.ts +605 -605
  120. package/docs/pending-updates/applicative-profiles.md +0 -15
  121. package/docs/pending-updates/caller-bound-capability-token.md +0 -15
  122. package/docs/pending-updates/dm-auto-space.md +0 -15
  123. /package/docs/archive/{2026-07-01-applicative-profiles.md → applicative-profiles/2026-07-01-applicative-profiles.md} +0 -0
  124. /package/docs/archive/{2026-07-01-caller-bound-capability-token.md → applicative-profiles/2026-07-01-caller-bound-capability-token.md} +0 -0
  125. /package/docs/archive/{2026-07-01-dm-auto-space.md → applicative-profiles/2026-07-01-dm-auto-space.md} +0 -0
package/src/core/api.ts CHANGED
@@ -1,125 +1,125 @@
1
- import { timingSafeEqual } from "node:crypto";
2
- import { Hono } from "hono";
3
- import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
- import { verifyCallerToken } from "./caller-token.js";
5
- import { resolveRole } from "./permissions.js";
6
- import {
7
- capability,
8
- config,
9
- connections,
10
- control,
11
- conversations,
12
- extensions,
13
- media,
14
- messages,
15
- mutes,
16
- permissions,
17
- prefs,
18
- roles,
19
- spaces,
20
- tasks,
21
- tradestation,
22
- tts,
23
- } from "./routes/index.js";
24
-
25
- function safeCompare(a: string, b: string): boolean {
26
- if (a.length !== b.length) return false;
27
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
28
- }
29
-
30
- // ─── App Factory ──────────────────────────────────────────────────────────
31
-
32
- export function createApiApp(apiCtx: ApiContext): Hono<Env> {
33
- const app = new Hono<Env>();
34
-
35
- // ─── Auth Middleware ────────────────────────────────────────────────────
36
-
37
- app.use("*", async (c, next) => {
38
- // Validate API secret when configured
39
- const secret = apiCtx.config.apiSecret;
40
- if (secret) {
41
- const authHeader = c.req.header("authorization");
42
- const token = authHeader?.startsWith("Bearer ")
43
- ? authHeader.slice(7)
44
- : undefined;
45
-
46
- if (!token || !safeCompare(token, secret)) {
47
- return c.json({ error: "Unauthorized" }, 401);
48
- }
49
- }
50
-
51
- // Resolve caller identity. A per-turn caller token (minted host-side at
52
- // container spawn) is authoritative and unspoofable — prefer it over the
53
- // x-mercury-caller / x-mercury-space headers, which any code holding the
54
- // shared API_SECRET could forge. Headers remain the fallback for callers
55
- // that predate tokens (backward compatibility).
56
- let callerId = c.req.header("x-mercury-caller");
57
- let spaceId = c.req.header("x-mercury-space");
58
-
59
- const callerToken = c.req.header("x-mercury-token");
60
- if (callerToken) {
61
- const verified = verifyCallerToken(
62
- callerToken,
63
- apiCtx.config.callerTokenKey,
64
- );
65
- if (!verified) {
66
- return c.json({ error: "Invalid or expired caller token" }, 401);
67
- }
68
- callerId = verified.callerId;
69
- spaceId = verified.spaceId;
70
- }
71
-
72
- if (!callerId || !spaceId) {
73
- return c.json(
74
- { error: "Missing X-Mercury-Caller or X-Mercury-Space headers" },
75
- 400,
76
- );
77
- }
78
-
79
- // Resolve role
80
- const seededAdmins = apiCtx.config.admins
81
- ? apiCtx.config.admins
82
- .split(",")
83
- .map((s) => s.trim())
84
- .filter(Boolean)
85
- : [];
86
-
87
- apiCtx.db.ensureSpace(spaceId);
88
- const role = resolveRole(apiCtx.db, spaceId, callerId, seededAdmins);
89
-
90
- // Store in request context
91
- c.set("auth", { callerId, spaceId, role } as AuthContext);
92
- c.set("apiCtx", apiCtx);
93
- await next();
94
- });
95
-
96
- // ─── Mount Routes ───────────────────────────────────────────────────────
97
-
98
- app.route("/", control);
99
- app.route("/tasks", tasks);
100
- app.route("/config", config);
101
- app.route("/prefs", prefs);
102
- app.route("/roles", roles);
103
- app.route("/permissions", permissions);
104
- app.route("/spaces", spaces);
105
- app.route("/conversations", conversations);
106
- app.route("/media", media);
107
- app.route("/messages", messages);
108
- app.route("/mutes", mutes);
109
- app.route("/ext", extensions);
110
- app.route("/connections", connections);
111
- app.route("/tradestation", tradestation);
112
- app.route("/tts", tts);
113
- app.route("/capability", capability);
114
-
115
- // ─── Fallback ───────────────────────────────────────────────────────────
116
-
117
- app.all("*", (c) => {
118
- return c.json({ error: "Not found" }, 404);
119
- });
120
-
121
- return app;
122
- }
123
-
124
- // Re-export types for convenience
125
- export type { ApiContext, AuthContext, Env } from "./api-types.js";
1
+ import { timingSafeEqual } from "node:crypto";
2
+ import { Hono } from "hono";
3
+ import type { ApiContext, AuthContext, Env } from "./api-types.js";
4
+ import { verifyCallerToken } from "./caller-token.js";
5
+ import { resolveRole } from "./permissions.js";
6
+ import {
7
+ capability,
8
+ config,
9
+ connections,
10
+ control,
11
+ conversations,
12
+ extensions,
13
+ media,
14
+ messages,
15
+ mutes,
16
+ permissions,
17
+ prefs,
18
+ roles,
19
+ spaces,
20
+ tasks,
21
+ tradestation,
22
+ tts,
23
+ } from "./routes/index.js";
24
+
25
+ function safeCompare(a: string, b: string): boolean {
26
+ if (a.length !== b.length) return false;
27
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
28
+ }
29
+
30
+ // ─── App Factory ──────────────────────────────────────────────────────────
31
+
32
+ export function createApiApp(apiCtx: ApiContext): Hono<Env> {
33
+ const app = new Hono<Env>();
34
+
35
+ // ─── Auth Middleware ────────────────────────────────────────────────────
36
+
37
+ app.use("*", async (c, next) => {
38
+ // Validate API secret when configured
39
+ const secret = apiCtx.config.apiSecret;
40
+ if (secret) {
41
+ const authHeader = c.req.header("authorization");
42
+ const token = authHeader?.startsWith("Bearer ")
43
+ ? authHeader.slice(7)
44
+ : undefined;
45
+
46
+ if (!token || !safeCompare(token, secret)) {
47
+ return c.json({ error: "Unauthorized" }, 401);
48
+ }
49
+ }
50
+
51
+ // Resolve caller identity. A per-turn caller token (minted host-side at
52
+ // container spawn) is authoritative and unspoofable — prefer it over the
53
+ // x-mercury-caller / x-mercury-space headers, which any code holding the
54
+ // shared API_SECRET could forge. Headers remain the fallback for callers
55
+ // that predate tokens (backward compatibility).
56
+ let callerId = c.req.header("x-mercury-caller");
57
+ let spaceId = c.req.header("x-mercury-space");
58
+
59
+ const callerToken = c.req.header("x-mercury-token");
60
+ if (callerToken) {
61
+ const verified = verifyCallerToken(
62
+ callerToken,
63
+ apiCtx.config.callerTokenKey,
64
+ );
65
+ if (!verified) {
66
+ return c.json({ error: "Invalid or expired caller token" }, 401);
67
+ }
68
+ callerId = verified.callerId;
69
+ spaceId = verified.spaceId;
70
+ }
71
+
72
+ if (!callerId || !spaceId) {
73
+ return c.json(
74
+ { error: "Missing X-Mercury-Caller or X-Mercury-Space headers" },
75
+ 400,
76
+ );
77
+ }
78
+
79
+ // Resolve role
80
+ const seededAdmins = apiCtx.config.admins
81
+ ? apiCtx.config.admins
82
+ .split(",")
83
+ .map((s) => s.trim())
84
+ .filter(Boolean)
85
+ : [];
86
+
87
+ apiCtx.db.ensureSpace(spaceId);
88
+ const role = resolveRole(apiCtx.db, spaceId, callerId, seededAdmins);
89
+
90
+ // Store in request context
91
+ c.set("auth", { callerId, spaceId, role } as AuthContext);
92
+ c.set("apiCtx", apiCtx);
93
+ await next();
94
+ });
95
+
96
+ // ─── Mount Routes ───────────────────────────────────────────────────────
97
+
98
+ app.route("/", control);
99
+ app.route("/tasks", tasks);
100
+ app.route("/config", config);
101
+ app.route("/prefs", prefs);
102
+ app.route("/roles", roles);
103
+ app.route("/permissions", permissions);
104
+ app.route("/spaces", spaces);
105
+ app.route("/conversations", conversations);
106
+ app.route("/media", media);
107
+ app.route("/messages", messages);
108
+ app.route("/mutes", mutes);
109
+ app.route("/ext", extensions);
110
+ app.route("/connections", connections);
111
+ app.route("/tradestation", tradestation);
112
+ app.route("/tts", tts);
113
+ app.route("/capability", capability);
114
+
115
+ // ─── Fallback ───────────────────────────────────────────────────────────
116
+
117
+ app.all("*", (c) => {
118
+ return c.json({ error: "Not found" }, 404);
119
+ });
120
+
121
+ return app;
122
+ }
123
+
124
+ // Re-export types for convenience
125
+ export type { ApiContext, AuthContext, Env } from "./api-types.js";
@@ -1,101 +1,101 @@
1
- /**
2
- * Caller-bound capability token.
3
- *
4
- * Mercury's in-container CLIs authenticate to the host control-plane API with a
5
- * single shared `API_SECRET` and, historically, assert their identity via the
6
- * spoofable `x-mercury-caller` / `x-mercury-space` headers. Because `API_SECRET`
7
- * is readable inside the container, any code there could claim to be any caller.
8
- *
9
- * This module mints a short-lived, HMAC-signed token at container spawn, bound
10
- * to `{callerId, spaceId}`. The host verifies it and uses the token payload as
11
- * the authoritative identity for authorization — so a container cannot forge a
12
- * different caller.
13
- *
14
- * The signing key NEVER enters a container. When no key is configured
15
- * (`callerTokenKey`), an ephemeral random key is generated once per host process
16
- * — sufficient because tokens are per-turn and short-lived. A configured key is
17
- * only needed when minting and verification happen in separate processes.
18
- */
19
-
20
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
21
-
22
- export interface CallerTokenClaims {
23
- callerId: string;
24
- spaceId: string;
25
- /** Expiry, unix seconds. */
26
- exp: number;
27
- }
28
-
29
- /** Ephemeral host-only key, generated lazily when no key is configured. */
30
- let ephemeralKey: Buffer | null = null;
31
-
32
- function resolveKey(configuredKey?: string): Buffer {
33
- if (configuredKey && configuredKey.length > 0) {
34
- return Buffer.from(configuredKey, "utf8");
35
- }
36
- if (!ephemeralKey) {
37
- ephemeralKey = randomBytes(32);
38
- }
39
- return ephemeralKey;
40
- }
41
-
42
- /** Mint a signed caller token. `configuredKey` falls back to a host ephemeral key. */
43
- export function mintCallerToken(
44
- claims: CallerTokenClaims,
45
- configuredKey?: string,
46
- ): string {
47
- const key = resolveKey(configuredKey);
48
- const payload = Buffer.from(
49
- JSON.stringify({ c: claims.callerId, s: claims.spaceId, exp: claims.exp }),
50
- "utf8",
51
- ).toString("base64url");
52
- const sig = createHmac("sha256", key).update(payload).digest("base64url");
53
- return `${payload}.${sig}`;
54
- }
55
-
56
- /**
57
- * Verify a caller token. Returns the bound identity on success, or null if the
58
- * signature is invalid, the payload is malformed, or the token has expired.
59
- */
60
- export function verifyCallerToken(
61
- token: string,
62
- configuredKey?: string,
63
- nowSeconds?: number,
64
- ): { callerId: string; spaceId: string } | null {
65
- const dot = token.indexOf(".");
66
- if (dot <= 0 || dot === token.length - 1) return null;
67
-
68
- const payload = token.slice(0, dot);
69
- const sig = token.slice(dot + 1);
70
-
71
- const key = resolveKey(configuredKey);
72
- const expected = createHmac("sha256", key)
73
- .update(payload)
74
- .digest("base64url");
75
-
76
- const sigBuf = Buffer.from(sig);
77
- const expBuf = Buffer.from(expected);
78
- if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
79
- return null;
80
- }
81
-
82
- let decoded: { c?: unknown; s?: unknown; exp?: unknown };
83
- try {
84
- decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
85
- } catch {
86
- return null;
87
- }
88
-
89
- if (
90
- typeof decoded.c !== "string" ||
91
- typeof decoded.s !== "string" ||
92
- typeof decoded.exp !== "number"
93
- ) {
94
- return null;
95
- }
96
-
97
- const now = nowSeconds ?? Math.floor(Date.now() / 1000);
98
- if (decoded.exp < now) return null;
99
-
100
- return { callerId: decoded.c, spaceId: decoded.s };
101
- }
1
+ /**
2
+ * Caller-bound capability token.
3
+ *
4
+ * Mercury's in-container CLIs authenticate to the host control-plane API with a
5
+ * single shared `API_SECRET` and, historically, assert their identity via the
6
+ * spoofable `x-mercury-caller` / `x-mercury-space` headers. Because `API_SECRET`
7
+ * is readable inside the container, any code there could claim to be any caller.
8
+ *
9
+ * This module mints a short-lived, HMAC-signed token at container spawn, bound
10
+ * to `{callerId, spaceId}`. The host verifies it and uses the token payload as
11
+ * the authoritative identity for authorization — so a container cannot forge a
12
+ * different caller.
13
+ *
14
+ * The signing key NEVER enters a container. When no key is configured
15
+ * (`callerTokenKey`), an ephemeral random key is generated once per host process
16
+ * — sufficient because tokens are per-turn and short-lived. A configured key is
17
+ * only needed when minting and verification happen in separate processes.
18
+ */
19
+
20
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
21
+
22
+ export interface CallerTokenClaims {
23
+ callerId: string;
24
+ spaceId: string;
25
+ /** Expiry, unix seconds. */
26
+ exp: number;
27
+ }
28
+
29
+ /** Ephemeral host-only key, generated lazily when no key is configured. */
30
+ let ephemeralKey: Buffer | null = null;
31
+
32
+ function resolveKey(configuredKey?: string): Buffer {
33
+ if (configuredKey && configuredKey.length > 0) {
34
+ return Buffer.from(configuredKey, "utf8");
35
+ }
36
+ if (!ephemeralKey) {
37
+ ephemeralKey = randomBytes(32);
38
+ }
39
+ return ephemeralKey;
40
+ }
41
+
42
+ /** Mint a signed caller token. `configuredKey` falls back to a host ephemeral key. */
43
+ export function mintCallerToken(
44
+ claims: CallerTokenClaims,
45
+ configuredKey?: string,
46
+ ): string {
47
+ const key = resolveKey(configuredKey);
48
+ const payload = Buffer.from(
49
+ JSON.stringify({ c: claims.callerId, s: claims.spaceId, exp: claims.exp }),
50
+ "utf8",
51
+ ).toString("base64url");
52
+ const sig = createHmac("sha256", key).update(payload).digest("base64url");
53
+ return `${payload}.${sig}`;
54
+ }
55
+
56
+ /**
57
+ * Verify a caller token. Returns the bound identity on success, or null if the
58
+ * signature is invalid, the payload is malformed, or the token has expired.
59
+ */
60
+ export function verifyCallerToken(
61
+ token: string,
62
+ configuredKey?: string,
63
+ nowSeconds?: number,
64
+ ): { callerId: string; spaceId: string } | null {
65
+ const dot = token.indexOf(".");
66
+ if (dot <= 0 || dot === token.length - 1) return null;
67
+
68
+ const payload = token.slice(0, dot);
69
+ const sig = token.slice(dot + 1);
70
+
71
+ const key = resolveKey(configuredKey);
72
+ const expected = createHmac("sha256", key)
73
+ .update(payload)
74
+ .digest("base64url");
75
+
76
+ const sigBuf = Buffer.from(sig);
77
+ const expBuf = Buffer.from(expected);
78
+ if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) {
79
+ return null;
80
+ }
81
+
82
+ let decoded: { c?: unknown; s?: unknown; exp?: unknown };
83
+ try {
84
+ decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
85
+ } catch {
86
+ return null;
87
+ }
88
+
89
+ if (
90
+ typeof decoded.c !== "string" ||
91
+ typeof decoded.s !== "string" ||
92
+ typeof decoded.exp !== "number"
93
+ ) {
94
+ return null;
95
+ }
96
+
97
+ const now = nowSeconds ?? Math.floor(Date.now() / 1000);
98
+ if (decoded.exp < now) return null;
99
+
100
+ return { callerId: decoded.c, spaceId: decoded.s };
101
+ }