@openparachute/vault 0.6.3-rc.4 → 0.6.3-rc.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.3-rc.4",
3
+ "version": "0.6.3-rc.5",
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.1",
29
+ "@openparachute/scope-guard": "^0.5.0",
30
30
  "jose": "^6.2.2",
31
31
  "otpauth": "^9.5.0",
32
32
  "qrcode-terminal": "^0.12.0"
@@ -21,6 +21,7 @@ import {
21
21
  looksLikeJwt,
22
22
  getHubOrigin,
23
23
  getJwksOrigin,
24
+ parseHubOrigins,
24
25
  } from "./hub-jwt.ts";
25
26
 
26
27
  interface Keypair {
@@ -121,6 +122,7 @@ let fixture: JwksFixture;
121
122
  let kp: Keypair;
122
123
  let prevHubOrigin: string | undefined;
123
124
  let prevJwksOrigin: string | undefined;
125
+ let prevHubOrigins: string | undefined;
124
126
 
125
127
  beforeAll(async () => {
126
128
  fixture = startJwksFixture();
@@ -134,6 +136,8 @@ afterAll(() => {
134
136
  else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
135
137
  if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
136
138
  else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
139
+ if (prevHubOrigins === undefined) delete process.env.PARACHUTE_HUB_ORIGINS;
140
+ else process.env.PARACHUTE_HUB_ORIGINS = prevHubOrigins;
137
141
  });
138
142
 
139
143
  beforeEach(() => {
@@ -143,8 +147,12 @@ beforeEach(() => {
143
147
  // the loopback default (no JWKS server there) and every case would fail.
144
148
  prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
145
149
  prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
150
+ prevHubOrigins = process.env.PARACHUTE_HUB_ORIGINS;
146
151
  process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
147
152
  process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
153
+ // Default each case to the single-origin (env-unset) world so the multi-origin
154
+ // iss-set is opt-in per test — unrelated cases stay byte-identical to before.
155
+ delete process.env.PARACHUTE_HUB_ORIGINS;
148
156
  fixture.setUnreachable(false);
149
157
  fixture.setKeys([kp]);
150
158
  resetJwksCache();
@@ -168,6 +176,28 @@ describe("looksLikeJwt", () => {
168
176
  });
169
177
  });
170
178
 
179
+ describe("parseHubOrigins — multi-origin iss-set (hub#692)", () => {
180
+ test("undefined → [] (back-compat: env unset collapses to single hubOrigin)", () => {
181
+ expect(parseHubOrigins(undefined)).toEqual([]);
182
+ });
183
+
184
+ test("empty string → []", () => {
185
+ expect(parseHubOrigins("")).toEqual([]);
186
+ });
187
+
188
+ test("splits, trims, strips trailing slash, drops empties, dedupes", () => {
189
+ // "a,b/, ,a" → [a, b]: trailing slash off b, blank entry dropped, dup a collapsed.
190
+ expect(parseHubOrigins("https://a.example,https://b.example/, ,https://a.example")).toEqual([
191
+ "https://a.example",
192
+ "https://b.example",
193
+ ]);
194
+ });
195
+
196
+ test("whitespace-only entries are dropped", () => {
197
+ expect(parseHubOrigins(" , , ")).toEqual([]);
198
+ });
199
+ });
200
+
171
201
  describe("origin resolvers — iss/jwks split (vault#464)", () => {
172
202
  test("getHubOrigin honors PARACHUTE_HUB_ORIGIN (iss-validation origin)", () => {
173
203
  process.env.PARACHUTE_HUB_ORIGIN = "https://vault.example.com/";
@@ -226,6 +256,56 @@ describe("origin resolvers — iss/jwks split (vault#464)", () => {
226
256
  });
227
257
  });
228
258
 
259
+ describe("validateHubJwt — multi-origin iss-set (hub#692)", () => {
260
+ // A second + third origin that are NOT the canonical PARACHUTE_HUB_ORIGIN.
261
+ // Every token here is signed by the SAME published key (`kp`), so the
262
+ // signature always verifies — the ONLY variable under test is whether the
263
+ // token's `iss` is in the accepted set. JWKS + revocation stay served by the
264
+ // default `fixture`, reached as both the iss origin and the jwks origin.
265
+ const SECOND = "https://second.example";
266
+ const THIRD = "https://third.example";
267
+
268
+ test("token issued by a SECOND origin validates when that origin is in PARACHUTE_HUB_ORIGINS", async () => {
269
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin; // canonical (also JWKS host)
270
+ process.env.PARACHUTE_HUB_ORIGINS = `${fixture.origin},${SECOND}`;
271
+ resetJwksCache();
272
+ const token = await signJwt(kp, { iss: SECOND });
273
+ const claims = await validateHubJwt(token);
274
+ expect(claims.sub).toBe("user-1");
275
+ });
276
+
277
+ test("the canonical origin still validates when PARACHUTE_HUB_ORIGINS lists a different second origin", async () => {
278
+ // The resolved hubOrigin is always added to the set, so a token minted under
279
+ // the canonical origin keeps validating even when the env names only others.
280
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
281
+ process.env.PARACHUTE_HUB_ORIGINS = SECOND; // env need not include the canonical
282
+ resetJwksCache();
283
+ const token = await signJwt(kp, { iss: fixture.origin });
284
+ const claims = await validateHubJwt(token);
285
+ expect(claims.sub).toBe("user-1");
286
+ });
287
+
288
+ test("token issued by a THIRD, unlisted origin is rejected even with PARACHUTE_HUB_ORIGINS set", async () => {
289
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
290
+ process.env.PARACHUTE_HUB_ORIGINS = `${fixture.origin},${SECOND}`;
291
+ resetJwksCache();
292
+ const token = await signJwt(kp, { iss: THIRD });
293
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
294
+ });
295
+
296
+ test("back-compat: with PARACHUTE_HUB_ORIGINS UNSET only the canonical origin validates; a second origin is rejected", async () => {
297
+ delete process.env.PARACHUTE_HUB_ORIGINS;
298
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
299
+ resetJwksCache();
300
+ // canonical iss → accepted
301
+ const ok = await signJwt(kp, { iss: fixture.origin });
302
+ expect((await validateHubJwt(ok)).sub).toBe("user-1");
303
+ // the same SECOND origin that WOULD pass when listed → rejected when unset
304
+ const bad = await signJwt(kp, { iss: SECOND });
305
+ await expect(validateHubJwt(bad)).rejects.toThrow(/verification failed/);
306
+ });
307
+ });
308
+
229
309
  describe("validateHubJwt — happy path", () => {
230
310
  test("valid JWT with correct iss → claims surface", async () => {
231
311
  const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
package/src/hub-jwt.ts CHANGED
@@ -65,6 +65,37 @@ export function getJwksOrigin(): string {
65
65
  return DEFAULT_HUB_LOOPBACK;
66
66
  }
67
67
 
68
+ /**
69
+ * Parse the hub's comma-separated legitimate-origin SET from
70
+ * `PARACHUTE_HUB_ORIGINS` into a clean string array. Mirrors the hub's own
71
+ * `parseHubOrigins` semantics: split on `,`, trim each, strip a trailing
72
+ * slash, drop empties, dedupe. We write the ~6-line helper locally rather than
73
+ * import from a private hub path.
74
+ *
75
+ * The multi-origin iss-set (onboarding-streamline 2026-06-25, hub#692): one box
76
+ * reachable on several URLs at once (loopback + `<ip>.sslip.io` + a custom
77
+ * domain) mints tokens whose `iss` is whichever origin the request arrived on.
78
+ * The signing KEY is stable and origin-independent, so a token minted under
79
+ * URL-A must validate when this resource is reached via URL-B on the SAME box.
80
+ * The hub publishes its legitimate-origin set here (out-of-band, never from an
81
+ * unvalidated request Host); scope-guard layers it on top of the signature
82
+ * verify, never as a substitute for it.
83
+ *
84
+ * BACK-COMPAT INVARIANT: when `PARACHUTE_HUB_ORIGINS` is UNSET this returns
85
+ * `[]`. scope-guard's `resolveAcceptedIssuers` sees an empty set and collapses
86
+ * to the single canonical `getHubOrigin()` — byte-identical to today. A vault
87
+ * that never receives the new env var behaves exactly as before.
88
+ */
89
+ export function parseHubOrigins(raw: string | undefined): string[] {
90
+ if (!raw) return [];
91
+ const seen = new Set<string>();
92
+ for (const part of raw.split(",")) {
93
+ const origin = part.trim().replace(/\/$/, "");
94
+ if (origin.length > 0) seen.add(origin);
95
+ }
96
+ return Array.from(seen);
97
+ }
98
+
68
99
  // Process-wide guard. The resolver form lets tests flip
69
100
  // `PARACHUTE_HUB_ORIGIN` / `PARACHUTE_HUB_JWKS_ORIGIN` between cases — the lib
70
101
  // re-resolves on every `validateHubJwt` and `resetJwksCache` call so the
@@ -79,6 +110,12 @@ export function getJwksOrigin(): string {
79
110
  const guard = createScopeGuard({
80
111
  hubOrigin: () => getHubOrigin(),
81
112
  jwksOrigin: () => getJwksOrigin(),
113
+ // Multi-origin iss-set (hub#692): accept the hub's full legitimate-origin set
114
+ // (published via PARACHUTE_HUB_ORIGINS), not just the single PARACHUTE_HUB_ORIGIN.
115
+ // Re-evaluated per call so an operator widening the box's origins is picked up
116
+ // without a vault restart. When the env var is unset → `[]` → scope-guard
117
+ // collapses to the single canonical hubOrigin (byte-identical to before).
118
+ allowedIssuers: () => parseHubOrigins(process.env.PARACHUTE_HUB_ORIGINS),
82
119
  });
83
120
 
84
121
  /**