@openparachute/vault 0.4.0 → 0.4.4-rc.11
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/README.md +191 -2
- package/core/src/core.test.ts +1295 -526
- package/core/src/mcp.ts +129 -428
- package/core/src/notes.ts +405 -32
- package/core/src/obsidian.ts +55 -177
- package/core/src/portable-md.test.ts +1001 -0
- package/core/src/portable-md.ts +1409 -0
- package/core/src/schema-defaults.ts +233 -171
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +103 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +52 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/cli.ts +699 -141
- package/src/doctor.test.ts +7 -6
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-install-interactive.test.ts +883 -0
- package/src/mcp-install-interactive.ts +412 -0
- package/src/mcp-install.test.ts +957 -5
- package/src/mcp-install.ts +580 -13
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +330 -207
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +1052 -333
- package/core/src/note-schemas.ts +0 -232
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -10,13 +10,18 @@
|
|
|
10
10
|
* - broad `vault:<verb>` scope rejected (forced narrowing per #180)
|
|
11
11
|
* - `aud=vault.<other>` rejected (audience mismatch)
|
|
12
12
|
* - JWT path rejected at the global (cross-vault) entrypoint
|
|
13
|
+
* - revoked jti rejected (revocation list integration; client-facing
|
|
14
|
+
* message is sanitized so the jti doesn't leak)
|
|
15
|
+
* - revocation list unavailable on cold start → fail-closed 401
|
|
13
16
|
*
|
|
14
|
-
* Each test owns a fresh `PARACHUTE_HOME` and
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
+
* Each test owns a fresh `PARACHUTE_HOME` and a fake hub fixture that serves
|
|
18
|
+
* BOTH `/.well-known/jwks.json` and `/.well-known/parachute-revocation.json`.
|
|
19
|
+
* scope-guard's own unit suite covers the cache mechanics (TTL refresh,
|
|
20
|
+
* fail-open with last-good, single-flight); this file pins the vault-side
|
|
21
|
+
* wiring and the response-shape contract.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
|
-
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
24
|
+
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
|
|
20
25
|
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
21
26
|
import { join } from "path";
|
|
22
27
|
import { tmpdir } from "os";
|
|
@@ -24,7 +29,7 @@ import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
|
24
29
|
import { writeVaultConfig, readVaultConfig } from "./config.ts";
|
|
25
30
|
import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
|
|
26
31
|
import { authenticateVaultRequest, authenticateGlobalRequest } from "./auth.ts";
|
|
27
|
-
import { resetJwksCache } from "./hub-jwt.ts";
|
|
32
|
+
import { resetJwksCache, resetRevocationCache } from "./hub-jwt.ts";
|
|
28
33
|
|
|
29
34
|
interface Keypair {
|
|
30
35
|
privateKey: CryptoKey;
|
|
@@ -42,24 +47,45 @@ async function makeKeypair(kid: string): Promise<Keypair> {
|
|
|
42
47
|
};
|
|
43
48
|
}
|
|
44
49
|
|
|
45
|
-
interface
|
|
50
|
+
interface HubFixture {
|
|
46
51
|
origin: string;
|
|
52
|
+
/** Drive the revocation list contents; cleared by default. */
|
|
53
|
+
setRevoked(jtis: string[]): void;
|
|
54
|
+
/** When true, the revocation endpoint returns 503 — exercises fail-closed. */
|
|
55
|
+
setRevocationFails(fails: boolean): void;
|
|
47
56
|
stop: () => void;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
function
|
|
59
|
+
function startHubFixture(keys: Keypair[]): HubFixture {
|
|
60
|
+
let revokedJtis: string[] = [];
|
|
61
|
+
let revocationFails = false;
|
|
51
62
|
const server = Bun.serve({
|
|
52
63
|
port: 0,
|
|
53
64
|
fetch(req) {
|
|
54
65
|
const url = new URL(req.url);
|
|
55
|
-
if (url.pathname
|
|
56
|
-
return
|
|
66
|
+
if (url.pathname === "/.well-known/jwks.json") {
|
|
67
|
+
return Response.json({ keys: keys.map((k) => k.publicJwk) });
|
|
57
68
|
}
|
|
58
|
-
|
|
69
|
+
if (url.pathname === "/.well-known/parachute-revocation.json") {
|
|
70
|
+
if (revocationFails) {
|
|
71
|
+
return new Response("hub down", { status: 503 });
|
|
72
|
+
}
|
|
73
|
+
return Response.json({
|
|
74
|
+
generated_at: new Date().toISOString(),
|
|
75
|
+
jtis: revokedJtis,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return new Response("not found", { status: 404 });
|
|
59
79
|
},
|
|
60
80
|
});
|
|
61
81
|
return {
|
|
62
82
|
origin: `http://127.0.0.1:${server.port}`,
|
|
83
|
+
setRevoked: (jtis) => {
|
|
84
|
+
revokedJtis = jtis;
|
|
85
|
+
},
|
|
86
|
+
setRevocationFails: (fails) => {
|
|
87
|
+
revocationFails = fails;
|
|
88
|
+
},
|
|
63
89
|
stop: () => server.stop(true),
|
|
64
90
|
};
|
|
65
91
|
}
|
|
@@ -70,6 +96,8 @@ interface SignOpts {
|
|
|
70
96
|
scope: string;
|
|
71
97
|
sub?: string;
|
|
72
98
|
ttlSeconds?: number;
|
|
99
|
+
/** Override the random jti — needed when a test wants to revoke this exact token. */
|
|
100
|
+
jti?: string;
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
@@ -82,7 +110,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
|
82
110
|
.setAudience(opts.aud)
|
|
83
111
|
.setIssuedAt(iat)
|
|
84
112
|
.setExpirationTime(exp)
|
|
85
|
-
.setJti(`jti-${Math.random().toString(36).slice(2)}`)
|
|
113
|
+
.setJti(opts.jti ?? `jti-${Math.random().toString(36).slice(2)}`)
|
|
86
114
|
.sign(kp.privateKey);
|
|
87
115
|
}
|
|
88
116
|
|
|
@@ -95,7 +123,7 @@ function bearer(token: string): Request {
|
|
|
95
123
|
let tmpHome: string;
|
|
96
124
|
let prevHome: string | undefined;
|
|
97
125
|
let prevHubOrigin: string | undefined;
|
|
98
|
-
let fixture:
|
|
126
|
+
let fixture: HubFixture;
|
|
99
127
|
let kp: Keypair;
|
|
100
128
|
|
|
101
129
|
beforeEach(async () => {
|
|
@@ -109,10 +137,11 @@ beforeEach(async () => {
|
|
|
109
137
|
clearVaultStoreCache();
|
|
110
138
|
|
|
111
139
|
kp = await makeKeypair("k1");
|
|
112
|
-
fixture =
|
|
140
|
+
fixture = startHubFixture([kp]);
|
|
113
141
|
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
114
142
|
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
115
143
|
resetJwksCache();
|
|
144
|
+
resetRevocationCache();
|
|
116
145
|
});
|
|
117
146
|
|
|
118
147
|
afterEach(() => {
|
|
@@ -228,4 +257,104 @@ describe("authenticateVaultRequest — hub JWT integration", () => {
|
|
|
228
257
|
expect(body.message).toContain("/vault/<name>");
|
|
229
258
|
}
|
|
230
259
|
});
|
|
260
|
+
|
|
261
|
+
test("revoked jti → 401 sanitized; full diagnostic (with jti) routed to console.warn audit log", async () => {
|
|
262
|
+
seedVault("journal");
|
|
263
|
+
const revokedJti = "jti-revoked-by-operator";
|
|
264
|
+
fixture.setRevoked([revokedJti]);
|
|
265
|
+
const token = await signJwt(kp, {
|
|
266
|
+
iss: fixture.origin,
|
|
267
|
+
aud: "vault.journal",
|
|
268
|
+
scope: "vault:journal:read",
|
|
269
|
+
jti: revokedJti,
|
|
270
|
+
});
|
|
271
|
+
const config = readVaultConfig("journal")!;
|
|
272
|
+
const store = getVaultStore("journal");
|
|
273
|
+
|
|
274
|
+
// Spy + suppress so the assertion is the audit-trail invariant for
|
|
275
|
+
// this scenario, not a stderr inspection. Pattern carries to scribe/agent.
|
|
276
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
277
|
+
try {
|
|
278
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
279
|
+
expect("error" in result).toBe(true);
|
|
280
|
+
if ("error" in result) {
|
|
281
|
+
expect(result.error.status).toBe(401);
|
|
282
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
283
|
+
expect(body.error).toBe("Unauthorized");
|
|
284
|
+
// Client-facing message must NOT carry the jti — that's a server-side
|
|
285
|
+
// audit-log concern only. See the `code === "revoked"` branch in
|
|
286
|
+
// authenticateHubJwt for the sanitization.
|
|
287
|
+
expect(body.message).toBe("token has been revoked");
|
|
288
|
+
expect(body.message).not.toContain(revokedJti);
|
|
289
|
+
}
|
|
290
|
+
// Audit-log invariant: console.warn fires exactly once with a message
|
|
291
|
+
// that carries the jti, so an operator chasing a 401 in production logs
|
|
292
|
+
// can correlate to which token was retired.
|
|
293
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
294
|
+
const warnArg = warnSpy.mock.calls[0]![0] as string;
|
|
295
|
+
expect(warnArg).toContain(revokedJti);
|
|
296
|
+
expect(warnArg).toContain("revoked");
|
|
297
|
+
} finally {
|
|
298
|
+
warnSpy.mockRestore();
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test("non-revoked jti against populated list → still honored (happy path with active revocations)", async () => {
|
|
303
|
+
seedVault("journal");
|
|
304
|
+
fixture.setRevoked(["some-other-revoked-jti"]);
|
|
305
|
+
const token = await signJwt(kp, {
|
|
306
|
+
iss: fixture.origin,
|
|
307
|
+
aud: "vault.journal",
|
|
308
|
+
scope: "vault:journal:write",
|
|
309
|
+
jti: "jti-still-good",
|
|
310
|
+
});
|
|
311
|
+
const config = readVaultConfig("journal")!;
|
|
312
|
+
const store = getVaultStore("journal");
|
|
313
|
+
|
|
314
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
315
|
+
expect("error" in result).toBe(false);
|
|
316
|
+
if (!("error" in result)) {
|
|
317
|
+
expect(result.permission).toBe("full");
|
|
318
|
+
expect(result.scopes).toEqual(["vault:journal:write"]);
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("revocation list unreachable on cold start → fail-closed 401 sanitized; full diagnostic routed to console.warn", async () => {
|
|
323
|
+
seedVault("journal");
|
|
324
|
+
// Hub is reachable for JWKS but the revocation endpoint 503s. Cold cache
|
|
325
|
+
// + first-fetch-fail = "unknown" outcome, surfaced as
|
|
326
|
+
// HubJwtError(code: "revocation_unavailable"). Client gets a code-shaped
|
|
327
|
+
// sentence; the implementation-detail phrasing ("no last-good cache")
|
|
328
|
+
// stays in the server-side audit log.
|
|
329
|
+
fixture.setRevocationFails(true);
|
|
330
|
+
const token = await signJwt(kp, {
|
|
331
|
+
iss: fixture.origin,
|
|
332
|
+
aud: "vault.journal",
|
|
333
|
+
scope: "vault:journal:read",
|
|
334
|
+
});
|
|
335
|
+
const config = readVaultConfig("journal")!;
|
|
336
|
+
const store = getVaultStore("journal");
|
|
337
|
+
|
|
338
|
+
const warnSpy = spyOn(console, "warn").mockImplementation(() => {});
|
|
339
|
+
try {
|
|
340
|
+
const result = await authenticateVaultRequest(bearer(token), config, store.db);
|
|
341
|
+
expect("error" in result).toBe(true);
|
|
342
|
+
if ("error" in result) {
|
|
343
|
+
expect(result.error.status).toBe(401);
|
|
344
|
+
const body = (await result.error.json()) as { error: string; message: string };
|
|
345
|
+
// Client message: code-shaped, no internals.
|
|
346
|
+
expect(body.message).toBe("token cannot be validated: revocation list unavailable");
|
|
347
|
+
// The internal phrase "no last-good cache" is a scope-guard
|
|
348
|
+
// implementation detail and must not leak into the public response.
|
|
349
|
+
expect(body.message).not.toContain("last-good cache");
|
|
350
|
+
}
|
|
351
|
+
// Audit-log invariant: full diagnostic routed to console.warn so
|
|
352
|
+
// operators can distinguish cold-start from sustained outage.
|
|
353
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
354
|
+
const warnArg = warnSpy.mock.calls[0]![0] as string;
|
|
355
|
+
expect(warnArg).toContain("no last-good cache");
|
|
356
|
+
} finally {
|
|
357
|
+
warnSpy.mockRestore();
|
|
358
|
+
}
|
|
359
|
+
});
|
|
231
360
|
});
|
package/src/auth.ts
CHANGED
|
@@ -275,6 +275,35 @@ async function authenticateHubJwt(
|
|
|
275
275
|
return { permission, scopes: claims.scopes, legacyDerived: false, scoped_tags: null, vault_name: null };
|
|
276
276
|
} catch (err) {
|
|
277
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
|
+
}
|
|
278
307
|
return { error: Response.json({ error: "Unauthorized", message: err.message }, { status: 401 }) };
|
|
279
308
|
}
|
|
280
309
|
// Unknown failure shape — surface the message but stay 401.
|