@openparachute/vault 0.5.3-rc.2 → 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.
@@ -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/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 {
@@ -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.2",
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
@@ -149,7 +149,12 @@ function reset(): void {
149
149
  // Default every test to the fixture hub origin so the hub-JWT mint path
150
150
  // resolves JWKS + validates `iss`. Describes that assert the loopback
151
151
  // default (OAuth discovery metadata) override this in their own beforeEach.
152
- if (hubFixtureOrigin) process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
152
+ if (hubFixtureOrigin) {
153
+ process.env.PARACHUTE_HUB_ORIGIN = hubFixtureOrigin;
154
+ // Post-vault#464 the JWKS fetch origin resolves separately (loopback by
155
+ // default); point it at the fixture so the mint path's JWKS fetch resolves.
156
+ process.env.PARACHUTE_HUB_JWKS_ORIGIN = hubFixtureOrigin;
157
+ }
153
158
  resetJwksCache();
154
159
  resetRevocationCache();
155
160
  }
@@ -183,6 +188,7 @@ afterAll(() => {
183
188
  clearVaultStoreCache();
184
189
  hubServer?.stop(true);
185
190
  delete process.env.PARACHUTE_HUB_ORIGIN;
191
+ delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
186
192
  if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
187
193
  });
188
194