@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -123,6 +123,26 @@ describe("services-manifest", () => {
123
123
  }
124
124
  });
125
125
 
126
+ test("preserves hub-stamped fields on the row (e.g. installDir from parachute-hub#84)", () => {
127
+ const { path, cleanup } = tempPath();
128
+ try {
129
+ // Pre-existing row carries `installDir` — a field the hub stamps onto
130
+ // the entry post-install. Vault's self-registration pass must not drop
131
+ // it, even though `installDir` isn't part of the typed ServiceEntry.
132
+ const stamped = { ...vault, installDir: "/Users/test/.parachute/vault" };
133
+ writeFileSync(path, `${JSON.stringify({ services: [stamped] }, null, 2)}\n`);
134
+ const updated = { ...vault, version: "0.4.0" };
135
+ upsertService(updated, path);
136
+ const m = readManifest(path);
137
+ expect(m.services).toHaveLength(1);
138
+ const row = m.services[0] as ServiceEntry & { installDir?: string };
139
+ expect(row.version).toBe("0.4.0"); // vault's field wins
140
+ expect(row.installDir).toBe("/Users/test/.parachute/vault"); // hub's field survives
141
+ } finally {
142
+ cleanup();
143
+ }
144
+ });
145
+
126
146
  test("default path honors PARACHUTE_HOME set at runtime", () => {
127
147
  const dir = mkdtempSync(join(tmpdir(), "pvault-home-"));
128
148
  const prior = process.env.PARACHUTE_HOME;
@@ -46,7 +46,10 @@ function validateEntry(raw: unknown, where: string): ServiceEntry {
46
46
  if (typeof version !== "string") {
47
47
  throw new ServicesManifestError(`${where}: "version" must be a string`);
48
48
  }
49
- return { name, port, paths: paths as string[], health, version };
49
+ // Spread the raw object first so hub-stamped fields (e.g. `installDir` from
50
+ // parachute-hub#84) ride through the read. The strict fields below pin the
51
+ // typed shape we promise callers; anything extra survives untouched.
52
+ return { ...e, name, port, paths: paths as string[], health, version } as ServiceEntry;
50
53
  }
51
54
 
52
55
  function validateManifest(raw: unknown, where: string): ServicesManifest {
@@ -90,7 +93,11 @@ export function upsertService(
90
93
  const current = readManifest(path);
91
94
  const idx = current.services.findIndex((s) => s.name === entry.name);
92
95
  if (idx >= 0) {
93
- current.services[idx] = entry;
96
+ // Merge rather than replace so fields the hub stamps onto the row
97
+ // (`installDir` from parachute-hub#84, etc.) survive a self-registration
98
+ // pass. Vault still wins for the fields it owns — port, paths, version,
99
+ // health — because `entry` spreads last.
100
+ current.services[idx] = { ...current.services[idx], ...entry };
94
101
  } else {
95
102
  current.services.push(entry);
96
103
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Integration test for `parachute-vault stop` — exercises the filesystem
3
+ * sentinel handshake end-to-end: spawn server → write sentinel → confirm
4
+ * the server exits cleanly. Closes #100.
5
+ */
6
+
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, mkdirSync, existsSync, writeFileSync } from "fs";
9
+ import { tmpdir } from "os";
10
+ import { join, resolve } from "path";
11
+ import { stopSignalPath } from "./config.ts";
12
+ import { waitForHealthy, checkHealth } from "./health.ts";
13
+
14
+ const SERVER_PATH = resolve(import.meta.dir, "server.ts");
15
+
16
+ // Pick a port unlikely to clash with the developer's running daemon (1940)
17
+ // or the typical hub/scribe range.
18
+ const TEST_PORT = 19_404;
19
+
20
+ let tmpHome: string;
21
+ let originalHome: string | undefined;
22
+
23
+ beforeAll(() => {
24
+ tmpHome = mkdtempSync(join(tmpdir(), "vault-stop-test-"));
25
+ mkdirSync(join(tmpHome, "vault"), { recursive: true });
26
+ originalHome = process.env.PARACHUTE_HOME;
27
+ process.env.PARACHUTE_HOME = tmpHome;
28
+ });
29
+
30
+ afterAll(() => {
31
+ if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
32
+ else process.env.PARACHUTE_HOME = originalHome;
33
+ rmSync(tmpHome, { recursive: true, force: true });
34
+ });
35
+
36
+ describe("graceful shutdown via stop.signal (#100)", () => {
37
+ test("stopSignalPath resolves under PARACHUTE_HOME", () => {
38
+ expect(stopSignalPath()).toBe(join(tmpHome, "vault", "stop.signal"));
39
+ });
40
+
41
+ test("server clears a stale sentinel at startup, then exits when one is written", async () => {
42
+ // Pre-populate a stale sentinel — the server should clear it on boot
43
+ // rather than treating it as a fresh shutdown request.
44
+ writeFileSync(stopSignalPath(), "stale\n");
45
+ expect(existsSync(stopSignalPath())).toBe(true);
46
+
47
+ const proc = Bun.spawn({
48
+ cmd: ["bun", SERVER_PATH],
49
+ env: {
50
+ ...process.env,
51
+ PARACHUTE_HOME: tmpHome,
52
+ PORT: String(TEST_PORT),
53
+ // Avoid the transcription worker spinning up + adding shutdown latency.
54
+ SCRIBE_URL: "",
55
+ },
56
+ stdout: "pipe",
57
+ stderr: "pipe",
58
+ });
59
+
60
+ try {
61
+ const health = await waitForHealthy(TEST_PORT, { totalMs: 10_000 });
62
+ expect(health.status).toBe("healthy");
63
+ expect(existsSync(stopSignalPath())).toBe(false); // stale cleared
64
+
65
+ writeFileSync(stopSignalPath(), `${new Date().toISOString()}\n`);
66
+
67
+ const exitCode = await Promise.race([
68
+ proc.exited,
69
+ new Promise<number>((_, reject) =>
70
+ setTimeout(() => reject(new Error("server did not exit within 5s")), 5_000),
71
+ ),
72
+ ]);
73
+ expect(exitCode).toBe(0);
74
+
75
+ // Server should also have removed the sentinel as it processed it.
76
+ expect(existsSync(stopSignalPath())).toBe(false);
77
+
78
+ // And the port is no longer accepting connections.
79
+ const after = await checkHealth(TEST_PORT);
80
+ expect(["not-listening", "error"]).toContain(after.status);
81
+ } finally {
82
+ if (!proc.killed) proc.kill();
83
+ }
84
+ }, 20_000);
85
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Storage upload allowlist tests (issue #127).
3
+ *
4
+ * The allowlist guards `POST /api/storage/upload` against turning user
5
+ * uploads into XSS vectors when the asset is later served back from
6
+ * `/storage/`. We pin both the accepted set and the deliberate exclusions
7
+ * so a future widening doesn't quietly let SVG/HTML in.
8
+ */
9
+
10
+ import { describe, test, expect, beforeAll, afterAll } from "bun:test";
11
+ import { rmSync, existsSync, mkdirSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+
15
+ const testDir = join(
16
+ tmpdir(),
17
+ `vault-storage-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
18
+ );
19
+ process.env.PARACHUTE_HOME = testDir;
20
+ process.env.ASSETS_DIR = join(testDir, "assets");
21
+
22
+ const { handleStorage } = await import("./routes.ts");
23
+
24
+ function uploadRequest(filename: string, mimeType: string): Request {
25
+ const form = new FormData();
26
+ const file = new File([new Uint8Array([0x00, 0x01, 0x02])], filename, {
27
+ type: mimeType,
28
+ });
29
+ form.set("file", file);
30
+ return new Request("http://localhost:1940/storage/upload", {
31
+ method: "POST",
32
+ body: form,
33
+ });
34
+ }
35
+
36
+ beforeAll(() => {
37
+ mkdirSync(testDir, { recursive: true });
38
+ mkdirSync(join(testDir, "assets"), { recursive: true });
39
+ });
40
+
41
+ afterAll(() => {
42
+ if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
43
+ });
44
+
45
+ describe("storage upload allowlist", () => {
46
+ test("accepts .pdf — knowledge-vault content (#127)", async () => {
47
+ const res = await handleStorage(uploadRequest("paper.pdf", "application/pdf"), "/upload", "default");
48
+ expect(res.status).toBe(201);
49
+ const body = (await res.json()) as { mimeType: string; path: string };
50
+ expect(body.mimeType).toBe("application/pdf");
51
+ expect(body.path).toMatch(/\.pdf$/);
52
+ });
53
+
54
+ test("accepts .mp4 — mobile capture default (#127)", async () => {
55
+ const res = await handleStorage(uploadRequest("clip.mp4", "video/mp4"), "/upload", "default");
56
+ expect(res.status).toBe(201);
57
+ const body = (await res.json()) as { mimeType: string };
58
+ expect(body.mimeType).toBe("video/mp4");
59
+ });
60
+
61
+ test("still accepts the existing audio + image set", async () => {
62
+ for (const [name, mime] of [
63
+ ["clip.wav", "audio/wav"],
64
+ ["clip.mp3", "audio/mpeg"],
65
+ ["photo.png", "image/png"],
66
+ ["photo.jpg", "image/jpeg"],
67
+ ["clip.webm", "audio/webm"],
68
+ ] as const) {
69
+ const res = await handleStorage(uploadRequest(name, mime), "/upload", "default");
70
+ expect(res.status).toBe(201);
71
+ }
72
+ });
73
+
74
+ test("rejects .svg — XSS vector via inline <script> (#127)", async () => {
75
+ const res = await handleStorage(uploadRequest("evil.svg", "image/svg+xml"), "/upload", "default");
76
+ expect(res.status).toBe(400);
77
+ const body = (await res.json()) as { error: string };
78
+ expect(body.error).toContain(".svg");
79
+ });
80
+
81
+ test("rejects .html — same XSS surface as SVG (#127)", async () => {
82
+ const res = await handleStorage(uploadRequest("evil.html", "text/html"), "/upload", "default");
83
+ expect(res.status).toBe(400);
84
+ const body = (await res.json()) as { error: string };
85
+ expect(body.error).toContain(".html");
86
+ });
87
+
88
+ test("rejects unknown extensions (default-deny)", async () => {
89
+ const res = await handleStorage(uploadRequest("payload.exe", "application/octet-stream"), "/upload", "default");
90
+ expect(res.status).toBe(400);
91
+ });
92
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Tag-scope enforcement for tag-scoped tokens (patterns/tag-scoped-tokens.md).
3
+ *
4
+ * A token's `scoped_tags` allowlist narrows its effective access to notes
5
+ * carrying one of the allowlisted tags or a sub-tag thereof. The expansion
6
+ * to descendants happens via the per-vault `_tags/<name>` config-note
7
+ * hierarchy (see core/src/tag-hierarchy.ts).
8
+ *
9
+ * Auth check pseudocode (from patterns/tag-scoped-tokens.md):
10
+ *
11
+ * if (!hasScope(token, ...)) return forbidden();
12
+ * if (token.scoped_tags === null) return ok(); // unscoped
13
+ * const noteTags = note.tags; // hierarchy-aware
14
+ * if (noteTags.some(t => allowlist.includes(rootOf(t)))) return ok();
15
+ * return forbidden();
16
+ *
17
+ * This module returns the *expanded* allowlist (root + descendants), so
18
+ * call-sites just intersect with the note's actual tag set — no per-tag
19
+ * `rootOf` walk is needed at the boundary.
20
+ */
21
+
22
+ import type { Store, Note } from "../core/src/types.ts";
23
+
24
+ /**
25
+ * Build the effective tag-allowlist for a token: union of `{root} ∪
26
+ * descendants(root)` for each root in `scoped_tags`. Returns null when the
27
+ * token is unscoped (no enforcement needed). An empty array also returns
28
+ * null — defensive parity with the token-store parser, which collapses
29
+ * `[]` to null.
30
+ */
31
+ export async function expandTokenTagScope(
32
+ store: Store,
33
+ scoped_tags: string[] | null,
34
+ ): Promise<Set<string> | null> {
35
+ if (!scoped_tags || scoped_tags.length === 0) return null;
36
+ return await store.expandTagsWithDescendants(scoped_tags);
37
+ }
38
+
39
+ /**
40
+ * Return true iff the note's tag set intersects the expanded allowlist OR
41
+ * — fail-open per patterns/tag-scoped-tokens.md §Storage — any of the
42
+ * note's tags has a string-form root inside `rawRoots`. The string-form
43
+ * fallback covers the orphan-sub-tag case: a token allowlisted for
44
+ * `health` should still see `#health/food` even when no `_tags/health/food`
45
+ * schema declares the hierarchy. The raw `rawRoots` array is the canonical
46
+ * allowlist source; `allowed` is just a precomputed expansion for the
47
+ * common (declared-hierarchy) case.
48
+ *
49
+ * Pass `null` for both when the token is unscoped (always permitted).
50
+ */
51
+ export function noteWithinTagScope(
52
+ note: Note,
53
+ allowed: Set<string> | null,
54
+ rawRoots: string[] | null,
55
+ ): boolean {
56
+ if (rawRoots === null) return true;
57
+ if (!note.tags || note.tags.length === 0) return false;
58
+ for (const t of note.tags) {
59
+ if (allowed && allowed.has(t)) return true;
60
+ const root = t.split("/")[0];
61
+ if (root && rawRoots.includes(root)) return true;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /**
67
+ * Filter an array of notes to those within the token's tag scope.
68
+ * No-op when `rawRoots` is null. See `noteWithinTagScope` for the
69
+ * string-form fallback semantics.
70
+ */
71
+ export function filterNotesByTagScope<T extends Note>(
72
+ notes: T[],
73
+ allowed: Set<string> | null,
74
+ rawRoots: string[] | null,
75
+ ): T[] {
76
+ if (rawRoots === null) return notes;
77
+ return notes.filter((n) => noteWithinTagScope(n, allowed, rawRoots));
78
+ }
79
+
80
+ /**
81
+ * For write paths: a note being created/updated must end up carrying at
82
+ * least one tag inside the allowlist. `tags` is the post-write tag set
83
+ * (already including any tag updates). The string-form fallback in
84
+ * `rawRoots` mirrors the read-path semantics — a token allowlisted for
85
+ * `health` can write `#health/food` even when the sub-tag has no
86
+ * declared schema. Returns true iff write is permitted.
87
+ */
88
+ export function tagsWithinScope(
89
+ tags: string[] | undefined,
90
+ allowed: Set<string> | null,
91
+ rawRoots: string[] | null,
92
+ ): boolean {
93
+ if (rawRoots === null) return true;
94
+ if (!tags || tags.length === 0) return false;
95
+ for (const t of tags) {
96
+ if (allowed && allowed.has(t)) return true;
97
+ const root = t.split("/")[0];
98
+ if (root && rawRoots.includes(root)) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ /**
104
+ * Standard 403 response shape for tag-scope rejections. Mirrors the
105
+ * `insufficient_scope` 403 shape used elsewhere in the API so clients
106
+ * get a consistent error envelope.
107
+ */
108
+ export function tagScopeForbidden(scoped_tags: string[]): Response {
109
+ return Response.json(
110
+ {
111
+ error: "Forbidden",
112
+ error_type: "tag_scope_violation",
113
+ message: `This token is restricted to tags: ${scoped_tags.join(", ")}. The note (or write) is outside that scope.`,
114
+ scoped_tags,
115
+ },
116
+ { status: 403 },
117
+ );
118
+ }
@@ -157,6 +157,53 @@ describe("token CRUD", () => {
157
157
  });
158
158
  });
159
159
 
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();
167
+
168
+ const [row] = listTokens(db);
169
+ expect(row!.vault_name).toBeNull();
170
+ });
171
+
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");
181
+ });
182
+
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"]);
200
+
201
+ // No filter → everything.
202
+ const all = listTokens(db);
203
+ expect(all.length).toBe(3);
204
+ });
205
+ });
206
+
160
207
  describe("token generation", () => {
161
208
  test("generated tokens have pvt_ prefix", () => {
162
209
  const { fullToken, tokenHash } = generateToken();
@@ -45,6 +45,21 @@ export interface Token {
45
45
  scope_tag: string | null;
46
46
  /** @deprecated Scope columns exist in DB but are not enforced at runtime. */
47
47
  scope_path_prefix: string | null;
48
+ /**
49
+ * Tag-allowlist (root tag names). When non-null, the token's effective
50
+ * access is the intersection of `scopes` and notes carrying one of these
51
+ * tags or a sub-tag thereof (hierarchy expansion via getTagDescendants).
52
+ * NULL = unscoped, full vault access per `scopes`. See
53
+ * patterns/tag-scoped-tokens.md.
54
+ */
55
+ scoped_tags: string[] | null;
56
+ /**
57
+ * Per-vault binding (v16). Non-null = token can only authenticate against
58
+ * this vault; cross-vault presentation rejects in
59
+ * `authenticateVaultRequest`. NULL = legacy / server-wide token, accepted
60
+ * for any vault. See vault#257.
61
+ */
62
+ vault_name: string | null;
48
63
  expires_at: string | null;
49
64
  created_at: string;
50
65
  last_used_at: string | null;
@@ -61,6 +76,36 @@ export interface ResolvedToken {
61
76
  scopes: string[];
62
77
  /** True iff `scopes` was derived from the legacy `permission` column. */
63
78
  legacyDerived: boolean;
79
+ /**
80
+ * Tag-allowlist for tag-scoped tokens (root tag names). NULL = unscoped.
81
+ * See `Token.scoped_tags`.
82
+ */
83
+ scoped_tags: string[] | null;
84
+ /**
85
+ * Per-vault binding (v16). Non-null = token is bound to this vault;
86
+ * `authenticateVaultRequest` rejects when the bound vault doesn't match
87
+ * the request's vault. NULL = legacy / server-wide, accepted for any
88
+ * vault. See vault#257.
89
+ */
90
+ vault_name: string | null;
91
+ }
92
+
93
+ /**
94
+ * Parse the JSON-encoded `scoped_tags` column. Returns null for NULL/empty
95
+ * input. Defensive: malformed JSON or non-array shapes degrade to null
96
+ * (treat as unscoped) rather than throwing — a corrupt column value
97
+ * shouldn't take down auth; it just means the token loses its tag scope.
98
+ */
99
+ export function parseScopedTags(raw: string | null): string[] | null {
100
+ if (!raw) return null;
101
+ try {
102
+ const parsed = JSON.parse(raw);
103
+ if (!Array.isArray(parsed)) return null;
104
+ const tags = parsed.filter((t): t is string => typeof t === "string" && t.length > 0);
105
+ return tags.length === 0 ? null : tags;
106
+ } catch {
107
+ return null;
108
+ }
64
109
  }
65
110
 
66
111
  // ---------------------------------------------------------------------------
@@ -89,6 +134,20 @@ export function createToken(
89
134
  scope_tag?: string | null;
90
135
  /** @deprecated Written to DB but not enforced at runtime. */
91
136
  scope_path_prefix?: string | null;
137
+ /**
138
+ * Tag-allowlist (root tag names). null/undefined → unscoped (full vault
139
+ * access per `scopes`). When provided, must be already-validated root tag
140
+ * names per patterns/tag-scoped-tokens.md (no path separators); the mint
141
+ * endpoint validates against existing tags before passing through.
142
+ */
143
+ scoped_tags?: string[] | null;
144
+ /**
145
+ * Per-vault binding (v16). Non-null = token can only authenticate
146
+ * against this vault. NULL = legacy / server-wide; auth accepts the
147
+ * token for any vault. New mints via per-vault routes set this; the
148
+ * legacy YAML-import path leaves it NULL. See vault#257.
149
+ */
150
+ vault_name?: string | null;
92
151
  expires_at?: string | null;
93
152
  },
94
153
  ): Token {
@@ -97,19 +156,24 @@ export function createToken(
97
156
  const permission = opts.permission ?? "full";
98
157
  const scopes = opts.scopes ?? legacyPermissionToScopes(permission);
99
158
  const scopesStr = serializeScopes(scopes);
159
+ const scopedTags = opts.scoped_tags && opts.scoped_tags.length > 0 ? opts.scoped_tags : null;
160
+ const scopedTagsStr = scopedTags ? JSON.stringify(scopedTags) : null;
161
+ const vaultName = opts.vault_name ?? null;
100
162
 
101
163
  db.prepare(`
102
- INSERT INTO tokens (token_hash, label, permission, scopes, scope_tag, scope_path_prefix, expires_at, created_at)
103
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
164
+ INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, scope_tag, scope_path_prefix, expires_at, created_at, vault_name)
165
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
104
166
  `).run(
105
167
  tokenHash,
106
168
  opts.label,
107
169
  permission,
108
170
  scopesStr,
171
+ scopedTagsStr,
109
172
  opts.scope_tag ?? null,
110
173
  opts.scope_path_prefix ?? null,
111
174
  opts.expires_at ?? null,
112
175
  now,
176
+ vaultName,
113
177
  );
114
178
 
115
179
  return {
@@ -118,6 +182,8 @@ export function createToken(
118
182
  permission,
119
183
  scope_tag: opts.scope_tag ?? null,
120
184
  scope_path_prefix: opts.scope_path_prefix ?? null,
185
+ scoped_tags: scopedTags,
186
+ vault_name: vaultName,
121
187
  expires_at: opts.expires_at ?? null,
122
188
  created_at: now,
123
189
  last_used_at: null,
@@ -135,13 +201,15 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
135
201
  const candidateHash = hashKey(providedToken);
136
202
 
137
203
  const row = db.prepare(`
138
- SELECT token_hash, permission, scopes, expires_at
204
+ SELECT token_hash, permission, scopes, scoped_tags, expires_at, vault_name
139
205
  FROM tokens WHERE token_hash = ?
140
206
  `).get(candidateHash) as {
141
207
  token_hash: string;
142
208
  permission: string;
143
209
  scopes: string | null;
210
+ scoped_tags: string | null;
144
211
  expires_at: string | null;
212
+ vault_name: string | null;
145
213
  } | null;
146
214
 
147
215
  if (!row) return null;
@@ -160,29 +228,74 @@ export function resolveToken(db: Database, providedToken: string): ResolvedToken
160
228
  const hasVaultScope = parsed.some((s) => s.startsWith("vault:"));
161
229
  const scopes = hasVaultScope ? parsed : legacyPermissionToScopes(permission);
162
230
  const legacyDerived = !hasVaultScope;
231
+ const scoped_tags = parseScopedTags(row.scoped_tags);
163
232
 
164
- return { permission, scopes, legacyDerived };
233
+ return { permission, scopes, legacyDerived, scoped_tags, vault_name: row.vault_name };
165
234
  }
166
235
 
167
236
  /**
168
- * List all tokens (for CLI display). Never exposes the hash directly —
169
- * shows a truncated prefix for identification.
237
+ * List tokens (for CLI display + admin SPA). Never exposes the hash
238
+ * directly — shows a truncated prefix for identification.
239
+ *
240
+ * Filtering (v16): pass `{ vaultName }` to scope the result to tokens
241
+ * bound to that vault. The filter is `vault_name = $vaultName OR
242
+ * vault_name IS NULL` — legacy server-wide tokens (NULL) remain visible
243
+ * inside every per-vault listing, since they authenticate cross-vault
244
+ * by design and the operator should see them in any vault's admin UI
245
+ * to revoke. Pass no filter (or `vaultName: null`) to list everything.
170
246
  */
171
- export function listTokens(db: Database): (Token & { id: string })[] {
247
+ export function listTokens(
248
+ db: Database,
249
+ opts: { vaultName?: string | null } = {},
250
+ ): (Token & { id: string })[] {
251
+ const where = opts.vaultName ? "WHERE vault_name = ? OR vault_name IS NULL" : "";
252
+ const params = opts.vaultName ? [opts.vaultName] : [];
172
253
  const rows = db.prepare(`
173
254
  SELECT token_hash, label, permission, scope_tag, scope_path_prefix,
174
- expires_at, created_at, last_used_at
175
- FROM tokens ORDER BY created_at DESC
176
- `).all() as Token[];
255
+ scoped_tags, vault_name, expires_at, created_at, last_used_at
256
+ FROM tokens ${where}
257
+ ORDER BY created_at DESC
258
+ `).all(...params) as (Omit<Token, "scoped_tags"> & { scoped_tags: string | null })[];
177
259
 
178
260
  return rows.map((r) => ({
179
261
  ...r,
180
262
  permission: normalizePermission(r.permission),
263
+ scoped_tags: parseScopedTags(r.scoped_tags),
181
264
  // Derive a short display ID from the hash (first 12 chars after "sha256:")
182
265
  id: `t_${r.token_hash.slice(7, 19)}`,
183
266
  }));
184
267
  }
185
268
 
269
+ /**
270
+ * Find tokens whose `scoped_tags` allowlist references the given root tag.
271
+ * Used by tag-delete and tag-merge to fail-closed (409) when removing a
272
+ * tag would silently orphan a tag-scoped token's allowlist entry.
273
+ *
274
+ * Returns display ID + label pairs (no token-hash exposure) so error
275
+ * envelopes can name the offending tokens for the operator. The match is
276
+ * exact on the root name — `scoped_tags` only ever stores roots per
277
+ * patterns/tag-scoped-tokens.md.
278
+ */
279
+ export function findTokensReferencingTag(
280
+ db: Database,
281
+ tag: string,
282
+ ): { id: string; label: string }[] {
283
+ const rows = db.prepare(`
284
+ SELECT token_hash, label, scoped_tags
285
+ FROM tokens
286
+ WHERE scoped_tags IS NOT NULL
287
+ `).all() as { token_hash: string; label: string; scoped_tags: string | null }[];
288
+
289
+ const matches: { id: string; label: string }[] = [];
290
+ for (const row of rows) {
291
+ const tags = parseScopedTags(row.scoped_tags);
292
+ if (tags && tags.includes(tag)) {
293
+ matches.push({ id: `t_${row.token_hash.slice(7, 19)}`, label: row.label });
294
+ }
295
+ }
296
+ return matches;
297
+ }
298
+
186
299
  /**
187
300
  * Revoke (delete) a token by its display ID or full hash.
188
301
  * Returns true if exactly one token was deleted.
@@ -196,7 +309,7 @@ export function revokeToken(db: Database, idOrHash: string): boolean {
196
309
  "SELECT token_hash FROM tokens WHERE token_hash LIKE ?"
197
310
  ).all(`sha256:${hashPrefix}%`) as { token_hash: string }[];
198
311
  if (rows.length === 1) {
199
- db.prepare("DELETE FROM tokens WHERE token_hash = ?").run(rows[0].token_hash);
312
+ db.prepare("DELETE FROM tokens WHERE token_hash = ?").run(rows[0]!.token_hash);
200
313
  return true;
201
314
  }
202
315
  if (rows.length > 1) {
@@ -206,8 +319,10 @@ export function revokeToken(db: Database, idOrHash: string): boolean {
206
319
  }
207
320
 
208
321
  // Try matching by full hash
209
- const result = db.prepare("DELETE FROM tokens WHERE token_hash = ?").run(idOrHash);
210
- return result.changes > 0;
322
+ const deleted = db.prepare(
323
+ "DELETE FROM tokens WHERE token_hash = ? RETURNING token_hash",
324
+ ).get(idOrHash) as { token_hash: string } | null;
325
+ return deleted !== null;
211
326
  }
212
327
 
213
328
  // ---------------------------------------------------------------------------