@openparachute/hub 0.6.1-rc.2 → 0.6.1-rc.4

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/hub",
3
- "version": "0.6.1-rc.2",
3
+ "version": "0.6.1-rc.4",
4
4
  "description": "parachute \u2014 the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -310,7 +310,9 @@ describe("renderAccountHome", () => {
310
310
  expect(html).not.toContain('data-testid="mint-verb-write"');
311
311
  });
312
312
 
313
- test("mint affordance — never offers an admin verb", () => {
313
+ test("mint affordance — offers the admin verb when the user holds it", () => {
314
+ // 2026-05-30: assigned users hold read/write/admin on their vault, so the
315
+ // mint form offers admin (the live `vaultVerbsForUserVault` returns it).
314
316
  const html = renderAccountHome({
315
317
  username: "alice",
316
318
  assignedVaults: ["work"],
@@ -319,10 +321,10 @@ describe("renderAccountHome", () => {
319
321
  isFirstAdmin: false,
320
322
  csrfToken: CSRF,
321
323
  twoFactorEnabled: false,
322
- mintableVerbs: { work: ["read", "write"] },
324
+ mintableVerbs: { work: ["read", "write", "admin"] },
323
325
  });
324
- expect(html).not.toContain('value="admin"');
325
- expect(html).not.toContain('data-testid="mint-verb-admin"');
326
+ expect(html).toContain('value="admin"');
327
+ expect(html).toContain('data-testid="mint-verb-admin"');
326
328
  });
327
329
 
328
330
  test("mint affordance — absent when no mintable verbs (admin / no-vault / unmapped role)", () => {
@@ -10,7 +10,8 @@
10
10
  * - UNassigned vault → 403 (cannot mint for a vault not in the
11
11
  * user's `user_vaults` assignment — blocks
12
12
  * cross-vault).
13
- * - `admin` verb → rejected (not in the form vocabulary).
13
+ * - `admin` verb → minted for an ASSIGNED vault (2026-05-30:
14
+ * assigned users hold full vault authority).
14
15
  * - Broader/garbage verb → rejected.
15
16
  * - First admin → 403 (no `user_vaults` rows → unrestricted
16
17
  * admins use the SPA path, not this one).
@@ -245,17 +246,19 @@ describe("handleAccountVaultTokenPost — authorization gates (adversarial)", ()
245
246
  expect(res.status).toBe(403);
246
247
  });
247
248
 
248
- test("admin verb is rejected never mints vault:<name>:admin", async () => {
249
+ test("200 mints vault:<name>:admin when verb=admin (assigned users hold admin, 2026-05-30)", async () => {
249
250
  const { cookie, csrfToken } = await seedFriend(["work"]);
250
251
  const res = await handleAccountVaultTokenPost(
251
252
  mintReq("work", { cookie, csrfToken, verb: "admin" }),
252
253
  "work",
253
254
  deps(),
254
255
  );
255
- expect(res.status).toBe(400);
256
+ expect(res.status).toBe(200);
256
257
  const html = await res.text();
257
- expect(html).not.toContain('data-testid="minted-token-banner"');
258
- expect(html).not.toContain("vault:work:admin");
258
+ const token = html.match(/data-testid="minted-token-value">([^<]+)</)?.[1] as string;
259
+ const validated = await validateAccessToken(harness.db, token, ISSUER);
260
+ const scopeClaim = (validated.payload as { scope?: string }).scope ?? "";
261
+ expect(scopeClaim.split(/\s+/)).toEqual(["vault:work:admin"]);
259
262
  });
260
263
 
261
264
  test("a garbage / broader verb is rejected", async () => {
@@ -95,7 +95,10 @@ function fixtureLoadServicesManifest(): ServicesManifest {
95
95
 
96
96
  describe("authorizationServerMetadata", () => {
97
97
  test("emits RFC 8414 fields rooted at the issuer", async () => {
98
- const res = authorizationServerMetadata({ issuer: ISSUER });
98
+ const res = authorizationServerMetadata({
99
+ issuer: ISSUER,
100
+ loadServicesManifest: fixtureLoadServicesManifest,
101
+ });
99
102
  expect(res.status).toBe(200);
100
103
  const body = (await res.json()) as Record<string, unknown>;
101
104
  expect(body.issuer).toBe(ISSUER);
@@ -110,15 +113,21 @@ describe("authorizationServerMetadata", () => {
110
113
  const scopesSupported = body.scopes_supported as string[];
111
114
  expect(scopesSupported).toContain("vault:read");
112
115
  expect(scopesSupported).toContain("vault:admin");
113
- expect(scopesSupported).toContain("scribe:transcribe");
116
+ expect(scopesSupported).toContain("scribe:transcribe"); // scribe is in the fixture manifest
114
117
  expect(scopesSupported).toContain("hub:admin");
118
+ // channel isn't in the fixture manifest → its scopes aren't advertised
119
+ // (hub#…: optional-module scopes only surface when the module is installed).
120
+ expect(scopesSupported).not.toContain("channel:send");
115
121
  });
116
122
 
117
123
  test("does NOT advertise non-requestable operator-only scopes", async () => {
118
124
  // #96: parachute:host:admin is operator-only. RFC 8414 §2 frames
119
125
  // scopes_supported as scopes a client *can* request — advertising what
120
126
  // we always reject would mislead clients.
121
- const res = authorizationServerMetadata({ issuer: ISSUER });
127
+ const res = authorizationServerMetadata({
128
+ issuer: ISSUER,
129
+ loadServicesManifest: fixtureLoadServicesManifest,
130
+ });
122
131
  const body = (await res.json()) as Record<string, unknown>;
123
132
  const scopesSupported = body.scopes_supported as string[];
124
133
  expect(scopesSupported).not.toContain("parachute:host:admin");
@@ -143,6 +152,7 @@ describe("authorizationServerMetadata", () => {
143
152
  const res = authorizationServerMetadata({
144
153
  issuer: ISSUER,
145
154
  loadDeclaredScopes: () => declared,
155
+ loadServicesManifest: fixtureLoadServicesManifest,
146
156
  });
147
157
  const body = (await res.json()) as Record<string, unknown>;
148
158
  const scopesSupported = body.scopes_supported as string[];
@@ -157,11 +167,84 @@ describe("authorizationServerMetadata", () => {
157
167
  // NON_REQUESTABLE filter still applies even when the scope is declared
158
168
  expect(scopesSupported).not.toContain("parachute:host:admin");
159
169
  });
170
+
171
+ test("advertises an optional module's scopes only when it's installed", async () => {
172
+ // FIRST_PARTY_SCOPES carries scribe:* + channel:send statically. On a
173
+ // vault-only hub they must NOT be advertised — a discovery client (e.g.
174
+ // claude.ai's connector UI) lists the catalog verbatim, so a friend
175
+ // connecting one vault was shown Scribe + Channel access the hub can't
176
+ // honor. Vault + hub are core and always advertised.
177
+ const declared = new Set<string>([
178
+ "vault:read",
179
+ "vault:write",
180
+ "vault:admin",
181
+ "scribe:transcribe",
182
+ "scribe:admin",
183
+ "channel:send",
184
+ "hub:admin",
185
+ ]);
186
+ const vaultOnly = {
187
+ services: [
188
+ {
189
+ name: "parachute-vault",
190
+ port: 1940,
191
+ paths: ["/vault/default"],
192
+ health: "/health",
193
+ version: "0.5.1",
194
+ },
195
+ ],
196
+ };
197
+ const res = authorizationServerMetadata({
198
+ issuer: ISSUER,
199
+ loadDeclaredScopes: () => declared,
200
+ loadServicesManifest: () => vaultOnly as unknown as ServicesManifest,
201
+ });
202
+ const scopes = ((await res.json()) as Record<string, unknown>).scopes_supported as string[];
203
+ // core scopes survive
204
+ expect(scopes).toContain("vault:read");
205
+ expect(scopes).toContain("vault:admin");
206
+ expect(scopes).toContain("hub:admin");
207
+ // uninstalled optional-module scopes are dropped
208
+ expect(scopes).not.toContain("scribe:transcribe");
209
+ expect(scopes).not.toContain("scribe:admin");
210
+ expect(scopes).not.toContain("channel:send");
211
+
212
+ // ...but once scribe is installed, its scopes ARE advertised again.
213
+ const withScribe = {
214
+ services: [
215
+ {
216
+ name: "parachute-vault",
217
+ port: 1940,
218
+ paths: ["/vault/default"],
219
+ health: "/health",
220
+ version: "0.5.1",
221
+ },
222
+ {
223
+ name: "parachute-scribe",
224
+ port: 1943,
225
+ paths: ["/scribe"],
226
+ health: "/health",
227
+ version: "0.4.5",
228
+ },
229
+ ],
230
+ };
231
+ const res2 = authorizationServerMetadata({
232
+ issuer: ISSUER,
233
+ loadDeclaredScopes: () => declared,
234
+ loadServicesManifest: () => withScribe as unknown as ServicesManifest,
235
+ });
236
+ const scopes2 = ((await res2.json()) as Record<string, unknown>).scopes_supported as string[];
237
+ expect(scopes2).toContain("scribe:transcribe");
238
+ expect(scopes2).not.toContain("channel:send"); // channel still not installed
239
+ });
160
240
  });
161
241
 
162
242
  describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
163
243
  test("emits the required RFC 9728 fields rooted at the issuer", async () => {
164
- const res = protectedResourceMetadata({ issuer: ISSUER });
244
+ const res = protectedResourceMetadata({
245
+ issuer: ISSUER,
246
+ loadServicesManifest: fixtureLoadServicesManifest,
247
+ });
165
248
  expect(res.status).toBe(200);
166
249
  expect(res.headers.get("content-type")).toMatch(/application\/json/);
167
250
  const body = (await res.json()) as Record<string, unknown>;
@@ -185,6 +268,7 @@ describe("protectedResourceMetadata (RFC 9728, closes hub#393)", () => {
185
268
  const res = protectedResourceMetadata({
186
269
  issuer: ISSUER,
187
270
  loadDeclaredScopes: () => declared,
271
+ loadServicesManifest: fixtureLoadServicesManifest,
188
272
  });
189
273
  const body = (await res.json()) as Record<string, unknown>;
190
274
  const scopes = body.scopes_supported as string[];
@@ -3904,7 +3988,10 @@ describe("refresh-token rotation + /oauth/revoke (#73)", () => {
3904
3988
  });
3905
3989
 
3906
3990
  test("authorizationServerMetadata advertises revocation_endpoint", async () => {
3907
- const res = authorizationServerMetadata({ issuer: ISSUER });
3991
+ const res = authorizationServerMetadata({
3992
+ issuer: ISSUER,
3993
+ loadServicesManifest: fixtureLoadServicesManifest,
3994
+ });
3908
3995
  const body = (await res.json()) as Record<string, unknown>;
3909
3996
  expect(body.revocation_endpoint).toBe(`${ISSUER}/oauth/revoke`);
3910
3997
  });
@@ -8455,12 +8542,12 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8455
8542
  // Test 9 — privesc: read/write assigned (non-owner) user requests
8456
8543
  // vault:work:admin + vault:work:write → admin DROPPED, token has write only,
8457
8544
  // recorded grant lacks admin.
8458
- test("[9] non-owner read/write user requests admin+write → admin DROPPED (write only), grant lacks admin", async () => {
8545
+ test("[9] non-owner ASSIGNED to the vault requests admin+write → BOTH granted (assigned users hold admin)", async () => {
8459
8546
  const { db, cleanup } = await makeDb();
8460
8547
  try {
8461
8548
  await createUser(db, "owner", "pw"); // first user = owner; consumes the admin slot.
8462
8549
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8463
- setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write]
8550
+ setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write, admin]
8464
8551
  const session = createSession(db, { userId: friend.id });
8465
8552
  const reg = registerClient(db, {
8466
8553
  redirectUris: ["https://app.example/cb"],
@@ -8477,20 +8564,20 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8477
8564
  expect(consentRes.status).toBe(302);
8478
8565
  const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8479
8566
  const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8480
- // admin dropped; write kept.
8481
- expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8567
+ // Assigned user holds admin on their own vault → BOTH kept (2026-05-30 policy).
8568
+ expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
8482
8569
  expect(aud).toBe("vault.work");
8483
- // Recorded grant lacks admin.
8570
+ // Recorded grant includes admin.
8484
8571
  const grant = findGrant(db, friend.id, reg.client.clientId);
8485
8572
  expect(grant?.scopes).toContain("vault:work:write");
8486
- expect(grant?.scopes).not.toContain("vault:work:admin");
8573
+ expect(grant?.scopes).toContain("vault:work:admin");
8487
8574
  } finally {
8488
8575
  cleanup();
8489
8576
  }
8490
8577
  });
8491
8578
 
8492
8579
  // Test 10 — non-owner admin-ONLY request → REFUSED (clear error), no token.
8493
- test("[10] non-owner admin-only request → REFUSED with invalid_scope, no token minted", async () => {
8580
+ test("[10] non-owner assigned, admin-only request → GRANTED (holds admin on their vault)", async () => {
8494
8581
  const { db, cleanup } = await makeDb();
8495
8582
  try {
8496
8583
  await createUser(db, "owner", "pw");
@@ -8501,7 +8588,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8501
8588
  redirectUris: ["https://app.example/cb"],
8502
8589
  status: "approved",
8503
8590
  });
8504
- const { challenge } = makePkce();
8591
+ const { verifier, challenge } = makePkce();
8505
8592
  const consentRes = await submitConsent(
8506
8593
  db,
8507
8594
  session.id,
@@ -8509,14 +8596,17 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8509
8596
  "vault:work:admin",
8510
8597
  challenge,
8511
8598
  );
8512
- // Capping leaves an EMPTY scope setrefuse (no zero-scope token).
8599
+ // Assigned user holds admin on their vault minted, not refused.
8513
8600
  expect(consentRes.status).toBe(302);
8514
8601
  const loc = new URL(consentRes.headers.get("location") ?? "");
8515
8602
  expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
8516
- expect(loc.searchParams.get("error")).toBe("invalid_scope");
8517
- expect(loc.searchParams.get("code")).toBeNull();
8518
- // No grant recorded.
8519
- expect(findGrant(db, friend.id, reg.client.clientId)).toBeNull();
8603
+ const code = loc.searchParams.get("code");
8604
+ expect(code).toBeTruthy();
8605
+ const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8606
+ expect(scope.split(" ")).toEqual(["vault:work:admin"]);
8607
+ expect(aud).toBe("vault.work");
8608
+ const grant = findGrant(db, friend.id, reg.client.clientId);
8609
+ expect(grant?.scopes).toContain("vault:work:admin");
8520
8610
  } finally {
8521
8611
  cleanup();
8522
8612
  }
@@ -8524,7 +8614,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8524
8614
 
8525
8615
  // Test 11 — non-owner unnamed vault:admin + picks assigned vault → after
8526
8616
  // narrowing, admin dropped (cap runs post-narrow).
8527
- test("[11] non-owner unnamed vault:admin + picks assigned vault → admin dropped post-narrow", async () => {
8617
+ test("[11] non-owner unnamed vault:admin + picks assigned vault → admin KEPT post-narrow", async () => {
8528
8618
  const { db, cleanup } = await makeDb();
8529
8619
  try {
8530
8620
  await createUser(db, "owner", "pw");
@@ -8545,16 +8635,16 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8545
8635
  challenge,
8546
8636
  { vault_pick: "work" },
8547
8637
  );
8548
- // narrowVaultScopes → vault:work:admin + vault:work:write; cap drops
8549
- // admin (non-owner doesn't hold it), keeps write → mints write only.
8638
+ // narrowVaultScopes → vault:work:admin + vault:work:write; cap KEEPS
8639
+ // both (assigned user holds admin on their picked vault) → mints both.
8550
8640
  expect(consentRes.status).toBe(302);
8551
8641
  const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8552
8642
  expect(code).toBeTruthy();
8553
8643
  const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8554
- expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8644
+ expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
8555
8645
  const grant = findGrant(db, friend.id, reg.client.clientId);
8556
8646
  expect(grant?.scopes).toContain("vault:work:write");
8557
- expect(grant?.scopes).not.toContain("vault:work:admin");
8647
+ expect(grant?.scopes).toContain("vault:work:admin");
8558
8648
  } finally {
8559
8649
  cleanup();
8560
8650
  }
@@ -8683,35 +8773,41 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8683
8773
  // issueAuthCodeRedirect with an admin scope via ANY path (skip-consent /
8684
8774
  // same-hub / consent). Assert no grants row ever contains an un-held admin
8685
8775
  // verb across each path.
8686
- test("[15] bypass-proof: no mint path ever records an un-held admin verb for a non-owner", async () => {
8776
+ test("[15] bypass-proof: no mint path grants admin on a vault the user is NOT assigned", async () => {
8687
8777
  const { db, cleanup } = await makeDb();
8688
8778
  try {
8689
8779
  await createUser(db, "owner", "pw");
8690
8780
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8691
- setUserVaults(db, friend.id, ["work"]); // verbs [read, write] only
8781
+ // Assigned to "work" only holds work:read/write/admin, but NOT "other"
8782
+ // (which exists in CAP_MANIFEST). "other" is the un-held boundary.
8783
+ setUserVaults(db, friend.id, ["work"]);
8692
8784
  const session = createSession(db, { userId: friend.id });
8693
8785
 
8694
- // Path A — consent-submit with admin+write admin dropped, recorded grant
8695
- // lacks admin.
8786
+ // Path A — consent-submit admin on the UNASSIGNED "other" → the cap
8787
+ // empties the set (friend doesn't hold it) → invalid_scope refusal, no
8788
+ // grant. (Granting admin on the user's OWN assigned vault is exercised by
8789
+ // [9]/[10]; here we isolate the un-held boundary.)
8696
8790
  const regConsent = registerClient(db, {
8697
8791
  redirectUris: ["https://app.example/cb"],
8698
8792
  status: "approved",
8699
8793
  });
8700
8794
  {
8701
8795
  const { challenge } = makePkce();
8702
- await submitConsent(
8796
+ const res = await submitConsent(
8703
8797
  db,
8704
8798
  session.id,
8705
8799
  regConsent.client.clientId,
8706
- "vault:work:admin vault:work:write",
8800
+ "vault:other:admin",
8707
8801
  challenge,
8708
8802
  );
8709
- const g = findGrant(db, friend.id, regConsent.client.clientId);
8710
- expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8803
+ // The consent-submit assignment gate rejects a request naming a vault
8804
+ // the user isn't assigned (400) refused before any mint, no grant.
8805
+ expect(res.status).toBe(400);
8806
+ expect(findGrant(db, friend.id, regConsent.client.clientId)).toBeNull();
8711
8807
  }
8712
8808
 
8713
- // Path B — same-hub client requesting admin consent renders (admin gate
8714
- // blocks the silent path); no grant recorded with admin.
8809
+ // Path B — same-hub client requesting admin on the UNASSIGNED "other"
8810
+ // consent renders (admin gate blocks the silent path); no grant with it.
8715
8811
  const regSameHub = registerClient(db, {
8716
8812
  redirectUris: ["https://app.example/cb"],
8717
8813
  status: "approved",
@@ -8728,7 +8824,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8728
8824
  response_type: "code",
8729
8825
  code_challenge: challenge,
8730
8826
  code_challenge_method: "S256",
8731
- scope: "vault:work:admin",
8827
+ scope: "vault:other:admin",
8732
8828
  }),
8733
8829
  { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8734
8830
  ),
@@ -8736,22 +8832,23 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8736
8832
  );
8737
8833
  expect(res.status).toBe(200); // consent, not silent-mint
8738
8834
  const g = findGrant(db, friend.id, regSameHub.client.clientId);
8739
- expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8835
+ expect(g?.scopes ?? []).not.toContain("vault:other:admin");
8740
8836
  }
8741
8837
 
8742
- // Path C — skip-consent: even if a grant row were somehow seeded with an
8743
- // admin verb, the cap at issueAuthCodeRedirect drops it before minting AND
8744
- // re-records the capped set. Seed a poisoned grant, drive skip-consent,
8745
- // and assert the minted token + the (re-recorded) grant carry no admin.
8838
+ // Path C — skip-consent: a grant row seeded with an UNASSIGNED-vault admin
8839
+ // verb. The cap at issueAuthCodeRedirect is the authority, not the grant
8840
+ // row request only the held write scope (skip-consent fires) and the
8841
+ // minted token carries no other:admin (the un-held verb never rides along).
8746
8842
  const regSkip = registerClient(db, {
8747
8843
  redirectUris: ["https://app.example/cb"],
8748
8844
  status: "approved",
8749
8845
  });
8750
- recordGrant(db, friend.id, regSkip.client.clientId, ["vault:work:write", "vault:work:admin"]);
8846
+ recordGrant(db, friend.id, regSkip.client.clientId, [
8847
+ "vault:work:write",
8848
+ "vault:other:admin",
8849
+ ]);
8751
8850
  {
8752
8851
  const { verifier, challenge } = makePkce();
8753
- // Request only the held write scope so skip-consent fires (covered by
8754
- // the seeded grant) and routes through issueAuthCodeRedirect.
8755
8852
  const res = handleAuthorizeGet(
8756
8853
  db,
8757
8854
  new Request(
@@ -8770,37 +8867,36 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8770
8867
  expect(res.status).toBe(302); // skip-consent silent mint of the held scope
8771
8868
  const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
8772
8869
  const { scope } = await redeemToScopeAud(db, code ?? "", regSkip.client.clientId, verifier);
8773
- expect(scope.split(" ")).not.toContain("vault:work:admin");
8870
+ expect(scope.split(" ")).not.toContain("vault:other:admin");
8774
8871
  }
8775
8872
  } finally {
8776
8873
  cleanup();
8777
8874
  }
8778
8875
  });
8779
8876
 
8780
- // Reviewer fold (security-relevant): test 15 path C only requested `write`
8781
- // against the poisoned client, which proves the cap doesn't *re-record* an
8782
- // un-held verb. This case requests `vault:work:admin` DIRECTLY against a
8783
- // client whose grant row ALREADY lists `vault:work:admin` (poisoned). The
8784
- // skip-consent gate fires (the requested admin scope IS covered by the
8785
- // poisoned grant) and routes through issueAuthCodeRedirect — where the CAP,
8786
- // not the grant lookup, drops the admin verb. Admin-only request → caps to
8787
- // EMPTY → invalid_scope refusal, no code, no token. This pins that the
8788
- // cap-before-issueAuthCode invariant holds even when a stale admin grant
8789
- // would otherwise satisfy the coverage check.
8790
- test("[15b] non-owner requests admin DIRECTLY against a POISONED-grant client → cap refuses, no admin token", async () => {
8877
+ // Reviewer fold (security-relevant): test 15 path C only requested the held
8878
+ // `write` scope, proving the cap doesn't re-record an un-held verb. This case
8879
+ // requests admin on the UNASSIGNED "other" vault DIRECTLY against a client
8880
+ // whose grant row already lists `vault:other:admin` (poisoned). The
8881
+ // skip-consent gate fires (the requested scope IS covered by the poisoned
8882
+ // grant) and routes through issueAuthCodeRedirect — where the CAP, not the
8883
+ // grant lookup, drops the un-held verb. Admin-only request → caps to EMPTY →
8884
+ // invalid_scope refusal, no code, no token. Pins that the cap-before-
8885
+ // issueAuthCode invariant holds even when a stale grant satisfies coverage.
8886
+ test("[15b] non-owner requests admin on an UNASSIGNED vault against a POISONED-grant client → cap refuses", async () => {
8791
8887
  const { db, cleanup } = await makeDb();
8792
8888
  try {
8793
8889
  await createUser(db, "owner", "pw");
8794
8890
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8795
- setUserVaults(db, friend.id, ["work"]); // verbs [read, write] onlynever admin
8891
+ setUserVaults(db, friend.id, ["work"]); // holds work:* (incl. admin)NOT "other"
8796
8892
  const session = createSession(db, { userId: friend.id });
8797
8893
  const reg = registerClient(db, {
8798
8894
  redirectUris: ["https://app.example/cb"],
8799
8895
  status: "approved",
8800
8896
  });
8801
- // Poisoned grant: already lists vault:work:admin (so the skip-consent
8802
- // coverage check would pass for a direct admin request).
8803
- recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:work:admin"]);
8897
+ // Poisoned grant: already lists vault:other:admin (so the skip-consent
8898
+ // coverage check would pass for a direct other:admin request).
8899
+ recordGrant(db, friend.id, reg.client.clientId, ["vault:work:write", "vault:other:admin"]);
8804
8900
 
8805
8901
  const { challenge } = makePkce();
8806
8902
  const res = handleAuthorizeGet(
@@ -8812,14 +8908,14 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8812
8908
  response_type: "code",
8813
8909
  code_challenge: challenge,
8814
8910
  code_challenge_method: "S256",
8815
- scope: "vault:work:admin",
8911
+ scope: "vault:other:admin",
8816
8912
  state: "poisoned-direct",
8817
8913
  }),
8818
8914
  { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8819
8915
  ),
8820
8916
  capDeps,
8821
8917
  );
8822
- // The cap leaves an EMPTY set (non-owner doesn't hold admin) → refuse.
8918
+ // The cap leaves an EMPTY set (friend isn't assigned "other") → refuse.
8823
8919
  // 302 to redirect_uri with invalid_scope and NO code — no token minted.
8824
8920
  expect(res.status).toBe(302);
8825
8921
  const loc = new URL(res.headers.get("location") ?? "");
@@ -740,10 +740,13 @@ describe("getFirstAdminId / isFirstAdmin", () => {
740
740
 
741
741
  describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", () => {
742
742
  test("vaultVerbsForRole maps roles to verbs, fails closed on unknown", () => {
743
- expect(vaultVerbsForRole("write")).toEqual(["read", "write"]);
743
+ // Assigned users (role=write, today's default) hold FULL vault authority
744
+ // incl. admin (2026-05-30 policy: any assigned user gets admin). A
745
+ // deliberate read-only assignment stays read-only. Unknown role strings
746
+ // (including the literal "admin") map to [] — only the recognised roles
747
+ // grant verbs; never silently default to write.
748
+ expect(vaultVerbsForRole("write")).toEqual(["read", "write", "admin"]);
744
749
  expect(vaultVerbsForRole("read")).toEqual(["read"]);
745
- // Unknown / future roles grant NO mintable verb — never silently
746
- // default to write. `admin` is explicitly NOT a mintable role here.
747
750
  expect(vaultVerbsForRole("admin")).toEqual([]);
748
751
  expect(vaultVerbsForRole("owner")).toEqual([]);
749
752
  expect(vaultVerbsForRole("")).toEqual([]);
@@ -757,8 +760,9 @@ describe("vaultVerbsForRole / vaultVerbsForUserVault (friend token-mint cap)", (
757
760
  allowMulti: true,
758
761
  assignedVaults: ["work"],
759
762
  });
760
- // createUser/setUserVaults insert role='write' today → read+write.
761
- expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read", "write"]);
763
+ // createUser/setUserVaults insert role='write' today → read+write+admin
764
+ // (assigned users hold full vault authority, 2026-05-30 policy).
765
+ expect(vaultVerbsForUserVault(db, friend.id, "work")).toEqual(["read", "write", "admin"]);
762
766
  } finally {
763
767
  cleanup();
764
768
  }
@@ -384,7 +384,12 @@ function renderTokenMintBlock(
384
384
  const radios = verbs
385
385
  .map((verb, i) => {
386
386
  const checked = i === 0 ? " checked" : "";
387
- const label = verb === "read" ? "Read-only" : "Read + write";
387
+ const label =
388
+ verb === "read"
389
+ ? "Read-only"
390
+ : verb === "admin"
391
+ ? "Full (read, write, rotate tokens + config)"
392
+ : "Read + write";
388
393
  return `
389
394
  <label class="mint-verb-option">
390
395
  <input type="radio" name="verb" value="${verb}"${checked}
@@ -21,12 +21,10 @@
21
21
  * `vaultVerbsForUserVault` (which returns `null` for an unassigned
22
22
  * vault), NOT from the verb-blind `assignedVaults` array.
23
23
  * 3. **Scope cap.** The requested verb MUST be one the user's assignment
24
- * role permits, and may only ever be `read` or `write` NEVER `admin`,
25
- * never a broader scope than the user holds. `vaultVerbsForUserVault`
26
- * returns the role's verb set (today always `["read", "write"]` since
27
- * every assignment is `role = 'write'`); a verb outside that set → 403.
28
- * `admin` is not in the form's vocabulary at all and is rejected at the
29
- * parse step as an invalid verb.
24
+ * role permits. As of 2026-05-30 an assigned user holds the full set
25
+ * `["read", "write", "admin"]` on their vault (`vaultVerbsForUserVault`),
26
+ * so this surface mints admin tokens too consistent with the OAuth
27
+ * flow. A verb outside the held set, or for an unassigned vault, → 403.
30
28
  *
31
29
  * The first admin (unrestricted, empty `assignedVaults`) has no `user_vaults`
32
30
  * rows, so gate 2 returns `null` for every vault and the admin gets a 403
@@ -81,7 +79,7 @@ import { type VaultVerb, getUserById, isFirstAdmin, vaultVerbsForUserVault } fro
81
79
  /** Matches the manifest vault-name validator + `/admin/vault-admin-token`. */
82
80
  const VAULT_NAME_RE = /^[a-zA-Z0-9_-]+$/;
83
81
  /** Verbs this surface will ever mint. `admin` is deliberately absent. */
84
- const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write"];
82
+ const ALLOWED_VERBS: readonly VaultVerb[] = ["read", "write", "admin"];
85
83
  /** client_id stamped on the minted JWT + registry row. */
86
84
  const ACCOUNT_VAULT_TOKEN_CLIENT_ID = "parachute-account";
87
85
 
@@ -199,14 +197,15 @@ export async function handleAccountVaultTokenPost(
199
197
  return renderHome(400, { mintError: `"${vaultName}" is not a valid vault name.` });
200
198
  }
201
199
 
202
- // Verb parse — must be exactly one of read/write. `admin` (or anything
203
- // else) is not in this surface's vocabulary and is rejected here, well
204
- // before authority is even consulted.
200
+ // Verb parse — must be one of read/write/admin (this surface's vocabulary).
201
+ // Anything else is rejected here, well before authority is even consulted;
202
+ // the per-vault authority cap (gate 3 below) then drops verbs the user
203
+ // doesn't actually hold.
205
204
  const verbRaw = form.get("verb");
206
205
  const verb = typeof verbRaw === "string" ? verbRaw : "";
207
206
  if (!ALLOWED_VERBS.includes(verb as VaultVerb)) {
208
207
  return renderHome(400, {
209
- mintError: "Pick an access level (read or write).",
208
+ mintError: "Pick an access level (read, write, or admin).",
210
209
  });
211
210
  }
212
211
  const requestedVerb = verb as VaultVerb;
@@ -217,9 +216,11 @@ export async function handleAccountVaultTokenPost(
217
216
  // (fail-closed for an unknown role): 403.
218
217
  // - [...] → the verbs the assignment role permits. The requested verb
219
218
  // must be in this set (gate 3): else 403.
220
- // This is the cap to the user's actual authority — it blocks minting for
221
- // an unassigned vault, a broader verb than the role grants, and (since the
222
- // set never contains `admin`) any admin escalation.
219
+ // This is the cap to the user's actual authority — it blocks minting for an
220
+ // unassigned vault or a verb the role doesn't grant. Assigned users hold
221
+ // read/write/admin on their vault (2026-05-30), so admin mints for an
222
+ // assigned vault; the cap still refuses admin (and everything else) for a
223
+ // vault the user isn't assigned (`allowedForUser === null`).
223
224
  const allowedForUser = vaultVerbsForUserVault(deps.db, user.id, vaultName);
224
225
  if (allowedForUser === null) {
225
226
  return renderHome(403, {
@@ -388,13 +388,53 @@ function oauthErrorRedirect(
388
388
  *
389
389
  * Closes hub#393.
390
390
  */
391
+
392
+ /**
393
+ * Optional first-party modules whose scopes `FIRST_PARTY_SCOPES` carries
394
+ * statically (it's `Object.keys(SCOPE_EXPLANATIONS)`), paired with the
395
+ * services.json entry that means "installed." Vault + hub are core and always
396
+ * advertised; these are the modules a hub may not have.
397
+ */
398
+ const OPTIONAL_MODULE_SCOPES: ReadonlyArray<readonly [prefix: string, service: string]> = [
399
+ ["scribe:", "parachute-scribe"],
400
+ ["channel:", "parachute-channel"],
401
+ ];
402
+
403
+ /**
404
+ * The scope set to advertise in `scopes_supported` (RFC 8414 + RFC 9728): the
405
+ * requestable declared scopes, minus any OPTIONAL module's scopes when that
406
+ * module isn't installed.
407
+ *
408
+ * Why: `FIRST_PARTY_SCOPES` is static, so a vault-only hub still advertised
409
+ * `scribe:*` + `channel:send`. Discovery clients list the advertised catalog
410
+ * verbatim — claude.ai's connector UI showed a friend connecting ONE vault a
411
+ * request for Scribe + Channel access the hub can't even honor. So advertise an
412
+ * optional module's scopes only when its service is present in services.json.
413
+ * (Trims the ADVERTISEMENT only; issuance/validation still use the full
414
+ * `loadDeclaredScopes` set, and the per-vault PRM stays vault-narrowed.)
415
+ */
416
+ function advertisedScopes(declared: ReadonlySet<string>, manifest: ServicesManifest): string[] {
417
+ const installed = new Set(manifest.services.map((s) => s.name));
418
+ return Array.from(declared)
419
+ .filter(isRequestableScope)
420
+ .filter((scope) => {
421
+ for (const [prefix, service] of OPTIONAL_MODULE_SCOPES) {
422
+ if (scope.startsWith(prefix) && !installed.has(service)) return false;
423
+ }
424
+ return true;
425
+ });
426
+ }
427
+
391
428
  export function protectedResourceMetadata(deps: OAuthDeps): Response {
392
429
  const iss = deps.issuer;
393
430
  const declared = (deps.loadDeclaredScopes ?? loadDeclaredScopes)();
394
431
  return jsonResponse({
395
432
  resource: iss,
396
433
  authorization_servers: [iss],
397
- scopes_supported: Array.from(declared).filter(isRequestableScope),
434
+ scopes_supported: advertisedScopes(
435
+ declared,
436
+ (deps.loadServicesManifest ?? readServicesManifest)(),
437
+ ),
398
438
  bearer_methods_supported: ["header"],
399
439
  resource_documentation: "https://parachute.computer",
400
440
  // Intentional omission: `resource_signing_alg_values_supported` +
@@ -435,7 +475,10 @@ export function authorizationServerMetadata(deps: OAuthDeps): Response {
435
475
  // — RFC 8414 §2 frames `scopes_supported` as "the OAuth 2.0 [...] scope
436
476
  // values that this authorization server supports" for clients to request.
437
477
  // Advertising what we always reject would mislead clients.
438
- scopes_supported: Array.from(declared).filter(isRequestableScope),
478
+ scopes_supported: advertisedScopes(
479
+ declared,
480
+ (deps.loadServicesManifest ?? readServicesManifest)(),
481
+ ),
439
482
  });
440
483
  }
441
484
 
@@ -1150,12 +1193,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
1150
1193
  * - The authority source of truth today is `isFirstAdmin` for owner-wide
1151
1194
  * authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
1152
1195
  * users.
1153
- * - `vaultVerbsForRole` provably never returns `admin` for an assigned user
1154
- * (it maps write→[read,write], read→[read], unknown→[]), so this helper
1155
- * drops `vault:<name>:admin` for every non-owner BY CONSTRUCTION without
1156
- * hardcoding "drop admin". It reads the held verb set and admits only
1157
- * held verbs, so it's forward-compatible: if a future role ever granted
1158
- * admin, the cap would admit it automatically.
1196
+ * - `vaultVerbsForRole` maps write→[read,write,admin] (2026-05-30: any
1197
+ * assigned user holds FULL vault authority, incl. admin), read→[read],
1198
+ * unknown→[]. This helper reads the held verb set and admits ONLY held
1199
+ * verbs no hardcoded allow/deny of admin. So an assigned user gets
1200
+ * `vault:<their-vault>:admin`, while a user gets NOTHING for a vault they
1201
+ * aren't assigned (held=null every verb dropped). The cap is the
1202
+ * forward-compatible single source: it admitted admin automatically the
1203
+ * moment the role mapping changed — no edit here was needed.
1159
1204
  * - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
1160
1205
  * paths funnel through: consent-submit, skip-consent, and same-hub
1161
1206
  * auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
@@ -45,6 +45,12 @@ export const SCOPE_EXPLANATIONS: Record<string, ScopeExplanation> = {
45
45
  label: "Full vault access plus configuration changes (rotate tokens, change settings).",
46
46
  level: "admin",
47
47
  },
48
+ // Optional-module scopes (scribe / channel). These are in FIRST_PARTY_SCOPES
49
+ // (= Object.keys(this map)) but the modules may not be installed — so they're
50
+ // GATED in `OPTIONAL_MODULE_SCOPES` (oauth-handlers.ts) and only advertised in
51
+ // `scopes_supported` when the service is in services.json. If you add scopes
52
+ // for another optional module here, add a matching gate there too, or a
53
+ // vault-only hub will over-advertise them (the bug behind hub#489).
48
54
  "scribe:transcribe": {
49
55
  label: "Send audio to Scribe for transcription.",
50
56
  level: "write",
@@ -146,10 +152,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
146
152
  * enforced at the shared mint choke-point (`capScopesToUserAuthority` applied
147
153
  * inside `issueAuthCodeRedirect` in `oauth-handlers.ts`): an OAuth flow caps
148
154
  * named vault verbs to those the consenting user actually holds on that vault.
149
- * `vaultVerbsForRole` never returns `admin` for an assigned (read/write) user,
150
- * so admin is dropped for everyone except the hub owner (isFirstAdmin) who
151
- * holds admin everywhere by construction. An admin-only request from a
152
- * non-owner is refused outright (never minted as a zero-scope token).
155
+ * `vaultVerbsForRole` returns admin for an assigned user (2026-05-30: any
156
+ * assigned user holds full vault authority on their own vault), so a non-owner
157
+ * can delegate `vault:<their-vault>:admin` to their client. The cap still
158
+ * drops admin (and every verb) for a vault the user is NOT assigned to
159
+ * (held=null), and an admin-only request the cap empties is refused outright
160
+ * (never minted as a zero-scope token). The hub owner (isFirstAdmin) holds
161
+ * admin everywhere by construction.
153
162
  *
154
163
  * `vault:<name>:admin` also remains mintable by operator-proving local paths,
155
164
  * all of which require already-established authority:
package/src/users.ts CHANGED
@@ -146,30 +146,37 @@ function readVaultsForUser(db: Database, userId: string): string[] {
146
146
 
147
147
  /**
148
148
  * The per-vault verbs a `user_vaults.role` grants. The schema's `role`
149
- * column is `TEXT NOT NULL DEFAULT 'write'` and is reserved forward-compat
150
- * for per-vault role granularity (see the v10 migration note in
151
- * `hub-db.ts`). Today every assignment is created with `role = 'write'`, so
152
- * the only live mapping is `write → {read, write}`. The function is the
153
- * single place the verb-cap lives so a future role taxonomy (`read`-only,
154
- * `admin`, etc.) lands here without the friend-mint path having to change.
149
+ * column is `TEXT NOT NULL DEFAULT 'write'`; today every assignment is created
150
+ * with `role = 'write'`. This is the single place the verb-cap lives, so the
151
+ * OAuth mint cap (`capScopesToUserAuthority`) and the `/account` mint UI both
152
+ * read authority from here.
153
+ *
154
+ * **Assigned users hold FULL vault authority (read + write + admin)** as of
155
+ * 2026-05-30 (Aaron's call: "any assigned user gets admin"). The point of the
156
+ * multi-user flow is that someone given a vault — owned or shared — can connect
157
+ * their own client (e.g. Claude MCP) to it and grant everything they'd want,
158
+ * including `vault:<name>:admin` (token creation + config). Owner-vs-shared is
159
+ * NOT distinguished today; a shared user gets admin too (explicit trade-off).
155
160
  *
156
161
  * Mapping:
157
- * - `write` (today's only value) → `["read", "write"]`
158
- * - `read` → `["read"]`
162
+ * - `write` (today's default) → `["read", "write", "admin"]`
163
+ * - `read` (forward-compat) → `["read"]` — a *deliberate* read-only
164
+ * assignment stays read-only even under the any-assigned-user-gets-admin
165
+ * policy. Not created by any flow today.
159
166
  * - anything else (unknown role) → `[]` — fail closed. An unrecognised
160
167
  * role grants no minting authority rather than silently defaulting to
161
168
  * write. (Defense-in-depth: a hand-edited / future row with a role this
162
- * code doesn't understand should not be treated as broad write.)
169
+ * code doesn't understand should not be treated as broad.)
163
170
  *
164
- * `admin` is intentionally NOT mapped to a `vault:<name>:admin` mint here —
165
- * the friend-facing token mint is capped at read/write by design. A
166
- * future per-vault-admin friend grant would route through the
167
- * vault-admin-token path, not this one.
171
+ * Scope of the widening: this only affects `vault:<name>:<verb>` for vaults
172
+ * the user is assigned. Hub-level admin (`hub:admin`) + host operator scopes
173
+ * (`parachute:host:*`) are NOT vault scopes and remain ungrantable by
174
+ * non-admins — the cap's named-vault branch is the only thing this touches.
168
175
  */
169
- export type VaultVerb = "read" | "write";
176
+ export type VaultVerb = "read" | "write" | "admin";
170
177
 
171
178
  export function vaultVerbsForRole(role: string): VaultVerb[] {
172
- if (role === "write") return ["read", "write"];
179
+ if (role === "write") return ["read", "write", "admin"];
173
180
  if (role === "read") return ["read"];
174
181
  return [];
175
182
  }