@openparachute/hub 0.5.13 → 0.5.14-rc.10

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 (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -66,6 +66,7 @@ import {
66
66
  } from "./oauth-ui.ts";
67
67
  import { isSameOriginRequest } from "./origin-check.ts";
68
68
  import { isHttpsRequest } from "./request-protocol.ts";
69
+ import { narrowResourceVaultScopes, resolveResourceVault } from "./resource-binding.ts";
69
70
  import { isNonRequestableScope, isRequestableScope, scopeIsAdmin } from "./scope-explanations.ts";
70
71
  import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
71
72
  import {
@@ -83,7 +84,7 @@ import {
83
84
  findSession,
84
85
  parseSessionCookie,
85
86
  } from "./sessions.ts";
86
- import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
87
+ import { getUserById, getUserByUsername, isFirstAdmin, verifyPassword } from "./users.ts";
87
88
  import { listVaultNames } from "./vault-names.ts";
88
89
  import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
89
90
 
@@ -114,30 +115,35 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
114
115
 
115
116
  /**
116
117
  * Derive the `vault_scope` claim value for a given hub user. Multi-user
117
- * Phase 1 (design
118
- * [`2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/),
119
- * §oauth-claim-shape).
118
+ * Phase 2 PR 2 (design 2026-05-20-multi-user-phase-1.md §Phase 2 —
119
+ * many-to-many membership via the `user_vaults` table).
120
120
  *
121
121
  * - `userId` resolves to no row → `[]`. Defensive: the caller already
122
122
  * validated the user existed (auth-code redemption / refresh row's
123
- * user_id), but a delete-between-mint-and-now race shouldn't 500. Empty
124
- * is the safe sentinel — the scope-bearing `scope` claim is still the
125
- * gate.
126
- * - User exists with `assignedVault === null` → `[]`. Admin / unpinned
127
- * posture; the consent picker is the source of truth.
128
- * - User exists with `assignedVault === "name"` → `["name"]`. The Phase 1
129
- * single-vault pin; PR 5's scope-guard at vault/notes/scribe consumes
130
- * this to enforce that an assigned user can't request scope against any
131
- * vault other than their assigned one.
123
+ * user_id), but a delete-between-mint-and-now race shouldn't 500.
124
+ * Empty is the safe sentinel — the scope-bearing `scope` claim is
125
+ * still the gate.
126
+ * - First admin → `[]`. Admin posture is unrestricted by design (see
127
+ * `isFirstAdmin`). The consent picker is the source of truth and
128
+ * the scope-guard reads an empty `vault_scope` claim as "no
129
+ * narrowing" first admin can request scope against any vault.
130
+ * - Non-admin user the list of vault names from `user_vaults`. The
131
+ * scope-guard at vault/notes/scribe enforces that the user can
132
+ * only request scope against vaults in their list (Phase 1 pinned
133
+ * to a single vault; Phase 2 lifts that to N). A non-admin with
134
+ * zero assignments returns `[]` — distinct semantics from the
135
+ * admin's `[]` because the consent picker plus the picked-must-
136
+ * match-assignment defense in `handleConsentSubmit` enforces that
137
+ * non-admin tokens carry a non-empty `vault_scope`.
132
138
  *
133
139
  * Always returns an array (never undefined) so the JWT carries the claim
134
140
  * unconditionally — readers don't have to distinguish "absent" from "empty."
135
141
  */
136
142
  export function vaultScopeForUser(db: Database, userId: string): string[] {
143
+ if (isFirstAdmin(db, userId)) return [];
137
144
  const user = getUserById(db, userId);
138
145
  if (!user) return [];
139
- if (user.assignedVault === null) return [];
140
- return [user.assignedVault];
146
+ return [...user.assignedVaults];
141
147
  }
142
148
 
143
149
  export interface OAuthDeps {
@@ -455,6 +461,9 @@ function parseAuthorizeFormParams(url: URL): AuthorizeFormParams | { error: stri
455
461
  codeChallenge,
456
462
  codeChallengeMethod,
457
463
  state: url.searchParams.get("state"),
464
+ // RFC 8707 resource indicator (optional). When present and resolvable to
465
+ // a per-vault MCP resource, drives the narrow-consent + named-scope path.
466
+ resource: url.searchParams.get("resource"),
458
467
  };
459
468
  }
460
469
 
@@ -839,6 +848,41 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
839
848
  );
840
849
  }
841
850
 
851
+ // RFC 8707 resource binding. When the client named a per-vault MCP
852
+ // resource (`<origin>/vault/<name>/mcp` or its PRM URL), narrow the
853
+ // requested vault verbs to the named `vault:<name>:<verb>` form BEFORE any
854
+ // downstream processing. Two effects:
855
+ //
856
+ // 1. The consent screen shows ONLY that vault's scopes (the picker locks
857
+ // to <name>) instead of the whole-hub catalog — a friend connecting to
858
+ // one vault no longer sees `hub:admin`, `scribe:admin`, or every other
859
+ // vault's verbs.
860
+ // 2. The minted token carries the named scope, so `inferAudience` stamps
861
+ // `aud=vault.<name>` and a current-line vault accepts it (an unnamed
862
+ // `vault:read` token is rejected by `findBroadVaultScopes`).
863
+ //
864
+ // Narrowing happens before the non-requestable gate (below) on purpose: if
865
+ // a resource-bound client somehow asked for `vault:admin`, narrowing makes
866
+ // it `vault:<name>:admin`, which IS non-requestable — so the gate correctly
867
+ // blocks it. Read/write narrow to the requestable named form. Non-vault
868
+ // scopes and already-named scopes for other vaults pass through unchanged.
869
+ //
870
+ // No resource, or a resource that isn't one of our per-vault MCP resources
871
+ // (off-origin, malformed, non-vault path) → `boundVault` is null and the
872
+ // flow is byte-for-byte the pre-#461 behavior (manual picker, etc.).
873
+ const boundVault = resolveResourceVault(parsed.resource, resolveBoundOrigins(deps));
874
+ if (boundVault) {
875
+ const narrowed = narrowResourceVaultScopes(
876
+ parsed.scope.split(" ").filter((s) => s.length > 0),
877
+ boundVault,
878
+ );
879
+ // Rewrite `parsed.scope` so the narrowed named scopes flow through every
880
+ // downstream consumer: the login-redirect query round-trip, the consent
881
+ // props + hidden inputs, the skip-consent grant lookup, and the
882
+ // auth-code mint.
883
+ parsed.scope = narrowed.join(" ");
884
+ }
885
+
842
886
  // Operator-only scope gate (#96). Reject any request that names a scope
843
887
  // we'll never mint via this flow — `parachute:host:admin` and friends.
844
888
  // Per RFC 6749 §4.1.2.1, errors that aren't redirect-uri-related are
@@ -862,12 +906,20 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
862
906
  return htmlResponse(renderLogin({ params: parsed, csrfToken: csrf.token }), 200, extra);
863
907
  }
864
908
 
865
- // Multi-user Phase 1: non-admin users (with `assigned_vault !== null`) see
866
- // the picker locked to their assigned vault — they can't pick a different
867
- // one. Admin users (assigned_vault === null) see the full dropdown.
868
- // Defensive null-coalesce: the session points at a deleted user shouldn't
869
- // 500; treat as admin posture (the broader scope-validation gate will
870
- // catch any actual privilege issue).
909
+ // Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
910
+ // their assigned vault list — they can't pick a vault they don't own.
911
+ // First admin (admin posture) sees the full dropdown of every vault on
912
+ // the hub.
913
+ //
914
+ // Two shapes for non-admin users emerge:
915
+ // - exactly one assigned vault → picker renders locked to that name
916
+ // (same shape as Phase 1; smallest diff for the common case).
917
+ // - two or more assigned vaults → picker renders a free dropdown
918
+ // filtered to those names — user picks one per consent.
919
+ //
920
+ // Defensive null-coalesce: the session points at a deleted user
921
+ // shouldn't 500; treat as admin posture (the broader scope-validation
922
+ // gate will catch any actual privilege issue).
871
923
  //
872
924
  // Resolved here (before the fast-paths) because the stale-assignment
873
925
  // predicate below — which gates both skip-consent (#75) and same-hub
@@ -876,24 +928,28 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
876
928
  // closing the silent-mint-on-stale-vault gap; the read is one JSON parse
877
929
  // off-disk per /authorize.
878
930
  const user = getUserById(db, session.userId);
879
- const lockedVault = user?.assignedVault ?? null;
931
+ const userIsAdmin = isFirstAdmin(db, session.userId);
932
+ // Non-admin user's assigned vaults; admin posture (or no row) → empty.
933
+ const assignedVaults: string[] = userIsAdmin ? [] : (user?.assignedVaults ?? []);
880
934
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
881
935
  const vaultNames = listVaultNames(manifest);
882
936
 
883
- // Stale-assignment predicate (hub#284 reviewer fold). The user has an
884
- // `assigned_vault` pinned on their row but the named vault is no longer
885
- // in services.json. Both fast-paths below (#75 skip-consent, hub#312
886
- // same-hub auto-trust) would otherwise silently mint a token for a vault
887
- // that doesn't exist the user thinks consent succeeded but the
888
- // downstream API calls fail with no actionable signal. Fall through to
889
- // the consent render in either case so the banner UX (and the disabled
890
- // Approve when the requested scope depends on a vault) surfaces the
891
- // condition + admin-remediation path. Admin users (`lockedVault === null`)
892
- // are never stale.
893
- const hasStaleAssignment = lockedVault !== null && !vaultNames.includes(lockedVault);
937
+ // Stale-assignment predicate (hub#284, generalized in Phase 2 PR 2 from
938
+ // single-vault to N-vault). For a non-admin user, "stale" means at
939
+ // least one of their assigned vaults no longer exists in services.json
940
+ // AND no vault in their list still exists i.e. they have *zero*
941
+ // valid vaults to consent against. The banner surfaces this state with
942
+ // an admin-remediation hint instead of silently minting a token
943
+ // against a missing vault. If at least one of their vaults still
944
+ // exists, the consent flow proceeds normally the missing ones drop
945
+ // out of the picker without ceremony.
946
+ //
947
+ // Admin users are never stale (they aren't pinned to any vault list).
948
+ const remainingValidVaults = assignedVaults.filter((v) => vaultNames.includes(v));
949
+ const hasStaleAssignment = assignedVaults.length > 0 && remainingValidVaults.length === 0;
894
950
 
895
951
  // Skip-consent gate (#75). If the user has previously granted every
896
- // requested scope to this client, mint the auth code immediately. Two
952
+ // requested scope to this client, mint the auth code immediately. Three
897
953
  // important constraints:
898
954
  // - Unnamed vault verbs (`vault:read`) need the picker even if a prior
899
955
  // grant exists, because the operator's vault choice isn't recorded
@@ -905,10 +961,25 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
905
961
  // - Stale-assignment (above) also forces the consent render so the
906
962
  // banner explains the broken state rather than silently minting a
907
963
  // token against the missing vault.
964
+ // - The user is admin OR has at least one assigned vault (hub#429
965
+ // reviewer fold, follow-up). A zero-vault non-admin whose prior
966
+ // grants survived a `setUserVaults(_, [])` admin action would
967
+ // otherwise silently re-mint a token against the now-revoked
968
+ // vault assignment — the grants table has no FK cascade from
969
+ // `user_vaults`, so deleting assignments doesn't revoke grants.
970
+ // Same privesc shape as the same-hub auto-trust gate below;
971
+ // identical guard (`userHasVaultPosture`). Force fall-through to
972
+ // the consent render where the zero-vault gate in
973
+ // `handleConsentSubmit` also refuses (defense in depth). This
974
+ // also transitively defends the trust-by-client_name auto-
975
+ // promote path (~line 554) which recursively re-enters
976
+ // `handleAuthorizeGet` after promoting the pending client.
908
977
  const hasUnnamedVault = unnamedVaultVerbs(requestedScopes).length > 0;
978
+ const userHasVaultPosture = userIsAdmin || assignedVaults.length > 0;
909
979
  if (
910
980
  !hasStaleAssignment &&
911
981
  !hasUnnamedVault &&
982
+ userHasVaultPosture &&
912
983
  isCoveredByGrant(db, session.userId, client.clientId, requestedScopes)
913
984
  ) {
914
985
  console.log(
@@ -931,16 +1002,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
931
1002
  // still want explicit consent as a sanity gate.
932
1003
  // 3. No unnamed vault verbs are requested — those need the picker to
933
1004
  // narrow `vault:<verb>` → `vault:<name>:<verb>` before mint.
934
- // 4. The user's assigned_vault is not stale (hub#284 reviewer fold) —
935
- // otherwise the same-hub gate would silently mint a token for a
936
- // removed vault before the consent-render path's stale detection
937
- // ever runs.
1005
+ // 4. The user's assigned_vaults list is not stale (hub#284 reviewer
1006
+ // fold) — otherwise the same-hub gate would silently mint a token
1007
+ // for a removed vault before the consent-render path's stale
1008
+ // detection ever runs.
1009
+ // 5. The user is admin OR has at least one assigned vault (Phase 2
1010
+ // PR 2 reviewer fold). A zero-vault non-admin has
1011
+ // `hasStaleAssignment=false` (length===0 short-circuits the stale
1012
+ // predicate above) and would otherwise sail through the auto-
1013
+ // trust gate. The resulting `vault_scope: []` claim is the admin
1014
+ // "unrestricted" sentinel — minting it for a non-admin grants
1015
+ // hub-wide vault access. Force fall-through to the consent
1016
+ // render where the zero-vault gate in `handleConsentSubmit` also
1017
+ // refuses (defense in depth).
938
1018
  //
939
1019
  // The grant is also recorded so subsequent flows with the same scopes
940
1020
  // hit the standard skip-consent gate above. Logged so an operator
941
1021
  // auditing "who did this" can trace it back to a same-hub DCR.
942
1022
  const hasAdminScope = requestedScopes.some(scopeIsAdmin);
943
- if (client.sameHub && !hasAdminScope && !hasUnnamedVault && !hasStaleAssignment) {
1023
+ if (
1024
+ client.sameHub &&
1025
+ !hasAdminScope &&
1026
+ !hasUnnamedVault &&
1027
+ !hasStaleAssignment &&
1028
+ userHasVaultPosture
1029
+ ) {
944
1030
  console.log(
945
1031
  `[oauth] auto-approved same-hub client client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#312)`,
946
1032
  );
@@ -952,7 +1038,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
952
1038
  }
953
1039
 
954
1040
  return htmlResponse(
955
- renderConsent(consentProps(client, parsed, vaultNames, csrf.token, lockedVault)),
1041
+ renderConsent(
1042
+ consentProps(client, parsed, vaultNames, csrf.token, assignedVaults, userIsAdmin),
1043
+ ),
956
1044
  200,
957
1045
  extra,
958
1046
  );
@@ -1072,6 +1160,21 @@ async function handleConsentSubmit(
1072
1160
  csrfToken: string,
1073
1161
  ): Promise<Response> {
1074
1162
  const params = paramsFromForm(form);
1163
+ // RFC 8707 resource binding — defense-in-depth (mirror of the GET handler).
1164
+ // The consent form's hidden inputs already carry the narrowed named scopes
1165
+ // (the GET handler rewrote `parsed.scope` before rendering), but a hand-
1166
+ // crafted POST could re-supply an unnamed `vault:read` alongside the
1167
+ // `resource` field. Re-narrow here so the minted token is always named +
1168
+ // correctly-audienced regardless of what the form body claims. Same
1169
+ // semantics as the GET path: only when `resource` resolves to one of our
1170
+ // per-vault MCP resources; no-op otherwise (manual-pick path unchanged).
1171
+ const boundVault = resolveResourceVault(params.resource, resolveBoundOrigins(deps));
1172
+ if (boundVault) {
1173
+ params.scope = narrowResourceVaultScopes(
1174
+ params.scope.split(" ").filter((s) => s.length > 0),
1175
+ boundVault,
1176
+ ).join(" ");
1177
+ }
1075
1178
  const approve = String(form.get("approve") ?? "") === "yes";
1076
1179
  const sessionId = parseSessionCookie(req.headers.get("cookie"));
1077
1180
  const session = sessionId ? findSession(db, sessionId) : null;
@@ -1140,18 +1243,51 @@ async function handleConsentSubmit(
1140
1243
  params.state,
1141
1244
  );
1142
1245
  }
1143
- // Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md): non-admin
1144
- // users (`assigned_vault !== null`) are pinned to a single vault. The
1145
- // consent screen renders the picker locked to that vault and any named
1146
- // scopes (`vault:<name>:<verb>`) requested by the client must match. The
1147
- // server-side defense here refuses any mint where the user's submission
1148
- // disagrees Aaron pinned this as "server-side defense refuses mints
1149
- // whose picked vault disagrees with assigned_vault" rather than silent
1150
- // overwrite, so a hand-crafted POST or a misbehaving SPA can't bypass the
1151
- // lock. Admin users (`assigned_vault === null`) keep the existing picker-
1152
- // as-source-of-truth behavior.
1246
+ // Multi-user Phase 2 PR 2: non-admin users are pinned to a list of one
1247
+ // or more vaults via `user_vaults`. The consent screen renders the
1248
+ // picker narrowed to that list and any named scopes (`vault:<name>:
1249
+ // <verb>`) requested by the client must target a vault in the list.
1250
+ // The server-side defense here refuses any mint where the user's
1251
+ // submission disagrees, so a hand-crafted POST or a misbehaving SPA
1252
+ // can't bypass the narrowing. First admin (admin posture) keeps the
1253
+ // existing picker-as-source-of-truth behavior (empty `assignedVaults`).
1254
+ const userIsAdmin = isFirstAdmin(db, session.userId);
1153
1255
  const sessionUser = getUserById(db, session.userId);
1154
- const assignedVault = sessionUser?.assignedVault ?? null;
1256
+ const assignedVaults: string[] = userIsAdmin ? [] : (sessionUser?.assignedVaults ?? []);
1257
+ const isPinned = assignedVaults.length > 0;
1258
+ // By design: the resource-bound re-narrow above does NOT check the bound
1259
+ // vault exists in services.json for the admin path — admin (isPinned=false)
1260
+ // can already consent to any vault via the manual picker, so the asymmetry
1261
+ // (named-scope mint against a possibly-missing vault) is deliberate, not an
1262
+ // oversight. Non-admins still hit the assignment + stale-vault defenses below.
1263
+
1264
+ // Zero-vault non-admin gate (Phase 2 PR 2 reviewer fold). A non-admin
1265
+ // user with no `user_vaults` rows is a known-but-not-yet-assigned
1266
+ // posture — they can sign in to /account/, change their password, and
1267
+ // see the home page, but they have no vaults to authorize against.
1268
+ // Block any vault-scoped consent at the submit boundary so an OAuth
1269
+ // client can't trick them into minting a token: an empty `vault_scope`
1270
+ // claim is the admin "unrestricted" sentinel (see `vaultScopeForUser`),
1271
+ // and we must keep that sentinel reserved for true admins. Non-vault
1272
+ // scopes (`scribe:transcribe`, etc.) still consent normally — only
1273
+ // `vault:...` scopes are gated here. The defense pairs with the GET-
1274
+ // path same-hub-auto-trust gate below (which falls through to the
1275
+ // consent render that would otherwise show the picker).
1276
+ if (!userIsAdmin && assignedVaults.length === 0) {
1277
+ const submittedScopes = params.scope.split(" ").filter((s) => s.length > 0);
1278
+ const hasVaultScope = submittedScopes.some((s) => {
1279
+ if (s === "vault:read" || s === "vault:write" || s === "vault:admin") return true;
1280
+ const parts = s.split(":");
1281
+ return parts.length === 3 && parts[0] === "vault" && parts[2] && VAULT_VERBS.has(parts[2]);
1282
+ });
1283
+ if (hasVaultScope) {
1284
+ return htmlError(
1285
+ "No vaults assigned",
1286
+ "vault_scope_mismatch: you have no assigned vaults on this hub yet, so you can't authorize an app for vault access. Ask the hub admin to assign you at least one vault via /admin/users, then try again.",
1287
+ 400,
1288
+ );
1289
+ }
1290
+ }
1155
1291
 
1156
1292
  // Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
1157
1293
  // `vault:<verb>` scope is ambiguous about which vault it grants access to.
@@ -1171,24 +1307,17 @@ async function handleConsentSubmit(
1171
1307
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
1172
1308
  const validNames = listVaultNames(manifest);
1173
1309
  if (!validNames.includes(pickedVault)) {
1174
- // Stale-assignment branch (hub#284). Surfaced during hub#283
1175
- // reviewer pass the pre-rc.11 path 400'd with the generic
1176
- // "Unknown vault" copy when a user whose assigned_vault was
1177
- // removed by the admin landed back on consent and submitted the
1178
- // stale name. The locked picker rendered the missing name, the
1179
- // form posted it, the user saw "vault X is not registered" and
1180
- // had no path forward. The new copy names the actual condition
1181
- // (assignment removed) and points at the admin remediation
1182
- // surface (/admin/users) instead of leaving the user wondering
1183
- // whether hub itself is broken.
1310
+ // Stale-assignment branch (hub#284, generalized Phase 2 PR 2). The
1311
+ // user is consenting via an assignment that points at a vault no
1312
+ // longer in services.json. The new copy names the actual condition
1313
+ // (assignment removed) and points at the admin remediation surface.
1184
1314
  //
1185
- // The check fires only when the user's `assigned_vault` claim
1186
- // matches the picked vault — narrows the special-case to the
1187
- // "user is consenting via their locked assignment that's now
1188
- // stale" shape rather than swallowing every Unknown-vault. A
1189
- // hand-crafted POST naming a never-existed vault still hits the
1190
- // generic branch.
1191
- if (assignedVault !== null && pickedVault === assignedVault) {
1315
+ // The check fires when the picked vault is in the user's assigned
1316
+ // list — narrows the special-case to the "user is consenting via
1317
+ // a now-stale assignment" shape rather than swallowing every
1318
+ // Unknown-vault. A hand-crafted POST naming a never-existed vault
1319
+ // still hits the generic branch.
1320
+ if (isPinned && assignedVaults.includes(pickedVault)) {
1192
1321
  return htmlError(
1193
1322
  "Assigned vault was removed",
1194
1323
  `Your assigned vault "${pickedVault}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
@@ -1201,16 +1330,16 @@ async function handleConsentSubmit(
1201
1330
  400,
1202
1331
  );
1203
1332
  }
1204
- // Server-side defense: non-admin user submitted a vault that disagrees
1205
- // with their `assigned_vault`. The picker rendered as locked, so a UI-
1206
- // path user couldn't reach this — but a hand-crafted form bypassing the
1207
- // locked input lands here. Refuse the mint instead of silently
1208
- // overwriting; the explicit error tells the operator the assignment is
1209
- // load-bearing.
1210
- if (assignedVault !== null && pickedVault !== assignedVault) {
1333
+ // Server-side defense: non-admin user submitted a vault that's not in
1334
+ // their assigned list. The picker rendered as narrowed, so a UI-path
1335
+ // user couldn't reach this — but a hand-crafted form bypassing the
1336
+ // narrowed input lands here. Refuse the mint instead of silently
1337
+ // overwriting; the explicit error tells the operator the assignment
1338
+ // is load-bearing.
1339
+ if (isPinned && !assignedVaults.includes(pickedVault)) {
1211
1340
  return htmlError(
1212
1341
  "Vault assignment mismatch",
1213
- `vault_scope_mismatch: the picked vault "${pickedVault}" does not match your vault assignment. Ask the hub admin to update your assignment, or pick the vault shown on the consent screen.`,
1342
+ `vault_scope_mismatch: the picked vault "${pickedVault}" is not in your vault assignment. Ask the hub admin to update your assignment, or pick a vault shown on the consent screen.`,
1214
1343
  400,
1215
1344
  );
1216
1345
  }
@@ -1218,12 +1347,12 @@ async function handleConsentSubmit(
1218
1347
  }
1219
1348
 
1220
1349
  // Server-side defense for named-vault scopes (`vault:<name>:<verb>`) too.
1221
- // A non-admin user can't request scope against any vault other than their
1222
- // assigned one — same invariant as the picker check above, applied to
1223
- // scopes that arrived already-named (e.g. a client that knows the user's
1224
- // vault and asked for `vault:bob:read` directly). Admins (assigned_vault
1225
- // null) skip this check.
1226
- if (assignedVault !== null) {
1350
+ // A non-admin user can't request scope against any vault outside their
1351
+ // assignment list — same invariant as the picker check above, applied
1352
+ // to scopes that arrived already-named (e.g. a client that knows the
1353
+ // user's vault and asked for `vault:bob:read` directly). Admin posture
1354
+ // (`isPinned === false`) skips this check.
1355
+ if (isPinned) {
1227
1356
  const mismatched: string[] = [];
1228
1357
  for (const s of scopes) {
1229
1358
  const parts = s.split(":");
@@ -1233,7 +1362,7 @@ async function handleConsentSubmit(
1233
1362
  parts[1] &&
1234
1363
  parts[2] &&
1235
1364
  VAULT_VERBS.has(parts[2]) &&
1236
- parts[1] !== assignedVault
1365
+ !assignedVaults.includes(parts[1])
1237
1366
  ) {
1238
1367
  mismatched.push(s);
1239
1368
  }
@@ -1241,29 +1370,29 @@ async function handleConsentSubmit(
1241
1370
  if (mismatched.length > 0) {
1242
1371
  return htmlError(
1243
1372
  "Vault assignment mismatch",
1244
- `vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault other than your vault assignment.`,
1373
+ `vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault outside your assignment.`,
1245
1374
  400,
1246
1375
  );
1247
1376
  }
1248
1377
 
1249
- // Stale-assignment defense (hub#284) for named-vault scopes. A scope
1250
- // shaped `vault:<assigned>:<verb>` passes the mismatch check above but
1251
- // points at a vault that no longer exists in services.json. Minting a
1252
- // token here would silently issue scope against a vault the resource
1253
- // server can't find — the user thinks consent succeeded but the
1254
- // subsequent API calls fail with no actionable signal. Refuse the mint
1255
- // and surface the same admin-remediation hint the GET path's banner
1256
- // uses, so the picker-bypass POST and the natural form-render arrive at
1257
- // the same recovery story.
1378
+ // Stale-assignment defense (hub#284, generalized Phase 2 PR 2). A
1379
+ // named scope shaped `vault:<assigned>:<verb>` passes the mismatch
1380
+ // check above but points at a vault that no longer exists in
1381
+ // services.json. Minting a token here would silently issue scope
1382
+ // against a vault the resource server can't find — the user thinks
1383
+ // consent succeeded but the subsequent API calls fail with no
1384
+ // actionable signal. Refuse the mint and surface the same admin-
1385
+ // remediation hint the GET path's banner uses.
1258
1386
  const namedStaleScopes: string[] = [];
1259
1387
  for (const s of scopes) {
1260
1388
  const parts = s.split(":");
1261
1389
  if (
1262
1390
  parts.length === 3 &&
1263
1391
  parts[0] === "vault" &&
1264
- parts[1] === assignedVault &&
1392
+ parts[1] !== undefined &&
1265
1393
  parts[2] &&
1266
- VAULT_VERBS.has(parts[2])
1394
+ VAULT_VERBS.has(parts[2]) &&
1395
+ assignedVaults.includes(parts[1])
1267
1396
  ) {
1268
1397
  namedStaleScopes.push(s);
1269
1398
  }
@@ -1273,10 +1402,19 @@ async function handleConsentSubmit(
1273
1402
  // the no-vault-scope hot path off-disk for the common admin flows.
1274
1403
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
1275
1404
  const validNames = listVaultNames(manifest);
1276
- if (!validNames.includes(assignedVault)) {
1405
+ // Collect the stale vault names embedded in the named scopes.
1406
+ const staleNames = new Set<string>();
1407
+ for (const s of namedStaleScopes) {
1408
+ const parts = s.split(":");
1409
+ if (parts[1] !== undefined && !validNames.includes(parts[1])) {
1410
+ staleNames.add(parts[1]);
1411
+ }
1412
+ }
1413
+ if (staleNames.size > 0) {
1414
+ const exemplar = [...staleNames][0];
1277
1415
  return htmlError(
1278
1416
  "Assigned vault was removed",
1279
- `Your assigned vault "${assignedVault}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
1417
+ `Your assigned vault "${exemplar}" is no longer registered on this hub. Ask the hub admin to reassign you to an existing vault via /admin/users, then try again.`,
1280
1418
  400,
1281
1419
  );
1282
1420
  }
@@ -1421,10 +1559,7 @@ export async function handleApproveClientPost(
1421
1559
  );
1422
1560
  }
1423
1561
  target.searchParams.set("error", "access_denied");
1424
- target.searchParams.set(
1425
- "error_description",
1426
- "The user denied the authorization request.",
1427
- );
1562
+ target.searchParams.set("error_description", "The user denied the authorization request.");
1428
1563
  if (denyState !== undefined) target.searchParams.set("state", denyState);
1429
1564
  return redirectResponse(target.toString());
1430
1565
  }
@@ -1478,6 +1613,7 @@ function paramsFromForm(form: Awaited<ReturnType<Request["formData"]>>): Authori
1478
1613
  codeChallenge: String(form.get("code_challenge") ?? ""),
1479
1614
  codeChallengeMethod: String(form.get("code_challenge_method") ?? "S256"),
1480
1615
  state: (form.get("state") as string | null) ?? null,
1616
+ resource: (form.get("resource") as string | null) ?? null,
1481
1617
  };
1482
1618
  }
1483
1619
 
@@ -1491,6 +1627,10 @@ function authorizeParamsToQuery(p: AuthorizeFormParams): Record<string, string>
1491
1627
  code_challenge_method: p.codeChallengeMethod,
1492
1628
  };
1493
1629
  if (p.state) q.state = p.state;
1630
+ // Round-trip the RFC 8707 resource indicator through the login redirect so
1631
+ // the resource-bound narrowing survives a sign-in (it re-enters GET
1632
+ // /oauth/authorize with the original params).
1633
+ if (p.resource) q.resource = p.resource;
1494
1634
  return q;
1495
1635
  }
1496
1636
 
@@ -1660,11 +1800,17 @@ async function handleTokenAuthorizationCode(
1660
1800
  audience,
1661
1801
  clientId: redeemed.clientId,
1662
1802
  issuer: deps.issuer,
1663
- // vault_scope claim — Phase 1 per-user vault pin. Non-empty list for
1664
- // non-admin users with `assigned_vault` set; empty for admin / unpinned.
1665
- // The narrowing in `handleConsentSubmit` already rewrote `vault:<verb>`
1666
- // `vault:<assigned_vault>:<verb>` for non-admin users, so the auth code's
1667
- // scopes are pre-aligned; this claim is the explicit "owned vault"
1803
+ // vault_scope claim — Phase 2 per-user vault pin (Phase 1 had a single
1804
+ // `assigned_vault` column; Phase 2 PR 2 generalized to `assigned_vaults`
1805
+ // via `user_vaults`). Non-empty list for non-admin users with at least
1806
+ // one assigned vault; empty for first-admin (unrestricted sentinel).
1807
+ // Zero-vault non-admin is also empty by `vaultScopeForUser`, but the
1808
+ // OAuth flow refuses to mint a vault-scoped token for them upstream
1809
+ // (see the zero-vault gate in `handleConsentSubmit` + the same-hub
1810
+ // auto-trust posture check), so we never reach here with that user
1811
+ // posture. The narrowing in `handleConsentSubmit` already rewrote
1812
+ // `vault:<verb>` → `vault:<assigned>:<verb>`, so the auth code's
1813
+ // scopes are pre-aligned; this claim is the explicit "owned vaults"
1668
1814
  // signal PR 5 consumes downstream.
1669
1815
  vaultScope: vaultScopeForUser(db, redeemed.userId),
1670
1816
  now: deps.now,
@@ -1781,12 +1927,12 @@ async function handleTokenRefresh(
1781
1927
  clientId: row.clientId,
1782
1928
  issuer: deps.issuer,
1783
1929
  // vault_scope claim — re-derived from the user's *current*
1784
- // `assigned_vault` at refresh time (not snapshotted onto the refresh-
1785
- // token row). An admin who changes a user's `assigned_vault` between
1930
+ // `assigned_vaults` at refresh time (not snapshotted onto the refresh-
1931
+ // token row). An admin who changes a user's vault assignments between
1786
1932
  // mint and refresh sees the new value on the next refresh; existing
1787
1933
  // access tokens carry their original claim until their 15-minute TTL
1788
1934
  // elapses. Same posture as the design's "OAuth issuer reads
1789
- // `assigned_vault` at mint time, not at session-creation time" pin.
1935
+ // `assigned_vaults` at mint time, not at session-creation time" pin.
1790
1936
  vaultScope: vaultScopeForUser(db, refreshUserId),
1791
1937
  now: deps.now,
1792
1938
  });
@@ -2156,95 +2302,117 @@ function consentProps(
2156
2302
  params: AuthorizeFormParams,
2157
2303
  vaultNames: string[],
2158
2304
  csrfToken: string,
2159
- lockedVault: string | null,
2305
+ assignedVaults: readonly string[],
2306
+ userIsAdmin: boolean,
2160
2307
  ) {
2161
2308
  const scopes = params.scope.split(" ").filter((s) => s.length > 0);
2162
2309
  const unnamedVerbs = unnamedVaultVerbs(scopes);
2163
- // Multi-user Phase 1, stale-assignment branch (hub#284). The user has an
2164
- // `assigned_vault` pinned on their row but the named vault is no longer
2165
- // in services.json — admin removed / renamed it without reassigning the
2166
- // user. Pre-hub#284 this fell through to the locked picker, posted the
2167
- // stale name, and `handleConsentSubmit` rejected it with a generic
2168
- // "Unknown vault" 400. The user couldn't tell what happened or how to
2169
- // recover.
2310
+ // Multi-user Phase 2 PR 2 stale-assignment branch (hub#284 generalized
2311
+ // from one vault to N). A non-admin user whose entire vault list has
2312
+ // been removed from services.json — admin removed / renamed the vaults
2313
+ // without reassigning. The banner surfaces this state with an admin-
2314
+ // remediation hint instead of silently minting against a missing vault.
2170
2315
  //
2171
- // Detect it here and surface the banner on the consent screen explaining
2172
- // the state and pointing at admin remediation. The banner is informational
2173
- // for non-vault-scoped flows (the user can still consent to e.g.
2174
- // `scribe:transcribe` without a working vault assignment), but the picker
2175
- // section + Approve button are gated when the requested scope actually
2176
- // depends on a vault — see the `vaultPicker` + `approveDisabled` logic
2177
- // below.
2316
+ // The user-facing surface is "your assigned vault(s) were removed"
2317
+ // the banner names the first stale vault as the canonical example. The
2318
+ // exact list is unimportant for the recovery path (operator goes to
2319
+ // /admin/users either way), and pluralizing the banner copy doesn't
2320
+ // change the action.
2178
2321
  //
2179
- // Security posture: we deliberately do NOT relax the picked-must-match-
2180
- // assigned check or let the user pick any other vault. Doing so would
2181
- // let assignment-binding be circumvented by getting an admin to remove
2182
- // the assigned vault. Stale-assignment is an admin-remediated state.
2322
+ // Security posture: we deliberately do NOT relax the picked-must-be-in-
2323
+ // assigned-list check. Stale-assignment is admin-remediated.
2324
+ const remainingValidAssigned = assignedVaults.filter((v) => vaultNames.includes(v));
2325
+ const hasStaleAssignment = assignedVaults.length > 0 && remainingValidAssigned.length === 0;
2183
2326
  const staleAssignedVault =
2184
- lockedVault !== null && !vaultNames.includes(lockedVault) ? lockedVault : undefined;
2185
- // A named scope like `vault:<old>:read` requested by the client where
2186
- // <old> is the user's stale assigned_vault. The server-side named-scope
2187
- // defense (`handleConsentSubmit`) allows this through because the scope
2188
- // matches `assignedVault`, but the token it mints would point at a vault
2189
- // that doesn't exist. Gate Approve on this case too so the user doesn't
2190
- // burn a consent into a token that fails at the resource server.
2327
+ hasStaleAssignment && assignedVaults[0] !== undefined ? assignedVaults[0] : undefined;
2328
+ // A named scope like `vault:<old>:<verb>` requested by the client where
2329
+ // <old> is one of the user's stale vaults. The server-side named-scope
2330
+ // defense allows this through because the scope matches an assigned
2331
+ // vault, but the token it mints would point at a vault that no longer
2332
+ // exists. Gate Approve on this case too so the user doesn't burn a
2333
+ // consent into a token that fails at the resource server.
2191
2334
  const hasNamedStaleVaultScope =
2192
- staleAssignedVault !== undefined &&
2335
+ hasStaleAssignment &&
2193
2336
  scopes.some((s) => {
2194
2337
  const parts = s.split(":");
2195
- return (
2196
- parts.length === 3 &&
2197
- parts[0] === "vault" &&
2198
- parts[1] === staleAssignedVault &&
2199
- parts[2] !== undefined &&
2200
- VAULT_VERBS.has(parts[2])
2201
- );
2338
+ if (
2339
+ parts.length !== 3 ||
2340
+ parts[0] !== "vault" ||
2341
+ parts[1] === undefined ||
2342
+ parts[2] === undefined ||
2343
+ !VAULT_VERBS.has(parts[2])
2344
+ ) {
2345
+ return false;
2346
+ }
2347
+ // Named for one of the user's vaults — and given hasStaleAssignment,
2348
+ // none of the user's vaults exist on this hub, so this scope points
2349
+ // at a stale name.
2350
+ return assignedVaults.includes(parts[1]);
2202
2351
  });
2203
2352
 
2204
- // Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md, decision-pin
2205
- // "consent picker for non-admin users"): non-admin users (assigned_vault
2206
- // non-null) see the picker locked to their `assigned_vault` rather than a
2207
- // free dropdown. Phase 2 will hide other vaults entirely; Phase 1 ships
2208
- // lock-the-picker (the smallest diff that satisfies "user can't pick a
2209
- // vault they don't own"). Server-side defense in `handleConsentSubmit`
2210
- // refuses mints whose POST disagrees regardless of how the picker is
2211
- // rendered.
2353
+ // Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
2354
+ // their assigned vault list. Four shapes emerge:
2212
2355
  //
2213
- // Stale-assignment shape (hub#284): when the user's assigned_vault no
2214
- // longer exists, the picker renders as no-vaults-available rather than
2215
- // locking onto a missing name the banner above the scope list explains
2216
- // why and the disabled Approve prevents a form submit that would fail
2217
- // server-side anyway.
2356
+ // - Single assigned vault (still valid) render locked to that name
2357
+ // (same shape as Phase 1).
2358
+ // - Two-or-more assigned vaults render a dropdown filtered to the
2359
+ // user's list. Same control as the admin dropdown but narrowed.
2360
+ // - Stale-assigned (all vaults gone) → render no-vaults-available so
2361
+ // the form gracefully rejects an Approve click instead of silently
2362
+ // submitting a missing name.
2363
+ // - First admin (admin posture, empty `assignedVaults`) → full hub-
2364
+ // wide dropdown of every vault on the hub.
2365
+ // - Zero-vault non-admin (Phase 2 PR 2 reviewer fold) → no-vaults-
2366
+ // available so the form gracefully rejects an Approve click. The
2367
+ // prior shape rendered the full hub-wide list for non-admins with
2368
+ // zero assignments, which let them pick a vault they had no
2369
+ // business consenting to. The consent-submit gate refuses any
2370
+ // vault-scoped POST from a zero-vault non-admin (defense in depth);
2371
+ // this branch keeps the picker UI honest.
2218
2372
  let vaultPicker: VaultPickerProps | undefined;
2219
2373
  if (unnamedVerbs.length > 0) {
2220
- if (staleAssignedVault !== undefined) {
2374
+ if (hasStaleAssignment) {
2221
2375
  vaultPicker = { unnamedVerbs, availableVaults: [] };
2222
- } else if (lockedVault !== null) {
2223
- vaultPicker = { unnamedVerbs, availableVaults: vaultNames, lockedVault };
2224
- } else {
2376
+ } else if (remainingValidAssigned.length === 1) {
2377
+ const only = remainingValidAssigned[0];
2378
+ if (only !== undefined) {
2379
+ vaultPicker = { unnamedVerbs, availableVaults: [only], lockedVault: only };
2380
+ }
2381
+ } else if (remainingValidAssigned.length > 1) {
2382
+ vaultPicker = { unnamedVerbs, availableVaults: remainingValidAssigned };
2383
+ } else if (userIsAdmin) {
2384
+ // Admin posture (no assignments) → full hub-wide list.
2225
2385
  vaultPicker = { unnamedVerbs, availableVaults: vaultNames };
2386
+ } else {
2387
+ // Zero-vault non-admin → no-vaults-available picker with the
2388
+ // "ask your admin to assign you" copy. The Approve button renders
2389
+ // disabled (same shape as the empty-services-json case) so the
2390
+ // form can't post a hand-picked name. The consent-submit gate
2391
+ // refuses any vault-scoped POST from this user too (defense in
2392
+ // depth — see `handleConsentSubmit`).
2393
+ vaultPicker = { unnamedVerbs, availableVaults: [], emptyReason: "no-assignments" };
2226
2394
  }
2227
2395
  }
2228
2396
  // Named-scope display: substitute unnamed `vault:<verb>` rows with the
2229
2397
  // resolved form the operator will actually consent to.
2230
- // - Non-admin (lockedVault set, NOT stale) → render `vault:<lockedVault>:<verb>`.
2231
- // - Stale-assigned (hub#284) → null; the scope row carries the
2232
- // `<TBD>` placeholder. The banner explains why a name isn't bound.
2233
- // - Admin with exactly one vault available render that vault's name;
2234
- // the picker pre-checks it and a default-Approve mints scope against
2235
- // it, so the displayed scope matches the next state of the form.
2236
- // - Admin with multiple vaults / no vaults → null sentinel; render with
2237
- // a `<TBD>` placeholder + a hint pointing at the picker. Once the
2238
- // operator clicks a radio the JS-free form still posts the chosen
2239
- // name; the displayed scope only changes after the next page render.
2398
+ // - Non-admin with exactly one valid assigned vault → render that name.
2399
+ // - Stale-assigned → null; the row carries the `<TBD>` placeholder.
2400
+ // The banner explains why a name isn't bound.
2401
+ // - Non-admin with multiple assigned vaultsnull sentinel (the user
2402
+ // hasn't picked yet).
2403
+ // - Admin with exactly one vault available render that name.
2404
+ // - Admin with multiple / no vaults → null sentinel.
2240
2405
  let displayVault: string | null = null;
2241
- if (staleAssignedVault === undefined && lockedVault !== null) {
2242
- displayVault = lockedVault;
2406
+ if (!hasStaleAssignment && remainingValidAssigned.length === 1) {
2407
+ const only = remainingValidAssigned[0];
2408
+ if (only !== undefined) displayVault = only;
2243
2409
  } else if (
2244
- staleAssignedVault === undefined &&
2410
+ !hasStaleAssignment &&
2411
+ assignedVaults.length === 0 &&
2245
2412
  unnamedVerbs.length > 0 &&
2246
2413
  vaultNames.length === 1
2247
2414
  ) {
2415
+ // Admin with a single vault on the hub: pre-check pattern from Phase 1.
2248
2416
  const only = vaultNames[0];
2249
2417
  if (only) displayVault = only;
2250
2418
  }
@@ -2271,4 +2439,5 @@ interface VaultPickerProps {
2271
2439
  unnamedVerbs: string[];
2272
2440
  availableVaults: string[];
2273
2441
  lockedVault?: string;
2442
+ emptyReason?: "no-assignments" | "no-vaults-on-hub";
2274
2443
  }