@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.
- package/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- 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;
|
package/src/services-manifest.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/src/tag-scope.ts
ADDED
|
@@ -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
|
+
}
|
package/src/token-store.test.ts
CHANGED
|
@@ -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();
|
package/src/token-store.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
176
|
-
|
|
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]
|
|
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
|
|
210
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|