@openparachute/hub 0.6.1-rc.3 → 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.3",
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 () => {
@@ -8542,12 +8542,12 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8542
8542
  // Test 9 — privesc: read/write assigned (non-owner) user requests
8543
8543
  // vault:work:admin + vault:work:write → admin DROPPED, token has write only,
8544
8544
  // recorded grant lacks admin.
8545
- 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 () => {
8546
8546
  const { db, cleanup } = await makeDb();
8547
8547
  try {
8548
8548
  await createUser(db, "owner", "pw"); // first user = owner; consumes the admin slot.
8549
8549
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8550
- setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write]
8550
+ setUserVaults(db, friend.id, ["work"]); // role=write → verbs [read, write, admin]
8551
8551
  const session = createSession(db, { userId: friend.id });
8552
8552
  const reg = registerClient(db, {
8553
8553
  redirectUris: ["https://app.example/cb"],
@@ -8564,20 +8564,20 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8564
8564
  expect(consentRes.status).toBe(302);
8565
8565
  const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8566
8566
  const { scope, aud } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8567
- // admin dropped; write kept.
8568
- 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"]);
8569
8569
  expect(aud).toBe("vault.work");
8570
- // Recorded grant lacks admin.
8570
+ // Recorded grant includes admin.
8571
8571
  const grant = findGrant(db, friend.id, reg.client.clientId);
8572
8572
  expect(grant?.scopes).toContain("vault:work:write");
8573
- expect(grant?.scopes).not.toContain("vault:work:admin");
8573
+ expect(grant?.scopes).toContain("vault:work:admin");
8574
8574
  } finally {
8575
8575
  cleanup();
8576
8576
  }
8577
8577
  });
8578
8578
 
8579
8579
  // Test 10 — non-owner admin-ONLY request → REFUSED (clear error), no token.
8580
- 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 () => {
8581
8581
  const { db, cleanup } = await makeDb();
8582
8582
  try {
8583
8583
  await createUser(db, "owner", "pw");
@@ -8588,7 +8588,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8588
8588
  redirectUris: ["https://app.example/cb"],
8589
8589
  status: "approved",
8590
8590
  });
8591
- const { challenge } = makePkce();
8591
+ const { verifier, challenge } = makePkce();
8592
8592
  const consentRes = await submitConsent(
8593
8593
  db,
8594
8594
  session.id,
@@ -8596,14 +8596,17 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8596
8596
  "vault:work:admin",
8597
8597
  challenge,
8598
8598
  );
8599
- // Capping leaves an EMPTY scope setrefuse (no zero-scope token).
8599
+ // Assigned user holds admin on their vault minted, not refused.
8600
8600
  expect(consentRes.status).toBe(302);
8601
8601
  const loc = new URL(consentRes.headers.get("location") ?? "");
8602
8602
  expect(loc.origin + loc.pathname).toBe("https://app.example/cb");
8603
- expect(loc.searchParams.get("error")).toBe("invalid_scope");
8604
- expect(loc.searchParams.get("code")).toBeNull();
8605
- // No grant recorded.
8606
- 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");
8607
8610
  } finally {
8608
8611
  cleanup();
8609
8612
  }
@@ -8611,7 +8614,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8611
8614
 
8612
8615
  // Test 11 — non-owner unnamed vault:admin + picks assigned vault → after
8613
8616
  // narrowing, admin dropped (cap runs post-narrow).
8614
- 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 () => {
8615
8618
  const { db, cleanup } = await makeDb();
8616
8619
  try {
8617
8620
  await createUser(db, "owner", "pw");
@@ -8632,16 +8635,16 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8632
8635
  challenge,
8633
8636
  { vault_pick: "work" },
8634
8637
  );
8635
- // narrowVaultScopes → vault:work:admin + vault:work:write; cap drops
8636
- // 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.
8637
8640
  expect(consentRes.status).toBe(302);
8638
8641
  const code = new URL(consentRes.headers.get("location") ?? "").searchParams.get("code");
8639
8642
  expect(code).toBeTruthy();
8640
8643
  const { scope } = await redeemToScopeAud(db, code ?? "", reg.client.clientId, verifier);
8641
- expect(scope.split(" ").sort()).toEqual(["vault:work:write"]);
8644
+ expect(scope.split(" ").sort()).toEqual(["vault:work:admin", "vault:work:write"]);
8642
8645
  const grant = findGrant(db, friend.id, reg.client.clientId);
8643
8646
  expect(grant?.scopes).toContain("vault:work:write");
8644
- expect(grant?.scopes).not.toContain("vault:work:admin");
8647
+ expect(grant?.scopes).toContain("vault:work:admin");
8645
8648
  } finally {
8646
8649
  cleanup();
8647
8650
  }
@@ -8770,35 +8773,41 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8770
8773
  // issueAuthCodeRedirect with an admin scope via ANY path (skip-consent /
8771
8774
  // same-hub / consent). Assert no grants row ever contains an un-held admin
8772
8775
  // verb across each path.
8773
- 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 () => {
8774
8777
  const { db, cleanup } = await makeDb();
8775
8778
  try {
8776
8779
  await createUser(db, "owner", "pw");
8777
8780
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8778
- 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"]);
8779
8784
  const session = createSession(db, { userId: friend.id });
8780
8785
 
8781
- // Path A — consent-submit with admin+write admin dropped, recorded grant
8782
- // 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.)
8783
8790
  const regConsent = registerClient(db, {
8784
8791
  redirectUris: ["https://app.example/cb"],
8785
8792
  status: "approved",
8786
8793
  });
8787
8794
  {
8788
8795
  const { challenge } = makePkce();
8789
- await submitConsent(
8796
+ const res = await submitConsent(
8790
8797
  db,
8791
8798
  session.id,
8792
8799
  regConsent.client.clientId,
8793
- "vault:work:admin vault:work:write",
8800
+ "vault:other:admin",
8794
8801
  challenge,
8795
8802
  );
8796
- const g = findGrant(db, friend.id, regConsent.client.clientId);
8797
- 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();
8798
8807
  }
8799
8808
 
8800
- // Path B — same-hub client requesting admin consent renders (admin gate
8801
- // 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.
8802
8811
  const regSameHub = registerClient(db, {
8803
8812
  redirectUris: ["https://app.example/cb"],
8804
8813
  status: "approved",
@@ -8815,7 +8824,7 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8815
8824
  response_type: "code",
8816
8825
  code_challenge: challenge,
8817
8826
  code_challenge_method: "S256",
8818
- scope: "vault:work:admin",
8827
+ scope: "vault:other:admin",
8819
8828
  }),
8820
8829
  { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8821
8830
  ),
@@ -8823,22 +8832,23 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8823
8832
  );
8824
8833
  expect(res.status).toBe(200); // consent, not silent-mint
8825
8834
  const g = findGrant(db, friend.id, regSameHub.client.clientId);
8826
- expect(g?.scopes ?? []).not.toContain("vault:work:admin");
8835
+ expect(g?.scopes ?? []).not.toContain("vault:other:admin");
8827
8836
  }
8828
8837
 
8829
- // Path C — skip-consent: even if a grant row were somehow seeded with an
8830
- // admin verb, the cap at issueAuthCodeRedirect drops it before minting AND
8831
- // re-records the capped set. Seed a poisoned grant, drive skip-consent,
8832
- // 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).
8833
8842
  const regSkip = registerClient(db, {
8834
8843
  redirectUris: ["https://app.example/cb"],
8835
8844
  status: "approved",
8836
8845
  });
8837
- 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
+ ]);
8838
8850
  {
8839
8851
  const { verifier, challenge } = makePkce();
8840
- // Request only the held write scope so skip-consent fires (covered by
8841
- // the seeded grant) and routes through issueAuthCodeRedirect.
8842
8852
  const res = handleAuthorizeGet(
8843
8853
  db,
8844
8854
  new Request(
@@ -8857,37 +8867,36 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8857
8867
  expect(res.status).toBe(302); // skip-consent silent mint of the held scope
8858
8868
  const code = new URL(res.headers.get("location") ?? "").searchParams.get("code");
8859
8869
  const { scope } = await redeemToScopeAud(db, code ?? "", regSkip.client.clientId, verifier);
8860
- expect(scope.split(" ")).not.toContain("vault:work:admin");
8870
+ expect(scope.split(" ")).not.toContain("vault:other:admin");
8861
8871
  }
8862
8872
  } finally {
8863
8873
  cleanup();
8864
8874
  }
8865
8875
  });
8866
8876
 
8867
- // Reviewer fold (security-relevant): test 15 path C only requested `write`
8868
- // against the poisoned client, which proves the cap doesn't *re-record* an
8869
- // un-held verb. This case requests `vault:work:admin` DIRECTLY against a
8870
- // client whose grant row ALREADY lists `vault:work:admin` (poisoned). The
8871
- // skip-consent gate fires (the requested admin scope IS covered by the
8872
- // poisoned grant) and routes through issueAuthCodeRedirect — where the CAP,
8873
- // not the grant lookup, drops the admin verb. Admin-only request → caps to
8874
- // EMPTY → invalid_scope refusal, no code, no token. This pins that the
8875
- // cap-before-issueAuthCode invariant holds even when a stale admin grant
8876
- // would otherwise satisfy the coverage check.
8877
- 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 () => {
8878
8887
  const { db, cleanup } = await makeDb();
8879
8888
  try {
8880
8889
  await createUser(db, "owner", "pw");
8881
8890
  const friend = await createUser(db, "friend", "pw", { allowMulti: true });
8882
- setUserVaults(db, friend.id, ["work"]); // verbs [read, write] onlynever admin
8891
+ setUserVaults(db, friend.id, ["work"]); // holds work:* (incl. admin)NOT "other"
8883
8892
  const session = createSession(db, { userId: friend.id });
8884
8893
  const reg = registerClient(db, {
8885
8894
  redirectUris: ["https://app.example/cb"],
8886
8895
  status: "approved",
8887
8896
  });
8888
- // Poisoned grant: already lists vault:work:admin (so the skip-consent
8889
- // coverage check would pass for a direct admin request).
8890
- 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"]);
8891
8900
 
8892
8901
  const { challenge } = makePkce();
8893
8902
  const res = handleAuthorizeGet(
@@ -8899,14 +8908,14 @@ describe("single OAuth consent + grantable vault admin + delegate-only cap (2026
8899
8908
  response_type: "code",
8900
8909
  code_challenge: challenge,
8901
8910
  code_challenge_method: "S256",
8902
- scope: "vault:work:admin",
8911
+ scope: "vault:other:admin",
8903
8912
  state: "poisoned-direct",
8904
8913
  }),
8905
8914
  { headers: { cookie: `${CSRF_COOKIE}; ${buildSessionCookie(session.id, TTL_S)}` } },
8906
8915
  ),
8907
8916
  capDeps,
8908
8917
  );
8909
- // 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.
8910
8919
  // 302 to redirect_uri with invalid_scope and NO code — no token minted.
8911
8920
  expect(res.status).toBe(302);
8912
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, {
@@ -1193,12 +1193,14 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
1193
1193
  * - The authority source of truth today is `isFirstAdmin` for owner-wide
1194
1194
  * authority and `user_vaults.role` (via `vaultVerbsForRole`) for assigned
1195
1195
  * users.
1196
- * - `vaultVerbsForRole` provably never returns `admin` for an assigned user
1197
- * (it maps write→[read,write], read→[read], unknown→[]), so this helper
1198
- * drops `vault:<name>:admin` for every non-owner BY CONSTRUCTION without
1199
- * hardcoding "drop admin". It reads the held verb set and admits only
1200
- * held verbs, so it's forward-compatible: if a future role ever granted
1201
- * 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.
1202
1204
  * - Applied inside `issueAuthCodeRedirect` (the single choke-point ALL mint
1203
1205
  * paths funnel through: consent-submit, skip-consent, and same-hub
1204
1206
  * auto-trust), the CAPPED set is what gets both recorded (`recordGrant`)
@@ -152,10 +152,13 @@ export const NON_REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
152
152
  * enforced at the shared mint choke-point (`capScopesToUserAuthority` applied
153
153
  * inside `issueAuthCodeRedirect` in `oauth-handlers.ts`): an OAuth flow caps
154
154
  * named vault verbs to those the consenting user actually holds on that vault.
155
- * `vaultVerbsForRole` never returns `admin` for an assigned (read/write) user,
156
- * so admin is dropped for everyone except the hub owner (isFirstAdmin) who
157
- * holds admin everywhere by construction. An admin-only request from a
158
- * 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.
159
162
  *
160
163
  * `vault:<name>:admin` also remains mintable by operator-proving local paths,
161
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
  }