@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
@@ -8,9 +8,14 @@
8
8
  */
9
9
 
10
10
  import { describe, test, expect, beforeAll, afterAll } from "bun:test";
11
- import { rmSync, existsSync, mkdirSync } from "fs";
11
+ import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { tmpdir } from "os";
14
+ import { Database } from "bun:sqlite";
15
+ import { SqliteStore } from "../core/src/store.ts";
16
+ import { initSchema } from "../core/src/schema.ts";
17
+ import type { Store } from "../core/src/types.ts";
18
+ import type { TagScopeCtx } from "./routes.ts";
14
19
 
15
20
  const testDir = join(
16
21
  tmpdir(),
@@ -20,6 +25,16 @@ process.env.PARACHUTE_HOME = testDir;
20
25
  process.env.ASSETS_DIR = join(testDir, "assets");
21
26
 
22
27
  const { handleStorage } = await import("./routes.ts");
28
+ const { expandTokenTagScope } = await import("./tag-scope.ts");
29
+
30
+ // The upload-allowlist tests never touch the store (POST /upload writes to
31
+ // disk only); a fresh in-memory store satisfies the now-required param.
32
+ function freshStore(): SqliteStore {
33
+ const db = new Database(":memory:");
34
+ initSchema(db);
35
+ return new SqliteStore(db);
36
+ }
37
+ const uploadStore = freshStore();
23
38
 
24
39
  function uploadRequest(filename: string, mimeType: string): Request {
25
40
  const form = new FormData();
@@ -33,6 +48,11 @@ function uploadRequest(filename: string, mimeType: string): Request {
33
48
  });
34
49
  }
35
50
 
51
+ /** Build the per-request TagScopeCtx the dispatcher hands handlers. */
52
+ async function tagScopeCtx(store: Store, scopedTags: string[] | null): Promise<TagScopeCtx> {
53
+ return { allowed: await expandTokenTagScope(store, scopedTags), raw: scopedTags };
54
+ }
55
+
36
56
  beforeAll(() => {
37
57
  mkdirSync(testDir, { recursive: true });
38
58
  mkdirSync(join(testDir, "assets"), { recursive: true });
@@ -44,7 +64,7 @@ afterAll(() => {
44
64
 
45
65
  describe("storage upload allowlist", () => {
46
66
  test("accepts .pdf — knowledge-vault content (#127)", async () => {
47
- const res = await handleStorage(uploadRequest("paper.pdf", "application/pdf"), "/upload", "default");
67
+ const res = await handleStorage(uploadRequest("paper.pdf", "application/pdf"), "/upload", "default", uploadStore);
48
68
  expect(res.status).toBe(201);
49
69
  const body = (await res.json()) as { mimeType: string; path: string };
50
70
  expect(body.mimeType).toBe("application/pdf");
@@ -52,7 +72,7 @@ describe("storage upload allowlist", () => {
52
72
  });
53
73
 
54
74
  test("accepts .mp4 — mobile capture default (#127)", async () => {
55
- const res = await handleStorage(uploadRequest("clip.mp4", "video/mp4"), "/upload", "default");
75
+ const res = await handleStorage(uploadRequest("clip.mp4", "video/mp4"), "/upload", "default", uploadStore);
56
76
  expect(res.status).toBe(201);
57
77
  const body = (await res.json()) as { mimeType: string };
58
78
  expect(body.mimeType).toBe("video/mp4");
@@ -66,27 +86,132 @@ describe("storage upload allowlist", () => {
66
86
  ["photo.jpg", "image/jpeg"],
67
87
  ["clip.webm", "audio/webm"],
68
88
  ] as const) {
69
- const res = await handleStorage(uploadRequest(name, mime), "/upload", "default");
89
+ const res = await handleStorage(uploadRequest(name, mime), "/upload", "default", uploadStore);
70
90
  expect(res.status).toBe(201);
71
91
  }
72
92
  });
73
93
 
74
94
  test("rejects .svg — XSS vector via inline <script> (#127)", async () => {
75
- const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default");
95
+ const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default", uploadStore);
76
96
  expect(res.status).toBe(400);
77
97
  const body = (await res.json()) as { error: string };
78
98
  expect(body.error).toContain(".svg");
79
99
  });
80
100
 
81
101
  test("rejects .html — same XSS surface as SVG (#127)", async () => {
82
- const res = await handleStorage(uploadRequest("evil.html", "text/html"), "/upload", "default");
102
+ const res = await handleStorage(uploadRequest("evil.html", "text/html"), "/upload", "default", uploadStore);
83
103
  expect(res.status).toBe(400);
84
104
  const body = (await res.json()) as { error: string };
85
105
  expect(body.error).toContain(".html");
86
106
  });
87
107
 
88
108
  test("rejects unknown extensions (default-deny)", async () => {
89
- const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default");
109
+ const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default", uploadStore);
90
110
  expect(res.status).toBe(400);
91
111
  });
92
112
  });
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // GET byte-serve tag-scope enforcement (C0 adversarial-audit finding).
116
+ //
117
+ // The raw `/api/storage/<date>/<file>` endpoint historically served bytes by
118
+ // filesystem path with only a path-traversal guard — bypassing the tag-scope
119
+ // enforcement that gates every note-keyed attachment surface. A tag-scoped
120
+ // token could therefore fetch an out-of-scope note's attachment bytes
121
+ // directly if it learned the (UUID-secret) storage path. These tests pin the
122
+ // fix: in-scope → 200, out-of-scope → 404 (no existence oracle), unscoped →
123
+ // 200 (regression), path-traversal guard intact (regression).
124
+ // ---------------------------------------------------------------------------
125
+
126
+ describe("storage GET tag-scope enforcement", () => {
127
+ // Each test builds its own vault assets dir + store so rows and on-disk
128
+ // files line up. `vault` names the assets subdir; ASSETS_DIR is global to
129
+ // the test process, so we point it at this vault's dir per test.
130
+ const VAULT = "scope-vault";
131
+
132
+ async function setup(): Promise<{
133
+ store: SqliteStore;
134
+ assets: string;
135
+ inScopePath: string;
136
+ outScopePath: string;
137
+ }> {
138
+ const store = freshStore();
139
+ const assets = join(testDir, "assets", VAULT, "data");
140
+ mkdirSync(join(assets, "2026-05-28"), { recursive: true });
141
+ process.env.ASSETS_DIR = assets;
142
+
143
+ // An in-scope (#work) note + attachment, and an out-of-scope (#health)
144
+ // note + attachment. Both files exist on disk.
145
+ const workNote = await store.createNote("work note", { tags: ["work"] });
146
+ const healthNote = await store.createNote("health note", { tags: ["health"] });
147
+
148
+ const inScopePath = "2026-05-28/work-asset.pdf";
149
+ const outScopePath = "2026-05-28/health-asset.pdf";
150
+ writeFileSync(join(assets, inScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46])); // %PDF
151
+ writeFileSync(join(assets, outScopePath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
152
+
153
+ await store.addAttachment(workNote.id, inScopePath, "application/pdf");
154
+ await store.addAttachment(healthNote.id, outScopePath, "application/pdf");
155
+
156
+ return { store, assets, inScopePath, outScopePath };
157
+ }
158
+
159
+ function getReq(reqPath: string): Request {
160
+ return new Request(`http://localhost:1940/storage/${reqPath}`, { method: "GET" });
161
+ }
162
+
163
+ test("tag-scoped token (work): GET in-scope attachment → 200 (bytes served)", async () => {
164
+ const { store, inScopePath } = await setup();
165
+ const ctx = await tagScopeCtx(store, ["work"]);
166
+ const res = await handleStorage(getReq(inScopePath), `/${inScopePath}`, VAULT, store, ctx);
167
+ expect(res.status).toBe(200);
168
+ expect(res.headers.get("Content-Type")).toBe("application/pdf");
169
+ expect((await res.arrayBuffer()).byteLength).toBe(4);
170
+ });
171
+
172
+ test("tag-scoped token (work): GET OUT-of-scope attachment → 404 (no existence oracle)", async () => {
173
+ const { store, outScopePath } = await setup();
174
+ const ctx = await tagScopeCtx(store, ["work"]);
175
+ const res = await handleStorage(getReq(outScopePath), `/${outScopePath}`, VAULT, store, ctx);
176
+ expect(res.status).toBe(404);
177
+ });
178
+
179
+ test("tag-scoped token: GET path with NO owning attachment row → 404", async () => {
180
+ const { store, assets } = await setup();
181
+ // A real on-disk file that no attachment row references — must 404 for a
182
+ // scoped token (would-be existence oracle otherwise).
183
+ const orphanPath = "2026-05-28/orphan-on-disk.pdf";
184
+ writeFileSync(join(assets, orphanPath), Buffer.from([0x25, 0x50, 0x44, 0x46]));
185
+ const ctx = await tagScopeCtx(store, ["work"]);
186
+ const res = await handleStorage(getReq(orphanPath), `/${orphanPath}`, VAULT, store, ctx);
187
+ expect(res.status).toBe(404);
188
+ });
189
+
190
+ test("unscoped token: GET any attachment → 200 (regression — no behavior change)", async () => {
191
+ const { store, outScopePath } = await setup();
192
+ const ctx = await tagScopeCtx(store, null); // unscoped
193
+ const res = await handleStorage(getReq(outScopePath), `/${outScopePath}`, VAULT, store, ctx);
194
+ expect(res.status).toBe(200);
195
+ });
196
+
197
+ test("default ctx (no tagScope arg): unscoped behavior — 200 (regression)", async () => {
198
+ const { store, outScopePath } = await setup();
199
+ const res = await handleStorage(getReq(outScopePath), `/${outScopePath}`, VAULT, store);
200
+ expect(res.status).toBe(200);
201
+ });
202
+
203
+ test("path-traversal guard still blocks ../ escapes (regression)", async () => {
204
+ const { store } = await setup();
205
+ const ctx = await tagScopeCtx(store, ["work"]);
206
+ // `/a/../../../etc/passwd` resolves outside assetsDir → 403 Invalid path.
207
+ const evil = "/a/../../../../../../etc/passwd";
208
+ const res = await handleStorage(
209
+ new Request(`http://localhost:1940/storage${evil}`, { method: "GET" }),
210
+ evil,
211
+ VAULT,
212
+ store,
213
+ ctx,
214
+ );
215
+ expect(res.status).toBe(403);
216
+ });
217
+ });
@@ -1,17 +1,24 @@
1
1
  /**
2
- * Tests for the token store scoped tokens with permissions.
3
- * Tokens now live inside each vault's SQLite database (schema v7).
2
+ * Tests for the surviving token-store surface (vault#282 Stage 2).
3
+ *
4
+ * Vault no longer mints (`generateToken`/`createToken`) or validates
5
+ * (`resolveToken`) opaque pvt_* tokens — it's a pure hub resource-server. What
6
+ * remains in token-store.ts is the vestigial-row cleanup surface
7
+ * (`listTokens` / `revokeToken` / `findTokensReferencingTag`) and the legacy
8
+ * YAML-import landing zone (`migrateVaultKeys`, raw INSERT). These tests seed
9
+ * rows the way the surviving code does (migrateVaultKeys + raw INSERT) rather
10
+ * than via the removed mint path.
4
11
  */
5
12
 
6
13
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
7
14
  import { Database } from "bun:sqlite";
8
15
  import { initSchema } from "../core/src/schema.ts";
16
+ import { hashKey } from "./config.ts";
9
17
  import {
10
- generateToken,
11
- createToken,
12
- resolveToken,
13
18
  listTokens,
14
19
  revokeToken,
20
+ findTokensReferencingTag,
21
+ migrateVaultKeys,
15
22
  } from "./token-store.ts";
16
23
 
17
24
  let db: Database;
@@ -25,197 +32,109 @@ afterEach(() => {
25
32
  db.close();
26
33
  });
27
34
 
28
- describe("token CRUD", () => {
29
- test("create and resolve a full-access token", () => {
30
- const { fullToken } = generateToken();
31
- createToken(db, fullToken, { label: "test-token", permission: "full" });
32
-
33
- const resolved = resolveToken(db, fullToken);
34
- expect(resolved).not.toBeNull();
35
- expect(resolved!.permission).toBe("full");
36
- });
37
-
38
- test("token with read permission", () => {
39
- const { fullToken } = generateToken();
40
- createToken(db, fullToken, {
41
- label: "reader",
42
- permission: "read",
43
- });
44
-
45
- const resolved = resolveToken(db, fullToken);
46
- expect(resolved!.permission).toBe("read");
47
- });
48
-
49
- test("default permission is full", () => {
50
- const { fullToken } = generateToken();
51
- createToken(db, fullToken, { label: "default-perm" });
52
-
53
- const resolved = resolveToken(db, fullToken);
54
- expect(resolved!.permission).toBe("full");
55
- });
56
-
57
- test("legacy admin permission normalizes to full", () => {
58
- const { fullToken } = generateToken();
59
- // Simulate a legacy token by writing "admin" directly to DB
60
- const hash = require("./config.ts").hashKey(fullToken);
61
- db.prepare("INSERT INTO tokens (token_hash, label, permission, created_at) VALUES (?, ?, ?, ?)")
62
- .run(hash, "legacy-admin", "admin", new Date().toISOString());
63
-
64
- const resolved = resolveToken(db, fullToken);
65
- expect(resolved!.permission).toBe("full");
66
- });
67
-
68
- test("legacy write permission normalizes to full", () => {
69
- const { fullToken } = generateToken();
70
- const hash = require("./config.ts").hashKey(fullToken);
71
- db.prepare("INSERT INTO tokens (token_hash, label, permission, created_at) VALUES (?, ?, ?, ?)")
72
- .run(hash, "legacy-write", "write", new Date().toISOString());
73
-
74
- const resolved = resolveToken(db, fullToken);
75
- expect(resolved!.permission).toBe("full");
76
- });
77
-
78
- test("expired token is rejected", () => {
79
- const { fullToken } = generateToken();
80
- createToken(db, fullToken, {
81
- label: "expired",
82
- permission: "full",
83
- expires_at: "2020-01-01T00:00:00.000Z", // in the past
84
- });
85
-
86
- const resolved = resolveToken(db, fullToken);
87
- expect(resolved).toBeNull();
88
- });
89
-
90
- test("non-expired token is accepted", () => {
91
- const { fullToken } = generateToken();
92
- const future = new Date(Date.now() + 86400000).toISOString(); // +1 day
93
- createToken(db, fullToken, {
94
- label: "valid",
95
- permission: "read",
96
- expires_at: future,
97
- });
98
-
99
- const resolved = resolveToken(db, fullToken);
100
- expect(resolved).not.toBeNull();
101
- expect(resolved!.permission).toBe("read");
102
- });
103
-
104
- test("invalid token returns null", () => {
105
- const resolved = resolveToken(db, "pvt_does_not_exist");
106
- expect(resolved).toBeNull();
107
- });
108
-
109
- test("list tokens shows all tokens", () => {
110
- const { fullToken: t1 } = generateToken();
111
- const { fullToken: t2 } = generateToken();
112
- createToken(db, t1, { label: "first", permission: "full" });
113
- createToken(db, t2, { label: "second", permission: "read" });
35
+ /** Seed a row the way migrateVaultKeys does — raw INSERT, no mint path. */
36
+ function seedRow(
37
+ label: string,
38
+ opts: { permission?: string; vault_name?: string | null; scoped_tags?: string[] | null } = {},
39
+ ): string {
40
+ const hash = hashKey(`legacy-${label}-${Math.random()}`);
41
+ db.prepare(
42
+ `INSERT INTO tokens (token_hash, label, permission, scoped_tags, created_at, vault_name)
43
+ VALUES (?, ?, ?, ?, ?, ?)`,
44
+ ).run(
45
+ hash,
46
+ label,
47
+ opts.permission ?? "full",
48
+ opts.scoped_tags ? JSON.stringify(opts.scoped_tags) : null,
49
+ new Date().toISOString(),
50
+ opts.vault_name ?? null,
51
+ );
52
+ return `t_${hash.slice(7, 19)}`;
53
+ }
54
+
55
+ describe("listTokens", () => {
56
+ test("lists all rows with display IDs and normalized permission", () => {
57
+ seedRow("first", { permission: "full" });
58
+ seedRow("second", { permission: "read" });
114
59
 
115
60
  const tokens = listTokens(db);
116
61
  expect(tokens.length).toBe(2);
117
62
  expect(tokens.some((t) => t.label === "first")).toBe(true);
118
63
  expect(tokens.some((t) => t.label === "second")).toBe(true);
119
- // Each token should have a display ID
120
64
  expect(tokens.every((t) => t.id.startsWith("t_"))).toBe(true);
121
65
  });
122
66
 
123
- test("revoke token by display ID", () => {
124
- const { fullToken } = generateToken();
125
- createToken(db, fullToken, { label: "to-revoke" });
67
+ test("legacy admin/write permission normalizes to full", () => {
68
+ seedRow("legacy-admin", { permission: "admin" });
69
+ seedRow("legacy-write", { permission: "write" });
126
70
 
127
71
  const tokens = listTokens(db);
128
- expect(tokens.length).toBe(1);
129
-
130
- const revoked = revokeToken(db, tokens[0].id);
131
- expect(revoked).toBe(true);
132
-
133
- const after = listTokens(db);
134
- expect(after.length).toBe(0);
135
-
136
- // Token should no longer resolve
137
- expect(resolveToken(db, fullToken)).toBeNull();
138
- });
139
-
140
- test("revoke non-existent token returns false", () => {
141
- expect(revokeToken(db, "t_doesnotexist")).toBe(false);
72
+ expect(tokens.every((t) => t.permission === "full")).toBe(true);
142
73
  });
74
+ });
143
75
 
144
- test("resolve updates last_used_at", () => {
145
- const { fullToken } = generateToken();
146
- createToken(db, fullToken, { label: "usage-tracking" });
76
+ describe("per-vault filter (v16, vestigial)", () => {
77
+ test("vaultName filter returns matching + legacy NULL rows", () => {
78
+ seedRow("boulder", { vault_name: "boulder" });
79
+ seedRow("default-vault", { vault_name: "default" });
80
+ seedRow("server-wide", { vault_name: null });
147
81
 
148
- // Before first use
149
- const before = listTokens(db);
150
- expect(before[0].last_used_at).toBeNull();
82
+ const boulderTokens = listTokens(db, { vaultName: "boulder" });
83
+ expect(boulderTokens.map((t) => t.label).sort()).toEqual(["boulder", "server-wide"]);
151
84
 
152
- // Resolve (which should update last_used_at)
153
- resolveToken(db, fullToken);
85
+ const defaultTokens = listTokens(db, { vaultName: "default" });
86
+ expect(defaultTokens.map((t) => t.label).sort()).toEqual(["default-vault", "server-wide"]);
154
87
 
155
- const after = listTokens(db);
156
- expect(after[0].last_used_at).not.toBeNull();
88
+ // No filter → everything.
89
+ expect(listTokens(db).length).toBe(3);
157
90
  });
158
91
  });
159
92
 
160
- describe("per-vault binding (v16)", () => {
161
- test("createToken without vault_name leaves the column NULL (legacy / server-wide)", () => {
162
- const { fullToken } = generateToken();
163
- createToken(db, fullToken, { label: "legacy" });
164
-
165
- const resolved = resolveToken(db, fullToken);
166
- expect(resolved!.vault_name).toBeNull();
93
+ describe("revokeToken", () => {
94
+ test("revokes by display ID", () => {
95
+ const id = seedRow("to-revoke");
96
+ expect(listTokens(db).length).toBe(1);
167
97
 
168
- const [row] = listTokens(db);
169
- expect(row!.vault_name).toBeNull();
98
+ expect(revokeToken(db, id)).toBe(true);
99
+ expect(listTokens(db).length).toBe(0);
170
100
  });
171
101
 
172
- test("createToken with vault_name binds the token to that vault", () => {
173
- const { fullToken } = generateToken();
174
- createToken(db, fullToken, { label: "boulder-bound", vault_name: "boulder" });
175
-
176
- const resolved = resolveToken(db, fullToken);
177
- expect(resolved!.vault_name).toBe("boulder");
178
-
179
- const [row] = listTokens(db);
180
- expect(row!.vault_name).toBe("boulder");
102
+ test("returns false for a non-existent id", () => {
103
+ expect(revokeToken(db, "t_doesnotexist")).toBe(false);
181
104
  });
105
+ });
182
106
 
183
- test("listTokens with vaultName filter returns matching + legacy NULL tokens", () => {
184
- // Per the contract: per-vault listings show tokens bound to THIS vault
185
- // plus any server-wide (NULL) tokens. The latter authenticate cross-vault
186
- // by design, so the operator should be able to see + revoke them in any
187
- // vault's admin UI. Tokens bound to OTHER vaults are excluded.
188
- const { fullToken: tA } = generateToken();
189
- const { fullToken: tB } = generateToken();
190
- const { fullToken: tLegacy } = generateToken();
191
- createToken(db, tA, { label: "boulder", vault_name: "boulder" });
192
- createToken(db, tB, { label: "default-vault", vault_name: "default" });
193
- createToken(db, tLegacy, { label: "server-wide" });
194
-
195
- const boulderTokens = listTokens(db, { vaultName: "boulder" });
196
- expect(boulderTokens.map((t) => t.label).sort()).toEqual(["boulder", "server-wide"]);
197
-
198
- const defaultTokens = listTokens(db, { vaultName: "default" });
199
- expect(defaultTokens.map((t) => t.label).sort()).toEqual(["default-vault", "server-wide"]);
107
+ describe("findTokensReferencingTag", () => {
108
+ test("matches rows whose scoped_tags allowlist names the root tag", () => {
109
+ seedRow("health-scoped", { scoped_tags: ["health"] });
110
+ seedRow("work-scoped", { scoped_tags: ["work"] });
111
+ seedRow("unscoped");
200
112
 
201
- // No filter everything.
202
- const all = listTokens(db);
203
- expect(all.length).toBe(3);
113
+ const matches = findTokensReferencingTag(db, "health");
114
+ expect(matches.map((m) => m.label)).toEqual(["health-scoped"]);
204
115
  });
205
116
  });
206
117
 
207
- describe("token generation", () => {
208
- test("generated tokens have pvt_ prefix", () => {
209
- const { fullToken, tokenHash } = generateToken();
210
- expect(fullToken.startsWith("pvt_")).toBe(true);
211
- expect(tokenHash.startsWith("sha256:")).toBe(true);
212
- });
118
+ describe("migrateVaultKeys — legacy YAML import landing zone", () => {
119
+ test("imports per-vault + global YAML keys via raw INSERT (idempotent)", () => {
120
+ const vaultKeys = [
121
+ { key_hash: hashKey("yaml-vault-key"), label: "vault-key", scope: "read", created_at: "2026-01-01T00:00:00Z" },
122
+ ];
123
+ const globalKeys = [
124
+ { key_hash: hashKey("yaml-global-key"), label: "global-key", created_at: "2026-01-01T00:00:00Z" },
125
+ ];
126
+
127
+ const migrated = migrateVaultKeys(db, vaultKeys, globalKeys);
128
+ expect(migrated).toBe(2);
213
129
 
214
- test("generated tokens are unique", () => {
215
- const t1 = generateToken();
216
- const t2 = generateToken();
217
- expect(t1.fullToken).not.toBe(t2.fullToken);
218
- expect(t1.tokenHash).not.toBe(t2.tokenHash);
130
+ const tokens = listTokens(db);
131
+ expect(tokens.map((t) => t.label).sort()).toEqual(["global-key", "vault-key"]);
132
+ // Per-vault read key keeps read permission; global key becomes full.
133
+ expect(tokens.find((t) => t.label === "vault-key")?.permission).toBe("read");
134
+ expect(tokens.find((t) => t.label === "global-key")?.permission).toBe("full");
135
+
136
+ // Re-running skips already-imported hashes (idempotent).
137
+ expect(migrateVaultKeys(db, vaultKeys, globalKeys)).toBe(0);
138
+ expect(listTokens(db).length).toBe(2);
219
139
  });
220
140
  });
221
-