@vellumai/vellum-gateway 0.7.0 → 0.7.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 (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
@@ -1,455 +0,0 @@
1
- /**
2
- * Route handler for `POST /v1/browser-extension-pair`.
3
- *
4
- * Mints a short-lived, scoped `host_browser_command` capability token for a
5
- * chrome extension running on the same machine as the gateway.
6
- *
7
- * Security properties:
8
- * - **Localhost-only**: enforced by both the TCP peer IP (via
9
- * `server.requestIP`) and the `Host` header. Non-localhost callers
10
- * receive a 403.
11
- * - **Browser-origin rejection**: if an `Origin` header is present it
12
- * must be either empty or explicitly on the
13
- * `getAllowedExtensionOrigins()` allowlist. This defends against a
14
- * malicious web page in another tab issuing a cross-origin POST from
15
- * the user's browser — such a request would carry the page's origin
16
- * and would be rejected here even if it somehow reached loopback.
17
- * - **Strict rate limiting**: a dedicated per-peer sliding-window
18
- * limiter caps pair requests at 10/minute per peer IP.
19
- * - **Audit logs on denial**: every rejected request emits a structured
20
- * warn log.
21
- * - **Origin allowlist**: the body must include `extensionOrigin`
22
- * matching an allowlist of known Vellum chrome extension ids.
23
- *
24
- * Request body: `{ extensionOrigin: string }` (also accepts the legacy
25
- * `{ origin: string }` for backwards compatibility).
26
- * Response body: `{ token, expiresAt, guardianId }` — `expiresAt` is an
27
- * ISO 8601 timestamp string.
28
- */
29
-
30
- import { readFileSync } from "node:fs";
31
- import { join } from "node:path";
32
-
33
- import { mintHostBrowserCapability } from "../../auth/capability-tokens.js";
34
- import { assistantDbQuery } from "../../db/assistant-db-proxy.js";
35
- import { getLogger } from "../../logger.js";
36
- import { getGatewaySecurityDir } from "../../paths.js";
37
- import { isLoopbackAddress } from "../../util/is-loopback-address.js";
38
-
39
- const log = getLogger("browser-extension-pair");
40
-
41
- // ---------------------------------------------------------------------------
42
- // Constants
43
- // ---------------------------------------------------------------------------
44
-
45
- const BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS = 10;
46
- const BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS = 60_000;
47
-
48
- const BROWSER_EXTENSION_PAIR_EXTENSION_ID_REGEX = /^[a-p]{32}$/;
49
-
50
- /** Daemon's internal assistant scope identifier. */
51
- const BROWSER_EXTENSION_PAIR_DAEMON_INTERNAL_ASSISTANT_ID = "self";
52
-
53
- // ---------------------------------------------------------------------------
54
- // Rate limiter (dedicated, per-peer)
55
- // ---------------------------------------------------------------------------
56
-
57
- interface RateLimitEntry {
58
- timestamps: number[];
59
- }
60
-
61
- const rateLimitMap = new Map<string, RateLimitEntry>();
62
-
63
- function checkRateLimit(peerIp: string): {
64
- allowed: boolean;
65
- limit: number;
66
- resetAt: number;
67
- } {
68
- const now = Date.now();
69
- const windowStart = now - BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS;
70
-
71
- let entry = rateLimitMap.get(peerIp);
72
- if (!entry) {
73
- entry = { timestamps: [] };
74
- rateLimitMap.set(peerIp, entry);
75
- }
76
-
77
- // Prune timestamps outside the window
78
- entry.timestamps = entry.timestamps.filter((t) => t > windowStart);
79
-
80
- if (
81
- entry.timestamps.length >= BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS
82
- ) {
83
- const oldestInWindow = entry.timestamps[0] ?? now;
84
- const resetAt = Math.ceil(
85
- (oldestInWindow + BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS) / 1000,
86
- );
87
- return {
88
- allowed: false,
89
- limit: BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS,
90
- resetAt,
91
- };
92
- }
93
-
94
- entry.timestamps.push(now);
95
- return {
96
- allowed: true,
97
- limit: BROWSER_EXTENSION_PAIR_RATE_LIMIT_MAX_REQUESTS,
98
- resetAt: Math.ceil(
99
- (now + BROWSER_EXTENSION_PAIR_RATE_LIMIT_WINDOW_MS) / 1000,
100
- ),
101
- };
102
- }
103
-
104
- /** Test helper: clear the rate limiter state. */
105
- export function resetBrowserExtensionPairRateLimiterForTests(): void {
106
- rateLimitMap.clear();
107
- }
108
-
109
- // ---------------------------------------------------------------------------
110
- // Chrome extension allowlist
111
- // ---------------------------------------------------------------------------
112
-
113
- type ChromeExtensionAllowlistConfig = {
114
- version: number;
115
- allowedExtensionIds: string[];
116
- };
117
-
118
- function parseAllowedExtensionIds(
119
- value: unknown,
120
- opts: { allowEmpty?: boolean } = {},
121
- ): string[] {
122
- if (!Array.isArray(value)) {
123
- throw new Error("allowedExtensionIds is not an array");
124
- }
125
- const ids = value
126
- .filter((id): id is string => typeof id === "string")
127
- .filter((id) => BROWSER_EXTENSION_PAIR_EXTENSION_ID_REGEX.test(id));
128
- if (ids.length === 0 && !opts.allowEmpty) {
129
- throw new Error("allowedExtensionIds has no valid extension ids");
130
- }
131
- return ids;
132
- }
133
-
134
- function loadAllowedExtensionIdsFromEnv(): string[] {
135
- const raw =
136
- process.env.VELLUM_CHROME_EXTENSION_IDS ??
137
- process.env.VELLUM_CHROME_EXTENSION_ID;
138
- if (!raw) return [];
139
- const ids = raw
140
- .split(/[,\s]+/)
141
- .map((id) => id.trim())
142
- .filter((id) => id.length > 0)
143
- .filter((id) => BROWSER_EXTENSION_PAIR_EXTENSION_ID_REGEX.test(id));
144
- return Array.from(new Set(ids));
145
- }
146
-
147
- function readIdsFromFile(
148
- path: string,
149
- opts: { allowEmpty?: boolean } = {},
150
- ): string[] {
151
- const raw = readFileSync(path, "utf8");
152
- const parsed = JSON.parse(raw) as Partial<ChromeExtensionAllowlistConfig>;
153
- return parseAllowedExtensionIds(parsed.allowedExtensionIds, opts);
154
- }
155
-
156
- function loadAllowedExtensionOrigins(): ReadonlySet<string> {
157
- const merged = new Set<string>();
158
- const loadErrors: string[] = [];
159
-
160
- // 1. Local override in $GATEWAY_SECURITY_DIR. Optional — a missing file
161
- // is not an error.
162
- const localOverridePath = join(
163
- getGatewaySecurityDir(),
164
- "chrome-extension-allowlist.local.json",
165
- );
166
- try {
167
- for (const id of readIdsFromFile(localOverridePath, {
168
- allowEmpty: true,
169
- })) {
170
- merged.add(`chrome-extension://${id}/`);
171
- }
172
- } catch (err) {
173
- const isMissing =
174
- err instanceof Error &&
175
- "code" in err &&
176
- (err as NodeJS.ErrnoException).code === "ENOENT";
177
- if (!isMissing) {
178
- const detail = err instanceof Error ? err.message : String(err);
179
- loadErrors.push(`${localOverridePath}: ${detail}`);
180
- }
181
- }
182
-
183
- // 2. Env-var fallback. Compiled Bun binaries run from a virtual FS root
184
- // so file-based config paths can disappear in packaged builds;
185
- // `VELLUM_CHROME_EXTENSION_IDS` is set at compile time for that case.
186
- for (const id of loadAllowedExtensionIdsFromEnv()) {
187
- merged.add(`chrome-extension://${id}/`);
188
- }
189
-
190
- if (merged.size === 0) {
191
- log.error(
192
- {
193
- localOverridePath,
194
- loadErrors,
195
- },
196
- "Failed to load Chrome extension allowlist from any source; pairing will reject all origins",
197
- );
198
- } else if (loadErrors.length > 0) {
199
- log.warn(
200
- { loadErrors },
201
- "Chrome extension allowlist load errors — using env vars only",
202
- );
203
- }
204
-
205
- return merged;
206
- }
207
-
208
- let _allowedExtensionOrigins: ReadonlySet<string> | null = null;
209
-
210
- export function getAllowedExtensionOrigins(): ReadonlySet<string> {
211
- if (!_allowedExtensionOrigins) {
212
- _allowedExtensionOrigins = loadAllowedExtensionOrigins();
213
- }
214
- return _allowedExtensionOrigins;
215
- }
216
-
217
- /** Test helper: clear the cached allowlist. */
218
- export function resetAllowedExtensionOriginsForTests(): void {
219
- _allowedExtensionOrigins = null;
220
- }
221
-
222
- // ---------------------------------------------------------------------------
223
- // Host header parsing
224
- // ---------------------------------------------------------------------------
225
-
226
- /**
227
- * Parse an HTTP `Host` header value and extract the hostname portion.
228
- */
229
- export function parseHostHeader(raw: string): string | null {
230
- if (raw.length === 0) return null;
231
- if (raw.startsWith("[")) {
232
- const end = raw.indexOf("]");
233
- if (end < 0) return null;
234
- const after = raw.substring(end + 1);
235
- if (after.length > 0 && !after.startsWith(":")) return null;
236
- return raw.substring(1, end);
237
- }
238
- const firstColon = raw.indexOf(":");
239
- if (firstColon < 0) return raw;
240
- const secondColon = raw.indexOf(":", firstColon + 1);
241
- if (secondColon >= 0) {
242
- return raw;
243
- }
244
- return raw.substring(0, firstColon);
245
- }
246
-
247
- function isLoopbackHostHeader(host: string | null): boolean {
248
- if (!host) return true;
249
- const parsed = parseHostHeader(host);
250
- if (parsed === null) return false;
251
- const hostname = parsed.toLowerCase();
252
- if (hostname === "localhost") return true;
253
- return isLoopbackAddress(hostname);
254
- }
255
-
256
- // ---------------------------------------------------------------------------
257
- // Guardian resolution
258
- // ---------------------------------------------------------------------------
259
-
260
- interface GuardianLookupRow {
261
- principal_id: string | null;
262
- }
263
-
264
- async function resolveLocalGuardianId(): Promise<string> {
265
- try {
266
- const rows = await assistantDbQuery<GuardianLookupRow>(
267
- `SELECT c.principal_id
268
- FROM contacts c
269
- INNER JOIN contact_channels cc ON cc.contact_id = c.id
270
- WHERE c.role = 'guardian'
271
- AND cc.type = 'vellum'
272
- AND cc.status = 'active'
273
- ORDER BY cc.verified_at DESC
274
- LIMIT 1`,
275
- );
276
- if (rows[0]?.principal_id) {
277
- return rows[0].principal_id;
278
- }
279
- } catch (err) {
280
- log.warn(
281
- { err },
282
- "Failed to look up local vellum guardian; falling back to 'local'",
283
- );
284
- }
285
- return "local";
286
- }
287
-
288
- // ---------------------------------------------------------------------------
289
- // Audit logging
290
- // ---------------------------------------------------------------------------
291
-
292
- function auditDeny(
293
- req: Request,
294
- peerIp: string,
295
- reason: string,
296
- extra?: Record<string, unknown>,
297
- ): void {
298
- const host = req.headers.get("host");
299
- const origin = req.headers.get("origin");
300
- log.warn(
301
- {
302
- audit: "browser-extension-pair-denied",
303
- peerIp,
304
- host,
305
- origin,
306
- reason,
307
- ...extra,
308
- },
309
- `pair_denied: ${reason}`,
310
- );
311
- }
312
-
313
- // ---------------------------------------------------------------------------
314
- // Handler
315
- // ---------------------------------------------------------------------------
316
-
317
- function errorResponse(
318
- code: string,
319
- message: string,
320
- status: number,
321
- ): Response {
322
- return Response.json({ error: { code, message } }, { status });
323
- }
324
-
325
- function getExternalAssistantId(): string {
326
- return (
327
- process.env.VELLUM_ASSISTANT_NAME?.trim() ||
328
- BROWSER_EXTENSION_PAIR_DAEMON_INTERNAL_ASSISTANT_ID
329
- );
330
- }
331
-
332
- /**
333
- * Handle POST /v1/browser-extension-pair.
334
- *
335
- * `clientIp` is the resolved peer IP from the gateway's `getClientIp()`
336
- * helper (which reads `server.requestIP(req)`).
337
- */
338
- export async function handleBrowserExtensionPair(
339
- req: Request,
340
- clientIp: string,
341
- ): Promise<Response> {
342
- if (req.method !== "POST") {
343
- return new Response("method not allowed", {
344
- status: 405,
345
- headers: { Allow: "POST" },
346
- });
347
- }
348
-
349
- // Enforce localhost-only via peer IP.
350
- if (!clientIp || !isLoopbackAddress(clientIp)) {
351
- auditDeny(req, clientIp, "non_loopback_peer");
352
- return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
353
- }
354
-
355
- // Secondary check: Host header.
356
- const host = req.headers.get("host");
357
- if (!isLoopbackHostHeader(host)) {
358
- auditDeny(req, clientIp, "non_loopback_host_header");
359
- return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
360
- }
361
-
362
- // Reject proxied requests.
363
- if (req.headers.get("x-forwarded-for")) {
364
- auditDeny(req, clientIp, "x_forwarded_for_present");
365
- return errorResponse("FORBIDDEN", "endpoint is local-only", 403);
366
- }
367
-
368
- // Strict rate limit by peer IP.
369
- const rateResult = checkRateLimit(clientIp);
370
- if (!rateResult.allowed) {
371
- auditDeny(req, clientIp, "rate_limited", {
372
- limit: rateResult.limit,
373
- resetAt: rateResult.resetAt,
374
- });
375
- const retryAfter = Math.max(
376
- 1,
377
- rateResult.resetAt - Math.ceil(Date.now() / 1000),
378
- );
379
- return Response.json(
380
- { error: { code: "RATE_LIMITED", message: "too many pair requests" } },
381
- {
382
- status: 429,
383
- headers: {
384
- "Retry-After": String(retryAfter),
385
- "X-RateLimit-Limit": String(rateResult.limit),
386
- "X-RateLimit-Remaining": "0",
387
- "X-RateLimit-Reset": String(rateResult.resetAt),
388
- },
389
- },
390
- );
391
- }
392
-
393
- // Browser-origin rejection.
394
- const originHeader = req.headers.get("origin");
395
- if (originHeader !== null && originHeader.length > 0) {
396
- const withSlash = `${originHeader}/`;
397
- if (
398
- !getAllowedExtensionOrigins().has(originHeader) &&
399
- !getAllowedExtensionOrigins().has(withSlash)
400
- ) {
401
- auditDeny(req, clientIp, "browser_origin_not_allowlisted", {
402
- originHeader,
403
- });
404
- return errorResponse("FORBIDDEN", "origin not allowed", 403);
405
- }
406
- }
407
-
408
- let body: unknown;
409
- try {
410
- body = await req.json();
411
- } catch {
412
- auditDeny(req, clientIp, "invalid_json_body");
413
- return errorResponse("BAD_REQUEST", "invalid JSON body", 400);
414
- }
415
-
416
- if (!body || typeof body !== "object") {
417
- auditDeny(req, clientIp, "body_not_object");
418
- return errorResponse("BAD_REQUEST", "body must be an object", 400);
419
- }
420
-
421
- const raw = body as { extensionOrigin?: unknown; origin?: unknown };
422
- const extensionOrigin =
423
- typeof raw.extensionOrigin === "string" && raw.extensionOrigin.length > 0
424
- ? raw.extensionOrigin
425
- : typeof raw.origin === "string" && raw.origin.length > 0
426
- ? raw.origin
427
- : null;
428
- if (extensionOrigin === null) {
429
- auditDeny(req, clientIp, "missing_extension_origin");
430
- return errorResponse("BAD_REQUEST", "extensionOrigin is required", 400);
431
- }
432
-
433
- if (!getAllowedExtensionOrigins().has(extensionOrigin)) {
434
- auditDeny(req, clientIp, "extension_origin_not_allowlisted", {
435
- extensionOrigin,
436
- });
437
- return errorResponse("FORBIDDEN", "extension origin not allowed", 403);
438
- }
439
-
440
- const guardianId = await resolveLocalGuardianId();
441
- const { token, expiresAt } = mintHostBrowserCapability(guardianId);
442
- const expiresAtIso = new Date(expiresAt).toISOString();
443
-
444
- log.info(
445
- { extensionOrigin, guardianId, expiresAt: expiresAtIso },
446
- "Browser extension paired successfully",
447
- );
448
-
449
- return Response.json({
450
- token,
451
- expiresAt: expiresAtIso,
452
- guardianId,
453
- assistantId: getExternalAssistantId(),
454
- });
455
- }