@openparachute/hub 0.5.10-rc.6 → 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 (51) 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 +139 -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-server.test.ts +29 -4
  10. package/src/__tests__/hub-settings.test.ts +377 -0
  11. package/src/__tests__/hub.test.ts +17 -0
  12. package/src/__tests__/jwt-sign.test.ts +59 -0
  13. package/src/__tests__/oauth-handlers.test.ts +1059 -10
  14. package/src/__tests__/oauth-ui.test.ts +210 -0
  15. package/src/__tests__/scope-explanations.test.ts +23 -0
  16. package/src/__tests__/serve.test.ts +8 -1
  17. package/src/__tests__/setup-wizard.test.ts +1500 -13
  18. package/src/__tests__/supervisor.test.ts +76 -2
  19. package/src/__tests__/users.test.ts +196 -0
  20. package/src/__tests__/vault-name.test.ts +79 -0
  21. package/src/__tests__/vault-names.test.ts +172 -0
  22. package/src/account-change-password-ui.ts +379 -0
  23. package/src/admin-handlers.ts +68 -2
  24. package/src/admin-host-admin-token.ts +5 -0
  25. package/src/admin-vault-admin-token.ts +7 -0
  26. package/src/api-account.ts +443 -0
  27. package/src/api-mint-token.ts +6 -0
  28. package/src/api-modules-ops.ts +30 -6
  29. package/src/api-modules.ts +101 -0
  30. package/src/api-users.ts +393 -0
  31. package/src/commands/auth.ts +10 -1
  32. package/src/commands/serve.ts +5 -1
  33. package/src/cors.ts +263 -0
  34. package/src/hub-db.ts +54 -0
  35. package/src/hub-server.ts +162 -18
  36. package/src/hub-settings.ts +259 -0
  37. package/src/hub.ts +34 -9
  38. package/src/jwt-sign.ts +17 -1
  39. package/src/oauth-handlers.ts +256 -29
  40. package/src/oauth-ui.ts +451 -38
  41. package/src/operator-token.ts +4 -0
  42. package/src/scope-explanations.ts +26 -1
  43. package/src/setup-wizard.ts +1100 -56
  44. package/src/supervisor.ts +66 -14
  45. package/src/users.ts +210 -3
  46. package/src/vault-name.ts +71 -0
  47. package/src/vault-names.ts +57 -0
  48. package/web/ui/dist/assets/index-XhxYXDT5.js +61 -0
  49. package/web/ui/dist/assets/{index-D54otIhv.css → index-p6DkOcsk.css} +1 -1
  50. package/web/ui/dist/index.html +2 -2
  51. package/web/ui/dist/assets/index-AX_UHJ5e.js +0 -61
package/src/oauth-ui.ts CHANGED
@@ -82,6 +82,28 @@ export interface ConsentViewProps {
82
82
  * vault-config-and-scopes design — force the picker, don't default).
83
83
  */
84
84
  vaultPicker?: VaultPicker;
85
+ /**
86
+ * Named-vault display: substitute unnamed `vault:<verb>` rows with
87
+ * `vault:<displayVault>:<verb>` in the rendered scope list so the user
88
+ * sees the scope shape that will actually be minted into the token. The
89
+ * raw `scopes` value still drives the form-roundtrip; this only changes
90
+ * what the operator reads.
91
+ *
92
+ * - Non-admin user (assigned_vault set): pass the assigned vault name so
93
+ * the row reads `vault:my-vault:read`.
94
+ * - Admin user with a single vault available + the picker pre-checks the
95
+ * first option: pass that name so the displayed scope matches what the
96
+ * default-Approve will mint.
97
+ * - Admin user with multiple vaults / no obvious default: pass `null` (or
98
+ * omit) and the row renders as `vault:<TBD>:read` with a tooltip
99
+ * pointing at the picker. Same explanation either way.
100
+ *
101
+ * Closes the "raw scope display" leg of the approval-UX bug — silent
102
+ * narrowing at mint surprised operators who thought they were granting
103
+ * vault-wide access. Now they see the named form on the consent screen
104
+ * itself.
105
+ */
106
+ displayVault?: string | null;
85
107
  }
86
108
 
87
109
  export interface VaultPicker {
@@ -89,6 +111,20 @@ export interface VaultPicker {
89
111
  unnamedVerbs: string[];
90
112
  /** Vault names registered on this host. Empty → caller can't approve. */
91
113
  availableVaults: string[];
114
+ /**
115
+ * Multi-user Phase 1 (design 2026-05-20-multi-user-phase-1.md, decision-pin
116
+ * "consent picker for non-admin users"): set when the signed-in user has a
117
+ * non-null `assigned_vault`. The picker renders the vault name as a
118
+ * read-only label with an admin-managed note instead of the free dropdown
119
+ * — the user can't choose a different vault. The form still POSTs the
120
+ * locked value via `vault_pick` so the server-side defense in
121
+ * `handleConsentSubmit` (which refuses mints whose picked vault disagrees
122
+ * with the user's `assigned_vault`) sees an unambiguous value.
123
+ *
124
+ * Admin users (assigned_vault === null) leave this undefined and see the
125
+ * full dropdown — existing behavior.
126
+ */
127
+ lockedVault?: string;
92
128
  }
93
129
 
94
130
  export interface ErrorViewProps {
@@ -99,10 +135,22 @@ export interface ErrorViewProps {
99
135
 
100
136
  /**
101
137
  * Props for the "App not yet approved" view rendered when an unapproved
102
- * client lands on `/oauth/authorize`. When `session` is true the operator is
103
- * authenticated to this hub from the browser making the request, so we render
104
- * an inline approve form (closes #208). When false we fall back to the
105
- * pre-#208 CLI-only message.
138
+ * client lands on `/oauth/authorize`. Two-branch UI:
139
+ *
140
+ * - Authenticated admin (operator session present + same-origin) render
141
+ * the inline approve form (closes #208). One click flips the client to
142
+ * `approved` and re-enters the OAuth flow at consent.
143
+ * - Unauthenticated viewer — render TWO CTAs (no terminal mention):
144
+ * 1. Primary: "Sign in as admin to approve" → links to
145
+ * `/login?next=/admin/approve-client/<client_id>` so the admin
146
+ * lands directly on the approval page after sign-in.
147
+ * 2. Secondary: a fully-qualified shareable deep link to
148
+ * `<hub_origin>/admin/approve-client/<client_id>` with a copy
149
+ * button — the operator can send it to whoever runs the hub.
150
+ *
151
+ * The CLI fallback (`parachute auth approve-client <id>`) was retired —
152
+ * the web path is the path now. Operators who want the CLI still have it
153
+ * in the shell; we no longer point new users there from the browser.
106
154
  */
107
155
  export interface ApprovePendingViewProps {
108
156
  /** Display name to show — falls back to client_id when no name was supplied at DCR. */
@@ -119,6 +167,13 @@ export interface ApprovePendingViewProps {
119
167
  * #244). Single-vault hubs leave this absent and the section omits.
120
168
  */
121
169
  requestedVault?: string;
170
+ /**
171
+ * Fully-qualified hub origin used to build the shareable approval
172
+ * deep-link in the unauthenticated branch. Required because the link
173
+ * the operator copies needs to work when opened in a different browser
174
+ * session — only the absolute URL gets that.
175
+ */
176
+ hubOrigin: string;
122
177
  /**
123
178
  * When set, render the inline approve form. The form posts to
124
179
  * `/oauth/authorize/approve` with the CSRF token + a `return_to` URL the
@@ -165,14 +220,25 @@ export function renderLogin(props: LoginViewProps): string {
165
220
  }
166
221
 
167
222
  export function renderConsent(props: ConsentViewProps): string {
168
- const { params, clientName, clientId, scopes, vaultPicker, csrfToken } = props;
223
+ const { params, clientName, clientId, scopes, vaultPicker, csrfToken, displayVault } = props;
224
+ // Substitute unnamed `vault:<verb>` rows with the resolved named form so
225
+ // the operator sees the scope shape that will appear in the token. Raw
226
+ // `scopes` keeps the wire form for the hidden form fields; only what's
227
+ // rendered changes. See `ConsentViewProps.displayVault`.
228
+ const displayedScopes = scopes.map((s) => substituteVaultDisplay(s, displayVault));
169
229
  const scopeRows =
170
- scopes.length === 0
230
+ displayedScopes.length === 0
171
231
  ? `<li class="scope scope-empty">No scopes requested — the app gets a session token only.</li>`
172
- : scopes.map(renderScopeRow).join("\n");
232
+ : displayedScopes.map(renderScopeRow).join("\n");
173
233
  const pickerSection = vaultPicker ? renderVaultPicker(vaultPicker) : "";
234
+ // Approve is disabled when the picker can't yield a valid vault. The
235
+ // empty-vault branch (no vaults registered) is the original case. A
236
+ // locked-vault picker (multi-user Phase 1) always has a valid value via
237
+ // the hidden input, so Approve stays enabled.
174
238
  const approveDisabled =
175
- vaultPicker && vaultPicker.availableVaults.length === 0 ? " disabled" : "";
239
+ vaultPicker && vaultPicker.lockedVault === undefined && vaultPicker.availableVaults.length === 0
240
+ ? " disabled"
241
+ : "";
176
242
  const body = `
177
243
  <div class="card">
178
244
  <div class="card-header">
@@ -209,6 +275,32 @@ export function renderConsent(props: ConsentViewProps): string {
209
275
 
210
276
  function renderVaultPicker(picker: VaultPicker): string {
211
277
  const verbList = picker.unnamedVerbs.map((v) => `<code>vault:${escapeHtml(v)}</code>`).join(", ");
278
+
279
+ // Multi-user Phase 1: non-admin users see the picker locked to their
280
+ // `assigned_vault`. The form still posts via `vault_pick` so the
281
+ // server-side defense in `handleConsentSubmit` sees the value — but the
282
+ // user can't change it through the UI. A small `<input type=hidden>` and
283
+ // a read-only label is the smallest diff that ships the lock without
284
+ // disabling the broader form flow (Approve / Deny still work).
285
+ if (picker.lockedVault !== undefined) {
286
+ const locked = escapeHtml(picker.lockedVault);
287
+ return `
288
+ <section class="vault-picker vault-picker-locked">
289
+ <h2 class="scopes-title">Vault</h2>
290
+ <p class="picker-help">
291
+ ${verbList} apply to your assigned vault.
292
+ </p>
293
+ <div class="vault-locked-row">
294
+ <code class="vault-locked-name">${locked}</code>
295
+ <span class="vault-locked-badge">Assigned</span>
296
+ </div>
297
+ <p class="vault-locked-note">
298
+ Assigned vault — admin-managed; you can't change this here.
299
+ </p>
300
+ <input type="hidden" name="vault_pick" value="${locked}" />
301
+ </section>`;
302
+ }
303
+
212
304
  if (picker.availableVaults.length === 0) {
213
305
  return `
214
306
  <section class="vault-picker vault-picker-empty">
@@ -240,21 +332,32 @@ function renderVaultPicker(picker: VaultPicker): string {
240
332
  }
241
333
 
242
334
  /**
243
- * "App not yet approved" page (#74). When the request carries a valid
244
- * operator session (#208), render the inline approve form so one click lands
245
- * the client as `approved` and re-enters the OAuth flow at consent. Without
246
- * a session, fall back to the original CLI-only message — anyone hitting
247
- * /oauth/authorize unauthenticated to the hub itself can't be trusted to
248
- * approve a DCR client from the browser, so they need to drop to a terminal
249
- * and run `parachute auth approve-client <id>`.
335
+ * "App not yet approved" page (#74). Two branches:
250
336
  *
251
- * The CLI fallback hint is shown in BOTH branches: a button-equipped operator
252
- * may still want the CLI invocation handy (different machine, scriptable
253
- * context). The button is the easy path; the CLI is always-available.
337
+ * - **Authenticated operator with same-origin posture** (#208): render the
338
+ * inline approve form so one click flips the client to `approved` and
339
+ * re-enters the OAuth flow at consent.
340
+ * - **Unauthenticated viewer** (Issue 1 in the approval-UX PR): render a
341
+ * primary "Sign in as admin to approve" CTA wired to
342
+ * `/login?next=/admin/approve-client/<id>` so the admin lands directly
343
+ * on the approval page after sign-in, plus a secondary shareable
344
+ * deep-link section (fully-qualified `<hub_origin>/admin/approve-client/<id>`
345
+ * in a code block + Copy-to-clipboard button). The pre-rc.19 CLI hint
346
+ * ("ask the operator to run `parachute auth approve-client <id>`") was
347
+ * retired — the web path is the path now. CLI is still available for
348
+ * terminal-first operators who already know it; we just stop pointing
349
+ * new users there from a browser they're already in.
254
350
  */
255
351
  export function renderApprovePending(props: ApprovePendingViewProps): string {
256
- const { clientName, clientId, redirectUris, requestedScopes, requestedVault, approveForm } =
257
- props;
352
+ const {
353
+ clientName,
354
+ clientId,
355
+ redirectUris,
356
+ requestedScopes,
357
+ requestedVault,
358
+ hubOrigin,
359
+ approveForm,
360
+ } = props;
258
361
  const redirectList = redirectUris.map((u) => `<li><code>${escapeHtml(u)}</code></li>`).join("");
259
362
  const scopeRows =
260
363
  requestedScopes.length === 0
@@ -279,15 +382,8 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
279
382
  <input type="hidden" name="client_id" value="${escapeHtml(clientId)}" />
280
383
  <input type="hidden" name="return_to" value="${escapeHtml(approveForm.returnTo)}" />
281
384
  <button type="submit" class="btn btn-primary">Approve and continue</button>
282
- </form>
283
- <p class="approve-cli-hint">
284
- Or run <code>parachute auth approve-client ${escapeHtml(clientId)}</code> from a terminal.
285
- </p>`
286
- : `
287
- <p class="approve-cli-hint">
288
- Ask the operator to run <code>parachute auth approve-client ${escapeHtml(clientId)}</code>
289
- from a terminal, then try again.
290
- </p>`;
385
+ </form>`
386
+ : renderUnauthenticatedApproveCtas(hubOrigin, clientId);
291
387
  const body = `
292
388
  <div class="card">
293
389
  <div class="card-header">
@@ -325,6 +421,111 @@ export function renderApprovePending(props: ApprovePendingViewProps): string {
325
421
  return baseDocument("App not yet approved", body);
326
422
  }
327
423
 
424
+ /**
425
+ * Unauthenticated branch of `renderApprovePending`. Two CTAs:
426
+ *
427
+ * 1. Primary: "Sign in as admin to approve" → links to
428
+ * `/login?next=/admin/approve-client/<client_id>` so the admin lands
429
+ * on the approval page after sign-in.
430
+ * 2. Secondary: a fully-qualified shareable deep-link to
431
+ * `<hub_origin>/admin/approve-client/<client_id>` with a Copy button
432
+ * so the operator can send it to whoever runs the hub.
433
+ *
434
+ * Inline JS is scoped to the Copy button only — `navigator.clipboard.writeText`
435
+ * with a brief "Copied!" affordance. The button degrades gracefully when
436
+ * scripting is unavailable (the URL is still selectable + copyable from the
437
+ * `<code>` block via the OS clipboard).
438
+ */
439
+ function renderUnauthenticatedApproveCtas(hubOrigin: string, clientId: string): string {
440
+ const approvalPath = `/admin/approve-client/${encodeURIComponent(clientId)}`;
441
+ const loginHref = `/login?next=${encodeURIComponent(approvalPath)}`;
442
+ const trimmedOrigin = hubOrigin.replace(/\/+$/, "");
443
+ const deepLink = `${trimmedOrigin}${approvalPath}`;
444
+ return `
445
+ <div class="approve-actions">
446
+ <a href="${escapeHtml(loginHref)}" class="btn btn-primary approve-signin-cta">
447
+ Sign in as admin to approve
448
+ </a>
449
+ </div>
450
+ <section class="approve-share">
451
+ <h2 class="scopes-title">Or send this link to your hub admin</h2>
452
+ <p class="approve-share-help">
453
+ Anyone with admin access on this hub can open the link below to approve the app.
454
+ </p>
455
+ <div class="approve-share-row">
456
+ <code class="approve-share-link" id="approve-share-link">${escapeHtml(deepLink)}</code>
457
+ <button
458
+ type="button"
459
+ class="btn btn-secondary approve-share-copy"
460
+ id="approve-share-copy"
461
+ data-link="${escapeHtml(deepLink)}"
462
+ >Copy link</button>
463
+ </div>
464
+ </section>
465
+ <script>
466
+ (function () {
467
+ var btn = document.getElementById('approve-share-copy');
468
+ if (!btn) return;
469
+ var defaultLabel = btn.textContent;
470
+ btn.addEventListener('click', function () {
471
+ var link = btn.dataset.link || '';
472
+ var done = function (ok) {
473
+ btn.textContent = ok ? 'Copied!' : 'Copy failed';
474
+ setTimeout(function () { btn.textContent = defaultLabel; }, 1600);
475
+ };
476
+ if (navigator.clipboard && navigator.clipboard.writeText) {
477
+ navigator.clipboard.writeText(link).then(function () { done(true); }, function () { done(false); });
478
+ } else {
479
+ // Fallback for browsers without async clipboard (older Safari,
480
+ // sandboxed iframes). Select the code block so the user can
481
+ // hit cmd/ctrl-C to copy manually.
482
+ try {
483
+ var range = document.createRange();
484
+ range.selectNode(document.getElementById('approve-share-link'));
485
+ var sel = window.getSelection();
486
+ if (sel) { sel.removeAllRanges(); sel.addRange(range); }
487
+ done(true);
488
+ } catch (e) {
489
+ done(false);
490
+ }
491
+ }
492
+ });
493
+ })();
494
+ </script>`;
495
+ }
496
+
497
+ /**
498
+ * Substitute an unnamed `vault:<verb>` scope with the resolved named form
499
+ * (`vault:<displayVault>:<verb>`) so consent / approval screens render
500
+ * what'll actually appear in the token rather than the raw OAuth request.
501
+ *
502
+ * - `displayVault === undefined` → no substitution (keep input as-is).
503
+ * Use this when the caller doesn't know what vault will be picked yet
504
+ * and prefers the raw OAuth form. (Currently unused; reserved for the
505
+ * pre-narrowing approve flow where the vault isn't known until consent.)
506
+ * - `displayVault === null` → render with a `<TBD>` placeholder. Used on
507
+ * the admin consent screen when the picker hasn't been touched yet, and
508
+ * on the operator approval page where the vault is selected at the
509
+ * per-user sign-in step, not at approve time.
510
+ * - `displayVault === "name"` → render as `vault:name:verb` literally.
511
+ *
512
+ * Non-vault scopes pass through untouched. Already-named `vault:<x>:<verb>`
513
+ * scopes also pass through — the OAuth request already specified a vault,
514
+ * so there's nothing to resolve.
515
+ */
516
+ export function substituteVaultDisplay(
517
+ scope: string,
518
+ displayVault: string | null | undefined,
519
+ ): string {
520
+ if (displayVault === undefined) return scope;
521
+ const parts = scope.split(":");
522
+ if (parts.length !== 2 || parts[0] !== "vault") return scope;
523
+ const verb = parts[1];
524
+ if (verb !== "read" && verb !== "write") return scope;
525
+ const vaultLabel = displayVault === null ? "<TBD>" : displayVault;
526
+ return `vault:${vaultLabel}:${verb}`;
527
+ }
528
+
328
529
  export function renderError(props: ErrorViewProps): string {
329
530
  const body = `
330
531
  <div class="card">
@@ -344,8 +545,127 @@ export function renderError(props: ErrorViewProps): string {
344
545
  return baseDocument(props.title, body);
345
546
  }
346
547
 
548
+ export interface UnknownClientViewProps {
549
+ /** The unknown client_id the request carried. Surfaced verbatim for debugging. */
550
+ clientId: string;
551
+ /**
552
+ * The redirect_uri the request carried, when present + parseable as
553
+ * `<one-of-our-bound-origins>/<path>`. Triggers the inline "Reset
554
+ * connection" affordance: the page emits a tiny JS snippet that clears
555
+ * the requesting SPA's DCR localStorage cache (any key prefixed
556
+ * `lens:dcr:`) on our own origin, then navigates to the redirect_uri's
557
+ * mount path so the SPA picks up a fresh DCR.
558
+ *
559
+ * Set to `null` when the redirect_uri is missing, malformed, or points
560
+ * at an origin we don't serve — those reach a third-party SPA we can't
561
+ * safely interact with from our DOM and we fall back to the static
562
+ * error variant.
563
+ */
564
+ selfOriginRedirectPath: string | null;
565
+ }
566
+
567
+ /**
568
+ * "Unknown application" page rendered when `/oauth/authorize` receives a
569
+ * `client_id` that's not in the hub's `clients` table. The single most-
570
+ * reported cause is a stale localStorage entry on the SPA side: the
571
+ * operator wiped `~/.parachute/hub.db` between testing iterations, but the
572
+ * browser still holds the old hub's DCR-cached client_id and keeps using
573
+ * it. The hub's behaviour is correct (reject unknown client_id, never
574
+ * grant an authorize request against an unregistered client) but the
575
+ * operator is stranded — they need to clear the SPA's cache and the SPA
576
+ * has no signal to do that on its own.
577
+ *
578
+ * Recovery affordance: when the redirect_uri points at an origin the hub
579
+ * itself serves (any entry in `hubBoundOrigins`), the page renders a
580
+ * "Reset connection" button. The button runs an inline JS snippet that
581
+ * clears every `lens:dcr:*` key from localStorage on the hub's own origin
582
+ * — since Notes (and any future Parachute SPA) is mounted at the hub's
583
+ * origin, they share localStorage with this error page — then navigates
584
+ * back to the redirect_uri's mount path. The SPA loads fresh, finds no
585
+ * cached client_id, and runs a brand-new DCR against the current hub.
586
+ *
587
+ * `lens:dcr:` is intentionally hardcoded: Notes' storage layer uses that
588
+ * prefix (see `parachute-notes/src/lib/vault/storage.ts`'s `DCR_PREFIX`).
589
+ * Future SPAs that follow the same hub-origin-mounted shape would need
590
+ * their prefix added here, or we extend the snippet to clear any key
591
+ * matching `.*:dcr:.*`. Today it's just Notes.
592
+ *
593
+ * No JS used outside the optional reset button — the static parts stay
594
+ * form-free + accessible to readers/screen-readers without script.
595
+ */
596
+ export function renderUnknownClient(props: UnknownClientViewProps): string {
597
+ const safeClientId = escapeHtml(props.clientId);
598
+ const resetSection =
599
+ props.selfOriginRedirectPath !== null
600
+ ? `
601
+ <p>Most often this means the app's local connection state was saved
602
+ against a previous installation of this hub. Resetting the
603
+ connection clears the stale state and lets the app register
604
+ afresh.</p>
605
+ <div class="unknown-client-actions">
606
+ <button type="button" class="btn btn-primary" id="unknown-client-reset"
607
+ data-target="${escapeHtml(props.selfOriginRedirectPath)}">Reset connection &amp; reload</button>
608
+ </div>
609
+ <p class="fine">If the button doesn't help, clear site data for this
610
+ hub in your browser and reload the app.</p>
611
+ <script>
612
+ (function () {
613
+ var btn = document.getElementById('unknown-client-reset');
614
+ if (!btn) return;
615
+ btn.addEventListener('click', function () {
616
+ try {
617
+ // Notes (and other Parachute SPAs mounted at this hub's
618
+ // origin) cache DCR client_ids under the 'lens:dcr:' key
619
+ // prefix. Clear them all — a stale entry against a wiped
620
+ // hub.db is the canonical cause of this error.
621
+ var keys = [];
622
+ for (var i = 0; i < localStorage.length; i++) {
623
+ var k = localStorage.key(i);
624
+ if (k && k.indexOf('lens:dcr:') === 0) keys.push(k);
625
+ }
626
+ keys.forEach(function (k) { localStorage.removeItem(k); });
627
+ } catch (e) {
628
+ // localStorage may be unavailable (private mode, sandbox).
629
+ // The redirect still happens — the SPA will try DCR again
630
+ // and either succeed or surface its own diagnostic.
631
+ }
632
+ var target = btn.dataset.target || '/';
633
+ window.location.assign(target);
634
+ });
635
+ })();
636
+ </script>`
637
+ : `
638
+ <p class="error-help">
639
+ If you reached this from a third-party app, the app's OAuth
640
+ configuration may be wrong. You can safely close this window.
641
+ </p>`;
642
+ const body = `
643
+ <div class="card">
644
+ <div class="card-header">
645
+ <div class="brand">
646
+ <span class="brand-mark">⌬</span>
647
+ <span class="brand-name">Parachute</span>
648
+ </div>
649
+ <h1 class="error-title">Unknown application</h1>
650
+ <p class="subtitle">
651
+ This <code>client_id</code> is not registered with this hub:
652
+ <code>${safeClientId}</code>.
653
+ </p>
654
+ </div>
655
+ ${resetSection}
656
+ </div>`;
657
+ return baseDocument("Unknown application", body);
658
+ }
659
+
347
660
  function renderScopeRow(scope: string): string {
348
- const explanation = explainScope(scope);
661
+ // Special-case the `<TBD>` placeholder substituted by `substituteVaultDisplay`
662
+ // when the consent picker hasn't bound a vault yet — `explainScope` doesn't
663
+ // match it because `<` / `>` aren't in the vault-name charset, but the
664
+ // canonical verb-form does. Look up by the unnamed verb form so the
665
+ // explanation + level styling are still correct.
666
+ const tbdMatch = scope.match(/^vault:<TBD>:(read|write)$/);
667
+ const lookup = tbdMatch ? `vault:${tbdMatch[1]}` : scope;
668
+ const explanation = explainScope(lookup);
349
669
  if (!explanation) {
350
670
  return `<li class="scope scope-unknown">
351
671
  <code class="scope-name">${escapeHtml(scope)}</code>
@@ -354,12 +674,21 @@ function renderScopeRow(scope: string): string {
354
674
  }
355
675
  const cls = `scope scope-${explanation.level}`;
356
676
  const badge = badgeForLevel(explanation);
677
+ // Pending-vault hint surfaces the silent-narrowing semantics for admin
678
+ // operators who land on the consent screen before touching the picker.
679
+ // Once they pick, the form submission narrows the scope to the chosen
680
+ // vault — the rendered placeholder reflects that the vault is still
681
+ // open at this moment in the flow.
682
+ const pendingNote = tbdMatch
683
+ ? `<span class="scope-pending-note">A specific vault is picked below before approving.</span>`
684
+ : "";
357
685
  return `<li class="${cls}">
358
686
  <div class="scope-head">
359
687
  <code class="scope-name">${escapeHtml(scope)}</code>
360
688
  ${badge}
361
689
  </div>
362
690
  <span class="scope-label">${escapeHtml(explanation.label)}</span>
691
+ ${pendingNote}
363
692
  </li>`;
364
693
  }
365
694
 
@@ -561,6 +890,16 @@ const STYLES = `
561
890
  color: ${PALETTE.fgMuted};
562
891
  font-size: 0.88rem;
563
892
  }
893
+ .unknown-client-actions {
894
+ margin: 1.25rem 0 0.5rem;
895
+ display: flex;
896
+ flex-direction: column;
897
+ gap: 0.5rem;
898
+ }
899
+ .fine {
900
+ color: ${PALETTE.fgMuted};
901
+ font-size: 0.85rem;
902
+ }
564
903
 
565
904
  .scopes { margin: 0 0 1.5rem; }
566
905
  .scopes-title {
@@ -662,6 +1001,40 @@ const STYLES = `
662
1001
  }
663
1002
  .vault-picker-empty .picker-help { color: ${PALETTE.danger}; }
664
1003
  .vault-picker-empty .picker-help code { color: ${PALETTE.fg}; }
1004
+ .vault-picker-locked .picker-help { color: ${PALETTE.fgMuted}; }
1005
+ .vault-locked-row {
1006
+ display: flex;
1007
+ align-items: center;
1008
+ gap: 0.5rem;
1009
+ padding: 0.5rem 0.6rem;
1010
+ border: 1px solid ${PALETTE.border};
1011
+ border-radius: 6px;
1012
+ background: ${PALETTE.cardBg};
1013
+ margin: 0.25rem 0 0.5rem;
1014
+ }
1015
+ .vault-locked-name {
1016
+ font-family: ${FONT_MONO};
1017
+ font-size: 0.9rem;
1018
+ color: ${PALETTE.fg};
1019
+ flex: 1;
1020
+ }
1021
+ .vault-locked-badge {
1022
+ display: inline-block;
1023
+ font-size: 0.68rem;
1024
+ text-transform: uppercase;
1025
+ letter-spacing: 0.06em;
1026
+ font-weight: 600;
1027
+ padding: 0.1rem 0.5rem;
1028
+ border-radius: 999px;
1029
+ background: ${PALETTE.accentSoft};
1030
+ color: ${PALETTE.accent};
1031
+ }
1032
+ .vault-locked-note {
1033
+ margin: 0;
1034
+ font-size: 0.82rem;
1035
+ color: ${PALETTE.fgDim};
1036
+ font-style: italic;
1037
+ }
665
1038
 
666
1039
  .approve-meta {
667
1040
  margin: 0 0 1.25rem;
@@ -713,23 +1086,63 @@ const STYLES = `
713
1086
  word-break: break-all;
714
1087
  }
715
1088
  .approve-form { gap: 0; }
716
- .approve-cli-hint {
1089
+ .approve-actions {
717
1090
  margin-top: 1rem;
718
- padding-top: 0.85rem;
1091
+ display: flex;
1092
+ flex-direction: column;
1093
+ gap: 0.5rem;
1094
+ }
1095
+ .approve-signin-cta {
1096
+ display: inline-block;
1097
+ text-align: center;
1098
+ text-decoration: none;
1099
+ line-height: 1.4;
1100
+ }
1101
+ .approve-signin-cta:hover {
1102
+ background: ${PALETTE.accentHover};
1103
+ color: ${PALETTE.cardBg};
1104
+ }
1105
+ .approve-share {
1106
+ margin-top: 1.5rem;
1107
+ padding-top: 1.25rem;
719
1108
  border-top: 1px solid ${PALETTE.borderLight};
1109
+ }
1110
+ .approve-share-help {
1111
+ margin: 0 0 0.6rem;
720
1112
  color: ${PALETTE.fgMuted};
721
- font-size: 0.85rem;
1113
+ font-size: 0.88rem;
722
1114
  }
723
- .approve-cli-hint code {
1115
+ .approve-share-row {
1116
+ display: flex;
1117
+ gap: 0.5rem;
1118
+ align-items: stretch;
1119
+ flex-wrap: wrap;
1120
+ }
1121
+ .approve-share-link {
1122
+ flex: 1;
1123
+ min-width: 0;
724
1124
  font-family: ${FONT_MONO};
725
- font-size: 0.8rem;
1125
+ font-size: 0.82rem;
726
1126
  background: ${PALETTE.bgSoft};
727
- padding: 0.1rem 0.4rem;
728
- border-radius: 4px;
1127
+ padding: 0.55rem 0.65rem;
1128
+ border-radius: 6px;
729
1129
  color: ${PALETTE.fg};
730
1130
  word-break: break-all;
1131
+ border: 1px solid ${PALETTE.border};
1132
+ }
1133
+ .approve-share-copy {
1134
+ flex-shrink: 0;
1135
+ min-height: 0;
1136
+ padding: 0.5rem 0.9rem;
1137
+ font-size: 0.85rem;
1138
+ }
1139
+ .scope-pending-note {
1140
+ display: block;
1141
+ margin-top: 0.25rem;
1142
+ font-size: 0.78rem;
1143
+ color: ${PALETTE.fgDim};
1144
+ font-style: italic;
731
1145
  }
732
-
733
1146
  .badge {
734
1147
  display: inline-block;
735
1148
  font-size: 0.7rem;
@@ -160,6 +160,10 @@ export async function mintOperatorToken(
160
160
  clientId: OPERATOR_TOKEN_CLIENT_ID,
161
161
  issuer: opts.issuer,
162
162
  ttlSeconds: opts.ttlSeconds ?? OPERATOR_TOKEN_TTL_SECONDS,
163
+ // Operator token — the hub-operator bearer. No per-user vault pin; the
164
+ // operator can act against any vault in this hub. Empty `vault_scope`
165
+ // is the "no restriction" sentinel (matches the admin OAuth shape).
166
+ vaultScope: [],
163
167
  extraClaims: { [OPERATOR_TOKEN_SCOPE_SET_CLAIM]: scopeSet },
164
168
  ...(opts.jti !== undefined ? { jti: opts.jti } : {}),
165
169
  ...(opts.now !== undefined ? { now: opts.now } : {}),
@@ -159,8 +159,33 @@ export function isRequestableScope(scope: string): boolean {
159
159
  return !isNonRequestableScope(scope);
160
160
  }
161
161
 
162
+ /**
163
+ * Recognize narrowed vault scopes (`vault:<name>:<verb>`) and the wildcard
164
+ * display form (`vault:*:<verb>`) — both render with the same explanation as
165
+ * the corresponding unnamed `vault:<verb>` row, since they describe the same
166
+ * permission scoped to a specific (or unspecified-at-mint-time) vault.
167
+ *
168
+ * The hub narrows unnamed `vault:read` → `vault:<name>:read` at consent /
169
+ * token-mint via the picker (Q1 of the vault-config-and-scopes design); the
170
+ * consent screen now surfaces that narrowed form so the user sees the scope
171
+ * shape that will appear in the token. `vault:*:read` is the display-only
172
+ * shape we use on the operator approval page where no per-user vault has
173
+ * been selected yet (a specific vault is chosen during sign-in).
174
+ *
175
+ * Verb-only — admin verbs on a per-vault basis (`vault:<name>:admin`) are
176
+ * `NON_REQUESTABLE_SCOPES` by policy and never reach the consent screen, so
177
+ * we don't substitute for them here. Read / write get the matching label.
178
+ */
179
+ const VAULT_VERB_RE = /^vault:[a-zA-Z0-9_*-]+:(read|write)$/;
180
+
162
181
  export function explainScope(scope: string): ScopeExplanation | null {
163
- return SCOPE_EXPLANATIONS[scope] ?? null;
182
+ const direct = SCOPE_EXPLANATIONS[scope];
183
+ if (direct) return direct;
184
+ if (VAULT_VERB_RE.test(scope)) {
185
+ const verb = scope.split(":")[2] as "read" | "write";
186
+ return SCOPE_EXPLANATIONS[`vault:${verb}`] ?? null;
187
+ }
188
+ return null;
164
189
  }
165
190
 
166
191
  export function scopeIsAdmin(scope: string): boolean {