@openparachute/vault 0.4.9-rc.9 → 0.5.0-rc.2

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 (62) hide show
  1. package/README.md +51 -54
  2. package/core/src/core.test.ts +4 -1
  3. package/core/src/indexed-fields.test.ts +151 -0
  4. package/core/src/indexed-fields.ts +98 -0
  5. package/core/src/mcp.ts +66 -43
  6. package/core/src/notes.ts +26 -2
  7. package/core/src/portable-md.test.ts +52 -0
  8. package/core/src/portable-md.ts +48 -0
  9. package/core/src/schema.ts +87 -14
  10. package/core/src/store.ts +117 -0
  11. package/core/src/types.ts +28 -0
  12. package/package.json +2 -2
  13. package/src/auth-hub-jwt.test.ts +191 -11
  14. package/src/auth-status.ts +12 -5
  15. package/src/auth.test.ts +135 -219
  16. package/src/auth.ts +158 -107
  17. package/src/cli.ts +306 -224
  18. package/src/config.ts +12 -4
  19. package/src/export-watch.test.ts +23 -0
  20. package/src/export-watch.ts +14 -0
  21. package/src/git-preflight.test.ts +70 -0
  22. package/src/git-preflight.ts +68 -0
  23. package/src/hub-jwt.test.ts +27 -2
  24. package/src/hub-jwt.ts +10 -0
  25. package/src/init-summary.test.ts +4 -4
  26. package/src/init-summary.ts +36 -10
  27. package/src/mcp-config.test.ts +4 -2
  28. package/src/mcp-http.ts +24 -3
  29. package/src/mcp-install-interactive.test.ts +33 -71
  30. package/src/mcp-install-interactive.ts +23 -76
  31. package/src/mcp-install.test.ts +156 -55
  32. package/src/mcp-install.ts +109 -3
  33. package/src/mcp-tools.ts +249 -74
  34. package/src/mirror-config.test.ts +107 -0
  35. package/src/mirror-config.ts +275 -9
  36. package/src/mirror-credentials.test.ts +168 -17
  37. package/src/mirror-credentials.ts +155 -32
  38. package/src/mirror-deps.ts +25 -16
  39. package/src/mirror-import.test.ts +122 -16
  40. package/src/mirror-import.ts +50 -16
  41. package/src/mirror-manager.test.ts +51 -0
  42. package/src/mirror-manager.ts +116 -22
  43. package/src/mirror-per-vault.test.ts +519 -0
  44. package/src/mirror-registry.ts +91 -14
  45. package/src/mirror-routes.test.ts +81 -21
  46. package/src/mirror-routes.ts +90 -16
  47. package/src/routes.ts +39 -2
  48. package/src/routing.test.ts +203 -118
  49. package/src/routing.ts +46 -59
  50. package/src/scopes.test.ts +0 -86
  51. package/src/scopes.ts +9 -97
  52. package/src/server.ts +102 -34
  53. package/src/storage.test.ts +132 -7
  54. package/src/token-store.test.ts +88 -169
  55. package/src/token-store.ts +123 -249
  56. package/src/vault-create.test.ts +12 -4
  57. package/src/vault.test.ts +408 -103
  58. package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
  59. package/web/ui/dist/index.html +1 -1
  60. package/src/tokens-routes.test.ts +0 -727
  61. package/src/tokens-routes.ts +0 -392
  62. package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/routing.ts CHANGED
@@ -64,7 +64,6 @@ import {
64
64
  type TagScopeCtx,
65
65
  } from "./routes.ts";
66
66
  import { expandTokenTagScope } from "./tag-scope.ts";
67
- import { handleTokens } from "./tokens-routes.ts";
68
67
  import {
69
68
  handleProtectedResource,
70
69
  handleAuthorizationServer,
@@ -127,12 +126,11 @@ async function withMcpChallenge(
127
126
  async function isViewAuthenticated(
128
127
  req: Request,
129
128
  vaultConfig: VaultConfig | null,
130
- vaultDb?: import("bun:sqlite").Database,
131
129
  ): Promise<boolean> {
132
130
  if (!vaultConfig) return false;
133
131
  const key = extractApiKey(req);
134
132
  if (!key) return false;
135
- const auth = await authenticateVaultRequest(req, vaultConfig, vaultDb);
133
+ const auth = await authenticateVaultRequest(req, vaultConfig);
136
134
  return !("error" in auth);
137
135
  }
138
136
 
@@ -334,7 +332,7 @@ export async function route(
334
332
  const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
335
333
  if (vaultViewMatch && req.method === "GET") {
336
334
  const store = getVaultStore(vaultName);
337
- const authenticated = await isViewAuthenticated(req, vaultConfig, store.db);
335
+ const authenticated = await isViewAuthenticated(req, vaultConfig);
338
336
  return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]!), {
339
337
  authenticated,
340
338
  publishedTag: vaultConfig.published_tag,
@@ -395,7 +393,7 @@ export async function route(
395
393
  // (worker intervals, TTLs, retention policy) but are still configuration
396
394
  // an attacker could use to map the deployment. `vault:admin` keeps the
397
395
  // hub's loopback workflow intact while locking out read-only tokens.
398
- const configAuth = await authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
396
+ const configAuth = await authenticateVaultRequest(req, vaultConfig);
399
397
  if ("error" in configAuth) return configAuth.error;
400
398
  if (!hasScopeForVault(configAuth.scopes, vaultName, "admin")) {
401
399
  return Response.json(
@@ -430,7 +428,7 @@ export async function route(
430
428
  // ---------------------------------------------------------------------
431
429
 
432
430
  const store = getVaultStore(vaultName);
433
- const auth = await authenticateVaultRequest(req, vaultConfig, store.db);
431
+ const auth = await authenticateVaultRequest(req, vaultConfig);
434
432
  const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
435
433
  if ("error" in auth) {
436
434
  return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
@@ -438,7 +436,15 @@ export async function route(
438
436
 
439
437
  // MCP (per-vault, single-vault session).
440
438
  if (isScopedMcp) {
441
- return handleScopedMcp(req, vaultName, auth);
439
+ // Thread the RAW caller bearer (the exact credential the session
440
+ // presented) into the MCP layer so the manage-token tool can forward it
441
+ // to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
442
+ // validated bearer — never a fabricated one. extractApiKey returns the
443
+ // same value `authenticateVaultRequest` validated above; non-forwardable
444
+ // credentials (env-var secret, legacy pvt_*) are handled by manage-token
445
+ // itself (it only forwards JWT-shaped bearers).
446
+ const callerBearer = extractApiKey(req);
447
+ return handleScopedMcp(req, vaultName, auth, callerBearer);
442
448
  }
443
449
 
444
450
  // Bare `/vault/<name>` — single-vault root. Returns name, description,
@@ -456,40 +462,18 @@ export async function route(
456
462
  });
457
463
  }
458
464
 
459
- // /tokens — admin-gated REST surface for minting/listing/revoking pvt_*
460
- // tokens. Admin gate applies to every method (POST/GET/DELETE) since both
461
- // the plaintext mint and the metadata listing are sensitive — knowing
462
- // labels and scopes of issued tokens leaks the deployment's auth shape.
463
- // The handler's POST path applies a strict subset check on requested
464
- // scopes via `validateMintedScopes` (defense-in-depth: cross-vault and
465
- // privilege-escalation rejections survive even if this gate is later
466
- // relaxed).
467
- const tokensMatch = subpath.match(/^\/tokens(\/.*)?$/);
468
- if (tokensMatch) {
469
- if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
470
- return Response.json(
471
- {
472
- error: "Forbidden",
473
- error_type: "insufficient_scope",
474
- message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
475
- required_scope: SCOPE_ADMIN,
476
- granted_scopes: auth.scopes,
477
- },
478
- { status: 403 },
479
- );
480
- }
481
- return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
482
- }
483
-
484
- // /.parachute/mirror — vault-sync Phase A1. Admin-gated read+write of
485
- // the persistent mirror config + runtime status. Lives under
486
- // `.parachute/` (alongside info/icon/config) rather than `admin/`
487
- // because `/vault/<name>/admin/*` is reserved for the admin SPA's
488
- // static-file mount; the API surface goes under `.parachute/` by the
489
- // module-protocol convention. Per the design doc, the hub admin SPA
490
- // (Phase A2 — future PR) is the eventual primary consumer; for Phase
491
- // A1 these endpoints unblock direct API callers and the by-hand
492
- // config workflow.
465
+ // The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
466
+ // at 0.5.0 (vault#282 Stage 2 vault is a pure hub resource-server). Hub
467
+ // JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
468
+ // request now falls through to the catch-all 404 below.
469
+
470
+ // /.parachute/mirror Admin-gated read+write of THIS vault's persistent
471
+ // mirror config + runtime status. Per-vault (vault#400): the manager is
472
+ // resolved by the URL's vault name, so each vault's mirror page reflects
473
+ // its own config + git remote. Lives under `.parachute/` (alongside
474
+ // info/icon/config) rather than `admin/` because `/vault/<name>/admin/*`
475
+ // is reserved for the admin SPA's static-file mount; the API surface goes
476
+ // under `.parachute/` by the module-protocol convention.
493
477
  if (subpath === "/.parachute/mirror") {
494
478
  if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
495
479
  return Response.json(
@@ -503,19 +487,22 @@ export async function route(
503
487
  { status: 403 },
504
488
  );
505
489
  }
506
- const manager = getMirrorManager();
490
+ // vault#400: resolve the manager for THIS vault (from the URL). The
491
+ // registry lazily builds one (via the boot-installed factory) for any
492
+ // existing vault — including a non-default vault or one configured at
493
+ // runtime — so the handler operates on the right vault's config + status
494
+ // + git remote, never the default vault's.
495
+ const manager = getMirrorManager(vaultName);
507
496
  if (!manager) {
508
- // The boot path constructs a manager when at least one vault
509
- // exists; a missing manager here means either a startup error or
510
- // a brand-new deploy that hasn't finished first-boot. Surface a
511
- // clear 503 rather than a JSON null so the operator + the hub
512
- // SPA know it's a service-state issue, not a misconfig on their
513
- // end.
497
+ // Null only when boot hasn't installed the factory yet (startup race
498
+ // or boot failure). Surface a clear 503 rather than a JSON null so the
499
+ // operator + the hub SPA know it's a service-state issue, not a
500
+ // misconfig on their end.
514
501
  return Response.json(
515
502
  {
516
503
  error: "Mirror manager not initialized",
517
504
  message:
518
- "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
505
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
519
506
  },
520
507
  { status: 503 },
521
508
  );
@@ -543,13 +530,13 @@ export async function route(
543
530
  { status: 403 },
544
531
  );
545
532
  }
546
- const manager = getMirrorManager();
533
+ const manager = getMirrorManager(vaultName);
547
534
  if (!manager) {
548
535
  return Response.json(
549
536
  {
550
537
  error: "Mirror manager not initialized",
551
538
  message:
552
- "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
539
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
553
540
  },
554
541
  { status: 503 },
555
542
  );
@@ -575,13 +562,13 @@ export async function route(
575
562
  { status: 403 },
576
563
  );
577
564
  }
578
- const manager = getMirrorManager();
565
+ const manager = getMirrorManager(vaultName);
579
566
  if (!manager) {
580
567
  return Response.json(
581
568
  {
582
569
  error: "Mirror manager not initialized",
583
570
  message:
584
- "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
571
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
585
572
  },
586
573
  { status: 503 },
587
574
  );
@@ -631,20 +618,20 @@ export async function route(
631
618
  { status: 403 },
632
619
  );
633
620
  }
634
- const manager = getMirrorManager();
621
+ const manager = getMirrorManager(vaultName);
635
622
  if (!manager) {
636
623
  return Response.json(
637
624
  {
638
625
  error: "Mirror manager not initialized",
639
626
  message:
640
- "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
627
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
641
628
  },
642
629
  { status: 503 },
643
630
  );
644
631
  }
645
632
 
646
633
  if (subpath === "/.parachute/mirror/auth") {
647
- if (req.method === "GET") return handleAuthGet();
634
+ if (req.method === "GET") return handleAuthGet(manager);
648
635
  if (req.method === "DELETE") return handleAuthDelete(manager);
649
636
  return Response.json({ error: "Method not allowed" }, { status: 405 });
650
637
  }
@@ -657,11 +644,11 @@ export async function route(
657
644
  return Response.json({ error: "Method not allowed" }, { status: 405 });
658
645
  }
659
646
  if (subpath === "/.parachute/mirror/auth/github/repos") {
660
- if (req.method === "GET") return handleAuthGithubRepos();
647
+ if (req.method === "GET") return handleAuthGithubRepos(manager);
661
648
  return Response.json({ error: "Method not allowed" }, { status: 405 });
662
649
  }
663
650
  if (subpath === "/.parachute/mirror/auth/github/create-repo") {
664
- if (req.method === "POST") return handleAuthGithubCreateRepo(req);
651
+ if (req.method === "POST") return handleAuthGithubCreateRepo(req, manager);
665
652
  return Response.json({ error: "Method not allowed" }, { status: 405 });
666
653
  }
667
654
  if (subpath === "/.parachute/mirror/auth/github/select-repo") {
@@ -722,7 +709,7 @@ export async function route(
722
709
  });
723
710
  }
724
711
  if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
725
- if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName);
712
+ if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName, store, tagScope);
726
713
  if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
727
714
 
728
715
  return Response.json({ error: "Not found" }, { status: 404 });
@@ -11,7 +11,6 @@ import {
11
11
  SCOPE_ADMIN,
12
12
  parseScopes,
13
13
  parseScopeFlags,
14
- resolveCreateTokenFlags,
15
14
  hasScope,
16
15
  hasScopeForVault,
17
16
  findBroadVaultScopes,
@@ -254,91 +253,6 @@ describe("parseScopeFlags", () => {
254
253
  });
255
254
  });
256
255
 
257
- describe("resolveCreateTokenFlags", () => {
258
- test("no flags → full scope, full permission (historical default)", () => {
259
- expect(resolveCreateTokenFlags([]))
260
- .toEqual({ scopes: undefined, permission: "full", error: null });
261
- });
262
-
263
- test("--read alone → [vault:read], read permission", () => {
264
- expect(resolveCreateTokenFlags(["--read"]))
265
- .toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
266
- });
267
-
268
- test("--scope vault:read alone → read permission", () => {
269
- expect(resolveCreateTokenFlags(["--scope", "vault:read"]))
270
- .toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
271
- });
272
-
273
- test("--scope vault:write,vault:read → full permission (any write surface → full)", () => {
274
- expect(resolveCreateTokenFlags(["--scope", "vault:write,vault:read"]))
275
- .toEqual({ scopes: [SCOPE_WRITE, SCOPE_READ], permission: "full", error: null });
276
- });
277
-
278
- test("--scope vault:admin alone → full permission", () => {
279
- expect(resolveCreateTokenFlags(["--scope", "vault:admin"]))
280
- .toEqual({ scopes: [SCOPE_ADMIN], permission: "full", error: null });
281
- });
282
-
283
- test("--permission read → no scopes (token-store default), read permission", () => {
284
- expect(resolveCreateTokenFlags(["--permission", "read"]))
285
- .toEqual({ scopes: undefined, permission: "read", error: null });
286
- });
287
-
288
- test("--permission full → no scopes, full permission", () => {
289
- expect(resolveCreateTokenFlags(["--permission", "full"]))
290
- .toEqual({ scopes: undefined, permission: "full", error: null });
291
- });
292
-
293
- test("--scope + --read errors and mentions both flags", () => {
294
- const result = resolveCreateTokenFlags(["--scope", "vault:write", "--read"]);
295
- expect(result.scopes).toBeUndefined();
296
- expect(result.error).toContain("--scope");
297
- expect(result.error).toContain("--read");
298
- expect(result.error).toContain("cannot be combined");
299
- });
300
-
301
- test("--scope + --permission errors", () => {
302
- const result = resolveCreateTokenFlags(["--scope", "vault:read", "--permission", "full"]);
303
- expect(result.scopes).toBeUndefined();
304
- expect(result.error).toContain("--scope");
305
- expect(result.error).toContain("--permission");
306
- expect(result.error).toContain("cannot be combined");
307
- });
308
-
309
- test("--read + --permission errors", () => {
310
- const result = resolveCreateTokenFlags(["--read", "--permission", "full"]);
311
- expect(result.scopes).toBeUndefined();
312
- expect(result.error).toContain("--read");
313
- expect(result.error).toContain("--permission");
314
- expect(result.error).toContain("cannot be combined");
315
- });
316
-
317
- test("invalid --permission value errors with prefer-scope hint", () => {
318
- const result = resolveCreateTokenFlags(["--permission", "admin"]);
319
- expect(result.scopes).toBeUndefined();
320
- expect(result.error).toContain("admin");
321
- expect(result.error).toContain("full");
322
- expect(result.error).toContain("read");
323
- expect(result.error).toContain("--scope");
324
- });
325
-
326
- test("--permission with no value errors", () => {
327
- expect(resolveCreateTokenFlags(["--permission"]).error).toContain("requires a value");
328
- expect(resolveCreateTokenFlags(["--permission", "--label", "x"]).error).toContain("requires a value");
329
- });
330
-
331
- test("surfaces parseScopeFlags errors unchanged", () => {
332
- const result = resolveCreateTokenFlags(["--scope", "vault:frob"]);
333
- expect(result.error).toContain("Unknown scope");
334
- });
335
-
336
- test("ignores unrelated flags", () => {
337
- const result = resolveCreateTokenFlags(["--vault", "journal", "--label", "r", "--read"]);
338
- expect(result).toEqual({ scopes: [SCOPE_READ], permission: "read", error: null });
339
- });
340
- });
341
-
342
256
  describe("serializeScopes — round-trips with parseScopes", () => {
343
257
  test("joins with spaces", () => {
344
258
  expect(serializeScopes([SCOPE_READ, SCOPE_WRITE])).toBe("vault:read vault:write");
package/src/scopes.ts CHANGED
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Tokens carry OAuth-standard whitespace-separated scopes. Two shapes coexist:
5
5
  *
6
- * - **Broad** `vault:<verb>` — used by `pvt_*` tokens, which are vault-pinned
7
- * by storage (each vault has its own tokens DB; a token only resolves
8
- * against the vault that minted it).
6
+ * - **Broad** `vault:<verb>` — used by legacy YAML api_keys and the
7
+ * VAULT_AUTH_TOKEN operator bearer, which are vault-pinned by context
8
+ * (the YAML key lives under a specific vault; the operator bearer is
9
+ * server-wide full-admin). (The `pvt_*` vault-DB token that also used
10
+ * this shape was dropped at 0.5.0 — vault#282 Stage 2.)
9
11
  * - **Narrowed** `vault:<name>:<verb>` — used by hub-issued JWTs, which are
10
12
  * not pinned by storage and so MUST name the resource they grant access
11
13
  * to. Hub JWTs carrying broad `vault:<verb>` are rejected at validation
@@ -113,9 +115,10 @@ export function hasScope(granted: string[], required: string): boolean {
113
115
  *
114
116
  * Match rules:
115
117
  * - Broad `vault:<verb>` in granted satisfies any vault (the broad scope
116
- * has no resource constraint; the caller pins the vault upstream — pvt_*
117
- * resolves only against its issuing vault's DB, hub JWTs reject broad
118
- * scopes at validation).
118
+ * has no resource constraint; the caller pins the vault upstream — a
119
+ * legacy YAML key lives under a specific vault, the VAULT_AUTH_TOKEN
120
+ * bearer is server-wide full-admin, and hub JWTs reject broad scopes at
121
+ * validation).
119
122
  * - Narrowed `vault:<name>:<verb>` satisfies only the matching `vaultName`.
120
123
  * - Verb inheritance `admin ⊇ write ⊇ read` applies in both forms.
121
124
  */
@@ -286,94 +289,3 @@ export function parseScopeFlags(
286
289
  }
287
290
  return { scopes: deduped, error: null };
288
291
  }
289
-
290
- /**
291
- * Resolve `parachute vault tokens create` argv into a concrete scope set +
292
- * legacy `permission` column value, or an actionable error.
293
- *
294
- * Precedence is **exclusive**: `--scope`, `--read`, and `--permission` all
295
- * narrow the token, but combining them is always an error — a user who
296
- * writes `--scope vault:write --read` almost certainly expects one of the
297
- * two to win, and silently picking would mint the opposite of what at
298
- * least one reading intended. Fail loud for anything token-minting.
299
- *
300
- * With no narrowing flag, falls back to a full-scope token for back-compat.
301
- */
302
- export function resolveCreateTokenFlags(args: string[]): {
303
- scopes: string[] | undefined;
304
- permission: "full" | "read";
305
- error: string | null;
306
- } {
307
- const scopeResult = parseScopeFlags(args);
308
- if (scopeResult.error) {
309
- return { scopes: undefined, permission: "full", error: scopeResult.error };
310
- }
311
- const hasScopeFlag = scopeResult.scopes !== null;
312
- const hasReadFlag = args.includes("--read");
313
- const permIdx = args.indexOf("--permission");
314
- const hasPermFlag = permIdx !== -1;
315
-
316
- if (hasScopeFlag && hasReadFlag) {
317
- return {
318
- scopes: undefined,
319
- permission: "full",
320
- error:
321
- "--scope and --read cannot be combined. Pick one:\n" +
322
- " --read # shorthand for --scope vault:read\n" +
323
- " --scope vault:read # equivalent, explicit\n" +
324
- " --scope vault:write # write scope",
325
- };
326
- }
327
- if (hasScopeFlag && hasPermFlag) {
328
- return {
329
- scopes: undefined,
330
- permission: "full",
331
- error:
332
- "--scope and --permission cannot be combined. --scope is the canonical way to narrow a token; --permission is legacy.",
333
- };
334
- }
335
- if (hasReadFlag && hasPermFlag) {
336
- return {
337
- scopes: undefined,
338
- permission: "full",
339
- error: "--read and --permission cannot be combined. --read is a shorthand for --permission read.",
340
- };
341
- }
342
-
343
- if (hasPermFlag) {
344
- const rawPerm = args[permIdx + 1];
345
- if (!rawPerm || rawPerm.startsWith("--")) {
346
- return {
347
- scopes: undefined,
348
- permission: "full",
349
- error: `--permission requires a value ("full" or "read"). Prefer --scope for new scripts.`,
350
- };
351
- }
352
- if (!["full", "read"].includes(rawPerm)) {
353
- return {
354
- scopes: undefined,
355
- permission: "full",
356
- error: `Invalid --permission: ${rawPerm}. Must be "full" or "read". Prefer --scope for new scripts.`,
357
- };
358
- }
359
- }
360
-
361
- if (scopeResult.scopes) {
362
- const scopes = scopeResult.scopes;
363
- const permission: "full" | "read" =
364
- scopes.includes(SCOPE_WRITE) || scopes.includes(SCOPE_ADMIN) ? "full" : "read";
365
- return { scopes, permission, error: null };
366
- }
367
- if (hasReadFlag) {
368
- return { scopes: [SCOPE_READ], permission: "read", error: null };
369
- }
370
- if (hasPermFlag) {
371
- const rawPerm = args[permIdx + 1];
372
- return {
373
- scopes: undefined,
374
- permission: rawPerm === "read" ? "read" : "full",
375
- error: null,
376
- };
377
- }
378
- return { scopes: undefined, permission: "full", error: null };
379
- }
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;
253
311
  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
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
318
+ try {
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
 
@@ -317,10 +377,11 @@ console.log(`Parachute Vault server listening on http://${hostname}:${server.por
317
377
  // Graceful shutdown — best-effort drain of in-flight note-mutation hooks.
318
378
  //
319
379
  // Order matters under the event-driven mirror shape (vault#382):
320
- // 1. MirrorManager.stop() runs FIRST so it can unsubscribe from hooks
321
- // cleanly + cancel its debounce timer. Otherwise the registry drain
322
- // below would wait on the manager's hook handler that just queued
323
- // itself after a final mutation.
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.
324
385
  // 2. Then drain hooks + stop the transcription worker in parallel —
325
386
  // neither depends on the other.
326
387
  //
@@ -331,9 +392,16 @@ async function shutdown(signal: string): Promise<void> {
331
392
  try {
332
393
  await Promise.race([
333
394
  (async () => {
334
- // Mirror stop first — unsubscribes + cancels its debounce. Its
335
- // internal soft-settle timeout (250ms) bounds the wait.
336
- await (mirrorManager?.stop() ?? Promise.resolve());
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
+ );
337
405
  // Then drain hooks + stop the transcription worker in parallel.
338
406
  await Promise.all([
339
407
  defaultHookRegistry.drain(),