@openparachute/vault 0.4.0 → 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.
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Vault projection — computes a comprehensive description of the vault
3
+ * (tags-with-schemas + effective inheritance + indexed-field catalog +
4
+ * query hints) shared by two consumers:
5
+ *
6
+ * - `vault-info` MCP tool — returns the full projection as a JSON object
7
+ * so an agent can request a refresh mid-session.
8
+ * - `getServerInstruction` (MCP `initialize` response) — renders the
9
+ * same projection as a terse markdown brief sent once at connect.
10
+ *
11
+ * The projection lives outside the per-note path: it describes what kinds
12
+ * of notes and queries are available, not the contents. Nothing here
13
+ * depends on auth/scopes — both consumers compose this with vault config
14
+ * (name/description) and any policy-driven framing on top.
15
+ *
16
+ * See vault#271 for design notes.
17
+ */
18
+
19
+ import { Database } from "bun:sqlite";
20
+ import {
21
+ loadSchemaConfig,
22
+ resolveNoteSchemas,
23
+ walkAncestors,
24
+ type ResolvedSchemas,
25
+ type SchemaField,
26
+ } from "./schema-defaults.ts";
27
+ import { listIndexedFields } from "./indexed-fields.ts";
28
+ import {
29
+ listTagRecords,
30
+ type TagFieldSchema,
31
+ type TagRecord,
32
+ } from "./tag-schemas.ts";
33
+ import { DEFAULT_TAG_NAME } from "./tag-hierarchy.ts";
34
+ import * as noteOps from "./notes.ts";
35
+ import type { VaultStats } from "./types.ts";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Types
39
+ // ---------------------------------------------------------------------------
40
+
41
+ export interface ProjectionTag {
42
+ name: string;
43
+ description: string | null;
44
+ /** Direct parents declared in `tags.parent_names` (verbatim, no walk). */
45
+ parents: string[];
46
+ /**
47
+ * Walk-order ancestor closure (parents → grandparents → …) including the
48
+ * implicit `_default` universal parent when present, with the tag itself
49
+ * excluded. Empty when the tag has no parents and no `_default` exists.
50
+ */
51
+ effective_parents: string[];
52
+ /**
53
+ * Own field declarations (verbatim from `tags.fields`). Carries the full
54
+ * `TagFieldSchema` shape — `type` (string), optional `description`,
55
+ * `enum`, `indexed`. Width matches the on-disk row.
56
+ */
57
+ fields: Record<string, TagFieldSchema> | null;
58
+ /**
59
+ * Merged field map = own ∪ inherited (first-in-walk wins, matching
60
+ * `resolveNoteSchemas`). Uses the `SchemaField` view returned by the
61
+ * resolver (narrower `type` enum). Empty when no ancestor — including
62
+ * the tag itself — declared anything.
63
+ */
64
+ effective_fields: Record<string, SchemaField>;
65
+ relationships: TagRecord["relationships"] | null;
66
+ }
67
+
68
+ export interface ProjectionIndexedField {
69
+ name: string;
70
+ /** User-facing field type ("string" | "integer" | "boolean") drawn from the first declarer. */
71
+ type: string;
72
+ tags: string[];
73
+ }
74
+
75
+ export interface VaultProjection {
76
+ tags: ProjectionTag[];
77
+ indexed_fields: ProjectionIndexedField[];
78
+ query_hints: string[];
79
+ /** Included when the caller requests stats; omitted otherwise. */
80
+ stats?: VaultStats;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Inheritance helpers (built on the #272 resolver)
85
+ // ---------------------------------------------------------------------------
86
+
87
+ /**
88
+ * Resolve a single tag's effective inheritance.
89
+ *
90
+ * Built on top of `resolveNoteSchemas({ tags: [tag] })` so the walk order
91
+ * and conflict precedence match the runtime validator exactly. Returns:
92
+ *
93
+ * - `effective_parents`: walk-order ancestor list with the tag itself
94
+ * excluded. Includes `_default` when a `_default` row exists, regardless
95
+ * of whether the tag declares it (universal-parent semantics).
96
+ * - `effective_fields`: merged field map (first-in-walk wins). When no
97
+ * ancestor contributes, this equals the tag's own `fields`.
98
+ */
99
+ export function resolveTagInheritance(
100
+ resolved: ResolvedSchemas,
101
+ tag: string,
102
+ ): { effective_parents: string[]; effective_fields: Record<string, SchemaField> } {
103
+ const resolution = resolveNoteSchemas(resolved, { tags: [tag] });
104
+
105
+ // resolveNoteSchemas returns effectiveTags (only fields-contributing tags).
106
+ // We need the full walk for effective_parents — replay using the same
107
+ // resolver helper so walk-order semantics stay in lockstep with #270.
108
+ const visited = new Set<string>();
109
+ const order: string[] = [];
110
+ walkAncestors(tag, resolved, visited, order);
111
+ if (resolved.allTags.has(DEFAULT_TAG_NAME) && !visited.has(DEFAULT_TAG_NAME)) {
112
+ walkAncestors(DEFAULT_TAG_NAME, resolved, visited, order);
113
+ }
114
+ const effective_parents = order.filter((t) => t !== tag);
115
+
116
+ const effective_fields: Record<string, SchemaField> = {};
117
+ for (const [field, { spec }] of resolution.mergedFields) {
118
+ effective_fields[field] = spec;
119
+ }
120
+
121
+ return { effective_parents, effective_fields };
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Projection
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Static query-hint catalog. Sent verbatim in both vault-info JSON and the
130
+ * connect-time markdown projection so an agent can self-orient without
131
+ * reading source. Edit here when query semantics change.
132
+ */
133
+ export const QUERY_HINTS: readonly string[] = [
134
+ "query-notes { tag: \"X\" } — all notes with tag X (includes descendants per inheritance)",
135
+ "query-notes { tag: \"X\", metadata: { field: { op: value } } } — operator queries on indexed fields (eq/ne/gt/gte/lt/lte/in/not_in/exists)",
136
+ "query-notes { search: \"...\" } — full-text search across content",
137
+ "query-notes { near: { id: \"...\" }, depth: 2 } — graph neighborhood within N hops",
138
+ "query-notes { id: \"<note-id-or-path>\" } — fetch a single note by ID or path",
139
+ ] as const;
140
+
141
+ /**
142
+ * Build the comprehensive vault projection. Pure read; no caches mutated.
143
+ *
144
+ * Shape rules:
145
+ *
146
+ * - `tags`: only tags carrying their own `description` or `fields`. A
147
+ * hierarchy-only tag (parent_names but no own schema) is omitted from
148
+ * the catalog — its semantics live in whichever ancestor contributes
149
+ * fields. `effective_fields` still surfaces the merged spec when the
150
+ * tag *does* appear (because it has its own description/fields).
151
+ *
152
+ * - `indexed_fields`: one entry per row in the `indexed_fields` table.
153
+ * The user-facing `type` is drawn from the first declarer's spec —
154
+ * declarers must agree on type (enforced at write time) so picking the
155
+ * first is unambiguous. `tags` lists every declarer, sorted.
156
+ *
157
+ * - `stats`: included when `opts.includeStats === true`. Uses the
158
+ * existing `getVaultStats` shape unchanged — camelCase keys, full
159
+ * monthly distribution.
160
+ */
161
+ export function buildVaultProjection(
162
+ db: Database,
163
+ opts?: { includeStats?: boolean },
164
+ ): VaultProjection {
165
+ const resolved = loadSchemaConfig(db);
166
+ const records = listTagRecords(db);
167
+
168
+ const tags: ProjectionTag[] = [];
169
+ for (const r of records) {
170
+ const hasOwnSchema =
171
+ (r.description !== undefined && r.description !== null) ||
172
+ (r.fields !== undefined && r.fields !== null && Object.keys(r.fields).length > 0);
173
+ if (!hasOwnSchema) continue;
174
+
175
+ const { effective_parents, effective_fields } = resolveTagInheritance(resolved, r.tag);
176
+
177
+ tags.push({
178
+ name: r.tag,
179
+ description: r.description ?? null,
180
+ parents: r.parent_names ?? [],
181
+ effective_parents,
182
+ fields: r.fields ?? null,
183
+ effective_fields,
184
+ relationships: r.relationships ?? null,
185
+ });
186
+ }
187
+
188
+ const indexedRows = listIndexedFields(db);
189
+ const indexed_fields: ProjectionIndexedField[] = indexedRows.map((row) => {
190
+ const declarers = [...row.declarerTags].sort();
191
+ // Recover the user-facing type from the first declarer's spec. Falls
192
+ // back to a sqlite-derived label if the declarer's row is gone (race;
193
+ // shouldn't happen because release drops the indexed_fields row, but
194
+ // robust against drift).
195
+ let userType = sqliteToUserType(row.sqliteType);
196
+ for (const t of declarers) {
197
+ const fields = resolved.tagToFields.get(t);
198
+ const declared = fields?.[row.field]?.type;
199
+ if (typeof declared === "string" && declared.length > 0) {
200
+ userType = declared;
201
+ break;
202
+ }
203
+ }
204
+ return { name: row.field, type: userType, tags: declarers };
205
+ });
206
+
207
+ const projection: VaultProjection = {
208
+ tags,
209
+ indexed_fields,
210
+ query_hints: [...QUERY_HINTS],
211
+ };
212
+
213
+ if (opts?.includeStats) {
214
+ projection.stats = noteOps.getVaultStats(db);
215
+ }
216
+
217
+ return projection;
218
+ }
219
+
220
+ function sqliteToUserType(t: string): string {
221
+ if (t === "TEXT") return "string";
222
+ if (t === "INTEGER") return "integer";
223
+ return t.toLowerCase();
224
+ }
225
+
226
+ // ---------------------------------------------------------------------------
227
+ // Markdown rendering — for getServerInstruction
228
+ // ---------------------------------------------------------------------------
229
+
230
+ /**
231
+ * Render a vault projection as a terse markdown brief for the MCP
232
+ * `initialize` response. Keep dense — agents see this once at connect, and
233
+ * the goal is "everything an agent needs to start using the vault sensibly,
234
+ * with explicit pointers for refresh."
235
+ *
236
+ * Token budget guideline: ~600 tokens for a small vault (Aaron's, ~4 tags-
237
+ * with-schemas) and under ~5K tokens at 50 tags-with-schemas. Listing all
238
+ * tags-with-schemas inline is the default; cap heuristics can be added if
239
+ * a real test shape demands it.
240
+ */
241
+ export function projectionToMarkdown(args: {
242
+ vaultName: string;
243
+ description?: string | null;
244
+ projection: VaultProjection;
245
+ }): string {
246
+ const { vaultName, description, projection } = args;
247
+ const stats = projection.stats;
248
+
249
+ const lines: string[] = [];
250
+ lines.push(`You are connected to Parachute Vault "${vaultName}".`);
251
+ if (description && description.trim().length > 0) {
252
+ lines.push("");
253
+ lines.push(description.trim());
254
+ }
255
+
256
+ lines.push("");
257
+ lines.push("## Quick orientation (call `vault-info` for full schema)");
258
+ lines.push("");
259
+
260
+ if (stats) {
261
+ // Two distinct counts surface here so an agent doesn't conflate
262
+ // them (vault#274): `tagCount` is "tags ANY note uses" — driven by
263
+ // note_tags rows. `projection.tags.length` is "tags carrying a
264
+ // schema declaration" — strictly smaller and the relevant denominator
265
+ // for the schema-bearing list a few lines down. Showing only one
266
+ // hid the gap (e.g., 100 tags but only 5 with schemas read as
267
+ // "100 tags with schemas").
268
+ const noteCount = stats.totalNotes;
269
+ const tagCount = stats.tagCount;
270
+ const withSchemas = projection.tags.length;
271
+ const noteLabel = noteCount === 1 ? "note" : "notes";
272
+ const tagLabel = tagCount === 1 ? "tag" : "tags";
273
+ const tagSuffix = withSchemas > 0 ? `, ${withSchemas} with schemas` : "";
274
+ lines.push(`- ${noteCount} ${noteLabel}, ${tagCount} ${tagLabel} total${tagSuffix}`);
275
+ } else {
276
+ lines.push(`- (call \`vault-info { include_stats: true }\` for note/tag counts)`);
277
+ }
278
+
279
+ if (projection.tags.length === 0) {
280
+ lines.push(`- No tag schemas declared yet — every note is freeform.`);
281
+ } else {
282
+ const names = projection.tags.map((t) => t.name).join(", ");
283
+ lines.push(`- ${projection.tags.length} tag${projection.tags.length === 1 ? "" : "s"} with schemas: ${names}`);
284
+ }
285
+
286
+ if (projection.indexed_fields.length > 0) {
287
+ lines.push(`- Indexed metadata fields (queryable with operators):`);
288
+ for (const f of projection.indexed_fields) {
289
+ const declarers = f.tags.map((t) => `#${t}`).join(", ");
290
+ lines.push(` - ${f.name} (${f.type}; from ${declarers})`);
291
+ }
292
+ } else {
293
+ lines.push(`- No indexed metadata fields.`);
294
+ }
295
+
296
+ lines.push("");
297
+ lines.push("## Querying");
298
+ lines.push("");
299
+ for (const hint of projection.query_hints) {
300
+ lines.push(`- \`${hint}\``);
301
+ }
302
+
303
+ lines.push("");
304
+ lines.push("## Refreshing context");
305
+ lines.push("");
306
+ lines.push("If schema or tags change during this session, call `vault-info` to refresh the full projection. Call `list-tags { include_schema: true }` for tag-only details.");
307
+
308
+ return lines.join("\n");
309
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.4.0",
3
+ "version": "0.4.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@modelcontextprotocol/sdk": "^1.12.1",
26
- "@openparachute/scope-guard": "^0.1.0",
26
+ "@openparachute/scope-guard": "^0.2.0",
27
27
  "jose": "^6.2.2",
28
28
  "otpauth": "^9.5.0",
29
29
  "qrcode-terminal": "^0.12.0"
@@ -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 JWKS fixture, like the auth.test
15
- * peer file. The JWKS fixture mirrors the one in hub-jwt.test.ts; duplicating
16
- * ~30 lines is cheaper than introducing a shared test-helper module.
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 JwksFixture {
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 startJwksFixture(keys: Keypair[]): JwksFixture {
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 !== "/.well-known/jwks.json") {
56
- return new Response("not found", { status: 404 });
66
+ if (url.pathname === "/.well-known/jwks.json") {
67
+ return Response.json({ keys: keys.map((k) => k.publicJwk) });
57
68
  }
58
- return Response.json({ keys: keys.map((k) => k.publicJwk) });
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: JwksFixture;
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 = startJwksFixture([kp]);
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.
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
16
16
  import { generateKeyPair, exportJWK, SignJWT } from "jose";
17
- import { resetJwksCache, validateHubJwt, looksLikeJwt } from "./hub-jwt.ts";
17
+ import { resetJwksCache, resetRevocationCache, validateHubJwt, looksLikeJwt } from "./hub-jwt.ts";
18
18
 
19
19
  interface Keypair {
20
20
  privateKey: CryptoKey;
@@ -53,11 +53,19 @@ function startJwksFixture(): JwksFixture {
53
53
  port: 0,
54
54
  fetch(req) {
55
55
  const url = new URL(req.url);
56
- if (url.pathname !== "/.well-known/jwks.json") {
57
- return new Response("not found", { status: 404 });
58
- }
59
56
  if (down) return new Response("upstream down", { status: 503 });
60
- return Response.json({ keys: keys.map((k) => k.publicJwk) });
57
+ if (url.pathname === "/.well-known/jwks.json") {
58
+ return Response.json({ keys: keys.map((k) => k.publicJwk) });
59
+ }
60
+ // scope-guard 0.2+ consults `/.well-known/parachute-revocation.json` on
61
+ // every JWT validation (when the token has a jti). Serve an empty list
62
+ // by default so unrelated tests in this file aren't fail-closed by a
63
+ // 404 on that endpoint. The integration tests (`auth-hub-jwt.test.ts`)
64
+ // own the revoked-jti / fail-closed cases separately.
65
+ if (url.pathname === "/.well-known/parachute-revocation.json") {
66
+ return Response.json({ generated_at: new Date().toISOString(), jtis: [] });
67
+ }
68
+ return new Response("not found", { status: 404 });
61
69
  },
62
70
  });
63
71
  return {
@@ -121,6 +129,9 @@ beforeEach(() => {
121
129
  fixture.setUnreachable(false);
122
130
  fixture.setKeys([kp]);
123
131
  resetJwksCache();
132
+ // Drop the per-process revocation cache so each test starts cold against
133
+ // the fixture (an empty list by default; tests opt into populated lists).
134
+ resetRevocationCache();
124
135
  });
125
136
 
126
137
  describe("looksLikeJwt", () => {
package/src/hub-jwt.ts CHANGED
@@ -75,5 +75,14 @@ export function resetJwksCache(): void {
75
75
  guard.resetJwksCache();
76
76
  }
77
77
 
78
+ /**
79
+ * Reset the cached revocation list. Tests use this to start from a clean
80
+ * fail-closed state between cases; production callers shouldn't need it
81
+ * (the cache refreshes itself on TTL expiry).
82
+ */
83
+ export function resetRevocationCache(): void {
84
+ guard.resetRevocationCache();
85
+ }
86
+
78
87
  export { HubJwtError, looksLikeJwt };
79
88
  export type { HubJwtClaims, ValidateHubJwtOptions };
package/src/mcp-http.ts CHANGED
@@ -41,7 +41,6 @@ const TOOL_REQUIRED_VERB: Record<string, VaultVerb> = {
41
41
  "query-notes": "read",
42
42
  "list-tags": "read",
43
43
  "find-path": "read",
44
- "synthesize-notes": "read",
45
44
  "vault-info": "read",
46
45
  "create-note": "write",
47
46
  "update-note": "write",
@@ -58,7 +57,10 @@ function requiredVerbForTool(toolName: string): VaultVerb {
58
57
 
59
58
  /** Handle scoped MCP at /vault/{name}/mcp (single vault). */
60
59
  export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
61
- const instruction = getServerInstruction(vaultName);
60
+ // Auth flows through to getServerInstruction so the connect-time
61
+ // markdown brief is filtered by `scoped_tags` — symmetric with the
62
+ // JSON `vault-info` wrapper.
63
+ const instruction = await getServerInstruction(vaultName, auth);
62
64
  return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
63
65
  }
64
66