@openparachute/hub 0.5.13 → 0.5.14-rc.2

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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -83,7 +83,7 @@ import {
83
83
  findSession,
84
84
  parseSessionCookie,
85
85
  } from "./sessions.ts";
86
- import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
86
+ import { getUserById, getUserByUsername, isFirstAdmin, verifyPassword } from "./users.ts";
87
87
  import { listVaultNames } from "./vault-names.ts";
88
88
  import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
89
89
 
@@ -114,30 +114,35 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
114
114
 
115
115
  /**
116
116
  * 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).
117
+ * Phase 2 PR 2 (design 2026-05-20-multi-user-phase-1.md §Phase 2 —
118
+ * many-to-many membership via the `user_vaults` table).
120
119
  *
121
120
  * - `userId` resolves to no row → `[]`. Defensive: the caller already
122
121
  * 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.
122
+ * user_id), but a delete-between-mint-and-now race shouldn't 500.
123
+ * Empty is the safe sentinel — the scope-bearing `scope` claim is
124
+ * still the gate.
125
+ * - First admin → `[]`. Admin posture is unrestricted by design (see
126
+ * `isFirstAdmin`). The consent picker is the source of truth and
127
+ * the scope-guard reads an empty `vault_scope` claim as "no
128
+ * narrowing" first admin can request scope against any vault.
129
+ * - Non-admin user the list of vault names from `user_vaults`. The
130
+ * scope-guard at vault/notes/scribe enforces that the user can
131
+ * only request scope against vaults in their list (Phase 1 pinned
132
+ * to a single vault; Phase 2 lifts that to N). A non-admin with
133
+ * zero assignments returns `[]` — distinct semantics from the
134
+ * admin's `[]` because the consent picker plus the picked-must-
135
+ * match-assignment defense in `handleConsentSubmit` enforces that
136
+ * non-admin tokens carry a non-empty `vault_scope`.
132
137
  *
133
138
  * Always returns an array (never undefined) so the JWT carries the claim
134
139
  * unconditionally — readers don't have to distinguish "absent" from "empty."
135
140
  */
136
141
  export function vaultScopeForUser(db: Database, userId: string): string[] {
142
+ if (isFirstAdmin(db, userId)) return [];
137
143
  const user = getUserById(db, userId);
138
144
  if (!user) return [];
139
- if (user.assignedVault === null) return [];
140
- return [user.assignedVault];
145
+ return [...user.assignedVaults];
141
146
  }
142
147
 
143
148
  export interface OAuthDeps {
@@ -862,12 +867,20 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
862
867
  return htmlResponse(renderLogin({ params: parsed, csrfToken: csrf.token }), 200, extra);
863
868
  }
864
869
 
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).
870
+ // Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
871
+ // their assigned vault list — they can't pick a vault they don't own.
872
+ // First admin (admin posture) sees the full dropdown of every vault on
873
+ // the hub.
874
+ //
875
+ // Two shapes for non-admin users emerge:
876
+ // - exactly one assigned vault → picker renders locked to that name
877
+ // (same shape as Phase 1; smallest diff for the common case).
878
+ // - two or more assigned vaults → picker renders a free dropdown
879
+ // filtered to those names — user picks one per consent.
880
+ //
881
+ // Defensive null-coalesce: the session points at a deleted user
882
+ // shouldn't 500; treat as admin posture (the broader scope-validation
883
+ // gate will catch any actual privilege issue).
871
884
  //
872
885
  // Resolved here (before the fast-paths) because the stale-assignment
873
886
  // predicate below — which gates both skip-consent (#75) and same-hub
@@ -876,24 +889,28 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
876
889
  // closing the silent-mint-on-stale-vault gap; the read is one JSON parse
877
890
  // off-disk per /authorize.
878
891
  const user = getUserById(db, session.userId);
879
- const lockedVault = user?.assignedVault ?? null;
892
+ const userIsAdmin = isFirstAdmin(db, session.userId);
893
+ // Non-admin user's assigned vaults; admin posture (or no row) → empty.
894
+ const assignedVaults: string[] = userIsAdmin ? [] : (user?.assignedVaults ?? []);
880
895
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
881
896
  const vaultNames = listVaultNames(manifest);
882
897
 
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);
898
+ // Stale-assignment predicate (hub#284, generalized in Phase 2 PR 2 from
899
+ // single-vault to N-vault). For a non-admin user, "stale" means at
900
+ // least one of their assigned vaults no longer exists in services.json
901
+ // AND no vault in their list still exists i.e. they have *zero*
902
+ // valid vaults to consent against. The banner surfaces this state with
903
+ // an admin-remediation hint instead of silently minting a token
904
+ // against a missing vault. If at least one of their vaults still
905
+ // exists, the consent flow proceeds normally the missing ones drop
906
+ // out of the picker without ceremony.
907
+ //
908
+ // Admin users are never stale (they aren't pinned to any vault list).
909
+ const remainingValidVaults = assignedVaults.filter((v) => vaultNames.includes(v));
910
+ const hasStaleAssignment = assignedVaults.length > 0 && remainingValidVaults.length === 0;
894
911
 
895
912
  // Skip-consent gate (#75). If the user has previously granted every
896
- // requested scope to this client, mint the auth code immediately. Two
913
+ // requested scope to this client, mint the auth code immediately. Three
897
914
  // important constraints:
898
915
  // - Unnamed vault verbs (`vault:read`) need the picker even if a prior
899
916
  // grant exists, because the operator's vault choice isn't recorded
@@ -905,10 +922,25 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
905
922
  // - Stale-assignment (above) also forces the consent render so the
906
923
  // banner explains the broken state rather than silently minting a
907
924
  // token against the missing vault.
925
+ // - The user is admin OR has at least one assigned vault (hub#429
926
+ // reviewer fold, follow-up). A zero-vault non-admin whose prior
927
+ // grants survived a `setUserVaults(_, [])` admin action would
928
+ // otherwise silently re-mint a token against the now-revoked
929
+ // vault assignment — the grants table has no FK cascade from
930
+ // `user_vaults`, so deleting assignments doesn't revoke grants.
931
+ // Same privesc shape as the same-hub auto-trust gate below;
932
+ // identical guard (`userHasVaultPosture`). Force fall-through to
933
+ // the consent render where the zero-vault gate in
934
+ // `handleConsentSubmit` also refuses (defense in depth). This
935
+ // also transitively defends the trust-by-client_name auto-
936
+ // promote path (~line 554) which recursively re-enters
937
+ // `handleAuthorizeGet` after promoting the pending client.
908
938
  const hasUnnamedVault = unnamedVaultVerbs(requestedScopes).length > 0;
939
+ const userHasVaultPosture = userIsAdmin || assignedVaults.length > 0;
909
940
  if (
910
941
  !hasStaleAssignment &&
911
942
  !hasUnnamedVault &&
943
+ userHasVaultPosture &&
912
944
  isCoveredByGrant(db, session.userId, client.clientId, requestedScopes)
913
945
  ) {
914
946
  console.log(
@@ -931,16 +963,31 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
931
963
  // still want explicit consent as a sanity gate.
932
964
  // 3. No unnamed vault verbs are requested — those need the picker to
933
965
  // 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.
966
+ // 4. The user's assigned_vaults list is not stale (hub#284 reviewer
967
+ // fold) — otherwise the same-hub gate would silently mint a token
968
+ // for a removed vault before the consent-render path's stale
969
+ // detection ever runs.
970
+ // 5. The user is admin OR has at least one assigned vault (Phase 2
971
+ // PR 2 reviewer fold). A zero-vault non-admin has
972
+ // `hasStaleAssignment=false` (length===0 short-circuits the stale
973
+ // predicate above) and would otherwise sail through the auto-
974
+ // trust gate. The resulting `vault_scope: []` claim is the admin
975
+ // "unrestricted" sentinel — minting it for a non-admin grants
976
+ // hub-wide vault access. Force fall-through to the consent
977
+ // render where the zero-vault gate in `handleConsentSubmit` also
978
+ // refuses (defense in depth).
938
979
  //
939
980
  // The grant is also recorded so subsequent flows with the same scopes
940
981
  // hit the standard skip-consent gate above. Logged so an operator
941
982
  // auditing "who did this" can trace it back to a same-hub DCR.
942
983
  const hasAdminScope = requestedScopes.some(scopeIsAdmin);
943
- if (client.sameHub && !hasAdminScope && !hasUnnamedVault && !hasStaleAssignment) {
984
+ if (
985
+ client.sameHub &&
986
+ !hasAdminScope &&
987
+ !hasUnnamedVault &&
988
+ !hasStaleAssignment &&
989
+ userHasVaultPosture
990
+ ) {
944
991
  console.log(
945
992
  `[oauth] auto-approved same-hub client client_id=${client.clientId} user_id=${session.userId} scopes=${requestedScopes.join(" ")} (hub#312)`,
946
993
  );
@@ -952,7 +999,9 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
952
999
  }
953
1000
 
954
1001
  return htmlResponse(
955
- renderConsent(consentProps(client, parsed, vaultNames, csrf.token, lockedVault)),
1002
+ renderConsent(
1003
+ consentProps(client, parsed, vaultNames, csrf.token, assignedVaults, userIsAdmin),
1004
+ ),
956
1005
  200,
957
1006
  extra,
958
1007
  );
@@ -1140,18 +1189,46 @@ async function handleConsentSubmit(
1140
1189
  params.state,
1141
1190
  );
1142
1191
  }
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.
1192
+ // Multi-user Phase 2 PR 2: non-admin users are pinned to a list of one
1193
+ // or more vaults via `user_vaults`. The consent screen renders the
1194
+ // picker narrowed to that list and any named scopes (`vault:<name>:
1195
+ // <verb>`) requested by the client must target a vault in the list.
1196
+ // The server-side defense here refuses any mint where the user's
1197
+ // submission disagrees, so a hand-crafted POST or a misbehaving SPA
1198
+ // can't bypass the narrowing. First admin (admin posture) keeps the
1199
+ // existing picker-as-source-of-truth behavior (empty `assignedVaults`).
1200
+ const userIsAdmin = isFirstAdmin(db, session.userId);
1153
1201
  const sessionUser = getUserById(db, session.userId);
1154
- const assignedVault = sessionUser?.assignedVault ?? null;
1202
+ const assignedVaults: string[] = userIsAdmin ? [] : (sessionUser?.assignedVaults ?? []);
1203
+ const isPinned = assignedVaults.length > 0;
1204
+
1205
+ // Zero-vault non-admin gate (Phase 2 PR 2 reviewer fold). A non-admin
1206
+ // user with no `user_vaults` rows is a known-but-not-yet-assigned
1207
+ // posture — they can sign in to /account/, change their password, and
1208
+ // see the home page, but they have no vaults to authorize against.
1209
+ // Block any vault-scoped consent at the submit boundary so an OAuth
1210
+ // client can't trick them into minting a token: an empty `vault_scope`
1211
+ // claim is the admin "unrestricted" sentinel (see `vaultScopeForUser`),
1212
+ // and we must keep that sentinel reserved for true admins. Non-vault
1213
+ // scopes (`scribe:transcribe`, etc.) still consent normally — only
1214
+ // `vault:...` scopes are gated here. The defense pairs with the GET-
1215
+ // path same-hub-auto-trust gate below (which falls through to the
1216
+ // consent render that would otherwise show the picker).
1217
+ if (!userIsAdmin && assignedVaults.length === 0) {
1218
+ const submittedScopes = params.scope.split(" ").filter((s) => s.length > 0);
1219
+ const hasVaultScope = submittedScopes.some((s) => {
1220
+ if (s === "vault:read" || s === "vault:write" || s === "vault:admin") return true;
1221
+ const parts = s.split(":");
1222
+ return parts.length === 3 && parts[0] === "vault" && parts[2] && VAULT_VERBS.has(parts[2]);
1223
+ });
1224
+ if (hasVaultScope) {
1225
+ return htmlError(
1226
+ "No vaults assigned",
1227
+ "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.",
1228
+ 400,
1229
+ );
1230
+ }
1231
+ }
1155
1232
 
1156
1233
  // Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
1157
1234
  // `vault:<verb>` scope is ambiguous about which vault it grants access to.
@@ -1171,24 +1248,17 @@ async function handleConsentSubmit(
1171
1248
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
1172
1249
  const validNames = listVaultNames(manifest);
1173
1250
  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.
1251
+ // Stale-assignment branch (hub#284, generalized Phase 2 PR 2). The
1252
+ // user is consenting via an assignment that points at a vault no
1253
+ // longer in services.json. The new copy names the actual condition
1254
+ // (assignment removed) and points at the admin remediation surface.
1184
1255
  //
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) {
1256
+ // The check fires when the picked vault is in the user's assigned
1257
+ // list — narrows the special-case to the "user is consenting via
1258
+ // a now-stale assignment" shape rather than swallowing every
1259
+ // Unknown-vault. A hand-crafted POST naming a never-existed vault
1260
+ // still hits the generic branch.
1261
+ if (isPinned && assignedVaults.includes(pickedVault)) {
1192
1262
  return htmlError(
1193
1263
  "Assigned vault was removed",
1194
1264
  `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 +1271,16 @@ async function handleConsentSubmit(
1201
1271
  400,
1202
1272
  );
1203
1273
  }
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) {
1274
+ // Server-side defense: non-admin user submitted a vault that's not in
1275
+ // their assigned list. The picker rendered as narrowed, so a UI-path
1276
+ // user couldn't reach this — but a hand-crafted form bypassing the
1277
+ // narrowed input lands here. Refuse the mint instead of silently
1278
+ // overwriting; the explicit error tells the operator the assignment
1279
+ // is load-bearing.
1280
+ if (isPinned && !assignedVaults.includes(pickedVault)) {
1211
1281
  return htmlError(
1212
1282
  "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.`,
1283
+ `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
1284
  400,
1215
1285
  );
1216
1286
  }
@@ -1218,12 +1288,12 @@ async function handleConsentSubmit(
1218
1288
  }
1219
1289
 
1220
1290
  // 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) {
1291
+ // A non-admin user can't request scope against any vault outside their
1292
+ // assignment list — same invariant as the picker check above, applied
1293
+ // to scopes that arrived already-named (e.g. a client that knows the
1294
+ // user's vault and asked for `vault:bob:read` directly). Admin posture
1295
+ // (`isPinned === false`) skips this check.
1296
+ if (isPinned) {
1227
1297
  const mismatched: string[] = [];
1228
1298
  for (const s of scopes) {
1229
1299
  const parts = s.split(":");
@@ -1233,7 +1303,7 @@ async function handleConsentSubmit(
1233
1303
  parts[1] &&
1234
1304
  parts[2] &&
1235
1305
  VAULT_VERBS.has(parts[2]) &&
1236
- parts[1] !== assignedVault
1306
+ !assignedVaults.includes(parts[1])
1237
1307
  ) {
1238
1308
  mismatched.push(s);
1239
1309
  }
@@ -1241,29 +1311,29 @@ async function handleConsentSubmit(
1241
1311
  if (mismatched.length > 0) {
1242
1312
  return htmlError(
1243
1313
  "Vault assignment mismatch",
1244
- `vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault other than your vault assignment.`,
1314
+ `vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault outside your assignment.`,
1245
1315
  400,
1246
1316
  );
1247
1317
  }
1248
1318
 
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.
1319
+ // Stale-assignment defense (hub#284, generalized Phase 2 PR 2). A
1320
+ // named scope shaped `vault:<assigned>:<verb>` passes the mismatch
1321
+ // check above but points at a vault that no longer exists in
1322
+ // services.json. Minting a token here would silently issue scope
1323
+ // against a vault the resource server can't find — the user thinks
1324
+ // consent succeeded but the subsequent API calls fail with no
1325
+ // actionable signal. Refuse the mint and surface the same admin-
1326
+ // remediation hint the GET path's banner uses.
1258
1327
  const namedStaleScopes: string[] = [];
1259
1328
  for (const s of scopes) {
1260
1329
  const parts = s.split(":");
1261
1330
  if (
1262
1331
  parts.length === 3 &&
1263
1332
  parts[0] === "vault" &&
1264
- parts[1] === assignedVault &&
1333
+ parts[1] !== undefined &&
1265
1334
  parts[2] &&
1266
- VAULT_VERBS.has(parts[2])
1335
+ VAULT_VERBS.has(parts[2]) &&
1336
+ assignedVaults.includes(parts[1])
1267
1337
  ) {
1268
1338
  namedStaleScopes.push(s);
1269
1339
  }
@@ -1273,10 +1343,19 @@ async function handleConsentSubmit(
1273
1343
  // the no-vault-scope hot path off-disk for the common admin flows.
1274
1344
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
1275
1345
  const validNames = listVaultNames(manifest);
1276
- if (!validNames.includes(assignedVault)) {
1346
+ // Collect the stale vault names embedded in the named scopes.
1347
+ const staleNames = new Set<string>();
1348
+ for (const s of namedStaleScopes) {
1349
+ const parts = s.split(":");
1350
+ if (parts[1] !== undefined && !validNames.includes(parts[1])) {
1351
+ staleNames.add(parts[1]);
1352
+ }
1353
+ }
1354
+ if (staleNames.size > 0) {
1355
+ const exemplar = [...staleNames][0];
1277
1356
  return htmlError(
1278
1357
  "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.`,
1358
+ `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
1359
  400,
1281
1360
  );
1282
1361
  }
@@ -1421,10 +1500,7 @@ export async function handleApproveClientPost(
1421
1500
  );
1422
1501
  }
1423
1502
  target.searchParams.set("error", "access_denied");
1424
- target.searchParams.set(
1425
- "error_description",
1426
- "The user denied the authorization request.",
1427
- );
1503
+ target.searchParams.set("error_description", "The user denied the authorization request.");
1428
1504
  if (denyState !== undefined) target.searchParams.set("state", denyState);
1429
1505
  return redirectResponse(target.toString());
1430
1506
  }
@@ -1660,11 +1736,17 @@ async function handleTokenAuthorizationCode(
1660
1736
  audience,
1661
1737
  clientId: redeemed.clientId,
1662
1738
  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"
1739
+ // vault_scope claim — Phase 2 per-user vault pin (Phase 1 had a single
1740
+ // `assigned_vault` column; Phase 2 PR 2 generalized to `assigned_vaults`
1741
+ // via `user_vaults`). Non-empty list for non-admin users with at least
1742
+ // one assigned vault; empty for first-admin (unrestricted sentinel).
1743
+ // Zero-vault non-admin is also empty by `vaultScopeForUser`, but the
1744
+ // OAuth flow refuses to mint a vault-scoped token for them upstream
1745
+ // (see the zero-vault gate in `handleConsentSubmit` + the same-hub
1746
+ // auto-trust posture check), so we never reach here with that user
1747
+ // posture. The narrowing in `handleConsentSubmit` already rewrote
1748
+ // `vault:<verb>` → `vault:<assigned>:<verb>`, so the auth code's
1749
+ // scopes are pre-aligned; this claim is the explicit "owned vaults"
1668
1750
  // signal PR 5 consumes downstream.
1669
1751
  vaultScope: vaultScopeForUser(db, redeemed.userId),
1670
1752
  now: deps.now,
@@ -1781,12 +1863,12 @@ async function handleTokenRefresh(
1781
1863
  clientId: row.clientId,
1782
1864
  issuer: deps.issuer,
1783
1865
  // 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
1866
+ // `assigned_vaults` at refresh time (not snapshotted onto the refresh-
1867
+ // token row). An admin who changes a user's vault assignments between
1786
1868
  // mint and refresh sees the new value on the next refresh; existing
1787
1869
  // access tokens carry their original claim until their 15-minute TTL
1788
1870
  // elapses. Same posture as the design's "OAuth issuer reads
1789
- // `assigned_vault` at mint time, not at session-creation time" pin.
1871
+ // `assigned_vaults` at mint time, not at session-creation time" pin.
1790
1872
  vaultScope: vaultScopeForUser(db, refreshUserId),
1791
1873
  now: deps.now,
1792
1874
  });
@@ -2156,95 +2238,117 @@ function consentProps(
2156
2238
  params: AuthorizeFormParams,
2157
2239
  vaultNames: string[],
2158
2240
  csrfToken: string,
2159
- lockedVault: string | null,
2241
+ assignedVaults: readonly string[],
2242
+ userIsAdmin: boolean,
2160
2243
  ) {
2161
2244
  const scopes = params.scope.split(" ").filter((s) => s.length > 0);
2162
2245
  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.
2246
+ // Multi-user Phase 2 PR 2 stale-assignment branch (hub#284 generalized
2247
+ // from one vault to N). A non-admin user whose entire vault list has
2248
+ // been removed from services.json — admin removed / renamed the vaults
2249
+ // without reassigning. The banner surfaces this state with an admin-
2250
+ // remediation hint instead of silently minting against a missing vault.
2170
2251
  //
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.
2252
+ // The user-facing surface is "your assigned vault(s) were removed"
2253
+ // the banner names the first stale vault as the canonical example. The
2254
+ // exact list is unimportant for the recovery path (operator goes to
2255
+ // /admin/users either way), and pluralizing the banner copy doesn't
2256
+ // change the action.
2178
2257
  //
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.
2258
+ // Security posture: we deliberately do NOT relax the picked-must-be-in-
2259
+ // assigned-list check. Stale-assignment is admin-remediated.
2260
+ const remainingValidAssigned = assignedVaults.filter((v) => vaultNames.includes(v));
2261
+ const hasStaleAssignment = assignedVaults.length > 0 && remainingValidAssigned.length === 0;
2183
2262
  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.
2263
+ hasStaleAssignment && assignedVaults[0] !== undefined ? assignedVaults[0] : undefined;
2264
+ // A named scope like `vault:<old>:<verb>` requested by the client where
2265
+ // <old> is one of the user's stale vaults. The server-side named-scope
2266
+ // defense allows this through because the scope matches an assigned
2267
+ // vault, but the token it mints would point at a vault that no longer
2268
+ // exists. Gate Approve on this case too so the user doesn't burn a
2269
+ // consent into a token that fails at the resource server.
2191
2270
  const hasNamedStaleVaultScope =
2192
- staleAssignedVault !== undefined &&
2271
+ hasStaleAssignment &&
2193
2272
  scopes.some((s) => {
2194
2273
  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
- );
2274
+ if (
2275
+ parts.length !== 3 ||
2276
+ parts[0] !== "vault" ||
2277
+ parts[1] === undefined ||
2278
+ parts[2] === undefined ||
2279
+ !VAULT_VERBS.has(parts[2])
2280
+ ) {
2281
+ return false;
2282
+ }
2283
+ // Named for one of the user's vaults — and given hasStaleAssignment,
2284
+ // none of the user's vaults exist on this hub, so this scope points
2285
+ // at a stale name.
2286
+ return assignedVaults.includes(parts[1]);
2202
2287
  });
2203
2288
 
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.
2289
+ // Multi-user Phase 2 PR 2: non-admin users see the picker narrowed to
2290
+ // their assigned vault list. Four shapes emerge:
2212
2291
  //
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.
2292
+ // - Single assigned vault (still valid) render locked to that name
2293
+ // (same shape as Phase 1).
2294
+ // - Two-or-more assigned vaults render a dropdown filtered to the
2295
+ // user's list. Same control as the admin dropdown but narrowed.
2296
+ // - Stale-assigned (all vaults gone) → render no-vaults-available so
2297
+ // the form gracefully rejects an Approve click instead of silently
2298
+ // submitting a missing name.
2299
+ // - First admin (admin posture, empty `assignedVaults`) → full hub-
2300
+ // wide dropdown of every vault on the hub.
2301
+ // - Zero-vault non-admin (Phase 2 PR 2 reviewer fold) → no-vaults-
2302
+ // available so the form gracefully rejects an Approve click. The
2303
+ // prior shape rendered the full hub-wide list for non-admins with
2304
+ // zero assignments, which let them pick a vault they had no
2305
+ // business consenting to. The consent-submit gate refuses any
2306
+ // vault-scoped POST from a zero-vault non-admin (defense in depth);
2307
+ // this branch keeps the picker UI honest.
2218
2308
  let vaultPicker: VaultPickerProps | undefined;
2219
2309
  if (unnamedVerbs.length > 0) {
2220
- if (staleAssignedVault !== undefined) {
2310
+ if (hasStaleAssignment) {
2221
2311
  vaultPicker = { unnamedVerbs, availableVaults: [] };
2222
- } else if (lockedVault !== null) {
2223
- vaultPicker = { unnamedVerbs, availableVaults: vaultNames, lockedVault };
2224
- } else {
2312
+ } else if (remainingValidAssigned.length === 1) {
2313
+ const only = remainingValidAssigned[0];
2314
+ if (only !== undefined) {
2315
+ vaultPicker = { unnamedVerbs, availableVaults: [only], lockedVault: only };
2316
+ }
2317
+ } else if (remainingValidAssigned.length > 1) {
2318
+ vaultPicker = { unnamedVerbs, availableVaults: remainingValidAssigned };
2319
+ } else if (userIsAdmin) {
2320
+ // Admin posture (no assignments) → full hub-wide list.
2225
2321
  vaultPicker = { unnamedVerbs, availableVaults: vaultNames };
2322
+ } else {
2323
+ // Zero-vault non-admin → no-vaults-available picker with the
2324
+ // "ask your admin to assign you" copy. The Approve button renders
2325
+ // disabled (same shape as the empty-services-json case) so the
2326
+ // form can't post a hand-picked name. The consent-submit gate
2327
+ // refuses any vault-scoped POST from this user too (defense in
2328
+ // depth — see `handleConsentSubmit`).
2329
+ vaultPicker = { unnamedVerbs, availableVaults: [], emptyReason: "no-assignments" };
2226
2330
  }
2227
2331
  }
2228
2332
  // Named-scope display: substitute unnamed `vault:<verb>` rows with the
2229
2333
  // 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.
2334
+ // - Non-admin with exactly one valid assigned vault → render that name.
2335
+ // - Stale-assigned → null; the row carries the `<TBD>` placeholder.
2336
+ // The banner explains why a name isn't bound.
2337
+ // - Non-admin with multiple assigned vaultsnull sentinel (the user
2338
+ // hasn't picked yet).
2339
+ // - Admin with exactly one vault available render that name.
2340
+ // - Admin with multiple / no vaults → null sentinel.
2240
2341
  let displayVault: string | null = null;
2241
- if (staleAssignedVault === undefined && lockedVault !== null) {
2242
- displayVault = lockedVault;
2342
+ if (!hasStaleAssignment && remainingValidAssigned.length === 1) {
2343
+ const only = remainingValidAssigned[0];
2344
+ if (only !== undefined) displayVault = only;
2243
2345
  } else if (
2244
- staleAssignedVault === undefined &&
2346
+ !hasStaleAssignment &&
2347
+ assignedVaults.length === 0 &&
2245
2348
  unnamedVerbs.length > 0 &&
2246
2349
  vaultNames.length === 1
2247
2350
  ) {
2351
+ // Admin with a single vault on the hub: pre-check pattern from Phase 1.
2248
2352
  const only = vaultNames[0];
2249
2353
  if (only) displayVault = only;
2250
2354
  }
@@ -2271,4 +2375,5 @@ interface VaultPickerProps {
2271
2375
  unnamedVerbs: string[];
2272
2376
  availableVaults: string[];
2273
2377
  lockedVault?: string;
2378
+ emptyReason?: "no-assignments" | "no-vaults-on-hub";
2274
2379
  }