@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.ts
CHANGED
|
@@ -40,20 +40,24 @@ import {
|
|
|
40
40
|
authenticateVaultRequest,
|
|
41
41
|
authenticateGlobalRequest,
|
|
42
42
|
extractApiKey,
|
|
43
|
-
requireScope,
|
|
44
43
|
} from "./auth.ts";
|
|
45
|
-
import { SCOPE_ADMIN, scopeForMethod } from "./scopes.ts";
|
|
44
|
+
import { hasScopeForVault, SCOPE_ADMIN, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
46
45
|
import { getVaultStore } from "./vault-store.ts";
|
|
47
46
|
import { handleScopedMcp } from "./mcp-http.ts";
|
|
47
|
+
import { defaultAdminSpaDistDir, isAdminSpaPath, serveAdminSpa } from "./admin-spa.ts";
|
|
48
48
|
import {
|
|
49
49
|
handleNotes,
|
|
50
50
|
handleTags,
|
|
51
|
+
handleNoteSchemas,
|
|
51
52
|
handleFindPath,
|
|
52
53
|
handleVault,
|
|
53
54
|
handleUnresolvedWikilinks,
|
|
54
55
|
handleStorage,
|
|
55
56
|
handleViewNote,
|
|
57
|
+
type TagScopeCtx,
|
|
56
58
|
} from "./routes.ts";
|
|
59
|
+
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
60
|
+
import { handleTokens } from "./tokens-routes.ts";
|
|
57
61
|
import {
|
|
58
62
|
handleProtectedResource,
|
|
59
63
|
handleAuthorizationServer,
|
|
@@ -64,6 +68,8 @@ import {
|
|
|
64
68
|
getBaseUrl,
|
|
65
69
|
} from "./oauth.ts";
|
|
66
70
|
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
71
|
+
import { buildAuthStatus } from "./auth-status.ts";
|
|
72
|
+
import { getAuthorizeRateLimiter } from "./owner-auth.ts";
|
|
67
73
|
|
|
68
74
|
/**
|
|
69
75
|
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
@@ -100,15 +106,15 @@ async function withMcpChallenge(
|
|
|
100
106
|
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
101
107
|
* requests still get public notes.
|
|
102
108
|
*/
|
|
103
|
-
function isViewAuthenticated(
|
|
109
|
+
async function isViewAuthenticated(
|
|
104
110
|
req: Request,
|
|
105
111
|
vaultConfig: VaultConfig | null,
|
|
106
112
|
vaultDb?: import("bun:sqlite").Database,
|
|
107
|
-
): boolean {
|
|
113
|
+
): Promise<boolean> {
|
|
108
114
|
if (!vaultConfig) return false;
|
|
109
115
|
const key = extractApiKey(req);
|
|
110
116
|
if (!key) return false;
|
|
111
|
-
const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
117
|
+
const auth = await authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
112
118
|
return !("error" in auth);
|
|
113
119
|
}
|
|
114
120
|
|
|
@@ -188,7 +194,7 @@ export async function route(
|
|
|
188
194
|
/^\/\.well-known\/oauth-protected-resource\/vault\/([^/]+)(?:\/mcp)?$/,
|
|
189
195
|
);
|
|
190
196
|
if (protectedResourceInsert) {
|
|
191
|
-
const vaultName = protectedResourceInsert[1]
|
|
197
|
+
const vaultName = protectedResourceInsert[1]!;
|
|
192
198
|
if (!readVaultConfig(vaultName)) {
|
|
193
199
|
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
194
200
|
}
|
|
@@ -198,7 +204,7 @@ export async function route(
|
|
|
198
204
|
/^\/\.well-known\/oauth-authorization-server\/vault\/([^/]+)(?:\/mcp)?$/,
|
|
199
205
|
);
|
|
200
206
|
if (authServerInsert) {
|
|
201
|
-
const vaultName = authServerInsert[1]
|
|
207
|
+
const vaultName = authServerInsert[1]!;
|
|
202
208
|
if (!readVaultConfig(vaultName)) {
|
|
203
209
|
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
204
210
|
}
|
|
@@ -210,7 +216,7 @@ export async function route(
|
|
|
210
216
|
// ---------------------------------------------------------------------
|
|
211
217
|
|
|
212
218
|
if (path === "/health") {
|
|
213
|
-
const auth = authenticateGlobalRequest(req);
|
|
219
|
+
const auth = await authenticateGlobalRequest(req);
|
|
214
220
|
if ("error" in auth) {
|
|
215
221
|
return Response.json({ status: "ok" });
|
|
216
222
|
}
|
|
@@ -229,9 +235,43 @@ export async function route(
|
|
|
229
235
|
return Response.json({ vaults: listVaults() });
|
|
230
236
|
}
|
|
231
237
|
|
|
238
|
+
// Public auth-state probe. Tells a first-contact client whether the
|
|
239
|
+
// server has any vaults yet, which bearer formats it accepts, and
|
|
240
|
+
// whether owner-password / TOTP / tokens are configured. Replaces
|
|
241
|
+
// hub's out-of-process filesystem snoop (parachute-hub#86 follow-up).
|
|
242
|
+
// No auth, GET-only, no token counts — `hasTokens` is a yes/no signal
|
|
243
|
+
// only, with `null` for "DB read failed."
|
|
244
|
+
//
|
|
245
|
+
// Gated on `discovery: disabled` like /vaults/list — both leak vault
|
|
246
|
+
// existence (the `vaults` array names them), so an operator who hides
|
|
247
|
+
// one wants both hidden (#191).
|
|
248
|
+
if (path === "/auth/status" && req.method === "GET") {
|
|
249
|
+
if (readGlobalConfig().discovery === "disabled") {
|
|
250
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
251
|
+
}
|
|
252
|
+
return Response.json(buildAuthStatus(), {
|
|
253
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
|
|
258
|
+
// file serving only (index.html + Vite asset bundle); no auth at this
|
|
259
|
+
// seam since the bundle reveals nothing privileged. The SPA's data
|
|
260
|
+
// fetches land on existing per-vault routes that enforce
|
|
261
|
+
// `vault:<name>:read` / `:admin`. GET-only — POSTs to `.../admin/*` aren't
|
|
262
|
+
// a thing the SPA bundle exposes; rejecting them here keeps surprises
|
|
263
|
+
// out. Must fire BEFORE the per-vault dispatch below or the auth wall
|
|
264
|
+
// there would short-circuit the static-asset response.
|
|
265
|
+
if (isAdminSpaPath(path)) {
|
|
266
|
+
if (req.method !== "GET") {
|
|
267
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
268
|
+
}
|
|
269
|
+
return serveAdminSpa(defaultAdminSpaDistDir(), path);
|
|
270
|
+
}
|
|
271
|
+
|
|
232
272
|
// Authenticated vault metadata list.
|
|
233
273
|
if (path === "/vaults" && req.method === "GET") {
|
|
234
|
-
const auth = authenticateGlobalRequest(req);
|
|
274
|
+
const auth = await authenticateGlobalRequest(req);
|
|
235
275
|
if ("error" in auth) return auth.error;
|
|
236
276
|
const names = listVaults();
|
|
237
277
|
const vaults = names.map((name) => {
|
|
@@ -254,7 +294,7 @@ export async function route(
|
|
|
254
294
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
255
295
|
}
|
|
256
296
|
|
|
257
|
-
const vaultName = vaultMatch[1]
|
|
297
|
+
const vaultName = vaultMatch[1]!;
|
|
258
298
|
const subpath = vaultMatch[2] ?? "";
|
|
259
299
|
|
|
260
300
|
const vaultConfig = readVaultConfig(vaultName);
|
|
@@ -266,7 +306,7 @@ export async function route(
|
|
|
266
306
|
// convenience for published-note URLs that predate the /view/ path).
|
|
267
307
|
const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
|
|
268
308
|
if (vaultPublicMatch && req.method === "GET") {
|
|
269
|
-
const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
|
|
309
|
+
const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]!}`, req.url);
|
|
270
310
|
dest.search = new URL(req.url).search;
|
|
271
311
|
return Response.redirect(dest.toString(), 301);
|
|
272
312
|
}
|
|
@@ -277,8 +317,8 @@ export async function route(
|
|
|
277
317
|
const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
|
|
278
318
|
if (vaultViewMatch && req.method === "GET") {
|
|
279
319
|
const store = getVaultStore(vaultName);
|
|
280
|
-
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
281
|
-
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
|
|
320
|
+
const authenticated = await isViewAuthenticated(req, vaultConfig, store.db);
|
|
321
|
+
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]!), {
|
|
282
322
|
authenticated,
|
|
283
323
|
publishedTag: vaultConfig.published_tag,
|
|
284
324
|
});
|
|
@@ -308,6 +348,10 @@ export async function route(
|
|
|
308
348
|
clientIp,
|
|
309
349
|
ownerPasswordHash,
|
|
310
350
|
totpSecret,
|
|
351
|
+
// Per-vault rate-limit instance — prevents brute-force traffic on
|
|
352
|
+
// one vault's consent flow from locking out IPs trying to authorize
|
|
353
|
+
// against an unrelated vault (#93).
|
|
354
|
+
rateLimiter: getAuthorizeRateLimiter(vaultConfig.name),
|
|
311
355
|
});
|
|
312
356
|
}
|
|
313
357
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
@@ -350,14 +394,14 @@ export async function route(
|
|
|
350
394
|
// (worker intervals, TTLs, retention policy) but are still configuration
|
|
351
395
|
// an attacker could use to map the deployment. `vault:admin` keeps the
|
|
352
396
|
// hub's loopback workflow intact while locking out read-only tokens.
|
|
353
|
-
const configAuth = authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
|
|
397
|
+
const configAuth = await authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
|
|
354
398
|
if ("error" in configAuth) return configAuth.error;
|
|
355
|
-
if (!
|
|
399
|
+
if (!hasScopeForVault(configAuth.scopes, vaultName, "admin")) {
|
|
356
400
|
return Response.json(
|
|
357
401
|
{
|
|
358
402
|
error: "Forbidden",
|
|
359
403
|
error_type: "insufficient_scope",
|
|
360
|
-
message: `This endpoint requires the '${SCOPE_ADMIN}' scope.`,
|
|
404
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
361
405
|
required_scope: SCOPE_ADMIN,
|
|
362
406
|
granted_scopes: configAuth.scopes,
|
|
363
407
|
},
|
|
@@ -385,7 +429,7 @@ export async function route(
|
|
|
385
429
|
// ---------------------------------------------------------------------
|
|
386
430
|
|
|
387
431
|
const store = getVaultStore(vaultName);
|
|
388
|
-
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
432
|
+
const auth = await authenticateVaultRequest(req, vaultConfig, store.db);
|
|
389
433
|
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
390
434
|
if ("error" in auth) {
|
|
391
435
|
return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
|
|
@@ -411,6 +455,31 @@ export async function route(
|
|
|
411
455
|
});
|
|
412
456
|
}
|
|
413
457
|
|
|
458
|
+
// /tokens — admin-gated REST surface for minting/listing/revoking pvt_*
|
|
459
|
+
// tokens. Admin gate applies to every method (POST/GET/DELETE) since both
|
|
460
|
+
// the plaintext mint and the metadata listing are sensitive — knowing
|
|
461
|
+
// labels and scopes of issued tokens leaks the deployment's auth shape.
|
|
462
|
+
// The handler's POST path applies a strict subset check on requested
|
|
463
|
+
// scopes via `validateMintedScopes` (defense-in-depth: cross-vault and
|
|
464
|
+
// privilege-escalation rejections survive even if this gate is later
|
|
465
|
+
// relaxed).
|
|
466
|
+
const tokensMatch = subpath.match(/^\/tokens(\/.*)?$/);
|
|
467
|
+
if (tokensMatch) {
|
|
468
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
469
|
+
return Response.json(
|
|
470
|
+
{
|
|
471
|
+
error: "Forbidden",
|
|
472
|
+
error_type: "insufficient_scope",
|
|
473
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
474
|
+
required_scope: SCOPE_ADMIN,
|
|
475
|
+
granted_scopes: auth.scopes,
|
|
476
|
+
},
|
|
477
|
+
{ status: 403 },
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
|
|
481
|
+
}
|
|
482
|
+
|
|
414
483
|
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
415
484
|
if (!apiMatch) {
|
|
416
485
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
@@ -418,14 +487,17 @@ export async function route(
|
|
|
418
487
|
|
|
419
488
|
// REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
|
|
420
489
|
// POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
490
|
+
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
491
|
+
// `vault:<vaultName>:<verb>` from hub JWTs) are handled by
|
|
492
|
+
// `hasScopeForVault`.
|
|
493
|
+
const requiredVerb = verbForMethod(req.method);
|
|
494
|
+
if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
|
|
495
|
+
const requiredApiScope = scopeForMethod(req.method);
|
|
424
496
|
return Response.json(
|
|
425
497
|
{
|
|
426
498
|
error: "Forbidden",
|
|
427
499
|
error_type: "insufficient_scope",
|
|
428
|
-
message: `This endpoint requires the '${requiredApiScope}' scope.`,
|
|
500
|
+
message: `This endpoint requires the '${requiredApiScope}' scope (or '${requiredApiScope.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
429
501
|
required_scope: requiredApiScope,
|
|
430
502
|
granted_scopes: auth.scopes,
|
|
431
503
|
},
|
|
@@ -435,9 +507,21 @@ export async function route(
|
|
|
435
507
|
|
|
436
508
|
const apiPath = apiMatch[1] ?? "";
|
|
437
509
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
510
|
+
// Tag-scoped tokens (patterns/tag-scoped-tokens.md): expand the token's
|
|
511
|
+
// root-tag allowlist into `{root} ∪ descendants(root)` once per request,
|
|
512
|
+
// so handlers can intersect against the note's tag set without re-walking
|
|
513
|
+
// the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
|
|
514
|
+
// for unscoped tokens — the no-op fast path leaves the pre-tag-scope
|
|
515
|
+
// behavior identical.
|
|
516
|
+
const tagScope: TagScopeCtx = {
|
|
517
|
+
allowed: await expandTokenTagScope(store, auth.scoped_tags),
|
|
518
|
+
raw: auth.scoped_tags,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
|
|
522
|
+
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
523
|
+
if (apiPath.startsWith("/note-schemas")) return handleNoteSchemas(req, store, apiPath.slice(13), tagScope);
|
|
524
|
+
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
441
525
|
if (apiPath === "/vault") {
|
|
442
526
|
return handleVault(req, store, vaultConfig, () => {
|
|
443
527
|
writeVaultConfig(vaultConfig);
|
package/src/scopes.test.ts
CHANGED
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
parseScopeFlags,
|
|
14
14
|
resolveCreateTokenFlags,
|
|
15
15
|
hasScope,
|
|
16
|
+
hasScopeForVault,
|
|
17
|
+
findBroadVaultScopes,
|
|
16
18
|
scopeForMethod,
|
|
19
|
+
verbForMethod,
|
|
17
20
|
legacyPermissionToScopes,
|
|
18
21
|
serializeScopes,
|
|
19
22
|
} from "./scopes.ts";
|
|
@@ -34,10 +37,10 @@ describe("parseScopes", () => {
|
|
|
34
37
|
]);
|
|
35
38
|
});
|
|
36
39
|
|
|
37
|
-
test("
|
|
38
|
-
expect(parseScopes("vault:journal:read")).toEqual([
|
|
40
|
+
test("preserves vault:<name>:<verb> narrowed shape verbatim", () => {
|
|
41
|
+
expect(parseScopes("vault:journal:read")).toEqual(["vault:journal:read"]);
|
|
39
42
|
expect(parseScopes("vault:journal:write vault:work:admin")).toEqual([
|
|
40
|
-
|
|
43
|
+
"vault:journal:write", "vault:work:admin",
|
|
41
44
|
]);
|
|
42
45
|
});
|
|
43
46
|
|
|
@@ -46,12 +49,13 @@ describe("parseScopes", () => {
|
|
|
46
49
|
expect(parseScopes("vault:unknown:frob")).toEqual(["vault:unknown:frob"]);
|
|
47
50
|
});
|
|
48
51
|
|
|
49
|
-
test("empty name segment
|
|
52
|
+
test("empty name segment is treated as unrecognized (vault::read stays literal)", () => {
|
|
50
53
|
// Guard against a hand-crafted DB row with `vault::read` satisfying a
|
|
51
54
|
// `vault:read` check by accident. Only reachable via direct DB write,
|
|
52
55
|
// not API input, but the parser stays honest.
|
|
53
56
|
expect(parseScopes("vault::read")).toEqual(["vault::read"]);
|
|
54
57
|
expect(hasScope(parseScopes("vault::read"), SCOPE_READ)).toBe(false);
|
|
58
|
+
expect(hasScopeForVault(parseScopes("vault::read"), "", "read")).toBe(false);
|
|
55
59
|
});
|
|
56
60
|
});
|
|
57
61
|
|
|
@@ -91,25 +95,79 @@ describe("hasScope — inheritance admin ⊇ write ⊇ read", () => {
|
|
|
91
95
|
expect(hasScope(["profile"], "email")).toBe(false);
|
|
92
96
|
expect(hasScope([SCOPE_ADMIN], "profile")).toBe(false);
|
|
93
97
|
});
|
|
98
|
+
|
|
99
|
+
test("narrowed grant satisfies broad query (more-specific is at-least-as-strong)", () => {
|
|
100
|
+
expect(hasScope(["vault:work:write"], SCOPE_READ)).toBe(true);
|
|
101
|
+
expect(hasScope(["vault:work:write"], SCOPE_WRITE)).toBe(true);
|
|
102
|
+
expect(hasScope(["vault:work:write"], SCOPE_ADMIN)).toBe(false);
|
|
103
|
+
expect(hasScope(["vault:work:admin"], SCOPE_ADMIN)).toBe(true);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("broad query against narrowed query returns false (use hasScopeForVault for that)", () => {
|
|
107
|
+
// hasScope refuses to answer narrowed queries — they need a vault context.
|
|
108
|
+
expect(hasScope([SCOPE_ADMIN], "vault:work:read")).toBe(false);
|
|
109
|
+
expect(hasScope(["vault:work:write"], "vault:work:read")).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("hasScopeForVault — per-vault matching with inheritance", () => {
|
|
114
|
+
test("broad grant satisfies any vault (caller pins via DB lookup / aud check)", () => {
|
|
115
|
+
expect(hasScopeForVault([SCOPE_READ], "work", "read")).toBe(true);
|
|
116
|
+
expect(hasScopeForVault([SCOPE_WRITE], "work", "read")).toBe(true);
|
|
117
|
+
expect(hasScopeForVault([SCOPE_WRITE], "work", "write")).toBe(true);
|
|
118
|
+
expect(hasScopeForVault([SCOPE_ADMIN], "anything", "admin")).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("narrowed grant satisfies only the matching vault", () => {
|
|
122
|
+
expect(hasScopeForVault(["vault:work:read"], "work", "read")).toBe(true);
|
|
123
|
+
expect(hasScopeForVault(["vault:work:read"], "personal", "read")).toBe(false);
|
|
124
|
+
expect(hasScopeForVault(["vault:work:write"], "work", "read")).toBe(true);
|
|
125
|
+
expect(hasScopeForVault(["vault:work:admin"], "work", "write")).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("narrowed grant does NOT satisfy a higher verb on its vault", () => {
|
|
129
|
+
expect(hasScopeForVault(["vault:work:read"], "work", "write")).toBe(false);
|
|
130
|
+
expect(hasScopeForVault(["vault:work:write"], "work", "admin")).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("mixed grant — narrowed for one vault, broad fallback nowhere", () => {
|
|
134
|
+
expect(hasScopeForVault(["vault:work:write"], "personal", "read")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("empty grant fails everywhere", () => {
|
|
138
|
+
expect(hasScopeForVault([], "any", "read")).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("findBroadVaultScopes", () => {
|
|
143
|
+
test("returns broad vault scopes only", () => {
|
|
144
|
+
expect(findBroadVaultScopes([SCOPE_READ, SCOPE_WRITE])).toEqual([SCOPE_READ, SCOPE_WRITE]);
|
|
145
|
+
expect(findBroadVaultScopes(["vault:work:read"])).toEqual([]);
|
|
146
|
+
expect(findBroadVaultScopes([SCOPE_READ, "vault:work:write", "profile"])).toEqual([SCOPE_READ]);
|
|
147
|
+
expect(findBroadVaultScopes([])).toEqual([]);
|
|
148
|
+
});
|
|
94
149
|
});
|
|
95
150
|
|
|
96
|
-
describe("scopeForMethod", () => {
|
|
97
|
-
test("read methods → vault:read", () => {
|
|
151
|
+
describe("scopeForMethod / verbForMethod", () => {
|
|
152
|
+
test("read methods → vault:read / read", () => {
|
|
98
153
|
expect(scopeForMethod("GET")).toBe(SCOPE_READ);
|
|
99
154
|
expect(scopeForMethod("HEAD")).toBe(SCOPE_READ);
|
|
100
155
|
expect(scopeForMethod("OPTIONS")).toBe(SCOPE_READ);
|
|
101
156
|
expect(scopeForMethod("get")).toBe(SCOPE_READ); // case-insensitive
|
|
157
|
+
expect(verbForMethod("GET")).toBe("read");
|
|
102
158
|
});
|
|
103
159
|
|
|
104
|
-
test("write methods → vault:write", () => {
|
|
160
|
+
test("write methods → vault:write / write", () => {
|
|
105
161
|
expect(scopeForMethod("POST")).toBe(SCOPE_WRITE);
|
|
106
162
|
expect(scopeForMethod("PATCH")).toBe(SCOPE_WRITE);
|
|
107
163
|
expect(scopeForMethod("PUT")).toBe(SCOPE_WRITE);
|
|
108
164
|
expect(scopeForMethod("DELETE")).toBe(SCOPE_WRITE);
|
|
165
|
+
expect(verbForMethod("POST")).toBe("write");
|
|
109
166
|
});
|
|
110
167
|
|
|
111
|
-
test("unknown method falls back to
|
|
168
|
+
test("unknown method falls back to write (default-deny)", () => {
|
|
112
169
|
expect(scopeForMethod("TRACE")).toBe(SCOPE_WRITE);
|
|
170
|
+
expect(verbForMethod("TRACE")).toBe("write");
|
|
113
171
|
});
|
|
114
172
|
});
|
|
115
173
|
|
package/src/scopes.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scope primitives for
|
|
2
|
+
* Scope primitives for vault enforcement.
|
|
3
3
|
*
|
|
4
|
-
* Tokens carry OAuth-standard whitespace-separated scopes.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* (
|
|
8
|
-
*
|
|
4
|
+
* Tokens carry OAuth-standard whitespace-separated scopes. Two shapes coexist:
|
|
5
|
+
*
|
|
6
|
+
* - **Broad** `vault:<verb>` — used by `pvt_*` tokens, which are vault-pinned
|
|
7
|
+
* by storage (each vault has its own tokens DB; a token only resolves
|
|
8
|
+
* against the vault that minted it).
|
|
9
|
+
* - **Narrowed** `vault:<name>:<verb>` — used by hub-issued JWTs, which are
|
|
10
|
+
* not pinned by storage and so MUST name the resource they grant access
|
|
11
|
+
* to. Hub JWTs carrying broad `vault:<verb>` are rejected at validation
|
|
12
|
+
* (see `authenticateHubJwt`).
|
|
13
|
+
*
|
|
14
|
+
* Inheritance is `admin ⊇ write ⊇ read` for both shapes. `hasScopeForVault`
|
|
15
|
+
* resolves a (vault, verb) request: broad grants satisfy any vault (the
|
|
16
|
+
* caller has already pinned the vault via DB lookup), narrowed grants
|
|
17
|
+
* satisfy only the matching vault.
|
|
9
18
|
*
|
|
10
19
|
* Legacy back-compat: tokens without any `vault:*` scope — but with a
|
|
11
20
|
* 0.2.x-era `permission = "full" | "read"` — are mapped to the appropriate
|
|
@@ -21,14 +30,40 @@ export const SCOPE_ADMIN = "vault:admin" as const;
|
|
|
21
30
|
export const VAULT_SCOPES = [SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN] as const;
|
|
22
31
|
export type VaultScope = (typeof VAULT_SCOPES)[number];
|
|
23
32
|
|
|
33
|
+
/** The verb component of a vault scope — `read`, `write`, or `admin`. */
|
|
34
|
+
export type VaultVerb = "read" | "write" | "admin";
|
|
35
|
+
|
|
36
|
+
const VERB_RANK: Record<VaultVerb, number> = { read: 0, write: 1, admin: 2 };
|
|
37
|
+
|
|
38
|
+
function isVerb(s: string): s is VaultVerb {
|
|
39
|
+
return s === "read" || s === "write" || s === "admin";
|
|
40
|
+
}
|
|
41
|
+
|
|
24
42
|
/**
|
|
25
|
-
*
|
|
43
|
+
* Decompose a scope string into `{ vault?, verb }` if it's a recognized vault
|
|
44
|
+
* scope; return `null` otherwise. Recognizes both broad (`vault:<verb>`) and
|
|
45
|
+
* narrowed (`vault:<name>:<verb>`) shapes. The empty-name case
|
|
46
|
+
* (`vault::read`) is rejected — a hand-crafted DB row with that shape must
|
|
47
|
+
* not satisfy any vault scope check.
|
|
48
|
+
*/
|
|
49
|
+
function decomposeVaultScope(scope: string): { vault: string | null; verb: VaultVerb } | null {
|
|
50
|
+
const parts = scope.split(":");
|
|
51
|
+
if (parts.length === 2 && parts[0] === "vault" && isVerb(parts[1]!)) {
|
|
52
|
+
return { vault: null, verb: parts[1]! as VaultVerb };
|
|
53
|
+
}
|
|
54
|
+
if (parts.length === 3 && parts[0] === "vault" && parts[1]!.length > 0 && isVerb(parts[2]!)) {
|
|
55
|
+
return { vault: parts[1]!, verb: parts[2]! as VaultVerb };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse a whitespace-separated scope string into a scope list.
|
|
26
62
|
*
|
|
27
|
-
* Normalization:
|
|
28
63
|
* - Empty / null → []
|
|
29
64
|
* - Trim + split on any whitespace
|
|
30
|
-
* - `vault:<
|
|
31
|
-
*
|
|
65
|
+
* - Both `vault:<verb>` and `vault:<name>:<verb>` shapes are preserved
|
|
66
|
+
* verbatim; `hasScope` / `hasScopeForVault` decide what each satisfies.
|
|
32
67
|
* - Unrecognized scopes are preserved as-is (they just won't match anything)
|
|
33
68
|
*/
|
|
34
69
|
export function parseScopes(raw: string | null | undefined): string[] {
|
|
@@ -36,38 +71,65 @@ export function parseScopes(raw: string | null | undefined): string[] {
|
|
|
36
71
|
return raw
|
|
37
72
|
.split(/\s+/)
|
|
38
73
|
.map((s) => s.trim())
|
|
39
|
-
.filter(Boolean)
|
|
40
|
-
.map((s) => normalizeScope(s));
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function normalizeScope(scope: string): string {
|
|
44
|
-
// `vault:<name>:<verb>` → `vault:<verb>` (synonym collapse). Reject an empty
|
|
45
|
-
// name segment (`vault::read`) — preserve it as-is so it can't accidentally
|
|
46
|
-
// satisfy a `vault:read` check. Only reachable via direct DB write, but the
|
|
47
|
-
// one-liner keeps the parser honest.
|
|
48
|
-
const parts = scope.split(":");
|
|
49
|
-
if (parts.length === 3 && parts[0] === "vault" && parts[1].length > 0) {
|
|
50
|
-
const verb = parts[2];
|
|
51
|
-
if (verb === "read" || verb === "write" || verb === "admin") {
|
|
52
|
-
return `vault:${verb}`;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return scope;
|
|
74
|
+
.filter(Boolean);
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
78
|
+
* Broad-query check: does `granted` satisfy `required` (e.g. `vault:read`)?
|
|
79
|
+
*
|
|
80
|
+
* Used by code paths that don't have a specific vault in hand — JWT claim
|
|
81
|
+
* inspection, MCP tool list filtering inside a session that's already pinned
|
|
82
|
+
* to one vault, the legacy permission-derivation path. For per-request
|
|
83
|
+
* routing where the URL names a vault, prefer `hasScopeForVault`.
|
|
84
|
+
*
|
|
85
|
+
* A `vault:<name>:<verb>` grant DOES satisfy a broad `vault:<verb>` query —
|
|
86
|
+
* narrowed scopes are strictly more specific. The reverse is not true; broad
|
|
87
|
+
* grants do not satisfy narrowed queries via this function.
|
|
88
|
+
*
|
|
89
|
+
* Inheritance `admin ⊇ write ⊇ read` applies in both forms. Non-vault scopes
|
|
90
|
+
* require exact match.
|
|
61
91
|
*/
|
|
62
92
|
export function hasScope(granted: string[], required: string): boolean {
|
|
63
93
|
if (granted.includes(required)) return true;
|
|
64
94
|
|
|
65
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
95
|
+
const requiredDecomposed = decomposeVaultScope(required);
|
|
96
|
+
if (!requiredDecomposed || requiredDecomposed.vault !== null) {
|
|
97
|
+
// Non-vault scope or narrowed query — exact match only via hasScope.
|
|
98
|
+
// (Narrowed queries belong on hasScopeForVault.)
|
|
99
|
+
return false;
|
|
68
100
|
}
|
|
69
|
-
|
|
70
|
-
|
|
101
|
+
const reqRank = VERB_RANK[requiredDecomposed.verb];
|
|
102
|
+
for (const s of granted) {
|
|
103
|
+
const d = decomposeVaultScope(s);
|
|
104
|
+
if (d && VERB_RANK[d.verb] >= reqRank) return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Per-vault check: does `granted` satisfy a (vault, verb) request? Use this
|
|
111
|
+
* at request-routing time — the URL names the vault and the method picks
|
|
112
|
+
* the verb.
|
|
113
|
+
*
|
|
114
|
+
* Match rules:
|
|
115
|
+
* - Broad `vault:<verb>` in granted satisfies any vault (the broad scope
|
|
116
|
+
* has no resource constraint; the caller pins the vault upstream — pvt_*
|
|
117
|
+
* resolves only against its issuing vault's DB, hub JWTs reject broad
|
|
118
|
+
* scopes at validation).
|
|
119
|
+
* - Narrowed `vault:<name>:<verb>` satisfies only the matching `vaultName`.
|
|
120
|
+
* - Verb inheritance `admin ⊇ write ⊇ read` applies in both forms.
|
|
121
|
+
*/
|
|
122
|
+
export function hasScopeForVault(
|
|
123
|
+
granted: string[],
|
|
124
|
+
vaultName: string,
|
|
125
|
+
requiredVerb: VaultVerb,
|
|
126
|
+
): boolean {
|
|
127
|
+
const reqRank = VERB_RANK[requiredVerb];
|
|
128
|
+
for (const s of granted) {
|
|
129
|
+
const d = decomposeVaultScope(s);
|
|
130
|
+
if (!d) continue;
|
|
131
|
+
if (d.vault !== null && d.vault !== vaultName) continue;
|
|
132
|
+
if (VERB_RANK[d.verb] >= reqRank) return true;
|
|
71
133
|
}
|
|
72
134
|
return false;
|
|
73
135
|
}
|
|
@@ -78,12 +140,76 @@ export function hasScope(granted: string[], required: string): boolean {
|
|
|
78
140
|
* - POST/PATCH/PUT/DELETE → write
|
|
79
141
|
*
|
|
80
142
|
* Admin-gated endpoints (like `/.parachute/config`) don't go through this
|
|
81
|
-
* helper — they call `
|
|
143
|
+
* helper — they call `hasScopeForVault(auth.scopes, vaultName, "admin")`
|
|
144
|
+
* directly.
|
|
82
145
|
*/
|
|
83
146
|
export function scopeForMethod(method: string): VaultScope {
|
|
147
|
+
return verbForMethod(method) === "read" ? SCOPE_READ : SCOPE_WRITE;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Verb-only variant of `scopeForMethod`, for use with `hasScopeForVault`. */
|
|
151
|
+
export function verbForMethod(method: string): VaultVerb {
|
|
84
152
|
const m = method.toUpperCase();
|
|
85
|
-
if (m === "GET" || m === "HEAD" || m === "OPTIONS") return
|
|
86
|
-
return
|
|
153
|
+
if (m === "GET" || m === "HEAD" || m === "OPTIONS") return "read";
|
|
154
|
+
return "write";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Validate scopes requested for token minting on a specific vault.
|
|
159
|
+
*
|
|
160
|
+
* Each requested scope must be (a) a recognized vault scope shape, (b) not
|
|
161
|
+
* naming a different vault (cross-vault rejected), and (c) within the
|
|
162
|
+
* caller's verb power on `vaultName`. The third check is defense-in-depth:
|
|
163
|
+
* the REST endpoint already gates on `vault:admin`, but enforcing subset
|
|
164
|
+
* here means a future loosening of the gate (or a partially-trusted caller)
|
|
165
|
+
* still cannot mint a token stronger than what they hold.
|
|
166
|
+
*
|
|
167
|
+
* Pass-through on success — we don't rewrite scopes, just decide yes/no.
|
|
168
|
+
*/
|
|
169
|
+
export function validateMintedScopes(
|
|
170
|
+
requested: string[],
|
|
171
|
+
vaultName: string,
|
|
172
|
+
callerScopes: string[],
|
|
173
|
+
): { ok: true } | { ok: false; rejected: { scope: string; reason: string }[] } {
|
|
174
|
+
const rejected: { scope: string; reason: string }[] = [];
|
|
175
|
+
for (const s of requested) {
|
|
176
|
+
const d = decomposeVaultScope(s);
|
|
177
|
+
if (!d) {
|
|
178
|
+
rejected.push({ scope: s, reason: "unknown or unsupported scope" });
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (d.vault !== null && d.vault !== vaultName) {
|
|
182
|
+
rejected.push({
|
|
183
|
+
scope: s,
|
|
184
|
+
reason: `cross-vault scope not allowed (this endpoint mints for vault '${vaultName}')`,
|
|
185
|
+
});
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (!hasScopeForVault(callerScopes, vaultName, d.verb)) {
|
|
189
|
+
rejected.push({
|
|
190
|
+
scope: s,
|
|
191
|
+
reason: `caller lacks '${d.verb}' on vault '${vaultName}' — cannot grant a stronger scope than held`,
|
|
192
|
+
});
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (rejected.length > 0) return { ok: false, rejected };
|
|
197
|
+
return { ok: true };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Detect a broad `vault:<verb>` scope in a granted list. Hub-issued JWTs
|
|
202
|
+
* must NOT carry broad vault scopes — the hub mints `vault:<name>:<verb>` so
|
|
203
|
+
* the resource is named on the wire. `authenticateHubJwt` calls this to
|
|
204
|
+
* reject tokens that slipped through with the old shape.
|
|
205
|
+
*/
|
|
206
|
+
export function findBroadVaultScopes(granted: string[]): string[] {
|
|
207
|
+
const out: string[] = [];
|
|
208
|
+
for (const s of granted) {
|
|
209
|
+
const d = decomposeVaultScope(s);
|
|
210
|
+
if (d && d.vault === null) out.push(s);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
87
213
|
}
|
|
88
214
|
|
|
89
215
|
/**
|