@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.
- package/README.md +46 -11
- package/core/src/attribution.test.ts +273 -0
- package/core/src/core.test.ts +126 -0
- package/core/src/cursor.ts +8 -0
- package/core/src/enforced-writes.test.ts +533 -0
- package/core/src/mcp.ts +235 -9
- package/core/src/migrate-tag-field.test.ts +471 -0
- package/core/src/migrate-tag-field.ts +638 -0
- package/core/src/notes.ts +280 -7
- package/core/src/query-operators.ts +117 -0
- package/core/src/schema-defaults.ts +162 -9
- package/core/src/schema.ts +61 -2
- package/core/src/store.ts +51 -19
- package/core/src/tag-schemas.ts +12 -0
- package/core/src/triggers-store.ts +6 -0
- package/core/src/types.ts +35 -14
- package/core/src/vault-projection.ts +19 -0
- package/package.json +1 -1
- package/src/admin-spa.test.ts +18 -5
- package/src/admin-spa.ts +24 -3
- package/src/attribution-threading.test.ts +350 -0
- package/src/auth.ts +82 -4
- package/src/cli.ts +345 -9
- package/src/config.ts +11 -0
- package/src/first-boot-create.test.ts +155 -0
- package/src/import-daemon-busy.test.ts +8 -17
- package/src/mcp-http.ts +27 -0
- package/src/mcp-tools.ts +31 -4
- package/src/mirror-credentials.test.ts +47 -0
- package/src/mirror-credentials.ts +33 -0
- package/src/mirror-history.test.ts +426 -0
- package/src/mirror-manager.ts +202 -22
- package/src/mirror-routes.test.ts +78 -0
- package/src/mirror-routes.ts +195 -1
- package/src/module-config.ts +46 -80
- package/src/routes.ts +209 -41
- package/src/routing.test.ts +115 -25
- package/src/routing.ts +64 -2
- package/src/scale.bench.test.ts +82 -0
- package/src/scopes.test.ts +24 -0
- package/src/scopes.ts +63 -0
- package/src/self-register.test.ts +5 -5
- package/src/self-register.ts +8 -3
- package/src/server.ts +58 -38
- package/src/subscribe.test.ts +23 -2
- package/src/test-support/spawn.ts +85 -0
- package/src/triggers-api.ts +24 -0
- package/src/triggers.test.ts +33 -0
- package/src/triggers.ts +17 -0
- package/src/vault-remove.test.ts +4 -5
- package/src/vault.test.ts +188 -17
package/src/routing.test.ts
CHANGED
|
@@ -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.
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
|
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
|
|
1289
|
-
|
|
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:
|
|
1293
|
-
|
|
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("
|
|
1322
|
-
|
|
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
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/scopes.test.ts
CHANGED
|
@@ -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 →
|
|
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
|
-
|
|
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");
|
package/src/self-register.ts
CHANGED
|
@@ -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`.
|
|
60
|
-
*
|
|
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 [
|
|
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
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
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
|
|
166
|
-
//
|
|
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
|
-
|
|
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})
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|