@mycadet/gateway 0.12.11 → 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.
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/ces-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/gateway-client/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/node_modules/@mycadet/gateway-client/src/gateway-ipc-contracts.ts +0 -20
- package/node_modules/@mycadet/service-contracts/src/__tests__/ingress.test.ts +2 -2
- package/node_modules/@mycadet/service-contracts/src/ingress.ts +2 -2
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/__tests__/actor-token-revocation.test.ts +41 -34
- package/src/__tests__/contact-store-mirror-op-missing.test.ts +33 -70
- package/src/__tests__/contacts-mirror-op-reporter.test.ts +33 -89
- package/src/__tests__/credential-requests.test.ts +4 -4
- package/src/__tests__/devices-mine.test.ts +187 -0
- package/src/__tests__/edge-auth.test.ts +1 -20
- package/src/__tests__/edge-guardian-auth.test.ts +1 -17
- package/src/__tests__/feature-flag-resolver.test.ts +2 -30
- package/src/__tests__/feature-flag-watcher-callback.test.ts +0 -22
- package/src/__tests__/feature-flags-route.test.ts +2 -191
- package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -25
- package/src/__tests__/guardian-integrity-reporter.test.ts +51 -82
- package/src/__tests__/guardian-integrity.test.ts +16 -20
- package/src/__tests__/handle-inbound-trust-verdict.test.ts +1 -14
- package/src/__tests__/ipc-feature-flag-routes.test.ts +0 -56
- package/src/__tests__/lum-2783-recovery-e2e.test.ts +0 -12
- package/src/__tests__/post-assistant-ready-deferred.test.ts +0 -18
- package/src/__tests__/refresh-device-binding.test.ts +0 -12
- package/src/__tests__/remote-web-pairing-token.test.ts +4 -14
- package/src/__tests__/runtime-client.test.ts +7 -7
- package/src/__tests__/speech-relay-websocket.test.ts +4 -4
- package/src/__tests__/trust-verdict-resolver-guardian-integrity.test.ts +30 -36
- package/src/__tests__/twilio-webhooks.test.ts +2 -2
- package/src/auth/actor-token-revocation.ts +34 -9
- package/src/cli/__tests__/pairing.test.ts +95 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/pairing.ts +81 -6
- package/src/contacts-mirror-op-reporter.ts +19 -36
- package/src/db/connection.ts +0 -10
- package/src/feature-flag-resolver.ts +2 -7
- package/src/feature-flag-watcher.ts +1 -14
- package/src/guardian-integrity-reporter.ts +19 -47
- package/src/http/middleware/auth.ts +33 -27
- package/src/http/routes/auth-token.ts +1 -1
- package/src/http/routes/devices-mine.ts +103 -0
- package/src/http/routes/feature-flags.ts +1 -8
- package/src/http/routes/guardian-pin.ts +2 -11
- package/src/http/routes/ipc-runtime-proxy.ts +1 -1
- package/src/http/routes/live-voice-websocket.ts +1 -1
- package/src/http/routes/runtime-audio-stream.ts +1 -1
- package/src/http/routes/runtime-proxy.ts +2 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +13 -13
- package/src/index.ts +11 -198
- package/src/ipc/feature-flag-handlers.ts +2 -9
- package/src/remote-web/pairing-challenge-store.ts +15 -0
- package/src/risk/command-registry/commands/assistant.ts +0 -24
- package/src/risk/command-registry.test.ts +2 -4
- package/src/schema.ts +44 -372
- package/src/__tests__/auth-fallback-count-tracker.test.ts +0 -92
- package/src/__tests__/auth-fallback-reporter.test.ts +0 -101
- package/src/__tests__/backup-worker-cloud-mode.test.ts +0 -44
- package/src/__tests__/backup-worker-export-timeout.test.ts +0 -164
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -216
- package/src/__tests__/remote-feature-flag-sync.test.ts +0 -961
- package/src/auth-fallback-count-tracker.ts +0 -113
- package/src/auth-fallback-reporter.ts +0 -127
- package/src/backup/backup-key.ts +0 -138
- package/src/backup/backup-routes.ts +0 -161
- package/src/backup/backup-worker.ts +0 -425
- package/src/backup/list-snapshots.ts +0 -97
- package/src/backup/local-writer.ts +0 -87
- package/src/backup/offsite-writer.ts +0 -182
- package/src/backup/paths.ts +0 -113
- package/src/backup/stream-crypt.ts +0 -258
- package/src/device-id.ts +0 -18
- package/src/feature-flag-remote-store.ts +0 -166
- package/src/http/routes/migration-proxy.ts +0 -636
- package/src/internal-telemetry-client.ts +0 -39
- package/src/ipc/debug-export-handlers.test.ts +0 -102
- package/src/ipc/debug-export-handlers.ts +0 -81
- package/src/remote-feature-flag-sync.ts +0 -440
- 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
|
-
}
|