@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,379 +0,0 @@
1
- /**
2
- * Gateway-native pairing route handlers.
3
- *
4
- * Handles the full iOS pairing flow directly in the gateway:
5
- * - POST /pairing/register — macOS pre-registers a pairing request (QR displayed)
6
- * - POST /pairing/request — iOS initiates a pairing handshake
7
- * - GET /pairing/status — iOS polls for approval status
8
- *
9
- * Credentials (JWT access token + opaque refresh token) are minted via
10
- * guardian-bootstrap, which writes directly to the assistant's SQLite
11
- * database with dual-writes to the gateway DB.
12
- */
13
-
14
- import { bootstrapGuardian } from "../auth/guardian-bootstrap.js";
15
- import { mintServiceToken } from "../auth/token-exchange.js";
16
- import { getLogger } from "../logger.js";
17
-
18
- import {
19
- hashDeviceId,
20
- isDeviceApproved,
21
- refreshDevice,
22
- } from "./approved-devices-store.js";
23
- import { PairingStore } from "./pairing-store.js";
24
-
25
- const log = getLogger("pairing-routes");
26
-
27
- // ---------------------------------------------------------------------------
28
- // Credential types
29
- // ---------------------------------------------------------------------------
30
-
31
- interface PairingCredentials {
32
- accessToken: string;
33
- accessTokenExpiresAt: number;
34
- refreshToken: string;
35
- refreshTokenExpiresAt: number;
36
- refreshAfter: number;
37
- }
38
-
39
- // ---------------------------------------------------------------------------
40
- // Credential minting (delegates to gateway's guardian-bootstrap)
41
- // ---------------------------------------------------------------------------
42
-
43
- async function mintPairingCredentials(
44
- deviceId: string,
45
- platform: string,
46
- ): Promise<PairingCredentials | null> {
47
- try {
48
- const result = await bootstrapGuardian({ platform, deviceId });
49
- log.info({ platform }, "Minted credentials during pairing");
50
- return {
51
- accessToken: result.accessToken,
52
- accessTokenExpiresAt: result.accessTokenExpiresAt,
53
- refreshToken: result.refreshToken,
54
- refreshTokenExpiresAt: result.refreshTokenExpiresAt,
55
- refreshAfter: result.refreshAfter,
56
- };
57
- } catch (err) {
58
- log.warn(
59
- { err },
60
- "Failed to mint credentials during pairing — continuing without them",
61
- );
62
- return null;
63
- }
64
- }
65
-
66
- // ---------------------------------------------------------------------------
67
- // Transient in-memory state
68
- // ---------------------------------------------------------------------------
69
-
70
- const PAIRING_PENDING_DEVICE_ID_TTL_MS = 10 * 60 * 1000; // 10 minutes
71
- const pendingDeviceIds = new Map<
72
- string,
73
- { deviceId: string; createdAt: number }
74
- >();
75
-
76
- const PAIRING_CREDENTIAL_RETRIEVAL_TTL_MS = 5 * 60 * 1000; // 5 minutes
77
- const approvedCredentials = new Map<
78
- string,
79
- { credentials: PairingCredentials; approvedAt: number }
80
- >();
81
-
82
- function sweepApprovedCredentials(): void {
83
- const now = Date.now();
84
- for (const [id, entry] of approvedCredentials) {
85
- if (now - entry.approvedAt > PAIRING_CREDENTIAL_RETRIEVAL_TTL_MS) {
86
- approvedCredentials.delete(id);
87
- }
88
- }
89
- }
90
-
91
- function sweepPendingDeviceIds(): void {
92
- const now = Date.now();
93
- for (const [id, entry] of pendingDeviceIds) {
94
- if (now - entry.createdAt > PAIRING_PENDING_DEVICE_ID_TTL_MS) {
95
- pendingDeviceIds.delete(id);
96
- }
97
- }
98
- }
99
-
100
- const mintingInFlight = new Set<string>();
101
-
102
- function cleanupPairingState(pairingRequestId: string): void {
103
- pendingDeviceIds.delete(pairingRequestId);
104
- approvedCredentials.delete(pairingRequestId);
105
- mintingInFlight.delete(pairingRequestId);
106
- }
107
-
108
- // ---------------------------------------------------------------------------
109
- // HTTP helpers
110
- // ---------------------------------------------------------------------------
111
-
112
- const MAX_PAIRING_BODY_BYTES = 4096;
113
-
114
- function jsonError(code: string, message: string, status: number): Response {
115
- return Response.json({ error: { code, message } }, { status });
116
- }
117
-
118
- // ---------------------------------------------------------------------------
119
- // Route handler factory
120
- // ---------------------------------------------------------------------------
121
-
122
- export function createPairingHandler(deps: {
123
- pairingStore: PairingStore;
124
- getBearerToken?: () => string | undefined;
125
- }) {
126
- const { pairingStore } = deps;
127
-
128
- function getOrMintBearerToken(): string | undefined {
129
- if (deps.getBearerToken) {
130
- const token = deps.getBearerToken();
131
- if (token) return token;
132
- }
133
- try {
134
- return mintServiceToken();
135
- } catch {
136
- return undefined;
137
- }
138
- }
139
-
140
- return {
141
- /**
142
- * POST /pairing/register — Bearer-authenticated.
143
- * macOS pre-registers a pairing request when the QR is displayed.
144
- */
145
- async handlePairingRegister(req: Request): Promise<Response> {
146
- try {
147
- const body = (await req.json()) as Record<string, unknown>;
148
- const pairingRequestId =
149
- typeof body.pairingRequestId === "string"
150
- ? body.pairingRequestId
151
- : "";
152
- const pairingSecret =
153
- typeof body.pairingSecret === "string" ? body.pairingSecret : "";
154
- const gatewayUrl =
155
- typeof body.gatewayUrl === "string" ? body.gatewayUrl : "";
156
- const localLanUrl =
157
- typeof body.localLanUrl === "string" ? body.localLanUrl : null;
158
-
159
- if (!pairingRequestId || !pairingSecret || !gatewayUrl) {
160
- return jsonError(
161
- "BAD_REQUEST",
162
- "Missing required fields: pairingRequestId, pairingSecret, gatewayUrl",
163
- 400,
164
- );
165
- }
166
-
167
- const result = pairingStore.register({
168
- pairingRequestId,
169
- pairingSecret,
170
- gatewayUrl,
171
- localLanUrl,
172
- });
173
- if (!result.ok) {
174
- const message =
175
- result.reason === "active_pairing"
176
- ? "A pairing request is already in progress"
177
- : "Conflict: pairingRequestId exists with different secret";
178
- return jsonError("CONFLICT", message, 409);
179
- }
180
-
181
- return Response.json({ ok: true });
182
- } catch (err) {
183
- log.error({ err }, "Failed to register pairing request");
184
- return jsonError("INTERNAL_ERROR", "Internal server error", 500);
185
- }
186
- },
187
-
188
- /**
189
- * POST /pairing/request — Unauthenticated (secret-gated).
190
- * iOS initiates a pairing handshake.
191
- */
192
- async handlePairingRequest(req: Request): Promise<Response> {
193
- try {
194
- const contentLength = req.headers.get("content-length");
195
- if (contentLength && Number(contentLength) > MAX_PAIRING_BODY_BYTES) {
196
- return jsonError("BAD_REQUEST", "Payload too large", 413);
197
- }
198
- const body = (await req.json()) as Record<string, unknown>;
199
- const pairingRequestId =
200
- typeof body.pairingRequestId === "string"
201
- ? body.pairingRequestId
202
- : "";
203
- const pairingSecret =
204
- typeof body.pairingSecret === "string" ? body.pairingSecret : "";
205
- const deviceId =
206
- typeof body.deviceId === "string" ? body.deviceId.trim() : "";
207
- const deviceName =
208
- typeof body.deviceName === "string" ? body.deviceName.trim() : "";
209
-
210
- log.info(
211
- { pairingRequestId, deviceName, hasDeviceId: !!deviceId },
212
- "Pairing request received",
213
- );
214
-
215
- if (!deviceId || !deviceName) {
216
- return jsonError(
217
- "BAD_REQUEST",
218
- "Missing required fields: deviceId, deviceName",
219
- 400,
220
- );
221
- }
222
-
223
- if (!pairingRequestId || !pairingSecret) {
224
- return jsonError(
225
- "BAD_REQUEST",
226
- "Missing required fields: pairingRequestId, pairingSecret",
227
- 400,
228
- );
229
- }
230
-
231
- const result = pairingStore.beginRequest({
232
- pairingRequestId,
233
- pairingSecret,
234
- deviceId,
235
- deviceName,
236
- });
237
- if (!result.ok) {
238
- if (result.reason === "already_paired") {
239
- return jsonError(
240
- "CONFLICT",
241
- "This pairing request is already bound to another device",
242
- 409,
243
- );
244
- }
245
- const statusCode =
246
- result.reason === "invalid_secret"
247
- ? 403
248
- : result.reason === "not_found"
249
- ? 403
250
- : 410;
251
- return jsonError("FORBIDDEN", "Forbidden", statusCode);
252
- }
253
-
254
- const entry = result.entry;
255
- const hashedDeviceId = hashDeviceId(deviceId);
256
- const bearerToken = getOrMintBearerToken();
257
-
258
- // Auto-approve if device is in the allowlist
259
- if (isDeviceApproved(hashedDeviceId) && bearerToken) {
260
- refreshDevice(hashedDeviceId, deviceName);
261
- pairingStore.approve(pairingRequestId, bearerToken);
262
- log.info(
263
- { pairingRequestId, hashedDeviceId },
264
- "Auto-approved allowlisted device",
265
- );
266
- const credentials = await mintPairingCredentials(deviceId, "ios");
267
- return Response.json({
268
- status: "approved",
269
- bearerToken,
270
- gatewayUrl: entry.gatewayUrl,
271
- localLanUrl: entry.localLanUrl,
272
- ...(credentials
273
- ? {
274
- accessToken: credentials.accessToken,
275
- accessTokenExpiresAt: credentials.accessTokenExpiresAt,
276
- refreshToken: credentials.refreshToken,
277
- refreshTokenExpiresAt: credentials.refreshTokenExpiresAt,
278
- refreshAfter: credentials.refreshAfter,
279
- }
280
- : {}),
281
- });
282
- }
283
-
284
- // Store the raw deviceId transiently so we can mint the actor token
285
- // later when the pairing is actually approved.
286
- pendingDeviceIds.set(pairingRequestId, {
287
- deviceId,
288
- createdAt: Date.now(),
289
- });
290
-
291
- return Response.json({ status: "pending" });
292
- } catch (err) {
293
- log.error({ err }, "Failed to process pairing request");
294
- return jsonError("INTERNAL_ERROR", "Internal server error", 500);
295
- }
296
- },
297
-
298
- /**
299
- * GET /pairing/status — Unauthenticated (secret-gated).
300
- * iOS polls for approval status.
301
- */
302
- async handlePairingStatus(req: Request): Promise<Response> {
303
- const url = new URL(req.url);
304
- const id = url.searchParams.get("id") ?? "";
305
- const secret = url.searchParams.get("secret") ?? "";
306
- const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
307
-
308
- if (!id || !secret) {
309
- return jsonError(
310
- "BAD_REQUEST",
311
- "Missing required params: id, secret",
312
- 400,
313
- );
314
- }
315
-
316
- if (!pairingStore.validateSecret(id, secret)) {
317
- return jsonError("FORBIDDEN", "Forbidden", 403);
318
- }
319
-
320
- sweepApprovedCredentials();
321
- sweepPendingDeviceIds();
322
-
323
- const entry = pairingStore.get(id);
324
- if (!entry) {
325
- pendingDeviceIds.delete(id);
326
- return jsonError("NOT_FOUND", "Not found", 404);
327
- }
328
-
329
- if (entry.status === "approved") {
330
- let credentialEntry = approvedCredentials.get(id);
331
- if (!credentialEntry && !mintingInFlight.has(id)) {
332
- const pending = pendingDeviceIds.get(id);
333
- const deviceIdMatchesEntry = Boolean(
334
- deviceId &&
335
- entry.hashedDeviceId &&
336
- hashDeviceId(deviceId) === entry.hashedDeviceId,
337
- );
338
- const mintDeviceId =
339
- pending?.deviceId ?? (deviceIdMatchesEntry ? deviceId : undefined);
340
- if (mintDeviceId) {
341
- mintingInFlight.add(id);
342
- try {
343
- const credentials = await mintPairingCredentials(mintDeviceId, "ios");
344
- if (credentials) {
345
- pendingDeviceIds.delete(id);
346
- credentialEntry = { credentials, approvedAt: Date.now() };
347
- approvedCredentials.set(id, credentialEntry);
348
- }
349
- } finally {
350
- mintingInFlight.delete(id);
351
- }
352
- }
353
- }
354
-
355
- return Response.json({
356
- status: "approved",
357
- bearerToken: entry.bearerToken,
358
- gatewayUrl: entry.gatewayUrl,
359
- localLanUrl: entry.localLanUrl,
360
- ...(credentialEntry
361
- ? {
362
- accessToken: credentialEntry.credentials.accessToken,
363
- accessTokenExpiresAt:
364
- credentialEntry.credentials.accessTokenExpiresAt,
365
- refreshToken: credentialEntry.credentials.refreshToken,
366
- refreshTokenExpiresAt:
367
- credentialEntry.credentials.refreshTokenExpiresAt,
368
- refreshAfter: credentialEntry.credentials.refreshAfter,
369
- }
370
- : {}),
371
- });
372
- }
373
-
374
- return Response.json({ status: entry.status });
375
- },
376
-
377
- cleanupPairingState,
378
- };
379
- }
@@ -1,218 +0,0 @@
1
- /**
2
- * In-memory pairing request store with TTL.
3
- *
4
- * Each pairing request lives for at most TTL_MS (5 minutes) before
5
- * being swept as expired. Status transitions:
6
- * registered → pending → approved | denied | expired
7
- */
8
-
9
- import { createHash, timingSafeEqual } from "node:crypto";
10
-
11
- import { getLogger } from "../logger.js";
12
-
13
- const log = getLogger("pairing-store");
14
-
15
- const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 minutes
16
- const PAIRING_SWEEP_INTERVAL_MS = 30_000; // 30 seconds
17
-
18
- export type PairingStatus =
19
- | "registered"
20
- | "pending"
21
- | "approved"
22
- | "denied"
23
- | "expired";
24
-
25
- export interface PairingRequest {
26
- pairingRequestId: string;
27
- hashedPairingSecret: string;
28
- hashedDeviceId?: string;
29
- deviceName?: string;
30
- status: PairingStatus;
31
- gatewayUrl: string;
32
- localLanUrl: string | null;
33
- bearerToken?: string;
34
- createdAt: number;
35
- }
36
-
37
- function hashValue(value: string): string {
38
- return createHash("sha256").update(value).digest("hex");
39
- }
40
-
41
- function timingSafeCompare(a: string, b: string): boolean {
42
- const bufA = Buffer.from(a);
43
- const bufB = Buffer.from(b);
44
- if (bufA.length !== bufB.length) return false;
45
- return timingSafeEqual(bufA, bufB);
46
- }
47
-
48
- export class PairingStore {
49
- private requests = new Map<string, PairingRequest>();
50
- private sweepTimer: ReturnType<typeof setInterval> | null = null;
51
-
52
- start(): void {
53
- this.sweepTimer = setInterval(
54
- () => this.sweep(),
55
- PAIRING_SWEEP_INTERVAL_MS,
56
- );
57
- }
58
-
59
- stop(): void {
60
- if (this.sweepTimer) {
61
- clearInterval(this.sweepTimer);
62
- this.sweepTimer = null;
63
- }
64
- this.requests.clear();
65
- }
66
-
67
- /**
68
- * Pre-register a pairing request (called when QR is displayed).
69
- * Idempotent: if the same ID exists and secret matches, overwrite.
70
- * Returns false with 'conflict' if ID exists but secret doesn't match,
71
- * or 'active_pairing' if another pairing request is already in progress.
72
- */
73
- register(params: {
74
- pairingRequestId: string;
75
- pairingSecret: string;
76
- gatewayUrl: string;
77
- localLanUrl?: string | null;
78
- }): { ok: true } | { ok: false; reason: "conflict" | "active_pairing" } {
79
- const hashedSecret = hashValue(params.pairingSecret);
80
- const existing = this.requests.get(params.pairingRequestId);
81
-
82
- if (existing) {
83
- if (!timingSafeCompare(existing.hashedPairingSecret, hashedSecret)) {
84
- return { ok: false, reason: "conflict" };
85
- }
86
- }
87
-
88
- // Reject if another pairing request is already active (registered or pending).
89
- for (const entry of this.requests.values()) {
90
- if (entry.pairingRequestId === params.pairingRequestId) continue;
91
- if (entry.status === "registered" || entry.status === "pending") {
92
- log.warn(
93
- {
94
- existingPairingRequestId: entry.pairingRequestId,
95
- newPairingRequestId: params.pairingRequestId,
96
- },
97
- "Rejected pairing registration — another pairing is already in progress",
98
- );
99
- return { ok: false, reason: "active_pairing" };
100
- }
101
- }
102
-
103
- this.requests.set(params.pairingRequestId, {
104
- pairingRequestId: params.pairingRequestId,
105
- hashedPairingSecret: hashedSecret,
106
- status: "registered",
107
- gatewayUrl: params.gatewayUrl,
108
- localLanUrl: params.localLanUrl ?? null,
109
- createdAt: Date.now(),
110
- });
111
- log.info(
112
- { pairingRequestId: params.pairingRequestId },
113
- "Pairing request registered",
114
- );
115
- return { ok: true };
116
- }
117
-
118
- /**
119
- * iOS initiates a pairing request. Validates the secret and transitions
120
- * the entry to "pending" (or "approved" if auto-approved).
121
- */
122
- beginRequest(params: {
123
- pairingRequestId: string;
124
- pairingSecret: string;
125
- deviceId: string;
126
- deviceName: string;
127
- }):
128
- | { ok: true; entry: PairingRequest }
129
- | {
130
- ok: false;
131
- reason: "not_found" | "invalid_secret" | "expired" | "already_paired";
132
- } {
133
- const entry = this.requests.get(params.pairingRequestId);
134
- if (!entry) {
135
- return { ok: false, reason: "not_found" };
136
- }
137
-
138
- if (entry.status === "expired" || entry.status === "denied") {
139
- return { ok: false, reason: "expired" };
140
- }
141
-
142
- const hashedSecret = hashValue(params.pairingSecret);
143
- if (!timingSafeCompare(entry.hashedPairingSecret, hashedSecret)) {
144
- return { ok: false, reason: "invalid_secret" };
145
- }
146
-
147
- const hashedDeviceId = hashValue(params.deviceId);
148
-
149
- if (
150
- entry.hashedDeviceId &&
151
- !timingSafeCompare(entry.hashedDeviceId, hashedDeviceId)
152
- ) {
153
- log.warn(
154
- { pairingRequestId: params.pairingRequestId },
155
- "Pairing request already bound to a different device",
156
- );
157
- return { ok: false, reason: "already_paired" };
158
- }
159
-
160
- entry.hashedDeviceId = hashedDeviceId;
161
- entry.deviceName = params.deviceName;
162
- if (entry.status === "registered") {
163
- entry.status = "pending";
164
- }
165
- return { ok: true, entry };
166
- }
167
-
168
- approve(
169
- pairingRequestId: string,
170
- bearerToken: string,
171
- ): PairingRequest | null {
172
- const entry = this.requests.get(pairingRequestId);
173
- if (!entry) return null;
174
- entry.status = "approved";
175
- entry.bearerToken = bearerToken;
176
- return entry;
177
- }
178
-
179
- deny(pairingRequestId: string): PairingRequest | null {
180
- const entry = this.requests.get(pairingRequestId);
181
- if (!entry) return null;
182
- entry.status = "denied";
183
- return entry;
184
- }
185
-
186
- get(pairingRequestId: string): PairingRequest | null {
187
- return this.requests.get(pairingRequestId) ?? null;
188
- }
189
-
190
- validateSecret(pairingRequestId: string, secret: string): boolean {
191
- const entry = this.requests.get(pairingRequestId);
192
- if (!entry) return false;
193
- const hashedSecret = hashValue(secret);
194
- return timingSafeCompare(entry.hashedPairingSecret, hashedSecret);
195
- }
196
-
197
- private sweep(): void {
198
- const now = Date.now();
199
- let changed = false;
200
- for (const [id, entry] of this.requests) {
201
- if (now - entry.createdAt > PAIRING_TTL_MS) {
202
- if (entry.status !== "approved") {
203
- entry.status = "expired";
204
- changed = true;
205
- }
206
- // Remove entries older than 2x TTL regardless of status
207
- if (now - entry.createdAt > PAIRING_TTL_MS * 2) {
208
- this.requests.delete(id);
209
- changed = true;
210
- log.debug({ pairingRequestId: id }, "Pairing request swept");
211
- }
212
- }
213
- }
214
- if (changed) {
215
- log.debug("Sweep completed with changes");
216
- }
217
- }
218
- }