@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.
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/links.ts +1 -1
- package/core/src/notes.ts +8 -8
- package/core/src/schema.ts +1 -1
- package/core/src/store.ts +2 -2
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +7 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/routing.test.ts +7 -1
|
@@ -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 |
|
|
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 |
|
|
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 |
|
|
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 |
|
|
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 } |
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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
|
-
|
|
|
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 } |
|
|
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 } |
|
|
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
|
package/core/src/schema.ts
CHANGED
|
@@ -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 } |
|
|
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 } |
|
|
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 } |
|
|
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 !== "{}") {
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -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 |
|
|
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 } |
|
|
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/wikilinks.ts
CHANGED
|
@@ -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 } |
|
|
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 } |
|
|
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.
|
|
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.
|
|
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"
|
package/src/auth-hub-jwt.test.ts
CHANGED
|
@@ -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
|
|
package/src/hub-jwt.test.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
42
|
-
// `validateHubJwt` and `resetJwksCache` call so the
|
|
43
|
-
// without a server restart. JWKS cache (5min/30s
|
|
44
|
-
// guard, shared across requests.
|
|
45
|
-
|
|
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
|
package/src/routing.test.ts
CHANGED
|
@@ -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)
|
|
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
|
|