@openparachute/vault 0.3.3 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/routing.ts
CHANGED
|
@@ -40,11 +40,11 @@ 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,
|
|
@@ -53,7 +53,10 @@ import {
|
|
|
53
53
|
handleUnresolvedWikilinks,
|
|
54
54
|
handleStorage,
|
|
55
55
|
handleViewNote,
|
|
56
|
+
type TagScopeCtx,
|
|
56
57
|
} from "./routes.ts";
|
|
58
|
+
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
59
|
+
import { handleTokens } from "./tokens-routes.ts";
|
|
57
60
|
import {
|
|
58
61
|
handleProtectedResource,
|
|
59
62
|
handleAuthorizationServer,
|
|
@@ -64,6 +67,8 @@ import {
|
|
|
64
67
|
getBaseUrl,
|
|
65
68
|
} from "./oauth.ts";
|
|
66
69
|
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
70
|
+
import { buildAuthStatus } from "./auth-status.ts";
|
|
71
|
+
import { getAuthorizeRateLimiter } from "./owner-auth.ts";
|
|
67
72
|
|
|
68
73
|
/**
|
|
69
74
|
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
@@ -100,15 +105,15 @@ async function withMcpChallenge(
|
|
|
100
105
|
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
101
106
|
* requests still get public notes.
|
|
102
107
|
*/
|
|
103
|
-
function isViewAuthenticated(
|
|
108
|
+
async function isViewAuthenticated(
|
|
104
109
|
req: Request,
|
|
105
110
|
vaultConfig: VaultConfig | null,
|
|
106
111
|
vaultDb?: import("bun:sqlite").Database,
|
|
107
|
-
): boolean {
|
|
112
|
+
): Promise<boolean> {
|
|
108
113
|
if (!vaultConfig) return false;
|
|
109
114
|
const key = extractApiKey(req);
|
|
110
115
|
if (!key) return false;
|
|
111
|
-
const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
116
|
+
const auth = await authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
112
117
|
return !("error" in auth);
|
|
113
118
|
}
|
|
114
119
|
|
|
@@ -188,7 +193,7 @@ export async function route(
|
|
|
188
193
|
/^\/\.well-known\/oauth-protected-resource\/vault\/([^/]+)(?:\/mcp)?$/,
|
|
189
194
|
);
|
|
190
195
|
if (protectedResourceInsert) {
|
|
191
|
-
const vaultName = protectedResourceInsert[1]
|
|
196
|
+
const vaultName = protectedResourceInsert[1]!;
|
|
192
197
|
if (!readVaultConfig(vaultName)) {
|
|
193
198
|
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
194
199
|
}
|
|
@@ -198,7 +203,7 @@ export async function route(
|
|
|
198
203
|
/^\/\.well-known\/oauth-authorization-server\/vault\/([^/]+)(?:\/mcp)?$/,
|
|
199
204
|
);
|
|
200
205
|
if (authServerInsert) {
|
|
201
|
-
const vaultName = authServerInsert[1]
|
|
206
|
+
const vaultName = authServerInsert[1]!;
|
|
202
207
|
if (!readVaultConfig(vaultName)) {
|
|
203
208
|
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
204
209
|
}
|
|
@@ -210,7 +215,7 @@ export async function route(
|
|
|
210
215
|
// ---------------------------------------------------------------------
|
|
211
216
|
|
|
212
217
|
if (path === "/health") {
|
|
213
|
-
const auth = authenticateGlobalRequest(req);
|
|
218
|
+
const auth = await authenticateGlobalRequest(req);
|
|
214
219
|
if ("error" in auth) {
|
|
215
220
|
return Response.json({ status: "ok" });
|
|
216
221
|
}
|
|
@@ -229,9 +234,43 @@ export async function route(
|
|
|
229
234
|
return Response.json({ vaults: listVaults() });
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
// Public auth-state probe. Tells a first-contact client whether the
|
|
238
|
+
// server has any vaults yet, which bearer formats it accepts, and
|
|
239
|
+
// whether owner-password / TOTP / tokens are configured. Replaces
|
|
240
|
+
// hub's out-of-process filesystem snoop (parachute-hub#86 follow-up).
|
|
241
|
+
// No auth, GET-only, no token counts — `hasTokens` is a yes/no signal
|
|
242
|
+
// only, with `null` for "DB read failed."
|
|
243
|
+
//
|
|
244
|
+
// Gated on `discovery: disabled` like /vaults/list — both leak vault
|
|
245
|
+
// existence (the `vaults` array names them), so an operator who hides
|
|
246
|
+
// one wants both hidden (#191).
|
|
247
|
+
if (path === "/auth/status" && req.method === "GET") {
|
|
248
|
+
if (readGlobalConfig().discovery === "disabled") {
|
|
249
|
+
return Response.json({ error: "Not found" }, { status: 404 });
|
|
250
|
+
}
|
|
251
|
+
return Response.json(buildAuthStatus(), {
|
|
252
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Admin SPA — per-vault at `/vault/<name>/admin/*` (vault#252). Static-
|
|
257
|
+
// file serving only (index.html + Vite asset bundle); no auth at this
|
|
258
|
+
// seam since the bundle reveals nothing privileged. The SPA's data
|
|
259
|
+
// fetches land on existing per-vault routes that enforce
|
|
260
|
+
// `vault:<name>:read` / `:admin`. GET-only — POSTs to `.../admin/*` aren't
|
|
261
|
+
// a thing the SPA bundle exposes; rejecting them here keeps surprises
|
|
262
|
+
// out. Must fire BEFORE the per-vault dispatch below or the auth wall
|
|
263
|
+
// there would short-circuit the static-asset response.
|
|
264
|
+
if (isAdminSpaPath(path)) {
|
|
265
|
+
if (req.method !== "GET") {
|
|
266
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
267
|
+
}
|
|
268
|
+
return serveAdminSpa(defaultAdminSpaDistDir(), path);
|
|
269
|
+
}
|
|
270
|
+
|
|
232
271
|
// Authenticated vault metadata list.
|
|
233
272
|
if (path === "/vaults" && req.method === "GET") {
|
|
234
|
-
const auth = authenticateGlobalRequest(req);
|
|
273
|
+
const auth = await authenticateGlobalRequest(req);
|
|
235
274
|
if ("error" in auth) return auth.error;
|
|
236
275
|
const names = listVaults();
|
|
237
276
|
const vaults = names.map((name) => {
|
|
@@ -254,7 +293,7 @@ export async function route(
|
|
|
254
293
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
255
294
|
}
|
|
256
295
|
|
|
257
|
-
const vaultName = vaultMatch[1]
|
|
296
|
+
const vaultName = vaultMatch[1]!;
|
|
258
297
|
const subpath = vaultMatch[2] ?? "";
|
|
259
298
|
|
|
260
299
|
const vaultConfig = readVaultConfig(vaultName);
|
|
@@ -266,7 +305,7 @@ export async function route(
|
|
|
266
305
|
// convenience for published-note URLs that predate the /view/ path).
|
|
267
306
|
const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
|
|
268
307
|
if (vaultPublicMatch && req.method === "GET") {
|
|
269
|
-
const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
|
|
308
|
+
const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]!}`, req.url);
|
|
270
309
|
dest.search = new URL(req.url).search;
|
|
271
310
|
return Response.redirect(dest.toString(), 301);
|
|
272
311
|
}
|
|
@@ -277,8 +316,8 @@ export async function route(
|
|
|
277
316
|
const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
|
|
278
317
|
if (vaultViewMatch && req.method === "GET") {
|
|
279
318
|
const store = getVaultStore(vaultName);
|
|
280
|
-
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
281
|
-
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
|
|
319
|
+
const authenticated = await isViewAuthenticated(req, vaultConfig, store.db);
|
|
320
|
+
return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]!), {
|
|
282
321
|
authenticated,
|
|
283
322
|
publishedTag: vaultConfig.published_tag,
|
|
284
323
|
});
|
|
@@ -308,6 +347,10 @@ export async function route(
|
|
|
308
347
|
clientIp,
|
|
309
348
|
ownerPasswordHash,
|
|
310
349
|
totpSecret,
|
|
350
|
+
// Per-vault rate-limit instance — prevents brute-force traffic on
|
|
351
|
+
// one vault's consent flow from locking out IPs trying to authorize
|
|
352
|
+
// against an unrelated vault (#93).
|
|
353
|
+
rateLimiter: getAuthorizeRateLimiter(vaultConfig.name),
|
|
311
354
|
});
|
|
312
355
|
}
|
|
313
356
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
@@ -350,14 +393,14 @@ export async function route(
|
|
|
350
393
|
// (worker intervals, TTLs, retention policy) but are still configuration
|
|
351
394
|
// an attacker could use to map the deployment. `vault:admin` keeps the
|
|
352
395
|
// hub's loopback workflow intact while locking out read-only tokens.
|
|
353
|
-
const configAuth = authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
|
|
396
|
+
const configAuth = await authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
|
|
354
397
|
if ("error" in configAuth) return configAuth.error;
|
|
355
|
-
if (!
|
|
398
|
+
if (!hasScopeForVault(configAuth.scopes, vaultName, "admin")) {
|
|
356
399
|
return Response.json(
|
|
357
400
|
{
|
|
358
401
|
error: "Forbidden",
|
|
359
402
|
error_type: "insufficient_scope",
|
|
360
|
-
message: `This endpoint requires the '${SCOPE_ADMIN}' scope.`,
|
|
403
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
361
404
|
required_scope: SCOPE_ADMIN,
|
|
362
405
|
granted_scopes: configAuth.scopes,
|
|
363
406
|
},
|
|
@@ -385,7 +428,7 @@ export async function route(
|
|
|
385
428
|
// ---------------------------------------------------------------------
|
|
386
429
|
|
|
387
430
|
const store = getVaultStore(vaultName);
|
|
388
|
-
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
431
|
+
const auth = await authenticateVaultRequest(req, vaultConfig, store.db);
|
|
389
432
|
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
390
433
|
if ("error" in auth) {
|
|
391
434
|
return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
|
|
@@ -411,6 +454,31 @@ export async function route(
|
|
|
411
454
|
});
|
|
412
455
|
}
|
|
413
456
|
|
|
457
|
+
// /tokens — admin-gated REST surface for minting/listing/revoking pvt_*
|
|
458
|
+
// tokens. Admin gate applies to every method (POST/GET/DELETE) since both
|
|
459
|
+
// the plaintext mint and the metadata listing are sensitive — knowing
|
|
460
|
+
// labels and scopes of issued tokens leaks the deployment's auth shape.
|
|
461
|
+
// The handler's POST path applies a strict subset check on requested
|
|
462
|
+
// scopes via `validateMintedScopes` (defense-in-depth: cross-vault and
|
|
463
|
+
// privilege-escalation rejections survive even if this gate is later
|
|
464
|
+
// relaxed).
|
|
465
|
+
const tokensMatch = subpath.match(/^\/tokens(\/.*)?$/);
|
|
466
|
+
if (tokensMatch) {
|
|
467
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
468
|
+
return Response.json(
|
|
469
|
+
{
|
|
470
|
+
error: "Forbidden",
|
|
471
|
+
error_type: "insufficient_scope",
|
|
472
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
473
|
+
required_scope: SCOPE_ADMIN,
|
|
474
|
+
granted_scopes: auth.scopes,
|
|
475
|
+
},
|
|
476
|
+
{ status: 403 },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
|
|
480
|
+
}
|
|
481
|
+
|
|
414
482
|
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
415
483
|
if (!apiMatch) {
|
|
416
484
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
@@ -418,14 +486,17 @@ export async function route(
|
|
|
418
486
|
|
|
419
487
|
// REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
|
|
420
488
|
// POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
|
|
421
|
-
//
|
|
422
|
-
|
|
423
|
-
|
|
489
|
+
// and the broad-vs-narrowed shape (`vault:<verb>` from pvt_*, or
|
|
490
|
+
// `vault:<vaultName>:<verb>` from hub JWTs) are handled by
|
|
491
|
+
// `hasScopeForVault`.
|
|
492
|
+
const requiredVerb = verbForMethod(req.method);
|
|
493
|
+
if (!hasScopeForVault(auth.scopes, vaultName, requiredVerb)) {
|
|
494
|
+
const requiredApiScope = scopeForMethod(req.method);
|
|
424
495
|
return Response.json(
|
|
425
496
|
{
|
|
426
497
|
error: "Forbidden",
|
|
427
498
|
error_type: "insufficient_scope",
|
|
428
|
-
message: `This endpoint requires the '${requiredApiScope}' scope.`,
|
|
499
|
+
message: `This endpoint requires the '${requiredApiScope}' scope (or '${requiredApiScope.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
429
500
|
required_scope: requiredApiScope,
|
|
430
501
|
granted_scopes: auth.scopes,
|
|
431
502
|
},
|
|
@@ -435,9 +506,20 @@ export async function route(
|
|
|
435
506
|
|
|
436
507
|
const apiPath = apiMatch[1] ?? "";
|
|
437
508
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
509
|
+
// Tag-scoped tokens (patterns/tag-scoped-tokens.md): expand the token's
|
|
510
|
+
// root-tag allowlist into `{root} ∪ descendants(root)` once per request,
|
|
511
|
+
// so handlers can intersect against the note's tag set without re-walking
|
|
512
|
+
// the `_tags/<name>` hierarchy on every check. `tagScope.allowed` is null
|
|
513
|
+
// for unscoped tokens — the no-op fast path leaves the pre-tag-scope
|
|
514
|
+
// behavior identical.
|
|
515
|
+
const tagScope: TagScopeCtx = {
|
|
516
|
+
allowed: await expandTokenTagScope(store, auth.scoped_tags),
|
|
517
|
+
raw: auth.scoped_tags,
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName, tagScope);
|
|
521
|
+
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5), tagScope);
|
|
522
|
+
if (apiPath === "/find-path") return handleFindPath(req, store, tagScope);
|
|
441
523
|
if (apiPath === "/vault") {
|
|
442
524
|
return handleVault(req, store, vaultConfig, () => {
|
|
443
525
|
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
|
/**
|
package/src/server.ts
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* The request pipeline lives in ./routing.ts (exported for unit testing).
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
|
|
18
|
+
import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey, stopSignalPath } from "./config.ts";
|
|
19
|
+
import { existsSync, rmSync } from "fs";
|
|
19
20
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
20
21
|
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
21
22
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
@@ -198,7 +199,7 @@ const server = Bun.serve({
|
|
|
198
199
|
const trustProxy = process.env.TRUST_PROXY === "1" || process.env.TRUST_PROXY === "true";
|
|
199
200
|
const forwardedFor = trustProxy ? req.headers.get("x-forwarded-for") : null;
|
|
200
201
|
const clientIp = forwardedFor
|
|
201
|
-
? forwardedFor.split(",")[0]
|
|
202
|
+
? forwardedFor.split(",")[0]!.trim()
|
|
202
203
|
: server.requestIP(req)?.address;
|
|
203
204
|
|
|
204
205
|
try {
|
|
@@ -241,3 +242,24 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
241
242
|
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
242
243
|
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
243
244
|
|
|
245
|
+
// Filesystem-sentinel shutdown: `parachute-vault stop` writes
|
|
246
|
+
// `~/.parachute/vault/stop.signal`; we poll for it and exit cleanly when it
|
|
247
|
+
// appears. Useful when no signal channel is available — Docker exec, foreground
|
|
248
|
+
// runs without a TTY, scripts that can't manage PIDs. Any stale sentinel is
|
|
249
|
+
// removed before we start polling so a previous `stop` can't pre-empt this boot.
|
|
250
|
+
const STOP_POLL_MS = 500;
|
|
251
|
+
try {
|
|
252
|
+
if (existsSync(stopSignalPath())) rmSync(stopSignalPath(), { force: true });
|
|
253
|
+
} catch (err) {
|
|
254
|
+
console.warn("[stop-signal] could not clear stale sentinel:", err);
|
|
255
|
+
}
|
|
256
|
+
setInterval(() => {
|
|
257
|
+
if (!existsSync(stopSignalPath())) return;
|
|
258
|
+
try {
|
|
259
|
+
rmSync(stopSignalPath(), { force: true });
|
|
260
|
+
} catch (err) {
|
|
261
|
+
console.warn("[stop-signal] could not remove sentinel:", err);
|
|
262
|
+
}
|
|
263
|
+
void shutdown("STOP_SIGNAL");
|
|
264
|
+
}, STOP_POLL_MS).unref();
|
|
265
|
+
|