@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/auth.test.ts
CHANGED
|
@@ -83,7 +83,7 @@ function bearer(token: string): Request {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
describe("auth — per-vault routing", () => {
|
|
86
|
-
test("token minted in a vault authenticates at its own /vault/<name>/* endpoints", () => {
|
|
86
|
+
test("token minted in a vault authenticates at its own /vault/<name>/* endpoints", async () => {
|
|
87
87
|
seedVault("journal");
|
|
88
88
|
const token = mintTokenInVault("journal");
|
|
89
89
|
const journalConfig = readVaultConfig("journal")!;
|
|
@@ -91,44 +91,44 @@ describe("auth — per-vault routing", () => {
|
|
|
91
91
|
|
|
92
92
|
// /vault/journal/api/* and /vault/journal/mcp both funnel into
|
|
93
93
|
// authenticateVaultRequest with journal's config + DB.
|
|
94
|
-
const vaultAuth = authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
|
|
94
|
+
const vaultAuth = await authenticateVaultRequest(bearer(token), journalConfig, journalStore.db);
|
|
95
95
|
expect("error" in vaultAuth).toBe(false);
|
|
96
96
|
if (!("error" in vaultAuth)) expect(vaultAuth.permission).toBe("full");
|
|
97
97
|
|
|
98
98
|
// /vaults (global metadata listing) uses authenticateGlobalRequest which
|
|
99
99
|
// scans every vault's DB. Since the token is in journal's DB, it must resolve.
|
|
100
|
-
const global = authenticateGlobalRequest(bearer(token));
|
|
100
|
+
const global = await authenticateGlobalRequest(bearer(token));
|
|
101
101
|
expect("error" in global).toBe(false);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
104
|
// HTTP-level routing stand-in. Mirrors routing.ts: every vault-scoped path
|
|
105
105
|
// matches `/vault/<name>/...`, we look the vault up, then authenticate the
|
|
106
106
|
// request against that vault's DB.
|
|
107
|
-
function dispatchAuthFromPath(path: string, req: Request): {
|
|
107
|
+
async function dispatchAuthFromPath(path: string, req: Request): Promise<{
|
|
108
108
|
status: number;
|
|
109
109
|
permission?: string;
|
|
110
|
-
} {
|
|
110
|
+
}> {
|
|
111
111
|
const match = path.match(/^\/vault\/([^/]+)(\/.*)?$/);
|
|
112
112
|
if (!match) return { status: 404 };
|
|
113
|
-
const vaultName = match[1]
|
|
113
|
+
const vaultName = match[1]!;
|
|
114
114
|
const vaultConfig = readVaultConfig(vaultName);
|
|
115
115
|
if (!vaultConfig) return { status: 404 };
|
|
116
116
|
const store = getVaultStore(vaultName);
|
|
117
|
-
const res = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
117
|
+
const res = await authenticateVaultRequest(req, vaultConfig, store.db);
|
|
118
118
|
if ("error" in res) return { status: res.error.status };
|
|
119
119
|
return { status: 200, permission: res.permission };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
test("routing: /vault/<name>/api/health accepts a token minted in that vault", () => {
|
|
122
|
+
test("routing: /vault/<name>/api/health accepts a token minted in that vault", async () => {
|
|
123
123
|
seedVault("journal");
|
|
124
124
|
const token = mintTokenInVault("journal");
|
|
125
125
|
|
|
126
|
-
const result = dispatchAuthFromPath("/vault/journal/api/health", bearer(token));
|
|
126
|
+
const result = await dispatchAuthFromPath("/vault/journal/api/health", bearer(token));
|
|
127
127
|
expect(result.status).toBe(200);
|
|
128
128
|
expect(result.permission).toBe("full");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
test("routing: /vault/A/api/* rejects a token issued for vault B", () => {
|
|
131
|
+
test("routing: /vault/A/api/* rejects a token issued for vault B", async () => {
|
|
132
132
|
// The privilege-escalation barrier: a valid token for vault A must not
|
|
133
133
|
// authenticate at vault B's endpoint, even though the token is valid
|
|
134
134
|
// for some vault. This is the point of per-vault DBs.
|
|
@@ -136,44 +136,103 @@ describe("auth — per-vault routing", () => {
|
|
|
136
136
|
seedVault("work");
|
|
137
137
|
const workToken = mintTokenInVault("work");
|
|
138
138
|
|
|
139
|
-
const crossVault = dispatchAuthFromPath("/vault/journal/api/health", bearer(workToken));
|
|
139
|
+
const crossVault = await dispatchAuthFromPath("/vault/journal/api/health", bearer(workToken));
|
|
140
140
|
expect(crossVault.status).toBe(401);
|
|
141
141
|
});
|
|
142
142
|
|
|
143
|
-
test("routing: /vault/<unknown> returns 404 (not 401)", () => {
|
|
143
|
+
test("routing: /vault/<unknown> returns 404 (not 401)", async () => {
|
|
144
144
|
seedVault("journal");
|
|
145
145
|
const token = mintTokenInVault("journal");
|
|
146
|
-
const result = dispatchAuthFromPath("/vault/nonexistent/api/health", bearer(token));
|
|
146
|
+
const result = await dispatchAuthFromPath("/vault/nonexistent/api/health", bearer(token));
|
|
147
147
|
expect(result.status).toBe(404);
|
|
148
148
|
});
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
describe("auth — cross-vault isolation", () => {
|
|
152
|
-
test("token minted in a non-default vault authenticates via scoped and global paths", () => {
|
|
152
|
+
test("token minted in a non-default vault authenticates via scoped and global paths", async () => {
|
|
153
153
|
seedVault("journal", { isDefault: true });
|
|
154
154
|
seedVault("work");
|
|
155
155
|
const workToken = mintTokenInVault("work");
|
|
156
156
|
const workConfig = readVaultConfig("work")!;
|
|
157
157
|
const workStore = getVaultStore("work");
|
|
158
158
|
|
|
159
|
-
const scoped = authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
|
|
159
|
+
const scoped = await authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
|
|
160
160
|
expect("error" in scoped).toBe(false);
|
|
161
161
|
|
|
162
162
|
// Global auth scans every vault, must find the token in work's DB.
|
|
163
|
-
const global = authenticateGlobalRequest(bearer(workToken));
|
|
163
|
+
const global = await authenticateGlobalRequest(bearer(workToken));
|
|
164
164
|
expect("error" in global).toBe(false);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
test("a work-vault token does NOT authenticate against the journal vault", () => {
|
|
167
|
+
test("a work-vault token does NOT authenticate against the journal vault", async () => {
|
|
168
168
|
seedVault("journal", { isDefault: true });
|
|
169
169
|
seedVault("work");
|
|
170
170
|
const workToken = mintTokenInVault("work");
|
|
171
171
|
const journalConfig = readVaultConfig("journal")!;
|
|
172
172
|
const journalStore = getVaultStore("journal");
|
|
173
173
|
|
|
174
|
-
const res = authenticateVaultRequest(bearer(workToken), journalConfig, journalStore.db);
|
|
174
|
+
const res = await authenticateVaultRequest(bearer(workToken), journalConfig, journalStore.db);
|
|
175
175
|
expect("error" in res).toBe(true);
|
|
176
176
|
});
|
|
177
|
+
|
|
178
|
+
test("v16 vault_name binding rejects with 403 when the row is cross-vault", async () => {
|
|
179
|
+
// Per-vault DB scoping is the first line of defense (a token only
|
|
180
|
+
// resolves against the DB it was minted in). The v16 vault_name column
|
|
181
|
+
// is defense-in-depth: if a token row somehow lives in vault A's DB
|
|
182
|
+
// but its vault_name says "B" (e.g. an out-of-band copy, or a future
|
|
183
|
+
// mistake in the mint path), the binding mismatch must reject. We
|
|
184
|
+
// simulate that by minting into journal's DB with vault_name="work".
|
|
185
|
+
seedVault("journal", { isDefault: true });
|
|
186
|
+
seedVault("work");
|
|
187
|
+
const journalStore = getVaultStore("journal");
|
|
188
|
+
const { fullToken } = generateToken();
|
|
189
|
+
createToken(journalStore.db, fullToken, {
|
|
190
|
+
label: "mis-bound",
|
|
191
|
+
permission: "full",
|
|
192
|
+
vault_name: "work",
|
|
193
|
+
});
|
|
194
|
+
const journalConfig = readVaultConfig("journal")!;
|
|
195
|
+
|
|
196
|
+
const res = await authenticateVaultRequest(bearer(fullToken), journalConfig, journalStore.db);
|
|
197
|
+
expect("error" in res).toBe(true);
|
|
198
|
+
if ("error" in res) {
|
|
199
|
+
expect(res.error.status).toBe(403);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("v16 vault_name binding accepts when token is bound to the requested vault", async () => {
|
|
204
|
+
seedVault("work");
|
|
205
|
+
const workStore = getVaultStore("work");
|
|
206
|
+
const { fullToken } = generateToken();
|
|
207
|
+
createToken(workStore.db, fullToken, {
|
|
208
|
+
label: "bound",
|
|
209
|
+
permission: "full",
|
|
210
|
+
vault_name: "work",
|
|
211
|
+
});
|
|
212
|
+
const workConfig = readVaultConfig("work")!;
|
|
213
|
+
|
|
214
|
+
const res = await authenticateVaultRequest(bearer(fullToken), workConfig, workStore.db);
|
|
215
|
+
expect("error" in res).toBe(false);
|
|
216
|
+
if (!("error" in res)) {
|
|
217
|
+
expect(res.vault_name).toBe("work");
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("legacy NULL-bound tokens still authenticate against any vault", async () => {
|
|
222
|
+
// Backwards compatibility: pre-v16 tokens (and legacy YAML keys, and
|
|
223
|
+
// hub JWTs) all carry vault_name = null. They keep working at any
|
|
224
|
+
// vault — the migration is lenient by design.
|
|
225
|
+
seedVault("work");
|
|
226
|
+
const workToken = mintTokenInVault("work");
|
|
227
|
+
const workConfig = readVaultConfig("work")!;
|
|
228
|
+
const workStore = getVaultStore("work");
|
|
229
|
+
|
|
230
|
+
const res = await authenticateVaultRequest(bearer(workToken), workConfig, workStore.db);
|
|
231
|
+
expect("error" in res).toBe(false);
|
|
232
|
+
if (!("error" in res)) {
|
|
233
|
+
expect(res.vault_name).toBeNull();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
177
236
|
});
|
|
178
237
|
|
|
179
238
|
// ---------------------------------------------------------------------------
|
|
@@ -256,11 +315,11 @@ describe("OAuth-minted tokens — per-vault coherence", () => {
|
|
|
256
315
|
const store = getVaultStore("journal");
|
|
257
316
|
|
|
258
317
|
// /vault/journal/api/* and /vault/journal/mcp both reach this auth call.
|
|
259
|
-
const vaultAuth = authenticateVaultRequest(bearer(token), cfg, store.db);
|
|
318
|
+
const vaultAuth = await authenticateVaultRequest(bearer(token), cfg, store.db);
|
|
260
319
|
expect("error" in vaultAuth).toBe(false);
|
|
261
320
|
|
|
262
321
|
// /vaults (authenticated listing) uses authenticateGlobalRequest.
|
|
263
|
-
const global = authenticateGlobalRequest(bearer(token));
|
|
322
|
+
const global = await authenticateGlobalRequest(bearer(token));
|
|
264
323
|
expect("error" in global).toBe(false);
|
|
265
324
|
});
|
|
266
325
|
|
|
@@ -272,17 +331,70 @@ describe("OAuth-minted tokens — per-vault coherence", () => {
|
|
|
272
331
|
const workStore = getVaultStore("work");
|
|
273
332
|
|
|
274
333
|
// Valid at work's own endpoints.
|
|
275
|
-
const scoped = authenticateVaultRequest(bearer(token), workCfg, workStore.db);
|
|
334
|
+
const scoped = await authenticateVaultRequest(bearer(token), workCfg, workStore.db);
|
|
276
335
|
expect("error" in scoped).toBe(false);
|
|
277
336
|
|
|
278
337
|
// Global auth finds the token in work's DB.
|
|
279
|
-
const global = authenticateGlobalRequest(bearer(token));
|
|
338
|
+
const global = await authenticateGlobalRequest(bearer(token));
|
|
280
339
|
expect("error" in global).toBe(false);
|
|
281
340
|
|
|
282
341
|
// Isolation: the token is NOT usable against the journal vault.
|
|
283
342
|
const journalCfg = readVaultConfig("journal")!;
|
|
284
343
|
const journalStore = getVaultStore("journal");
|
|
285
|
-
const crossCheck = authenticateVaultRequest(bearer(token), journalCfg, journalStore.db);
|
|
344
|
+
const crossCheck = await authenticateVaultRequest(bearer(token), journalCfg, journalStore.db);
|
|
286
345
|
expect("error" in crossCheck).toBe(true);
|
|
287
346
|
});
|
|
288
347
|
});
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Legacy YAML global keys — scope must round-trip through the parser
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
describe("auth — legacy global YAML keys honor declared scope", () => {
|
|
354
|
+
test("scope: read on a global config.yaml key resolves to read permission, not full", async () => {
|
|
355
|
+
// Regression: the global parser used to drop the `scope` field, leaving
|
|
356
|
+
// `globalKey.scope` undefined. The auth check `=== "read"` then resolved
|
|
357
|
+
// any undefined value to "full", silently escalating read-only keys.
|
|
358
|
+
const { fullKey, keyId } = generateApiKey();
|
|
359
|
+
writeGlobalConfig({
|
|
360
|
+
port: 1940,
|
|
361
|
+
api_keys: [
|
|
362
|
+
{
|
|
363
|
+
id: keyId,
|
|
364
|
+
label: "reader",
|
|
365
|
+
scope: "read",
|
|
366
|
+
key_hash: hashKey(fullKey),
|
|
367
|
+
created_at: new Date().toISOString(),
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const result = await authenticateGlobalRequest(bearer(fullKey));
|
|
373
|
+
expect("error" in result).toBe(false);
|
|
374
|
+
if (!("error" in result)) {
|
|
375
|
+
expect(result.permission).toBe("read");
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("scope: write on a global config.yaml key resolves to full permission", async () => {
|
|
380
|
+
const { fullKey, keyId } = generateApiKey();
|
|
381
|
+
writeGlobalConfig({
|
|
382
|
+
port: 1940,
|
|
383
|
+
api_keys: [
|
|
384
|
+
{
|
|
385
|
+
id: keyId,
|
|
386
|
+
label: "writer",
|
|
387
|
+
scope: "write",
|
|
388
|
+
key_hash: hashKey(fullKey),
|
|
389
|
+
created_at: new Date().toISOString(),
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const result = await authenticateGlobalRequest(bearer(fullKey));
|
|
395
|
+
expect("error" in result).toBe(false);
|
|
396
|
+
if (!("error" in result)) {
|
|
397
|
+
expect(result.permission).toBe("full");
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -21,7 +21,16 @@ import { resolveToken } from "./token-store.ts";
|
|
|
21
21
|
import type { TokenPermission } from "./token-store.ts";
|
|
22
22
|
import type { Database } from "bun:sqlite";
|
|
23
23
|
import { getVaultStore } from "./vault-store.ts";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
findBroadVaultScopes,
|
|
26
|
+
hasScope,
|
|
27
|
+
hasScopeForVault,
|
|
28
|
+
legacyPermissionToScopes,
|
|
29
|
+
SCOPE_ADMIN,
|
|
30
|
+
SCOPE_READ,
|
|
31
|
+
SCOPE_WRITE,
|
|
32
|
+
} from "./scopes.ts";
|
|
33
|
+
import { HubJwtError, looksLikeJwt, validateHubJwt } from "./hub-jwt.ts";
|
|
25
34
|
|
|
26
35
|
/** Result of a successful auth check. */
|
|
27
36
|
export interface AuthResult {
|
|
@@ -34,6 +43,21 @@ export interface AuthResult {
|
|
|
34
43
|
* one-time deprecation warning when they encounter `legacyDerived: true`.
|
|
35
44
|
*/
|
|
36
45
|
legacyDerived: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Tag-allowlist (root tag names) for tag-scoped tokens. NULL = unscoped
|
|
48
|
+
* (current full-vault behavior). Hub-issued JWTs and legacy YAML keys
|
|
49
|
+
* always have null — tag scoping is a per-token vault-DB attribute, not
|
|
50
|
+
* an OAuth-claim concern. See patterns/tag-scoped-tokens.md.
|
|
51
|
+
*/
|
|
52
|
+
scoped_tags: string[] | null;
|
|
53
|
+
/**
|
|
54
|
+
* Per-vault binding (v16). Non-null = token can only authenticate against
|
|
55
|
+
* this vault. authenticateVaultRequest enforces the match before this
|
|
56
|
+
* result returns; callers downstream can read it for additional scoping
|
|
57
|
+
* (e.g. cross-vault filtering at the unified /mcp endpoint). NULL =
|
|
58
|
+
* legacy / server-wide / hub JWT — no per-vault binding. See vault#257.
|
|
59
|
+
*/
|
|
60
|
+
vault_name: string | null;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
63
|
/**
|
|
@@ -46,17 +70,11 @@ function legacyAuthResult(permission: TokenPermission): AuthResult {
|
|
|
46
70
|
permission,
|
|
47
71
|
scopes: legacyPermissionToScopes(permission),
|
|
48
72
|
legacyDerived: true,
|
|
73
|
+
scoped_tags: null,
|
|
74
|
+
vault_name: null,
|
|
49
75
|
};
|
|
50
76
|
}
|
|
51
77
|
|
|
52
|
-
/**
|
|
53
|
-
* Guard: does the authenticated request carry the required scope?
|
|
54
|
-
* Uses `hasScope` inheritance: admin ⊇ write ⊇ read.
|
|
55
|
-
*/
|
|
56
|
-
export function requireScope(auth: AuthResult, required: string): boolean {
|
|
57
|
-
return hasScope(auth.scopes, required);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
78
|
// One-shot deprecation warning tracker, keyed by token hash / legacy label so
|
|
61
79
|
// we don't spam the log on every request.
|
|
62
80
|
const warnedLegacyTokens = new Set<string>();
|
|
@@ -128,23 +146,59 @@ function validateKey(keys: StoredKey[], providedKey: string): StoredKey | null {
|
|
|
128
146
|
|
|
129
147
|
/**
|
|
130
148
|
* Authenticate for a specific vault.
|
|
131
|
-
*
|
|
149
|
+
*
|
|
150
|
+
* Token shape decides the path:
|
|
151
|
+
* - JWT-shaped (`eyJ…`) → validate against the hub's JWKS. JWT-shaped tokens
|
|
152
|
+
* commit to JWT validation; we don't fall through to `pvt_*` lookup on
|
|
153
|
+
* failure, since a malformed JWT was never going to be a valid local
|
|
154
|
+
* token anyway.
|
|
155
|
+
* - Anything else → try the vault's token DB, then legacy YAML keys.
|
|
156
|
+
*
|
|
157
|
+
* Dual-validate window: both paths are live during this release cycle so
|
|
158
|
+
* existing `pvt_*` callers continue to work. A follow-up issue retires the
|
|
159
|
+
* legacy path.
|
|
132
160
|
*/
|
|
133
|
-
export function authenticateVaultRequest(
|
|
161
|
+
export async function authenticateVaultRequest(
|
|
134
162
|
req: Request,
|
|
135
163
|
vaultConfig: VaultConfig,
|
|
136
164
|
vaultDb?: Database,
|
|
137
|
-
): { error: Response } | AuthResult {
|
|
165
|
+
): Promise<{ error: Response } | AuthResult> {
|
|
138
166
|
const key = extractApiKey(req);
|
|
139
167
|
if (!key) {
|
|
140
168
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
141
169
|
}
|
|
142
170
|
|
|
171
|
+
// JWT path: hub-issued tokens. Trust pinned to the hub origin via `iss`
|
|
172
|
+
// verification inside validateHubJwt; signature checked against hub's JWKS.
|
|
173
|
+
// Audience strict-checked against `vault.<name>` so a token stamped for
|
|
174
|
+
// one vault can't reach another.
|
|
175
|
+
if (looksLikeJwt(key)) {
|
|
176
|
+
return await authenticateHubJwt(key, { expectedAudience: `vault.${vaultConfig.name}` });
|
|
177
|
+
}
|
|
178
|
+
|
|
143
179
|
// Try vault's token DB first
|
|
144
180
|
if (vaultDb) {
|
|
145
181
|
try {
|
|
146
182
|
const resolved = resolveToken(vaultDb, key);
|
|
147
183
|
if (resolved) {
|
|
184
|
+
// Per-vault binding (v16): tokens minted via /vault/<name>/tokens
|
|
185
|
+
// carry vault_name = <name>. Reject if presented at a different
|
|
186
|
+
// vault. NULL = legacy / server-wide; accept anywhere. The DB
|
|
187
|
+
// lookup itself already filters by per-vault DB scoping (resolve
|
|
188
|
+
// only succeeds if the token row lives in this vault's DB), so
|
|
189
|
+
// a mismatch here would only happen if a token row was copied
|
|
190
|
+
// across vault DBs out-of-band — defense-in-depth.
|
|
191
|
+
if (resolved.vault_name !== null && resolved.vault_name !== vaultConfig.name) {
|
|
192
|
+
return {
|
|
193
|
+
error: Response.json(
|
|
194
|
+
{
|
|
195
|
+
error: "Unauthorized",
|
|
196
|
+
message: `token is bound to vault '${resolved.vault_name}'; cannot be used against vault '${vaultConfig.name}'`,
|
|
197
|
+
},
|
|
198
|
+
{ status: 403 },
|
|
199
|
+
),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
148
202
|
if (resolved.legacyDerived) {
|
|
149
203
|
warnLegacyOnce(`vault-token:${vaultConfig.name ?? ""}`, "vault token without scopes column");
|
|
150
204
|
}
|
|
@@ -152,6 +206,8 @@ export function authenticateVaultRequest(
|
|
|
152
206
|
permission: resolved.permission,
|
|
153
207
|
scopes: resolved.scopes,
|
|
154
208
|
legacyDerived: resolved.legacyDerived,
|
|
209
|
+
scoped_tags: resolved.scoped_tags,
|
|
210
|
+
vault_name: resolved.vault_name,
|
|
155
211
|
};
|
|
156
212
|
}
|
|
157
213
|
} catch {
|
|
@@ -181,20 +237,115 @@ export function authenticateVaultRequest(
|
|
|
181
237
|
return { error: Response.json({ error: "Unauthorized", message: "Invalid API key" }, { status: 401 }) };
|
|
182
238
|
}
|
|
183
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Validate a JWT-shaped bearer and convert the result into an `AuthResult`.
|
|
242
|
+
* The token's scope claim becomes the granted scopes; permission is derived
|
|
243
|
+
* for back-compat with code paths that still branch on `permission` (MCP
|
|
244
|
+
* tool gating, view auth). `legacyDerived` is `false` — JWT-issued scopes
|
|
245
|
+
* are explicit, never inferred.
|
|
246
|
+
*
|
|
247
|
+
* Scope-shape policy: hub-issued tokens MUST carry resource-narrowed
|
|
248
|
+
* `vault:<name>:<verb>` scopes. Broad `vault:<verb>` scopes are rejected
|
|
249
|
+
* here — Phase B2 settled that hub tokens always name the resource. Per-
|
|
250
|
+
* vault audience enforcement happens inside `validateHubJwt` via
|
|
251
|
+
* `opts.expectedAudience`.
|
|
252
|
+
*/
|
|
253
|
+
async function authenticateHubJwt(
|
|
254
|
+
token: string,
|
|
255
|
+
opts: { expectedAudience: string | null },
|
|
256
|
+
): Promise<{ error: Response } | AuthResult> {
|
|
257
|
+
try {
|
|
258
|
+
const claims = await validateHubJwt(token, { expectedAudience: opts.expectedAudience });
|
|
259
|
+
const broad = findBroadVaultScopes(claims.scopes);
|
|
260
|
+
if (broad.length > 0) {
|
|
261
|
+
return {
|
|
262
|
+
error: Response.json(
|
|
263
|
+
{
|
|
264
|
+
error: "Unauthorized",
|
|
265
|
+
message: `hub JWT carries broad vault scope(s): ${broad.join(" ")}. Hub-issued tokens must use resource-narrowed scopes (vault:<name>:<verb>).`,
|
|
266
|
+
},
|
|
267
|
+
{ status: 401 },
|
|
268
|
+
),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
const permission: TokenPermission =
|
|
272
|
+
hasScope(claims.scopes, SCOPE_WRITE) || hasScope(claims.scopes, SCOPE_ADMIN)
|
|
273
|
+
? "full"
|
|
274
|
+
: "read";
|
|
275
|
+
return { permission, scopes: claims.scopes, legacyDerived: false, scoped_tags: null, vault_name: null };
|
|
276
|
+
} catch (err) {
|
|
277
|
+
if (err instanceof HubJwtError) {
|
|
278
|
+
// Revocation-related codes get sanitized client messages: server-side
|
|
279
|
+
// audit log carries the full diagnostic (jti for `revoked`,
|
|
280
|
+
// implementation-detail phrasing for `revocation_unavailable`); the
|
|
281
|
+
// unauthenticated caller gets a code-shaped sentence with no internals.
|
|
282
|
+
// This is the inheritable pattern across vault/scribe/agent — keep all
|
|
283
|
+
// revocation-related diagnostics server-side. Other HubJwtError codes
|
|
284
|
+
// (signature, audience, expired, etc.) carry generic messages and are
|
|
285
|
+
// forwarded as-is; the existing test suite pins those exact strings.
|
|
286
|
+
if (err.code === "revoked") {
|
|
287
|
+
console.warn(`[auth] hub JWT rejected: ${err.message}`);
|
|
288
|
+
return {
|
|
289
|
+
error: Response.json(
|
|
290
|
+
{ error: "Unauthorized", message: "token has been revoked" },
|
|
291
|
+
{ status: 401 },
|
|
292
|
+
),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
if (err.code === "revocation_unavailable") {
|
|
296
|
+
console.warn(`[auth] hub JWT rejected: ${err.message}`);
|
|
297
|
+
return {
|
|
298
|
+
error: Response.json(
|
|
299
|
+
{
|
|
300
|
+
error: "Unauthorized",
|
|
301
|
+
message: "token cannot be validated: revocation list unavailable",
|
|
302
|
+
},
|
|
303
|
+
{ status: 401 },
|
|
304
|
+
),
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return { error: Response.json({ error: "Unauthorized", message: err.message }, { status: 401 }) };
|
|
308
|
+
}
|
|
309
|
+
// Unknown failure shape — surface the message but stay 401.
|
|
310
|
+
const msg = err instanceof Error ? err.message : "JWT validation failed";
|
|
311
|
+
return { error: Response.json({ error: "Unauthorized", message: msg }, { status: 401 }) };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
184
315
|
/**
|
|
185
316
|
* Authenticate for the unified /mcp endpoint.
|
|
186
317
|
* Checks legacy global config.yaml keys first, then falls back to checking
|
|
187
318
|
* each vault's token DB. This allows OAuth-minted pvt_ tokens to work on
|
|
188
319
|
* the unified endpoint.
|
|
189
320
|
*/
|
|
190
|
-
export function authenticateGlobalRequest(
|
|
321
|
+
export async function authenticateGlobalRequest(
|
|
191
322
|
req: Request,
|
|
192
|
-
): { error: Response } | AuthResult {
|
|
323
|
+
): Promise<{ error: Response } | AuthResult> {
|
|
193
324
|
const key = extractApiKey(req);
|
|
194
325
|
if (!key) {
|
|
195
326
|
return { error: Response.json({ error: "Unauthorized", message: "API key required" }, { status: 401 }) };
|
|
196
327
|
}
|
|
197
328
|
|
|
329
|
+
// Hub-issued JWTs are always vault-bound (aud=vault.<name>). The unified
|
|
330
|
+
// /vaults / /health surface spans every vault and has no single audience to
|
|
331
|
+
// strict-check against, so JWTs aren't accepted here. Cross-vault listing
|
|
332
|
+
// for hub-authenticated callers will land alongside the `parachute:host:*`
|
|
333
|
+
// scope namespace; until then, callers wanting `/vaults` from a JWT
|
|
334
|
+
// context use a per-vault token at `/vault/<name>` and rely on services.json
|
|
335
|
+
// for the broader catalog.
|
|
336
|
+
if (looksLikeJwt(key)) {
|
|
337
|
+
return {
|
|
338
|
+
error: Response.json(
|
|
339
|
+
{
|
|
340
|
+
error: "Unauthorized",
|
|
341
|
+
message:
|
|
342
|
+
"Hub-issued JWTs are vault-bound; use /vault/<name>/* endpoints. Cross-vault routes accept legacy config.yaml keys or per-vault tokens.",
|
|
343
|
+
},
|
|
344
|
+
{ status: 401 },
|
|
345
|
+
),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
198
349
|
// Legacy: check global keys from config.yaml
|
|
199
350
|
const globalConfig = readGlobalConfig();
|
|
200
351
|
if (globalConfig.api_keys) {
|
|
@@ -208,7 +359,12 @@ export function authenticateGlobalRequest(
|
|
|
208
359
|
|
|
209
360
|
// Fall through to vault token DBs — check each vault for the token.
|
|
210
361
|
// This enables OAuth-minted pvt_ tokens and CLI-created tokens to
|
|
211
|
-
// authenticate against the unified /mcp endpoint.
|
|
362
|
+
// authenticate against the unified /mcp endpoint. The token's vault
|
|
363
|
+
// binding (if any) is propagated via AuthResult.vault_name; downstream
|
|
364
|
+
// handlers that operate on a specific vault are responsible for
|
|
365
|
+
// checking that binding matches their target. The unified surface
|
|
366
|
+
// itself doesn't reject here — a vault-bound token authenticating to
|
|
367
|
+
// call back into its own vault via /mcp is legitimate.
|
|
212
368
|
for (const vaultName of listVaults()) {
|
|
213
369
|
try {
|
|
214
370
|
const store = getVaultStore(vaultName);
|
|
@@ -221,6 +377,8 @@ export function authenticateGlobalRequest(
|
|
|
221
377
|
permission: resolved.permission,
|
|
222
378
|
scopes: resolved.scopes,
|
|
223
379
|
legacyDerived: resolved.legacyDerived,
|
|
380
|
+
scoped_tags: resolved.scoped_tags,
|
|
381
|
+
vault_name: resolved.vault_name,
|
|
224
382
|
};
|
|
225
383
|
}
|
|
226
384
|
} catch {
|
package/src/backup.ts
CHANGED
|
@@ -97,7 +97,7 @@ export function backupFilename(timestamp: string): string {
|
|
|
97
97
|
export function parseBackupFilename(name: string): { timestamp: string } | null {
|
|
98
98
|
const m = name.match(/^parachute-backup-(.+)\.tar\.gz$/);
|
|
99
99
|
if (!m) return null;
|
|
100
|
-
return { timestamp: m[1] };
|
|
100
|
+
return { timestamp: m[1]! };
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
// ---------------------------------------------------------------------------
|
|
@@ -298,13 +298,10 @@ async function writeToDestination(
|
|
|
298
298
|
const pruned = pruneRetention(target, retention);
|
|
299
299
|
return { destination: dest, writtenPath: out, pruned };
|
|
300
300
|
}
|
|
301
|
-
// Exhaustiveness guard — if a future destination kind is added to the
|
|
302
|
-
// type union but not handled here, the compiler fails the build.
|
|
303
|
-
default: {
|
|
304
|
-
const _exhaustive: never = dest;
|
|
305
|
-
throw new Error(`Unsupported destination kind: ${JSON.stringify(_exhaustive)}`);
|
|
306
|
-
}
|
|
307
301
|
}
|
|
302
|
+
// Unreachable while BackupDestination only has "local"; once a second
|
|
303
|
+
// kind is added, restore an exhaustiveness `never` check here.
|
|
304
|
+
throw new Error(`Unsupported destination kind: ${JSON.stringify(dest)}`);
|
|
308
305
|
}
|
|
309
306
|
|
|
310
307
|
// ---------------------------------------------------------------------------
|