@openparachute/vault 0.4.8 → 0.4.9-rc.11

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 (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * HTTP surface for the mirror lifecycle.
3
3
  *
4
- * GET /vault/<name>/.parachute/mirror — read current config + runtime status
5
- * PUT /vault/<name>/.parachute/mirror — update config + reload watch loop
4
+ * GET /vault/<name>/.parachute/mirror — read current config + runtime status
5
+ * PUT /vault/<name>/.parachute/mirror — update config + reload watch loop
6
+ * POST /vault/<name>/.parachute/mirror/run-now — fire a one-shot export+commit+push pass
6
7
  *
7
8
  * URL note: the design doc names this `/admin/mirror`, but vault's
8
9
  * existing routing already mounts the admin SPA's static-file bundle at
@@ -30,6 +31,39 @@ import {
30
31
  type MirrorConfig,
31
32
  } from "./mirror-config.ts";
32
33
  import type { MirrorManager } from "./mirror-manager.ts";
34
+ import {
35
+ applyToGitRemote,
36
+ deleteCredentials,
37
+ emptyCredentials,
38
+ readCredentials,
39
+ sanitizeCredentials,
40
+ unsetGitRemote,
41
+ writeCredentials,
42
+ type MirrorCredentials,
43
+ } from "./mirror-credentials.ts";
44
+ import {
45
+ createRepo,
46
+ fetchUser,
47
+ getGithubClientId,
48
+ isPlaceholderClientId,
49
+ listRepos,
50
+ pollForToken,
51
+ requestDeviceCode,
52
+ type FetchLike,
53
+ type GitHubRepoInfo,
54
+ } from "./github-device-flow.ts";
55
+ import {
56
+ CloneFailedError,
57
+ ImportConflictError,
58
+ NotAVaultExportError,
59
+ cloneAndImport,
60
+ type GitSpawn,
61
+ type ImportAuth,
62
+ type ImportResult,
63
+ } from "./mirror-import.ts";
64
+ import { redactToken } from "./export-watch.ts";
65
+ import { getVaultStore } from "./vault-store.ts";
66
+ import { assetsDir } from "./routes.ts";
33
67
 
34
68
  /**
35
69
  * `GET /vault/<name>/.parachute/mirror` — return the persisted config +
@@ -41,7 +75,11 @@ import type { MirrorManager } from "./mirror-manager.ts";
41
75
  * any persistence has happened yet.
42
76
  */
43
77
  export function handleMirrorGet(manager: MirrorManager): Response {
44
- const config = manager.getConfig();
78
+ // Per-vault (vault#400): `getEffectiveConfig()` returns THIS vault's
79
+ // persisted config even when the lazily-built manager hasn't started yet,
80
+ // so a non-default vault's page shows ITS config — never the default
81
+ // vault's. That's the exact "same remote on every vault page" symptom.
82
+ const config = manager.getEffectiveConfig();
45
83
  const status = manager.getStatus();
46
84
  return Response.json(
47
85
  {
@@ -58,9 +96,12 @@ export function handleMirrorGet(manager: MirrorManager): Response {
58
96
  * lifecycle.
59
97
  *
60
98
  * Request shape: same JSON as the MirrorConfig type — { enabled,
61
- * location, external_path, watch, auto_commit, auto_push,
62
- * commit_template, interval_seconds }. All fields optional; missing
63
- * fields fall back to defaults.
99
+ * location, external_path, sync_mode, auto_commit, auto_push,
100
+ * commit_template, safety_net_seconds }. All fields optional; missing
101
+ * fields fall back to defaults. Legacy `watch: boolean` and
102
+ * `interval_seconds: number` are also accepted (back-compat with
103
+ * hand-edited configs); they translate to `sync_mode` / `safety_net_seconds`
104
+ * via `validateMirrorConfigShape`.
64
105
  *
65
106
  * Validation surface:
66
107
  * - JSON shape: location ∈ {internal, external}, types match, etc.
@@ -93,7 +134,9 @@ export async function handleMirrorPut(
93
134
  );
94
135
  }
95
136
 
96
- const shape = validateMirrorConfigShape(body);
137
+ // Per-vault (vault#399): bind the auto_push/internal credential gate to
138
+ // the mirror-owning vault so it reads that vault's own credentials file.
139
+ const shape = validateMirrorConfigShape(body, { vaultName: manager.getVaultName() });
97
140
  if (!shape.ok) {
98
141
  return Response.json(
99
142
  {
@@ -137,6 +180,82 @@ export async function handleMirrorPut(
137
180
  );
138
181
  }
139
182
 
183
+ /**
184
+ * `POST /vault/<name>/.parachute/mirror/run-now` — fire a one-shot export
185
+ * cycle right now (export → optional commit → optional push), using the
186
+ * persisted config. Same response shape as GET so the admin SPA reuses
187
+ * one decoder for both initial-load and after-trigger refresh.
188
+ *
189
+ * Refuses to fire (400) when the mirror is disabled: `runNow()` would
190
+ * already no-op in that case, but returning a 200 with stale status
191
+ * lets a misclick look successful. The 400 is the actionable surface
192
+ * — "enable the mirror first, then re-trigger."
193
+ *
194
+ * Mutating verb, vault:admin-gated upstream in `routing.ts` (alongside
195
+ * the GET/PUT). Auth is already enforced by the time this handler runs.
196
+ */
197
+ export async function handleMirrorRunNow(
198
+ manager: MirrorManager,
199
+ ): Promise<Response> {
200
+ const status = manager.getStatus();
201
+ if (!status.enabled) {
202
+ return Response.json(
203
+ {
204
+ error: "Mirror not enabled",
205
+ message:
206
+ "Mirror must be enabled (and successfully bootstrapped) before a manual run can fire. Enable it via PUT /.parachute/mirror first.",
207
+ },
208
+ { status: 400 },
209
+ );
210
+ }
211
+ const updated = await manager.runNow();
212
+ return Response.json(
213
+ {
214
+ config: manager.getConfig(),
215
+ status: updated,
216
+ },
217
+ { headers: { "Access-Control-Allow-Origin": "*" } },
218
+ );
219
+ }
220
+
221
+ /**
222
+ * `POST /vault/<name>/.parachute/mirror/push-now` — Cut 6 of vault#392.
223
+ *
224
+ * Fire a push against the currently-committed state of the mirror dir.
225
+ * Distinguished from `/run-now` which exports + commits + pushes —
226
+ * `push-now` is the credentials-side flow where the operator just wants
227
+ * to see "did the credentials I just saved actually work?"
228
+ *
229
+ * Returns the post-push status snapshot. Errors (no path, mirror
230
+ * disabled, push failed) surface as a JSON response — push failures
231
+ * are NOT 500s because the operator's expected next-step is to look at
232
+ * `last_push_error` and fix their remote, not "vault crashed."
233
+ */
234
+ export async function handleMirrorPushNow(
235
+ manager: MirrorManager,
236
+ ): Promise<Response> {
237
+ const status = manager.getStatus();
238
+ if (!status.enabled) {
239
+ return Response.json(
240
+ {
241
+ error: "Mirror not enabled",
242
+ message:
243
+ "Mirror must be enabled before a push can fire. Enable it via PUT /.parachute/mirror first.",
244
+ },
245
+ { status: 400 },
246
+ );
247
+ }
248
+ const result = await manager.pushNow();
249
+ return Response.json(
250
+ {
251
+ config: manager.getConfig(),
252
+ status: manager.getStatus(),
253
+ push: result,
254
+ },
255
+ { headers: { "Access-Control-Allow-Origin": "*" } },
256
+ );
257
+ }
258
+
140
259
  /**
141
260
  * Convenience for tests + future callers: build the GET response from a
142
261
  * known-good config without needing a real MirrorManager.
@@ -150,3 +269,988 @@ export function buildMirrorGetResponse(
150
269
  status,
151
270
  };
152
271
  }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Credential routes — Cut 3 of the UI-configurable push credentials work.
275
+ //
276
+ // Six surfaces, all `vault:<name>:admin`-gated upstream:
277
+ //
278
+ // POST /.parachute/mirror/auth/github/device-code — start GitHub Device
279
+ // Flow; returns { polling_id, user_code, verification_uri, expires_in,
280
+ // interval }. The full device_code is kept server-side; the SPA polls
281
+ // by polling_id (a short opaque token) so the device_code doesn't
282
+ // land on the wire twice.
283
+ // POST /.parachute/mirror/auth/github/poll — poll for token, body
284
+ // { polling_id }. On `granted`: fetch user, save credentials, set
285
+ // remote URL, return { state: "granted", user }. Other states
286
+ // surface verbatim.
287
+ // POST /.parachute/mirror/auth/pat — validate + store a
288
+ // PAT (token + remote_url + label). Validates via `git ls-remote`.
289
+ // GET /.parachute/mirror/auth — current connection
290
+ // status (NO secrets). Returns the sanitized public shape.
291
+ // DELETE /.parachute/mirror/auth — wipe credentials,
292
+ // unset embedded-credential remote URL.
293
+ // GET /.parachute/mirror/auth/github/repos — list operator's
294
+ // GitHub repos via stored OAuth token.
295
+ // POST /.parachute/mirror/auth/github/create-repo — create a new private
296
+ // repo on behalf of the operator.
297
+ //
298
+ // ---------------------------------------------------------------------------
299
+
300
+ /**
301
+ * In-memory device-flow polling sessions. Maps a short opaque `polling_id`
302
+ * to the server-side `device_code` + metadata. Lives in process memory only;
303
+ * a vault restart blanks them (the operator restarts the flow, no big deal —
304
+ * the OAuth app's tokens that have already landed in credentials.yaml
305
+ * survive).
306
+ *
307
+ * 15-minute TTL — matches GitHub's default `expires_in` and saves us
308
+ * leaking polling slots on abandoned flows.
309
+ */
310
+ interface DeviceFlowSession {
311
+ device_code: string;
312
+ client_id: string;
313
+ expires_at: number; // epoch ms
314
+ interval: number;
315
+ }
316
+ const deviceFlowSessions = new Map<string, DeviceFlowSession>();
317
+
318
+ function generatePollingId(): string {
319
+ // 16 hex chars from crypto. Not a secret (it's just a session-lookup
320
+ // key) but cryptographic strength keeps two concurrent operators from
321
+ // ever colliding.
322
+ const bytes = new Uint8Array(8);
323
+ crypto.getRandomValues(bytes);
324
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
325
+ }
326
+
327
+ function reapExpiredSessions(now = Date.now()): void {
328
+ for (const [k, v] of deviceFlowSessions.entries()) {
329
+ if (v.expires_at <= now) deviceFlowSessions.delete(k);
330
+ }
331
+ }
332
+
333
+ /** Test seam — flush all in-memory sessions. */
334
+ export function _resetDeviceFlowSessionsForTest(): void {
335
+ deviceFlowSessions.clear();
336
+ }
337
+
338
+ /**
339
+ * Errors out cleanly when the operator hasn't replaced the placeholder
340
+ * client_id. The user-facing message explains the next step.
341
+ */
342
+ function placeholderClientIdResponse(): Response {
343
+ return Response.json(
344
+ {
345
+ error: "GitHub OAuth not configured",
346
+ error_type: "placeholder_client_id",
347
+ message:
348
+ "This Parachute Vault build doesn't have a registered GitHub OAuth App client_id. Set the PARACHUTE_GITHUB_CLIENT_ID environment variable (see src/github-device-flow.ts for setup steps) or use the Personal Access Token path instead.",
349
+ },
350
+ { status: 503 },
351
+ );
352
+ }
353
+
354
+ /**
355
+ * `POST /.parachute/mirror/auth/github/device-code` — kick off the device
356
+ * flow. Server retains the `device_code`; the SPA gets a short
357
+ * `polling_id` it uses to poll without re-sending the device_code on
358
+ * every round-trip.
359
+ */
360
+ export async function handleAuthGithubDeviceCode(
361
+ fetchImpl?: FetchLike,
362
+ ): Promise<Response> {
363
+ const clientId = getGithubClientId();
364
+ if (isPlaceholderClientId(clientId)) {
365
+ return placeholderClientIdResponse();
366
+ }
367
+ let result;
368
+ try {
369
+ result = await requestDeviceCode(clientId, fetchImpl);
370
+ } catch (err) {
371
+ return Response.json(
372
+ {
373
+ error: "Device code request failed",
374
+ message: (err as Error).message ?? String(err),
375
+ },
376
+ { status: 502 },
377
+ );
378
+ }
379
+ reapExpiredSessions();
380
+ const polling_id = generatePollingId();
381
+ deviceFlowSessions.set(polling_id, {
382
+ device_code: result.device_code,
383
+ client_id: clientId,
384
+ expires_at: Date.now() + result.expires_in * 1000,
385
+ interval: result.interval,
386
+ });
387
+ return Response.json(
388
+ {
389
+ polling_id,
390
+ user_code: result.user_code,
391
+ verification_uri: result.verification_uri,
392
+ expires_in: result.expires_in,
393
+ interval: result.interval,
394
+ },
395
+ { headers: { "Access-Control-Allow-Origin": "*" } },
396
+ );
397
+ }
398
+
399
+ /**
400
+ * `POST /.parachute/mirror/auth/github/poll` — poll for token (body
401
+ * `{polling_id}`). On `granted`, fetch user, save credentials, return
402
+ * `{state: "granted", user}`. On `pending`/`slow_down`, return state +
403
+ * any new interval. On terminal failure, return state + cleanup the
404
+ * session entry.
405
+ */
406
+ export async function handleAuthGithubPoll(
407
+ req: Request,
408
+ manager: MirrorManager,
409
+ fetchImpl?: FetchLike,
410
+ ): Promise<Response> {
411
+ let body: { polling_id?: unknown };
412
+ try {
413
+ body = (await req.json()) as { polling_id?: unknown };
414
+ } catch (err) {
415
+ return Response.json(
416
+ { error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
417
+ { status: 400 },
418
+ );
419
+ }
420
+ if (typeof body.polling_id !== "string") {
421
+ return Response.json(
422
+ { error: "polling_id required", message: "Pass the polling_id from /auth/github/device-code." },
423
+ { status: 400 },
424
+ );
425
+ }
426
+ reapExpiredSessions();
427
+ const session = deviceFlowSessions.get(body.polling_id);
428
+ if (!session) {
429
+ return Response.json(
430
+ {
431
+ state: "expired",
432
+ message: "Polling session not found or expired. Start the device flow again.",
433
+ },
434
+ { status: 404 },
435
+ );
436
+ }
437
+ let poll;
438
+ try {
439
+ poll = await pollForToken(session.client_id, session.device_code, fetchImpl);
440
+ } catch (err) {
441
+ return Response.json(
442
+ {
443
+ error: "Poll failed",
444
+ message: (err as Error).message ?? String(err),
445
+ },
446
+ { status: 502 },
447
+ );
448
+ }
449
+
450
+ if (poll.state === "granted") {
451
+ // Fetch user info to populate credentials.
452
+ let user;
453
+ try {
454
+ user = await fetchUser(poll.access_token, fetchImpl);
455
+ } catch (err) {
456
+ return Response.json(
457
+ {
458
+ error: "Token granted but /user fetch failed",
459
+ message: (err as Error).message ?? String(err),
460
+ },
461
+ { status: 502 },
462
+ );
463
+ }
464
+ // Persist credentials. Keep any existing PAT — only swap active method.
465
+ const vaultName = manager.getVaultName();
466
+ const existing = readCredentials(vaultName) ?? emptyCredentials();
467
+ const next: MirrorCredentials = {
468
+ ...existing,
469
+ active_method: "github_oauth",
470
+ github_oauth: {
471
+ access_token: poll.access_token,
472
+ scope: poll.scope,
473
+ authorized_at: new Date().toISOString(),
474
+ user_login: user.login,
475
+ user_id: user.id,
476
+ },
477
+ };
478
+ try {
479
+ writeCredentials(vaultName, next);
480
+ } catch (err) {
481
+ return Response.json(
482
+ {
483
+ error: "Credentials write failed",
484
+ message: (err as Error).message ?? String(err),
485
+ },
486
+ { status: 500 },
487
+ );
488
+ }
489
+ // Clean up the polling session.
490
+ deviceFlowSessions.delete(body.polling_id);
491
+ // Apply to git remote if mirror is currently running on an external
492
+ // path that's a git repo. The credentials become active on next push;
493
+ // the operator doesn't have to restart vault. We don't have an owner/
494
+ // repo yet (the operator hasn't picked a repo) — that wiring happens
495
+ // in the create-repo or repo-picked path. So at this point we just
496
+ // store credentials; the URL gets set when the operator picks a repo.
497
+ return Response.json(
498
+ {
499
+ state: "granted",
500
+ user: {
501
+ login: user.login,
502
+ id: user.id,
503
+ name: user.name,
504
+ avatar_url: user.avatar_url,
505
+ },
506
+ },
507
+ { headers: { "Access-Control-Allow-Origin": "*" } },
508
+ );
509
+ }
510
+
511
+ if (poll.state === "expired" || poll.state === "denied") {
512
+ deviceFlowSessions.delete(body.polling_id);
513
+ }
514
+
515
+ // Pending / slow_down / expired / denied — surface verbatim.
516
+ const responseBody: Record<string, unknown> = { state: poll.state };
517
+ if (poll.state === "slow_down") responseBody.interval = poll.interval;
518
+ return Response.json(responseBody, {
519
+ headers: { "Access-Control-Allow-Origin": "*" },
520
+ });
521
+ }
522
+
523
+ /**
524
+ * `POST /.parachute/mirror/auth/pat` — store a PAT + remote URL.
525
+ *
526
+ * Validation:
527
+ * - `token` is a non-empty string.
528
+ * - `remote_url` is a non-empty string that parses as an HTTPS URL.
529
+ * - `git ls-remote <remote_url>` succeeds with timeout 10s. The token
530
+ * can be embedded in the URL or rely on the server's git config —
531
+ * we just need git to be able to talk to the remote.
532
+ *
533
+ * Probes via `git ls-remote` with GIT_TERMINAL_PROMPT=0 (no interactive
534
+ * prompts; bad creds fail fast) and a 10-second hard timeout.
535
+ */
536
+ export async function handleAuthPat(
537
+ req: Request,
538
+ manager: MirrorManager,
539
+ ): Promise<Response> {
540
+ let body: { token?: unknown; remote_url?: unknown; label?: unknown };
541
+ try {
542
+ body = (await req.json()) as Record<string, unknown>;
543
+ } catch (err) {
544
+ return Response.json(
545
+ { error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
546
+ { status: 400 },
547
+ );
548
+ }
549
+ const token = typeof body.token === "string" ? body.token.trim() : "";
550
+ const remote_url = typeof body.remote_url === "string" ? body.remote_url.trim() : "";
551
+ const label =
552
+ typeof body.label === "string" && body.label.trim().length > 0
553
+ ? body.label.trim()
554
+ : "Custom git credential";
555
+ if (token.length === 0) {
556
+ return Response.json(
557
+ {
558
+ error: "token required",
559
+ field: "token",
560
+ message: "Provide a personal access token (e.g. ghp_...).",
561
+ },
562
+ { status: 400 },
563
+ );
564
+ }
565
+ if (remote_url.length === 0) {
566
+ return Response.json(
567
+ {
568
+ error: "remote_url required",
569
+ field: "remote_url",
570
+ message: "Provide the full HTTPS remote URL (e.g. https://github.com/owner/repo.git).",
571
+ },
572
+ { status: 400 },
573
+ );
574
+ }
575
+ // Quick URL shape check.
576
+ let parsed: URL;
577
+ try {
578
+ parsed = new URL(remote_url);
579
+ } catch {
580
+ return Response.json(
581
+ {
582
+ error: "remote_url invalid",
583
+ field: "remote_url",
584
+ message: "remote_url must be a valid URL (https://host/owner/repo.git).",
585
+ },
586
+ { status: 400 },
587
+ );
588
+ }
589
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
590
+ return Response.json(
591
+ {
592
+ error: "remote_url invalid",
593
+ field: "remote_url",
594
+ message: "remote_url must use http:// or https://; SSH remotes need a different flow.",
595
+ },
596
+ { status: 400 },
597
+ );
598
+ }
599
+
600
+ // Validate via `git ls-remote <embedded-auth-url>` — uses the same
601
+ // x-access-token shape we'd embed at push time so the probe exercises
602
+ // the actual auth path. If the operator pasted a URL that already has
603
+ // userinfo, use it verbatim; otherwise embed token via x-access-token.
604
+ const authedUrl =
605
+ parsed.username || parsed.password
606
+ ? remote_url
607
+ : (() => {
608
+ const u = new URL(remote_url);
609
+ u.username = "x-access-token";
610
+ u.password = token;
611
+ return u.toString();
612
+ })();
613
+ const probeResult = await probeGitLsRemote(authedUrl, 10_000);
614
+ if (!probeResult.ok) {
615
+ return Response.json(
616
+ {
617
+ error: "Probe failed",
618
+ message: `git ls-remote could not reach ${parsed.host}: ${probeResult.error}`,
619
+ },
620
+ { status: 400 },
621
+ );
622
+ }
623
+
624
+ // Save the userinfo'd URL — that's what gets embedded as `origin` so
625
+ // bare `git push` works without needing GIT_ASKPASS etc. Per-vault
626
+ // (vault#399): the PAT + remote_url land in this vault's own file, not a
627
+ // server-wide one — so configuring vault B never reuses vault A's remote.
628
+ const vaultName = manager.getVaultName();
629
+ const next: MirrorCredentials = {
630
+ ...(readCredentials(vaultName) ?? emptyCredentials()),
631
+ active_method: "pat",
632
+ pat: {
633
+ token,
634
+ remote_url: authedUrl,
635
+ label,
636
+ },
637
+ };
638
+ try {
639
+ writeCredentials(vaultName, next);
640
+ } catch (err) {
641
+ return Response.json(
642
+ {
643
+ error: "Credentials write failed",
644
+ message: (err as Error).message ?? String(err),
645
+ },
646
+ { status: 500 },
647
+ );
648
+ }
649
+
650
+ // Push the new URL onto the mirror's git remote if it's currently
651
+ // resolved + on disk. Non-fatal if the mirror isn't running.
652
+ await applyCredentialsToMirror(manager);
653
+
654
+ // Cut 3: auto-enable auto_push when credentials save. Operator wiring
655
+ // credentials almost certainly wants pushes to fire — silent
656
+ // "credentials saved but pushes never happen" is the bug Aaron hit.
657
+ // If `auto_push` was already true, leave it alone (idempotent).
658
+ const autoPushChange = await maybeEnableAutoPush(manager);
659
+
660
+ // Cut 6: kick off an initial push-now so the operator sees the push
661
+ // happen immediately rather than waiting for the next write event.
662
+ // Only when (a) auto_push ended up true AND (b) there are local commits
663
+ // ahead of the (now-configured) remote.
664
+ const initialPush = await maybeFireInitialPush(manager, autoPushChange.auto_push_now_enabled);
665
+
666
+ return Response.json(
667
+ {
668
+ ...sanitizeCredentials(next),
669
+ auto_push_was_already_enabled: autoPushChange.was_already_enabled,
670
+ auto_push_enabled: autoPushChange.auto_push_now_enabled,
671
+ initial_push: initialPush,
672
+ },
673
+ {
674
+ headers: { "Access-Control-Allow-Origin": "*" },
675
+ },
676
+ );
677
+ }
678
+
679
+ /**
680
+ * Run `git ls-remote <url>` with a hard timeout, no interactive prompts.
681
+ *
682
+ * Threat-model note (reviewer-flagged on vault#384):
683
+ * The URL passed here contains the user's token in userinfo position
684
+ * (`https://x-access-token:<TOKEN>@host/repo.git`). On Linux that means
685
+ * the token sits in `/proc/<pid>/cmdline` for the ~10s probe window,
686
+ * readable by any process running as another UID with default perms.
687
+ * For vault's threat model (owner-operated, single-user self-host) this
688
+ * is acceptable — anyone with shell on the box already has read access
689
+ * to `~/.parachute/vault/.mirror-credentials.yaml` (0600) and
690
+ * `<mirror>/.git/config` (0644). Same posture as `~/.git-credentials`
691
+ * and `~/.netrc`. If we ever ship multi-tenant vault (cloud Tier 2)
692
+ * this needs to switch to env-based auth via a credential helper script
693
+ * so the token never enters argv.
694
+ */
695
+ async function probeGitLsRemote(
696
+ url: string,
697
+ timeoutMs: number,
698
+ ): Promise<{ ok: boolean; error?: string }> {
699
+ // GIT_TERMINAL_PROMPT=0 ensures bad credentials FAIL FAST instead of
700
+ // sitting at "Username:" indefinitely (which the timeout would then
701
+ // catch, but failing fast on the auth wall is the better UX).
702
+ const proc = Bun.spawn(["git", "ls-remote", url], {
703
+ stdout: "pipe",
704
+ stderr: "pipe",
705
+ env: {
706
+ ...process.env,
707
+ GIT_TERMINAL_PROMPT: "0",
708
+ // Also kill any system credential helper from intercepting — we
709
+ // want the probe to use ONLY the URL-embedded credential, not
710
+ // whatever's in keychain.
711
+ GIT_ASKPASS: "/bin/echo",
712
+ },
713
+ });
714
+ const timer = setTimeout(() => {
715
+ try {
716
+ proc.kill();
717
+ } catch {
718
+ // already exited
719
+ }
720
+ }, timeoutMs);
721
+ const exitCode = await proc.exited;
722
+ clearTimeout(timer);
723
+ if (exitCode === 0) return { ok: true };
724
+ const stderr = new TextDecoder()
725
+ .decode(await new Response(proc.stderr).arrayBuffer())
726
+ .trim();
727
+ // Redact userinfo from anything we surface back; git's error messages
728
+ // sometimes echo the URL.
729
+ const redacted = stderr.replace(/https?:\/\/[^@\s]+@/g, "https://***@");
730
+ return { ok: false, error: redacted };
731
+ }
732
+
733
+ /**
734
+ * `GET /.parachute/mirror/auth` — connection status (no secrets). Reads the
735
+ * mirror-owning vault's per-vault credentials (vault#399).
736
+ */
737
+ export function handleAuthGet(manager: MirrorManager): Response {
738
+ const creds = readCredentials(manager.getVaultName());
739
+ return Response.json(sanitizeCredentials(creds), {
740
+ headers: { "Access-Control-Allow-Origin": "*" },
741
+ });
742
+ }
743
+
744
+ /**
745
+ * `DELETE /.parachute/mirror/auth` — wipe credentials, clear the embedded
746
+ * remote URL on the mirror dir.
747
+ */
748
+ export async function handleAuthDelete(manager: MirrorManager): Promise<Response> {
749
+ deleteCredentials(manager.getVaultName());
750
+ // Strip origin from the mirror dir if one is set.
751
+ const status = manager.getStatus();
752
+ if (status.mirror_path) {
753
+ try {
754
+ await unsetGitRemote(status.mirror_path);
755
+ } catch (err) {
756
+ console.warn(
757
+ `[mirror-auth] failed to unset git remote (non-fatal): ${(err as Error).message ?? err}`,
758
+ );
759
+ }
760
+ }
761
+ return Response.json(sanitizeCredentials(null), {
762
+ headers: { "Access-Control-Allow-Origin": "*" },
763
+ });
764
+ }
765
+
766
+ /**
767
+ * `GET /.parachute/mirror/auth/github/repos` — list operator's repos via
768
+ * the stored OAuth token. Requires `active_method === "github_oauth"`.
769
+ */
770
+ export async function handleAuthGithubRepos(
771
+ manager: MirrorManager,
772
+ fetchImpl?: FetchLike,
773
+ ): Promise<Response> {
774
+ const creds = readCredentials(manager.getVaultName());
775
+ if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
776
+ return Response.json(
777
+ {
778
+ error: "Not connected to GitHub",
779
+ message: "Run the device flow first (POST /.parachute/mirror/auth/github/device-code).",
780
+ },
781
+ { status: 400 },
782
+ );
783
+ }
784
+ let result;
785
+ try {
786
+ result = await listRepos(creds.github_oauth.access_token, {}, fetchImpl);
787
+ } catch (err) {
788
+ return Response.json(
789
+ {
790
+ error: "Repo list failed",
791
+ message: (err as Error).message ?? String(err),
792
+ },
793
+ { status: 502 },
794
+ );
795
+ }
796
+ return Response.json(
797
+ { repos: result.repos, truncated: result.truncated },
798
+ { headers: { "Access-Control-Allow-Origin": "*" } },
799
+ );
800
+ }
801
+
802
+ /**
803
+ * `POST /.parachute/mirror/auth/github/create-repo` — create a new repo on
804
+ * the operator's account, return the new RepoInfo. The SPA flows straight
805
+ * from this into the "repo selected" state.
806
+ */
807
+ export async function handleAuthGithubCreateRepo(
808
+ req: Request,
809
+ manager: MirrorManager,
810
+ fetchImpl?: FetchLike,
811
+ ): Promise<Response> {
812
+ const creds = readCredentials(manager.getVaultName());
813
+ if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
814
+ return Response.json(
815
+ {
816
+ error: "Not connected to GitHub",
817
+ message: "Run the device flow first.",
818
+ },
819
+ { status: 400 },
820
+ );
821
+ }
822
+ let body: { name?: unknown; description?: unknown; private?: unknown };
823
+ try {
824
+ body = (await req.json()) as Record<string, unknown>;
825
+ } catch (err) {
826
+ return Response.json(
827
+ { error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
828
+ { status: 400 },
829
+ );
830
+ }
831
+ const name = typeof body.name === "string" ? body.name.trim() : "";
832
+ if (name.length === 0) {
833
+ return Response.json(
834
+ { error: "name required", field: "name", message: "Provide a repo name." },
835
+ { status: 400 },
836
+ );
837
+ }
838
+ const isPrivate = body.private === false ? false : true; // default true
839
+ const description = typeof body.description === "string" ? body.description : undefined;
840
+ let repo: GitHubRepoInfo;
841
+ try {
842
+ repo = await createRepo(
843
+ creds.github_oauth.access_token,
844
+ { name, description, private: isPrivate },
845
+ fetchImpl,
846
+ );
847
+ } catch (err) {
848
+ return Response.json(
849
+ { error: "Create failed", message: (err as Error).message ?? String(err) },
850
+ { status: 502 },
851
+ );
852
+ }
853
+ return Response.json(repo, { headers: { "Access-Control-Allow-Origin": "*" } });
854
+ }
855
+
856
+ /**
857
+ * `POST /.parachute/mirror/auth/github/select-repo` — operator picked a
858
+ * repo from the list (or just created one). Body `{owner, name}`. Writes
859
+ * the embedded-credential URL onto the mirror dir's `origin`.
860
+ */
861
+ export async function handleAuthGithubSelectRepo(
862
+ req: Request,
863
+ manager: MirrorManager,
864
+ ): Promise<Response> {
865
+ const creds = readCredentials(manager.getVaultName());
866
+ if (!creds || creds.active_method !== "github_oauth" || !creds.github_oauth) {
867
+ return Response.json(
868
+ {
869
+ error: "Not connected to GitHub",
870
+ message: "Run the device flow first.",
871
+ },
872
+ { status: 400 },
873
+ );
874
+ }
875
+ let body: { owner?: unknown; name?: unknown };
876
+ try {
877
+ body = (await req.json()) as Record<string, unknown>;
878
+ } catch (err) {
879
+ return Response.json(
880
+ { error: "Invalid JSON body", message: (err as Error).message ?? String(err) },
881
+ { status: 400 },
882
+ );
883
+ }
884
+ const owner = typeof body.owner === "string" ? body.owner.trim() : "";
885
+ const name = typeof body.name === "string" ? body.name.trim() : "";
886
+ if (!owner || !name) {
887
+ return Response.json(
888
+ {
889
+ error: "owner and name required",
890
+ message: "Provide both `owner` and `name` for the repo to push to.",
891
+ },
892
+ { status: 400 },
893
+ );
894
+ }
895
+ // Reach into mirror-credentials.ts for the authed URL builder.
896
+ const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
897
+ const authedUrl = githubAuthedRemoteUrl(
898
+ creds.github_oauth.access_token,
899
+ owner,
900
+ name,
901
+ );
902
+
903
+ // Apply to the mirror dir if running. If the mirror isn't running (no
904
+ // mirror_path), we still consider this a success — the credentials are
905
+ // stored, and the URL will get applied next time the mirror starts.
906
+ const status = manager.getStatus();
907
+ let applied = false;
908
+ if (status.mirror_path) {
909
+ const res = await applyToGitRemote(status.mirror_path, authedUrl);
910
+ if (!res.ok) {
911
+ return Response.json(
912
+ {
913
+ error: "Failed to set remote URL on mirror",
914
+ message: res.error,
915
+ },
916
+ { status: 500 },
917
+ );
918
+ }
919
+ applied = true;
920
+ }
921
+ // Cut 3 / Cut 6: auto-enable auto_push + fire initial push if there's
922
+ // anything to push. Same logic as handleAuthPat — operator picking a
923
+ // repo wants the push to fire.
924
+ const autoPushChange = await maybeEnableAutoPush(manager);
925
+ const initialPush = await maybeFireInitialPush(manager, autoPushChange.auto_push_now_enabled);
926
+
927
+ return Response.json(
928
+ {
929
+ ok: true,
930
+ applied,
931
+ owner,
932
+ name,
933
+ // Echo the redacted form back so the SPA can show "pushing to <repo>".
934
+ // No raw token in the response.
935
+ remote: `https://github.com/${owner}/${name}.git`,
936
+ auto_push_was_already_enabled: autoPushChange.was_already_enabled,
937
+ auto_push_enabled: autoPushChange.auto_push_now_enabled,
938
+ initial_push: initialPush,
939
+ },
940
+ { headers: { "Access-Control-Allow-Origin": "*" } },
941
+ );
942
+ }
943
+
944
+ /**
945
+ * Cut 3: when credentials save, flip `auto_push` from false → true on
946
+ * the persisted config. Operators wiring credentials almost certainly
947
+ * want pushes to fire — silent "credentials saved but no push" was the
948
+ * three-stacking-gaps bug Aaron hit.
949
+ *
950
+ * Idempotent: if `auto_push` was already true, no config write happens.
951
+ * If `auto_push` is false, write the flipped config via `manager.reload`
952
+ * — that restarts the lifecycle and applies the credentials to the
953
+ * remote in the same pass.
954
+ *
955
+ * Returns a small struct so the route handler can shape the response:
956
+ * - `was_already_enabled` — true iff auto_push was true before this call.
957
+ * - `auto_push_now_enabled` — true iff auto_push is true after this call.
958
+ *
959
+ * Both being false means the operator deliberately left auto_push off
960
+ * and we leave it that way (we never touch a config whose mirror is
961
+ * disabled, and we never flip true → false).
962
+ */
963
+ async function maybeEnableAutoPush(
964
+ manager: MirrorManager,
965
+ ): Promise<{ was_already_enabled: boolean; auto_push_now_enabled: boolean }> {
966
+ const config = manager.getConfig();
967
+ // Don't muck with auto_push when the mirror is disabled — the operator
968
+ // is configuring credentials before turning the mirror on, which is a
969
+ // legitimate sequence. They'll flip enabled themselves.
970
+ if (!config.enabled) {
971
+ return {
972
+ was_already_enabled: config.auto_push,
973
+ auto_push_now_enabled: config.auto_push,
974
+ };
975
+ }
976
+ if (config.auto_push) {
977
+ return { was_already_enabled: true, auto_push_now_enabled: true };
978
+ }
979
+ try {
980
+ await manager.reload({ ...config, auto_push: true });
981
+ return { was_already_enabled: false, auto_push_now_enabled: true };
982
+ } catch (err) {
983
+ console.warn(
984
+ `[mirror-auth] could not auto-enable auto_push (non-fatal): ${(err as Error).message ?? err}`,
985
+ );
986
+ return { was_already_enabled: false, auto_push_now_enabled: false };
987
+ }
988
+ }
989
+
990
+ /**
991
+ * Cut 6: after credential save, fire a `push-now` immediately so the
992
+ * operator sees the push happen rather than waiting for the next write
993
+ * event. Only when (a) auto_push is enabled (we just turned it on, or it
994
+ * was already on) AND (b) there are local commits to push.
995
+ *
996
+ * Best-effort: non-fatal on any failure. Returns a struct the caller
997
+ * folds into its response.
998
+ */
999
+ async function maybeFireInitialPush(
1000
+ manager: MirrorManager,
1001
+ autoPushEnabled: boolean,
1002
+ ): Promise<
1003
+ | { fired: false; reason: "auto_push_disabled" | "no_mirror_path" | "manager_skipped" | "not_enabled" }
1004
+ | { fired: true; pushed: boolean; error?: string; sha?: string }
1005
+ > {
1006
+ if (!autoPushEnabled) return { fired: false, reason: "auto_push_disabled" };
1007
+ const status = manager.getStatus();
1008
+ if (!status.mirror_path) return { fired: false, reason: "no_mirror_path" };
1009
+ // Need an active enabled mirror with a resolved path to push from.
1010
+ if (!status.enabled) return { fired: false, reason: "manager_skipped" };
1011
+ try {
1012
+ const result = await manager.pushNow();
1013
+ return result;
1014
+ } catch (err) {
1015
+ // Defense-in-depth: every other push-error site routes through
1016
+ // `redactToken` before surfacing — this catch-block was the one
1017
+ // outlier where a thrown error's `.message` could carry an
1018
+ // un-redacted token from a future code path that throws before the
1019
+ // existing internal redaction runs. Apply the same scrub here.
1020
+ // Reviewer-flagged on vault#392.
1021
+ const rawMessage = (err as Error).message ?? String(err);
1022
+ const safeMessage = redactToken(rawMessage);
1023
+ console.warn(`[mirror-auth] initial push-now failed (non-fatal): ${safeMessage}`);
1024
+ return { fired: true, pushed: false, error: safeMessage };
1025
+ }
1026
+ }
1027
+
1028
+ /**
1029
+ * Apply the active credential's remote URL to the running mirror dir.
1030
+ * Idempotent. Called from auth/pat (after store) + auth/github/select-repo
1031
+ * (after store). Non-fatal on failure — the credentials are saved either
1032
+ * way; the next mirror restart picks them up.
1033
+ */
1034
+ export async function applyCredentialsToMirror(
1035
+ manager: MirrorManager,
1036
+ ): Promise<void> {
1037
+ const status = manager.getStatus();
1038
+ if (!status.mirror_path) return;
1039
+ const creds = readCredentials(manager.getVaultName());
1040
+ if (!creds || !creds.active_method) {
1041
+ await unsetGitRemote(status.mirror_path);
1042
+ return;
1043
+ }
1044
+ if (creds.active_method === "pat" && creds.pat) {
1045
+ await applyToGitRemote(status.mirror_path, creds.pat.remote_url);
1046
+ return;
1047
+ }
1048
+ // GitHub OAuth — needs the operator to have picked a repo; the URL
1049
+ // wiring happens in handleAuthGithubSelectRepo. Nothing to apply here
1050
+ // until a repo is selected. Caller is responsible for invoking
1051
+ // select-repo separately.
1052
+ }
1053
+
1054
+ // ---------------------------------------------------------------------------
1055
+ // Import-from-git route — symmetric counterpart to the mirror export work.
1056
+ //
1057
+ // `POST /vault/<name>/.parachute/mirror/import` — clone a vault export from
1058
+ // a git remote and import it into the named vault.
1059
+ //
1060
+ // Request body:
1061
+ // {
1062
+ // "remote_url": "https://github.com/aaron/my-vault.git",
1063
+ // "mode": "merge" | "replace",
1064
+ // "credentials": null
1065
+ // | { "kind": "pat", "token": "ghp_..." }
1066
+ // | { "kind": "none" }
1067
+ // }
1068
+ //
1069
+ // `credentials: null` means "use the stored mirror credentials." Passing
1070
+ // `{kind: "pat", token}` is the one-shot path — token doesn't get persisted.
1071
+ //
1072
+ // Response:
1073
+ // 200 { notes_imported, tags_imported, attachments_imported,
1074
+ // notes_deleted?, warnings }
1075
+ // 400 { error, error_type, message } — validation / not-a-vault-export
1076
+ // 409 { error, error_type, message } — concurrent import for this vault
1077
+ // 502 { error, message } — clone failed (auth, network, …)
1078
+ //
1079
+ // Admin-gated upstream in routing.ts.
1080
+ // ---------------------------------------------------------------------------
1081
+
1082
+ /**
1083
+ * `POST /vault/<name>/.parachute/mirror/import`. See block comment above.
1084
+ *
1085
+ * Synchronous response: imports complete in <30s for typical vaults
1086
+ * (a 1k-note vault clones+imports in well under that bound on Aaron's hardware).
1087
+ * If/when bigger vaults arrive we promote to async polling — for now the
1088
+ * synchronous path keeps the UI flow simple.
1089
+ *
1090
+ * `spawnOverride` is a test seam: lets the test inject a fake git binary.
1091
+ * Production callers omit it; `cloneAndImport` falls back to `defaultGitSpawn`.
1092
+ */
1093
+ export async function handleMirrorImport(
1094
+ req: Request,
1095
+ vaultName: string,
1096
+ spawnOverride?: GitSpawn,
1097
+ ): Promise<Response> {
1098
+ let body: {
1099
+ remote_url?: unknown;
1100
+ mode?: unknown;
1101
+ credentials?: unknown;
1102
+ };
1103
+ try {
1104
+ body = (await req.json()) as Record<string, unknown>;
1105
+ } catch (err) {
1106
+ return Response.json(
1107
+ {
1108
+ error: "Invalid JSON body",
1109
+ error_type: "invalid_json",
1110
+ message: (err as Error).message ?? String(err),
1111
+ },
1112
+ { status: 400 },
1113
+ );
1114
+ }
1115
+
1116
+ const remote_url =
1117
+ typeof body.remote_url === "string" ? body.remote_url.trim() : "";
1118
+ if (remote_url.length === 0) {
1119
+ return Response.json(
1120
+ {
1121
+ error: "remote_url required",
1122
+ error_type: "validation",
1123
+ field: "remote_url",
1124
+ message: "Provide an HTTPS or SSH clone URL.",
1125
+ },
1126
+ { status: 400 },
1127
+ );
1128
+ }
1129
+ const mode = body.mode;
1130
+ if (mode !== "merge" && mode !== "replace") {
1131
+ return Response.json(
1132
+ {
1133
+ error: "mode invalid",
1134
+ error_type: "validation",
1135
+ field: "mode",
1136
+ message: 'mode must be "merge" or "replace".',
1137
+ },
1138
+ { status: 400 },
1139
+ );
1140
+ }
1141
+
1142
+ // Resolve auth shape. The wire format mirrors the internal ImportAuth
1143
+ // type, but tightened: only `kind` and `token` cross the wire. `none`
1144
+ // and `null` both fall through to "use stored credentials" because the
1145
+ // common case is "I configured mirror creds; use them" — and the
1146
+ // shorthand keeps the SPA from having to track which kind to send.
1147
+ let auth: ImportAuth;
1148
+ const creds = body.credentials;
1149
+ if (creds === null || creds === undefined) {
1150
+ auth = { kind: "credentialsFile" };
1151
+ } else if (typeof creds === "object") {
1152
+ const credsObj = creds as Record<string, unknown>;
1153
+ if (credsObj.kind === "pat") {
1154
+ const token = typeof credsObj.token === "string" ? credsObj.token.trim() : "";
1155
+ if (token.length === 0) {
1156
+ return Response.json(
1157
+ {
1158
+ error: "credentials.token required",
1159
+ error_type: "validation",
1160
+ field: "credentials.token",
1161
+ message: 'When credentials.kind is "pat", credentials.token must be a non-empty string.',
1162
+ },
1163
+ { status: 400 },
1164
+ );
1165
+ }
1166
+ auth = { kind: "pat", token };
1167
+ } else if (credsObj.kind === "none") {
1168
+ auth = { kind: "none" };
1169
+ } else if (credsObj.kind === "credentialsFile") {
1170
+ auth = { kind: "credentialsFile" };
1171
+ } else {
1172
+ return Response.json(
1173
+ {
1174
+ error: "credentials.kind invalid",
1175
+ error_type: "validation",
1176
+ field: "credentials.kind",
1177
+ message: 'credentials.kind must be "pat", "credentialsFile", or "none". Or pass credentials: null.',
1178
+ },
1179
+ { status: 400 },
1180
+ );
1181
+ }
1182
+ } else {
1183
+ return Response.json(
1184
+ {
1185
+ error: "credentials invalid",
1186
+ error_type: "validation",
1187
+ field: "credentials",
1188
+ message: "credentials must be an object or null.",
1189
+ },
1190
+ { status: 400 },
1191
+ );
1192
+ }
1193
+
1194
+ // Resolve the target vault's store + assets dir. The route is gated on
1195
+ // `vault:<name>:admin`, so we trust vaultName is real by the time we
1196
+ // reach this code path; defensively re-resolve in case the vault was
1197
+ // deleted between auth and now.
1198
+ const store = getVaultStore(vaultName);
1199
+ const assets = assetsDir(vaultName);
1200
+
1201
+ let result: ImportResult;
1202
+ try {
1203
+ result = await cloneAndImport({
1204
+ vaultName,
1205
+ remoteUrl: remote_url,
1206
+ auth,
1207
+ mode,
1208
+ store,
1209
+ assetsDir: assets,
1210
+ spawn: spawnOverride,
1211
+ });
1212
+ } catch (err) {
1213
+ if (err instanceof ImportConflictError) {
1214
+ return Response.json(
1215
+ {
1216
+ error: "Import already running",
1217
+ error_type: "concurrent_import",
1218
+ message: err.message,
1219
+ },
1220
+ { status: 409 },
1221
+ );
1222
+ }
1223
+ if (err instanceof NotAVaultExportError) {
1224
+ return Response.json(
1225
+ {
1226
+ error: "Not a vault export",
1227
+ error_type: "not_a_vault_export",
1228
+ message: err.message,
1229
+ },
1230
+ { status: 400 },
1231
+ );
1232
+ }
1233
+ if (err instanceof CloneFailedError) {
1234
+ return Response.json(
1235
+ {
1236
+ error: "Clone failed",
1237
+ error_type: "clone_failed",
1238
+ message: err.message,
1239
+ },
1240
+ { status: 502 },
1241
+ );
1242
+ }
1243
+ return Response.json(
1244
+ {
1245
+ error: "Import failed",
1246
+ error_type: "internal",
1247
+ message: (err as Error).message ?? String(err),
1248
+ },
1249
+ { status: 500 },
1250
+ );
1251
+ }
1252
+
1253
+ return Response.json(result, {
1254
+ headers: { "Access-Control-Allow-Origin": "*" },
1255
+ });
1256
+ }