@openparachute/vault 0.6.3 → 0.6.4-rc.10

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 (51) hide show
  1. package/README.md +46 -11
  2. package/core/src/attribution.test.ts +273 -0
  3. package/core/src/core.test.ts +126 -0
  4. package/core/src/cursor.ts +8 -0
  5. package/core/src/enforced-writes.test.ts +533 -0
  6. package/core/src/mcp.ts +235 -9
  7. package/core/src/migrate-tag-field.test.ts +471 -0
  8. package/core/src/migrate-tag-field.ts +638 -0
  9. package/core/src/notes.ts +280 -7
  10. package/core/src/query-operators.ts +117 -0
  11. package/core/src/schema-defaults.ts +162 -9
  12. package/core/src/schema.ts +61 -2
  13. package/core/src/store.ts +51 -19
  14. package/core/src/tag-schemas.ts +12 -0
  15. package/core/src/triggers-store.ts +6 -0
  16. package/core/src/types.ts +35 -14
  17. package/core/src/vault-projection.ts +19 -0
  18. package/package.json +1 -1
  19. package/src/admin-spa.test.ts +18 -5
  20. package/src/admin-spa.ts +24 -3
  21. package/src/attribution-threading.test.ts +350 -0
  22. package/src/auth.ts +82 -4
  23. package/src/cli.ts +345 -9
  24. package/src/config.ts +11 -0
  25. package/src/first-boot-create.test.ts +155 -0
  26. package/src/import-daemon-busy.test.ts +8 -17
  27. package/src/mcp-http.ts +27 -0
  28. package/src/mcp-tools.ts +31 -4
  29. package/src/mirror-credentials.test.ts +47 -0
  30. package/src/mirror-credentials.ts +33 -0
  31. package/src/mirror-history.test.ts +426 -0
  32. package/src/mirror-manager.ts +202 -22
  33. package/src/mirror-routes.test.ts +78 -0
  34. package/src/mirror-routes.ts +195 -1
  35. package/src/module-config.ts +46 -80
  36. package/src/routes.ts +209 -41
  37. package/src/routing.test.ts +115 -25
  38. package/src/routing.ts +64 -2
  39. package/src/scale.bench.test.ts +82 -0
  40. package/src/scopes.test.ts +24 -0
  41. package/src/scopes.ts +63 -0
  42. package/src/self-register.test.ts +5 -5
  43. package/src/self-register.ts +8 -3
  44. package/src/server.ts +58 -38
  45. package/src/subscribe.test.ts +23 -2
  46. package/src/test-support/spawn.ts +85 -0
  47. package/src/triggers-api.ts +24 -0
  48. package/src/triggers.test.ts +33 -0
  49. package/src/triggers.ts +17 -0
  50. package/src/vault-remove.test.ts +4 -5
  51. package/src/vault.test.ts +188 -17
@@ -1262,12 +1262,17 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
1262
1262
  expect(body.type).toBe("object");
1263
1263
  expect(body.properties.audio_retention?.type).toBe("string");
1264
1264
  expect(body.properties.audio_retention?.enum).toEqual(["keep", "until_transcribed", "never"]);
1265
- expect(body.properties.scribe_url?.type).toBe("string");
1266
- expect(body.properties.scribe_token?.writeOnly).toBe(true);
1267
- expect(body.properties.port?.readOnly).toBe(true);
1265
+ expect(body.properties.autoTranscribe?.type).toBe("object");
1266
+ // vault#478 scope boundary: daemon-GLOBAL fields are NOT described by the
1267
+ // per-vault admin config schema. The per-vault admin surface is "admin
1268
+ // over *your* vault only" — port / scribe URL / scribe bearer are
1269
+ // deployment-wide and live behind the operator-only surface.
1270
+ expect(body.properties).not.toHaveProperty("scribe_url");
1271
+ expect(body.properties).not.toHaveProperty("scribe_token");
1272
+ expect(body.properties).not.toHaveProperty("port");
1268
1273
  });
1269
1274
 
1270
- test("config returns current values with writeOnly fields excluded", async () => {
1275
+ test("config returns ONLY per-vault values, never daemon-global ones (scope boundary, vault#478)", async () => {
1271
1276
  createVault("journal");
1272
1277
  const token = await createAdminToken("journal");
1273
1278
  const path = "/vault/journal/.parachute/config";
@@ -1284,13 +1289,22 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
1284
1289
  );
1285
1290
  expect(res.status).toBe(200);
1286
1291
  const body = (await res.json()) as Record<string, unknown>;
1292
+ // Per-vault config is present.
1287
1293
  expect(body.audio_retention).toBe("keep"); // default when unset
1288
- expect(body.scribe_url).toBe("https://scribe.example/v1");
1289
- expect(body.port).toBe(1940);
1294
+ expect(body).toHaveProperty("autoTranscribe");
1295
+ // Daemon-GLOBAL values must NOT cross the per-vault admin boundary.
1296
+ expect(body).not.toHaveProperty("port");
1297
+ expect(body).not.toHaveProperty("scribe_url");
1298
+ // The discovery-resolved scribe URL is daemon-global → must not leak,
1299
+ // even nested under autoTranscribe.
1300
+ const at = body.autoTranscribe as Record<string, unknown>;
1301
+ expect(at).not.toHaveProperty("scribeUrl");
1290
1302
  // writeOnly field must not appear in GET.
1291
1303
  expect(body).not.toHaveProperty("scribe_token");
1292
- // Defense in depth: grep the raw body for the token value.
1293
- expect(JSON.stringify(body)).not.toContain("super-secret-should-never-appear");
1304
+ // Defense in depth: no daemon-global value (URL or secret) anywhere.
1305
+ const raw = JSON.stringify(body);
1306
+ expect(raw).not.toContain("super-secret-should-never-appear");
1307
+ expect(raw).not.toContain("https://scribe.example/v1");
1294
1308
  } finally {
1295
1309
  if (origScribeToken === undefined) delete process.env.SCRIBE_TOKEN;
1296
1310
  else process.env.SCRIBE_TOKEN = origScribeToken;
@@ -1318,24 +1332,27 @@ describe("/.parachute/config/schema + /.parachute/config", () => {
1318
1332
  expect(body.audio_retention).toBe("until_transcribed");
1319
1333
  });
1320
1334
 
1321
- test("config scribe_url falls back to empty string when SCRIBE_URL env is unset", async () => {
1322
- createVault("journal");
1335
+ test("autoTranscribe.enabled reports the per-vault override (vault#478)", async () => {
1336
+ // Server default ON; this vault explicitly opts OUT — the per-vault value
1337
+ // must win, proving the field is reported per-vault (not the raw global).
1338
+ writeGlobalConfig({ port: 1940, auto_transcribe: { enabled: true } });
1339
+ writeVaultConfig({
1340
+ name: "journal",
1341
+ api_keys: [],
1342
+ created_at: new Date().toISOString(),
1343
+ auto_transcribe: { enabled: false },
1344
+ });
1323
1345
  const token = await createAdminToken("journal");
1324
- const orig = process.env.SCRIBE_URL;
1325
- delete process.env.SCRIBE_URL;
1326
- try {
1327
- const path = "/vault/journal/.parachute/config";
1328
- const res = await route(
1329
- new Request(`http://localhost:1940${path}`, {
1330
- headers: { authorization: `Bearer ${token}` },
1331
- }),
1332
- path,
1333
- );
1334
- const body = (await res.json()) as { scribe_url: string };
1335
- expect(body.scribe_url).toBe("");
1336
- } finally {
1337
- if (orig !== undefined) process.env.SCRIBE_URL = orig;
1338
- }
1346
+ const path = "/vault/journal/.parachute/config";
1347
+ const res = await route(
1348
+ new Request(`http://localhost:1940${path}`, {
1349
+ headers: { authorization: `Bearer ${token}` },
1350
+ }),
1351
+ path,
1352
+ );
1353
+ const body = (await res.json()) as { autoTranscribe: { enabled: boolean } };
1354
+ expect(body.autoTranscribe.enabled).toBe(false);
1355
+ writeGlobalConfig({ port: 1940 });
1339
1356
  });
1340
1357
 
1341
1358
  test("unknown vault returns 404 before reaching the config handlers", async () => {
@@ -2249,6 +2266,79 @@ describe("/vault/<name>/.parachute/mirror/run-now — auth + dispatch", () => {
2249
2266
  });
2250
2267
  });
2251
2268
 
2269
+ // ---------------------------------------------------------------------------
2270
+ // /vault/<name>/.parachute/mirror/history — surface the git write history
2271
+ // (vault#300). Admin-gated (ops/forensics surface). Tests pin the auth gate
2272
+ // matches the sibling mirror routes; helper + handler shape lives in
2273
+ // mirror-history.test.ts.
2274
+ // ---------------------------------------------------------------------------
2275
+
2276
+ describe("/vault/<name>/.parachute/mirror/history — auth + dispatch", () => {
2277
+ test("unauthenticated → 401", async () => {
2278
+ createVault("journal");
2279
+ const p = "/vault/journal/.parachute/mirror/history";
2280
+ const res = await route(new Request(`http://localhost:1940${p}`), p);
2281
+ expect(res.status).toBe(401);
2282
+ });
2283
+
2284
+ test("vault:read token → 403 insufficient_scope (admin required)", async () => {
2285
+ createVault("journal");
2286
+ const fullToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2287
+ const p = "/vault/journal/.parachute/mirror/history";
2288
+ const res = await route(
2289
+ new Request(`http://localhost:1940${p}`, {
2290
+ headers: { authorization: `Bearer ${fullToken}` },
2291
+ }),
2292
+ p,
2293
+ );
2294
+ expect(res.status).toBe(403);
2295
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
2296
+ expect(body.error_type).toBe("insufficient_scope");
2297
+ expect(body.required_scope).toBe("vault:admin");
2298
+ });
2299
+
2300
+ test("admin token reaches the handler (200 when wired, 503 when not)", async () => {
2301
+ createVault("journal");
2302
+ const token = await createAdminToken("journal");
2303
+ const p = "/vault/journal/.parachute/mirror/history";
2304
+ const res = await route(
2305
+ new Request(`http://localhost:1940${p}`, {
2306
+ headers: { authorization: `Bearer ${token}` },
2307
+ }),
2308
+ p,
2309
+ );
2310
+ // Either way the auth gate passed — that's what this routing-level test pins.
2311
+ expect([200, 503]).toContain(res.status);
2312
+ });
2313
+
2314
+ test("/history/show admin gate matches /history", async () => {
2315
+ createVault("journal");
2316
+ const fullToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
2317
+ const p = "/vault/journal/.parachute/mirror/history/show";
2318
+ const res = await route(
2319
+ new Request(`http://localhost:1940${p}?sha=abc123&path=Notes/a`, {
2320
+ headers: { authorization: `Bearer ${fullToken}` },
2321
+ }),
2322
+ p,
2323
+ );
2324
+ expect(res.status).toBe(403);
2325
+ });
2326
+
2327
+ test("non-GET method → 405 (or 503 when manager not wired)", async () => {
2328
+ createVault("journal");
2329
+ const token = await createAdminToken("journal");
2330
+ const p = "/vault/journal/.parachute/mirror/history";
2331
+ const res = await route(
2332
+ new Request(`http://localhost:1940${p}`, {
2333
+ method: "POST",
2334
+ headers: { authorization: `Bearer ${token}` },
2335
+ }),
2336
+ p,
2337
+ );
2338
+ expect([405, 503]).toContain(res.status);
2339
+ });
2340
+ });
2341
+
2252
2342
  // ---------------------------------------------------------------------------
2253
2343
  // /vault/<name>/.parachute/usage — per-vault data-footprint endpoint.
2254
2344
  //
package/src/routing.ts CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  authenticateGlobalRequest,
53
53
  extractApiKey,
54
54
  } from "./auth.ts";
55
- import { hasScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
55
+ import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
56
56
  import { getVaultStore } from "./vault-store.ts";
57
57
  import { handleScopedMcp } from "./mcp-http.ts";
58
58
  import {
@@ -71,6 +71,7 @@ import {
71
71
  handleStorage,
72
72
  handleViewNote,
73
73
  type TagScopeCtx,
74
+ type WriteCtx,
74
75
  } from "./routes.ts";
75
76
  import { handleSubscribe } from "./subscribe.ts";
76
77
  import { handleTriggers } from "./triggers-api.ts";
@@ -93,6 +94,8 @@ import {
93
94
  handleAuthGithubSelectRepo,
94
95
  handleAuthPat,
95
96
  handleMirrorGet,
97
+ handleMirrorHistory,
98
+ handleMirrorHistoryShow,
96
99
  handleMirrorImport,
97
100
  handleMirrorPushNow,
98
101
  handleMirrorPut,
@@ -670,6 +673,50 @@ export async function route(
670
673
  return Response.json({ error: "Method not allowed" }, { status: 405 });
671
674
  }
672
675
 
676
+ // /.parachute/mirror/history — surface the mirror's git commit history
677
+ // (vault#300). The vault is already git-backed (one file per note), so
678
+ // `git log` IS a tamper-evident write history; this read path exposes it.
679
+ // Admin-gated (it's an ops/forensics surface, not a content read) — same
680
+ // gate + manager check as the sibling mirror routes. GET-only.
681
+ // /history — full mirror history (?path=<note> scopes to one note,
682
+ // ?limit=<n> caps the count)
683
+ // /history/show — `git show <sha>:<path>.md` to read a past revision
684
+ if (
685
+ subpath === "/.parachute/mirror/history" ||
686
+ subpath === "/.parachute/mirror/history/show"
687
+ ) {
688
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
689
+ return Response.json(
690
+ {
691
+ error: "Forbidden",
692
+ error_type: "insufficient_scope",
693
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
694
+ required_scope: SCOPE_ADMIN,
695
+ granted_scopes: auth.scopes,
696
+ },
697
+ { status: 403 },
698
+ );
699
+ }
700
+ const manager = getMirrorManager(vaultName);
701
+ if (!manager) {
702
+ return Response.json(
703
+ {
704
+ error: "Mirror manager not initialized",
705
+ message:
706
+ "The vault server hasn't wired the mirror manager registry yet (boot hasn't finished, or it failed). Check logs for [mirror] entries.",
707
+ },
708
+ { status: 503 },
709
+ );
710
+ }
711
+ if (req.method !== "GET") {
712
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
713
+ }
714
+ if (subpath === "/.parachute/mirror/history/show") {
715
+ return handleMirrorHistoryShow(req, manager);
716
+ }
717
+ return handleMirrorHistory(req, manager);
718
+ }
719
+
673
720
  // /.parachute/mirror/import — clone a vault export from git + import.
674
721
  // Admin-gated. POST-only. Synchronous (imports finish in <30s for
675
722
  // typical vaults). See mirror-routes.ts:handleMirrorImport for the
@@ -812,7 +859,22 @@ export async function route(
812
859
  raw: auth.scoped_tags,
813
860
  };
814
861
 
815
- if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
862
+ // Write-attribution context (vault#298). `auth.actor` is the principal;
863
+ // `auth.via` is the credential class (`api` for hub JWTs + legacy keys,
864
+ // `operator` for the env-var bearer). The REST surface IS the `api` channel,
865
+ // so no refinement is needed here — the base via stands (the MCP handler is
866
+ // the one that refines to `mcp`). Threaded only into the write handler.
867
+ // Migration-bypass (vault#299): a `vault:migrate`-scoped caller may write
868
+ // notes that violate `strict:true` field constraints (for backfill /
869
+ // migration). Every bypassed write is logged. Orthogonal to read/write/admin
870
+ // — an admin token does NOT bypass unless it also holds `migrate`.
871
+ const writeCtx: WriteCtx = {
872
+ actor: auth.actor,
873
+ via: auth.via,
874
+ bypassStrict: hasMigrateScopeForVault(auth.scopes, vaultName),
875
+ };
876
+
877
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope, writeCtx);
816
878
  // Live-query SSE subscription (design 2026-06-08). Snapshot + scoped live
817
879
  // upsert/remove events over text/event-stream. Auth + tag-scope already
818
880
  // resolved above and threaded through, mirroring the /notes branch.
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Opt-in scale smoke (vault#325 Part 3).
3
+ *
4
+ * Guards the synthetic scale harness against bit-rot WITHOUT slowing the
5
+ * default `bun test` gate. The real benchmark lives in
6
+ * `scripts/scale-bench.ts` (10k / 50k / 100k); this is a fast, tiny-size
7
+ * functional smoke of the same hot-path code so a green CI run still
8
+ * proves the harness paths execute end-to-end.
9
+ *
10
+ * SKIPPED BY DEFAULT. To run it:
11
+ *
12
+ * VAULT_SCALE_BENCH=1 bun test ./src/scale.bench.test.ts
13
+ *
14
+ * For real timings + the documented ceiling, run the standalone harness:
15
+ *
16
+ * bun scripts/scale-bench.ts # 10000 50000 100000
17
+ *
18
+ * See docs/SCALE.md.
19
+ */
20
+
21
+ import { describe, test, expect } from "bun:test";
22
+ import { Database } from "bun:sqlite";
23
+ import { BunSqliteStore } from "../core/src/store.ts";
24
+ import { generateMcpTools } from "../core/src/mcp.ts";
25
+ import { exportVaultToDir } from "../core/src/portable-md.ts";
26
+ import type { BulkNoteInput } from "../core/src/notes.ts";
27
+ import { mkdtempSync, rmSync } from "fs";
28
+ import { tmpdir } from "os";
29
+ import { join } from "path";
30
+
31
+ const ENABLED = process.env.VAULT_SCALE_BENCH === "1";
32
+
33
+ // `describe.skipIf` keeps the suite registered (so it shows in the test list)
34
+ // but contributes zero runtime to the default gate. Size is intentionally
35
+ // tiny — this is a functional smoke, not a timing run.
36
+ describe.skipIf(!ENABLED)("scale harness smoke (opt-in: VAULT_SCALE_BENCH=1)", () => {
37
+ test("seed + hot-path queries + export run at small N", async () => {
38
+ const dir = mkdtempSync(join(tmpdir(), "vault-scale-smoke-"));
39
+ const db = new Database(join(dir, "smoke.db"));
40
+ const store = new BunSqliteStore(db);
41
+ try {
42
+ const N = 500;
43
+
44
+ // Declare the indexed field via the update-tag tool (the owner of the
45
+ // indexed_fields lifecycle — mirrors scripts/scale-bench.ts).
46
+ const tools = generateMcpTools(store);
47
+ const updateTag = tools.find((t) => t.name === "update-tag");
48
+ expect(updateTag).toBeDefined();
49
+ await updateTag!.execute({
50
+ tag: "work",
51
+ fields: { status: { type: "string", indexed: true } },
52
+ });
53
+
54
+ const inputs: BulkNoteInput[] = Array.from({ length: N }, (_, i) => ({
55
+ id: `note-${i}`,
56
+ path: `notes/note-${i}`,
57
+ content: `# Note ${i}\n\nconsectetur body ${i}`,
58
+ tags: i % 3 === 0 ? ["work", "work/meeting"] : ["health"],
59
+ metadata: { status: i % 2 === 0 ? "active" : "done", seq: i },
60
+ }));
61
+ await store.createNotes(inputs);
62
+
63
+ // Hot paths — same shapes the standalone harness times.
64
+ expect((await store.queryNotes({ tags: ["health"], limit: 50 })).length).toBeGreaterThan(0);
65
+ expect(
66
+ (await store.queryNotes({ metadata: { status: { eq: "active" } }, limit: 50 })).length,
67
+ ).toBeGreaterThan(0);
68
+ expect((await store.queryNotes({ orderBy: "status", limit: 50 })).length).toBe(50);
69
+ expect((await store.searchNotes("consectetur", { limit: 50 })).length).toBeGreaterThan(0);
70
+
71
+ const stats = await exportVaultToDir(store, {
72
+ outDir: join(dir, "export"),
73
+ vaultName: "smoke",
74
+ exportedAt: "2026-01-01T00:00:00Z",
75
+ });
76
+ expect(stats.notes).toBe(N);
77
+ } finally {
78
+ db.close();
79
+ rmSync(dir, { recursive: true, force: true });
80
+ }
81
+ });
82
+ });
@@ -9,10 +9,12 @@ import {
9
9
  SCOPE_READ,
10
10
  SCOPE_WRITE,
11
11
  SCOPE_ADMIN,
12
+ SCOPE_MIGRATE,
12
13
  parseScopes,
13
14
  parseScopeFlags,
14
15
  hasScope,
15
16
  hasScopeForVault,
17
+ hasMigrateScopeForVault,
16
18
  findBroadVaultScopes,
17
19
  scopeForMethod,
18
20
  verbForMethod,
@@ -20,6 +22,28 @@ import {
20
22
  serializeScopes,
21
23
  } from "./scopes.ts";
22
24
 
25
+ describe("hasMigrateScopeForVault — migration-bypass capability (vault#299)", () => {
26
+ test("broad vault:migrate satisfies any vault", () => {
27
+ expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "default")).toBe(true);
28
+ expect(hasMigrateScopeForVault([SCOPE_MIGRATE], "anything")).toBe(true);
29
+ });
30
+
31
+ test("narrowed vault:<name>:migrate satisfies only that vault", () => {
32
+ expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "gitcoin")).toBe(true);
33
+ expect(hasMigrateScopeForVault(["vault:gitcoin:migrate"], "default")).toBe(false);
34
+ });
35
+
36
+ test("admin does NOT imply migrate — orthogonal axis", () => {
37
+ expect(hasMigrateScopeForVault([SCOPE_ADMIN], "default")).toBe(false);
38
+ expect(hasMigrateScopeForVault(["vault:default:admin"], "default")).toBe(false);
39
+ });
40
+
41
+ test("no migrate scope → false", () => {
42
+ expect(hasMigrateScopeForVault([SCOPE_WRITE, SCOPE_READ], "default")).toBe(false);
43
+ expect(hasMigrateScopeForVault([], "default")).toBe(false);
44
+ });
45
+ });
46
+
23
47
  describe("parseScopes", () => {
24
48
  test("returns [] for null or empty input", () => {
25
49
  expect(parseScopes(null)).toEqual([]);
package/src/scopes.ts CHANGED
@@ -28,6 +28,19 @@ export const SCOPE_READ = "vault:read" as const;
28
28
  export const SCOPE_WRITE = "vault:write" as const;
29
29
  export const SCOPE_ADMIN = "vault:admin" as const;
30
30
 
31
+ /**
32
+ * Migration-bypass scope (vault#299). A SEPARATE capability axis — NOT part
33
+ * of the read ⊇ write ⊇ admin inheritance chain. A token holding
34
+ * `vault:migrate` (broad) or `vault:<name>:migrate` (narrowed) may skip
35
+ * `strict:true` schema enforcement so existing non-conforming notes can be
36
+ * migrated/backfilled. It is deliberately orthogonal: an `admin` token does
37
+ * NOT auto-bypass strict validation — bypass must be an explicit, audited
38
+ * grant. A bypass write still needs `write` to actually mutate (migrate is an
39
+ * ADD-ON flag, not a write grant on its own). Every bypassed write is logged
40
+ * (see the write path) since #300 (the audit-log table) is deferred.
41
+ */
42
+ export const SCOPE_MIGRATE = "vault:migrate" as const;
43
+
31
44
  /** All first-class vault scopes in inheritance order (lowest → highest). */
32
45
  export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
33
46
  export type VaultScope = (typeof VAULT_SCOPES)[number];
@@ -137,6 +150,56 @@ export function hasScopeForVault(
137
150
  return false;
138
151
  }
139
152
 
153
+ /**
154
+ * Migration-bypass check (vault#299): does `granted` hold the `migrate`
155
+ * capability for `vaultName`? Accepts broad `vault:migrate` (any vault) or
156
+ * narrowed `vault:<name>:migrate` (this vault only). Orthogonal to the
157
+ * read/write/admin verbs — a plain admin token returns `false` here, by
158
+ * design. The migrate scope does NOT live in `decomposeVaultScope` (it isn't a
159
+ * read/write/admin verb), so it's matched by exact shape here.
160
+ */
161
+ export function hasMigrateScopeForVault(granted: string[], vaultName: string): boolean {
162
+ for (const s of granted) {
163
+ if (s === SCOPE_MIGRATE) return true; // broad — any vault
164
+ if (s === `vault:${vaultName}:migrate`) return true; // narrowed — this vault
165
+ }
166
+ return false;
167
+ }
168
+
169
+ /**
170
+ * Structured log line for a migration-bypassed write (vault#299 settled lead
171
+ * #2). Records WHO bypassed (actor/via from MW1 attribution), WHAT note, and
172
+ * WHICH strict violations were waived — enough to reconstruct the bypass for
173
+ * later audit without the #300 audit-log table (deferred). One JSON line so
174
+ * it's grep/jq-able in the daemon log. Lives here (next to the migrate-scope
175
+ * check) so both write transports — REST (routes.ts) and MCP (mcp-tools.ts) —
176
+ * emit the identical line without importing each other.
177
+ */
178
+ export function logStrictBypass(info: {
179
+ actor: string | null;
180
+ via: string | null;
181
+ path?: string | null;
182
+ tags?: string[];
183
+ violations: { field: string; reason: string; schema: string }[];
184
+ }): void {
185
+ console.warn(
186
+ "[schema-bypass] " +
187
+ JSON.stringify({
188
+ event: "strict_schema_bypass",
189
+ actor: info.actor,
190
+ via: info.via,
191
+ path: info.path ?? null,
192
+ tags: info.tags ?? [],
193
+ violations: info.violations.map((v) => ({
194
+ field: v.field,
195
+ reason: v.reason,
196
+ schema: v.schema,
197
+ })),
198
+ at: new Date().toISOString(),
199
+ }),
200
+ );
201
+ }
202
+
140
203
  /**
141
204
  * Pick the required scope for a given API request.
142
205
  * - GET/HEAD/OPTIONS → read
@@ -61,10 +61,8 @@ function captureLogs(): {
61
61
  }
62
62
 
63
63
  describe("self-register", () => {
64
- test("buildVaultServicePaths — no vaults yet → manifest fallback", () => {
65
- expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([
66
- "/vault/default",
67
- ]);
64
+ test("buildVaultServicePaths — no vaults yet → empty paths (no phantom /vault/default, #478)", () => {
65
+ expect(buildVaultServicePaths(undefined, [], ["/vault/default"])).toEqual([]);
68
66
  });
69
67
 
70
68
  test("buildVaultServicePaths — default vault sorts first", () => {
@@ -108,7 +106,9 @@ describe("self-register", () => {
108
106
  const row = parsed.services[0] as Record<string, unknown>;
109
107
  expect(row.name).toBe("parachute-vault");
110
108
  expect(row.port).toBe(1940);
111
- expect(row.paths).toEqual(["/vault/default"]);
109
+ // Zero vaults → paths: [] (no phantom /vault/default, #478).
110
+ // Health still well-formed: paths[0] ?? "/vault/default" fallback.
111
+ expect(row.paths).toEqual([]);
112
112
  expect(row.health).toBe("/vault/default/health");
113
113
  expect(row.version).toBe("0.4.8-rc.3");
114
114
  expect(row.installDir).toBe("/fake/install/dir");
@@ -56,8 +56,13 @@ import { listVaults, readGlobalConfig, DEFAULT_PORT } from "./config.ts";
56
56
  *
57
57
  * Mirrors `buildVaultServicePaths` in `cli.ts` so the self-register pass
58
58
  * produces the same multi-vault path advertisement as `parachute-vault
59
- * init` / `vault create`. With no vaults yet, falls back to the manifest's
60
- * canonical `paths[0]` so early-boot registration is still well-formed.
59
+ * init` / `vault create`.
60
+ *
61
+ * At zero vaults, returns an empty array (`paths: []`). The row remains
62
+ * present in services.json (name, port, health, version, installDir intact
63
+ * — so the module is still detected as installed), but it advertises no
64
+ * `/vault/<name>` path and the hub must not resolve it to a phantom
65
+ * `/vault/default`. Closes #478.
61
66
  *
62
67
  * Exported for tests; not part of the public module surface.
63
68
  */
@@ -66,7 +71,7 @@ export function buildVaultServicePaths(
66
71
  vaults: readonly string[],
67
72
  fallbackFromManifest: readonly string[],
68
73
  ): string[] {
69
- if (vaults.length === 0) return [...fallbackFromManifest];
74
+ if (vaults.length === 0) return [];
70
75
  if (defaultVault && vaults.includes(defaultVault)) {
71
76
  return [
72
77
  `/vault/${defaultVault}`,
package/src/server.ts CHANGED
@@ -153,17 +153,23 @@ if (process.env.VAULT_AUTH_TOKEN?.trim()) {
153
153
  // logic lives in auth.ts (import-safe + unit-tested); see warnLegacyGlobalApiKeys.
154
154
  warnLegacyGlobalApiKeys(readGlobalConfig().api_keys);
155
155
 
156
- // Auto-init: create a default vault if none exist (first run in Docker).
157
- // The vault name comes from PARACHUTE_VAULT_NAME when set + valid; otherwise
158
- // falls back to "default". Hub's first-boot wizard (hub#267) passes through
159
- // an operator-chosen name via this env var.
156
+ // First-boot vault creation ONLY when PARACHUTE_VAULT_NAME is explicitly
157
+ // set in the environment. (#478 Part 2)
158
+ //
159
+ // The hub setup wizard and `parachute init --vault-name <name>` both spawn
160
+ // the vault server with PARACHUTE_VAULT_NAME=<operator-chosen-name>; that
161
+ // env var is the load-bearing signal that an external orchestrator has
162
+ // decided what the first vault should be called. With the env var unset the
163
+ // server boots with zero vaults and stays that way — no silent "default"
164
+ // — so a fresh install without a wizard can't accidentally squash an
165
+ // explicit later `parachute-vault create <name>` call.
160
166
  //
161
167
  // Gated on the `auto_create: false` marker (2026-06-09 hub-module-boundary
162
168
  // migration): `parachute-vault remove` writes it when the operator deletes
163
169
  // their LAST vault, so an explicit empty-the-server action isn't silently
164
170
  // undone by a resurrection with fresh credentials on the next boot. Fresh
165
- // installs have no config.yaml — no marker — so Docker first-run still
166
- // auto-creates.
171
+ // installs have no config.yaml — no marker — so the env-var-driven path
172
+ // still works on a clean box.
167
173
  if (listVaults().length === 0) {
168
174
  const globalConfig = readGlobalConfig();
169
175
  if (!bootAutoCreateAllowed(globalConfig)) {
@@ -173,43 +179,57 @@ if (listVaults().length === 0) {
173
179
  } else if (!globalConfig.default_vault) {
174
180
  const firstBoot = resolveFirstBootVaultName(process.env.PARACHUTE_VAULT_NAME);
175
181
  if (firstBoot.source === "env") {
176
- console.log(`[vault first-boot] using PARACHUTE_VAULT_NAME=${firstBoot.name}`);
182
+ // PARACHUTE_VAULT_NAME explicitly set + valid — create the named vault.
183
+ console.log(`[vault first-boot] PARACHUTE_VAULT_NAME=${firstBoot.name} — creating vault`);
184
+ const vaultName = firstBoot.name;
185
+ const { fullKey, keyId } = generateApiKey();
186
+ writeVaultConfig({
187
+ name: vaultName,
188
+ api_keys: [{
189
+ id: keyId,
190
+ label: "default",
191
+ scope: "write",
192
+ key_hash: hashKey(fullKey),
193
+ created_at: new Date().toISOString(),
194
+ }],
195
+ created_at: new Date().toISOString(),
196
+ });
197
+ globalConfig.default_vault = vaultName;
198
+ if (!globalConfig.api_keys?.length) {
199
+ globalConfig.api_keys = [{
200
+ id: keyId,
201
+ label: "default",
202
+ scope: "write",
203
+ key_hash: hashKey(fullKey),
204
+ created_at: new Date().toISOString(),
205
+ }];
206
+ }
207
+ writeGlobalConfig(globalConfig);
208
+ console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
209
+ // Seed the in-vault onboarding guide so a connected AI can self-orient and
210
+ // help set the vault up — same as the `create`/`init` CLI path. Idempotent
211
+ // + best-effort (never fails first boot). Mirrors createVault() in cli.ts.
212
+ await seedOnboardingNotesBestEffort(getVaultStore(vaultName));
177
213
  } else if (firstBoot.source === "env-invalid") {
214
+ // PARACHUTE_VAULT_NAME was set but failed validation — do NOT silently
215
+ // fall back to "default". Log the problem and stay empty so the operator
216
+ // sees the misconfiguration instead of a phantom vault.
178
217
  console.warn(
179
- `[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}); falling back to "default"`,
218
+ `[vault first-boot] PARACHUTE_VAULT_NAME=${JSON.stringify(firstBoot.rawValue)} is invalid (${firstBoot.reason}) server starting with zero vaults. Fix the env var, or create one via the admin wizard, parachute init --vault-name <name>, or parachute-vault create <name>.`,
180
219
  );
181
220
  } else {
182
- console.log("[vault first-boot] using default name (no PARACHUTE_VAULT_NAME set)");
183
- }
184
- const vaultName = firstBoot.name;
185
- const { fullKey, keyId } = generateApiKey();
186
- writeVaultConfig({
187
- name: vaultName,
188
- api_keys: [{
189
- id: keyId,
190
- label: "default",
191
- scope: "write",
192
- key_hash: hashKey(fullKey),
193
- created_at: new Date().toISOString(),
194
- }],
195
- created_at: new Date().toISOString(),
196
- });
197
- globalConfig.default_vault = vaultName;
198
- if (!globalConfig.api_keys?.length) {
199
- globalConfig.api_keys = [{
200
- id: keyId,
201
- label: "default",
202
- scope: "write",
203
- key_hash: hashKey(fullKey),
204
- created_at: new Date().toISOString(),
205
- }];
221
+ // PARACHUTE_VAULT_NAME unset stay empty. No silent "default".
222
+ console.log(
223
+ '[vault first-boot] no vaults and PARACHUTE_VAULT_NAME unset — staying empty. Create one via the admin wizard, parachute init --vault-name <name>, or parachute-vault create <name>.',
224
+ );
206
225
  }
207
- writeGlobalConfig(globalConfig);
208
- console.log(`Auto-created vault "${vaultName}" (API key: ${fullKey})`);
209
- // Seed the in-vault onboarding guide so a connected AI can self-orient and
210
- // help set the vault up same as the `create`/`init` CLI path. Idempotent
211
- // + best-effort (never fails first boot). Mirrors createVault() in cli.ts.
212
- await seedOnboardingNotesBestEffort(getVaultStore(vaultName));
226
+ } else {
227
+ // Stale config: default_vault is set but no vault DB exists on disk, so
228
+ // first-boot create is skipped. Without a log line the server would boot
229
+ // empty and silent here, which is confusing. (#478 Part 2)
230
+ console.warn(
231
+ '[vault first-boot] config.yaml has default_vault set but no vaults exist on disk — skipping first-boot create. Create one with: parachute-vault create <name>',
232
+ );
213
233
  }
214
234
  }
215
235