@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.
- package/README.md +51 -54
- package/core/src/core.test.ts +4 -1
- package/core/src/indexed-fields.test.ts +151 -0
- package/core/src/indexed-fields.ts +98 -0
- package/core/src/mcp.ts +66 -43
- package/core/src/notes.ts +26 -2
- package/core/src/portable-md.test.ts +52 -0
- package/core/src/portable-md.ts +48 -0
- package/core/src/schema.ts +87 -14
- package/core/src/store.ts +117 -0
- package/core/src/types.ts +28 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +191 -11
- package/src/auth-status.ts +12 -5
- package/src/auth.test.ts +135 -219
- package/src/auth.ts +158 -107
- package/src/cli.ts +306 -224
- package/src/config.ts +12 -4
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +27 -2
- package/src/hub-jwt.ts +10 -0
- package/src/init-summary.test.ts +4 -4
- package/src/init-summary.ts +36 -10
- package/src/mcp-config.test.ts +4 -2
- package/src/mcp-http.ts +24 -3
- package/src/mcp-install-interactive.test.ts +33 -71
- package/src/mcp-install-interactive.ts +23 -76
- package/src/mcp-install.test.ts +156 -55
- package/src/mcp-install.ts +109 -3
- package/src/mcp-tools.ts +249 -74
- package/src/mirror-config.test.ts +107 -0
- package/src/mirror-config.ts +275 -9
- package/src/mirror-credentials.test.ts +168 -17
- package/src/mirror-credentials.ts +155 -32
- package/src/mirror-deps.ts +25 -16
- package/src/mirror-import.test.ts +122 -16
- package/src/mirror-import.ts +50 -16
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +116 -22
- package/src/mirror-per-vault.test.ts +519 -0
- package/src/mirror-registry.ts +91 -14
- package/src/mirror-routes.test.ts +81 -21
- package/src/mirror-routes.ts +90 -16
- package/src/routes.ts +39 -2
- package/src/routing.test.ts +203 -118
- package/src/routing.ts +46 -59
- package/src/scopes.test.ts +0 -86
- package/src/scopes.ts +9 -97
- package/src/server.ts +102 -34
- package/src/storage.test.ts +132 -7
- package/src/token-store.test.ts +88 -169
- package/src/token-store.ts +123 -249
- package/src/vault-create.test.ts +12 -4
- package/src/vault.test.ts +408 -103
- package/web/ui/dist/assets/index-DDRo6F4u.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/src/tokens-routes.test.ts +0 -727
- package/src/tokens-routes.ts +0 -392
- package/web/ui/dist/assets/index-Degr8snN.js +0 -60
package/src/storage.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/token-store.test.ts
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the token
|
|
3
|
-
*
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
test("
|
|
50
|
-
|
|
51
|
-
|
|
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("
|
|
124
|
-
|
|
125
|
-
|
|
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.
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
85
|
+
const defaultTokens = listTokens(db, { vaultName: "default" });
|
|
86
|
+
expect(defaultTokens.map((t) => t.label).sort()).toEqual(["default-vault", "server-wide"]);
|
|
154
87
|
|
|
155
|
-
|
|
156
|
-
expect(
|
|
88
|
+
// No filter → everything.
|
|
89
|
+
expect(listTokens(db).length).toBe(3);
|
|
157
90
|
});
|
|
158
91
|
});
|
|
159
92
|
|
|
160
|
-
describe("
|
|
161
|
-
test("
|
|
162
|
-
const
|
|
163
|
-
|
|
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
|
-
|
|
169
|
-
expect(
|
|
98
|
+
expect(revokeToken(db, id)).toBe(true);
|
|
99
|
+
expect(listTokens(db).length).toBe(0);
|
|
170
100
|
});
|
|
171
101
|
|
|
172
|
-
test("
|
|
173
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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("
|
|
208
|
-
test("
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
expect(
|
|
218
|
-
expect(
|
|
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
|
-
|