@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
package/src/routing.test.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
21
|
-
import { rmSync, existsSync, mkdirSync } from "fs";
|
|
21
|
+
import { rmSync, existsSync, mkdirSync, writeFileSync } from "fs";
|
|
22
22
|
import { join } from "path";
|
|
23
23
|
import { tmpdir } from "os";
|
|
24
24
|
|
|
@@ -42,6 +42,7 @@ const {
|
|
|
42
42
|
// even when the DB files are already gone.
|
|
43
43
|
const { clearVaultStoreCache, getVaultStore } = await import("./vault-store.ts");
|
|
44
44
|
const { generateToken, createToken } = await import("./token-store.ts");
|
|
45
|
+
const { vaultDbPath } = await import("./config.ts");
|
|
45
46
|
|
|
46
47
|
function createVault(name: string, description?: string): void {
|
|
47
48
|
writeVaultConfig({
|
|
@@ -216,6 +217,224 @@ describe("GET /vaults/list (public discovery)", () => {
|
|
|
216
217
|
});
|
|
217
218
|
});
|
|
218
219
|
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
// /vault/<name>/admin/* — admin SPA static-file mount. Detailed tests live
|
|
222
|
+
// in admin-spa.test.ts (with a tmp dist dir); these pin the dispatch — i.e.
|
|
223
|
+
// the SPA layer fires *before* the per-vault dispatcher swallows the path.
|
|
224
|
+
// Per-vault mount (vault#252): the SPA used to live at /admin/* but is now
|
|
225
|
+
// scoped under each vault so it's reachable through hub's /vault/<name>/*
|
|
226
|
+
// proxy.
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
describe("/vault/<name>/admin/* SPA mount", () => {
|
|
230
|
+
test("/vault/<name>/admin/ never returns the per-vault dispatcher's 404 JSON", async () => {
|
|
231
|
+
// dist may or may not be built in CI; the dispatch check just asserts
|
|
232
|
+
// that we don't fall through to the catch-all. Both 200 (dist present)
|
|
233
|
+
// and 503 (dist absent) are valid SPA-layer responses.
|
|
234
|
+
createVault("work");
|
|
235
|
+
const req = new Request("http://localhost:1940/vault/work/admin/");
|
|
236
|
+
const res = await route(req, "/vault/work/admin/");
|
|
237
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
238
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("/vault/<name>/admin/tokens (client-routed path) reaches the SPA layer", async () => {
|
|
242
|
+
createVault("work");
|
|
243
|
+
const req = new Request("http://localhost:1940/vault/work/admin/tokens");
|
|
244
|
+
const res = await route(req, "/vault/work/admin/tokens");
|
|
245
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
246
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("/vault/<name>/admin fires the SPA layer even when the vault doesn't exist", async () => {
|
|
250
|
+
// Admin-spa dispatch sits *before* the per-vault config check — the SPA
|
|
251
|
+
// shell is static and surfaces its own auth-required state, so 404'ing
|
|
252
|
+
// here would just hide the operator's typo behind the SPA layer's own
|
|
253
|
+
// empty-state. Belt-and-braces: the SPA layer never reads vault config.
|
|
254
|
+
const req = new Request("http://localhost:1940/vault/ghost/admin/");
|
|
255
|
+
const res = await route(req, "/vault/ghost/admin/");
|
|
256
|
+
expect(res.status === 200 || res.status === 503).toBe(true);
|
|
257
|
+
expect(res.headers.get("content-type") ?? "").not.toContain("application/json");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("POST /vault/<name>/admin/ returns 405 (no admin SPA writes today)", async () => {
|
|
261
|
+
createVault("work");
|
|
262
|
+
const req = new Request("http://localhost:1940/vault/work/admin/", { method: "POST" });
|
|
263
|
+
const res = await route(req, "/vault/work/admin/");
|
|
264
|
+
expect(res.status).toBe(405);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("/vault/<name>/admin-foo does NOT match the admin mount", async () => {
|
|
268
|
+
// Falls through to the per-vault dispatcher; the auth wall there 401s
|
|
269
|
+
// before any route lookup runs, which is exactly the signal that the
|
|
270
|
+
// SPA layer didn't swallow this path. (Same shape as /api/notes below.)
|
|
271
|
+
createVault("work");
|
|
272
|
+
const req = new Request("http://localhost:1940/vault/work/admin-foo");
|
|
273
|
+
const res = await route(req, "/vault/work/admin-foo");
|
|
274
|
+
expect(res.status).toBe(401);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("origin-rooted /admin (legacy mount retired) returns 404", async () => {
|
|
278
|
+
// Pre-vault#252 the SPA was at /admin/*. Routing now lets that fall
|
|
279
|
+
// through to the catch-all — hub's directory page should link to
|
|
280
|
+
// /vault/<name>/admin instead.
|
|
281
|
+
const req = new Request("http://localhost:1940/admin/");
|
|
282
|
+
const res = await route(req, "/admin/");
|
|
283
|
+
expect(res.status).toBe(404);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("/vault/<name>/api/notes still reaches the per-vault API (not the SPA)", async () => {
|
|
287
|
+
// Regression: the SPA mount must not shadow the existing API surface.
|
|
288
|
+
// No auth here — the per-vault dispatcher 401s, which is exactly the
|
|
289
|
+
// signal that the request reached the API layer rather than the SPA.
|
|
290
|
+
createVault("work");
|
|
291
|
+
const req = new Request("http://localhost:1940/vault/work/api/notes");
|
|
292
|
+
const res = await route(req, "/vault/work/api/notes");
|
|
293
|
+
expect(res.status).toBe(401);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// /auth/status — public preflight discovery (issue #163). Tells first-contact
|
|
299
|
+
// clients which bearer format to use and surfaces auth-state bits the hub's
|
|
300
|
+
// post-exposure flow needs without locking us into any auth check.
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
describe("GET /auth/status (public auth preflight)", () => {
|
|
304
|
+
test("empty server: initialized=false, no vaults, no auth bits", async () => {
|
|
305
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
306
|
+
expect(res.status).toBe(200);
|
|
307
|
+
const body = (await res.json()) as {
|
|
308
|
+
initialized: boolean;
|
|
309
|
+
auth_modes: string[];
|
|
310
|
+
vaults: { name: string; url: string }[];
|
|
311
|
+
hasOwnerPassword: boolean;
|
|
312
|
+
hasTotp: boolean;
|
|
313
|
+
hasTokens: boolean | null;
|
|
314
|
+
};
|
|
315
|
+
expect(body.initialized).toBe(false);
|
|
316
|
+
expect(body.vaults).toEqual([]);
|
|
317
|
+
expect(body.auth_modes).toEqual(["pvt_token", "hub_jwt"]);
|
|
318
|
+
expect(body.hasOwnerPassword).toBe(false);
|
|
319
|
+
expect(body.hasTotp).toBe(false);
|
|
320
|
+
// No vaults means hasTokens collapses to false (not null), since there's
|
|
321
|
+
// no DB to fail on.
|
|
322
|
+
expect(body.hasTokens).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("vault with no tokens: initialized=true, hasTokens=false", async () => {
|
|
326
|
+
createVault("journal");
|
|
327
|
+
// getVaultStore opens (and creates) the SQLite file with the tokens
|
|
328
|
+
// table — without it, the probe falls into the "DB missing" branch and
|
|
329
|
+
// hasTokens stays false anyway, but we want the table to exist for the
|
|
330
|
+
// realistic case.
|
|
331
|
+
getVaultStore("journal");
|
|
332
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
333
|
+
expect(res.status).toBe(200);
|
|
334
|
+
const body = (await res.json()) as {
|
|
335
|
+
initialized: boolean;
|
|
336
|
+
vaults: { name: string; url: string }[];
|
|
337
|
+
hasTokens: boolean | null;
|
|
338
|
+
};
|
|
339
|
+
expect(body.initialized).toBe(true);
|
|
340
|
+
expect(body.vaults).toEqual([{ name: "journal", url: "/vault/journal" }]);
|
|
341
|
+
expect(body.hasTokens).toBe(false);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("vault with a token: hasTokens=true", async () => {
|
|
345
|
+
createVault("journal");
|
|
346
|
+
createAdminToken("journal");
|
|
347
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
348
|
+
const body = (await res.json()) as { hasTokens: boolean | null };
|
|
349
|
+
expect(body.hasTokens).toBe(true);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("multiple vaults are all listed; hasTokens=true if any has tokens", async () => {
|
|
353
|
+
createVault("journal");
|
|
354
|
+
createVault("work");
|
|
355
|
+
getVaultStore("journal");
|
|
356
|
+
createAdminToken("work");
|
|
357
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
358
|
+
const body = (await res.json()) as {
|
|
359
|
+
vaults: { name: string; url: string }[];
|
|
360
|
+
hasTokens: boolean | null;
|
|
361
|
+
};
|
|
362
|
+
expect(new Set(body.vaults.map((v) => v.name))).toEqual(new Set(["journal", "work"]));
|
|
363
|
+
expect(body.hasTokens).toBe(true);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test("hasTokens degrades to null when one vault has tokens and another DB is unreadable (#192)", async () => {
|
|
367
|
+
// The probe loop's whole point: a single failed DB read poisons the
|
|
368
|
+
// overall answer to `null`, even if an earlier vault already proved
|
|
369
|
+
// tokens exist. Otherwise an operator who locked one DB would see a
|
|
370
|
+
// misleading `true` and think auth-state is fully observable.
|
|
371
|
+
createVault("alpha");
|
|
372
|
+
createAdminToken("alpha");
|
|
373
|
+
createVault("beta");
|
|
374
|
+
// Replace beta's DB file with a non-SQLite blob; the readonly Database
|
|
375
|
+
// open throws at probe time. clearVaultStoreCache so beta's pre-opened
|
|
376
|
+
// handle (if any) doesn't shadow the on-disk corruption.
|
|
377
|
+
clearVaultStoreCache();
|
|
378
|
+
writeFileSync(vaultDbPath("beta"), "not-a-sqlite-file");
|
|
379
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
380
|
+
const body = (await res.json()) as { hasTokens: boolean | null };
|
|
381
|
+
expect(body.hasTokens).toBeNull();
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("owner password / TOTP set in global config surface as true", async () => {
|
|
385
|
+
createVault("journal");
|
|
386
|
+
writeGlobalConfig({
|
|
387
|
+
port: 1940,
|
|
388
|
+
owner_password_hash: "$2b$10$abcdefghijklmnopqrstuv",
|
|
389
|
+
totp_secret: "JBSWY3DPEHPK3PXP",
|
|
390
|
+
});
|
|
391
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
392
|
+
const body = (await res.json()) as { hasOwnerPassword: boolean; hasTotp: boolean };
|
|
393
|
+
expect(body.hasOwnerPassword).toBe(true);
|
|
394
|
+
expect(body.hasTotp).toBe(true);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("response never leaks secrets, hashes, descriptions, or token counts", async () => {
|
|
398
|
+
createVault("journal", "Private journal — must not appear in /auth/status");
|
|
399
|
+
createAdminToken("journal");
|
|
400
|
+
writeGlobalConfig({
|
|
401
|
+
port: 1940,
|
|
402
|
+
owner_password_hash: "$2b$10$verysecretpasswordhash",
|
|
403
|
+
totp_secret: "JBSWY3DPEHPK3PXP",
|
|
404
|
+
backup_codes: ["$2b$10$backup1", "$2b$10$backup2"],
|
|
405
|
+
});
|
|
406
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
407
|
+
const dump = JSON.stringify(await res.json());
|
|
408
|
+
expect(dump).not.toContain("Private journal");
|
|
409
|
+
expect(dump).not.toContain("$2b$10$verysecretpasswordhash");
|
|
410
|
+
expect(dump).not.toContain("JBSWY3DPEHPK3PXP");
|
|
411
|
+
expect(dump).not.toContain("backup");
|
|
412
|
+
// Token-count guard: even with one token created above, no integer count
|
|
413
|
+
// appears in the dump. `hasTokens` is the only token-derived field.
|
|
414
|
+
expect(dump).not.toMatch(/"tokenCount"/);
|
|
415
|
+
expect(dump).not.toMatch(/"token_count"/);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("ignores Authorization header (endpoint is public)", async () => {
|
|
419
|
+
const req = new Request("http://localhost:1940/auth/status", {
|
|
420
|
+
headers: { Authorization: "Bearer not-a-real-token" },
|
|
421
|
+
});
|
|
422
|
+
const res = await route(req, "/auth/status");
|
|
423
|
+
expect(res.status).toBe(200);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test("rejects non-GET methods (falls through to 404)", async () => {
|
|
427
|
+
const req = new Request("http://localhost:1940/auth/status", { method: "POST" });
|
|
428
|
+
const res = await route(req, "/auth/status");
|
|
429
|
+
expect(res.status).toBe(404);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("response includes CORS allow-origin so first-contact browser clients can read it", async () => {
|
|
433
|
+
const res = await route(new Request("http://localhost:1940/auth/status"), "/auth/status");
|
|
434
|
+
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
219
438
|
// ---------------------------------------------------------------------------
|
|
220
439
|
// Per-vault routing: /vault/<name>/... is the only URL shape for vault
|
|
221
440
|
// resources. Unscoped routes (/mcp, /api/*, /oauth/*) no longer exist.
|
|
@@ -1146,6 +1365,252 @@ describe("scope enforcement on /api/*", () => {
|
|
|
1146
1365
|
expect(res.status).toBe(401);
|
|
1147
1366
|
});
|
|
1148
1367
|
|
|
1368
|
+
// ----- tag-scoped tokens (patterns/tag-scoped-tokens.md) -----------------
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Mint a tag-scoped token. Mirrors `mintToken` above but threads
|
|
1372
|
+
* `scoped_tags` through to the token row so `resolveToken` returns the
|
|
1373
|
+
* allowlist on the AuthResult and routing.ts feeds it into the per-request
|
|
1374
|
+
* TagScopeCtx that handlers consult.
|
|
1375
|
+
*/
|
|
1376
|
+
function mintTagScopedToken(
|
|
1377
|
+
vaultName: string,
|
|
1378
|
+
scopes: string[],
|
|
1379
|
+
scopedTags: string[],
|
|
1380
|
+
): string {
|
|
1381
|
+
const store = getVaultStore(vaultName);
|
|
1382
|
+
const { fullToken } = generateToken();
|
|
1383
|
+
createToken(store.db, fullToken, {
|
|
1384
|
+
label: `test-tag-scoped`,
|
|
1385
|
+
permission: scopes.includes("vault:write") || scopes.includes("vault:admin") ? "full" : "read",
|
|
1386
|
+
scopes,
|
|
1387
|
+
scoped_tags: scopedTags,
|
|
1388
|
+
});
|
|
1389
|
+
return fullToken;
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
test("tag-scoped read token: GET /api/notes/:id 404s on out-of-scope note (no existence leak)", async () => {
|
|
1393
|
+
createVault("journal");
|
|
1394
|
+
const store = getVaultStore("journal");
|
|
1395
|
+
const inScope = await store.createNote("h", { tags: ["health"] });
|
|
1396
|
+
const outOfScope = await store.createNote("w", { tags: ["work"] });
|
|
1397
|
+
const token = mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1398
|
+
|
|
1399
|
+
const ok = `/vault/journal/api/notes/${inScope.id}`;
|
|
1400
|
+
expect((await route(authed(token, "GET", ok), ok)).status).toBe(200);
|
|
1401
|
+
|
|
1402
|
+
const notFound = `/vault/journal/api/notes/${outOfScope.id}`;
|
|
1403
|
+
expect((await route(authed(token, "GET", notFound), notFound)).status).toBe(404);
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
test("tag-scoped read token: GET /api/notes filters list to in-scope notes only", async () => {
|
|
1407
|
+
createVault("journal");
|
|
1408
|
+
const store = getVaultStore("journal");
|
|
1409
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1410
|
+
await store.createNote("w", { tags: ["work"] });
|
|
1411
|
+
const token = mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1412
|
+
|
|
1413
|
+
const path = "/vault/journal/api/notes";
|
|
1414
|
+
const res = await route(authed(token, "GET", path), path);
|
|
1415
|
+
expect(res.status).toBe(200);
|
|
1416
|
+
const body = (await res.json()) as { notes?: { tags: string[] }[] } | { tags: string[] }[];
|
|
1417
|
+
const list = Array.isArray(body) ? body : (body.notes ?? []);
|
|
1418
|
+
expect(list.every((n) => n.tags.includes("health"))).toBe(true);
|
|
1419
|
+
expect(list.length).toBeGreaterThanOrEqual(1);
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
test("tag-scoped read token: GET /api/tags filters to allowlisted tags", async () => {
|
|
1423
|
+
createVault("journal");
|
|
1424
|
+
const store = getVaultStore("journal");
|
|
1425
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1426
|
+
await store.createNote("w", { tags: ["work"] });
|
|
1427
|
+
const token = mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1428
|
+
|
|
1429
|
+
const path = "/vault/journal/api/tags";
|
|
1430
|
+
const res = await route(authed(token, "GET", path), path);
|
|
1431
|
+
expect(res.status).toBe(200);
|
|
1432
|
+
const body = (await res.json()) as { name: string }[];
|
|
1433
|
+
const names = body.map((t) => t.name);
|
|
1434
|
+
expect(names).toContain("health");
|
|
1435
|
+
expect(names).not.toContain("work");
|
|
1436
|
+
});
|
|
1437
|
+
|
|
1438
|
+
test("tag-scoped write token: POST /api/notes with in-scope tag → 201", async () => {
|
|
1439
|
+
createVault("journal");
|
|
1440
|
+
const store = getVaultStore("journal");
|
|
1441
|
+
await store.createNote("seed", { tags: ["health"] });
|
|
1442
|
+
const token = mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
|
|
1443
|
+
|
|
1444
|
+
const path = "/vault/journal/api/notes";
|
|
1445
|
+
const res = await route(
|
|
1446
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1447
|
+
method: "POST",
|
|
1448
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1449
|
+
body: JSON.stringify({ content: "ok", tags: ["health"] }),
|
|
1450
|
+
}),
|
|
1451
|
+
path,
|
|
1452
|
+
);
|
|
1453
|
+
expect(res.status).toBe(201);
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
test("tag-scoped write token: POST /api/notes outside allowlist → 403 tag_scope_violation", async () => {
|
|
1457
|
+
createVault("journal");
|
|
1458
|
+
const token = mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
|
|
1459
|
+
|
|
1460
|
+
const path = "/vault/journal/api/notes";
|
|
1461
|
+
const res = await route(
|
|
1462
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1463
|
+
method: "POST",
|
|
1464
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1465
|
+
body: JSON.stringify({ content: "denied", tags: ["work"] }),
|
|
1466
|
+
}),
|
|
1467
|
+
path,
|
|
1468
|
+
);
|
|
1469
|
+
expect(res.status).toBe(403);
|
|
1470
|
+
const body = (await res.json()) as { error_type?: string };
|
|
1471
|
+
expect(body.error_type).toBe("tag_scope_violation");
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
test("tag-scoped write token: DELETE on out-of-scope note → 404 (no leak)", async () => {
|
|
1475
|
+
createVault("journal");
|
|
1476
|
+
const store = getVaultStore("journal");
|
|
1477
|
+
const outOfScope = await store.createNote("w", { tags: ["work"] });
|
|
1478
|
+
const token = mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
|
|
1479
|
+
|
|
1480
|
+
const path = `/vault/journal/api/notes/${outOfScope.id}`;
|
|
1481
|
+
const res = await route(authed(token, "DELETE", path), path);
|
|
1482
|
+
expect(res.status).toBe(404);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
// ----- Q6: orphan-sub-tag fail-open ------------------------------------
|
|
1486
|
+
// patterns/tag-scoped-tokens.md §Storage: when a sub-tag has no declared
|
|
1487
|
+
// schema, the string-form root authorizes. Token allowlisted for `health`
|
|
1488
|
+
// must see `#health/food` even when no `_tags/health/food` schema exists.
|
|
1489
|
+
|
|
1490
|
+
test("tag-scoped read token: orphan sub-tag is in scope via string-form root", async () => {
|
|
1491
|
+
createVault("journal");
|
|
1492
|
+
const store = getVaultStore("journal");
|
|
1493
|
+
// No `_tags/health/food` schema is created — this is the orphan case.
|
|
1494
|
+
const orphan = await store.createNote("orphan", { tags: ["health/food"] });
|
|
1495
|
+
const token = mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1496
|
+
|
|
1497
|
+
const path = `/vault/journal/api/notes/${orphan.id}`;
|
|
1498
|
+
const res = await route(authed(token, "GET", path), path);
|
|
1499
|
+
expect(res.status).toBe(200);
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
test("tag-scoped write token: orphan sub-tag write succeeds via string-form root", async () => {
|
|
1503
|
+
createVault("journal");
|
|
1504
|
+
const token = mintTagScopedToken("journal", ["vault:read", "vault:write"], ["health"]);
|
|
1505
|
+
|
|
1506
|
+
const path = "/vault/journal/api/notes";
|
|
1507
|
+
const res = await route(
|
|
1508
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1509
|
+
method: "POST",
|
|
1510
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
1511
|
+
body: JSON.stringify({ content: "ok", tags: ["health/food"] }),
|
|
1512
|
+
}),
|
|
1513
|
+
path,
|
|
1514
|
+
);
|
|
1515
|
+
expect(res.status).toBe(201);
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
// ----- Q5: tag-delete dependency check ---------------------------------
|
|
1519
|
+
// Deleting a tag referenced by any token's scoped_tags would silently
|
|
1520
|
+
// orphan the token's allowlist; fail closed with 409 + referenced_by.
|
|
1521
|
+
|
|
1522
|
+
test("DELETE /api/tags/:name → 409 when a tag-scoped token references it", async () => {
|
|
1523
|
+
createVault("journal");
|
|
1524
|
+
const store = getVaultStore("journal");
|
|
1525
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1526
|
+
// Mint a tag-scoped token that references `health`, then try to delete
|
|
1527
|
+
// `health` with an admin token.
|
|
1528
|
+
mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1529
|
+
const admin = createAdminToken("journal");
|
|
1530
|
+
|
|
1531
|
+
const path = "/vault/journal/api/tags/health";
|
|
1532
|
+
const res = await route(authed(admin, "DELETE", path), path);
|
|
1533
|
+
expect(res.status).toBe(409);
|
|
1534
|
+
const body = (await res.json()) as {
|
|
1535
|
+
error_type?: string;
|
|
1536
|
+
tag?: string;
|
|
1537
|
+
referenced_by?: { id: string; label: string }[];
|
|
1538
|
+
};
|
|
1539
|
+
expect(body.error_type).toBe("tag_in_use_by_tokens");
|
|
1540
|
+
expect(body.tag).toBe("health");
|
|
1541
|
+
expect(body.referenced_by?.length).toBe(1);
|
|
1542
|
+
expect(body.referenced_by?.[0]?.label).toBe("test-tag-scoped");
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
test("DELETE /api/tags/:name → 200 when no tag-scoped token references it", async () => {
|
|
1546
|
+
createVault("journal");
|
|
1547
|
+
const store = getVaultStore("journal");
|
|
1548
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1549
|
+
const admin = createAdminToken("journal");
|
|
1550
|
+
|
|
1551
|
+
const path = "/vault/journal/api/tags/health";
|
|
1552
|
+
const res = await route(authed(admin, "DELETE", path), path);
|
|
1553
|
+
expect(res.status).toBe(200);
|
|
1554
|
+
});
|
|
1555
|
+
|
|
1556
|
+
test("POST /api/tags/:name/rename → 409 when a tag-scoped token references the old name", async () => {
|
|
1557
|
+
createVault("journal");
|
|
1558
|
+
const store = getVaultStore("journal");
|
|
1559
|
+
await store.createNote("h", { tags: ["health"] });
|
|
1560
|
+
mintTagScopedToken("journal", ["vault:read"], ["health"]);
|
|
1561
|
+
const admin = createAdminToken("journal");
|
|
1562
|
+
|
|
1563
|
+
const path = "/vault/journal/api/tags/health/rename";
|
|
1564
|
+
const res = await route(
|
|
1565
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
1568
|
+
body: JSON.stringify({ new_name: "wellness" }),
|
|
1569
|
+
}),
|
|
1570
|
+
path,
|
|
1571
|
+
);
|
|
1572
|
+
expect(res.status).toBe(409);
|
|
1573
|
+
const body = (await res.json()) as {
|
|
1574
|
+
error_type?: string;
|
|
1575
|
+
tag?: string;
|
|
1576
|
+
referenced_by?: { id: string; label: string }[];
|
|
1577
|
+
};
|
|
1578
|
+
expect(body.error_type).toBe("tag_in_use_by_tokens");
|
|
1579
|
+
expect(body.tag).toBe("health");
|
|
1580
|
+
expect(body.referenced_by?.length).toBe(1);
|
|
1581
|
+
|
|
1582
|
+
// Tag was not renamed.
|
|
1583
|
+
expect((await store.listTags()).find((t) => t.name === "health")).toBeTruthy();
|
|
1584
|
+
expect((await store.listTags()).find((t) => t.name === "wellness")).toBeFalsy();
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
test("POST /api/tags/merge → 409 when a tag-scoped token references a source", async () => {
|
|
1588
|
+
createVault("journal");
|
|
1589
|
+
const store = getVaultStore("journal");
|
|
1590
|
+
await store.createNote("a", { tags: ["alpha"] });
|
|
1591
|
+
await store.createNote("b", { tags: ["beta"] });
|
|
1592
|
+
mintTagScopedToken("journal", ["vault:read"], ["alpha"]);
|
|
1593
|
+
const admin = createAdminToken("journal");
|
|
1594
|
+
|
|
1595
|
+
const path = "/vault/journal/api/tags/merge";
|
|
1596
|
+
const res = await route(
|
|
1597
|
+
new Request(`http://localhost:1940${path}`, {
|
|
1598
|
+
method: "POST",
|
|
1599
|
+
headers: { authorization: `Bearer ${admin}`, "content-type": "application/json" },
|
|
1600
|
+
body: JSON.stringify({ sources: ["alpha"], target: "beta" }),
|
|
1601
|
+
}),
|
|
1602
|
+
path,
|
|
1603
|
+
);
|
|
1604
|
+
expect(res.status).toBe(409);
|
|
1605
|
+
const body = (await res.json()) as {
|
|
1606
|
+
error_type?: string;
|
|
1607
|
+
referenced_by?: { source: string; tokens: { id: string; label: string }[] }[];
|
|
1608
|
+
};
|
|
1609
|
+
expect(body.error_type).toBe("tag_in_use_by_tokens");
|
|
1610
|
+
expect(body.referenced_by?.[0]?.source).toBe("alpha");
|
|
1611
|
+
expect(body.referenced_by?.[0]?.tokens?.length).toBe(1);
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1149
1614
|
test("CLI --read equivalent token (permission='read', scopes=[vault:read]) is read-only at the HTTP boundary", async () => {
|
|
1150
1615
|
// This pins the end-to-end contract: a token minted the way
|
|
1151
1616
|
// `parachute-vault tokens create --read` mints them actually refuses
|