@openparachute/hub 0.5.10-rc.9 → 0.5.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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-handlers.test.ts +141 -6
  3. package/src/__tests__/api-account.test.ts +463 -0
  4. package/src/__tests__/api-modules-ops.test.ts +74 -0
  5. package/src/__tests__/api-modules.test.ts +134 -0
  6. package/src/__tests__/api-users.test.ts +522 -0
  7. package/src/__tests__/cors.test.ts +587 -0
  8. package/src/__tests__/hub-db.test.ts +126 -1
  9. package/src/__tests__/hub-settings.test.ts +152 -0
  10. package/src/__tests__/jwt-sign.test.ts +59 -0
  11. package/src/__tests__/oauth-handlers.test.ts +912 -10
  12. package/src/__tests__/oauth-ui.test.ts +210 -0
  13. package/src/__tests__/scope-explanations.test.ts +23 -0
  14. package/src/__tests__/serve.test.ts +8 -1
  15. package/src/__tests__/setup-wizard.test.ts +216 -3
  16. package/src/__tests__/users.test.ts +196 -0
  17. package/src/__tests__/vault-names.test.ts +172 -0
  18. package/src/account-change-password-ui.ts +379 -0
  19. package/src/admin-handlers.ts +68 -2
  20. package/src/admin-host-admin-token.ts +5 -0
  21. package/src/admin-vault-admin-token.ts +7 -0
  22. package/src/api-account.ts +443 -0
  23. package/src/api-mint-token.ts +6 -0
  24. package/src/api-modules-ops.ts +15 -6
  25. package/src/api-modules.ts +101 -0
  26. package/src/api-users.ts +393 -0
  27. package/src/commands/auth.ts +10 -1
  28. package/src/commands/serve.ts +5 -1
  29. package/src/cors.ts +263 -0
  30. package/src/hub-db.ts +30 -0
  31. package/src/hub-server.ts +138 -18
  32. package/src/hub-settings.ts +98 -1
  33. package/src/jwt-sign.ts +17 -1
  34. package/src/oauth-handlers.ts +237 -29
  35. package/src/oauth-ui.ts +451 -38
  36. package/src/operator-token.ts +4 -0
  37. package/src/scope-explanations.ts +26 -1
  38. package/src/setup-wizard.ts +134 -16
  39. package/src/users.ts +210 -3
  40. package/src/vault-names.ts +57 -0
  41. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  42. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  43. package/web/ui/dist/index.html +2 -2
  44. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/jwt-sign.ts CHANGED
@@ -47,6 +47,20 @@ export interface SignAccessTokenOpts {
47
47
  * or thread it from `OAuthDeps.issuer`.
48
48
  */
49
49
  issuer: string;
50
+ /**
51
+ * Per-user vault pin — the multi-user Phase 1 (design
52
+ * [`2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/))
53
+ * vault_scope claim. Non-empty for non-admin users (a single-element list
54
+ * naming their `assigned_vault`); empty `[]` for admin users (the "no
55
+ * per-user vault restriction" sentinel — admins can request any vault on
56
+ * the hub via the consent picker). Always emitted as a claim — defaults
57
+ * to `[]` when callers omit — so a downstream consumer (PR 5's
58
+ * scope-guard at vault / notes / scribe) can unambiguously read it
59
+ * without distinguishing "absent" from "empty." Phase 1 always has
60
+ * length ≤1; the list shape carries Phase 2 multi-vault forward without
61
+ * a wire-shape change.
62
+ */
63
+ vaultScope?: string[];
50
64
  /** Override the jti (defaults to random base64url(16)). Used by tests. */
51
65
  jti?: string;
52
66
  /**
@@ -60,7 +74,8 @@ export interface SignAccessTokenOpts {
60
74
  * `pa_scope_set` (which scope-set the token was minted under) so an
61
75
  * auto-rotation can preserve the operator's chosen narrowing across mints.
62
76
  * Reserved claims (`scope`, `client_id`, `sub`, `iss`, `iat`, `exp`, `aud`,
63
- * `jti`) are owned by this function and overwritten if passed here.
77
+ * `jti`, `vault_scope`) are owned by this function and overwritten if
78
+ * passed here.
64
79
  */
65
80
  extraClaims?: Record<string, unknown>;
66
81
  }
@@ -85,6 +100,7 @@ export async function signAccessToken(
85
100
  ...(opts.extraClaims ?? {}),
86
101
  scope: opts.scopes.join(" "),
87
102
  client_id: opts.clientId,
103
+ vault_scope: opts.vaultScope ?? [],
88
104
  })
89
105
  .setProtectedHeader({ alg: SIGNING_ALGORITHM, kid: key.kid })
90
106
  .setSubject(opts.sub)
@@ -62,6 +62,7 @@ import {
62
62
  renderConsent,
63
63
  renderError,
64
64
  renderLogin,
65
+ renderUnknownClient,
65
66
  } from "./oauth-ui.ts";
66
67
  import { isSameOriginRequest } from "./origin-check.ts";
67
68
  import { isHttpsRequest } from "./request-protocol.ts";
@@ -79,7 +80,8 @@ import {
79
80
  findSession,
80
81
  parseSessionCookie,
81
82
  } from "./sessions.ts";
82
- import { getUserByUsername, verifyPassword } from "./users.ts";
83
+ import { getUserById, getUserByUsername, verifyPassword } from "./users.ts";
84
+ import { listVaultNames } from "./vault-names.ts";
83
85
  import { isVaultEntry, shortName, vaultInstanceNameFor } from "./well-known.ts";
84
86
 
85
87
  /** Verbs whose unnamed `vault:<verb>` form needs picker disambiguation. */
@@ -95,26 +97,6 @@ function unnamedVaultVerbs(scopes: string[]): string[] {
95
97
  return verbs;
96
98
  }
97
99
 
98
- /**
99
- * Vault instance names registered on this host, derived from services.json.
100
- * Walks both manifest shapes — single-entry-multi-path (`paths: ["/vault/work",
101
- * "/vault/personal"]`) and per-vault entries (`parachute-vault-work`) — by
102
- * delegating each (name, path) pair to the canonical `vaultInstanceNameFor`
103
- * helper. Entries with no paths still resolve to a name via the helper's
104
- * manifest-suffix fallback (#143).
105
- */
106
- function listVaultNames(manifest: ServicesManifest): string[] {
107
- const names = new Set<string>();
108
- for (const svc of manifest.services) {
109
- if (!isVaultEntry(svc)) continue;
110
- const paths = svc.paths.length > 0 ? svc.paths : [undefined];
111
- for (const path of paths) {
112
- names.add(vaultInstanceNameFor(svc.name, path));
113
- }
114
- }
115
- return Array.from(names).sort();
116
- }
117
-
118
100
  /** Rewrite each unnamed `vault:<verb>` to `vault:<picked>:<verb>`. */
119
101
  function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
120
102
  return scopes.map((s) => {
@@ -127,6 +109,34 @@ function narrowVaultScopes(scopes: string[], pickedVault: string): string[] {
127
109
  });
128
110
  }
129
111
 
112
+ /**
113
+ * Derive the `vault_scope` claim value for a given hub user. Multi-user
114
+ * Phase 1 (design
115
+ * [`2026-05-20-multi-user-phase-1.md`](https://parachute.computer/design/2026-05-20-multi-user-phase-1/),
116
+ * §oauth-claim-shape).
117
+ *
118
+ * - `userId` resolves to no row → `[]`. Defensive: the caller already
119
+ * validated the user existed (auth-code redemption / refresh row's
120
+ * user_id), but a delete-between-mint-and-now race shouldn't 500. Empty
121
+ * is the safe sentinel — the scope-bearing `scope` claim is still the
122
+ * gate.
123
+ * - User exists with `assignedVault === null` → `[]`. Admin / unpinned
124
+ * posture; the consent picker is the source of truth.
125
+ * - User exists with `assignedVault === "name"` → `["name"]`. The Phase 1
126
+ * single-vault pin; PR 5's scope-guard at vault/notes/scribe consumes
127
+ * this to enforce that an assigned user can't request scope against any
128
+ * vault other than their assigned one.
129
+ *
130
+ * Always returns an array (never undefined) so the JWT carries the claim
131
+ * unconditionally — readers don't have to distinguish "absent" from "empty."
132
+ */
133
+ export function vaultScopeForUser(db: Database, userId: string): string[] {
134
+ const user = getUserById(db, userId);
135
+ if (!user) return [];
136
+ if (user.assignedVault === null) return [];
137
+ return [user.assignedVault];
138
+ }
139
+
130
140
  export interface OAuthDeps {
131
141
  /** Hub origin used for `iss`, `authorization_endpoint`, etc. */
132
142
  issuer: string;
@@ -452,6 +462,7 @@ function pendingClientResponse(
452
462
  redirectUris: client.redirectUris,
453
463
  requestedScopes,
454
464
  ...(requestedVault !== undefined && { requestedVault }),
465
+ hubOrigin: deps.issuer,
455
466
  approveForm: { csrfToken: csrf.token, returnTo },
456
467
  }),
457
468
  403,
@@ -465,6 +476,7 @@ function pendingClientResponse(
465
476
  redirectUris: client.redirectUris,
466
477
  requestedScopes,
467
478
  ...(requestedVault !== undefined && { requestedVault }),
479
+ hubOrigin: deps.issuer,
468
480
  }),
469
481
  403,
470
482
  extra,
@@ -489,6 +501,70 @@ function pendingClientResponse(
489
501
  * strict spec-shaped handling. The extra fields are spec-permitted
490
502
  * extensions ("other parameters").
491
503
  */
504
+ /**
505
+ * Render the "Unknown application" page surfaced by /oauth/authorize when
506
+ * `getClient(db, clientId)` returns null. Promotes the recovery affordance
507
+ * (a "Reset connection" button that clears `lens:dcr:*` localStorage on the
508
+ * hub's own origin) when the request's redirect_uri points at one of the
509
+ * hub's bound origins — that's the Notes-mounted-at-hub-origin case where
510
+ * we can safely run inline JS against the SPA's storage.
511
+ *
512
+ * Root-cause shape (rc.11 fresh-machine connect bug): an operator who wipes
513
+ * `~/.parachute/hub.db` between testing iterations strands their browser's
514
+ * cached client_id, and the SPA has no signal to clear it without
515
+ * operator action. The hub-side fix is to give the operator one click on
516
+ * the error page. See `renderUnknownClient` in oauth-ui.ts for the full
517
+ * design + the localStorage key the snippet clears.
518
+ *
519
+ * Cross-origin SPA redirect_uris fall back to the static error variant —
520
+ * we can't reach a third-party SPA's storage from this page. Malformed
521
+ * redirect_uris likewise fall back (we never trust an unparsed URL).
522
+ */
523
+ function unknownClientResponse(
524
+ clientId: string,
525
+ redirectUri: string | null,
526
+ deps: OAuthDeps,
527
+ ): Response {
528
+ const selfOriginRedirectPath = resolveSelfOriginRedirectPath(redirectUri, deps);
529
+ return htmlResponse(
530
+ renderUnknownClient({
531
+ clientId,
532
+ selfOriginRedirectPath,
533
+ }),
534
+ 400,
535
+ );
536
+ }
537
+
538
+ /**
539
+ * Parse a redirect_uri and return its pathname iff its origin is one the
540
+ * hub serves itself (any entry in `hubBoundOrigins`). Returns null when
541
+ * the URL is missing, malformed, or points at a non-hub origin — the
542
+ * caller falls back to a static error in those cases. The pathname is
543
+ * what the "Reset connection" button navigates to after clearing the
544
+ * SPA's cached client_id; e.g. for `http://localhost:1939/notes/oauth/callback`
545
+ * we return `/notes/oauth/callback`. The SPA then routes that internally
546
+ * (Notes' /oauth/callback handler harmlessly errors on missing code +
547
+ * state, and the user gets dropped onto the connect screen for a fresh
548
+ * DCR — that's the recovery path).
549
+ *
550
+ * Pathname-only (not the full URL) is deliberate: same-origin navigation
551
+ * is trivially safe, and we don't want to surface a redirect that
552
+ * leaves the hub's origin even when the redirect_uri claims it. If a
553
+ * future SPA needs different recovery semantics, this is the seam.
554
+ */
555
+ function resolveSelfOriginRedirectPath(redirectUri: string | null, deps: OAuthDeps): string | null {
556
+ if (!redirectUri) return null;
557
+ let parsed: URL;
558
+ try {
559
+ parsed = new URL(redirectUri);
560
+ } catch {
561
+ return null;
562
+ }
563
+ const bound = resolveBoundOrigins(deps);
564
+ if (!bound.includes(parsed.origin)) return null;
565
+ return parsed.pathname || "/";
566
+ }
567
+
492
568
  function pendingClientJson(clientId: string, issuer: string): Response {
493
569
  const base = issuer.replace(/\/$/, "");
494
570
  return jsonResponse(
@@ -588,8 +664,12 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
588
664
  const client = getClient(db, parsed.clientId);
589
665
  if (!client) {
590
666
  // Can't safely redirect — we don't trust the redirect_uri until we've
591
- // matched it against a registered client. Render an HTML error.
592
- return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
667
+ // matched it against a registered client. Render an HTML error that
668
+ // promotes a one-click recovery when the redirect_uri points at one
669
+ // of our own origins (the canonical fresh-machine repro: an operator
670
+ // wiped hub.db, the SPA still holds the old client_id in
671
+ // localStorage, the recovery is "clear that key and reload the SPA").
672
+ return unknownClientResponse(parsed.clientId, parsed.redirectUri, deps);
593
673
  }
594
674
  if (client.status !== "approved") {
595
675
  return pendingClientResponse(db, req, client, url, deps);
@@ -645,10 +725,19 @@ export function handleAuthorizeGet(db: Database, req: Request, deps: OAuthDeps):
645
725
  return issueAuthCodeRedirect(db, parsed, requestedScopes, session.userId, deps);
646
726
  }
647
727
 
728
+ // Multi-user Phase 1: non-admin users (with `assigned_vault !== null`) see
729
+ // the picker locked to their assigned vault — they can't pick a different
730
+ // one. Admin users (assigned_vault === null) see the full dropdown.
731
+ // Defensive null-coalesce: the session points at a deleted user shouldn't
732
+ // 500; treat as admin posture (the broader scope-validation gate will
733
+ // catch any actual privilege issue).
734
+ const user = getUserById(db, session.userId);
735
+ const lockedVault = user?.assignedVault ?? null;
736
+
648
737
  const manifest = (deps.loadServicesManifest ?? readServicesManifest)();
649
738
  const vaultNames = listVaultNames(manifest);
650
739
  return htmlResponse(
651
- renderConsent(consentProps(client, parsed, vaultNames, csrf.token)),
740
+ renderConsent(consentProps(client, parsed, vaultNames, csrf.token, lockedVault)),
652
741
  200,
653
742
  extra,
654
743
  );
@@ -784,17 +873,25 @@ async function handleConsentSubmit(
784
873
  }
785
874
  const client = getClient(db, params.clientId);
786
875
  if (!client) {
787
- return htmlError("Unknown application", "This client_id is not registered with this hub.", 400);
876
+ // Same shape as the GET handler see the comment there. Reaching
877
+ // this branch on the consent POST means the client_id was deleted
878
+ // between render and submit (vanishingly rare) or the form was
879
+ // hand-crafted; the recovery affordance is still the right answer
880
+ // for both cases.
881
+ return unknownClientResponse(params.clientId, params.redirectUri, deps);
788
882
  }
789
883
  if (client.status !== "approved") {
790
884
  // Defensive: consent only renders for approved clients, so a non-approved
791
885
  // status here means the row was unapproved between render and submit (or
792
886
  // the form was hand-crafted). The approve UI requires a known authorize
793
887
  // URL to round-trip via `return_to`, which we don't reconstruct here —
794
- // surface the static error and let the operator restart from the SPA.
888
+ // surface the static error pointing at the web approval path (the
889
+ // canonical recovery post-#277; rc.19 retired the CLI mention from
890
+ // every browser-visible surface so the path advertised here matches
891
+ // what the unauth GET-on-pending page now shows).
795
892
  return htmlError(
796
893
  "App not yet approved",
797
- `This client_id is registered but has not been approved. Run \`parachute auth approve-client ${client.clientId}\` from a terminal, then try again.`,
894
+ `This client_id is registered but has not been approved. Sign in as admin and approve at /admin/approve-client/${client.clientId}, or send the link to your hub operator.`,
798
895
  403,
799
896
  );
800
897
  }
@@ -828,6 +925,19 @@ async function handleConsentSubmit(
828
925
  params.state,
829
926
  );
830
927
  }
928
+ // Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md): non-admin
929
+ // users (`assigned_vault !== null`) are pinned to a single vault. The
930
+ // consent screen renders the picker locked to that vault and any named
931
+ // scopes (`vault:<name>:<verb>`) requested by the client must match. The
932
+ // server-side defense here refuses any mint where the user's submission
933
+ // disagrees — Aaron pinned this as "server-side defense refuses mints
934
+ // whose picked vault disagrees with assigned_vault" rather than silent
935
+ // overwrite, so a hand-crafted POST or a misbehaving SPA can't bypass the
936
+ // lock. Admin users (`assigned_vault === null`) keep the existing picker-
937
+ // as-source-of-truth behavior.
938
+ const sessionUser = getUserById(db, session.userId);
939
+ const assignedVault = sessionUser?.assignedVault ?? null;
940
+
831
941
  // Vault picker (Q1 of the vault-config-and-scopes design): an unnamed
832
942
  // `vault:<verb>` scope is ambiguous about which vault it grants access to.
833
943
  // Force the operator to pick before the JWT is minted, then rewrite the
@@ -852,8 +962,52 @@ async function handleConsentSubmit(
852
962
  400,
853
963
  );
854
964
  }
965
+ // Server-side defense: non-admin user submitted a vault that disagrees
966
+ // with their `assigned_vault`. The picker rendered as locked, so a UI-
967
+ // path user couldn't reach this — but a hand-crafted form bypassing the
968
+ // locked input lands here. Refuse the mint instead of silently
969
+ // overwriting; the explicit error tells the operator the assignment is
970
+ // load-bearing.
971
+ if (assignedVault !== null && pickedVault !== assignedVault) {
972
+ return htmlError(
973
+ "Vault assignment mismatch",
974
+ `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.`,
975
+ 400,
976
+ );
977
+ }
855
978
  scopes = narrowVaultScopes(scopes, pickedVault);
856
979
  }
980
+
981
+ // Server-side defense for named-vault scopes (`vault:<name>:<verb>`) too.
982
+ // A non-admin user can't request scope against any vault other than their
983
+ // assigned one — same invariant as the picker check above, applied to
984
+ // scopes that arrived already-named (e.g. a client that knows the user's
985
+ // vault and asked for `vault:bob:read` directly). Admins (assigned_vault
986
+ // null) skip this check.
987
+ if (assignedVault !== null) {
988
+ const mismatched: string[] = [];
989
+ for (const s of scopes) {
990
+ const parts = s.split(":");
991
+ if (
992
+ parts.length === 3 &&
993
+ parts[0] === "vault" &&
994
+ parts[1] &&
995
+ parts[2] &&
996
+ VAULT_VERBS.has(parts[2]) &&
997
+ parts[1] !== assignedVault
998
+ ) {
999
+ mismatched.push(s);
1000
+ }
1001
+ }
1002
+ if (mismatched.length > 0) {
1003
+ return htmlError(
1004
+ "Vault assignment mismatch",
1005
+ `vault_scope_mismatch: requested scopes ${mismatched.join(", ")} target a vault other than your vault assignment.`,
1006
+ 400,
1007
+ );
1008
+ }
1009
+ }
1010
+
857
1011
  // Record (or extend) the grant so the next /oauth/authorize for this
858
1012
  // (user, client) with these scopes — or any subset — can skip the consent
859
1013
  // screen (#75). UNION semantics: if the user previously granted [a, b, c]
@@ -1155,6 +1309,13 @@ async function handleTokenAuthorizationCode(
1155
1309
  audience,
1156
1310
  clientId: redeemed.clientId,
1157
1311
  issuer: deps.issuer,
1312
+ // vault_scope claim — Phase 1 per-user vault pin. Non-empty list for
1313
+ // non-admin users with `assigned_vault` set; empty for admin / unpinned.
1314
+ // The narrowing in `handleConsentSubmit` already rewrote `vault:<verb>` →
1315
+ // `vault:<assigned_vault>:<verb>` for non-admin users, so the auth code's
1316
+ // scopes are pre-aligned; this claim is the explicit "owned vault"
1317
+ // signal PR 5 consumes downstream.
1318
+ vaultScope: vaultScopeForUser(db, redeemed.userId),
1158
1319
  now: deps.now,
1159
1320
  });
1160
1321
  // Phase 1 (#212) registry exemption: code-grant access tokens piggyback
@@ -1268,6 +1429,14 @@ async function handleTokenRefresh(
1268
1429
  audience,
1269
1430
  clientId: row.clientId,
1270
1431
  issuer: deps.issuer,
1432
+ // vault_scope claim — re-derived from the user's *current*
1433
+ // `assigned_vault` at refresh time (not snapshotted onto the refresh-
1434
+ // token row). An admin who changes a user's `assigned_vault` between
1435
+ // mint and refresh sees the new value on the next refresh; existing
1436
+ // access tokens carry their original claim until their 15-minute TTL
1437
+ // elapses. Same posture as the design's "OAuth issuer reads
1438
+ // `assigned_vault` at mint time, not at session-creation time" pin.
1439
+ vaultScope: vaultScopeForUser(db, refreshUserId),
1271
1440
  now: deps.now,
1272
1441
  });
1273
1442
  let refresh: ReturnType<typeof signRefreshToken>;
@@ -1603,16 +1772,55 @@ function consentProps(
1603
1772
  params: AuthorizeFormParams,
1604
1773
  vaultNames: string[],
1605
1774
  csrfToken: string,
1775
+ lockedVault: string | null,
1606
1776
  ) {
1607
1777
  const scopes = params.scope.split(" ").filter((s) => s.length > 0);
1608
1778
  const unnamedVerbs = unnamedVaultVerbs(scopes);
1779
+ // Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md, decision-pin
1780
+ // "consent picker for non-admin users"): non-admin users (assigned_vault
1781
+ // non-null) see the picker locked to their `assigned_vault` rather than a
1782
+ // free dropdown. Phase 2 will hide other vaults entirely; Phase 1 ships
1783
+ // lock-the-picker (the smallest diff that satisfies "user can't pick a
1784
+ // vault they don't own"). Server-side defense in `handleConsentSubmit`
1785
+ // refuses mints whose POST disagrees regardless of how the picker is
1786
+ // rendered.
1787
+ let vaultPicker: VaultPickerProps | undefined;
1788
+ if (unnamedVerbs.length > 0) {
1789
+ vaultPicker =
1790
+ lockedVault !== null
1791
+ ? { unnamedVerbs, availableVaults: vaultNames, lockedVault }
1792
+ : { unnamedVerbs, availableVaults: vaultNames };
1793
+ }
1794
+ // Named-scope display: substitute unnamed `vault:<verb>` rows with the
1795
+ // resolved form the operator will actually consent to.
1796
+ // - Non-admin (lockedVault set) → render `vault:<lockedVault>:<verb>`.
1797
+ // - Admin with exactly one vault available → render that vault's name;
1798
+ // the picker pre-checks it and a default-Approve mints scope against
1799
+ // it, so the displayed scope matches the next state of the form.
1800
+ // - Admin with multiple vaults / no vaults → null sentinel; render with
1801
+ // a `<TBD>` placeholder + a hint pointing at the picker. Once the
1802
+ // operator clicks a radio the JS-free form still posts the chosen
1803
+ // name; the displayed scope only changes after the next page render.
1804
+ let displayVault: string | null = null;
1805
+ if (lockedVault !== null) {
1806
+ displayVault = lockedVault;
1807
+ } else if (unnamedVerbs.length > 0 && vaultNames.length === 1) {
1808
+ const only = vaultNames[0];
1809
+ if (only) displayVault = only;
1810
+ }
1609
1811
  return {
1610
1812
  params,
1611
1813
  clientId: client.clientId,
1612
1814
  clientName: client.clientName ?? client.clientId,
1613
1815
  scopes,
1614
1816
  csrfToken,
1615
- vaultPicker:
1616
- unnamedVerbs.length > 0 ? { unnamedVerbs, availableVaults: vaultNames } : undefined,
1817
+ vaultPicker,
1818
+ displayVault,
1617
1819
  };
1618
1820
  }
1821
+
1822
+ interface VaultPickerProps {
1823
+ unnamedVerbs: string[];
1824
+ availableVaults: string[];
1825
+ lockedVault?: string;
1826
+ }