@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,381 +0,0 @@
1
- import { buildWsUpstreamUrl } from "@vellumai/assistant-client";
2
-
3
- import {
4
- validateEdgeToken,
5
- mintServiceToken,
6
- } from "../../auth/token-exchange.js";
7
- import { parseSub } from "../../auth/subject.js";
8
- import type { GatewayConfig } from "../../config.js";
9
- import { getLogger } from "../../logger.js";
10
- import { isLoopbackAddress } from "../../util/is-loopback-address.js";
11
-
12
- const log = getLogger("browser-relay-ws");
13
-
14
- // Cap buffered messages to prevent unbounded memory growth if upstream stalls
15
- const MAX_PENDING_MESSAGES = 100;
16
-
17
- function isPrivateAddress(addr: string): boolean {
18
- const v4Mapped = addr.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i);
19
- const normalized = v4Mapped ? v4Mapped[1] : addr;
20
-
21
- if (normalized.includes(".")) {
22
- const parts = normalized.split(".").map(Number);
23
- if (
24
- parts.length !== 4 ||
25
- parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)
26
- )
27
- return false;
28
-
29
- if (parts[0] === 127) return true;
30
- if (parts[0] === 10) return true;
31
- if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
32
- if (parts[0] === 192 && parts[1] === 168) return true;
33
- if (parts[0] === 169 && parts[1] === 254) return true;
34
-
35
- return false;
36
- }
37
-
38
- const lower = normalized.toLowerCase();
39
- if (lower === "::1") return true;
40
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
41
- if (lower.startsWith("fe80")) return true;
42
-
43
- return false;
44
- }
45
-
46
- function isPrivateNetworkPeer(
47
- server: import("bun").Server<unknown>,
48
- req: Request,
49
- ): boolean {
50
- const ip = server.requestIP(req);
51
- if (!ip) return false;
52
- return isPrivateAddress(ip.address);
53
- }
54
-
55
- export function isLoopbackPeer(
56
- server: import("bun").Server<unknown>,
57
- req: Request,
58
- opts?: { trustProxy?: boolean },
59
- ): boolean {
60
- if (opts?.trustProxy) {
61
- const forwarded = req.headers.get("x-forwarded-for");
62
- if (forwarded) {
63
- const first = forwarded.split(",")[0]?.trim();
64
- if (!first) return false;
65
- return isLoopbackAddress(first);
66
- }
67
- }
68
-
69
- const peer = server.requestIP(req);
70
- if (!peer) return false;
71
- return isLoopbackAddress(peer.address);
72
- }
73
-
74
- /**
75
- * Resolved auth context carried into the upstream WS open handler.
76
- *
77
- * `guardianId` is populated whenever the downstream edge token carries an
78
- * actor principal (i.e. the sub is `actor:<assistantId>:<actorPrincipalId>`).
79
- * Service tokens — which intentionally do not carry a guardian — will have
80
- * `guardianId === undefined` and are expected to be rejected at upstream
81
- * upgrade time unless an alternate guardian-plumbing path is wired up.
82
- *
83
- * `clientInstanceId` is a stable per-extension-install identifier lifted
84
- * off the downstream handshake and forwarded to the runtime verbatim so
85
- * the multi-instance registry can key connections by (guardianId,
86
- * clientInstanceId). Without this forwarding, cloud reconnects from the
87
- * same extension install would each land in a fresh
88
- * `legacy:<connectionId>` slot and fail to supersede the prior socket.
89
- */
90
- export interface BrowserRelayAuthContext {
91
- guardianId?: string;
92
- /**
93
- * Per-install identifier sourced from the downstream handshake — either
94
- * the `clientInstanceId` query param (primary path, set by the Chrome
95
- * extension via `buildUrl`) or the `x-client-instance-id` header.
96
- * Propagated verbatim to the upstream runtime URL.
97
- */
98
- clientInstanceId?: string;
99
- /** True when auth was checked and succeeded. */
100
- authenticated: boolean;
101
- /** True when runtime proxy auth is globally disabled (dev bypass). */
102
- authBypassed: boolean;
103
- }
104
-
105
- export type BrowserRelaySocketData = {
106
- wsType: "browser-relay";
107
- config: GatewayConfig;
108
- auth: BrowserRelayAuthContext;
109
- upstream?: WebSocket;
110
- pendingMessages?: (string | ArrayBuffer | Uint8Array)[];
111
- };
112
-
113
- /**
114
- * Create a WebSocket upgrade handler that proxies browser-relay frames between
115
- * the local Chrome extension and the runtime's /v1/browser-relay endpoint.
116
- */
117
- export function createBrowserRelayWebsocketHandler(config: GatewayConfig) {
118
- return function handleUpgrade(
119
- req: Request,
120
- server: import("bun").Server<unknown>,
121
- ): Response | undefined {
122
- if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
123
- return new Response("Upgrade Required", { status: 426 });
124
- }
125
-
126
- const url = new URL(req.url);
127
-
128
- // Trust actual peer IP, not request host headers, for local/private gating.
129
- if (!isPrivateNetworkPeer(server, req)) {
130
- return new Response(
131
- "Browser relay only accepts connections from localhost",
132
- { status: 403 },
133
- );
134
- }
135
-
136
- const authResult = checkBrowserRelayAuth(req, url, config);
137
- if (!authResult.ok) return authResult.response;
138
-
139
- const upgraded = server.upgrade(req, {
140
- data: {
141
- wsType: "browser-relay",
142
- config,
143
- auth: authResult.context,
144
- },
145
- });
146
-
147
- if (!upgraded) {
148
- return new Response("WebSocket upgrade failed", { status: 500 });
149
- }
150
-
151
- // Return undefined to indicate upgrade was handled
152
- return undefined;
153
- };
154
- }
155
-
156
- type AuthCheckResult =
157
- | { ok: true; context: BrowserRelayAuthContext }
158
- | { ok: false; response: Response };
159
-
160
- /**
161
- * Parse the downstream edge token and return a structured auth context.
162
- *
163
- * The gateway accepts the token via `Authorization: Bearer <jwt>` or a
164
- * `?token=` query parameter (browser WebSocket upgrades cannot set custom
165
- * headers, so the query param is the primary mechanism for the Chrome
166
- * extension). Tokens must carry an actor principal in `sub`; service
167
- * subjects (`svc:*:*`) are rejected on this route. The extracted
168
- * `actorPrincipalId` is propagated forward as the guardian identity so
169
- * the runtime can register the connection under the correct guardian.
170
- */
171
- export function checkBrowserRelayAuth(
172
- req: Request,
173
- url: URL,
174
- config: GatewayConfig,
175
- ): AuthCheckResult {
176
- // Lift the per-install identifier off the downstream handshake so we
177
- // can propagate it to the runtime regardless of the auth path below.
178
- // The Chrome extension sets it as a query param via `buildUrl`; we
179
- // also accept the `x-client-instance-id` header for parity with the
180
- // runtime's own accepted forms. Empty strings are treated as absent
181
- // so sparse clients don't all collapse into the same key.
182
- const clientInstanceId = extractClientInstanceId(req, url);
183
-
184
- if (!config.runtimeProxyRequireAuth) {
185
- return {
186
- ok: true,
187
- context: {
188
- authenticated: false,
189
- authBypassed: true,
190
- clientInstanceId,
191
- },
192
- };
193
- }
194
-
195
- const authHeader = req.headers.get("authorization");
196
- const queryToken = url.searchParams.get("token");
197
- const rawToken = authHeader
198
- ? authHeader.toLowerCase().startsWith("bearer ")
199
- ? authHeader.slice(7)
200
- : null
201
- : queryToken;
202
-
203
- if (!rawToken) {
204
- log.warn("Browser relay WS: no token provided");
205
- return {
206
- ok: false,
207
- response: new Response("Unauthorized", { status: 401 }),
208
- };
209
- }
210
-
211
- const result = validateEdgeToken(rawToken);
212
- if (!result.ok) {
213
- log.warn(
214
- { reason: result.reason },
215
- "Browser relay WS: authentication failed",
216
- );
217
- return {
218
- ok: false,
219
- response: new Response("Unauthorized", { status: 401 }),
220
- };
221
- }
222
-
223
- const parsed = parseSub(result.claims.sub);
224
- if (
225
- !parsed.ok ||
226
- parsed.principalType !== "actor" ||
227
- !parsed.actorPrincipalId
228
- ) {
229
- // Fail closed: browser-relay sockets must be guardian-scoped.
230
- // Service tokens (`svc:*:*`) do not carry an actor principal and are
231
- // therefore rejected on this path.
232
- log.warn(
233
- {
234
- reason: parsed.ok ? "missing_actor_principal" : parsed.reason,
235
- sub: result.claims.sub,
236
- },
237
- "Browser relay WS: denied token without actor principal",
238
- );
239
- return {
240
- ok: false,
241
- response: new Response("Unauthorized", { status: 401 }),
242
- };
243
- }
244
-
245
- return {
246
- ok: true,
247
- context: {
248
- authenticated: true,
249
- authBypassed: false,
250
- guardianId: parsed.actorPrincipalId,
251
- clientInstanceId,
252
- },
253
- };
254
- }
255
-
256
- /**
257
- * Extract the per-extension-install identifier from a downstream
258
- * browser-relay WS handshake. Query param is the primary path (the
259
- * Chrome extension sets it via `buildUrl` because WebSocket upgrades
260
- * cannot set custom headers from the browser); the header form is
261
- * accepted for completeness so non-browser clients have a consistent
262
- * contract. Empty strings are normalized to `undefined`.
263
- */
264
- function extractClientInstanceId(req: Request, url: URL): string | undefined {
265
- const rawHeader = req.headers.get("x-client-instance-id")?.trim();
266
- const rawQuery = url.searchParams.get("clientInstanceId")?.trim();
267
- const picked = (rawHeader ?? "") || (rawQuery ?? "") || undefined;
268
- return picked;
269
- }
270
-
271
- /**
272
- * WebSocket handler config for Bun.serve() that proxies frames to runtime.
273
- */
274
- export function getBrowserRelayWebsocketHandlers() {
275
- return {
276
- open(ws: import("bun").ServerWebSocket<BrowserRelaySocketData>) {
277
- const { config, auth } = ws.data;
278
-
279
- // Initialize message buffer for frames arriving before upstream connects
280
- ws.data.pendingMessages = [];
281
-
282
- // Build upstream URL with service-token auth and optional guardian/
283
- // client-instance identifiers. The per-install identifier is forwarded
284
- // so the runtime's multi-instance registry can key connections by
285
- // (guardianId, clientInstanceId). Without this, cloud reconnects from
286
- // the same extension install would each fall through to a fresh
287
- // `legacy:<connectionId>` key and fail to supersede the prior socket.
288
- const extraParams: Record<string, string> = {};
289
- if (auth.guardianId) {
290
- extraParams.guardianId = auth.guardianId;
291
- }
292
- if (auth.clientInstanceId) {
293
- extraParams.clientInstanceId = auth.clientInstanceId;
294
- }
295
- const { url: upstreamUrl, logSafeUrl: logSafeUpstreamUrl } =
296
- buildWsUpstreamUrl({
297
- baseUrl: config.assistantRuntimeBaseUrl,
298
- path: "/v1/browser-relay",
299
- serviceToken: mintServiceToken(),
300
- extraParams,
301
- });
302
-
303
- log.info(
304
- {
305
- upstreamUrl: logSafeUpstreamUrl,
306
- guardianId: auth.guardianId,
307
- clientInstanceId: auth.clientInstanceId,
308
- },
309
- "Opening upstream browser relay WS to runtime",
310
- );
311
-
312
- const upstream = new WebSocket(upstreamUrl);
313
- ws.data.upstream = upstream;
314
-
315
- upstream.addEventListener("open", () => {
316
- log.info("Upstream browser relay WS connected");
317
- const pending = ws.data.pendingMessages;
318
- if (pending) {
319
- for (const msg of pending) {
320
- upstream.send(msg);
321
- }
322
- ws.data.pendingMessages = undefined;
323
- }
324
- });
325
-
326
- upstream.addEventListener("message", (event) => {
327
- const data =
328
- typeof event.data === "string"
329
- ? event.data
330
- : new Uint8Array(event.data as ArrayBuffer);
331
- ws.send(data);
332
- });
333
-
334
- upstream.addEventListener("close", (event) => {
335
- log.info({ code: event.code }, "Upstream browser relay WS closed");
336
- ws.close(event.code, event.reason);
337
- });
338
-
339
- upstream.addEventListener("error", (event) => {
340
- log.error({ error: event }, "Upstream browser relay WS error");
341
- ws.close(1011, "Upstream error");
342
- });
343
- },
344
-
345
- message(
346
- ws: import("bun").ServerWebSocket<BrowserRelaySocketData>,
347
- message: string | ArrayBuffer | Uint8Array,
348
- ) {
349
- const upstream = ws.data.upstream;
350
- if (upstream && upstream.readyState === WebSocket.OPEN) {
351
- upstream.send(message);
352
- } else if (ws.data.pendingMessages) {
353
- if (ws.data.pendingMessages.length >= MAX_PENDING_MESSAGES) {
354
- log.warn(
355
- "Browser relay pending message buffer overflow — closing connection",
356
- );
357
- ws.close(1008, "Buffer overflow");
358
- return;
359
- }
360
- ws.data.pendingMessages.push(message);
361
- }
362
- },
363
-
364
- close(
365
- ws: import("bun").ServerWebSocket<BrowserRelaySocketData>,
366
- code: number,
367
- reason: string,
368
- ) {
369
- const { upstream } = ws.data;
370
- log.info({ code, reason }, "Browser relay downstream WS closed");
371
- ws.data.pendingMessages = undefined;
372
- if (
373
- upstream &&
374
- (upstream.readyState === WebSocket.OPEN ||
375
- upstream.readyState === WebSocket.CONNECTING)
376
- ) {
377
- upstream.close(code, reason);
378
- }
379
- },
380
- };
381
- }
@@ -1,73 +0,0 @@
1
- import {
2
- readFileSync,
3
- writeFileSync,
4
- mkdirSync,
5
- existsSync,
6
- renameSync,
7
- } from "node:fs";
8
- import { join, dirname } from "node:path";
9
- import { randomBytes } from "node:crypto";
10
- import { getWorkspaceDir } from "../../credential-reader.js";
11
-
12
- /**
13
- * Serializes config writes so concurrent PATCH requests don't race on
14
- * read-modify-write. Each write awaits the previous one before proceeding.
15
- *
16
- * This chain is shared across all config mutation routes (feature flags,
17
- * privacy config, etc.) to prevent concurrent writes to the same
18
- * config.json from overwriting each other's changes.
19
- */
20
- let configWriteChain: Promise<void> = Promise.resolve();
21
-
22
- /**
23
- * Enqueue a write operation onto the shared config write chain.
24
- * The callback runs only after all previously enqueued writes have finished.
25
- */
26
- export function enqueueConfigWrite(fn: () => void): void {
27
- configWriteChain = configWriteChain.then(fn);
28
- }
29
-
30
- export function getConfigPath(): string {
31
- return join(getWorkspaceDir(), "config.json");
32
- }
33
-
34
- export type ConfigReadResult =
35
- | { ok: true; data: Record<string, unknown> }
36
- | { ok: false; reason: "malformed"; detail: string };
37
-
38
- export function readConfigFile(): ConfigReadResult {
39
- const cfgPath = getConfigPath();
40
- if (!existsSync(cfgPath)) {
41
- return { ok: true, data: {} };
42
- }
43
- try {
44
- const raw = readFileSync(cfgPath, "utf-8");
45
- const parsed = JSON.parse(raw);
46
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
47
- return {
48
- ok: false,
49
- reason: "malformed",
50
- detail: "Config file is not a JSON object",
51
- };
52
- }
53
- return { ok: true, data: parsed };
54
- } catch (err) {
55
- return { ok: false, reason: "malformed", detail: String(err) };
56
- }
57
- }
58
-
59
- /**
60
- * Atomically write the config file: write to a temporary file in the same
61
- * directory, then rename. This avoids partial-file corruption if the process
62
- * crashes mid-write.
63
- */
64
- export function writeConfigFileAtomic(data: Record<string, unknown>): void {
65
- const cfgPath = getConfigPath();
66
- const dir = dirname(cfgPath);
67
- if (!existsSync(dir)) {
68
- mkdirSync(dir, { recursive: true });
69
- }
70
- const tmpPath = join(dir, `.config.${randomBytes(6).toString("hex")}.tmp`);
71
- writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n", "utf-8");
72
- renameSync(tmpPath, cfgPath);
73
- }
@@ -1,30 +0,0 @@
1
- /**
2
- * IPC route definitions for capability token operations.
3
- *
4
- * Exposes token verification to the assistant daemon so it can
5
- * authenticate browser-relay WebSocket handshakes without reading
6
- * the HMAC secret directly from the filesystem.
7
- */
8
-
9
- import { z } from "zod";
10
-
11
- import { verifyHostBrowserCapability } from "../auth/capability-tokens.js";
12
- import type { IpcRoute } from "./server.js";
13
-
14
- const VerifyCapabilityTokenParamsSchema = z.object({
15
- token: z.string(),
16
- });
17
-
18
- export const capabilityTokenRoutes: IpcRoute[] = [
19
- {
20
- method: "verify_capability_token",
21
- schema: VerifyCapabilityTokenParamsSchema,
22
- handler: (params) => {
23
- const { token } = params as z.infer<
24
- typeof VerifyCapabilityTokenParamsSchema
25
- >;
26
- const claims = verifyHostBrowserCapability(token);
27
- return claims ?? { valid: false };
28
- },
29
- },
30
- ];
@@ -1,110 +0,0 @@
1
- /**
2
- * Persistent store for always-allowed paired devices.
3
- *
4
- * Persisted to $GATEWAY_SECURITY_DIR/approved-devices.json using an
5
- * atomic-write pattern (write .tmp → rename → chmod).
6
- */
7
-
8
- import { createHash } from "node:crypto";
9
- import {
10
- chmodSync,
11
- existsSync,
12
- mkdirSync,
13
- readFileSync,
14
- renameSync,
15
- writeFileSync,
16
- } from "node:fs";
17
- import { dirname, join } from "node:path";
18
-
19
- import { getLogger } from "../logger.js";
20
- import { getGatewaySecurityDir } from "../paths.js";
21
-
22
- const log = getLogger("approved-devices-store");
23
-
24
- export interface ApprovedDevice {
25
- hashedDeviceId: string;
26
- deviceName: string;
27
- lastPairedAt: number;
28
- }
29
-
30
- interface ApprovedDevicesFile {
31
- version: 1;
32
- devices: ApprovedDevice[];
33
- }
34
-
35
- function getStorePath(): string {
36
- return join(getGatewaySecurityDir(), "approved-devices.json");
37
- }
38
-
39
- /** Hash a raw deviceId for storage. */
40
- export function hashDeviceId(deviceId: string): string {
41
- return createHash("sha256").update(deviceId).digest("hex");
42
- }
43
-
44
- let cachedDevices: Map<string, ApprovedDevice> | null = null;
45
-
46
- function loadFromDisk(): Map<string, ApprovedDevice> {
47
- const path = getStorePath();
48
- if (!existsSync(path)) {
49
- return new Map();
50
- }
51
- try {
52
- const raw = readFileSync(path, "utf-8");
53
- const data = JSON.parse(raw) as ApprovedDevicesFile;
54
- if (data.version !== 1 || !Array.isArray(data.devices)) {
55
- log.warn("Invalid approved-devices.json format, starting fresh");
56
- return new Map();
57
- }
58
- const map = new Map<string, ApprovedDevice>();
59
- for (const device of data.devices) {
60
- map.set(device.hashedDeviceId, device);
61
- }
62
- return map;
63
- } catch (err) {
64
- log.error({ err }, "Failed to load approved-devices.json");
65
- return new Map();
66
- }
67
- }
68
-
69
- function saveToDisk(devices: Map<string, ApprovedDevice>): void {
70
- const path = getStorePath();
71
- const dir = dirname(path);
72
- if (!existsSync(dir)) {
73
- mkdirSync(dir, { recursive: true });
74
- }
75
- const data: ApprovedDevicesFile = {
76
- version: 1,
77
- devices: Array.from(devices.values()),
78
- };
79
- const tmpPath = path + ".tmp." + process.pid;
80
- writeFileSync(tmpPath, JSON.stringify(data, null, 2), { mode: 0o600 });
81
- renameSync(tmpPath, path);
82
- chmodSync(path, 0o600);
83
- }
84
-
85
- function getDevices(): Map<string, ApprovedDevice> {
86
- if (cachedDevices == null) {
87
- cachedDevices = loadFromDisk();
88
- }
89
- return cachedDevices;
90
- }
91
-
92
- /** Check if a hashed device ID is in the allowlist. */
93
- export function isDeviceApproved(hashedDeviceId: string): boolean {
94
- return getDevices().has(hashedDeviceId);
95
- }
96
-
97
- /** Update lastPairedAt and deviceName for an existing device (auto-approve refresh). */
98
- export function refreshDevice(
99
- hashedDeviceId: string,
100
- deviceName: string,
101
- ): void {
102
- const devices = getDevices();
103
- const existing = devices.get(hashedDeviceId);
104
- if (existing) {
105
- existing.deviceName = deviceName;
106
- existing.lastPairedAt = Date.now();
107
- saveToDisk(devices);
108
- log.info({ hashedDeviceId }, "Device metadata refreshed");
109
- }
110
- }