@openparachute/vault 0.5.3-rc.1 → 0.5.3-rc.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.
@@ -2067,6 +2067,41 @@ describe("MCP tools", async () => {
2067
2067
  expect(result[1].tags).toContain("doc");
2068
2068
  });
2069
2069
 
2070
+ // vault#316 — the create-note tool re-reads each note AFTER
2071
+ // `applySchemaDefaults` runs, so the response reflects the post-defaults
2072
+ // on-disk state (matching the update-note path). Before the fix the
2073
+ // response mapped over the pre-defaults in-memory objects, so a
2074
+ // schema-default-filled field was missing from the returned note even
2075
+ // though it had just been written to disk.
2076
+ it("create-note response reflects post-applySchemaDefaults state (vault#316)", async () => {
2077
+ await store.upsertTagSchema("task", {
2078
+ fields: { priority: { type: "string", enum: ["high", "low"] } },
2079
+ });
2080
+ const tools = generateMcpTools(store);
2081
+ const createNote = tools.find((t) => t.name === "create-note")!;
2082
+
2083
+ // Single: default lands in the returned metadata.
2084
+ const single = await createNote.execute({
2085
+ content: "do the thing",
2086
+ path: "Inbox/task-1",
2087
+ tags: ["task"],
2088
+ }) as any;
2089
+ expect(single.metadata?.priority).toBe("high"); // first enum value
2090
+ // Disk and response agree.
2091
+ const onDisk = await store.getNoteByPath("Inbox/task-1");
2092
+ expect((onDisk!.metadata as any)?.priority).toBe("high");
2093
+
2094
+ // Batch: each entry is re-read post-defaults too.
2095
+ const batch = await createNote.execute({
2096
+ notes: [
2097
+ { content: "a", path: "Inbox/task-2", tags: ["task"] },
2098
+ { content: "b", path: "Inbox/task-3", tags: ["task"] },
2099
+ ],
2100
+ }) as any[];
2101
+ expect(batch[0].metadata?.priority).toBe("high");
2102
+ expect(batch[1].metadata?.priority).toBe("high");
2103
+ });
2104
+
2070
2105
  it("create-note accepts extension field (vault#328)", async () => {
2071
2106
  const tools = generateMcpTools(store);
2072
2107
  const createNote = tools.find((t) => t.name === "create-note")!;
@@ -101,7 +101,7 @@ export function listIndexedFields(db: Database): IndexedField[] {
101
101
  export function getIndexedField(db: Database, field: string): IndexedField | null {
102
102
  const row = db
103
103
  .prepare("SELECT field, sqlite_type, declarer_tags FROM indexed_fields WHERE field = ?")
104
- .get(field) as IndexedFieldRow | undefined;
104
+ .get(field) as IndexedFieldRow | null;
105
105
  return row ? rowToField(row) : null;
106
106
  }
107
107
 
package/core/src/links.ts CHANGED
@@ -106,7 +106,7 @@ function parseMetadata(raw: string | null): Record<string, unknown> | undefined
106
106
  function getNoteSummary(db: Database, noteId: string): NoteSummary | undefined {
107
107
  const row = db.prepare(
108
108
  "SELECT id, path, metadata, created_at, updated_at FROM notes WHERE id = ?",
109
- ).get(noteId) as SummaryRow | undefined;
109
+ ).get(noteId) as SummaryRow | null;
110
110
  if (!row) return undefined;
111
111
  return {
112
112
  id: row.id,
package/core/src/mcp.ts CHANGED
@@ -131,7 +131,7 @@ export interface GenerateMcpToolsOpts {
131
131
  * delete-tag, find-path, vault-info, prune-schema (admin).
132
132
  */
133
133
  export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
134
- const db: Database = (store as any).db;
134
+ const db: Database = store.db;
135
135
  const expandVisibility = opts?.expandVisibility;
136
136
  const nearTraversable = opts?.nearTraversable;
137
137
 
@@ -591,17 +591,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
591
591
  throw e;
592
592
  }
593
593
 
594
- // Apply tag schema effects
594
+ // Apply tag schema effects, then re-read the notes whose metadata was
595
+ // actually default-filled so the response reflects the final on-disk
596
+ // state (the `created` entries were read before `applySchemaDefaults`
597
+ // ran, so default-filled metadata isn't on them yet). This mirrors the
598
+ // update-note path, which already re-reads post-defaults. The re-read
599
+ // is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
600
+ // entirely when no defaults were applied, so the common no-defaults
601
+ // path adds zero extra reads.
602
+ const mutatedIds = new Set<string>();
595
603
  for (const note of created) {
596
604
  if (note.tags && note.tags.length > 0) {
597
- await applySchemaDefaults(store, db, [note.id], note.tags);
605
+ for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
606
+ mutatedIds.add(id);
607
+ }
598
608
  }
599
609
  }
600
-
601
- // Re-read after schema-default population so the response reflects the
602
- // final on-disk state, then attach `validation_status` from any
603
- // tag's `fields` declaration that applies to this note.
604
- const final = created.map((n) => attachValidationStatus(store, db, n));
610
+ const refreshed =
611
+ mutatedIds.size === 0
612
+ ? created
613
+ : (() => {
614
+ const byId = new Map(
615
+ noteOps.getNotes(db, [...mutatedIds]).map((n) => [n.id, n]),
616
+ );
617
+ return created.map((n) => byId.get(n.id) ?? n);
618
+ })();
619
+
620
+ // Attach `validation_status` from any tag's `fields` declaration that
621
+ // applies to this note, against the post-defaults state.
622
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
605
623
  return batch ? final : final[0];
606
624
  },
607
625
  },
@@ -1406,9 +1424,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1406
1424
  // Tag schema effects — auto-populate defaults when tags are applied
1407
1425
  // ---------------------------------------------------------------------------
1408
1426
 
1409
- async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<void> {
1427
+ /**
1428
+ * Fill schema-declared default values into the metadata of the given notes
1429
+ * for any field they omitted. Returns the IDs of the notes whose metadata was
1430
+ * actually written — callers use this to re-read ONLY the mutated notes (and
1431
+ * to skip the re-read entirely when nothing changed). The common no-schema /
1432
+ * no-defaults path returns an empty array.
1433
+ */
1434
+ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1410
1435
  const schemas = tagSchemaOps.getTagSchemaMap(db);
1411
- if (Object.keys(schemas).length === 0) return;
1436
+ if (Object.keys(schemas).length === 0) return [];
1412
1437
 
1413
1438
  const defaults: Record<string, unknown> = {};
1414
1439
  for (const tag of tags) {
@@ -1420,8 +1445,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1420
1445
  }
1421
1446
  }
1422
1447
  }
1423
- if (Object.keys(defaults).length === 0) return;
1448
+ if (Object.keys(defaults).length === 0) return [];
1424
1449
 
1450
+ const mutated: string[] = [];
1425
1451
  for (const noteId of noteIds) {
1426
1452
  const note = noteOps.getNote(db, noteId);
1427
1453
  if (!note) continue;
@@ -1437,7 +1463,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1437
1463
  metadata: { ...existing, ...missing },
1438
1464
  skipUpdatedAt: true,
1439
1465
  });
1466
+ mutated.push(noteId);
1440
1467
  }
1468
+ return mutated;
1441
1469
  }
1442
1470
 
1443
1471
  function defaultForField(field: { type: string; enum?: string[] }): unknown {
package/core/src/notes.ts CHANGED
@@ -83,7 +83,7 @@ export function createNote(
83
83
  }
84
84
 
85
85
  export function getNote(db: Database, id: string): Note | null {
86
- const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | undefined;
86
+ const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | null;
87
87
  if (!row) return null;
88
88
 
89
89
  const note = rowToNote(row);
@@ -111,7 +111,7 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
111
111
  if (extension !== undefined) {
112
112
  const row = db.prepare(
113
113
  "SELECT * FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
114
- ).get(path, extension.toLowerCase()) as NoteRow | undefined;
114
+ ).get(path, extension.toLowerCase()) as NoteRow | null;
115
115
  if (!row) return null;
116
116
  const note = rowToNote(row);
117
117
  note.tags = getNoteTags(db, note.id);
@@ -459,7 +459,7 @@ export function updateNote(
459
459
  if (isPathUniqueError(err)) {
460
460
  const conflictPath = updates.path !== undefined
461
461
  ? (normalizePath(updates.path) ?? updates.path)
462
- : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | undefined)?.path ?? "<unknown>");
462
+ : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | null)?.path ?? "<unknown>");
463
463
  throw new PathConflictError(conflictPath);
464
464
  }
465
465
  throw err;
@@ -475,7 +475,7 @@ export function updateNote(
475
475
  function throwConflictOrMissing(db: Database, id: string, expected: string): never {
476
476
  const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
477
477
  | { updated_at: string | null; path: string | null }
478
- | undefined;
478
+ | null;
479
479
  if (!row) {
480
480
  throw new Error(`Note not found: "${id}"`);
481
481
  }
@@ -972,7 +972,7 @@ export function listTags(db: Database): { name: string; count: number }[] {
972
972
  export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
973
973
  const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
974
974
  | { fields: string | null }
975
- | undefined;
975
+ | null;
976
976
  if (!row) return { deleted: false, notes_untagged: 0 };
977
977
 
978
978
  const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
@@ -1133,7 +1133,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1133
1133
  for (const { from, to } of renames) {
1134
1134
  const old = readStmt.get(from) as
1135
1135
  | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
1136
- | undefined;
1136
+ | null;
1137
1137
  insertStmt.run(
1138
1138
  to,
1139
1139
  old?.description ?? null,
@@ -1548,11 +1548,11 @@ export function getVaultStats(
1548
1548
 
1549
1549
  const earliestRow = db.prepare(
1550
1550
  "SELECT id, created_at FROM notes ORDER BY created_at ASC, id ASC LIMIT 1",
1551
- ).get() as { id: string; created_at: string } | undefined;
1551
+ ).get() as { id: string; created_at: string } | null;
1552
1552
 
1553
1553
  const latestRow = db.prepare(
1554
1554
  "SELECT id, created_at FROM notes ORDER BY created_at DESC, id DESC LIMIT 1",
1555
- ).get() as { id: string; created_at: string } | undefined;
1555
+ ).get() as { id: string; created_at: string } | null;
1556
1556
 
1557
1557
  const monthRows = db.prepare(`
1558
1558
  SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
@@ -797,7 +797,7 @@ function migrateToV15(db: Database): void {
797
797
  // 2. Copy `_schema_defaults` note → schema_mappings.
798
798
  const mappingNote = db.prepare(
799
799
  "SELECT metadata FROM notes WHERE path = '_schema_defaults'",
800
- ).get() as { metadata: string | null } | undefined;
800
+ ).get() as { metadata: string | null } | null;
801
801
  if (mappingNote?.metadata) {
802
802
  const insertMapping = db.prepare(
803
803
  "INSERT OR IGNORE INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
package/core/src/store.ts CHANGED
@@ -730,7 +730,7 @@ export class BunSqliteStore implements Store {
730
730
  // Scope by noteId so a token authorized for note A can't delete note B's attachments.
731
731
  const row = this.db.prepare(
732
732
  "SELECT path FROM attachments WHERE id = ? AND note_id = ?",
733
- ).get(attachmentId, noteId) as { path: string } | undefined;
733
+ ).get(attachmentId, noteId) as { path: string } | null;
734
734
  if (!row) return { deleted: false, path: null, orphaned: false };
735
735
 
736
736
  this.db.prepare("DELETE FROM attachments WHERE id = ? AND note_id = ?").run(attachmentId, noteId);
@@ -756,7 +756,7 @@ export class BunSqliteStore implements Store {
756
756
  async getAttachment(attachmentId: string): Promise<Attachment | null> {
757
757
  const row = this.db.prepare(
758
758
  "SELECT * FROM attachments WHERE id = ?",
759
- ).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | undefined;
759
+ ).get(attachmentId) as { id: string; note_id: string; path: string; mime_type: string; metadata: string | null; created_at: string } | null;
760
760
  if (!row) return null;
761
761
  let metadata: Record<string, unknown> | undefined;
762
762
  if (row.metadata && row.metadata !== "{}") {
@@ -115,7 +115,7 @@ export function listTagRecords(db: Database): TagRecord[] {
115
115
  export function getTagRecord(db: Database, tag: string): TagRecord | null {
116
116
  const row = db.prepare(
117
117
  "SELECT name, description, fields, relationships, parent_names, created_at, updated_at FROM tags WHERE name = ?",
118
- ).get(tag) as TagRow | undefined;
118
+ ).get(tag) as TagRow | null;
119
119
  return row ? rowToRecord(row) : null;
120
120
  }
121
121
 
@@ -189,7 +189,7 @@ export function listTagSchemas(db: Database): TagSchema[] {
189
189
  export function getTagSchema(db: Database, tag: string): TagSchema | null {
190
190
  const row = db.prepare(
191
191
  "SELECT name, description, fields FROM tags WHERE name = ?",
192
- ).get(tag) as { name: string; description: string | null; fields: string | null } | undefined;
192
+ ).get(tag) as { name: string; description: string | null; fields: string | null } | null;
193
193
  if (!row) return null;
194
194
  if (row.description === null && row.fields === null) return null;
195
195
  return {
package/core/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { Database } from "bun:sqlite";
1
2
  import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
2
3
  import type { PrunedField } from "./indexed-fields.js";
3
4
 
@@ -208,6 +209,15 @@ export interface HydratedLink extends Link {
208
209
  // ---- Store Interface ----
209
210
 
210
211
  export interface Store {
212
+ /**
213
+ * The underlying `bun:sqlite` handle. Exposed (read-only) so callers that
214
+ * need to run a raw query the Store interface doesn't surface — e.g. the
215
+ * token-table reverse-lookups in routes.ts and MCP tool generation in
216
+ * mcp.ts — can reach it without an `(store as any).db` cast. The concrete
217
+ * `Store` class declares this as `public readonly db: Database`. See vault#242.
218
+ */
219
+ readonly db: Database;
220
+
211
221
  // Notes
212
222
  createNote(content: string, opts?: { id?: string; path?: string; tags?: string[]; metadata?: Record<string, unknown>; created_at?: string; extension?: string }): Promise<Note>;
213
223
  getNote(id: string): Promise<Note | null>;
@@ -137,7 +137,7 @@ export function resolveWikilink(db: Database, target: string): string | null {
137
137
  const extPart = extMatch[2]!.toLowerCase();
138
138
  const explicit = db.prepare(
139
139
  "SELECT id FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
140
- ).get(pathPart, extPart) as { id: string } | undefined;
140
+ ).get(pathPart, extPart) as { id: string } | null;
141
141
  if (explicit) return explicit.id;
142
142
  // No match for explicit (path, ext) — fall through to the looser
143
143
  // rules so a literal note named `Recipe.v2` (where `v2` isn't an
@@ -199,7 +199,7 @@ export function resolveWikilinkDetailed(db: Database, target: string): WikilinkR
199
199
  const extPart = extMatch[2]!.toLowerCase();
200
200
  const explicit = db.prepare(
201
201
  "SELECT id, path FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
202
- ).get(pathPart, extPart) as { id: string; path: string } | undefined;
202
+ ).get(pathPart, extPart) as { id: string; path: string } | null;
203
203
  if (explicit) {
204
204
  return { resolved: true, note_id: explicit.id, path: explicit.path, candidates: [] };
205
205
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.5.3-rc.1",
3
+ "version": "0.5.3-rc.3",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "dependencies": {
28
28
  "@modelcontextprotocol/sdk": "^1.12.1",
29
- "@openparachute/scope-guard": "^0.4.0",
29
+ "@openparachute/scope-guard": "^0.4.1-rc.1",
30
30
  "jose": "^6.2.2",
31
31
  "otpauth": "^9.5.0",
32
32
  "qrcode-terminal": "^0.12.0"
@@ -143,6 +143,7 @@ function bearer(token: string): Request {
143
143
  let tmpHome: string;
144
144
  let prevHome: string | undefined;
145
145
  let prevHubOrigin: string | undefined;
146
+ let prevJwksOrigin: string | undefined;
146
147
  let fixture: HubFixture;
147
148
  let kp: Keypair;
148
149
 
@@ -159,7 +160,11 @@ beforeEach(async () => {
159
160
  kp = await makeKeypair("k1");
160
161
  fixture = startHubFixture([kp]);
161
162
  prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
163
+ prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
162
164
  process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
165
+ // Post-vault#464 the JWKS fetch origin resolves separately (loopback by
166
+ // default); point it at the fixture so keys are reachable in-test.
167
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
163
168
  resetJwksCache();
164
169
  resetRevocationCache();
165
170
  });
@@ -171,6 +176,8 @@ afterEach(() => {
171
176
  else process.env.PARACHUTE_HOME = prevHome;
172
177
  if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
173
178
  else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
179
+ if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
180
+ else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
174
181
  if (existsSync(tmpHome)) rmSync(tmpHome, { recursive: true, force: true });
175
182
  });
176
183
 
@@ -14,7 +14,14 @@
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, resetRevocationCache, validateHubJwt, looksLikeJwt } from "./hub-jwt.ts";
17
+ import {
18
+ resetJwksCache,
19
+ resetRevocationCache,
20
+ validateHubJwt,
21
+ looksLikeJwt,
22
+ getHubOrigin,
23
+ getJwksOrigin,
24
+ } from "./hub-jwt.ts";
18
25
 
19
26
  interface Keypair {
20
27
  privateKey: CryptoKey;
@@ -113,6 +120,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
113
120
  let fixture: JwksFixture;
114
121
  let kp: Keypair;
115
122
  let prevHubOrigin: string | undefined;
123
+ let prevJwksOrigin: string | undefined;
116
124
 
117
125
  beforeAll(async () => {
118
126
  fixture = startJwksFixture();
@@ -124,12 +132,19 @@ afterAll(() => {
124
132
  fixture.stop();
125
133
  if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
126
134
  else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
135
+ if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
136
+ else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
127
137
  });
128
138
 
129
139
  beforeEach(() => {
130
- // Each test sets its own origin for clarity.
140
+ // Each test sets its own origin for clarity. Post-vault#464 the JWKS *fetch*
141
+ // origin is resolved separately from the iss-validation origin, so point the
142
+ // JWKS fetch at the fixture too — otherwise the guard would read keys from
143
+ // the loopback default (no JWKS server there) and every case would fail.
131
144
  prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
145
+ prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
132
146
  process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
147
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
133
148
  fixture.setUnreachable(false);
134
149
  fixture.setKeys([kp]);
135
150
  resetJwksCache();
@@ -153,6 +168,64 @@ describe("looksLikeJwt", () => {
153
168
  });
154
169
  });
155
170
 
171
+ describe("origin resolvers — iss/jwks split (vault#464)", () => {
172
+ test("getHubOrigin honors PARACHUTE_HUB_ORIGIN (iss-validation origin)", () => {
173
+ process.env.PARACHUTE_HUB_ORIGIN = "https://vault.example.com/";
174
+ expect(getHubOrigin()).toBe("https://vault.example.com");
175
+ });
176
+
177
+ test("getHubOrigin falls back to loopback when unset", () => {
178
+ delete process.env.PARACHUTE_HUB_ORIGIN;
179
+ expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
180
+ });
181
+
182
+ test("getJwksOrigin defaults to loopback (no env override)", () => {
183
+ delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
184
+ expect(getJwksOrigin()).toBe("http://127.0.0.1:1939");
185
+ });
186
+
187
+ test("getJwksOrigin honors PARACHUTE_HUB_JWKS_ORIGIN and strips trailing slash", () => {
188
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = "http://10.0.0.5:1939/";
189
+ expect(getJwksOrigin()).toBe("http://10.0.0.5:1939");
190
+ });
191
+
192
+ test("jwks fetch is decoupled from the iss origin: keys served ONLY at the jwks origin still validate a token whose iss is the (separate) public origin", async () => {
193
+ // Mirrors the vault#464 deploy shape: iss + revocation live at the public
194
+ // origin (the default `fixture` here), while the JWKS is reachable only at
195
+ // a SEPARATE jwks origin (a second fixture standing in for loopback). The
196
+ // guard must fetch keys from the jwks origin, not the iss origin.
197
+ const jwksOnly = startJwksFixture();
198
+ jwksOnly.setKeys([kp]);
199
+ // The public iss origin (default fixture) serves revocation but NOT keys —
200
+ // if the guard fetched JWKS from here, verification would fail "no key".
201
+ fixture.setKeys([]);
202
+ try {
203
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin; // iss + revocation
204
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = jwksOnly.origin; // keys only
205
+ resetJwksCache();
206
+ const token = await signJwt(kp, { iss: fixture.origin });
207
+ const claims = await validateHubJwt(token);
208
+ expect(claims.sub).toBe("user-1");
209
+ } finally {
210
+ jwksOnly.stop();
211
+ }
212
+ });
213
+
214
+ test("token whose iss does NOT match the iss origin is rejected even when keys resolve at the jwks origin", async () => {
215
+ const jwksOnly = startJwksFixture();
216
+ jwksOnly.setKeys([kp]);
217
+ try {
218
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
219
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = jwksOnly.origin;
220
+ resetJwksCache();
221
+ const token = await signJwt(kp, { iss: "https://attacker.example" });
222
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
223
+ } finally {
224
+ jwksOnly.stop();
225
+ }
226
+ });
227
+ });
228
+
156
229
  describe("validateHubJwt — happy path", () => {
157
230
  test("valid JWT with correct iss → claims surface", async () => {
158
231
  const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
package/src/hub-jwt.ts CHANGED
@@ -23,13 +23,18 @@ import {
23
23
  const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
24
24
 
25
25
  /**
26
- * Resolve the hub origin used to fetch JWKS and validate `iss`. Strips a
26
+ * Resolve the hub origin used to validate the token's `iss` claim. Strips a
27
27
  * trailing slash so we get a single canonical form.
28
28
  *
29
29
  * Order: env var → loopback fallback. We deliberately don't read
30
30
  * `~/.parachute/services.json` — the hub is the dispatcher, not a registered
31
31
  * service in that file. If a deployment exposes the hub on a non-default
32
32
  * origin, the env var is the contract.
33
+ *
34
+ * `parachute expose` pins `PARACHUTE_HUB_ORIGIN` to the PUBLIC FQDN so the
35
+ * `iss` we validate against matches what the hub stamps on the tokens it
36
+ * mints — keep using this origin for iss-validation. The JWKS *fetch* origin
37
+ * is resolved separately by `getJwksOrigin()`; see vault#464.
33
38
  */
34
39
  export function getHubOrigin(): string {
35
40
  const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
@@ -37,12 +42,44 @@ export function getHubOrigin(): string {
37
42
  return DEFAULT_HUB_LOOPBACK;
38
43
  }
39
44
 
45
+ /**
46
+ * Resolve the origin used to FETCH the hub's JWKS — kept distinct from
47
+ * `getHubOrigin()` (the iss-validation origin) per vault#464.
48
+ *
49
+ * Vault is co-located with its hub (the hub supervises vault on the same box,
50
+ * the common deploy). After `parachute expose --cloudflare`, `getHubOrigin()`
51
+ * is the public Cloudflare FQDN. If we fetched JWKS from that public origin we
52
+ * would hairpin out through the tunnel and back to the same box — a round-trip
53
+ * that times out (hard fail under Docker NAT-loopback, slow/flaky on a real
54
+ * VPS) and 401s the first MCP connect after expose. So we always read keys
55
+ * from the LOCAL hub on loopback instead.
56
+ *
57
+ * Order: `PARACHUTE_HUB_JWKS_ORIGIN` override → loopback default. The override
58
+ * exists for the rare non-co-located case — a vault running on a DIFFERENT box
59
+ * than its hub sets `PARACHUTE_HUB_JWKS_ORIGIN` to the hub's reachable
60
+ * internal address. Trailing-slash-stripped, matching `getHubOrigin()`.
61
+ */
62
+ export function getJwksOrigin(): string {
63
+ const env = process.env.PARACHUTE_HUB_JWKS_ORIGIN?.replace(/\/$/, "");
64
+ if (env && env.length > 0) return env;
65
+ return DEFAULT_HUB_LOOPBACK;
66
+ }
67
+
40
68
  // Process-wide guard. The resolver form lets tests flip
41
- // `PARACHUTE_HUB_ORIGIN` between cases — the lib re-resolves on every
42
- // `validateHubJwt` and `resetJwksCache` call so the env-var change picks up
43
- // without a server restart. JWKS cache (5min/30s defaults) lives inside the
44
- // guard, shared across requests.
45
- const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
69
+ // `PARACHUTE_HUB_ORIGIN` / `PARACHUTE_HUB_JWKS_ORIGIN` between cases — the lib
70
+ // re-resolves on every `validateHubJwt` and `resetJwksCache` call so the
71
+ // env-var change picks up without a server restart. JWKS cache (5min/30s
72
+ // defaults) lives inside the guard, shared across requests.
73
+ //
74
+ // The iss/jwks split (vault#464): `hubOrigin` validates the token's `iss`
75
+ // (public FQDN post-expose, via PARACHUTE_HUB_ORIGIN); `jwksOrigin` fetches
76
+ // the keys from the local hub (loopback by default, via
77
+ // PARACHUTE_HUB_JWKS_ORIGIN). Co-located vault never egresses to read its own
78
+ // hub's keys, so no tunnel hairpin.
79
+ const guard = createScopeGuard({
80
+ hubOrigin: () => getHubOrigin(),
81
+ jwksOrigin: () => getJwksOrigin(),
82
+ });
46
83
 
47
84
  /**
48
85
  * Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any