@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
@@ -19,8 +19,11 @@
19
19
  * - port: GlobalConfig.port, exposed read-only.
20
20
  * - autoTranscribe.*: vault↔scribe handoff (vault#353, design 2026-05-21
21
21
  * Part 2). Three nested fields per design Q4:
22
- * - enabled: boolean toggle, default false (persisted in
23
- * GlobalConfig.auto_transcribe.enabled).
22
+ * - enabled: boolean toggle, default true when scribe is
23
+ * reachable (persisted in
24
+ * GlobalConfig.auto_transcribe.enabled). Default
25
+ * flipped from off → on so installing scribe is
26
+ * the only opt-in signal needed.
24
27
  * - scribeUrl: readOnly — resolved per-process from
25
28
  * `~/.parachute/services.json` via
26
29
  * `scribe-discovery.ts`. Operators can't point at an
@@ -69,10 +72,10 @@ export function buildConfigSchema(): ModuleConfigSchema {
69
72
  properties: {
70
73
  enabled: {
71
74
  type: "boolean",
72
- default: false,
75
+ default: true,
73
76
  title: "Enable auto-transcription",
74
77
  description:
75
- "Master toggle. When false, audio uploads land normally without any scribe interaction. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
78
+ "Master toggle. Default on audio uploads transcribe automatically when scribe is reachable. Set to false to disable. Global — persisted in `GlobalConfig.auto_transcribe.enabled` and applies to every vault on this server. Per-vault control is a future enhancement when multi-vault deployments need it.",
76
79
  },
77
80
  scribeUrl: {
78
81
  type: "string",
@@ -139,7 +142,10 @@ export function buildConfigValues(
139
142
  return {
140
143
  audio_retention: vaultConfig.audio_retention ?? "keep",
141
144
  autoTranscribe: {
142
- enabled: globalConfig.auto_transcribe?.enabled ?? false,
145
+ // Match shouldAutoTranscribe's `?? true` so the admin SPA displays
146
+ // the same value runtime uses. An unset config row shows `true`
147
+ // because that's what vault will actually do on the next audio upload.
148
+ enabled: globalConfig.auto_transcribe?.enabled ?? true,
143
149
  scribeUrl,
144
150
  },
145
151
  // Legacy alias mirrors `autoTranscribe.scribeUrl` so hubs reading the
package/src/routes.ts CHANGED
@@ -2077,7 +2077,13 @@ const MIME_TYPES: Record<string, string> = {
2077
2077
  ".mp4": "video/mp4",
2078
2078
  };
2079
2079
 
2080
- export async function handleStorage(req: Request, path: string, vault: string): Promise<Response> {
2080
+ export async function handleStorage(
2081
+ req: Request,
2082
+ path: string,
2083
+ vault: string,
2084
+ store: Store,
2085
+ tagScope: TagScopeCtx = NO_TAG_SCOPE,
2086
+ ): Promise<Response> {
2081
2087
  const assets = assetsDir(vault);
2082
2088
 
2083
2089
  if (req.method === "POST" && path === "/upload") {
@@ -2121,6 +2127,37 @@ export async function handleStorage(req: Request, path: string, vault: string):
2121
2127
  return json({ error: "Not found" }, 404);
2122
2128
  }
2123
2129
 
2130
+ // Tag-scope gate (C0 adversarial-audit finding). The note-keyed
2131
+ // attachment surfaces (GET /notes/:id?include_attachments, GET
2132
+ // /notes/:id/attachments, query results) are all gated behind
2133
+ // `noteWithinTagScope`, but this raw byte-serve endpoint historically
2134
+ // shipped bytes purely by filesystem path with only a path-traversal
2135
+ // guard — so a tag-scoped token (scoped to e.g. ["work"]) could fetch
2136
+ // an out-of-scope note's attachment bytes directly if it learned the
2137
+ // storage path. Path secrecy (the 122-bit UUID in the filename) is NOT
2138
+ // an access control. When the token is tag-scoped, reverse-lookup the
2139
+ // requested path → owning attachment row(s) → note_id, and serve only
2140
+ // if at least one owning note is within scope. Unscoped tokens
2141
+ // (tagScope.raw === null) keep the prior behavior verbatim.
2142
+ //
2143
+ // 404 (not 403) on out-of-scope / no-owning-row, matching the
2144
+ // note-level surfaces — don't create an existence oracle that confirms
2145
+ // "this path exists but you can't see it."
2146
+ if (tagScope.raw !== null) {
2147
+ const rows = await store.getAttachmentsByPath(reqPath);
2148
+ let allowed = false;
2149
+ for (const att of rows) {
2150
+ const note = await store.getNote(att.noteId);
2151
+ if (note && noteWithinTagScope(note, tagScope.allowed, tagScope.raw)) {
2152
+ allowed = true;
2153
+ break;
2154
+ }
2155
+ }
2156
+ if (!allowed) {
2157
+ return json({ error: "Not found" }, 404);
2158
+ }
2159
+ }
2160
+
2124
2161
  const stat = statSync(filePath);
2125
2162
  const ext = extname(filePath).toLowerCase();
2126
2163
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
@@ -17,7 +17,7 @@
17
17
  * never touch ~/.parachute.
18
18
  */
19
19
 
20
- import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
20
+ import { describe, test, expect, beforeAll, beforeEach, afterEach, afterAll } from "bun:test";
21
21
  import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
22
22
  import { join } from "path";
23
23
  import { tmpdir } from "os";
@@ -606,6 +606,24 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
606
606
 
607
607
  const HUB_ORIGIN = "http://127.0.0.1:1939";
608
608
 
609
+ // Process-env isolation: sibling test files (tokens-routes.test.ts,
610
+ // auth-hub-jwt.test.ts) set PARACHUTE_HUB_ORIGIN in their own beforeAll
611
+ // hooks. Bun's test runner shares a single process across test files,
612
+ // and when file-ordering puts those before this one, their hook-set
613
+ // value can still be live when our tests run. Restore the default
614
+ // (unset) here so we test against `DEFAULT_HUB_LOOPBACK`. Caught when
615
+ // vault rc.1 release CI failed with "Received: http://127.0.0.1:34295"
616
+ // — a leaked ephemeral port from another test's fixture.
617
+ let _prevHubOriginRouting: string | undefined;
618
+ beforeAll(() => {
619
+ _prevHubOriginRouting = process.env.PARACHUTE_HUB_ORIGIN;
620
+ delete process.env.PARACHUTE_HUB_ORIGIN;
621
+ });
622
+ afterAll(() => {
623
+ if (_prevHubOriginRouting === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
624
+ else process.env.PARACHUTE_HUB_ORIGIN = _prevHubOriginRouting;
625
+ });
626
+
609
627
  describe("per-vault OAuth discovery (hub-rooted after workstream E)", () => {
610
628
  test("AS metadata names the hub as issuer + endpoints", async () => {
611
629
  createVault("journal");
@@ -1804,3 +1822,76 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
1804
1822
  expect([405, 503]).toContain(res.status);
1805
1823
  });
1806
1824
  });
1825
+
1826
+ // ---------------------------------------------------------------------------
1827
+ // /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
1828
+ // alongside the SPA UI. Tests pin the auth gate matches the parent
1829
+ // endpoint; handler-shape coverage lives in mirror-routes.test.ts.
1830
+ // ---------------------------------------------------------------------------
1831
+
1832
+ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
1833
+ test("unauthenticated → 401", async () => {
1834
+ createVault("journal");
1835
+ const p = "/vault/journal/.parachute/mirror/run-now";
1836
+ const res = await route(
1837
+ new Request(`http://localhost:1940${p}`, { method: "POST" }),
1838
+ p,
1839
+ );
1840
+ expect(res.status).toBe(401);
1841
+ });
1842
+
1843
+ test("vault:read token → 403 insufficient_scope", async () => {
1844
+ createVault("journal");
1845
+ const store = getVaultStore("journal");
1846
+ const { fullToken } = generateToken();
1847
+ createToken(store.db, fullToken, {
1848
+ label: "reader",
1849
+ permission: "read",
1850
+ scopes: ["vault:read"],
1851
+ });
1852
+ const p = "/vault/journal/.parachute/mirror/run-now";
1853
+ const res = await route(
1854
+ new Request(`http://localhost:1940${p}`, {
1855
+ method: "POST",
1856
+ headers: { authorization: `Bearer ${fullToken}` },
1857
+ }),
1858
+ p,
1859
+ );
1860
+ expect(res.status).toBe(403);
1861
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
1862
+ expect(body.error_type).toBe("insufficient_scope");
1863
+ expect(body.required_scope).toBe("vault:admin");
1864
+ });
1865
+
1866
+ test("admin token reaches the handler — 503 when manager not wired, 400 when wired+disabled", async () => {
1867
+ // Mirrors the parent endpoint's harness behavior: test ordering
1868
+ // determines whether a previous test wired a manager. Either way
1869
+ // the auth gate passed, which is what this routing-level test pins.
1870
+ createVault("journal");
1871
+ const token = createAdminToken("journal");
1872
+ const p = "/vault/journal/.parachute/mirror/run-now";
1873
+ const res = await route(
1874
+ new Request(`http://localhost:1940${p}`, {
1875
+ method: "POST",
1876
+ headers: { authorization: `Bearer ${token}` },
1877
+ }),
1878
+ p,
1879
+ );
1880
+ expect([400, 503]).toContain(res.status);
1881
+ });
1882
+
1883
+ test("non-POST methods return 405 when manager is wired", async () => {
1884
+ createVault("journal");
1885
+ const token = createAdminToken("journal");
1886
+ const p = "/vault/journal/.parachute/mirror/run-now";
1887
+ const res = await route(
1888
+ new Request(`http://localhost:1940${p}`, {
1889
+ method: "GET",
1890
+ headers: { authorization: `Bearer ${token}` },
1891
+ }),
1892
+ p,
1893
+ );
1894
+ // 503 short-circuits the method check when no manager is wired.
1895
+ expect([405, 503]).toContain(res.status);
1896
+ });
1897
+ });
package/src/routing.ts CHANGED
@@ -72,7 +72,21 @@ import {
72
72
  } from "./oauth-discovery.ts";
73
73
  import { handleConfigSchema, handleConfig } from "./module-config.ts";
74
74
  import { buildAuthStatus } from "./auth-status.ts";
75
- import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
75
+ import {
76
+ handleAuthDelete,
77
+ handleAuthGet,
78
+ handleAuthGithubCreateRepo,
79
+ handleAuthGithubDeviceCode,
80
+ handleAuthGithubPoll,
81
+ handleAuthGithubRepos,
82
+ handleAuthGithubSelectRepo,
83
+ handleAuthPat,
84
+ handleMirrorGet,
85
+ handleMirrorImport,
86
+ handleMirrorPushNow,
87
+ handleMirrorPut,
88
+ handleMirrorRunNow,
89
+ } from "./mirror-routes.ts";
76
90
  import { getMirrorManager } from "./mirror-registry.ts";
77
91
 
78
92
  /**
@@ -424,7 +438,15 @@ export async function route(
424
438
 
425
439
  // MCP (per-vault, single-vault session).
426
440
  if (isScopedMcp) {
427
- return handleScopedMcp(req, vaultName, auth);
441
+ // Thread the RAW caller bearer (the exact credential the session
442
+ // presented) into the MCP layer so the manage-token tool can forward it
443
+ // to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
444
+ // validated bearer — never a fabricated one. extractApiKey returns the
445
+ // same value `authenticateVaultRequest` validated above; non-forwardable
446
+ // credentials (env-var secret, legacy pvt_*) are handled by manage-token
447
+ // itself (it only forwards JWT-shaped bearers).
448
+ const callerBearer = extractApiKey(req);
449
+ return handleScopedMcp(req, vaultName, auth, callerBearer);
428
450
  }
429
451
 
430
452
  // Bare `/vault/<name>` — single-vault root. Returns name, description,
@@ -467,15 +489,13 @@ export async function route(
467
489
  return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
468
490
  }
469
491
 
470
- // /.parachute/mirror — vault-sync Phase A1. Admin-gated read+write of
471
- // the persistent mirror config + runtime status. Lives under
472
- // `.parachute/` (alongside info/icon/config) rather than `admin/`
473
- // because `/vault/<name>/admin/*` is reserved for the admin SPA's
474
- // static-file mount; the API surface goes under `.parachute/` by the
475
- // module-protocol convention. Per the design doc, the hub admin SPA
476
- // (Phase A2 future PR) is the eventual primary consumer; for Phase
477
- // A1 these endpoints unblock direct API callers and the by-hand
478
- // config workflow.
492
+ // /.parachute/mirror — Admin-gated read+write of THIS vault's persistent
493
+ // mirror config + runtime status. Per-vault (vault#400): the manager is
494
+ // resolved by the URL's vault name, so each vault's mirror page reflects
495
+ // its own config + git remote. Lives under `.parachute/` (alongside
496
+ // info/icon/config) rather than `admin/` because `/vault/<name>/admin/*`
497
+ // is reserved for the admin SPA's static-file mount; the API surface goes
498
+ // under `.parachute/` by the module-protocol convention.
479
499
  if (subpath === "/.parachute/mirror") {
480
500
  if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
481
501
  return Response.json(
@@ -489,19 +509,22 @@ export async function route(
489
509
  { status: 403 },
490
510
  );
491
511
  }
492
- const manager = getMirrorManager();
512
+ // vault#400: resolve the manager for THIS vault (from the URL). The
513
+ // registry lazily builds one (via the boot-installed factory) for any
514
+ // existing vault — including a non-default vault or one configured at
515
+ // runtime — so the handler operates on the right vault's config + status
516
+ // + git remote, never the default vault's.
517
+ const manager = getMirrorManager(vaultName);
493
518
  if (!manager) {
494
- // The boot path constructs a manager when at least one vault
495
- // exists; a missing manager here means either a startup error or
496
- // a brand-new deploy that hasn't finished first-boot. Surface a
497
- // clear 503 rather than a JSON null so the operator + the hub
498
- // SPA know it's a service-state issue, not a misconfig on their
499
- // end.
519
+ // Null only when boot hasn't installed the factory yet (startup race
520
+ // or boot failure). Surface a clear 503 rather than a JSON null so the
521
+ // operator + the hub SPA know it's a service-state issue, not a
522
+ // misconfig on their end.
500
523
  return Response.json(
501
524
  {
502
525
  error: "Mirror manager not initialized",
503
526
  message:
504
- "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
527
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
505
528
  },
506
529
  { status: 503 },
507
530
  );
@@ -511,6 +534,156 @@ export async function route(
511
534
  return Response.json({ error: "Method not allowed" }, { status: 405 });
512
535
  }
513
536
 
537
+ // /.parachute/mirror/run-now — fire a one-shot export+commit+push pass.
538
+ // Same admin gate as the GET/PUT above; same manager presence check.
539
+ // POST-only — a GET would imply "read the result of running" which
540
+ // isn't the verb (the rolling status is already available on the
541
+ // parent GET endpoint).
542
+ if (subpath === "/.parachute/mirror/run-now") {
543
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
544
+ return Response.json(
545
+ {
546
+ error: "Forbidden",
547
+ error_type: "insufficient_scope",
548
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
549
+ required_scope: SCOPE_ADMIN,
550
+ granted_scopes: auth.scopes,
551
+ },
552
+ { status: 403 },
553
+ );
554
+ }
555
+ const manager = getMirrorManager(vaultName);
556
+ if (!manager) {
557
+ return Response.json(
558
+ {
559
+ error: "Mirror manager not initialized",
560
+ message:
561
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
562
+ },
563
+ { status: 503 },
564
+ );
565
+ }
566
+ if (req.method === "POST") return handleMirrorRunNow(manager);
567
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
568
+ }
569
+
570
+ // /.parachute/mirror/push-now — fire `git push` against committed state.
571
+ // Cut 6 of vault#392. Same admin gate + manager check as run-now;
572
+ // POST-only. Distinguished from /run-now in that this skips export +
573
+ // commit, only pushes — for "did my credentials actually work?" flow.
574
+ if (subpath === "/.parachute/mirror/push-now") {
575
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
576
+ return Response.json(
577
+ {
578
+ error: "Forbidden",
579
+ error_type: "insufficient_scope",
580
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
581
+ required_scope: SCOPE_ADMIN,
582
+ granted_scopes: auth.scopes,
583
+ },
584
+ { status: 403 },
585
+ );
586
+ }
587
+ const manager = getMirrorManager(vaultName);
588
+ if (!manager) {
589
+ return Response.json(
590
+ {
591
+ error: "Mirror manager not initialized",
592
+ message:
593
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
594
+ },
595
+ { status: 503 },
596
+ );
597
+ }
598
+ if (req.method === "POST") return handleMirrorPushNow(manager);
599
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
600
+ }
601
+
602
+ // /.parachute/mirror/import — clone a vault export from git + import.
603
+ // Admin-gated. POST-only. Synchronous (imports finish in <30s for
604
+ // typical vaults). See mirror-routes.ts:handleMirrorImport for the
605
+ // request/response shape + error map. Symmetric counterpart to the
606
+ // export-to-git flow vault#382 + vault#384 shipped.
607
+ if (subpath === "/.parachute/mirror/import") {
608
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
609
+ return Response.json(
610
+ {
611
+ error: "Forbidden",
612
+ error_type: "insufficient_scope",
613
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
614
+ required_scope: SCOPE_ADMIN,
615
+ granted_scopes: auth.scopes,
616
+ },
617
+ { status: 403 },
618
+ );
619
+ }
620
+ if (req.method !== "POST") {
621
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
622
+ }
623
+ return handleMirrorImport(req, vaultName);
624
+ }
625
+
626
+ // /.parachute/mirror/auth/* — UI-configurable git push credentials.
627
+ // GitHub OAuth Device Flow + PAT fallback. All admin-gated; the
628
+ // routes themselves don't carry secrets in their responses
629
+ // (mirror-routes.ts redacts via sanitizeCredentials).
630
+ if (subpath.startsWith("/.parachute/mirror/auth")) {
631
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
632
+ return Response.json(
633
+ {
634
+ error: "Forbidden",
635
+ error_type: "insufficient_scope",
636
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
637
+ required_scope: SCOPE_ADMIN,
638
+ granted_scopes: auth.scopes,
639
+ },
640
+ { status: 403 },
641
+ );
642
+ }
643
+ const manager = getMirrorManager(vaultName);
644
+ if (!manager) {
645
+ return Response.json(
646
+ {
647
+ error: "Mirror manager not initialized",
648
+ message:
649
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
650
+ },
651
+ { status: 503 },
652
+ );
653
+ }
654
+
655
+ if (subpath === "/.parachute/mirror/auth") {
656
+ if (req.method === "GET") return handleAuthGet(manager);
657
+ if (req.method === "DELETE") return handleAuthDelete(manager);
658
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
659
+ }
660
+ if (subpath === "/.parachute/mirror/auth/github/device-code") {
661
+ if (req.method === "POST") return handleAuthGithubDeviceCode();
662
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
663
+ }
664
+ if (subpath === "/.parachute/mirror/auth/github/poll") {
665
+ if (req.method === "POST") return handleAuthGithubPoll(req, manager);
666
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
667
+ }
668
+ if (subpath === "/.parachute/mirror/auth/github/repos") {
669
+ if (req.method === "GET") return handleAuthGithubRepos(manager);
670
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
671
+ }
672
+ if (subpath === "/.parachute/mirror/auth/github/create-repo") {
673
+ if (req.method === "POST") return handleAuthGithubCreateRepo(req, manager);
674
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
675
+ }
676
+ if (subpath === "/.parachute/mirror/auth/github/select-repo") {
677
+ if (req.method === "POST") return handleAuthGithubSelectRepo(req, manager);
678
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
679
+ }
680
+ if (subpath === "/.parachute/mirror/auth/pat") {
681
+ if (req.method === "POST") return handleAuthPat(req, manager);
682
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
683
+ }
684
+ return Response.json({ error: "Not found" }, { status: 404 });
685
+ }
686
+
514
687
  const apiMatch = subpath.match(/^\/api(\/.*)?$/);
515
688
  if (!apiMatch) {
516
689
  return Response.json({ error: "Not found" }, { status: 404 });
@@ -558,7 +731,7 @@ export async function route(
558
731
  });
559
732
  }
560
733
  if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
561
- if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName);
734
+ if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
562
735
  if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
563
736
 
564
737
  return Response.json({ error: "Not found" }, { status: 404 });
package/src/server.ts CHANGED
@@ -31,8 +31,19 @@ import { getCachedScribeUrl } from "./scribe-discovery.ts";
31
31
  import { readEnvFile, setEnvVar } from "./config.ts";
32
32
  import { resolveBindHostname } from "./bind.ts";
33
33
  import { MirrorManager } from "./mirror-manager.ts";
34
- import { setMirrorManager } from "./mirror-registry.ts";
34
+ import {
35
+ setMirrorManager,
36
+ setMirrorManagerFactory,
37
+ listMirrorManagers,
38
+ } from "./mirror-registry.ts";
35
39
  import { buildMirrorDeps, resolveMirrorVaultName } from "./mirror-deps.ts";
40
+ import { migrateLegacyServerWideCredentials } from "./mirror-credentials.ts";
41
+ import {
42
+ commentOutLegacyMirrorBlockFile,
43
+ migrateLegacyServerWideConfig,
44
+ readMirrorConfigForVault,
45
+ } from "./mirror-config.ts";
46
+ import { GLOBAL_CONFIG_PATH } from "./config.ts";
36
47
  import { selfRegister } from "./self-register.ts";
37
48
  import pkg from "../package.json" with { type: "json" };
38
49
 
@@ -231,47 +242,96 @@ const port = parseInt(process.env.PORT ?? "") || globalConfig.port || DEFAULT_PO
231
242
  const hostname = resolveBindHostname();
232
243
 
233
244
  // ---------------------------------------------------------------------------
234
- // Mirror lifecycle (vault-sync Phase A1).
245
+ // Mirror lifecycle — per-vault (vault#400).
235
246
  //
236
- // Boot-time bootstrap of the persistent mirror manager. Only stands up an
237
- // active mirror when global config carries `mirror.enabled: true`; otherwise
238
- // the manager construct + status reflects "disabled" but no filesystem work
239
- // happens. The HTTP `/admin/mirror` routes can still flip it on at runtime
240
- // without a vault restart — see routing.ts + mirror-routes.ts.
247
+ // Before vault#400 the server stood up a SINGLE mirror manager bound to the
248
+ // mirror-owning vault (default/first), and every mirror route resolved to it
249
+ // so every vault's mirror page showed the default vault's config + git
250
+ // remote. Now each vault gets its OWN config (`data/<vault>/mirror-config.yaml`)
251
+ // + its OWN manager. Boot:
241
252
  //
242
- // Failure here is logged and ignored; vault keeps serving. The operator
243
- // sees the error via `GET /admin/mirror` + the log line.
253
+ // 1. Migrate the legacy SERVER-WIDE credentials file (vault#399) + the
254
+ // legacy server-wide `mirror:` config block (vault#400) to the
255
+ // mirror-owning vault (default/first). Other vaults start unconfigured.
256
+ // 2. Install the lazy-build factory so routes can stand up a manager for
257
+ // any vault on first request (handles a vault configured at runtime
258
+ // without a restart).
259
+ // 3. Iterate ALL vaults; for each whose per-vault config has
260
+ // `enabled: true`, build + register + start its manager. Per-vault
261
+ // failure is logged + isolated — one vault's mirror error never breaks
262
+ // another vault's mirror or the vault server's serving path.
244
263
  // ---------------------------------------------------------------------------
245
- let mirrorManager: MirrorManager | null = null;
246
264
  {
247
- // Canonicalized in `mirror-deps.ts:resolveMirrorVaultName` so the
248
- // binding rule (default_vault → first listed vault → null) lives in
249
- // exactly one place; multi-vault-mirror work (design-doc open
250
- // question 2) only has to touch one site.
265
+ // Canonicalized in `mirror-deps.ts:resolveMirrorVaultName` the binding
266
+ // rule (default_vault → first listed vault → null) lives in exactly one
267
+ // place. Post-vault#400 it's only used for migration attribution.
251
268
  const mirrorVaultName = resolveMirrorVaultName(listVaults);
252
- if (mirrorVaultName) {
269
+
270
+ // vault#399: migrate the legacy SERVER-WIDE credentials file to the
271
+ // per-vault layout, attributed to the mirror-owning vault. Idempotent +
272
+ // safe (no-op when no legacy file / already migrated; legacy file renamed
273
+ // `.bak`, never deleted). Runs even with no vaults (no-op).
274
+ try {
275
+ migrateLegacyServerWideCredentials(mirrorVaultName);
276
+ } catch (err) {
277
+ console.warn(
278
+ `[mirror] legacy credential migration failed (non-fatal): ${(err as Error).message ?? err}`,
279
+ );
280
+ }
281
+
282
+ // vault#400: migrate the legacy server-wide `mirror:` CONFIG block from
283
+ // config.yaml to the mirror-owning vault's per-vault config file. The
284
+ // legacy block is commented out in place (preserved for reference), never
285
+ // silently dropped. Idempotent: no-op when no block / target already
286
+ // configured. (#399 already moved credentials — we do NOT re-migrate them.)
287
+ try {
288
+ migrateLegacyServerWideConfig(
289
+ readGlobalConfig().mirror,
290
+ mirrorVaultName,
291
+ () => commentOutLegacyMirrorBlockFile(GLOBAL_CONFIG_PATH),
292
+ );
293
+ } catch (err) {
294
+ console.warn(
295
+ `[mirror] legacy config migration failed (non-fatal): ${(err as Error).message ?? err}`,
296
+ );
297
+ }
298
+
299
+ // Install the lazy-build factory so routes can stand up a per-vault manager
300
+ // on demand (a vault configured at runtime works without a restart).
301
+ setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
302
+
303
+ // Stand up every vault whose per-vault config is enabled. Don't block
304
+ // server startup on slow initial exports — kick each off in the background.
305
+ const vaults = listVaults();
306
+ if (vaults.length === 0) {
307
+ console.log("[mirror] no vaults yet — managers stand up on next restart after a vault exists");
308
+ }
309
+ for (const name of vaults) {
310
+ let cfg;
311
+ try {
312
+ cfg = readMirrorConfigForVault(name);
313
+ } catch (err) {
314
+ console.warn(`[mirror] could not read config for vault "${name}" (skipping): ${(err as Error).message ?? err}`);
315
+ continue;
316
+ }
317
+ if (!cfg?.enabled) continue; // unconfigured / disabled → no manager work
253
318
  try {
254
- mirrorManager = new MirrorManager(buildMirrorDeps(mirrorVaultName));
255
- setMirrorManager(mirrorManager);
256
- // Don't block server startup on a slow initial export — kick it off
257
- // in the background, log the outcome. The HTTP server comes up
258
- // immediately so OAuth + REST aren't blocked behind a multi-second
259
- // export pass.
260
- void mirrorManager
319
+ const mgr = new MirrorManager(buildMirrorDeps(name));
320
+ setMirrorManager(name, mgr);
321
+ void mgr
261
322
  .start()
262
323
  .then((status) => {
263
324
  if (!status.enabled && status.last_error) {
264
- console.warn(`[mirror] startup error: ${status.last_error}`);
325
+ console.warn(`[mirror] vault "${name}" startup error: ${status.last_error}`);
265
326
  }
266
327
  })
267
328
  .catch((err) => {
268
- console.warn(`[mirror] startup threw: ${(err as Error).message ?? err}`);
329
+ console.warn(`[mirror] vault "${name}" startup threw: ${(err as Error).message ?? err}`);
269
330
  });
270
331
  } catch (err) {
271
- console.warn(`[mirror] manager construction failed: ${(err as Error).message ?? err}`);
332
+ // Isolated per-vault: one vault's failure never touches others.
333
+ console.warn(`[mirror] vault "${name}" manager construction failed: ${(err as Error).message ?? err}`);
272
334
  }
273
- } else {
274
- console.log("[mirror] no vaults yet — manager will be initialized on next restart after a vault exists");
275
335
  }
276
336
  }
277
337
 
@@ -315,18 +375,39 @@ const server = Bun.serve({
315
375
  console.log(`Parachute Vault server listening on http://${hostname}:${server.port}`);
316
376
 
317
377
  // Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
378
+ //
379
+ // Order matters under the event-driven mirror shape (vault#382):
380
+ // 1. Every MirrorManager.stop() runs FIRST (vault#400: drain ALL per-vault
381
+ // managers, not just one) so they unsubscribe from hooks cleanly +
382
+ // cancel their debounce timers. Otherwise the registry drain below
383
+ // would wait on a manager's hook handler that just queued itself after
384
+ // a final mutation.
385
+ // 2. Then drain hooks + stop the transcription worker in parallel —
386
+ // neither depends on the other.
387
+ //
388
+ // The whole thing races a 5s timeout so a stuck handler doesn't hang
389
+ // shutdown indefinitely.
318
390
  async function shutdown(signal: string): Promise<void> {
319
391
  console.log(`\n[${signal}] shutting down; in-flight hooks: ${defaultHookRegistry.inFlightCount}`);
320
392
  try {
321
393
  await Promise.race([
322
- Promise.all([
323
- defaultHookRegistry.drain(),
324
- transcriptionWorker?.stop() ?? Promise.resolve(),
325
- // Mirror manager: cancel the watch interval + let any in-flight
326
- // export cycle settle. `stop` already has its own brief timeout
327
- // (250ms) so this doesn't block the larger shutdown race.
328
- mirrorManager?.stop() ?? Promise.resolve(),
329
- ]),
394
+ (async () => {
395
+ // Mirror stop first — unsubscribes + cancels each debounce. Each
396
+ // manager's internal soft-settle timeout (250ms) bounds the wait;
397
+ // drain them in parallel so total shutdown stays bounded.
398
+ await Promise.all(
399
+ listMirrorManagers().map((m) =>
400
+ m.stop().catch((err) =>
401
+ console.warn(`[mirror] stop threw during shutdown (non-fatal): ${(err as Error).message ?? err}`),
402
+ ),
403
+ ),
404
+ );
405
+ // Then drain hooks + stop the transcription worker in parallel.
406
+ await Promise.all([
407
+ defaultHookRegistry.drain(),
408
+ transcriptionWorker?.stop() ?? Promise.resolve(),
409
+ ]);
410
+ })(),
330
411
  new Promise<void>((resolve) => setTimeout(resolve, 5000)),
331
412
  ]);
332
413
  } catch (err) {