@mycadet/gateway 0.12.10 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  2. package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  3. package/node_modules/@mycadet/ces-client/src/http-credentials.ts +18 -1
  4. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  5. package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  6. package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
  7. package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +10 -10
  8. package/node_modules/@mycadet/service-contracts/src/ingress.ts +3 -3
  9. package/openapi.json +1 -1
  10. package/package.json +1 -1
  11. package/src/__tests__/actor-token-revocation.test.ts +41 -34
  12. package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
  13. package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
  14. package/src/__tests__/credential-requests.test.ts +4 -4
  15. package/src/__tests__/devices-mine.test.ts +187 -0
  16. package/src/__tests__/edge-auth.test.ts +1 -20
  17. package/src/__tests__/edge-guardian-auth.test.ts +1 -17
  18. package/src/__tests__/feature-flag-resolver.test.ts +2 -30
  19. package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
  20. package/src/__tests__/feature-flags-route.test.ts +2 -191
  21. package/src/__tests__/guardian-bootstrap-lookups.test.ts +22 -29
  22. package/src/__tests__/guardian-init-lockfile.test.ts +2 -2
  23. package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
  24. package/src/__tests__/guardian-integrity.test.ts +16 -20
  25. package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
  26. package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
  27. package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
  28. package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
  29. package/src/__tests__/refresh-device-binding.test.ts +0 -12
  30. package/src/__tests__/remote-web-pairing-token.test.ts +5 -15
  31. package/src/__tests__/runtime-client.test.ts +7 -7
  32. package/src/__tests__/speech-relay-websocket.test.ts +4 -4
  33. package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
  34. package/src/__tests__/twilio-webhooks.test.ts +2 -2
  35. package/src/auth/actor-token-revocation.ts +34 -9
  36. package/src/auth/guardian-bootstrap.ts +1 -1
  37. package/src/cli/__tests__/pairing.test.ts +95 -0
  38. package/src/cli/index.ts +1 -1
  39. package/src/cli/pairing.ts +81 -6
  40. package/src/contacts-mirror-op-reporter.ts +19 -36
  41. package/src/db/connection.ts +0 -10
  42. package/src/feature-flag-resolver.ts +2 -7
  43. package/src/feature-flag-watcher.ts +1 -14
  44. package/src/guardian-integrity-reporter.ts +19 -47
  45. package/src/http/middleware/auth.ts +33 -27
  46. package/src/http/routes/auth-token.ts +1 -1
  47. package/src/http/routes/devices-mine.ts +103 -0
  48. package/src/http/routes/feature-flags.ts +1 -8
  49. package/src/http/routes/guardian-pin.ts +2 -11
  50. package/src/http/routes/ipc-runtime-proxy.ts +1 -1
  51. package/src/http/routes/live-voice-websocket.ts +1 -1
  52. package/src/http/routes/runtime-audio-stream.ts +1 -1
  53. package/src/http/routes/runtime-proxy.ts +2 -2
  54. package/src/http/routes/speech-relay-websocket.ts +2 -2
  55. package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
  56. package/src/index.ts +11 -198
  57. package/src/ipc/feature-flag-handlers.ts +2 -9
  58. package/src/remote-web/pairing-challenge-store.ts +15 -0
  59. package/src/risk/command-registry/commands/assistant.ts +0 -24
  60. package/src/risk/command-registry.test.ts +2 -4
  61. package/src/schema.ts +44 -372
  62. package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
  63. package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
  64. package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
  65. package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
  66. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
  67. package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
  68. package/src/auth-fallback-count-tracker.ts +0 -113
  69. package/src/auth-fallback-reporter.ts +0 -127
  70. package/src/backup/backup-key.ts +0 -138
  71. package/src/backup/backup-routes.ts +0 -161
  72. package/src/backup/backup-worker.ts +0 -425
  73. package/src/backup/list-snapshots.ts +0 -97
  74. package/src/backup/local-writer.ts +0 -87
  75. package/src/backup/offsite-writer.ts +0 -182
  76. package/src/backup/paths.ts +0 -113
  77. package/src/backup/stream-crypt.ts +0 -258
  78. package/src/device-id.ts +0 -18
  79. package/src/feature-flag-remote-store.ts +0 -166
  80. package/src/http/routes/migration-proxy.ts +0 -636
  81. package/src/internal-telemetry-client.ts +0 -39
  82. package/src/ipc/debug-export-handlers.test.ts +0 -102
  83. package/src/ipc/debug-export-handlers.ts +0 -81
  84. package/src/remote-feature-flag-sync.ts +0 -440
  85. package/src/watchdog-reporter.ts +0 -110
@@ -1,636 +0,0 @@
1
- /**
2
- * Gateway proxies for the daemon migration export/import endpoints.
3
- *
4
- * Follows the same forwarding pattern as upgrade-broadcast-proxy.ts:
5
- * strips hop-by-hop headers, replaces the client's edge JWT with a
6
- * minted service token, and proxies the request to the daemon.
7
- *
8
- * The import handler has two shapes:
9
- *
10
- * 1. Raw bytes (octet-stream / multipart) — proxied synchronously. The
11
- * caller's connection stays open for the full import duration. Used
12
- * by docker teleport, local .vbundle restore, and any client that
13
- * uploads the bundle body directly. Unchanged.
14
- *
15
- * 2. JSON `{ url }` body — run asynchronously. The gateway generates a
16
- * jobId, kicks off the upstream daemon call on an unawaited promise,
17
- * and returns `202 Accepted` with `{ job_id, status: "pending" }`
18
- * immediately. Callers poll `GET /v1/migrations/import/:jobId/status`
19
- * for progress. This keeps the external caller's request short
20
- * regardless of bundle size — critical for 8 GB imports where any
21
- * LB/ingress timeout along the caller path would otherwise cause
22
- * 504s while the daemon keeps working.
23
- *
24
- * The gateway → daemon hop remains synchronous: the background task
25
- * just holds the daemon request open until the import completes,
26
- * then records the outcome in the job map for the caller to poll.
27
- */
28
-
29
- import { randomUUID } from "node:crypto";
30
-
31
- import {
32
- proxyForwardToResponse,
33
- prepareUpstreamHeaders,
34
- buildUpstreamUrl,
35
- createTimeoutController,
36
- isTimeoutError,
37
- stripHopByHop,
38
- } from "@mycadet/assistant-client";
39
-
40
- import { mintServiceToken } from "../../auth/token-exchange.js";
41
- import type { GatewayConfig } from "../../config.js";
42
- import { fetchImpl } from "../../fetch.js";
43
- import { getLogger } from "../../logger.js";
44
-
45
- const log = getLogger("migration-proxy");
46
-
47
- /** Timeout for migration requests (60 minutes) — exports/imports can be large (up to 8 GB bundles). */
48
- const MIGRATION_TIMEOUT_MS = 3_600_000;
49
-
50
- /**
51
- * How long a finished (or failed) import job is kept in memory before it's
52
- * pruned. 30 minutes is long enough for a typical caller-side polling loop
53
- * to read the terminal state a few times, short enough to bound memory use
54
- * even under heavy job churn.
55
- */
56
- const COMPLETED_JOB_TTL_MS = 30 * 60 * 1000;
57
-
58
- /**
59
- * How often the pruner sweeps the job map. Not timing-critical — jobs that
60
- * survive a bit past their TTL are fine.
61
- */
62
- const JOB_PRUNE_INTERVAL_MS = 60 * 1000;
63
-
64
- export function createMigrationExportProxyHandler(config: GatewayConfig) {
65
- return async function handleMigrationExport(req: Request): Promise<Response> {
66
- const start = performance.now();
67
-
68
- const response = await proxyForwardToResponse(req, {
69
- baseUrl: config.assistantRuntimeBaseUrl,
70
- path: "/v1/migrations/export",
71
- serviceToken: mintServiceToken(),
72
- timeoutMs: MIGRATION_TIMEOUT_MS,
73
- fetchImpl,
74
- });
75
-
76
- const duration = Math.round(performance.now() - start);
77
-
78
- if (response.status >= 400) {
79
- log.warn(
80
- { status: response.status, duration },
81
- "Migration export proxy upstream error",
82
- );
83
- } else {
84
- log.info(
85
- { status: response.status, duration },
86
- "Migration export proxy completed",
87
- );
88
- }
89
-
90
- return response;
91
- };
92
- }
93
-
94
- // ---------------------------------------------------------------------------
95
- // Async import job bookkeeping
96
- // ---------------------------------------------------------------------------
97
-
98
- /**
99
- * Terminal job state mirrors the shape macOS clients already poll on the
100
- * platform's `/v1/migrations/import/:jobId/status/` endpoint, so a caller
101
- * library that already knows how to poll there can talk to the gateway
102
- * with no changes.
103
- */
104
- export type ImportJobStatus = "pending" | "processing" | "complete" | "failed";
105
-
106
- interface ImportJob {
107
- jobId: string;
108
- status: ImportJobStatus;
109
- /** Wall-clock ms when the job was created. Used for idle-pruning. */
110
- startedAt: number;
111
- /** Wall-clock ms when the job reached a terminal state. */
112
- completedAt?: number;
113
- /** Upstream HTTP status on success. */
114
- upstreamStatus?: number;
115
- /** Parsed JSON body the daemon returned on success. */
116
- result?: unknown;
117
- /** Terminal-state error message (fetch failure, non-2xx, body parse). */
118
- error?: string;
119
- }
120
-
121
- /**
122
- * Singleton job map. Module-level so the proxy handler and the status
123
- * handler share it. The gateway is a single Bun process; no external
124
- * coordination needed.
125
- */
126
- const jobs = new Map<string, ImportJob>();
127
- let prunerStarted = false;
128
-
129
- function ensurePrunerRunning(): void {
130
- if (prunerStarted) return;
131
- prunerStarted = true;
132
- // Unref'd timer so it doesn't keep the process alive during shutdown.
133
- const timer = setInterval(() => {
134
- const now = Date.now();
135
- for (const [id, job] of jobs) {
136
- if (job.completedAt && now - job.completedAt > COMPLETED_JOB_TTL_MS) {
137
- jobs.delete(id);
138
- }
139
- }
140
- }, JOB_PRUNE_INTERVAL_MS);
141
- if (typeof timer.unref === "function") timer.unref();
142
- }
143
-
144
- /** Test-only: reset the job map between test runs. */
145
- export function _resetImportJobsForTests(): void {
146
- jobs.clear();
147
- }
148
-
149
- /** Test-only: read-only snapshot of the current jobs. */
150
- export function _getImportJobsForTests(): ReadonlyMap<string, ImportJob> {
151
- return jobs;
152
- }
153
-
154
- // ---------------------------------------------------------------------------
155
- // Import proxy — dispatches on Content-Type
156
- // ---------------------------------------------------------------------------
157
-
158
- export function createMigrationImportProxyHandler(config: GatewayConfig) {
159
- return async function handleMigrationImport(req: Request): Promise<Response> {
160
- const contentType = req.headers.get("content-type") ?? "";
161
- if (contentType.toLowerCase().includes("application/json")) {
162
- return handleAsyncJsonImport(req, config);
163
- }
164
- return handleSyncBytesImport(req, config);
165
- };
166
- }
167
-
168
- /**
169
- * Async path: called by the external caller for URL-based imports. Returns
170
- * 202 immediately with a job_id; the upstream daemon call runs in the
171
- * background and the job map is updated when it completes.
172
- */
173
- async function handleAsyncJsonImport(
174
- req: Request,
175
- config: GatewayConfig,
176
- ): Promise<Response> {
177
- ensurePrunerRunning();
178
-
179
- let bodyText: string;
180
- try {
181
- bodyText = await req.text();
182
- } catch (err) {
183
- log.warn({ err }, "Migration import proxy failed to read JSON body");
184
- return Response.json(
185
- { error: "Bad Request", message: "Failed to read request body" },
186
- { status: 400 },
187
- );
188
- }
189
-
190
- // The gateway doesn't validate the URL or re-serialize — that's the
191
- // daemon's job. We just need enough of the headers and the raw body to
192
- // proxy the request. Log a preview of the path/host if the body parses
193
- // so ops has something to grep on.
194
- let previewHost: string | undefined;
195
- try {
196
- const parsed = JSON.parse(bodyText) as unknown;
197
- if (
198
- parsed &&
199
- typeof parsed === "object" &&
200
- "url" in parsed &&
201
- typeof (parsed as { url: unknown }).url === "string"
202
- ) {
203
- try {
204
- previewHost = new URL((parsed as { url: string }).url).host;
205
- } catch {
206
- // Malformed URL — daemon will 400 on it; log without the host.
207
- }
208
- }
209
- } catch {
210
- // Malformed JSON — daemon will 400 on it; let the error body flow
211
- // through when the daemon responds.
212
- }
213
-
214
- const jobId = randomUUID();
215
- const job: ImportJob = {
216
- jobId,
217
- status: "pending",
218
- startedAt: Date.now(),
219
- };
220
- jobs.set(jobId, job);
221
-
222
- const reqHeaders = prepareUpstreamHeaders(
223
- new Headers(req.headers),
224
- mintServiceToken(),
225
- );
226
- reqHeaders.set("content-type", "application/json");
227
- reqHeaders.set("content-length", String(Buffer.byteLength(bodyText)));
228
-
229
- const upstream = buildUpstreamUrl(
230
- config.assistantRuntimeBaseUrl,
231
- "/v1/migrations/import",
232
- );
233
-
234
- log.info(
235
- { jobId, previewHost },
236
- "Migration import proxy: kicking off async URL import",
237
- );
238
-
239
- // Fire-and-forget the upstream call. Errors are captured in the job map
240
- // so the caller's next poll surfaces them.
241
- void runUpstreamImport({
242
- jobId,
243
- upstream,
244
- reqHeaders,
245
- bodyText,
246
- });
247
-
248
- return Response.json(
249
- { job_id: jobId, status: "pending" satisfies ImportJobStatus },
250
- { status: 202 },
251
- );
252
- }
253
-
254
- /** Helper that holds the daemon request open for the full import duration. */
255
- async function runUpstreamImport(args: {
256
- jobId: string;
257
- upstream: string;
258
- reqHeaders: Headers;
259
- bodyText: string;
260
- }): Promise<void> {
261
- const { jobId, upstream, reqHeaders, bodyText } = args;
262
- const start = performance.now();
263
-
264
- const job = jobs.get(jobId);
265
- if (job) job.status = "processing";
266
-
267
- const { controller, clear } = createTimeoutController(MIGRATION_TIMEOUT_MS);
268
-
269
- try {
270
- // NOTE: `fetch` resolves on headers, not on full body delivery. Keep
271
- // the abort timer alive across `response.json()` so a partial-body
272
- // stall on the upstream side still aborts and we transition the job
273
- // to `failed` — otherwise a mid-body drop would leave the job pinned
274
- // at `processing` indefinitely and pollers would spin forever.
275
- // The timer is cleared in the `finally` below, which fires after the
276
- // body has been fully consumed (or the abort has fired).
277
- const response = await fetchImpl(upstream, {
278
- method: "POST",
279
- headers: reqHeaders,
280
- body: bodyText,
281
- signal: controller.signal,
282
- });
283
-
284
- const duration = Math.round(performance.now() - start);
285
-
286
- // The daemon always returns JSON for this path (success OR structured
287
- // failure shapes). Parse and record; treat a parse failure as a job
288
- // failure since the caller can't meaningfully poll on bytes.
289
- let parsed: unknown;
290
- try {
291
- parsed = await response.json();
292
- } catch (err) {
293
- markJobFailed(
294
- jobId,
295
- response.status,
296
- `Invalid JSON from assistant: ${errMessage(err)}`,
297
- );
298
- log.error(
299
- { jobId, status: response.status, duration, err },
300
- "Migration import async: failed to parse daemon JSON response",
301
- );
302
- return;
303
- }
304
-
305
- if (response.status >= 200 && response.status < 300) {
306
- // Daemon returns HTTP 200 for some logical-failure shapes — notably
307
- // `{ success: false, reason: "validation_failed", errors: [...] }`
308
- // for bundles that failed manifest / hash validation. Callers that
309
- // gate on `status === "complete"` would otherwise report success
310
- // for an import that actually failed. Inspect the body before
311
- // declaring victory.
312
- if (isLogicalFailureBody(parsed)) {
313
- const errorMessage =
314
- extractErrorMessage(parsed) ?? "Import reported failure";
315
- markJobFailed(jobId, response.status, errorMessage, parsed);
316
- log.warn(
317
- { jobId, status: response.status, duration, error: errorMessage },
318
- "Migration import async: daemon returned 2xx with success=false body",
319
- );
320
- return;
321
- }
322
- markJobComplete(jobId, response.status, parsed);
323
- log.info(
324
- { jobId, status: response.status, duration },
325
- "Migration import async: completed",
326
- );
327
- return;
328
- }
329
-
330
- // Non-2xx: the daemon's JSON body is the caller-visible failure. Pull
331
- // a `reason` / `message` out of it for the `error` field if present,
332
- // keep the whole body under `result` so nothing is lost.
333
- const errorMessage =
334
- extractErrorMessage(parsed) ?? `HTTP ${response.status}`;
335
- markJobFailed(jobId, response.status, errorMessage, parsed);
336
- log.warn(
337
- { jobId, status: response.status, duration, error: errorMessage },
338
- "Migration import async: daemon returned non-2xx",
339
- );
340
- } catch (err) {
341
- const duration = Math.round(performance.now() - start);
342
- const message = isTimeoutError(err)
343
- ? `Gateway \u2192 assistant request timed out after ${MIGRATION_TIMEOUT_MS}ms`
344
- : `Gateway \u2192 assistant request failed: ${errMessage(err)}`;
345
- markJobFailed(jobId, undefined, message);
346
- log.error(
347
- { jobId, duration, err },
348
- "Migration import async: upstream connection failed",
349
- );
350
- } finally {
351
- clear();
352
- }
353
- }
354
-
355
- function markJobComplete(
356
- jobId: string,
357
- upstreamStatus: number,
358
- result: unknown,
359
- ): void {
360
- const job = jobs.get(jobId);
361
- if (!job) return;
362
- job.status = "complete";
363
- job.upstreamStatus = upstreamStatus;
364
- job.result = result;
365
- job.completedAt = Date.now();
366
- }
367
-
368
- function markJobFailed(
369
- jobId: string,
370
- upstreamStatus: number | undefined,
371
- error: string,
372
- result?: unknown,
373
- ): void {
374
- const job = jobs.get(jobId);
375
- if (!job) return;
376
- job.status = "failed";
377
- job.upstreamStatus = upstreamStatus;
378
- job.error = error;
379
- if (result !== undefined) job.result = result;
380
- job.completedAt = Date.now();
381
- }
382
-
383
- /**
384
- * The daemon's migration import handler returns HTTP 200 for some logical
385
- * failures — specifically `{ success: false, reason: "validation_failed",
386
- * errors: [...] }` when the bundle's manifest / hash checks fail. Those
387
- * are semantic failures, NOT HTTP-level errors. Detect them so the async
388
- * job ends up in the `failed` state rather than `complete`.
389
- */
390
- function isLogicalFailureBody(body: unknown): boolean {
391
- if (!body || typeof body !== "object") return false;
392
- const record = body as Record<string, unknown>;
393
- return record.success === false;
394
- }
395
-
396
- function extractErrorMessage(body: unknown): string | null {
397
- if (!body || typeof body !== "object") return null;
398
- const record = body as Record<string, unknown>;
399
- if (typeof record.message === "string") return record.message;
400
- if (typeof record.error === "string") return record.error;
401
- if (typeof record.reason === "string") return record.reason;
402
- return null;
403
- }
404
-
405
- function errMessage(err: unknown): string {
406
- return err instanceof Error ? err.message : String(err);
407
- }
408
-
409
- /**
410
- * Sync path: raw-bytes imports (octet-stream / multipart). Unchanged from
411
- * the previous implementation — the request body IS the bundle, so there's
412
- * no benefit to running it async (the caller's upload has to stay open
413
- * anyway).
414
- */
415
- async function handleSyncBytesImport(
416
- req: Request,
417
- config: GatewayConfig,
418
- ): Promise<Response> {
419
- const start = performance.now();
420
-
421
- const response = await proxyForwardToResponse(req, {
422
- baseUrl: config.assistantRuntimeBaseUrl,
423
- path: "/v1/migrations/import",
424
- serviceToken: mintServiceToken(),
425
- timeoutMs: MIGRATION_TIMEOUT_MS,
426
- fetchImpl,
427
- });
428
-
429
- const duration = Math.round(performance.now() - start);
430
-
431
- if (response.status >= 400) {
432
- log.warn(
433
- { status: response.status, duration },
434
- "Migration import proxy upstream error",
435
- );
436
- } else {
437
- log.info(
438
- { status: response.status, duration },
439
- "Migration import proxy completed",
440
- );
441
- }
442
-
443
- return response;
444
- }
445
-
446
- // ---------------------------------------------------------------------------
447
- // Status polling
448
- // ---------------------------------------------------------------------------
449
-
450
- /**
451
- * GET `/v1/migrations/import/:jobId/status` handler.
452
- *
453
- * Returns the job's current status on 200, or 404 if the jobId is unknown
454
- * (never issued, or pruned after the TTL). The response shape deliberately
455
- * mirrors the cloud's existing `/v1/migrations/import/:jobId/status/`
456
- * endpoint so `PlatformMigrationClient.pollImportStatus` works against
457
- * either surface with no changes.
458
- */
459
- export function createMigrationImportStatusProxyHandler(
460
- _config: GatewayConfig,
461
- ) {
462
- return async function handleMigrationImportStatus(
463
- _req: Request,
464
- jobId: string,
465
- ): Promise<Response> {
466
- const job = jobs.get(jobId);
467
- if (!job) {
468
- return Response.json(
469
- { error: "Not Found", message: `Unknown import job: ${jobId}` },
470
- { status: 404 },
471
- );
472
- }
473
-
474
- const body: {
475
- status: ImportJobStatus;
476
- job_id: string;
477
- error?: string;
478
- result?: unknown;
479
- } = {
480
- status: job.status,
481
- job_id: job.jobId,
482
- };
483
- if (job.error !== undefined) body.error = job.error;
484
- if (job.result !== undefined) body.result = job.result;
485
-
486
- return Response.json(body, { status: 200 });
487
- };
488
- }
489
-
490
- // ---------------------------------------------------------------------------
491
- // Teleport-GCS proxies (POST export-to-gcs / POST import-from-gcs / GET jobs)
492
- // ---------------------------------------------------------------------------
493
-
494
- /**
495
- * These three endpoints back the unified teleport-GCS flow on the daemon.
496
- * Unlike the legacy `/v1/migrations/import` endpoint, they're already async
497
- * by design: POSTs return `202 { job_id }` quickly, and GET `/jobs/:job_id`
498
- * is a cheap status lookup. So the gateway just needs to forward the
499
- * request transparently — no job-tracking wrapping is required.
500
- *
501
- * They're registered as explicit routes (rather than relying on the runtime
502
- * proxy catch-all) so they get dedicated auth and timeout handling.
503
- */
504
-
505
- /**
506
- * Timeout for the teleport-GCS proxies. Much shorter than
507
- * `MIGRATION_TIMEOUT_MS` because the daemon returns 202 immediately on
508
- * POST and GET job-status is a cheap in-memory lookup — no multi-GB body
509
- * flows through the gateway on this path.
510
- */
511
- const TELEPORT_GCS_TIMEOUT_MS = 60_000;
512
-
513
- function createSimpleMigrationProxy(
514
- config: GatewayConfig,
515
- opts: {
516
- method: "POST" | "GET";
517
- upstreamPathFor: (pathParam: string | null) => string;
518
- logTag: string;
519
- },
520
- ) {
521
- const { method, upstreamPathFor, logTag } = opts;
522
-
523
- return async function handleSimpleMigrationProxy(
524
- req: Request,
525
- pathParam: string | null = null,
526
- ): Promise<Response> {
527
- const start = performance.now();
528
- const hasBody = method !== "GET";
529
- const bodyBuffer = hasBody ? await req.arrayBuffer() : null;
530
-
531
- const upstream = `${config.assistantRuntimeBaseUrl}${upstreamPathFor(
532
- pathParam,
533
- )}`;
534
-
535
- const reqHeaders = stripHopByHop(new Headers(req.headers));
536
- reqHeaders.delete("host");
537
- reqHeaders.delete("authorization");
538
- reqHeaders.set("authorization", `Bearer ${mintServiceToken()}`);
539
- if (bodyBuffer !== null) {
540
- reqHeaders.set("content-length", String(bodyBuffer.byteLength));
541
- }
542
-
543
- const controller = new AbortController();
544
- const timeoutId = setTimeout(() => {
545
- controller.abort(
546
- new DOMException(
547
- "The operation was aborted due to timeout",
548
- "TimeoutError",
549
- ),
550
- );
551
- }, TELEPORT_GCS_TIMEOUT_MS);
552
-
553
- let response: Response;
554
- try {
555
- response = await fetchImpl(upstream, {
556
- method,
557
- headers: reqHeaders,
558
- body: bodyBuffer,
559
- signal: controller.signal,
560
- });
561
- clearTimeout(timeoutId);
562
- } catch (err) {
563
- clearTimeout(timeoutId);
564
- const duration = Math.round(performance.now() - start);
565
- if (err instanceof DOMException && err.name === "TimeoutError") {
566
- log.error({ duration }, `${logTag} proxy upstream timed out`);
567
- return Response.json({ error: "Gateway Timeout" }, { status: 504 });
568
- }
569
- log.error(
570
- { err, duration },
571
- `${logTag} proxy upstream connection failed`,
572
- );
573
- return Response.json({ error: "Bad Gateway" }, { status: 502 });
574
- }
575
-
576
- const resHeaders = stripHopByHop(new Headers(response.headers));
577
- const duration = Math.round(performance.now() - start);
578
-
579
- if (response.status >= 400) {
580
- const body = await response.text();
581
- log.warn(
582
- { status: response.status, duration },
583
- `${logTag} proxy upstream error`,
584
- );
585
- return new Response(body, {
586
- status: response.status,
587
- headers: resHeaders,
588
- });
589
- }
590
-
591
- log.info(
592
- { status: response.status, duration },
593
- `${logTag} proxy completed`,
594
- );
595
- return new Response(response.body, {
596
- status: response.status,
597
- headers: resHeaders,
598
- });
599
- };
600
- }
601
-
602
- /** POST /v1/migrations/export-to-gcs → daemon. Returns 202 { job_id }. */
603
- export function createMigrationExportToGcsProxyHandler(config: GatewayConfig) {
604
- return createSimpleMigrationProxy(config, {
605
- method: "POST",
606
- upstreamPathFor: () => "/v1/migrations/export-to-gcs",
607
- logTag: "Migration export-to-gcs",
608
- });
609
- }
610
-
611
- /** POST /v1/migrations/import-from-gcs → daemon. Returns 202 { job_id }. */
612
- export function createMigrationImportFromGcsProxyHandler(
613
- config: GatewayConfig,
614
- ) {
615
- return createSimpleMigrationProxy(config, {
616
- method: "POST",
617
- upstreamPathFor: () => "/v1/migrations/import-from-gcs",
618
- logTag: "Migration import-from-gcs",
619
- });
620
- }
621
-
622
- /** GET /v1/migrations/jobs/:job_id → daemon. Returns job status JSON. */
623
- export function createMigrationJobStatusProxyHandler(config: GatewayConfig) {
624
- const proxy = createSimpleMigrationProxy(config, {
625
- method: "GET",
626
- upstreamPathFor: (jobId) =>
627
- `/v1/migrations/jobs/${encodeURIComponent(jobId ?? "")}`,
628
- logTag: "Migration job-status",
629
- });
630
- return async function handleMigrationJobStatus(
631
- req: Request,
632
- jobId: string,
633
- ): Promise<Response> {
634
- return proxy(req, jobId);
635
- };
636
- }
@@ -1,39 +0,0 @@
1
- // Authenticated POST to a daemon internal-telemetry route: service-token
2
- // auth, JSON body, bounded timeout, response body consumed so the connection
3
- // is released. Callers own the failure posture (re-queue vs drop) — this
4
- // helper only performs the transport.
5
-
6
- import { buildUpstreamUrl } from "@mycadet/assistant-client";
7
-
8
- import { mintServiceToken } from "./auth/token-exchange.js";
9
- import { fetchImpl as defaultFetchImpl } from "./fetch.js";
10
-
11
- const POST_TIMEOUT_MS = 10_000;
12
-
13
- export async function postInternalTelemetry(args: {
14
- /** Daemon runtime base URL (`config.assistantRuntimeBaseUrl`). */
15
- baseUrl: string;
16
- /** Route path, e.g. `/v1/internal/telemetry/watchdog`. */
17
- path: string;
18
- body: unknown;
19
- /** Injectable for tests. Defaults to the shared fetch wrapper. */
20
- fetchImpl?: typeof defaultFetchImpl;
21
- /** Injectable for tests. Defaults to minting a real service token. */
22
- mintToken?: () => string;
23
- }): Promise<Response> {
24
- const url = buildUpstreamUrl(args.baseUrl, args.path);
25
- const doFetch = args.fetchImpl ?? defaultFetchImpl;
26
- const mintToken = args.mintToken ?? mintServiceToken;
27
-
28
- const resp = await doFetch(url, {
29
- method: "POST",
30
- headers: {
31
- "Content-Type": "application/json",
32
- Authorization: `Bearer ${mintToken()}`,
33
- },
34
- body: JSON.stringify(args.body),
35
- signal: AbortSignal.timeout(POST_TIMEOUT_MS),
36
- });
37
- await resp.text(); // release the connection
38
- return resp;
39
- }