@openparachute/vault 0.3.1 → 0.4.0
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 +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- 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 +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- 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 +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- 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 +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -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 +720 -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 +868 -3
- 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
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `/vault/<name>/tokens` REST endpoints (issue #173).
|
|
3
|
+
*
|
|
4
|
+
* Covers the seven cases the design brief named, plus method-not-allowed
|
|
5
|
+
* and the hub-JWT auth path. Each test uses its own PARACHUTE_HOME tmp dir,
|
|
6
|
+
* mirroring `routing.test.ts` and `auth-hub-jwt.test.ts`.
|
|
7
|
+
*
|
|
8
|
+
* Key invariants under test:
|
|
9
|
+
* - POST returns plaintext exactly once (201 body), but never afterwards
|
|
10
|
+
* in GET listings (no plaintext, no token_hash field exposed).
|
|
11
|
+
* - Scope subset enforcement: minted scopes must be ≤ caller's vault
|
|
12
|
+
* verb power. Cross-vault scopes (`vault:other:*`) rejected.
|
|
13
|
+
* - Admin gate: read/write callers cannot reach the endpoint at all.
|
|
14
|
+
* - DELETE: 200 on success, 404 when the id doesn't exist, **403** when
|
|
15
|
+
* the id resolves to a different vault's binding (per #257 spec).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
19
|
+
import { rmSync, existsSync, mkdirSync } from "fs";
|
|
20
|
+
import { join } from "path";
|
|
21
|
+
import { tmpdir } from "os";
|
|
22
|
+
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
23
|
+
|
|
24
|
+
const testDir = join(
|
|
25
|
+
tmpdir(),
|
|
26
|
+
`vault-tokens-routes-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
27
|
+
);
|
|
28
|
+
process.env.PARACHUTE_HOME = testDir;
|
|
29
|
+
|
|
30
|
+
const { route } = await import("./routing.ts");
|
|
31
|
+
const { writeGlobalConfig, writeVaultConfig } = await import("./config.ts");
|
|
32
|
+
const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
|
|
33
|
+
const { generateToken, createToken, resolveToken } = await import("./token-store.ts");
|
|
34
|
+
const { resetJwksCache } = await import("./hub-jwt.ts");
|
|
35
|
+
|
|
36
|
+
interface Keypair {
|
|
37
|
+
privateKey: CryptoKey;
|
|
38
|
+
publicJwk: { kty: string; n: string; e: string; kid: string; alg: string; use: string };
|
|
39
|
+
kid: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function makeKeypair(kid: string): Promise<Keypair> {
|
|
43
|
+
const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
|
|
44
|
+
const jwk = await exportJWK(publicKey);
|
|
45
|
+
return {
|
|
46
|
+
privateKey,
|
|
47
|
+
publicJwk: { kty: "RSA", n: jwk.n!, e: jwk.e!, kid, alg: "RS256", use: "sig" },
|
|
48
|
+
kid,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface JwksFixture {
|
|
53
|
+
origin: string;
|
|
54
|
+
stop: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function startJwksFixture(keys: Keypair[]): JwksFixture {
|
|
58
|
+
const server = Bun.serve({
|
|
59
|
+
port: 0,
|
|
60
|
+
fetch(req) {
|
|
61
|
+
const url = new URL(req.url);
|
|
62
|
+
if (url.pathname !== "/.well-known/jwks.json") {
|
|
63
|
+
return new Response("not found", { status: 404 });
|
|
64
|
+
}
|
|
65
|
+
return Response.json({ keys: keys.map((k) => k.publicJwk) });
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
return {
|
|
69
|
+
origin: `http://127.0.0.1:${server.port}`,
|
|
70
|
+
stop: () => server.stop(true),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function signHubJwt(
|
|
75
|
+
kp: Keypair,
|
|
76
|
+
iss: string,
|
|
77
|
+
aud: string,
|
|
78
|
+
scope: string,
|
|
79
|
+
): Promise<string> {
|
|
80
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
81
|
+
return await new SignJWT({ scope, client_id: "test-client" })
|
|
82
|
+
.setProtectedHeader({ alg: "RS256", kid: kp.kid })
|
|
83
|
+
.setIssuer(iss)
|
|
84
|
+
.setSubject("user-1")
|
|
85
|
+
.setAudience(aud)
|
|
86
|
+
.setIssuedAt(iat)
|
|
87
|
+
.setExpirationTime(iat + 60)
|
|
88
|
+
.setJti(`jti-${Math.random().toString(36).slice(2)}`)
|
|
89
|
+
.sign(kp.privateKey);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createVault(name: string): void {
|
|
93
|
+
writeVaultConfig({ name, api_keys: [], created_at: new Date().toISOString() });
|
|
94
|
+
getVaultStore(name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mintAdminToken(vaultName: string): string {
|
|
98
|
+
const store = getVaultStore(vaultName);
|
|
99
|
+
const { fullToken } = generateToken();
|
|
100
|
+
createToken(store.db, fullToken, {
|
|
101
|
+
label: "test-admin",
|
|
102
|
+
permission: "full",
|
|
103
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
104
|
+
});
|
|
105
|
+
return fullToken;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mintReadOnlyToken(vaultName: string): string {
|
|
109
|
+
const store = getVaultStore(vaultName);
|
|
110
|
+
const { fullToken } = generateToken();
|
|
111
|
+
createToken(store.db, fullToken, {
|
|
112
|
+
label: "test-read",
|
|
113
|
+
permission: "read",
|
|
114
|
+
scopes: ["vault:read"],
|
|
115
|
+
});
|
|
116
|
+
return fullToken;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let fixture: JwksFixture | null = null;
|
|
120
|
+
let kp: Keypair | null = null;
|
|
121
|
+
let prevHubOrigin: string | undefined;
|
|
122
|
+
|
|
123
|
+
beforeEach(async () => {
|
|
124
|
+
clearVaultStoreCache();
|
|
125
|
+
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
|
|
126
|
+
mkdirSync(testDir, { recursive: true });
|
|
127
|
+
mkdirSync(join(testDir, "vault", "data"), { recursive: true });
|
|
128
|
+
writeGlobalConfig({ port: 1940 });
|
|
129
|
+
|
|
130
|
+
kp = await makeKeypair("k1");
|
|
131
|
+
fixture = startJwksFixture([kp]);
|
|
132
|
+
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
133
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
134
|
+
resetJwksCache();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
afterEach(() => {
|
|
138
|
+
if (fixture) fixture.stop();
|
|
139
|
+
fixture = null;
|
|
140
|
+
kp = null;
|
|
141
|
+
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
142
|
+
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
143
|
+
clearVaultStoreCache();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function postTokens(
|
|
147
|
+
vaultName: string,
|
|
148
|
+
bearer: string | null,
|
|
149
|
+
body: Record<string, unknown>,
|
|
150
|
+
): Promise<Response> {
|
|
151
|
+
const path = `/vault/${vaultName}/tokens`;
|
|
152
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
153
|
+
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
|
154
|
+
return route(
|
|
155
|
+
new Request(`http://localhost:1940${path}`, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers,
|
|
158
|
+
body: JSON.stringify(body),
|
|
159
|
+
}),
|
|
160
|
+
path,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function getTokens(vaultName: string, bearer: string | null): Promise<Response> {
|
|
165
|
+
const path = `/vault/${vaultName}/tokens`;
|
|
166
|
+
const headers: Record<string, string> = {};
|
|
167
|
+
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
|
168
|
+
return route(new Request(`http://localhost:1940${path}`, { headers }), path);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function deleteToken(
|
|
172
|
+
vaultName: string,
|
|
173
|
+
id: string,
|
|
174
|
+
bearer: string | null,
|
|
175
|
+
): Promise<Response> {
|
|
176
|
+
const path = `/vault/${vaultName}/tokens/${id}`;
|
|
177
|
+
const headers: Record<string, string> = {};
|
|
178
|
+
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
|
179
|
+
return route(
|
|
180
|
+
new Request(`http://localhost:1940${path}`, { method: "DELETE", headers }),
|
|
181
|
+
path,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
describe("POST /vault/<name>/tokens — happy path", () => {
|
|
186
|
+
test("admin pvt_* mints a token with default full scopes; plaintext returned exactly once", async () => {
|
|
187
|
+
createVault("journal");
|
|
188
|
+
const admin = mintAdminToken("journal");
|
|
189
|
+
|
|
190
|
+
const res = await postTokens("journal", admin, { label: "agent-x" });
|
|
191
|
+
expect(res.status).toBe(201);
|
|
192
|
+
const body = (await res.json()) as {
|
|
193
|
+
id: string;
|
|
194
|
+
token: string;
|
|
195
|
+
label: string;
|
|
196
|
+
permission: string;
|
|
197
|
+
scopes: string[];
|
|
198
|
+
expires_at: string | null;
|
|
199
|
+
created_at: string;
|
|
200
|
+
};
|
|
201
|
+
expect(body.token).toMatch(/^pvt_/);
|
|
202
|
+
expect(body.id).toMatch(/^t_/);
|
|
203
|
+
expect(body.label).toBe("agent-x");
|
|
204
|
+
expect(body.permission).toBe("full");
|
|
205
|
+
expect(body.scopes).toEqual(["vault:read", "vault:write", "vault:admin"]);
|
|
206
|
+
expect(body.expires_at).toBeNull();
|
|
207
|
+
|
|
208
|
+
// Minted token actually authenticates against the vault.
|
|
209
|
+
const store = getVaultStore("journal");
|
|
210
|
+
const resolved = resolveToken(store.db, body.token);
|
|
211
|
+
expect(resolved).not.toBeNull();
|
|
212
|
+
expect(resolved!.permission).toBe("full");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("admin pvt_* mints with explicit narrowed scopes — read-only", async () => {
|
|
216
|
+
createVault("journal");
|
|
217
|
+
const admin = mintAdminToken("journal");
|
|
218
|
+
|
|
219
|
+
const res = await postTokens("journal", admin, {
|
|
220
|
+
label: "reader",
|
|
221
|
+
scopes: ["vault:read"],
|
|
222
|
+
});
|
|
223
|
+
expect(res.status).toBe(201);
|
|
224
|
+
const body = (await res.json()) as {
|
|
225
|
+
permission: string;
|
|
226
|
+
scopes: string[];
|
|
227
|
+
};
|
|
228
|
+
expect(body.permission).toBe("read");
|
|
229
|
+
expect(body.scopes).toEqual(["vault:read"]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("admin pvt_* mints with OAuth-style space-separated `scope` string", async () => {
|
|
233
|
+
createVault("journal");
|
|
234
|
+
const admin = mintAdminToken("journal");
|
|
235
|
+
|
|
236
|
+
const res = await postTokens("journal", admin, {
|
|
237
|
+
scope: "vault:read vault:write",
|
|
238
|
+
});
|
|
239
|
+
expect(res.status).toBe(201);
|
|
240
|
+
const body = (await res.json()) as { scopes: string[]; permission: string };
|
|
241
|
+
expect(body.scopes).toEqual(["vault:read", "vault:write"]);
|
|
242
|
+
expect(body.permission).toBe("full");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("hub JWT with vault:<name>:admin can mint", async () => {
|
|
246
|
+
createVault("journal");
|
|
247
|
+
const token = await signHubJwt(
|
|
248
|
+
kp!,
|
|
249
|
+
fixture!.origin,
|
|
250
|
+
"vault.journal",
|
|
251
|
+
"vault:journal:admin",
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
const res = await postTokens("journal", token, { label: "from-hub" });
|
|
255
|
+
expect(res.status).toBe(201);
|
|
256
|
+
const body = (await res.json()) as { token: string; label: string };
|
|
257
|
+
expect(body.token).toMatch(/^pvt_/);
|
|
258
|
+
expect(body.label).toBe("from-hub");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("future expires_at is accepted and round-trips", async () => {
|
|
262
|
+
createVault("journal");
|
|
263
|
+
const admin = mintAdminToken("journal");
|
|
264
|
+
const exp = new Date(Date.now() + 24 * 3600 * 1000).toISOString();
|
|
265
|
+
|
|
266
|
+
const res = await postTokens("journal", admin, { expires_at: exp });
|
|
267
|
+
expect(res.status).toBe(201);
|
|
268
|
+
const body = (await res.json()) as { expires_at: string };
|
|
269
|
+
// The stored value is a re-serialized ISO string — same instant, byte-equal here.
|
|
270
|
+
expect(body.expires_at).toBe(exp);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe("POST /vault/<name>/tokens — scope narrowing", () => {
|
|
275
|
+
test("hub JWT with write scope can mint a write-or-lower token", async () => {
|
|
276
|
+
createVault("journal");
|
|
277
|
+
// Bypass the admin gate by minting with admin first to seed; then test
|
|
278
|
+
// narrowing by minting via a write-scoped JWT — but the gate blocks
|
|
279
|
+
// that path. The narrowing rule is most meaningful via subset check
|
|
280
|
+
// independently of the gate, so we use an admin JWT that requests a
|
|
281
|
+
// narrower (write-only) token. This is the supported narrowing path.
|
|
282
|
+
const token = await signHubJwt(
|
|
283
|
+
kp!,
|
|
284
|
+
fixture!.origin,
|
|
285
|
+
"vault.journal",
|
|
286
|
+
"vault:journal:admin",
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const res = await postTokens("journal", token, {
|
|
290
|
+
label: "write-only",
|
|
291
|
+
scopes: ["vault:write"],
|
|
292
|
+
});
|
|
293
|
+
expect(res.status).toBe(201);
|
|
294
|
+
const body = (await res.json()) as { permission: string; scopes: string[] };
|
|
295
|
+
expect(body.scopes).toEqual(["vault:write"]);
|
|
296
|
+
// Resolved permission derives from the highest scope held — write → full.
|
|
297
|
+
expect(body.permission).toBe("full");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("cross-vault scope `vault:<other>:read` is rejected with 400", async () => {
|
|
301
|
+
createVault("journal");
|
|
302
|
+
createVault("work");
|
|
303
|
+
const admin = mintAdminToken("journal");
|
|
304
|
+
|
|
305
|
+
const res = await postTokens("journal", admin, {
|
|
306
|
+
label: "cross-vault-attempt",
|
|
307
|
+
scopes: ["vault:work:read"],
|
|
308
|
+
});
|
|
309
|
+
expect(res.status).toBe(400);
|
|
310
|
+
const body = (await res.json()) as {
|
|
311
|
+
message: string;
|
|
312
|
+
rejected: { scope: string; reason: string }[];
|
|
313
|
+
};
|
|
314
|
+
expect(body.message).toBe("scope rejected");
|
|
315
|
+
expect(body.rejected).toHaveLength(1);
|
|
316
|
+
expect(body.rejected[0]!.scope).toBe("vault:work:read");
|
|
317
|
+
expect(body.rejected[0]!.reason).toContain("cross-vault");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("invalid scope name is rejected with 400 (no token created)", async () => {
|
|
321
|
+
createVault("journal");
|
|
322
|
+
const admin = mintAdminToken("journal");
|
|
323
|
+
|
|
324
|
+
const res = await postTokens("journal", admin, {
|
|
325
|
+
scopes: ["vault:read", "not-a-real-scope"],
|
|
326
|
+
});
|
|
327
|
+
expect(res.status).toBe(400);
|
|
328
|
+
const body = (await res.json()) as {
|
|
329
|
+
rejected: { scope: string; reason: string }[];
|
|
330
|
+
};
|
|
331
|
+
expect(body.rejected.some((r) => r.scope === "not-a-real-scope")).toBe(true);
|
|
332
|
+
|
|
333
|
+
// Confirm no token was minted.
|
|
334
|
+
const list = await getTokens("journal", admin).then((r) => r.json()) as {
|
|
335
|
+
tokens: { label: string }[];
|
|
336
|
+
};
|
|
337
|
+
expect(list.tokens.find((t) => t.label !== "test-admin")).toBeUndefined();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("expires_at in the past is rejected with 400", async () => {
|
|
341
|
+
createVault("journal");
|
|
342
|
+
const admin = mintAdminToken("journal");
|
|
343
|
+
const past = new Date(Date.now() - 1000).toISOString();
|
|
344
|
+
|
|
345
|
+
const res = await postTokens("journal", admin, { expires_at: past });
|
|
346
|
+
expect(res.status).toBe(400);
|
|
347
|
+
const body = (await res.json()) as { message: string };
|
|
348
|
+
expect(body.message).toContain("future");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("POST /vault/<name>/tokens — auth gates", () => {
|
|
353
|
+
test("missing auth → 401", async () => {
|
|
354
|
+
createVault("journal");
|
|
355
|
+
const res = await postTokens("journal", null, { label: "x" });
|
|
356
|
+
expect(res.status).toBe(401);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("read-only pvt_* token → 403 insufficient_scope (admin required)", async () => {
|
|
360
|
+
createVault("journal");
|
|
361
|
+
const reader = mintReadOnlyToken("journal");
|
|
362
|
+
|
|
363
|
+
const res = await postTokens("journal", reader, { label: "x" });
|
|
364
|
+
expect(res.status).toBe(403);
|
|
365
|
+
const body = (await res.json()) as {
|
|
366
|
+
error_type: string;
|
|
367
|
+
required_scope: string;
|
|
368
|
+
};
|
|
369
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
370
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("hub JWT scoped to a different vault → 401 audience mismatch", async () => {
|
|
374
|
+
createVault("journal");
|
|
375
|
+
createVault("work");
|
|
376
|
+
const wrongAud = await signHubJwt(
|
|
377
|
+
kp!,
|
|
378
|
+
fixture!.origin,
|
|
379
|
+
"vault.work",
|
|
380
|
+
"vault:work:admin",
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
const res = await postTokens("journal", wrongAud, {});
|
|
384
|
+
expect(res.status).toBe(401);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
describe("GET /vault/<name>/tokens — list", () => {
|
|
389
|
+
test("admin can list tokens; response excludes plaintext and hash", async () => {
|
|
390
|
+
createVault("journal");
|
|
391
|
+
const admin = mintAdminToken("journal");
|
|
392
|
+
// Add a second token via the endpoint so we have multiple rows.
|
|
393
|
+
await postTokens("journal", admin, { label: "agent-x", scopes: ["vault:read"] });
|
|
394
|
+
|
|
395
|
+
const res = await getTokens("journal", admin);
|
|
396
|
+
expect(res.status).toBe(200);
|
|
397
|
+
const body = (await res.json()) as {
|
|
398
|
+
tokens: Array<{
|
|
399
|
+
id: string;
|
|
400
|
+
label: string;
|
|
401
|
+
permission: string;
|
|
402
|
+
scopes: string[];
|
|
403
|
+
expires_at: string | null;
|
|
404
|
+
created_at: string;
|
|
405
|
+
last_used_at: string | null;
|
|
406
|
+
}>;
|
|
407
|
+
};
|
|
408
|
+
expect(body.tokens.length).toBeGreaterThanOrEqual(2);
|
|
409
|
+
for (const t of body.tokens) {
|
|
410
|
+
expect(t.id).toMatch(/^t_/);
|
|
411
|
+
// Plaintext and hash must never appear, even by accident.
|
|
412
|
+
expect(JSON.stringify(t)).not.toMatch(/pvt_/);
|
|
413
|
+
expect(JSON.stringify(t)).not.toMatch(/token_hash/);
|
|
414
|
+
expect(JSON.stringify(t)).not.toMatch(/sha256:/);
|
|
415
|
+
}
|
|
416
|
+
const minted = body.tokens.find((t) => t.label === "agent-x");
|
|
417
|
+
expect(minted).toBeDefined();
|
|
418
|
+
expect(minted!.scopes).toEqual(["vault:read"]);
|
|
419
|
+
expect(minted!.permission).toBe("read");
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test("read-only token cannot list (admin gate) → 403", async () => {
|
|
423
|
+
createVault("journal");
|
|
424
|
+
const reader = mintReadOnlyToken("journal");
|
|
425
|
+
|
|
426
|
+
const res = await getTokens("journal", reader);
|
|
427
|
+
expect(res.status).toBe(403);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("v16: list returns vault-bound tokens + legacy NULL, never tokens bound to other vaults", async () => {
|
|
431
|
+
// Per the per-vault token storage migration (vault#257): the SPA at
|
|
432
|
+
// /vault/<name>/admin/tokens must show only THIS vault's tokens. Mixing
|
|
433
|
+
// tokens from sibling vaults — the bug Aaron flagged — would re-introduce
|
|
434
|
+
// the cross-vault leak the migration is meant to close.
|
|
435
|
+
createVault("journal");
|
|
436
|
+
createVault("work");
|
|
437
|
+
const journalAdmin = mintAdminToken("journal");
|
|
438
|
+
|
|
439
|
+
// Mint a vault-bound token via the endpoint (so it carries vault_name="journal").
|
|
440
|
+
const journalMint = (await (await postTokens("journal", journalAdmin, {
|
|
441
|
+
label: "journal-bound",
|
|
442
|
+
})).json()) as { id: string };
|
|
443
|
+
|
|
444
|
+
// Plant a cross-vault token directly in journal's DB (vault_name="work").
|
|
445
|
+
// This shouldn't happen via normal mint paths after #257, but the filter
|
|
446
|
+
// must still hide it — defense-in-depth against future bugs / manual
|
|
447
|
+
// SQL.
|
|
448
|
+
const journalStore = getVaultStore("journal");
|
|
449
|
+
const { fullToken: crossBound } = generateToken();
|
|
450
|
+
createToken(journalStore.db, crossBound, {
|
|
451
|
+
label: "work-bound-leak",
|
|
452
|
+
permission: "full",
|
|
453
|
+
scopes: ["vault:read"],
|
|
454
|
+
vault_name: "work",
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Plant a legacy NULL-bound token (pre-v16 / YAML-imported shape).
|
|
458
|
+
const { fullToken: legacy } = generateToken();
|
|
459
|
+
createToken(journalStore.db, legacy, {
|
|
460
|
+
label: "legacy-server-wide",
|
|
461
|
+
permission: "read",
|
|
462
|
+
scopes: ["vault:read"],
|
|
463
|
+
// vault_name omitted → NULL.
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const res = await getTokens("journal", journalAdmin);
|
|
467
|
+
const body = (await res.json()) as {
|
|
468
|
+
tokens: Array<{ id: string; label: string; vault_name: string | null }>;
|
|
469
|
+
};
|
|
470
|
+
const labels = body.tokens.map((t) => t.label).sort();
|
|
471
|
+
expect(labels).toContain("journal-bound");
|
|
472
|
+
expect(labels).toContain("legacy-server-wide");
|
|
473
|
+
expect(labels).toContain("test-admin"); // the mintAdminToken seed
|
|
474
|
+
expect(labels).not.toContain("work-bound-leak");
|
|
475
|
+
|
|
476
|
+
// Each surfaced row carries its vault_name (null for legacy, "journal"
|
|
477
|
+
// for the bound one) so the SPA can render a "server-wide" badge.
|
|
478
|
+
const journalRow = body.tokens.find((t) => t.id === journalMint.id);
|
|
479
|
+
expect(journalRow!.vault_name).toBe("journal");
|
|
480
|
+
const legacyRow = body.tokens.find((t) => t.label === "legacy-server-wide");
|
|
481
|
+
expect(legacyRow!.vault_name).toBeNull();
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("DELETE /vault/<name>/tokens/<id> — revoke", () => {
|
|
486
|
+
test("admin revokes by display id; revoked token no longer resolves", async () => {
|
|
487
|
+
createVault("journal");
|
|
488
|
+
const admin = mintAdminToken("journal");
|
|
489
|
+
|
|
490
|
+
const minted = (await (await postTokens("journal", admin, {
|
|
491
|
+
label: "to-revoke",
|
|
492
|
+
})).json()) as { id: string; token: string };
|
|
493
|
+
const store = getVaultStore("journal");
|
|
494
|
+
expect(resolveToken(store.db, minted.token)).not.toBeNull();
|
|
495
|
+
|
|
496
|
+
const res = await deleteToken("journal", minted.id, admin);
|
|
497
|
+
expect(res.status).toBe(200);
|
|
498
|
+
const body = (await res.json()) as { revoked: boolean };
|
|
499
|
+
expect(body.revoked).toBe(true);
|
|
500
|
+
|
|
501
|
+
expect(resolveToken(store.db, minted.token)).toBeNull();
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("non-existent id → 404", async () => {
|
|
505
|
+
// Per vault#257 spec: silent 200 on missing id is misleading once the
|
|
506
|
+
// list endpoint already exposes the full vault-scoped + legacy-NULL
|
|
507
|
+
// listing — existence isn't being protected, so 404 is the honest shape.
|
|
508
|
+
createVault("journal");
|
|
509
|
+
const admin = mintAdminToken("journal");
|
|
510
|
+
|
|
511
|
+
const res = await deleteToken("journal", "t_doesnotexist", admin);
|
|
512
|
+
expect(res.status).toBe(404);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
test("v16: cross-vault binding revoke → 403 with descriptive error", async () => {
|
|
516
|
+
// Per vault#257 spec: an admin in vault A attempting to revoke a token
|
|
517
|
+
// bound to vault B must get 403, not silent 200. Silent 200 would tell
|
|
518
|
+
// the operator the token was revoked while it kept working from vault B.
|
|
519
|
+
createVault("journal");
|
|
520
|
+
createVault("work");
|
|
521
|
+
const journalAdmin = mintAdminToken("journal");
|
|
522
|
+
|
|
523
|
+
// Plant a token in journal's DB but bound to "work" (the cross-vault
|
|
524
|
+
// shape — same row layout the v16 list-filter test uses).
|
|
525
|
+
const journalStore = getVaultStore("journal");
|
|
526
|
+
const { fullToken, tokenHash } = generateToken();
|
|
527
|
+
createToken(journalStore.db, fullToken, {
|
|
528
|
+
label: "work-bound",
|
|
529
|
+
permission: "full",
|
|
530
|
+
scopes: ["vault:admin"],
|
|
531
|
+
vault_name: "work",
|
|
532
|
+
});
|
|
533
|
+
// Display id mirrors `t_${tokenHash.slice(7, 19)}` (the first 12 hex
|
|
534
|
+
// chars after `sha256:`) — same shape that listTokens emits.
|
|
535
|
+
const id = `t_${tokenHash.slice(7, 19)}`;
|
|
536
|
+
|
|
537
|
+
const res = await deleteToken("journal", id, journalAdmin);
|
|
538
|
+
expect(res.status).toBe(403);
|
|
539
|
+
const body = (await res.json()) as { error: string; message: string };
|
|
540
|
+
expect(body.error).toBe("Forbidden");
|
|
541
|
+
expect(body.message).toContain("'work'");
|
|
542
|
+
expect(body.message).toContain("'journal'");
|
|
543
|
+
|
|
544
|
+
// Defense-in-depth: the row is still there (revoke was rejected, not
|
|
545
|
+
// silently no-op'd into deletion).
|
|
546
|
+
const stillThere = journalStore.db
|
|
547
|
+
.prepare("SELECT 1 FROM tokens WHERE token_hash = ?")
|
|
548
|
+
.get(tokenHash);
|
|
549
|
+
expect(stillThere).not.toBeNull();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test("read-only token cannot revoke → 403", async () => {
|
|
553
|
+
createVault("journal");
|
|
554
|
+
const admin = mintAdminToken("journal");
|
|
555
|
+
const reader = mintReadOnlyToken("journal");
|
|
556
|
+
|
|
557
|
+
const minted = (await (await postTokens("journal", admin, {
|
|
558
|
+
label: "victim",
|
|
559
|
+
})).json()) as { id: string };
|
|
560
|
+
|
|
561
|
+
const res = await deleteToken("journal", minted.id, reader);
|
|
562
|
+
expect(res.status).toBe(403);
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
describe("POST /vault/<name>/tokens — tag-allowlist", () => {
|
|
567
|
+
// Helpers for the tag fixture: seed `_tags/health` so descendant expansion
|
|
568
|
+
// is exercised, plus a `health` and `work` note so listTags surfaces them
|
|
569
|
+
// as known root tags. The mint endpoint validates against listTags rather
|
|
570
|
+
// than against `_tags/*` config notes alone — keeps the picker UX honest.
|
|
571
|
+
async function seedTags(vaultName: string): Promise<void> {
|
|
572
|
+
const store = getVaultStore(vaultName);
|
|
573
|
+
await store.createNote("h", { tags: ["health"] });
|
|
574
|
+
await store.createNote("w", { tags: ["work"] });
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function mintTagScopedAdmin(vaultName: string, allowlist: string[]): string {
|
|
578
|
+
const store = getVaultStore(vaultName);
|
|
579
|
+
const { fullToken } = generateToken();
|
|
580
|
+
createToken(store.db, fullToken, {
|
|
581
|
+
label: "scoped-admin",
|
|
582
|
+
permission: "full",
|
|
583
|
+
scopes: ["vault:read", "vault:write", "vault:admin"],
|
|
584
|
+
scoped_tags: allowlist,
|
|
585
|
+
});
|
|
586
|
+
return fullToken;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
test("unscoped admin mints with `tags: ['health']` → 201; response carries scoped_tags", async () => {
|
|
590
|
+
createVault("journal");
|
|
591
|
+
await seedTags("journal");
|
|
592
|
+
const admin = mintAdminToken("journal");
|
|
593
|
+
const res = await postTokens("journal", admin, { label: "h-bot", tags: ["health"] });
|
|
594
|
+
expect(res.status).toBe(201);
|
|
595
|
+
const body = (await res.json()) as { scoped_tags: string[] | null };
|
|
596
|
+
expect(body.scoped_tags).toEqual(["health"]);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
test("unscoped admin omitting `tags` mints an unscoped token (back-compat)", async () => {
|
|
600
|
+
createVault("journal");
|
|
601
|
+
const admin = mintAdminToken("journal");
|
|
602
|
+
const res = await postTokens("journal", admin, { label: "no-tags" });
|
|
603
|
+
expect(res.status).toBe(201);
|
|
604
|
+
const body = (await res.json()) as { scoped_tags: string[] | null };
|
|
605
|
+
expect(body.scoped_tags).toBeNull();
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test("unknown tag → 400 with `unknown_tags` field", async () => {
|
|
609
|
+
createVault("journal");
|
|
610
|
+
await seedTags("journal");
|
|
611
|
+
const admin = mintAdminToken("journal");
|
|
612
|
+
const res = await postTokens("journal", admin, { label: "bad", tags: ["nope"] });
|
|
613
|
+
expect(res.status).toBe(400);
|
|
614
|
+
const body = (await res.json()) as { unknown_tags?: string[] };
|
|
615
|
+
expect(body.unknown_tags).toEqual(["nope"]);
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("path-separator tag (`health/food`) → 400", async () => {
|
|
619
|
+
createVault("journal");
|
|
620
|
+
await seedTags("journal");
|
|
621
|
+
const admin = mintAdminToken("journal");
|
|
622
|
+
const res = await postTokens("journal", admin, { label: "bad", tags: ["health/food"] });
|
|
623
|
+
expect(res.status).toBe(400);
|
|
624
|
+
const body = (await res.json()) as { message: string };
|
|
625
|
+
expect(body.message).toContain("root-tag");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("empty `tags` array → 400 (omit instead for unscoped)", async () => {
|
|
629
|
+
createVault("journal");
|
|
630
|
+
const admin = mintAdminToken("journal");
|
|
631
|
+
const res = await postTokens("journal", admin, { label: "bad", tags: [] });
|
|
632
|
+
expect(res.status).toBe(400);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("non-array `tags` → 400", async () => {
|
|
636
|
+
createVault("journal");
|
|
637
|
+
const admin = mintAdminToken("journal");
|
|
638
|
+
const res = await postTokens("journal", admin, { label: "bad", tags: "health" as unknown as string[] });
|
|
639
|
+
expect(res.status).toBe(400);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
test("tag-scoped admin mints within own allowlist → 201", async () => {
|
|
643
|
+
createVault("journal");
|
|
644
|
+
await seedTags("journal");
|
|
645
|
+
const admin = mintTagScopedAdmin("journal", ["health"]);
|
|
646
|
+
const res = await postTokens("journal", admin, { label: "child", tags: ["health"] });
|
|
647
|
+
expect(res.status).toBe(201);
|
|
648
|
+
const body = (await res.json()) as { scoped_tags: string[] | null };
|
|
649
|
+
expect(body.scoped_tags).toEqual(["health"]);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("tag-scoped admin minting outside own allowlist → 403 with rejected_tags", async () => {
|
|
653
|
+
createVault("journal");
|
|
654
|
+
await seedTags("journal");
|
|
655
|
+
const admin = mintTagScopedAdmin("journal", ["health"]);
|
|
656
|
+
const res = await postTokens("journal", admin, { label: "escalate", tags: ["work"] });
|
|
657
|
+
expect(res.status).toBe(403);
|
|
658
|
+
const body = (await res.json()) as { rejected_tags?: string[]; error_type?: string };
|
|
659
|
+
expect(body.error_type).toBe("tag_scope_violation");
|
|
660
|
+
expect(body.rejected_tags).toEqual(["work"]);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("tag-scoped admin omitting `tags` → 403 (cannot widen to unscoped)", async () => {
|
|
664
|
+
createVault("journal");
|
|
665
|
+
await seedTags("journal");
|
|
666
|
+
const admin = mintTagScopedAdmin("journal", ["health"]);
|
|
667
|
+
const res = await postTokens("journal", admin, { label: "would-be-universe" });
|
|
668
|
+
expect(res.status).toBe(403);
|
|
669
|
+
const body = (await res.json()) as { error_type?: string; minter_scoped_tags?: string[] };
|
|
670
|
+
expect(body.error_type).toBe("tag_scope_violation");
|
|
671
|
+
expect(body.minter_scoped_tags).toEqual(["health"]);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("GET /tokens surfaces scoped_tags for listed entries", async () => {
|
|
675
|
+
createVault("journal");
|
|
676
|
+
await seedTags("journal");
|
|
677
|
+
const admin = mintAdminToken("journal");
|
|
678
|
+
await postTokens("journal", admin, { label: "scoped", tags: ["health"] });
|
|
679
|
+
await postTokens("journal", admin, { label: "unscoped" });
|
|
680
|
+
const res = await getTokens("journal", admin);
|
|
681
|
+
expect(res.status).toBe(200);
|
|
682
|
+
const body = (await res.json()) as {
|
|
683
|
+
tokens: { label: string; scoped_tags: string[] | null }[];
|
|
684
|
+
};
|
|
685
|
+
const scoped = body.tokens.find((t) => t.label === "scoped");
|
|
686
|
+
const unscoped = body.tokens.find((t) => t.label === "unscoped");
|
|
687
|
+
expect(scoped?.scoped_tags).toEqual(["health"]);
|
|
688
|
+
expect(unscoped?.scoped_tags).toBeNull();
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
describe("/vault/<name>/tokens — method handling", () => {
|
|
693
|
+
test("PUT on collection → 405", async () => {
|
|
694
|
+
createVault("journal");
|
|
695
|
+
const admin = mintAdminToken("journal");
|
|
696
|
+
const path = "/vault/journal/tokens";
|
|
697
|
+
const res = await route(
|
|
698
|
+
new Request(`http://localhost:1940${path}`, {
|
|
699
|
+
method: "PUT",
|
|
700
|
+
headers: { authorization: `Bearer ${admin}` },
|
|
701
|
+
}),
|
|
702
|
+
path,
|
|
703
|
+
);
|
|
704
|
+
expect(res.status).toBe(405);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("PATCH on item → 405", async () => {
|
|
708
|
+
createVault("journal");
|
|
709
|
+
const admin = mintAdminToken("journal");
|
|
710
|
+
const path = "/vault/journal/tokens/t_anything";
|
|
711
|
+
const res = await route(
|
|
712
|
+
new Request(`http://localhost:1940${path}`, {
|
|
713
|
+
method: "PATCH",
|
|
714
|
+
headers: { authorization: `Bearer ${admin}` },
|
|
715
|
+
}),
|
|
716
|
+
path,
|
|
717
|
+
);
|
|
718
|
+
expect(res.status).toBe(405);
|
|
719
|
+
});
|
|
720
|
+
});
|