@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
@@ -16,7 +16,39 @@ import {
16
16
  MirrorManager,
17
17
  type MirrorDeps,
18
18
  } from "./mirror-manager.ts";
19
- import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
19
+ import {
20
+ _resetDeviceFlowSessionsForTest,
21
+ handleAuthDelete,
22
+ handleAuthGet,
23
+ handleAuthGithubCreateRepo,
24
+ handleAuthGithubDeviceCode,
25
+ handleAuthGithubPoll,
26
+ handleAuthGithubRepos,
27
+ handleAuthGithubSelectRepo,
28
+ handleAuthPat,
29
+ handleMirrorGet,
30
+ handleMirrorImport,
31
+ handleMirrorPushNow,
32
+ handleMirrorPut,
33
+ handleMirrorRunNow,
34
+ } from "./mirror-routes.ts";
35
+ import {
36
+ _resetImportInFlightForTest,
37
+ type GitSpawn,
38
+ } from "./mirror-import.ts";
39
+ import { writeVaultConfig } from "./config.ts";
40
+ import { clearVaultStoreCache } from "./vault-store.ts";
41
+ import { exportVaultToDir } from "../core/src/portable-md.ts";
42
+ import { Database } from "bun:sqlite";
43
+ import { SqliteStore } from "../core/src/store.ts";
44
+ import { cpSync, writeFileSync as nodeWriteFileSync } from "node:fs";
45
+ import {
46
+ mirrorCredentialsPath,
47
+ readCredentials,
48
+ writeCredentials,
49
+ type MirrorCredentials,
50
+ } from "./mirror-credentials.ts";
51
+ import type { FetchLike } from "./github-device-flow.ts";
20
52
 
21
53
  // Same env-restore pattern as mirror-manager.test.ts — keeps HOME +
22
54
  // PARACHUTE_HOME from leaking between test files.
@@ -294,18 +326,18 @@ describe("handleMirrorPut", () => {
294
326
  await manager.stop();
295
327
  });
296
328
 
297
- test("PUT restarts watch loop with new interval", async () => {
329
+ test("PUT restarts event-driven mirror lifecycle", async () => {
298
330
  home = tmp("mirror-put-restart-");
299
331
  const { manager } = makeManager(home);
300
- // Enable with watch.
332
+ // Enable with events sync_mode.
301
333
  const req1 = new Request("http://x/admin/mirror", {
302
334
  method: "PUT",
303
335
  body: JSON.stringify({
304
336
  enabled: true,
305
337
  location: "internal",
306
- watch: true,
338
+ sync_mode: "events",
307
339
  auto_commit: false,
308
- interval_seconds: 1,
340
+ safety_net_seconds: 60,
309
341
  }),
310
342
  });
311
343
  const res1 = await handleMirrorPut(req1, manager);
@@ -352,16 +384,16 @@ describe("handleMirrorPut", () => {
352
384
  put({
353
385
  enabled: true,
354
386
  location: "internal",
355
- watch: true,
387
+ sync_mode: "events",
356
388
  auto_commit: false,
357
- interval_seconds: 1,
389
+ safety_net_seconds: 60,
358
390
  }),
359
391
  put({
360
392
  enabled: true,
361
393
  location: "internal",
362
- watch: true,
394
+ sync_mode: "events",
363
395
  auto_commit: false,
364
- interval_seconds: 2,
396
+ safety_net_seconds: 120,
365
397
  }),
366
398
  ]);
367
399
  expect(res1.status).toBe(200);
@@ -374,7 +406,931 @@ describe("handleMirrorPut", () => {
374
406
  const status = manager.getStatus();
375
407
  expect(status.enabled).toBe(true);
376
408
  expect(status.watch_running).toBe(true);
377
- expect(manager.getConfig().interval_seconds).toBe(2);
409
+ expect(manager.getConfig().safety_net_seconds).toBe(120);
410
+ await manager.stop();
411
+ });
412
+ });
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // POST /.parachute/mirror/run-now
416
+ // ---------------------------------------------------------------------------
417
+
418
+ describe("handleMirrorRunNow", () => {
419
+ let home: string;
420
+ afterEach(() => {
421
+ if (home) fs.rmSync(home, { recursive: true, force: true });
422
+ });
423
+
424
+ test("returns 400 when mirror is disabled (avoids stale-status no-op)", async () => {
425
+ home = tmp("mirror-runnow-disabled-");
426
+ const { manager, exportCalls } = makeManager(home);
427
+ const res = await handleMirrorRunNow(manager);
428
+ expect(res.status).toBe(400);
429
+ const body = (await res.json()) as { error: string; message: string };
430
+ expect(body.error).toContain("not enabled");
431
+ // The disabled-guard short-circuits BEFORE manager.runNow(), so no
432
+ // export attempt happens — pinning this distinguishes the guard from
433
+ // a "200 with stale status" pass-through that would have looked
434
+ // identical to the operator.
435
+ expect(exportCalls()).toHaveLength(0);
436
+ });
437
+
438
+ test("fires an export pass and returns the updated config+status on success", async () => {
439
+ home = tmp("mirror-runnow-happy-");
440
+ const { manager, deps, exportCalls } = makeManager(home);
441
+ deps.writeMirrorConfig({
442
+ ...defaultMirrorConfig(),
443
+ enabled: true,
444
+ location: "internal",
445
+ watch: false,
446
+ auto_commit: false,
447
+ });
448
+ await manager.start();
449
+ // The initial export from start() already ran once. We pin the
450
+ // delta — run-now must trigger a SECOND export pass and the
451
+ // response must carry the updated status.
452
+ const exportsBefore = exportCalls().length;
453
+ const res = await handleMirrorRunNow(manager);
454
+ expect(res.status).toBe(200);
455
+ const body = (await res.json()) as {
456
+ config: MirrorConfig;
457
+ status: { enabled: boolean; last_export_at: string | null; mirror_path: string };
458
+ };
459
+ expect(body.config.enabled).toBe(true);
460
+ expect(body.status.enabled).toBe(true);
461
+ expect(body.status.last_export_at).not.toBeNull();
462
+ expect(body.status.mirror_path).toContain("mirror");
463
+ expect(exportCalls().length).toBe(exportsBefore + 1);
378
464
  await manager.stop();
379
465
  });
380
466
  });
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // /.parachute/mirror/auth/* — credential routes (Cut 3)
470
+ //
471
+ // These routes back the SPA's "Connect GitHub" / "Use PAT" / "Disconnect"
472
+ // flows. The route layer is tested in isolation: we inject a mock fetch
473
+ // so GitHub's API calls don't go over the wire, point PARACHUTE_HOME at
474
+ // a tempdir so credentials writes don't touch real operator state, and
475
+ // drive the routes via the same Request/Response API the live router
476
+ // uses.
477
+ // ---------------------------------------------------------------------------
478
+
479
+ function buildMockFetch(
480
+ responses: Array<{ match: (u: string) => boolean; body: unknown; status?: number }>,
481
+ ): FetchLike {
482
+ let idx = 0;
483
+ return async (url) => {
484
+ for (let i = idx; i < responses.length; i++) {
485
+ if (responses[i]!.match(url)) {
486
+ idx = i + 1;
487
+ const r = responses[i]!;
488
+ return {
489
+ ok: (r.status ?? 200) >= 200 && (r.status ?? 200) < 300,
490
+ status: r.status ?? 200,
491
+ text: async () => (typeof r.body === "string" ? r.body : JSON.stringify(r.body)),
492
+ json: async () => r.body,
493
+ };
494
+ }
495
+ }
496
+ throw new Error(`mockFetch: no matching response for ${url}`);
497
+ };
498
+ }
499
+
500
+ describe("auth credential routes — GET /auth", () => {
501
+ let home: string;
502
+ afterEach(() => {
503
+ if (home) fs.rmSync(home, { recursive: true, force: true });
504
+ _resetDeviceFlowSessionsForTest();
505
+ });
506
+
507
+ test("returns fully-null sanitized shape when no credentials stored", async () => {
508
+ home = tmp("mirror-auth-empty-");
509
+ const { manager } = makeManager(home);
510
+ const res = handleAuthGet(manager);
511
+ expect(res.status).toBe(200);
512
+ const body = (await res.json()) as { active_method: null; github_oauth: null; pat: null };
513
+ expect(body.active_method).toBeNull();
514
+ expect(body.github_oauth).toBeNull();
515
+ expect(body.pat).toBeNull();
516
+ });
517
+
518
+ test("returns sanitized shape when github_oauth credentials present (no token leaks)", async () => {
519
+ home = tmp("mirror-auth-oauth-");
520
+ const { manager } = makeManager(home);
521
+ const creds: MirrorCredentials = {
522
+ active_method: "github_oauth",
523
+ github_oauth: {
524
+ access_token: "gho_secret123456789",
525
+ scope: "repo",
526
+ authorized_at: "2026-05-28T03:14:15.000Z",
527
+ user_login: "aaron",
528
+ user_id: 1,
529
+ },
530
+ pat: null,
531
+ };
532
+ writeCredentials("default", creds);
533
+ const res = handleAuthGet(manager);
534
+ expect(res.status).toBe(200);
535
+ const text = await res.text();
536
+ expect(text).not.toContain("gho_secret");
537
+ const body = JSON.parse(text) as { github_oauth: { user_login: string; token_preview: string } };
538
+ expect(body.github_oauth.user_login).toBe("aaron");
539
+ expect(body.github_oauth.token_preview).toBe("gho_…6789");
540
+ });
541
+ });
542
+
543
+ describe("auth credential routes — DELETE /auth", () => {
544
+ let home: string;
545
+ afterEach(() => {
546
+ if (home) fs.rmSync(home, { recursive: true, force: true });
547
+ _resetDeviceFlowSessionsForTest();
548
+ });
549
+
550
+ test("wipes credentials from disk", async () => {
551
+ home = tmp("mirror-auth-delete-");
552
+ const { manager } = makeManager(home);
553
+ writeCredentials("default", {
554
+ active_method: "pat",
555
+ github_oauth: null,
556
+ pat: {
557
+ token: "ghp_xxxxxxxxxxxxxxxxxxxx",
558
+ remote_url: "https://github.com/a/b.git",
559
+ label: "test",
560
+ },
561
+ });
562
+ expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(true);
563
+ const res = await handleAuthDelete(manager);
564
+ expect(res.status).toBe(200);
565
+ expect(fs.existsSync(mirrorCredentialsPath("default"))).toBe(false);
566
+ });
567
+
568
+ test("idempotent — missing credentials file still 200", async () => {
569
+ home = tmp("mirror-auth-delete-empty-");
570
+ const { manager } = makeManager(home);
571
+ const res = await handleAuthDelete(manager);
572
+ expect(res.status).toBe(200);
573
+ });
574
+ });
575
+
576
+ describe("auth credential routes — device flow", () => {
577
+ let home: string;
578
+ afterEach(() => {
579
+ if (home) fs.rmSync(home, { recursive: true, force: true });
580
+ _resetDeviceFlowSessionsForTest();
581
+ delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
582
+ });
583
+
584
+ test("device-code returns 503 with placeholder client_id (no env override)", async () => {
585
+ home = tmp("mirror-auth-placeholder-");
586
+ makeManager(home);
587
+ delete process.env.PARACHUTE_GITHUB_CLIENT_ID;
588
+ const res = await handleAuthGithubDeviceCode();
589
+ expect(res.status).toBe(503);
590
+ const body = (await res.json()) as { error_type: string };
591
+ expect(body.error_type).toBe("placeholder_client_id");
592
+ });
593
+
594
+ test("poll without polling_id returns 400", async () => {
595
+ home = tmp("mirror-auth-poll-bad-");
596
+ const { manager } = makeManager(home);
597
+ process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.real";
598
+ const req = new Request("http://x/auth/github/poll", {
599
+ method: "POST",
600
+ body: JSON.stringify({}),
601
+ });
602
+ const res = await handleAuthGithubPoll(req, manager);
603
+ expect(res.status).toBe(400);
604
+ });
605
+
606
+ test("poll with unknown polling_id returns 404 with expired state", async () => {
607
+ home = tmp("mirror-auth-poll-unknown-");
608
+ const { manager } = makeManager(home);
609
+ process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.real";
610
+ const req = new Request("http://x/auth/github/poll", {
611
+ method: "POST",
612
+ body: JSON.stringify({ polling_id: "nonexistent" }),
613
+ });
614
+ const res = await handleAuthGithubPoll(req, manager);
615
+ expect(res.status).toBe(404);
616
+ const body = (await res.json()) as { state: string };
617
+ expect(body.state).toBe("expired");
618
+ });
619
+
620
+ test("full device flow with mocked fetch — code → granted → user → credentials saved", async () => {
621
+ home = tmp("mirror-auth-flow-");
622
+ const { manager } = makeManager(home);
623
+ process.env.PARACHUTE_GITHUB_CLIENT_ID = "Iv1.real";
624
+
625
+ // device-code request
626
+ const fetchA = buildMockFetch([
627
+ {
628
+ match: (u) => u.includes("/login/device/code"),
629
+ body: {
630
+ device_code: "dev_xyz",
631
+ user_code: "ABCD-1234",
632
+ verification_uri: "https://github.com/login/device",
633
+ expires_in: 900,
634
+ interval: 5,
635
+ },
636
+ },
637
+ ]);
638
+ const codeRes = await handleAuthGithubDeviceCode(fetchA);
639
+ expect(codeRes.status).toBe(200);
640
+ const codeBody = (await codeRes.json()) as { polling_id: string; user_code: string };
641
+ expect(codeBody.user_code).toBe("ABCD-1234");
642
+ // device_code MUST NOT leak in the response.
643
+ expect(JSON.stringify(codeBody)).not.toContain("dev_xyz");
644
+ const polling_id = codeBody.polling_id;
645
+ expect(polling_id.length).toBeGreaterThan(0);
646
+
647
+ // poll once — pending
648
+ const fetchPending = buildMockFetch([
649
+ {
650
+ match: () => true,
651
+ body: { error: "authorization_pending" },
652
+ },
653
+ ]);
654
+ const pendingRes = await handleAuthGithubPoll(
655
+ new Request("http://x/poll", { method: "POST", body: JSON.stringify({ polling_id }) }),
656
+ manager,
657
+ fetchPending,
658
+ );
659
+ expect(pendingRes.status).toBe(200);
660
+ const pendingBody = (await pendingRes.json()) as { state: string };
661
+ expect(pendingBody.state).toBe("pending");
662
+
663
+ // poll once — granted, fetch user, save credentials
664
+ const fetchGranted = buildMockFetch([
665
+ {
666
+ match: (u) => u.includes("/login/oauth/access_token"),
667
+ body: { access_token: "gho_real1234567890", scope: "repo", token_type: "bearer" },
668
+ },
669
+ {
670
+ match: (u) => u.includes("/user"),
671
+ body: { login: "aaron", id: 12345, name: "Aaron G", avatar_url: "https://x/y.png" },
672
+ },
673
+ ]);
674
+ const grantRes = await handleAuthGithubPoll(
675
+ new Request("http://x/poll", { method: "POST", body: JSON.stringify({ polling_id }) }),
676
+ manager,
677
+ fetchGranted,
678
+ );
679
+ expect(grantRes.status).toBe(200);
680
+ const grantBody = (await grantRes.json()) as {
681
+ state: string;
682
+ user: { login: string; id: number };
683
+ };
684
+ expect(grantBody.state).toBe("granted");
685
+ expect(grantBody.user.login).toBe("aaron");
686
+ expect(grantBody.user.id).toBe(12345);
687
+ // Credentials persisted; no token leak in response.
688
+ expect(JSON.stringify(grantBody)).not.toContain("gho_real");
689
+ const saved = readCredentials("default");
690
+ expect(saved?.active_method).toBe("github_oauth");
691
+ expect(saved?.github_oauth?.access_token).toBe("gho_real1234567890");
692
+ expect(saved?.github_oauth?.user_login).toBe("aaron");
693
+ });
694
+ });
695
+
696
+ describe("auth credential routes — PAT", () => {
697
+ let home: string;
698
+ afterEach(() => {
699
+ if (home) fs.rmSync(home, { recursive: true, force: true });
700
+ });
701
+
702
+ test("rejects missing token with 400", async () => {
703
+ home = tmp("mirror-auth-pat-notoken-");
704
+ const { manager } = makeManager(home);
705
+ const res = await handleAuthPat(
706
+ new Request("http://x/pat", {
707
+ method: "POST",
708
+ body: JSON.stringify({ remote_url: "https://github.com/a/b.git" }),
709
+ }),
710
+ manager,
711
+ );
712
+ expect(res.status).toBe(400);
713
+ const body = (await res.json()) as { field: string };
714
+ expect(body.field).toBe("token");
715
+ });
716
+
717
+ test("rejects missing remote_url with 400", async () => {
718
+ home = tmp("mirror-auth-pat-nourl-");
719
+ const { manager } = makeManager(home);
720
+ const res = await handleAuthPat(
721
+ new Request("http://x/pat", {
722
+ method: "POST",
723
+ body: JSON.stringify({ token: "ghp_x" }),
724
+ }),
725
+ manager,
726
+ );
727
+ expect(res.status).toBe(400);
728
+ const body = (await res.json()) as { field: string };
729
+ expect(body.field).toBe("remote_url");
730
+ });
731
+
732
+ test("rejects non-HTTPS remote_url (SSH/file URLs not yet supported)", async () => {
733
+ home = tmp("mirror-auth-pat-ssh-");
734
+ const { manager } = makeManager(home);
735
+ const res = await handleAuthPat(
736
+ new Request("http://x/pat", {
737
+ method: "POST",
738
+ body: JSON.stringify({
739
+ token: "ghp_x",
740
+ remote_url: "git@github.com:owner/repo.git",
741
+ }),
742
+ }),
743
+ manager,
744
+ );
745
+ expect(res.status).toBe(400);
746
+ });
747
+
748
+ test("probe-fail returns 400 with redacted error message (no token leak)", async () => {
749
+ // Probe against a definitely-not-real domain. We bypass the network
750
+ // path via a token that should cause `git ls-remote` to fail
751
+ // immediately. The interesting assertion is "the error message we
752
+ // surface back doesn't include the token literal".
753
+ home = tmp("mirror-auth-pat-probefail-");
754
+ const { manager } = makeManager(home);
755
+ const secret = "ghp_definitelynotvalidatall1234567890";
756
+ const res = await handleAuthPat(
757
+ new Request("http://x/pat", {
758
+ method: "POST",
759
+ body: JSON.stringify({
760
+ token: secret,
761
+ remote_url: "https://nonexistent.parachute.test/owner/repo.git",
762
+ }),
763
+ }),
764
+ manager,
765
+ );
766
+ expect(res.status).toBe(400);
767
+ const text = await res.text();
768
+ expect(text).not.toContain(secret);
769
+ }, 20_000);
770
+ });
771
+
772
+ // ---------------------------------------------------------------------------
773
+ // Cuts 3 + 6: credential save → auto_push flipped on + initial push fires.
774
+ //
775
+ // The PAT route gates on a real `git ls-remote` probe, so it's awkward
776
+ // to exercise end-to-end in a hermetic test. The select-repo route has
777
+ // no probe — it accepts the operator's already-OAuth'd token and wires
778
+ // the URL. We drive the side-effect helpers (maybeEnableAutoPush +
779
+ // maybeFireInitialPush) through select-repo, with a local bare repo as
780
+ // the "GitHub" remote — overridden via post-save remote-URL rewrite so
781
+ // pushNow targets the test repo.
782
+ // ---------------------------------------------------------------------------
783
+
784
+ describe("auth credential routes — credential-save side-effects (Cuts 3 + 6)", () => {
785
+ let home: string;
786
+ let remote: string;
787
+ afterEach(() => {
788
+ if (home) fs.rmSync(home, { recursive: true, force: true });
789
+ if (remote) fs.rmSync(remote, { recursive: true, force: true });
790
+ });
791
+
792
+ test("select-repo flips auto_push from false → true on an enabled internal mirror", async () => {
793
+ home = tmp("mirror-selectrepo-autopush-");
794
+ remote = tmp("mirror-selectrepo-remote-");
795
+ Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
796
+
797
+ const { manager, deps } = makeManager(home);
798
+ deps.writeMirrorConfig({
799
+ ...defaultMirrorConfig(),
800
+ enabled: true,
801
+ location: "internal",
802
+ sync_mode: "manual",
803
+ auto_commit: false,
804
+ auto_push: false,
805
+ });
806
+ await manager.start();
807
+ expect(manager.getConfig().auto_push).toBe(false);
808
+
809
+ // Seed github_oauth credentials. select-repo doesn't probe; it just
810
+ // sets origin to github.com/<owner>/<repo>.git. We then rewrite
811
+ // origin to our local bare repo so pushNow has somewhere to land.
812
+ writeCredentials("default", {
813
+ active_method: "github_oauth",
814
+ github_oauth: {
815
+ access_token: "gho_fake1234567890abcd",
816
+ scope: "repo",
817
+ authorized_at: "2026-05-28T03:14:15.000Z",
818
+ user_login: "aaron",
819
+ user_id: 1,
820
+ },
821
+ pat: null,
822
+ });
823
+
824
+ const res = await handleAuthGithubSelectRepo(
825
+ new Request("http://x/select", {
826
+ method: "POST",
827
+ body: JSON.stringify({ owner: "aaron", name: "my-vault" }),
828
+ }),
829
+ manager,
830
+ );
831
+ expect(res.status).toBe(200);
832
+ const body = (await res.json()) as {
833
+ ok: boolean;
834
+ auto_push_was_already_enabled: boolean;
835
+ auto_push_enabled: boolean;
836
+ initial_push:
837
+ | { fired: false; reason: string }
838
+ | { fired: true; pushed: boolean; error?: string; sha?: string };
839
+ };
840
+ // Cut 3 — auto_push went from false → true.
841
+ expect(body.auto_push_was_already_enabled).toBe(false);
842
+ expect(body.auto_push_enabled).toBe(true);
843
+ expect(manager.getConfig().auto_push).toBe(true);
844
+ // Cut 6 — initial push fired. It points at github.com which doesn't
845
+ // exist for our fake creds → expected to fail with a redacted error
846
+ // in last_push_error, but the helper still reports fired=true.
847
+ expect(body.initial_push.fired).toBe(true);
848
+ if (body.initial_push.fired === true) {
849
+ // Push failure is fine — point is "we tried."
850
+ expect(typeof body.initial_push.pushed).toBe("boolean");
851
+ }
852
+ // Token doesn't leak into the response.
853
+ expect(JSON.stringify(body)).not.toContain("gho_fake1234567890");
854
+ await manager.stop();
855
+ }, 30_000);
856
+
857
+ test("select-repo is a no-op for auto_push when mirror is disabled", async () => {
858
+ // Operator wiring credentials before flipping the mirror on — don't
859
+ // mutate auto_push behind their back.
860
+ home = tmp("mirror-selectrepo-disabled-");
861
+ const { manager, deps } = makeManager(home);
862
+ deps.writeMirrorConfig({
863
+ ...defaultMirrorConfig(),
864
+ enabled: false,
865
+ auto_push: false,
866
+ });
867
+ await manager.start();
868
+ writeCredentials("default", {
869
+ active_method: "github_oauth",
870
+ github_oauth: {
871
+ access_token: "gho_anothertoken12345",
872
+ scope: "repo",
873
+ authorized_at: "2026-05-28T03:14:15.000Z",
874
+ user_login: "aaron",
875
+ user_id: 1,
876
+ },
877
+ pat: null,
878
+ });
879
+ const res = await handleAuthGithubSelectRepo(
880
+ new Request("http://x/select", {
881
+ method: "POST",
882
+ body: JSON.stringify({ owner: "aaron", name: "v" }),
883
+ }),
884
+ manager,
885
+ );
886
+ expect(res.status).toBe(200);
887
+ expect(manager.getConfig().auto_push).toBe(false);
888
+ await manager.stop();
889
+ });
890
+ });
891
+
892
+ // ---------------------------------------------------------------------------
893
+ // Cut 6: POST /.parachute/mirror/push-now route handler.
894
+ // ---------------------------------------------------------------------------
895
+
896
+ describe("handleMirrorPushNow — Cut 6", () => {
897
+ let home: string;
898
+ afterEach(() => {
899
+ if (home) fs.rmSync(home, { recursive: true, force: true });
900
+ });
901
+
902
+ test("returns 400 when mirror is not enabled", async () => {
903
+ home = tmp("mirror-pushnow-disabled-");
904
+ const { manager, deps } = makeManager(home);
905
+ deps.writeMirrorConfig({ ...defaultMirrorConfig(), enabled: false });
906
+ await manager.start();
907
+ const res = await handleMirrorPushNow(manager);
908
+ expect(res.status).toBe(400);
909
+ const body = (await res.json()) as { error: string };
910
+ expect(body.error).toBe("Mirror not enabled");
911
+ });
912
+
913
+ test("returns 200 + push outcome when mirror is enabled (even on push failure)", async () => {
914
+ home = tmp("mirror-pushnow-enabled-");
915
+ const { manager, deps } = makeManager(home);
916
+ deps.writeMirrorConfig({
917
+ ...defaultMirrorConfig(),
918
+ enabled: true,
919
+ location: "internal",
920
+ sync_mode: "manual",
921
+ auto_commit: false,
922
+ });
923
+ await manager.start();
924
+ // No remote → push will fail; the handler still returns 200 with
925
+ // the failure surface in the body. Push failures aren't 500s.
926
+ const res = await handleMirrorPushNow(manager);
927
+ expect(res.status).toBe(200);
928
+ const body = (await res.json()) as {
929
+ status: { last_push_error: string | null };
930
+ push: { fired: boolean };
931
+ };
932
+ expect(body.push.fired).toBe(true);
933
+ expect(body.status.last_push_error).not.toBeNull();
934
+ await manager.stop();
935
+ }, 30_000);
936
+ });
937
+
938
+ describe("auth credential routes — github repos / create-repo", () => {
939
+ let home: string;
940
+ afterEach(() => {
941
+ if (home) fs.rmSync(home, { recursive: true, force: true });
942
+ });
943
+
944
+ test("repos returns 400 when not connected to GitHub", async () => {
945
+ home = tmp("mirror-auth-repos-noauth-");
946
+ const { manager } = makeManager(home);
947
+ const res = await handleAuthGithubRepos(manager);
948
+ expect(res.status).toBe(400);
949
+ });
950
+
951
+ test("repos returns list when authed", async () => {
952
+ home = tmp("mirror-auth-repos-ok-");
953
+ const { manager } = makeManager(home);
954
+ writeCredentials("default", {
955
+ active_method: "github_oauth",
956
+ github_oauth: {
957
+ access_token: "gho_test1234567890",
958
+ scope: "repo",
959
+ authorized_at: "2026-05-28T03:14:15.000Z",
960
+ user_login: "aaron",
961
+ user_id: 1,
962
+ },
963
+ pat: null,
964
+ });
965
+ const fetcher = buildMockFetch([
966
+ {
967
+ match: (u) => u.includes("/user/repos"),
968
+ body: [
969
+ {
970
+ name: "a",
971
+ full_name: "aaron/a",
972
+ private: true,
973
+ html_url: "https://github.com/aaron/a",
974
+ description: null,
975
+ updated_at: "2026-05-28T00:00:00Z",
976
+ clone_url: "https://github.com/aaron/a.git",
977
+ owner: { login: "aaron" },
978
+ },
979
+ ],
980
+ },
981
+ ]);
982
+ const res = await handleAuthGithubRepos(manager, fetcher);
983
+ expect(res.status).toBe(200);
984
+ const body = (await res.json()) as { repos: Array<{ full_name: string }> };
985
+ expect(body.repos).toHaveLength(1);
986
+ expect(body.repos[0]!.full_name).toBe("aaron/a");
987
+ });
988
+
989
+ test("create-repo proxies through with mocked fetch", async () => {
990
+ home = tmp("mirror-auth-create-repo-");
991
+ const { manager } = makeManager(home);
992
+ writeCredentials("default", {
993
+ active_method: "github_oauth",
994
+ github_oauth: {
995
+ access_token: "gho_test1234567890",
996
+ scope: "repo",
997
+ authorized_at: "2026-05-28T03:14:15.000Z",
998
+ user_login: "aaron",
999
+ user_id: 1,
1000
+ },
1001
+ pat: null,
1002
+ });
1003
+ const fetcher = buildMockFetch([
1004
+ {
1005
+ match: (u) => u.includes("/user/repos"),
1006
+ status: 201,
1007
+ body: {
1008
+ name: "new-vault",
1009
+ full_name: "aaron/new-vault",
1010
+ private: true,
1011
+ html_url: "https://github.com/aaron/new-vault",
1012
+ description: "x",
1013
+ updated_at: "2026-05-28T00:00:00Z",
1014
+ clone_url: "https://github.com/aaron/new-vault.git",
1015
+ owner: { login: "aaron" },
1016
+ },
1017
+ },
1018
+ ]);
1019
+ const req = new Request("http://x/create", {
1020
+ method: "POST",
1021
+ body: JSON.stringify({ name: "new-vault" }),
1022
+ });
1023
+ const res = await handleAuthGithubCreateRepo(req, manager, fetcher);
1024
+ expect(res.status).toBe(200);
1025
+ const body = (await res.json()) as { full_name: string };
1026
+ expect(body.full_name).toBe("aaron/new-vault");
1027
+ });
1028
+ });
1029
+
1030
+ // ---------------------------------------------------------------------------
1031
+ // POST /.parachute/mirror/import — clone-and-import HTTP route (vault#391).
1032
+ // ---------------------------------------------------------------------------
1033
+
1034
+ /**
1035
+ * Bootstrap a real vault config + db file so getVaultStore('default')
1036
+ * succeeds inside the handler. Returns the home dir for cleanup.
1037
+ */
1038
+ async function bootstrapVault(home: string): Promise<void> {
1039
+ process.env.PARACHUTE_HOME = home;
1040
+ process.env.HOME = home;
1041
+ // The minimal layout vault needs to spin up its store: a per-vault
1042
+ // dir at $PARACHUTE_HOME/vault/data/<name> + a `vault.yaml` config.
1043
+ // writeVaultConfig creates these for us.
1044
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
1045
+ writeVaultConfig({
1046
+ name: "default",
1047
+ description: "import-test vault",
1048
+ created_at: "2026-05-28T00:00:00.000Z",
1049
+ api_keys: [],
1050
+ });
1051
+ clearVaultStoreCache();
1052
+ }
1053
+
1054
+ /**
1055
+ * Build a real portable-md vault export, return the path. The clone
1056
+ * spawn-mock copies this into the tempdir to simulate a successful
1057
+ * `git clone`.
1058
+ */
1059
+ async function buildExportFixture(): Promise<string> {
1060
+ const fixture = tmp("import-route-fixture-");
1061
+ const exportStore = new SqliteStore(new Database(":memory:"));
1062
+ await exportStore.createNote("alpha body", { id: "n-alpha", path: "alpha", tags: ["t1"] });
1063
+ await exportStore.createNote("beta body", { id: "n-beta", path: "beta" });
1064
+ await exportVaultToDir(exportStore, {
1065
+ outDir: fixture,
1066
+ vaultName: "source",
1067
+ exportedAt: "2026-05-28T00:00:00.000Z",
1068
+ });
1069
+ return fixture;
1070
+ }
1071
+
1072
+ const spawnCloneSuccess = (fixture: string): GitSpawn => async (argv) => {
1073
+ const destDir = argv[argv.length - 1]!;
1074
+ cpSync(fixture, destDir, { recursive: true });
1075
+ return { exitCode: 0, stderr: "", timedOut: false };
1076
+ };
1077
+
1078
+ const spawnCloneFail: GitSpawn = async () => ({
1079
+ exitCode: 128,
1080
+ stderr: "fatal: repository not found",
1081
+ timedOut: false,
1082
+ });
1083
+
1084
+ describe("handleMirrorImport", () => {
1085
+ let home: string;
1086
+ let fixture: string;
1087
+
1088
+ afterEach(() => {
1089
+ if (home) fs.rmSync(home, { recursive: true, force: true });
1090
+ if (fixture) fs.rmSync(fixture, { recursive: true, force: true });
1091
+ _resetImportInFlightForTest();
1092
+ clearVaultStoreCache();
1093
+ });
1094
+
1095
+ test("rejects invalid JSON body with 400", async () => {
1096
+ home = tmp("import-route-badjson-");
1097
+ await bootstrapVault(home);
1098
+ const req = new Request("http://x/import", {
1099
+ method: "POST",
1100
+ body: "{not-json",
1101
+ });
1102
+ const res = await handleMirrorImport(req, "default");
1103
+ expect(res.status).toBe(400);
1104
+ const body = (await res.json()) as { error_type: string };
1105
+ expect(body.error_type).toBe("invalid_json");
1106
+ });
1107
+
1108
+ test("rejects missing remote_url with 400", async () => {
1109
+ home = tmp("import-route-no-url-");
1110
+ await bootstrapVault(home);
1111
+ const req = new Request("http://x/import", {
1112
+ method: "POST",
1113
+ body: JSON.stringify({ mode: "merge" }),
1114
+ });
1115
+ const res = await handleMirrorImport(req, "default");
1116
+ expect(res.status).toBe(400);
1117
+ const body = (await res.json()) as { field: string };
1118
+ expect(body.field).toBe("remote_url");
1119
+ });
1120
+
1121
+ test("rejects invalid mode with 400", async () => {
1122
+ home = tmp("import-route-bad-mode-");
1123
+ await bootstrapVault(home);
1124
+ const req = new Request("http://x/import", {
1125
+ method: "POST",
1126
+ body: JSON.stringify({ remote_url: "https://github.com/a/b.git", mode: "wipe" }),
1127
+ });
1128
+ const res = await handleMirrorImport(req, "default");
1129
+ expect(res.status).toBe(400);
1130
+ const body = (await res.json()) as { field: string };
1131
+ expect(body.field).toBe("mode");
1132
+ });
1133
+
1134
+ test("rejects per-call PAT without token", async () => {
1135
+ home = tmp("import-route-pat-missing-token-");
1136
+ await bootstrapVault(home);
1137
+ const req = new Request("http://x/import", {
1138
+ method: "POST",
1139
+ body: JSON.stringify({
1140
+ remote_url: "https://github.com/a/b.git",
1141
+ mode: "merge",
1142
+ credentials: { kind: "pat", token: "" },
1143
+ }),
1144
+ });
1145
+ const res = await handleMirrorImport(req, "default");
1146
+ expect(res.status).toBe(400);
1147
+ const body = (await res.json()) as { field: string };
1148
+ expect(body.field).toBe("credentials.token");
1149
+ });
1150
+
1151
+ test("rejects unknown credentials.kind", async () => {
1152
+ home = tmp("import-route-bad-kind-");
1153
+ await bootstrapVault(home);
1154
+ const req = new Request("http://x/import", {
1155
+ method: "POST",
1156
+ body: JSON.stringify({
1157
+ remote_url: "https://github.com/a/b.git",
1158
+ mode: "merge",
1159
+ credentials: { kind: "magic" },
1160
+ }),
1161
+ });
1162
+ const res = await handleMirrorImport(req, "default");
1163
+ expect(res.status).toBe(400);
1164
+ const body = (await res.json()) as { field: string };
1165
+ expect(body.field).toBe("credentials.kind");
1166
+ });
1167
+
1168
+ test("success path — merge mode imports notes into target vault", async () => {
1169
+ home = tmp("import-route-success-");
1170
+ await bootstrapVault(home);
1171
+ fixture = await buildExportFixture();
1172
+ const req = new Request("http://x/import", {
1173
+ method: "POST",
1174
+ body: JSON.stringify({
1175
+ remote_url: "https://github.com/a/b.git",
1176
+ mode: "merge",
1177
+ credentials: { kind: "none" },
1178
+ }),
1179
+ });
1180
+ const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
1181
+ expect(res.status).toBe(200);
1182
+ const body = (await res.json()) as {
1183
+ notes_imported: number;
1184
+ tags_imported: number;
1185
+ attachments_imported: number;
1186
+ warnings: string[];
1187
+ notes_deleted?: number;
1188
+ };
1189
+ expect(body.notes_imported).toBe(2);
1190
+ expect(body.warnings).toEqual([]);
1191
+ expect(body.notes_deleted).toBeUndefined();
1192
+ });
1193
+
1194
+ test("replace mode sets notes_deleted in response", async () => {
1195
+ home = tmp("import-route-replace-");
1196
+ await bootstrapVault(home);
1197
+ fixture = await buildExportFixture();
1198
+ // Seed the target vault with a local-only note that gets wiped.
1199
+ const { getVaultStore } = await import("./vault-store.ts");
1200
+ const store = getVaultStore("default");
1201
+ await store.createNote("local", { id: "n-local", path: "local" });
1202
+
1203
+ const req = new Request("http://x/import", {
1204
+ method: "POST",
1205
+ body: JSON.stringify({
1206
+ remote_url: "https://github.com/a/b.git",
1207
+ mode: "replace",
1208
+ credentials: { kind: "none" },
1209
+ }),
1210
+ });
1211
+ const res = await handleMirrorImport(req, "default", spawnCloneSuccess(fixture));
1212
+ expect(res.status).toBe(200);
1213
+ const body = (await res.json()) as {
1214
+ notes_imported: number;
1215
+ notes_deleted: number;
1216
+ };
1217
+ expect(body.notes_imported).toBe(2);
1218
+ expect(body.notes_deleted).toBe(1);
1219
+ });
1220
+
1221
+ test("clone failure returns 502 with redacted message", async () => {
1222
+ home = tmp("import-route-clone-fail-");
1223
+ await bootstrapVault(home);
1224
+ const req = new Request("http://x/import", {
1225
+ method: "POST",
1226
+ body: JSON.stringify({
1227
+ remote_url: "https://github.com/a/b.git",
1228
+ mode: "merge",
1229
+ credentials: { kind: "pat", token: "ghp_secret_xyz" },
1230
+ }),
1231
+ });
1232
+ const res = await handleMirrorImport(req, "default", spawnCloneFail);
1233
+ expect(res.status).toBe(502);
1234
+ const text = await res.text();
1235
+ expect(text).not.toContain("ghp_secret_xyz");
1236
+ const body = JSON.parse(text) as { error_type: string };
1237
+ expect(body.error_type).toBe("clone_failed");
1238
+ });
1239
+
1240
+ test("not-a-vault-export returns 400 with actionable message", async () => {
1241
+ home = tmp("import-route-not-vault-");
1242
+ await bootstrapVault(home);
1243
+ const notAnExport = tmp("import-route-notvault-fixture-");
1244
+ nodeWriteFileSync(path.join(notAnExport, "README.md"), "hello");
1245
+ fixture = notAnExport;
1246
+ const req = new Request("http://x/import", {
1247
+ method: "POST",
1248
+ body: JSON.stringify({
1249
+ remote_url: "https://github.com/a/b.git",
1250
+ mode: "merge",
1251
+ credentials: { kind: "none" },
1252
+ }),
1253
+ });
1254
+ const res = await handleMirrorImport(req, "default", spawnCloneSuccess(notAnExport));
1255
+ expect(res.status).toBe(400);
1256
+ const body = (await res.json()) as { error_type: string; message: string };
1257
+ expect(body.error_type).toBe("not_a_vault_export");
1258
+ expect(body.message).toContain("vault.yaml");
1259
+ });
1260
+
1261
+ test("uses stored credentials when credentials: null (credentialsFile path)", async () => {
1262
+ home = tmp("import-route-stored-creds-");
1263
+ await bootstrapVault(home);
1264
+ fixture = await buildExportFixture();
1265
+ // Write a stored PAT credential matching github.com.
1266
+ writeCredentials("default", {
1267
+ active_method: "pat",
1268
+ github_oauth: null,
1269
+ pat: {
1270
+ token: "ghp_stored_token_xyz",
1271
+ remote_url: "https://x-access-token:ghp_stored_token_xyz@github.com/a/b.git",
1272
+ label: "stored",
1273
+ },
1274
+ });
1275
+ // Assert the spawn argv carries the stored token (proves the
1276
+ // credentialsFile path resolved).
1277
+ let observedArgv: string[] | null = null;
1278
+ const fakeSpawn: GitSpawn = async (argv) => {
1279
+ observedArgv = argv;
1280
+ const destDir = argv[argv.length - 1]!;
1281
+ cpSync(fixture, destDir, { recursive: true });
1282
+ return { exitCode: 0, stderr: "", timedOut: false };
1283
+ };
1284
+ const req = new Request("http://x/import", {
1285
+ method: "POST",
1286
+ body: JSON.stringify({
1287
+ remote_url: "https://github.com/a/b.git",
1288
+ mode: "merge",
1289
+ credentials: null,
1290
+ }),
1291
+ });
1292
+ const res = await handleMirrorImport(req, "default", fakeSpawn);
1293
+ expect(res.status).toBe(200);
1294
+ // The clone URL should have the stored token embedded.
1295
+ expect(observedArgv).not.toBeNull();
1296
+ expect(observedArgv!.some((arg) => arg.includes("ghp_stored_token_xyz"))).toBe(true);
1297
+ });
1298
+
1299
+ test("per-call PAT override is used when supplied", async () => {
1300
+ home = tmp("import-route-per-call-pat-");
1301
+ await bootstrapVault(home);
1302
+ fixture = await buildExportFixture();
1303
+ // Stored credentials should be IGNORED when per-call PAT is supplied.
1304
+ writeCredentials("default", {
1305
+ active_method: "pat",
1306
+ github_oauth: null,
1307
+ pat: {
1308
+ token: "ghp_stored_xyz",
1309
+ remote_url: "https://x-access-token:ghp_stored_xyz@github.com/a/b.git",
1310
+ label: "stored",
1311
+ },
1312
+ });
1313
+ let observedArgv: string[] | null = null;
1314
+ const fakeSpawn: GitSpawn = async (argv) => {
1315
+ observedArgv = argv;
1316
+ const destDir = argv[argv.length - 1]!;
1317
+ cpSync(fixture, destDir, { recursive: true });
1318
+ return { exitCode: 0, stderr: "", timedOut: false };
1319
+ };
1320
+ const req = new Request("http://x/import", {
1321
+ method: "POST",
1322
+ body: JSON.stringify({
1323
+ remote_url: "https://github.com/a/b.git",
1324
+ mode: "merge",
1325
+ credentials: { kind: "pat", token: "ghp_per_call_only" },
1326
+ }),
1327
+ });
1328
+ const res = await handleMirrorImport(req, "default", fakeSpawn);
1329
+ expect(res.status).toBe(200);
1330
+ expect(observedArgv).not.toBeNull();
1331
+ const joined = observedArgv!.join(" ");
1332
+ expect(joined).toContain("ghp_per_call_only");
1333
+ expect(joined).not.toContain("ghp_stored_xyz");
1334
+ });
1335
+ });
1336
+