@openparachute/hub 0.5.14-rc.9 → 0.6.0

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.
Files changed (83) hide show
  1. package/README.md +23 -0
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +30 -21
  7. package/src/__tests__/api-modules-ops.test.ts +45 -0
  8. package/src/__tests__/api-users.test.ts +7 -2
  9. package/src/__tests__/auth.test.ts +157 -30
  10. package/src/__tests__/cli.test.ts +44 -5
  11. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  12. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  13. package/src/__tests__/expose-cloudflare.test.ts +482 -14
  14. package/src/__tests__/expose.test.ts +52 -2
  15. package/src/__tests__/hub-server.test.ts +97 -0
  16. package/src/__tests__/hub.test.ts +85 -6
  17. package/src/__tests__/init.test.ts +102 -1
  18. package/src/__tests__/lifecycle.test.ts +464 -2
  19. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  20. package/src/__tests__/oauth-ui.test.ts +12 -1
  21. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  22. package/src/__tests__/resource-binding.test.ts +97 -0
  23. package/src/__tests__/scope-explanations.test.ts +41 -12
  24. package/src/__tests__/services-manifest.test.ts +122 -4
  25. package/src/__tests__/setup-wizard.test.ts +335 -15
  26. package/src/__tests__/status.test.ts +36 -0
  27. package/src/__tests__/two-factor-flow.test.ts +602 -0
  28. package/src/__tests__/two-factor.test.ts +183 -0
  29. package/src/__tests__/upgrade.test.ts +78 -1
  30. package/src/__tests__/users.test.ts +68 -0
  31. package/src/__tests__/vault-auth-status.test.ts +47 -6
  32. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  33. package/src/account-home-ui.ts +488 -38
  34. package/src/account-vault-token.ts +282 -0
  35. package/src/admin-handlers.ts +159 -4
  36. package/src/admin-login-ui.ts +49 -5
  37. package/src/admin-vaults.ts +48 -15
  38. package/src/api-account.ts +14 -0
  39. package/src/api-modules-ops.ts +49 -11
  40. package/src/api-users.ts +29 -3
  41. package/src/cli.ts +26 -21
  42. package/src/clients.ts +18 -6
  43. package/src/cloudflare/config.ts +10 -4
  44. package/src/cloudflare/detect.ts +39 -44
  45. package/src/commands/auth.ts +165 -24
  46. package/src/commands/expose-2fa-warning.ts +34 -32
  47. package/src/commands/expose-auth-preflight.ts +89 -78
  48. package/src/commands/expose-cloudflare.ts +370 -12
  49. package/src/commands/expose.ts +8 -0
  50. package/src/commands/init.ts +33 -2
  51. package/src/commands/lifecycle.ts +386 -17
  52. package/src/commands/status.ts +22 -0
  53. package/src/commands/upgrade.ts +55 -11
  54. package/src/commands/wizard.ts +8 -4
  55. package/src/env-file.ts +10 -0
  56. package/src/help.ts +3 -1
  57. package/src/hub-db.ts +39 -1
  58. package/src/hub-server.ts +52 -0
  59. package/src/hub.ts +82 -14
  60. package/src/oauth-handlers.ts +298 -21
  61. package/src/oauth-ui.ts +10 -0
  62. package/src/operator-token.ts +151 -0
  63. package/src/pending-login.ts +116 -0
  64. package/src/rate-limit.ts +51 -0
  65. package/src/resource-binding.ts +134 -0
  66. package/src/scope-explanations.ts +46 -18
  67. package/src/services-manifest.ts +112 -0
  68. package/src/setup-wizard.ts +77 -7
  69. package/src/tailscale/run.ts +28 -11
  70. package/src/totp.ts +201 -0
  71. package/src/two-factor-handlers.ts +287 -0
  72. package/src/two-factor-store.ts +181 -0
  73. package/src/two-factor-ui.ts +462 -0
  74. package/src/users.ts +58 -0
  75. package/src/vault/auth-status.ts +71 -19
  76. package/src/vault-hub-origin-env.ts +163 -0
  77. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  78. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  79. package/web/ui/dist/index.html +2 -2
  80. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  81. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  82. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  83. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -20,6 +20,7 @@ const PARAMS: AuthorizeFormParams = {
20
20
  codeChallenge: "ch",
21
21
  codeChallengeMethod: "S256",
22
22
  state: "xyz",
23
+ resource: null,
23
24
  };
24
25
 
25
26
  const CSRF = "csrf-token-fixture";
@@ -50,9 +51,19 @@ describe("renderHiddenInputs", () => {
50
51
  });
51
52
 
52
53
  test("escapes hostile values into hidden inputs", () => {
53
- const html = renderHiddenInputs({ ...PARAMS, state: `"><script>alert(1)</script>` });
54
+ const html = renderHiddenInputs({
55
+ ...PARAMS,
56
+ state: `"><script>alert(1)</script>`,
57
+ // RFC 8707 resource is round-tripped through a hidden input too, so a
58
+ // hostile value must be escaped the same way.
59
+ resource: `"><script>alert(2)</script>`,
60
+ });
54
61
  expect(html).not.toContain("<script>alert(1)</script>");
62
+ expect(html).not.toContain("<script>alert(2)</script>");
55
63
  expect(html).toContain("&lt;script&gt;");
64
+ // The resource value is emitted as a hidden input (escaped).
65
+ expect(html).toContain('name="resource"');
66
+ expect(html).toContain("alert(2)");
56
67
  });
57
68
  });
58
69
 
@@ -0,0 +1,412 @@
1
+ /**
2
+ * hub#481 — `selfHealOperatorTokenIssuer` re-mints a genuine-but-stale
3
+ * operator token under the hub's current issuer.
4
+ *
5
+ * Background: a box that ran init/setup at loopback and was LATER exposed
6
+ * publicly carries an `operator.token` whose `iss` (e.g. `http://127.0.0.1:1939`)
7
+ * no longer matches the hub's current issuer. The hub rejects it on every CLI
8
+ * auth flow. The self-heal re-issues the hub's OWN credential under the new
9
+ * issuer, preserving scope-set + sub, gated on the token's signature verifying
10
+ * against this hub's current keys (no privilege-escalation surface).
11
+ *
12
+ * Mirrors the existing `operator-token.test.ts` harness shape.
13
+ */
14
+ import { describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { readFile } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
20
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
21
+ import {
22
+ OPERATOR_TOKEN_AUDIENCE,
23
+ OPERATOR_TOKEN_CLIENT_ID,
24
+ OPERATOR_TOKEN_FILENAME,
25
+ OPERATOR_TOKEN_SCOPE_SET_CLAIM,
26
+ issueOperatorToken,
27
+ operatorTokenPath,
28
+ readOperatorTokenFile,
29
+ selfHealOperatorTokenIssuer,
30
+ writeOperatorTokenFile,
31
+ } from "../operator-token.ts";
32
+ import { rotateSigningKey } from "../signing-keys.ts";
33
+
34
+ interface Harness {
35
+ dir: string;
36
+ cleanup: () => void;
37
+ }
38
+
39
+ function makeHarness(): Harness {
40
+ const dir = mkdtempSync(join(tmpdir(), "phub-op-heal-"));
41
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
42
+ }
43
+
44
+ const LOOPBACK_ISSUER = "http://127.0.0.1:1939";
45
+ const PUBLIC_ISSUER = "https://gitcoin-parachute.unforced.dev";
46
+
47
+ describe("selfHealOperatorTokenIssuer", () => {
48
+ test("stale-iss genuine token + non-loopback new issuer → rotated, scope-set preserved, valid under new issuer", async () => {
49
+ const h = makeHarness();
50
+ try {
51
+ const db = openHubDb(hubDbPath(h.dir));
52
+ try {
53
+ rotateSigningKey(db);
54
+ // Mint at loopback issuer with a non-default scope-set ("start").
55
+ await issueOperatorToken(db, "user-abc", {
56
+ dir: h.dir,
57
+ issuer: LOOPBACK_ISSUER,
58
+ scopeSet: "start",
59
+ });
60
+
61
+ const status = await selfHealOperatorTokenIssuer(db, {
62
+ issuer: PUBLIC_ISSUER,
63
+ configDir: h.dir,
64
+ });
65
+ expect(status.kind).toBe("rotated");
66
+ if (status.kind === "rotated") {
67
+ expect(status.scopeSet).toBe("start");
68
+ expect(status.path).toBe(operatorTokenPath(h.dir));
69
+ }
70
+
71
+ // The on-disk token now has iss=PUBLIC_ISSUER, scope-set preserved,
72
+ // and validates under the new issuer.
73
+ const onDisk = await readOperatorTokenFile(h.dir);
74
+ expect(onDisk).not.toBeNull();
75
+ const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
76
+ expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
77
+ expect(validated.payload.sub).toBe("user-abc");
78
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
79
+ } finally {
80
+ db.close();
81
+ }
82
+ } finally {
83
+ h.cleanup();
84
+ }
85
+ });
86
+
87
+ test("iss already current → fresh, on-disk file byte-identical", async () => {
88
+ const h = makeHarness();
89
+ try {
90
+ const db = openHubDb(hubDbPath(h.dir));
91
+ try {
92
+ rotateSigningKey(db);
93
+ await issueOperatorToken(db, "user-abc", {
94
+ dir: h.dir,
95
+ issuer: PUBLIC_ISSUER,
96
+ scopeSet: "admin",
97
+ });
98
+ const before = await readFile(operatorTokenPath(h.dir));
99
+
100
+ const status = await selfHealOperatorTokenIssuer(db, {
101
+ issuer: PUBLIC_ISSUER,
102
+ configDir: h.dir,
103
+ });
104
+ expect(status.kind).toBe("fresh");
105
+
106
+ const after = await readFile(operatorTokenPath(h.dir));
107
+ expect(after.equals(before)).toBe(true);
108
+ } finally {
109
+ db.close();
110
+ }
111
+ } finally {
112
+ h.cleanup();
113
+ }
114
+ });
115
+
116
+ test("absent token file → absent, no throw", async () => {
117
+ const h = makeHarness();
118
+ try {
119
+ const db = openHubDb(hubDbPath(h.dir));
120
+ try {
121
+ rotateSigningKey(db);
122
+ const status = await selfHealOperatorTokenIssuer(db, {
123
+ issuer: PUBLIC_ISSUER,
124
+ configDir: h.dir,
125
+ });
126
+ expect(status.kind).toBe("absent");
127
+ } finally {
128
+ db.close();
129
+ }
130
+ } finally {
131
+ h.cleanup();
132
+ }
133
+ });
134
+
135
+ test("bad signature (corrupt token) → skipped:unverifiable, disk untouched", async () => {
136
+ const h = makeHarness();
137
+ try {
138
+ const db = openHubDb(hubDbPath(h.dir));
139
+ try {
140
+ rotateSigningKey(db);
141
+ // Mint a real token at loopback, then corrupt its signature segment so
142
+ // it no longer verifies against the hub's keys.
143
+ const issued = await issueOperatorToken(db, "user-abc", {
144
+ dir: h.dir,
145
+ issuer: LOOPBACK_ISSUER,
146
+ scopeSet: "start",
147
+ });
148
+ const parts = issued.token.split(".");
149
+ const hdr = parts[0] ?? "";
150
+ const body = parts[1] ?? "";
151
+ const sig = parts[2] ?? "";
152
+ // Flip a character in the MIDDLE of the signature, keeping it
153
+ // base64url-shaped. Corrupting the last char is unreliable: the final
154
+ // base64url char of an RS256 signature encodes only padding bits, so a
155
+ // flip there can decode to identical bytes and leave the signature
156
+ // valid (~25% of mints). A mid-signature char sits in a full 4-char
157
+ // group with no padding, so any single-char change deterministically
158
+ // alters the decoded bytes and invalidates the signature.
159
+ const mid = Math.floor(sig.length / 2);
160
+ const flipped = sig[mid] === "A" ? "B" : "A";
161
+ const tampered = `${hdr}.${body}.${sig.slice(0, mid)}${flipped}${sig.slice(mid + 1)}`;
162
+ await writeOperatorTokenFile(tampered, h.dir);
163
+
164
+ const status = await selfHealOperatorTokenIssuer(db, {
165
+ issuer: PUBLIC_ISSUER,
166
+ configDir: h.dir,
167
+ });
168
+ expect(status.kind).toBe("skipped");
169
+ if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
170
+
171
+ const onDisk = await readOperatorTokenFile(h.dir);
172
+ expect(onDisk).toBe(tampered);
173
+ } finally {
174
+ db.close();
175
+ }
176
+ } finally {
177
+ h.cleanup();
178
+ }
179
+ });
180
+
181
+ test("expired token (exp in the past) → skipped:unverifiable (jose throws), disk untouched", async () => {
182
+ const h = makeHarness();
183
+ try {
184
+ const db = openHubDb(hubDbPath(h.dir));
185
+ try {
186
+ rotateSigningKey(db);
187
+ // Mint a token that expired in the past — jose's exp check throws on
188
+ // validate, so the self-heal must classify it unverifiable.
189
+ const issued = await issueOperatorToken(db, "user-abc", {
190
+ dir: h.dir,
191
+ issuer: LOOPBACK_ISSUER,
192
+ scopeSet: "start",
193
+ ttlSeconds: 60,
194
+ now: () => new Date("2026-01-01T00:00:00Z"),
195
+ });
196
+
197
+ const status = await selfHealOperatorTokenIssuer(db, {
198
+ issuer: PUBLIC_ISSUER,
199
+ configDir: h.dir,
200
+ });
201
+ expect(status.kind).toBe("skipped");
202
+ if (status.kind === "skipped") expect(status.reason).toBe("unverifiable");
203
+
204
+ const onDisk = await readOperatorTokenFile(h.dir);
205
+ expect(onDisk).toBe(issued.token);
206
+ } finally {
207
+ db.close();
208
+ }
209
+ } finally {
210
+ h.cleanup();
211
+ }
212
+ });
213
+
214
+ test("aud != operator (aud=scribe, valid sig, stale iss) → skipped:aud-mismatch, untouched", async () => {
215
+ const h = makeHarness();
216
+ try {
217
+ const db = openHubDb(hubDbPath(h.dir));
218
+ try {
219
+ rotateSigningKey(db);
220
+ // A hub-signed token with the WRONG audience must not be re-minted as
221
+ // an operator token (privilege guard).
222
+ const signed = await signAccessToken(db, {
223
+ sub: "user-abc",
224
+ scopes: ["scribe:transcribe"],
225
+ audience: "scribe",
226
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
227
+ issuer: LOOPBACK_ISSUER,
228
+ extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "admin" },
229
+ });
230
+ await writeOperatorTokenFile(signed.token, h.dir);
231
+
232
+ const status = await selfHealOperatorTokenIssuer(db, {
233
+ issuer: PUBLIC_ISSUER,
234
+ configDir: h.dir,
235
+ });
236
+ expect(status.kind).toBe("skipped");
237
+ if (status.kind === "skipped") expect(status.reason).toBe("aud-mismatch");
238
+
239
+ const onDisk = await readOperatorTokenFile(h.dir);
240
+ expect(onDisk).toBe(signed.token);
241
+ } finally {
242
+ db.close();
243
+ }
244
+ } finally {
245
+ h.cleanup();
246
+ }
247
+ });
248
+
249
+ test("missing/invalid pa_scope_set (stale iss, aud=operator) → skipped:no-scope-set, NOT widened to admin", async () => {
250
+ const h = makeHarness();
251
+ try {
252
+ const db = openHubDb(hubDbPath(h.dir));
253
+ try {
254
+ rotateSigningKey(db);
255
+ // aud=operator + stale iss + NO pa_scope_set claim. Falling back to a
256
+ // default scope-set would silently widen to admin (hub#224); refuse.
257
+ const signed = await signAccessToken(db, {
258
+ sub: "user-abc",
259
+ scopes: ["scribe:transcribe"],
260
+ audience: OPERATOR_TOKEN_AUDIENCE,
261
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
262
+ issuer: LOOPBACK_ISSUER,
263
+ });
264
+ await writeOperatorTokenFile(signed.token, h.dir);
265
+
266
+ const status = await selfHealOperatorTokenIssuer(db, {
267
+ issuer: PUBLIC_ISSUER,
268
+ configDir: h.dir,
269
+ });
270
+ expect(status.kind).toBe("skipped");
271
+ if (status.kind === "skipped") expect(status.reason).toBe("no-scope-set");
272
+
273
+ // On-disk file unchanged — no widening occurred.
274
+ const onDisk = await readOperatorTokenFile(h.dir);
275
+ expect(onDisk).toBe(signed.token);
276
+ } finally {
277
+ db.close();
278
+ }
279
+ } finally {
280
+ h.cleanup();
281
+ }
282
+ });
283
+
284
+ test("missing sub (stale iss, aud=operator, valid scope-set) → skipped:no-sub, untouched", async () => {
285
+ const h = makeHarness();
286
+ try {
287
+ const db = openHubDb(hubDbPath(h.dir));
288
+ try {
289
+ rotateSigningKey(db);
290
+ // aud=operator + recognized scope-set + stale iss but NO sub — we can't
291
+ // re-mint a token we can't attribute.
292
+ const signed = await signAccessToken(db, {
293
+ sub: "",
294
+ scopes: ["parachute:host:start"],
295
+ audience: OPERATOR_TOKEN_AUDIENCE,
296
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
297
+ issuer: LOOPBACK_ISSUER,
298
+ extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: "start" },
299
+ });
300
+ await writeOperatorTokenFile(signed.token, h.dir);
301
+
302
+ const status = await selfHealOperatorTokenIssuer(db, {
303
+ issuer: PUBLIC_ISSUER,
304
+ configDir: h.dir,
305
+ });
306
+ expect(status.kind).toBe("skipped");
307
+ if (status.kind === "skipped") expect(status.reason).toBe("no-sub");
308
+
309
+ const onDisk = await readOperatorTokenFile(h.dir);
310
+ expect(onDisk).toBe(signed.token);
311
+ } finally {
312
+ db.close();
313
+ }
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+
319
+ test("target issuer loopback (public token on disk) → skipped:issuer-loopback, public token preserved", async () => {
320
+ const h = makeHarness();
321
+ try {
322
+ const db = openHubDb(hubDbPath(h.dir));
323
+ try {
324
+ rotateSigningKey(db);
325
+ // A good PUBLIC-issuer token; calling self-heal with a loopback target
326
+ // must never downgrade it.
327
+ const issued = await issueOperatorToken(db, "user-abc", {
328
+ dir: h.dir,
329
+ issuer: PUBLIC_ISSUER,
330
+ scopeSet: "admin",
331
+ });
332
+
333
+ const status = await selfHealOperatorTokenIssuer(db, {
334
+ issuer: LOOPBACK_ISSUER,
335
+ configDir: h.dir,
336
+ });
337
+ expect(status.kind).toBe("skipped");
338
+ if (status.kind === "skipped") expect(status.reason).toBe("issuer-loopback");
339
+
340
+ const onDisk = await readOperatorTokenFile(h.dir);
341
+ expect(onDisk).toBe(issued.token);
342
+ // Still a public-issuer token.
343
+ const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
344
+ expect(validated.payload.iss).toBe(PUBLIC_ISSUER);
345
+ } finally {
346
+ db.close();
347
+ }
348
+ } finally {
349
+ h.cleanup();
350
+ }
351
+ });
352
+
353
+ test("scope-set preserved verbatim — 'auth' set stays 'auth', not widened to admin", async () => {
354
+ const h = makeHarness();
355
+ try {
356
+ const db = openHubDb(hubDbPath(h.dir));
357
+ try {
358
+ rotateSigningKey(db);
359
+ await issueOperatorToken(db, "user-xyz", {
360
+ dir: h.dir,
361
+ issuer: LOOPBACK_ISSUER,
362
+ scopeSet: "auth",
363
+ });
364
+
365
+ const status = await selfHealOperatorTokenIssuer(db, {
366
+ issuer: PUBLIC_ISSUER,
367
+ configDir: h.dir,
368
+ });
369
+ expect(status.kind).toBe("rotated");
370
+ if (status.kind === "rotated") expect(status.scopeSet).toBe("auth");
371
+
372
+ const onDisk = await readOperatorTokenFile(h.dir);
373
+ const validated = await validateAccessToken(db, onDisk as string, PUBLIC_ISSUER);
374
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("auth");
375
+ // The minted scopes are the "auth" set, NOT the admin superset.
376
+ expect(validated.payload.scope).toBe("parachute:host:auth");
377
+ } finally {
378
+ db.close();
379
+ }
380
+ } finally {
381
+ h.cleanup();
382
+ }
383
+ });
384
+
385
+ test("file path is the canonical operator.token under configDir", async () => {
386
+ const h = makeHarness();
387
+ try {
388
+ const db = openHubDb(hubDbPath(h.dir));
389
+ try {
390
+ rotateSigningKey(db);
391
+ await issueOperatorToken(db, "user-abc", {
392
+ dir: h.dir,
393
+ issuer: LOOPBACK_ISSUER,
394
+ scopeSet: "start",
395
+ });
396
+ const status = await selfHealOperatorTokenIssuer(db, {
397
+ issuer: PUBLIC_ISSUER,
398
+ configDir: h.dir,
399
+ });
400
+ if (status.kind === "rotated") {
401
+ expect(status.path).toBe(join(h.dir, OPERATOR_TOKEN_FILENAME));
402
+ } else {
403
+ throw new Error(`expected rotated, got ${status.kind}`);
404
+ }
405
+ } finally {
406
+ db.close();
407
+ }
408
+ } finally {
409
+ h.cleanup();
410
+ }
411
+ });
412
+ });
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { narrowResourceVaultScopes, resolveResourceVault } from "../resource-binding.ts";
3
+
4
+ const ORIGIN = "https://hub.example";
5
+ const BOUND = [ORIGIN, "http://127.0.0.1:1939"];
6
+
7
+ describe("resolveResourceVault", () => {
8
+ test("resolves a per-vault MCP resource to the vault name", () => {
9
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp`, BOUND)).toBe("jon");
10
+ });
11
+
12
+ test("tolerates a trailing slash on the MCP path", () => {
13
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/`, BOUND)).toBe("jon");
14
+ });
15
+
16
+ test("ignores query string + fragment", () => {
17
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp?x=1#y`, BOUND)).toBe("jon");
18
+ });
19
+
20
+ test("resolves the PRM document URL to the vault name", () => {
21
+ expect(
22
+ resolveResourceVault(`${ORIGIN}/vault/jon/.well-known/oauth-protected-resource`, BOUND),
23
+ ).toBe("jon");
24
+ });
25
+
26
+ test("resolves against a non-issuer bound origin (loopback)", () => {
27
+ expect(resolveResourceVault("http://127.0.0.1:1939/vault/work/mcp", BOUND)).toBe("work");
28
+ });
29
+
30
+ test("returns null for an off-origin resource (not one we front)", () => {
31
+ expect(resolveResourceVault("https://evil.example/vault/jon/mcp", BOUND)).toBeNull();
32
+ });
33
+
34
+ test("returns null for a non-vault path", () => {
35
+ expect(resolveResourceVault(`${ORIGIN}/scribe/mcp`, BOUND)).toBeNull();
36
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon`, BOUND)).toBeNull();
37
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/notes`, BOUND)).toBeNull();
38
+ });
39
+
40
+ test("returns null for absent / empty / malformed resource", () => {
41
+ expect(resolveResourceVault(null, BOUND)).toBeNull();
42
+ expect(resolveResourceVault(undefined, BOUND)).toBeNull();
43
+ expect(resolveResourceVault("", BOUND)).toBeNull();
44
+ expect(resolveResourceVault("not a url", BOUND)).toBeNull();
45
+ });
46
+
47
+ test("does not collapse a deeper vault sub-path into the MCP shape", () => {
48
+ // `/vault/jon/mcp/extra` is not the canonical MCP endpoint.
49
+ expect(resolveResourceVault(`${ORIGIN}/vault/jon/mcp/extra`, BOUND)).toBeNull();
50
+ });
51
+
52
+ test("rejects a vault segment that isn't a well-formed vault name (no junk mint)", () => {
53
+ // A crafted `resource=…/vault/%2F..%2Fadmin/mcp` decodes to `/../admin`,
54
+ // which is not `[a-zA-Z0-9_-]+`. Returning null falls through to the
55
+ // unbound flow — no narrowing, no token stamped `aud=vault./../admin`.
56
+ expect(resolveResourceVault(`${ORIGIN}/vault/%2F..%2Fadmin/mcp`, BOUND)).toBeNull();
57
+ // Spaces / dots / slashes in the decoded name are all out of shape.
58
+ expect(resolveResourceVault(`${ORIGIN}/vault/a.b/mcp`, BOUND)).toBeNull();
59
+ });
60
+
61
+ test("returns null for a malformed percent-escape in the vault segment (safeDecode catch path)", () => {
62
+ // `%GG` is not a valid percent-escape — `decodeURIComponent` throws; the
63
+ // helper must degrade to null rather than 500 the authorize handler.
64
+ expect(resolveResourceVault(`${ORIGIN}/vault/%GG/mcp`, BOUND)).toBeNull();
65
+ });
66
+ });
67
+
68
+ describe("narrowResourceVaultScopes", () => {
69
+ test("narrows unnamed vault verbs to the named form", () => {
70
+ expect(narrowResourceVaultScopes(["vault:read", "vault:write"], "jon")).toEqual([
71
+ "vault:jon:read",
72
+ "vault:jon:write",
73
+ ]);
74
+ });
75
+
76
+ test("leaves already-named scopes for other vaults untouched", () => {
77
+ expect(narrowResourceVaultScopes(["vault:other:read"], "jon")).toEqual(["vault:other:read"]);
78
+ });
79
+
80
+ test("passes non-vault scopes through unchanged", () => {
81
+ expect(narrowResourceVaultScopes(["scribe:transcribe", "vault:read"], "jon")).toEqual([
82
+ "scribe:transcribe",
83
+ "vault:jon:read",
84
+ ]);
85
+ });
86
+
87
+ test("narrows the admin verb too (gate happens downstream)", () => {
88
+ // narrowResourceVaultScopes only rewrites shape; the non-requestable gate
89
+ // (`vault:<name>:admin`) blocks it afterward.
90
+ expect(narrowResourceVaultScopes(["vault:admin"], "jon")).toEqual(["vault:jon:admin"]);
91
+ });
92
+
93
+ test("is idempotent over an already-narrowed list", () => {
94
+ const once = narrowResourceVaultScopes(["vault:read"], "jon");
95
+ expect(narrowResourceVaultScopes(once, "jon")).toEqual(once);
96
+ });
97
+ });
@@ -60,10 +60,18 @@ describe("explainScope", () => {
60
60
  expect(explainScope("vault:*:write")?.level).toBe("write");
61
61
  });
62
62
 
63
- test("doesn't promote a per-vault admin (vault:<name>:admin) into an explained scope", () => {
64
- // vault:<name>:admin is NON_REQUESTABLE never appears on the consent
65
- // screen. Explicitly not in the verb-pattern, so explainScope returns null.
66
- expect(explainScope("vault:default:admin")).toBeNull();
63
+ // Single-consent change (2026-05-29): vault:<name>:admin is now REQUESTABLE
64
+ // and reaches the consent screen, so explainScope MUST resolve it to the
65
+ // vault:admin explanation (level "admin"). This is load-bearing: it makes
66
+ // scopeIsAdmin("vault:<name>:admin") return true, which the same-hub and
67
+ // trust-by-name auto-mint gates rely on to keep admin consent-gated.
68
+ test("resolves a per-vault admin (vault:<name>:admin) to the vault:admin explanation", () => {
69
+ expect(explainScope("vault:default:admin")?.label).toBe(
70
+ SCOPE_EXPLANATIONS["vault:admin"]?.label,
71
+ );
72
+ expect(explainScope("vault:default:admin")?.level).toBe("admin");
73
+ expect(explainScope("vault:my-techne_2:admin")?.level).toBe("admin");
74
+ expect(explainScope("vault:*:admin")?.level).toBe("admin");
67
75
  });
68
76
  });
69
77
 
@@ -74,6 +82,17 @@ describe("scopeIsAdmin", () => {
74
82
  expect(scopeIsAdmin("parachute:host:admin")).toBe(true);
75
83
  });
76
84
 
85
+ // Single-consent change (2026-05-29): the named per-vault admin form must
86
+ // be recognized as admin. LOAD-BEARING — the same-hub auto-trust gate
87
+ // (`!hasAdminScope`) and the trust-by-client_name gate
88
+ // (`!requestedScopes.some(scopeIsAdmin)`) rely on this to keep a named admin
89
+ // grant consent-gated instead of silently auto-minting it.
90
+ test("true for named per-vault admin (vault:<name>:admin)", () => {
91
+ expect(scopeIsAdmin("vault:work:admin")).toBe(true);
92
+ expect(scopeIsAdmin("vault:default:admin")).toBe(true);
93
+ expect(scopeIsAdmin("vault:my-techne_2:admin")).toBe(true);
94
+ });
95
+
77
96
  test("false for non-admin and unknown scopes", () => {
78
97
  expect(scopeIsAdmin("vault:read")).toBe(false);
79
98
  expect(scopeIsAdmin("channel:send")).toBe(false);
@@ -120,16 +139,26 @@ describe("isRequestableScope", () => {
120
139
  expect(isRequestableScope("notes:something-new")).toBe(true);
121
140
  });
122
141
 
123
- // Per-vault admin scopes are pattern-matched as non-requestable so the
124
- // public OAuth flow can never mint vault:<name>:admin — only the local
125
- // session-cookie endpoint at /admin/vault-admin-token/<name> can.
126
- test("false for any vault:<name>:admin scope", () => {
127
- expect(isRequestableScope("vault:default:admin")).toBe(false);
128
- expect(isRequestableScope("vault:work:admin")).toBe(false);
129
- expect(isRequestableScope("vault:my-techne_2:admin")).toBe(false);
142
+ // Single-consent change (2026-05-29): per-vault admin scopes are now
143
+ // requestable via the public OAuth flow. The anti-privesc cap at the mint
144
+ // choke-point (`capScopesToUserAuthority`) keeps a non-owner from actually
145
+ // being granted admin — but the scope is no longer rejected up front, so
146
+ // Claude MCP (consenting as the owner) can mint a vault admin token.
147
+ test("true for any vault:<name>:admin scope (single-consent change)", () => {
148
+ expect(isRequestableScope("vault:default:admin")).toBe(true);
149
+ expect(isRequestableScope("vault:work:admin")).toBe(true);
150
+ expect(isRequestableScope("vault:my-techne_2:admin")).toBe(true);
151
+ });
152
+
153
+ test("host-level operator scopes stay non-requestable", () => {
154
+ // The asymmetry the single-consent change preserved: per-vault admin is
155
+ // now requestable (capped at mint), but host-wide operator authority is
156
+ // still operator-only-mintable.
157
+ expect(isRequestableScope("parachute:host:admin")).toBe(false);
158
+ expect(isRequestableScope("parachute:host:auth")).toBe(false);
130
159
  });
131
160
 
132
- test("vault:<name>:read|write stays requestable (only :admin is locked down)", () => {
161
+ test("vault:<name>:read|write stays requestable", () => {
133
162
  expect(isRequestableScope("vault:default:read")).toBe(true);
134
163
  expect(isRequestableScope("vault:work:write")).toBe(true);
135
164
  });