@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
@@ -39,14 +39,24 @@
39
39
 
40
40
  import type { Database } from "bun:sqlite";
41
41
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
42
- import type { CuratedModuleShort } from "./api-modules.ts";
42
+ import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
43
43
  import {
44
44
  CSRF_FIELD_NAME,
45
45
  ensureCsrfToken,
46
46
  renderCsrfHiddenInput,
47
47
  verifyCsrfToken,
48
48
  } from "./csrf.ts";
49
+ import {
50
+ SETUP_EXPOSE_MODES,
51
+ type SetupExposeMode,
52
+ deleteSetting,
53
+ getSetting,
54
+ isSetupExposeMode,
55
+ openFirstClientAutoApproveWindow,
56
+ setSetting,
57
+ } from "./hub-settings.ts";
49
58
  import { escapeHtml } from "./oauth-ui.ts";
59
+ import { mintOperatorToken } from "./operator-token.ts";
50
60
  import { isHttpsRequest } from "./request-protocol.ts";
51
61
  import { findService, readManifest } from "./services-manifest.ts";
52
62
  import {
@@ -57,6 +67,7 @@ import {
57
67
  } from "./sessions.ts";
58
68
  import type { Supervisor } from "./supervisor.ts";
59
69
  import { createUser, userCount } from "./users.ts";
70
+ import { DEFAULT_VAULT_NAME, validateVaultName } from "./vault-name.ts";
60
71
 
61
72
  // --- shared chrome --------------------------------------------------------
62
73
 
@@ -88,7 +99,15 @@ function escapeAttr(s: string): string {
88
99
 
89
100
  // --- state derivation ----------------------------------------------------
90
101
 
91
- export type WizardStep = "welcome" | "account" | "vault" | "done";
102
+ /**
103
+ * Wizard steps. `"account"` is a visual-only entry in the progress
104
+ * header — it shares a screen with `"welcome"` (the combined welcome +
105
+ * account form), and `deriveWizardState` never returns it: a welcome
106
+ * POST creates the admin and advances directly to `"vault"`. Kept in
107
+ * the union so the progress bar can render it as a distinct dot for
108
+ * display continuity.
109
+ */
110
+ export type WizardStep = "welcome" | "account" | "vault" | "expose" | "done";
92
111
 
93
112
  export interface DerivedWizardState {
94
113
  /** Current step the wizard should render. */
@@ -97,6 +116,13 @@ export interface DerivedWizardState {
97
116
  hasAdmin: boolean;
98
117
  /** Whether the first vault (curated) has been provisioned in services.json. */
99
118
  hasVault: boolean;
119
+ /**
120
+ * Whether the operator has answered the "how will this hub be reached?"
121
+ * question (the expose step, hub#268 Item 2). When admin + vault both
122
+ * exist but the operator hasn't picked an expose mode yet, the wizard
123
+ * renders the expose step rather than the done screen.
124
+ */
125
+ hasExposeMode: boolean;
100
126
  }
101
127
 
102
128
  /**
@@ -121,11 +147,19 @@ export function deriveWizardState(deps: {
121
147
  const vaultSpec = specFor(FIRST_VAULT_SHORT);
122
148
  const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
123
149
  const hasVault = vaultEntry !== undefined;
150
+ // Expose-mode is the operator's "how will this hub be reached?" answer
151
+ // (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
152
+ // sets it; absence means we should still ask.
153
+ const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
124
154
  let step: WizardStep;
155
+ // Note: `"account"` is a visual-only step in the progress header —
156
+ // welcome's POST creates the admin and advances directly to `"vault"`,
157
+ // so we never return `"account"` here.
125
158
  if (!hasAdmin) step = "welcome";
126
159
  else if (!hasVault) step = "vault";
160
+ else if (!hasExposeMode) step = "expose";
127
161
  else step = "done";
128
- return { step, hasAdmin, hasVault };
162
+ return { step, hasAdmin, hasVault, hasExposeMode };
129
163
  }
130
164
 
131
165
  // --- handler types -------------------------------------------------------
@@ -184,7 +218,7 @@ ${body}
184
218
  }
185
219
 
186
220
  function header(currentStep: WizardStep): string {
187
- const stepOrder: WizardStep[] = ["welcome", "account", "vault", "done"];
221
+ const stepOrder: WizardStep[] = ["welcome", "account", "vault", "expose", "done"];
188
222
  // Step 1 (welcome) + step 2 (account) collapse on the rendered page —
189
223
  // we show them as a single combined form. The progress bar still names
190
224
  // them separately so the operator sees the shape.
@@ -192,6 +226,7 @@ function header(currentStep: WizardStep): string {
192
226
  welcome: "Welcome",
193
227
  account: "Account",
194
228
  vault: "Vault",
229
+ expose: "Expose",
195
230
  done: "Done",
196
231
  };
197
232
  const items = stepOrder
@@ -283,6 +318,8 @@ export function renderAccountStep(props: RenderAccountStepProps): string {
283
318
  export interface RenderVaultStepProps {
284
319
  csrfToken: string;
285
320
  errorMessage?: string;
321
+ /** Pre-fill the vault name input after a validation failure. */
322
+ vaultName?: string;
286
323
  /**
287
324
  * When an install op is in progress, render the polling shape: no
288
325
  * form, just the op log + auto-refresh.
@@ -296,19 +333,21 @@ export interface RenderVaultStepProps {
296
333
  }
297
334
 
298
335
  export function renderVaultStep(props: RenderVaultStepProps): string {
299
- const { csrfToken, errorMessage, operation } = props;
336
+ const { csrfToken, errorMessage, operation, vaultName } = props;
300
337
  if (operation) return renderVaultOpStep({ operation });
301
338
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
302
- // hub#259 / hub#267: the first vault is hard-named "default" for now.
303
- // The CLI threads `--vault-name` through `parachute-vault init`, which
304
- // the wizard's container-mode `runInstall` doesn't run. Wiring the
305
- // operator's typed name end-to-end requires either a new `init` step
306
- // in `runInstall` or upstream changes in @openparachute/vault so it
307
- // reads `PARACHUTE_VAULT_NAME` (or services.json paths) on first
308
- // boot. Both are bigger than fits in this PR — tracked in hub#267.
309
- // For now: show what's actually being created, no form field, no
310
- // UX lie. The operator renames via the admin UI once the wizard
311
- // hands them off.
339
+ // hub#267: the typed name now flows end-to-end via
340
+ // `PARACHUTE_VAULT_NAME`. Vault#342 added the env var read on
341
+ // first-boot hub spawns vault with the env var set and vault's
342
+ // `resolveFirstBootVaultName` picks it up. The wizard's job here is
343
+ // to ask + validate + persist the choice; the supervised vault child
344
+ // does the rest.
345
+ //
346
+ // Leaving the field blank falls back to `default` server-side
347
+ // matches the prior shape so no-input + Submit still works for the
348
+ // "I don't care, just give me a vault" path.
349
+ const nameAttr = vaultName !== undefined ? ` value="${escapeAttr(vaultName)}"` : "";
350
+ const previewName = vaultName?.trim() ? escapeHtml(vaultName.trim()) : DEFAULT_VAULT_NAME;
312
351
  const body = `
313
352
  <div class="card">
314
353
  <div class="card-header">
@@ -321,7 +360,7 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
321
360
  <section class="explainer">
322
361
  <h2>Why this step</h2>
323
362
  <p>The wizard provisions a vault module at the path
324
- <code>/vault/default</code> and issues you an operator token —
363
+ <code>/vault/&lt;name&gt;</code> and issues you an operator token —
325
364
  the same shape <code>parachute install vault</code> produces from
326
365
  the CLI. We're doing both in one click.</p>
327
366
  <h2>What's next</h2>
@@ -333,20 +372,29 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
333
372
  <p class="preview-label">About to create</p>
334
373
  <div class="preview-card">
335
374
  <span class="preview-key">vault:</span>
336
- <span class="preview-val" id="preview-vault-name">default</span>
375
+ <span class="preview-val" id="preview-vault-name">${previewName}</span>
337
376
  <span class="preview-fine">— admin: you, MCP-ready for Claude Code</span>
338
377
  </div>
339
378
  <p class="preview-fine">
340
- The vault is named <code>default</code> on first boot. Custom
341
- names on the wizard are tracked in
342
- <a href="https://github.com/ParachuteComputer/parachute-hub/issues/267">hub#267</a> —
343
- for now, rename or add vaults from the admin UI after setup.
379
+ The name shows up in the MCP URL (<code>/vault/&lt;name&gt;/mcp</code>)
380
+ and on the admin UI. You can rename or add vaults later from
381
+ <code>/admin/vaults</code>.
344
382
  </p>
345
383
  </section>
346
384
  ${error}
347
385
  <form method="POST" action="/admin/setup/vault" class="auth-form">
348
386
  ${renderCsrfHiddenInput(csrfToken)}
349
- <button type="submit" class="btn btn-primary" autofocus>Create vault & finish</button>
387
+ <label class="field">
388
+ <span class="field-label">Vault name</span>
389
+ <input type="text" name="vault_name"
390
+ autofocus minlength="2" maxlength="32"
391
+ pattern="[a-z0-9_-]+"
392
+ title="lowercase letters, digits, hyphens, underscores (2–32 chars)"
393
+ placeholder="${DEFAULT_VAULT_NAME}"${nameAttr} />
394
+ <span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
395
+ 2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
396
+ </label>
397
+ <button type="submit" class="btn btn-primary">Create vault & finish</button>
350
398
  </form>
351
399
  </div>`;
352
400
  return baseDocument("Set up your Parachute hub — vault", body);
@@ -397,17 +445,166 @@ function renderVaultOpStep(props: {
397
445
  return baseDocument("Set up your Parachute hub — vault", body, refresh);
398
446
  }
399
447
 
400
- // --- step 4: done --------------------------------------------------------
448
+ // --- step 4: expose ------------------------------------------------------
449
+
450
+ export interface RenderExposeStepProps {
451
+ csrfToken: string;
452
+ errorMessage?: string;
453
+ /** Pre-select a radio when re-rendering after a validation error. */
454
+ selectedMode?: SetupExposeMode;
455
+ }
456
+
457
+ /**
458
+ * The expose step asks the operator how this hub will be reached. The
459
+ * wizard doesn't configure tailscale or DNS itself — the operator owns
460
+ * the actual networking step; the wizard's role is to ask the question,
461
+ * surface the right next-step instructions, and persist the choice so
462
+ * the done page (and the admin SPA later) shows the right URL shape.
463
+ *
464
+ * Three modes (hub#268 Item 2):
465
+ * * localhost — just this machine. No further action; the loopback
466
+ * URL is the canonical entry.
467
+ * * tailnet — Tailscale network. Show the `tailscale serve` command
468
+ * the operator runs themselves.
469
+ * * public — custom domain / reverse proxy. Show a brief explainer
470
+ * + link to the deploy docs.
471
+ */
472
+ export function renderExposeStep(props: RenderExposeStepProps): string {
473
+ const { csrfToken, errorMessage, selectedMode } = props;
474
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
475
+ // The default selection (localhost) is the most common case + the
476
+ // safest fallback — picking it changes nothing operational. Tailnet +
477
+ // public require the operator to actually run something; surfacing
478
+ // them as alternatives is the whole point of this step.
479
+ const sel = (m: SetupExposeMode) => (selectedMode === m ? " checked" : "");
480
+ const defaultChecked = selectedMode === undefined ? " checked" : "";
481
+ const body = `
482
+ <div class="card">
483
+ <div class="card-header">
484
+ ${header("expose")}
485
+ <h1>How will this hub be reached?</h1>
486
+ <p class="subtitle">Pick the network shape that matches your setup.
487
+ You can revisit this later from the admin UI — it just shapes the
488
+ URLs we surface on the next screen.</p>
489
+ </div>
490
+ ${error}
491
+ <form method="POST" action="/admin/setup/expose" class="auth-form expose-form">
492
+ ${renderCsrfHiddenInput(csrfToken)}
493
+ <label class="expose-option">
494
+ <input type="radio" name="expose_mode" value="localhost"${selectedMode ? sel("localhost") : defaultChecked} />
495
+ <div class="expose-option-body">
496
+ <span class="expose-option-title">Just this machine (localhost)</span>
497
+ <span class="expose-option-desc">Reach the hub at
498
+ <code>http://localhost:1939</code>. No further configuration
499
+ needed. This is the right answer for "I'm just trying it out"
500
+ and for "this machine is the only client."</span>
501
+ </div>
502
+ </label>
503
+ <label class="expose-option">
504
+ <input type="radio" name="expose_mode" value="tailnet"${sel("tailnet")} />
505
+ <div class="expose-option-body">
506
+ <span class="expose-option-title">My Tailscale network</span>
507
+ <span class="expose-option-desc">Share with your own devices over
508
+ a private tailnet. After finishing setup, run:</span>
509
+ <pre class="expose-option-cmd">tailscale serve --bg --https=1939 http://localhost:1939</pre>
510
+ <span class="expose-option-desc">The hub is then reachable at
511
+ your tailnet hostname (e.g.
512
+ <code>https://my-mac.tailnet-name.ts.net</code>) from any of
513
+ your logged-in devices.</span>
514
+ </div>
515
+ </label>
516
+ <label class="expose-option">
517
+ <input type="radio" name="expose_mode" value="public"${sel("public")} />
518
+ <div class="expose-option-body">
519
+ <span class="expose-option-title">Public URL (custom domain)</span>
520
+ <span class="expose-option-desc">Run the hub behind a reverse
521
+ proxy on a domain you own. See the
522
+ <a href="https://parachute.computer/docs/deploy" target="_blank" rel="noopener">deploy guide</a>
523
+ for nginx / Caddy / Cloudflare Tunnel examples + the env
524
+ vars (<code>PARACHUTE_HUB_ORIGIN</code>) the hub reads for
525
+ its own canonical URL.</span>
526
+ </div>
527
+ </label>
528
+ <button type="submit" class="btn btn-primary">Continue</button>
529
+ </form>
530
+ </div>`;
531
+ return baseDocument("Set up your Parachute hub — expose", body);
532
+ }
533
+
534
+ // --- step 5: done --------------------------------------------------------
535
+
536
+ /**
537
+ * Per-module install state surfaced on the done screen (hub#272 Item B).
538
+ * The renderer reads this to choose tile shape:
539
+ * * `idle` — no op yet, show the Install button + form
540
+ * * `running` / `pending` — op-poll panel + auto-refresh
541
+ * * `succeeded` — green check + "View in admin" link
542
+ * * `failed` — red banner + log + retry button
543
+ *
544
+ * Same op-id flows through the admin SPA's operation poll, so an
545
+ * operator can hop to `/admin/modules` mid-flight and watch from there
546
+ * without losing the op.
547
+ */
548
+ export interface ModuleInstallTileState {
549
+ short: CuratedModuleShort;
550
+ displayName: string;
551
+ tagline: string;
552
+ /** True when a services.json entry already exists for this module (already installed). */
553
+ alreadyInstalled: boolean;
554
+ /** Live op snapshot from the registry, if `?op_<short>=<id>` was set. */
555
+ operation?: {
556
+ id: string;
557
+ status: "pending" | "running" | "succeeded" | "failed";
558
+ log: readonly string[];
559
+ error?: string;
560
+ };
561
+ }
401
562
 
402
563
  export interface RenderDoneStepProps {
403
564
  vaultName: string;
404
565
  /** Hub origin used in copy-pastable MCP install commands. */
405
566
  hubOrigin: string;
567
+ /**
568
+ * Operator's expose-mode choice from step 4. Shapes the "Your hub is
569
+ * reachable at:" line + next-step instructions. Optional for back-compat
570
+ * with callers that render the done step without going through expose
571
+ * (e.g. tests of the wizard's older two-step flow).
572
+ */
573
+ exposeMode?: SetupExposeMode;
574
+ /**
575
+ * Auto-minted operator token surfaced once on the done screen
576
+ * (hub#272 Item A). When present, the MCP install command renders
577
+ * with `--header "Authorization: Bearer <token>"` pre-filled and a
578
+ * one-click Copy button. Absent means the mint either failed or
579
+ * the operator already consumed the single-use surface — the tile
580
+ * falls back to the un-headered command + a "mint at /admin/tokens"
581
+ * hint.
582
+ */
583
+ mintedToken?: string;
584
+ /**
585
+ * Optional per-module install tiles to render alongside the MCP
586
+ * command (hub#272 Item B). When omitted, the done step renders
587
+ * only the MCP tile + the admin-UI fallback link. Production wires
588
+ * Notes + Scribe; tests can omit this to assert the back-compat
589
+ * shape.
590
+ */
591
+ installTiles?: readonly ModuleInstallTileState[];
406
592
  }
407
593
 
408
594
  export function renderDoneStep(props: RenderDoneStepProps): string {
409
- const { vaultName, hubOrigin } = props;
410
- const mcpCmd = `claude mcp add --transport http parachute-${vaultName} ${hubOrigin}/vault/${vaultName}/mcp`;
595
+ const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
596
+ const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
597
+ const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
598
+ const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
599
+ const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
600
+ // The done-grid hosts the MCP-connect tile + the admin-UI fallback.
601
+ // The install tiles sit above it as a primary "what's next?" surface —
602
+ // they're the highest-friction next-step for most operators (operator
603
+ // just provisioned a vault, the obvious next action is installing the
604
+ // PWA / transcription module on top of it). Reachable tile leads
605
+ // everything because it answers "where's my hub?" before anything
606
+ // else — the question every operator hits before MCP / module
607
+ // installs even matter.
411
608
  const body = `
412
609
  <div class="card">
413
610
  <div class="card-header">
@@ -415,19 +612,14 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
415
612
  <h1>You're set up</h1>
416
613
  <p class="subtitle">Your hub is ready. Here's what to do next.</p>
417
614
  </div>
615
+ ${reachable}
616
+ ${installSection}
418
617
  <section class="done-grid">
618
+ ${mcpTile}
419
619
  <div class="done-tile">
420
620
  <h2>Open the admin UI</h2>
421
621
  <p>Manage vaults, tokens, OAuth grants, and module updates.</p>
422
- <p><a class="btn btn-primary" href="/admin/vaults">Go to admin</a></p>
423
- </div>
424
- <div class="done-tile">
425
- <h2>Connect Claude Code (MCP)</h2>
426
- <p>Wire <code>vault:${escapeHtml(vaultName)}</code> into Claude Code as an MCP server:</p>
427
- <pre>${escapeHtml(mcpCmd)}</pre>
428
- <p class="fine">You'll be prompted to mint an operator token from
429
- the admin UI on first use. See
430
- <code>/admin/tokens</code> for the canonical mint surface.</p>
622
+ <p><a class="btn btn-secondary" href="/admin/modules">Go to admin</a></p>
431
623
  </div>
432
624
  </section>
433
625
  <section class="explainer">
@@ -443,7 +635,276 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
443
635
  to <code>/login</code>.</p>
444
636
  </section>
445
637
  </div>`;
446
- return baseDocument("Parachute hub setup complete", body);
638
+ // Auto-refresh while any install op is in flight so the operator sees
639
+ // progress without manually reloading. Done step is the canonical
640
+ // poll surface for both the MCP-connect tile (static) and the
641
+ // module-install tiles (dynamic). Refresh interval matches the
642
+ // vault-op-poll page's 2s cadence so the wizard's two long-running
643
+ // surfaces (vault, post-vault notes/scribe) feel consistent.
644
+ const anyOpInFlight = tiles.some(
645
+ (t) => t.operation && (t.operation.status === "pending" || t.operation.status === "running"),
646
+ );
647
+ const refresh = anyOpInFlight ? 2 : undefined;
648
+ return baseDocument("Parachute hub — setup complete", body, refresh);
649
+ }
650
+
651
+ /**
652
+ * The MCP-connect tile. With a freshly-minted token the command renders
653
+ * fully formed with a `--header "Authorization: Bearer <token>"` flag +
654
+ * a Copy button. Without one, we fall back to the bare command + a
655
+ * pointer to `/admin/tokens` (the canonical mint surface). The Copy
656
+ * button is a tiny inline `<script>` — no SPA bundle, no module deps,
657
+ * the wizard stays server-rendered.
658
+ */
659
+ function renderMcpTile(
660
+ vaultName: string,
661
+ hubOrigin: string,
662
+ mintedToken: string | undefined,
663
+ ): string {
664
+ const safeVault = escapeHtml(vaultName);
665
+ const bareCmd = `claude mcp add --transport http parachute-${vaultName} ${hubOrigin}/vault/${vaultName}/mcp`;
666
+ if (mintedToken) {
667
+ // The token contents are surfaced once + then forgotten by the
668
+ // server (single-use hub_setting). Two visible variants of the
669
+ // command live in the DOM:
670
+ //
671
+ // * `pre#mcp-cmd` — what the operator sees. The Bearer token is
672
+ // replaced with a fixed-width row of • so shoulder-surfers,
673
+ // screencasts, and over-the-shoulder photos don't capture
674
+ // credentials by default. This is the "discoverable but not
675
+ // shoulder-surf-able" framing Aaron asked for.
676
+ // * `script#mcp-cmd-real` (type=text/plain) — the real command
677
+ // with the live token, stashed in a non-rendering script tag.
678
+ // The Copy + Show handlers read from this so the operator's
679
+ // terminal paste still gets the real header without the page
680
+ // ever painting the token.
681
+ //
682
+ // The view-source threat model is unchanged from rc.9 — the token
683
+ // is part of the response body either way. The improvement is
684
+ // *visibly hidden by default*, which is what an over-the-shoulder
685
+ // observer needs (and what existing screencasts of the wizard
686
+ // currently leak).
687
+ //
688
+ // Show toggles textContent between masked + real and flips a
689
+ // data-state attribute so a screencast / pair-programming session
690
+ // can briefly reveal-and-rehide without the operator losing the
691
+ // line of sight on which mode they're in. Auto-hide after 10s so
692
+ // a forgotten reveal doesn't leak the token into a subsequent
693
+ // recording.
694
+ const fullCmd = `${bareCmd} --header "Authorization: Bearer ${mintedToken}"`;
695
+ // Clamp the dot count to a 8–40 range so very-short or very-long
696
+ // tokens don't render comically — token format is fixed-width
697
+ // (JTI-derived), so this is purely visual.
698
+ const maskedToken = "•".repeat(Math.max(8, Math.min(40, mintedToken.length)));
699
+ const maskedCmd = `${bareCmd} --header "Authorization: Bearer ${maskedToken}"`;
700
+ // The real command rides in a hidden <script type="application/json">
701
+ // block as a JSON-encoded string. <script> element content is parsed
702
+ // as raw text (no entity references), so HTML escaping would put
703
+ // literal `&quot;` into the string — and Copy would paste that into
704
+ // the operator's terminal. JSON encoding (with `</` escaped so the
705
+ // sequence can't prematurely close the tag) round-trips safely:
706
+ // textContent returns the JSON, JSON.parse decodes back to the
707
+ // exact bytes of the original command. Caught while smoke-testing
708
+ // the rc.11 reveal/copy UX — pre-fix, the copied command included
709
+ // `&quot;` placeholders that broke shell parsing.
710
+ const fullCmdJson = JSON.stringify(fullCmd).replace(/<\//g, "<\\/");
711
+ return `<div class="done-tile">
712
+ <h2>Connect Claude Code (MCP)</h2>
713
+ <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
714
+ <div class="mcp-cmd-wrap" data-state="masked">
715
+ <pre id="mcp-cmd">${escapeHtml(maskedCmd)}</pre>
716
+ <script type="application/json" id="mcp-cmd-real">${fullCmdJson}</script>
717
+ <div class="mcp-cmd-actions">
718
+ <button type="button" class="btn btn-mcp-aux" id="mcp-cmd-show">Show token</button>
719
+ <button type="button" class="btn btn-copy" id="mcp-cmd-copy">Copy</button>
720
+ </div>
721
+ </div>
722
+ <p class="fine">We minted this token for your first MCP connection.
723
+ It's masked above so it's safe to leave open on screen; Copy
724
+ copies the real command. It's a full-scope operator token tied
725
+ to your admin account; manage and revoke tokens at
726
+ <a href="/admin/tokens"><code>/admin/tokens</code></a>.</p>
727
+ <script>
728
+ (function () {
729
+ var wrap = document.querySelector('.mcp-cmd-wrap[data-state]');
730
+ var pre = document.getElementById('mcp-cmd');
731
+ var real = document.getElementById('mcp-cmd-real');
732
+ var copyBtn = document.getElementById('mcp-cmd-copy');
733
+ var showBtn = document.getElementById('mcp-cmd-show');
734
+ if (!wrap || !pre || !real || !copyBtn || !showBtn) return;
735
+ var realCmd;
736
+ try { realCmd = JSON.parse(real.textContent || '""'); }
737
+ catch (e) { realCmd = ''; }
738
+ var maskedCmd = pre.textContent || '';
739
+ var revealTimer = null;
740
+ function setMasked() {
741
+ pre.textContent = maskedCmd;
742
+ wrap.setAttribute('data-state', 'masked');
743
+ showBtn.textContent = 'Show token';
744
+ if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
745
+ }
746
+ function setRevealed() {
747
+ pre.textContent = realCmd;
748
+ wrap.setAttribute('data-state', 'revealed');
749
+ showBtn.textContent = 'Hide token';
750
+ // Auto-hide after 10s so a stray reveal doesn't leak the
751
+ // token into a screencast capture that started after the
752
+ // click.
753
+ if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; }
754
+ revealTimer = setTimeout(setMasked, 10000);
755
+ }
756
+ showBtn.addEventListener('click', function () {
757
+ if (wrap.getAttribute('data-state') === 'masked') setRevealed();
758
+ else setMasked();
759
+ });
760
+ copyBtn.addEventListener('click', function () {
761
+ // Copy ALWAYS pulls from the real command — the operator's
762
+ // terminal needs the live token regardless of whether the
763
+ // page is currently masked. This is the load-bearing path:
764
+ // the visible mask is a UX nicety; the clipboard must
765
+ // carry the real header.
766
+ navigator.clipboard.writeText(realCmd).then(function () {
767
+ copyBtn.textContent = 'Copied ✓';
768
+ setTimeout(function () { copyBtn.textContent = 'Copy'; }, 2000);
769
+ });
770
+ });
771
+ })();
772
+ </script>
773
+ </div>`;
774
+ }
775
+ return `<div class="done-tile">
776
+ <h2>Connect Claude Code (MCP)</h2>
777
+ <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
778
+ <pre>${escapeHtml(bareCmd)}</pre>
779
+ <p class="fine">Mint an operator token at
780
+ <a href="/admin/tokens"><code>/admin/tokens</code></a> and append
781
+ <code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
782
+ </div>`;
783
+ }
784
+
785
+ /**
786
+ * The "What's next?" install-tiles row (hub#272 Item B). One tile per
787
+ * curated module the operator might want next (Notes, Scribe). Each
788
+ * tile is either an install form (POST → /admin/setup/install/<short>
789
+ * → 303 to /admin/setup?op_<short>=<id>) or an op-poll panel mirroring
790
+ * the vault-step's op-poll shape.
791
+ */
792
+ function renderInstallTiles(tiles: readonly ModuleInstallTileState[]): string {
793
+ const items = tiles.map((t) => renderInstallTile(t)).join("");
794
+ return `<section class="install-tiles">
795
+ <h2 class="install-tiles-heading">What's next?</h2>
796
+ <p class="install-tiles-subtitle">Install another module — these run alongside your vault on the same hub.</p>
797
+ <div class="install-grid">${items}</div>
798
+ </section>`;
799
+ }
800
+
801
+ function renderInstallTile(tile: ModuleInstallTileState): string {
802
+ const safeShort = escapeHtml(tile.short);
803
+ const safeName = escapeHtml(tile.displayName);
804
+ const safeTagline = escapeHtml(tile.tagline);
805
+ if (tile.operation) {
806
+ const op = tile.operation;
807
+ const logLines = op.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
808
+ const errBanner = op.error ? `<p class="error-banner">${escapeHtml(op.error)}</p>` : "";
809
+ // Terminal state (succeeded / failed) gets either a confirmation
810
+ // link or a retry form. Pending / running renders the live log
811
+ // panel and relies on the parent `<meta http-equiv="refresh">` for
812
+ // the next tick — no per-tile refresh needed (one full-page reload
813
+ // catches every in-flight op at once).
814
+ let actions = "";
815
+ if (op.status === "succeeded") {
816
+ actions = `<p><a class="btn btn-secondary" href="/admin/modules">Manage modules</a></p>`;
817
+ } else if (op.status === "failed") {
818
+ actions = `<form method="POST" action="/admin/setup/install/${safeShort}" class="install-retry">
819
+ ${renderInstallTileCsrfPlaceholder()}
820
+ <button type="submit" class="btn btn-secondary">Retry install</button>
821
+ </form>`;
822
+ }
823
+ return `<div class="install-tile install-tile-${op.status}">
824
+ <h3>${safeName}</h3>
825
+ <p class="install-tile-tagline">${safeTagline}</p>
826
+ ${errBanner}
827
+ <section class="op-log install-tile-log">
828
+ <p class="op-status op-${op.status}">status: ${op.status}</p>
829
+ <ol class="log-lines">${logLines}</ol>
830
+ </section>
831
+ ${actions}
832
+ </div>`;
833
+ }
834
+ if (tile.alreadyInstalled) {
835
+ return `<div class="install-tile install-tile-installed">
836
+ <h3>${safeName}</h3>
837
+ <p class="install-tile-tagline">${safeTagline}</p>
838
+ <p class="install-tile-status">Already installed.</p>
839
+ <p><a class="btn btn-secondary" href="/admin/modules">Manage in admin</a></p>
840
+ </div>`;
841
+ }
842
+ return `<div class="install-tile">
843
+ <h3>${safeName}</h3>
844
+ <p class="install-tile-tagline">${safeTagline}</p>
845
+ <form method="POST" action="/admin/setup/install/${safeShort}" class="install-tile-form">
846
+ ${renderInstallTileCsrfPlaceholder()}
847
+ <button type="submit" class="btn btn-primary">Install ${safeName}</button>
848
+ </form>
849
+ </div>`;
850
+ }
851
+
852
+ /**
853
+ * CSRF token placeholder for install-tile forms. The token comes from
854
+ * the wizard's per-request CSRF cookie; rendered by the parent step's
855
+ * `csrfToken` plumbing. Threaded through `renderDoneStep` props rather
856
+ * than read here directly because the tile renderer is a pure function
857
+ * the test surface can exercise without a request object.
858
+ *
859
+ * Currently rendered as a marker that the parent renderer rewrites
860
+ * before serving — keeps the per-tile shape pure but avoids dragging
861
+ * a CSRF token argument into every tile-shape function.
862
+ */
863
+ function renderInstallTileCsrfPlaceholder(): string {
864
+ return INSTALL_TILE_CSRF_PLACEHOLDER;
865
+ }
866
+
867
+ const INSTALL_TILE_CSRF_PLACEHOLDER = "__INSTALL_TILE_CSRF__";
868
+
869
+ /**
870
+ * Render the "Your hub is reachable at" tile on the done step, shaped by
871
+ * the operator's expose-mode choice. Always surfaces the loopback URL as
872
+ * an anchor (the operator's own browser hits the wizard on it); the
873
+ * tail-end instructions reframe based on what they picked.
874
+ */
875
+ function renderReachableTile(mode: SetupExposeMode, hubOrigin: string): string {
876
+ const safeOrigin = escapeHtml(hubOrigin);
877
+ if (mode === "localhost") {
878
+ return `<section class="reachable">
879
+ <h2>Your hub is reachable at</h2>
880
+ <p class="reachable-url"><code>${safeOrigin}</code></p>
881
+ <p class="fine">Local to this machine only. Want to share it with your
882
+ other devices? Re-visit setup later from the admin UI or run
883
+ <code>tailscale serve --bg --https=1939 http://localhost:1939</code>
884
+ from a terminal.</p>
885
+ </section>`;
886
+ }
887
+ if (mode === "tailnet") {
888
+ return `<section class="reachable">
889
+ <h2>Your hub is reachable at</h2>
890
+ <p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
891
+ <p class="reachable-url">Plus your tailnet URL once you run:</p>
892
+ <pre>tailscale serve --bg --https=1939 http://localhost:1939</pre>
893
+ <p class="fine">The Tailscale CLI prints the public hostname (e.g.
894
+ <code>my-mac.tailnet-name.ts.net</code>); use that on your phone /
895
+ other devices.</p>
896
+ </section>`;
897
+ }
898
+ // public
899
+ return `<section class="reachable">
900
+ <h2>Your hub is reachable at</h2>
901
+ <p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
902
+ <p class="fine">Wire a reverse proxy on your domain to
903
+ <code>${safeOrigin}</code>, then set <code>PARACHUTE_HUB_ORIGIN</code>
904
+ to your public URL and restart the hub. See the
905
+ <a href="https://parachute.computer/docs/deploy">deploy guide</a>
906
+ for nginx / Caddy / Cloudflare Tunnel examples.</p>
907
+ </section>`;
447
908
  }
448
909
 
449
910
  // --- handler entry points ------------------------------------------------
@@ -467,22 +928,100 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
467
928
  };
468
929
  if (csrf.setCookie) extraHeaders["set-cookie"] = csrf.setCookie;
469
930
 
470
- // Setup fully complete — redirect to /login unless we're rendering the
471
- // success page once. The success page sets `?just_finished=1` and the
472
- // session cookie is on the request from step 2.
473
- if (state.hasAdmin && state.hasVault) {
931
+ // Setup fully complete (including expose-mode choice) — redirect to
932
+ // /login unless we're rendering the success page once. The success
933
+ // page sets `?just_finished=1` and the session cookie is on the
934
+ // request from step 2.
935
+ if (state.hasAdmin && state.hasVault && state.hasExposeMode) {
474
936
  if (url.searchParams.get("just_finished") === "1") {
475
- return new Response(
476
- renderDoneStep({
477
- vaultName: firstVaultName(deps.manifestPath),
478
- hubOrigin: deps.issuer,
479
- }),
480
- { status: 200, headers: extraHeaders },
937
+ // hub#274 security fold: session-gate this branch. The
938
+ // `?just_finished=1` GET reads + consumes `setup_minted_token`
939
+ // (full-scope operator JWT) below; without a session check, any
940
+ // HTTP client that races the operator's browser between the
941
+ // expose POST (which writes the row) and the done GET (which
942
+ // reads it) walks off with admin-scope creds. The dispatcher
943
+ // in `hub-server.ts`'s `shouldGateForSetup` lets `/admin/setup*`
944
+ // through the pre-admin lockout, and that path stays open
945
+ // post-setup — so this gate has to live here, not at the
946
+ // dispatcher layer.
947
+ //
948
+ // A legitimate operator carrying the session cookie minted on
949
+ // the account POST sails through. A drive-by GET without the
950
+ // cookie 302s to /login: if it's a stale bookmark in the
951
+ // operator's other tab, they sign in + the row is already
952
+ // consumed by the legitimate done-GET (the single-use shape
953
+ // guarantees they see the fallback shape, never the secret).
954
+ // If it's an attacker, they can't pass /login without the
955
+ // password.
956
+ const session = findActiveSession(deps.db, req);
957
+ if (!session) {
958
+ // Preserve the CSRF set-cookie header on the 302 — same shape as
959
+ // every other branch of this handler. Without it, a freshly
960
+ // assigned CSRF token would be lost across the redirect, and
961
+ // form posts from a sign-in-then-come-back flow would 400 on
962
+ // their first attempt.
963
+ const redirectHeaders: Record<string, string> = { location: "/login" };
964
+ if (csrf.setCookie) redirectHeaders["set-cookie"] = csrf.setCookie;
965
+ return new Response(null, {
966
+ status: 302,
967
+ headers: redirectHeaders,
968
+ });
969
+ }
970
+ const stored = getSetting(deps.db, "setup_expose_mode");
971
+ const exposeMode = isSetupExposeMode(stored) ? stored : undefined;
972
+ // hub#272 Item A: read + consume the single-use minted-token row.
973
+ // Render-and-forget keeps the secret from re-appearing on
974
+ // refresh / back-button. The mint is non-fatal (see expose POST);
975
+ // its absence renders the bare MCP command + a hint at
976
+ // /admin/tokens.
977
+ const mintedToken = getSetting(deps.db, "setup_minted_token");
978
+ if (mintedToken) deleteSetting(deps.db, "setup_minted_token");
979
+ // hub#267: the operator-typed vault name lives in hub_settings
980
+ // (persisted by handleSetupVaultPost). Fall back to scanning
981
+ // services.json — covers wizard runs from before this PR where
982
+ // setup_vault_name wasn't written. The services.json read
983
+ // returns the path-tail; vault's own first-boot write produces
984
+ // the canonical name so the two should agree once the vault
985
+ // boots authoritatively.
986
+ const storedName = getSetting(deps.db, "setup_vault_name");
987
+ const vaultName = storedName ?? firstVaultName(deps.manifestPath);
988
+ // Module install tiles (hub#272 Item B). One per curated module
989
+ // other than vault (which the wizard already provisioned).
990
+ const installTiles = buildInstallTiles(url, deps);
991
+ const doneProps: RenderDoneStepProps = {
992
+ vaultName,
993
+ hubOrigin: deps.issuer,
994
+ installTiles,
995
+ };
996
+ if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
997
+ if (mintedToken) doneProps.mintedToken = mintedToken;
998
+ // Substitute CSRF placeholder for the install-tile forms with
999
+ // the current CSRF token. Keeping the per-tile renderer pure
1000
+ // means the substitution lives here (one rewrite per render).
1001
+ const html = renderDoneStep(doneProps).replaceAll(
1002
+ INSTALL_TILE_CSRF_PLACEHOLDER,
1003
+ renderCsrfHiddenInput(csrf.token),
481
1004
  );
1005
+ return new Response(html, {
1006
+ status: 200,
1007
+ headers: extraHeaders,
1008
+ });
482
1009
  }
483
1010
  return new Response(null, { status: 301, headers: { location: "/login" } });
484
1011
  }
485
1012
 
1013
+ // Expose step (hub#268 Item 2). Admin + vault exist, but the operator
1014
+ // hasn't picked an expose mode yet. The wizard form posts to
1015
+ // /admin/setup/expose. Gated on having an admin session (the session
1016
+ // cookie was minted on step 2); on a stale tab without it, the post
1017
+ // handler shows the no-session error.
1018
+ if (state.hasAdmin && state.hasVault && !state.hasExposeMode) {
1019
+ return new Response(renderExposeStep({ csrfToken: csrf.token }), {
1020
+ status: 200,
1021
+ headers: extraHeaders,
1022
+ });
1023
+ }
1024
+
486
1025
  // Step 3 (vault) with an op in flight — render the poll page.
487
1026
  if (state.hasAdmin && !state.hasVault) {
488
1027
  const opId = url.searchParams.get("op");
@@ -550,7 +1089,12 @@ export async function handleSetupAccountPost(
550
1089
  return htmlResponse(renderAccountStep({ csrfToken, username, errorMessage: fieldErr }), 400);
551
1090
  }
552
1091
  try {
553
- const user = await createUser(deps.db, username, password);
1092
+ // Wizard-admin chose their password through this very form; skip the
1093
+ // multi-user-Phase-1 force-change-password redirect by landing
1094
+ // `password_changed=true`. `assignedVault` stays null — admin posture
1095
+ // (the wizard never asks the first admin to pin themselves to a
1096
+ // single vault; that's a non-admin user pattern).
1097
+ const user = await createUser(deps.db, username, password, { passwordChanged: true });
554
1098
  const session = createSession(deps.db, { userId: user.id });
555
1099
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
556
1100
  secure: isHttpsRequest(req),
@@ -622,16 +1166,35 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
622
1166
  const state = deriveWizardState(deps);
623
1167
  if (state.hasVault) return redirect("/admin/setup?just_finished=1");
624
1168
 
625
- // The first vault is hard-named "default" for now (hub#267). The CLI
626
- // threads `--vault-name` through `parachute-vault init`; the wizard's
627
- // container-mode `runInstall` doesn't run `init` (just `bun add` +
628
- // seed services.json + supervisor.start), and the upstream vault
629
- // module's `server.ts` auto-creates a "default" vault on first boot
630
- // regardless of the seeded services.json paths. Wiring an
631
- // operator-typed name end-to-end requires either a new init step or
632
- // upstream changes in @openparachute/vault — both bigger than fit
633
- // here. Form has no name field now; the operator renames via the
634
- // admin UI post-setup.
1169
+ // hub#267: the operator-typed vault name is now threaded all the way
1170
+ // through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
1171
+ // shipped the env-var read in vault's `server.ts`). Empty input
1172
+ // falls back to the canonical `DEFAULT_VAULT_NAME` so the "just give
1173
+ // me a vault" path still works without typing anything.
1174
+ const csrfTokenStr = typeof formCsrf === "string" ? formCsrf : "";
1175
+ const rawName = String(form.get("vault_name") ?? "").trim();
1176
+ let vaultName: string;
1177
+ if (rawName === "") {
1178
+ vaultName = DEFAULT_VAULT_NAME;
1179
+ } else {
1180
+ const v = validateVaultName(rawName);
1181
+ if (!v.ok) {
1182
+ return htmlResponse(
1183
+ renderVaultStep({
1184
+ csrfToken: csrfTokenStr,
1185
+ vaultName: rawName,
1186
+ errorMessage: v.error,
1187
+ }),
1188
+ 400,
1189
+ );
1190
+ }
1191
+ vaultName = v.name;
1192
+ }
1193
+ // Persist for the done-step renderer. Vault overwrites services.json
1194
+ // on its first authoritative boot, but until that completes the wizard
1195
+ // needs a stable source of truth for the typed name — both for the
1196
+ // op-poll page subtitle and the post-redirect done step.
1197
+ setSetting(deps.db, "setup_vault_name", vaultName);
635
1198
  const registry = deps.registry;
636
1199
  const vaultSpec = specFor(FIRST_VAULT_SHORT);
637
1200
 
@@ -668,6 +1231,21 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
668
1231
  ? registry.create("install", FIRST_VAULT_SHORT)
669
1232
  : { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
670
1233
  if (registry) {
1234
+ // hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
1235
+ // vault's first-boot path (vault#342) names the created vault
1236
+ // accordingly. Skip the env override when the operator left the
1237
+ // field blank — vault's `resolveFirstBootVaultName` defaults to
1238
+ // `default` on absent env vars, so this preserves the prior
1239
+ // behaviour for the empty-input case.
1240
+ //
1241
+ // If the operator typed "default" explicitly, treat the same as
1242
+ // blank — vault's first-boot defaults to "default" anyway, so
1243
+ // skipping the env override is correct (the comparison below
1244
+ // catches both blank-trimmed-to-DEFAULT and typed-"default").
1245
+ const spawnEnv: Record<string, string> = {};
1246
+ if (vaultName !== DEFAULT_VAULT_NAME) {
1247
+ spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
1248
+ }
671
1249
  void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
672
1250
  db: deps.db,
673
1251
  issuer: deps.issuer,
@@ -676,6 +1254,7 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
676
1254
  supervisor: deps.supervisor,
677
1255
  registry,
678
1256
  ...(deps.run ? { run: deps.run } : {}),
1257
+ ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
679
1258
  }).catch((err) => {
680
1259
  const msg = err instanceof Error ? err.message : String(err);
681
1260
  registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
@@ -691,6 +1270,246 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
691
1270
  return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
692
1271
  }
693
1272
 
1273
+ /**
1274
+ * POST `/admin/setup/expose`. Form-encoded.
1275
+ *
1276
+ * Persists the operator's "how will this hub be reached?" answer to
1277
+ * `hub_settings.setup_expose_mode` (hub#268 Item 2). Three valid values:
1278
+ * `localhost`, `tailnet`, `public`.
1279
+ *
1280
+ * This is also the transition where the wizard considers itself "done"
1281
+ * for the auto-approve-first-client feature (hub#268 Item 3): we open a
1282
+ * 60-minute window where the next OAuth client registration is
1283
+ * auto-approved. Reasoning lives in `hub-settings.ts`; the wizard just
1284
+ * fires it on the only event that means "operator just finished the
1285
+ * canonical onboarding."
1286
+ *
1287
+ * Gated on an admin session cookie like the vault POST is — same shape,
1288
+ * same reason.
1289
+ */
1290
+ export async function handleSetupExposePost(
1291
+ req: Request,
1292
+ deps: SetupWizardDeps,
1293
+ ): Promise<Response> {
1294
+ const form = await req.formData();
1295
+ const formCsrf = form.get(CSRF_FIELD_NAME);
1296
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1297
+ return badRequestPage("Invalid form submission", "Reload and try again.");
1298
+ }
1299
+ const session = findActiveSession(deps.db, req);
1300
+ if (!session) {
1301
+ return badRequestPage(
1302
+ "No admin session",
1303
+ "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
1304
+ );
1305
+ }
1306
+ // Already done — short-circuit to the success screen. Belt-and-braces:
1307
+ // the wizard's GET shape catches this case too, but a direct POST
1308
+ // (curl, tab race) shouldn't double-fire the auto-approve window.
1309
+ if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
1310
+ return redirect("/admin/setup?just_finished=1");
1311
+ }
1312
+ const rawMode = form.get("expose_mode");
1313
+ if (!isSetupExposeMode(rawMode)) {
1314
+ return htmlResponse(
1315
+ renderExposeStep({
1316
+ csrfToken: typeof formCsrf === "string" ? formCsrf : "",
1317
+ errorMessage: `Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
1318
+ }),
1319
+ 400,
1320
+ );
1321
+ }
1322
+ setSetting(deps.db, "setup_expose_mode", rawMode);
1323
+ // hub#268 Item 3: open the 60-minute auto-approve window for the first
1324
+ // OAuth client registration. Logged so an operator chasing odd behavior
1325
+ // can see it fired.
1326
+ openFirstClientAutoApproveWindow(deps.db);
1327
+ console.log(
1328
+ `[setup-wizard] opened first-client auto-approve window (60min) after expose-mode=${rawMode}`,
1329
+ );
1330
+ // hub#272 Item A: auto-mint an operator token under the broad `admin`
1331
+ // scope-set + persist it once so the done-step renderer can pre-fill
1332
+ // the MCP install command with a Bearer header. The token is single-
1333
+ // use surface on the done page — the renderer deletes it from
1334
+ // hub_settings after one read so a stale tab refresh / back button
1335
+ // doesn't re-disclose the secret. The jti is still in the `tokens`
1336
+ // registry so revocation via the admin UI works as usual. Failures
1337
+ // are non-fatal: the done page falls back to the un-headered MCP
1338
+ // command + a "mint manually at /admin/tokens" hint.
1339
+ try {
1340
+ const minted = await mintOperatorToken(deps.db, session.userId, {
1341
+ issuer: deps.issuer,
1342
+ scopeSet: "admin",
1343
+ });
1344
+ setSetting(deps.db, "setup_minted_token", minted.token);
1345
+ console.log(
1346
+ `[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
1347
+ );
1348
+ } catch (err) {
1349
+ const msg = err instanceof Error ? err.message : String(err);
1350
+ console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
1351
+ }
1352
+ return redirect("/admin/setup?just_finished=1");
1353
+ }
1354
+
1355
+ // --- step 5 helpers: install tiles --------------------------------------
1356
+
1357
+ /**
1358
+ * Curated module short → display props rendered on the done-screen
1359
+ * install tiles. Order matters — list order is render order. Vault is
1360
+ * intentionally excluded (the wizard already provisioned it).
1361
+ *
1362
+ * `tagline` mirrors each module's `displayName + tagline` from
1363
+ * `FIRST_PARTY_FALLBACKS` (`src/service-spec.ts`); kept verbatim here
1364
+ * so the wizard isn't coupled to service-spec internals.
1365
+ */
1366
+ const INSTALL_TILE_PROPS: ReadonlyArray<{
1367
+ short: CuratedModuleShort;
1368
+ displayName: string;
1369
+ tagline: string;
1370
+ }> = [
1371
+ { short: "notes", displayName: "Notes", tagline: "Notes PWA backed by your vault." },
1372
+ {
1373
+ short: "scribe",
1374
+ displayName: "Scribe",
1375
+ tagline: "Local audio transcription for vault recordings.",
1376
+ },
1377
+ ];
1378
+
1379
+ /**
1380
+ * Construct the install-tile state array for the done step. Reads the
1381
+ * URL's `?op_<short>=<id>` query (per-module op-poll), the services.json
1382
+ * manifest (already-installed detection), and the operations registry
1383
+ * (op status snapshot). Pure-ish — only the registry call is impure.
1384
+ */
1385
+ function buildInstallTiles(url: URL, deps: SetupWizardDeps): ModuleInstallTileState[] {
1386
+ const manifest = readManifest(deps.manifestPath);
1387
+ return INSTALL_TILE_PROPS.filter((p) =>
1388
+ (CURATED_MODULES as readonly string[]).includes(p.short),
1389
+ ).map((p) => {
1390
+ const spec = specFor(p.short);
1391
+ const alreadyInstalled = manifest.services.some((s) => s.name === spec.manifestName);
1392
+ const tile: ModuleInstallTileState = {
1393
+ short: p.short,
1394
+ displayName: p.displayName,
1395
+ tagline: p.tagline,
1396
+ alreadyInstalled,
1397
+ };
1398
+ const opId = url.searchParams.get(`op_${p.short}`);
1399
+ if (opId && deps.registry) {
1400
+ const op = deps.registry.get(opId);
1401
+ if (op) {
1402
+ tile.operation = {
1403
+ id: op.id,
1404
+ status: op.status,
1405
+ log: op.log,
1406
+ ...(op.error !== undefined ? { error: op.error } : {}),
1407
+ };
1408
+ }
1409
+ }
1410
+ return tile;
1411
+ });
1412
+ }
1413
+
1414
+ /**
1415
+ * POST `/admin/setup/install/<short>`. Form-encoded, session-gated.
1416
+ *
1417
+ * Kicks off the same `runInstall` pipeline `/api/modules/<short>/install`
1418
+ * uses (hub#260) but from the wizard's session-cookie surface — no
1419
+ * separate bearer mint dance for the operator who just finished the
1420
+ * wizard.
1421
+ *
1422
+ * Returns 303 to `/admin/setup?just_finished=1&op_<short>=<opId>` so
1423
+ * the done-screen renderer picks up the op via `buildInstallTiles`.
1424
+ * Multiple in-flight installs are supported (query keeps `op_<short>`
1425
+ * per module); the auto-refresh meta keeps polling while any module
1426
+ * is pending/running.
1427
+ *
1428
+ * Rejects when:
1429
+ * * `short` isn't a curated module short
1430
+ * * `short === "vault"` — the wizard's vault step owns that
1431
+ * * session cookie missing
1432
+ * * CSRF token missing or wrong
1433
+ * * supervisor isn't wired (CLI-mode hub)
1434
+ */
1435
+ export async function handleSetupInstallPost(
1436
+ req: Request,
1437
+ short: string,
1438
+ deps: SetupWizardDeps,
1439
+ ): Promise<Response> {
1440
+ if (!deps.supervisor) {
1441
+ return badRequestPage(
1442
+ "Module supervisor unavailable",
1443
+ `Module installs from the wizard require container-mode \`parachute serve\`. On the on-box CLI surface, run \`parachute install ${short}\` directly.`,
1444
+ );
1445
+ }
1446
+ if (!(CURATED_MODULES as readonly string[]).includes(short) || short === "vault") {
1447
+ return badRequestPage(
1448
+ "Unknown module",
1449
+ `"${short}" is not an installable wizard module. Pick from the done-screen tiles.`,
1450
+ );
1451
+ }
1452
+ const form = await req.formData();
1453
+ const formCsrf = form.get(CSRF_FIELD_NAME);
1454
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1455
+ return badRequestPage("Invalid form submission", "Reload and try again.");
1456
+ }
1457
+ const session = findActiveSession(deps.db, req);
1458
+ if (!session) {
1459
+ return badRequestPage(
1460
+ "No admin session",
1461
+ "Sign in to continue. The wizard's session cookie was set at step 2; clearing cookies between steps lands you here.",
1462
+ );
1463
+ }
1464
+ const moduleShort = short as CuratedModuleShort;
1465
+ const spec = specFor(moduleShort);
1466
+ const registry = deps.registry;
1467
+ // Idempotent short-circuit: if already supervised + running, return a
1468
+ // synthesized succeeded op rather than firing a second `bun add`.
1469
+ // Mirrors `handleSetupVaultPost` + `handleInstall`.
1470
+ const supervisorState = deps.supervisor.get(moduleShort);
1471
+ if (
1472
+ supervisorState?.status === "running" ||
1473
+ supervisorState?.status === "starting" ||
1474
+ supervisorState?.status === "restarting"
1475
+ ) {
1476
+ if (registry) {
1477
+ const op = registry.create("install", moduleShort);
1478
+ registry.update(
1479
+ op.id,
1480
+ { status: "succeeded" },
1481
+ `${moduleShort} already supervised (status=${supervisorState.status})`,
1482
+ );
1483
+ return redirect(
1484
+ `/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`,
1485
+ );
1486
+ }
1487
+ return redirect("/admin/setup?just_finished=1");
1488
+ }
1489
+ const op = registry
1490
+ ? registry.create("install", moduleShort)
1491
+ : { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
1492
+ if (registry) {
1493
+ void runInstall(op.id, moduleShort, spec, {
1494
+ db: deps.db,
1495
+ issuer: deps.issuer,
1496
+ manifestPath: deps.manifestPath,
1497
+ configDir: deps.configDir,
1498
+ supervisor: deps.supervisor,
1499
+ registry,
1500
+ ...(deps.run ? { run: deps.run } : {}),
1501
+ }).catch((err) => {
1502
+ const msg = err instanceof Error ? err.message : String(err);
1503
+ registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
1504
+ });
1505
+ } else {
1506
+ console.warn(
1507
+ "[setup-wizard] handleSetupInstallPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
1508
+ );
1509
+ }
1510
+ return redirect(`/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`);
1511
+ }
1512
+
694
1513
  // --- helpers ------------------------------------------------------------
695
1514
 
696
1515
  function validateAccountFields(input: {
@@ -969,6 +1788,147 @@ const STYLES = `
969
1788
  margin-top: 0.4rem;
970
1789
  }
971
1790
  .btn-primary:hover { background: ${PALETTE.accentHover}; }
1791
+ .btn-secondary {
1792
+ background: transparent;
1793
+ color: ${PALETTE.accent};
1794
+ border-color: ${PALETTE.accent};
1795
+ }
1796
+ .btn-secondary:hover {
1797
+ background: ${PALETTE.accentSoft};
1798
+ }
1799
+ /* Copy + Show buttons ride the right edge of the MCP command pre.
1800
+ Compact vertical sizing so they don't dwarf the snippet on narrow
1801
+ widths; full text wrap on the pre keeps the snippet readable
1802
+ behind them. The Show button toggles the visible mask on the
1803
+ auto-minted Bearer token (rc.11 — discoverable
1804
+ but not shoulder-surf-able). Both buttons share a small flex
1805
+ container so they stack predictably on the wrap; layout-wise we
1806
+ keep the right-edge padding on .mcp-cmd-wrap pre so the buttons
1807
+ never overlap the command text. */
1808
+ .mcp-cmd-wrap {
1809
+ position: relative;
1810
+ margin: 0.5rem 0;
1811
+ }
1812
+ .mcp-cmd-wrap pre {
1813
+ background: ${PALETTE.bg};
1814
+ border: 1px solid ${PALETTE.borderLight};
1815
+ border-radius: 6px;
1816
+ padding: 0.5rem 8.5rem 0.5rem 0.75rem;
1817
+ overflow-x: auto;
1818
+ font-size: 0.82rem;
1819
+ margin: 0;
1820
+ white-space: pre-wrap;
1821
+ word-break: break-all;
1822
+ }
1823
+ .mcp-cmd-actions {
1824
+ position: absolute;
1825
+ top: 0.35rem;
1826
+ right: 0.35rem;
1827
+ display: flex;
1828
+ gap: 0.3rem;
1829
+ }
1830
+ .btn-copy, .btn-mcp-aux {
1831
+ padding: 0.25rem 0.6rem;
1832
+ font-size: 0.78rem;
1833
+ min-height: auto;
1834
+ background: ${PALETTE.cardBg};
1835
+ color: ${PALETTE.fg};
1836
+ border: 1px solid ${PALETTE.border};
1837
+ border-radius: 4px;
1838
+ cursor: pointer;
1839
+ font: inherit;
1840
+ font-size: 0.78rem;
1841
+ }
1842
+ .btn-copy:hover, .btn-mcp-aux:hover {
1843
+ border-color: ${PALETTE.accent};
1844
+ color: ${PALETTE.accent};
1845
+ }
1846
+ .mcp-cmd-wrap[data-state="revealed"] pre {
1847
+ /* Subtle visual cue that the token is currently visible — a warm
1848
+ border so the operator notices on a screencast even at low
1849
+ resolution. */
1850
+ border-color: #d4a017;
1851
+ background: rgba(212, 160, 23, 0.04);
1852
+ }
1853
+ .mcp-cmd-wrap[data-state="revealed"] .btn-mcp-aux {
1854
+ border-color: #d4a017;
1855
+ color: #6b4a00;
1856
+ }
1857
+ /* Install-tile section (hub#272 Item B). Lives above the .done-grid;
1858
+ primary "what's next?" surface. Tiles render in a responsive grid
1859
+ that collapses to one column on narrow viewports. */
1860
+ .install-tiles {
1861
+ margin: 1rem 0 1.25rem;
1862
+ }
1863
+ .install-tiles-heading {
1864
+ margin: 0 0 0.25rem;
1865
+ text-transform: none;
1866
+ letter-spacing: 0;
1867
+ font-size: 1.05rem;
1868
+ color: ${PALETTE.fg};
1869
+ }
1870
+ .install-tiles-subtitle {
1871
+ margin: 0 0 0.75rem;
1872
+ color: ${PALETTE.fgMuted};
1873
+ font-size: 0.9rem;
1874
+ }
1875
+ .install-grid {
1876
+ display: grid;
1877
+ grid-template-columns: 1fr;
1878
+ gap: 0.75rem;
1879
+ }
1880
+ @media (min-width: 30rem) {
1881
+ .install-grid { grid-template-columns: 1fr 1fr; }
1882
+ }
1883
+ .install-tile {
1884
+ border: 1px solid ${PALETTE.borderLight};
1885
+ border-radius: 8px;
1886
+ padding: 0.75rem 0.9rem;
1887
+ background: ${PALETTE.cardBg};
1888
+ display: flex;
1889
+ flex-direction: column;
1890
+ gap: 0.4rem;
1891
+ }
1892
+ .install-tile h3 {
1893
+ margin: 0;
1894
+ font-family: ${FONT_SERIF};
1895
+ font-weight: 400;
1896
+ font-size: 1.1rem;
1897
+ color: ${PALETTE.fg};
1898
+ }
1899
+ .install-tile-tagline {
1900
+ margin: 0;
1901
+ color: ${PALETTE.fgMuted};
1902
+ font-size: 0.85rem;
1903
+ }
1904
+ .install-tile-form {
1905
+ margin: 0;
1906
+ }
1907
+ .install-tile-installed {
1908
+ background: ${PALETTE.accentSoft};
1909
+ border-color: ${PALETTE.accent};
1910
+ }
1911
+ .install-tile-status {
1912
+ margin: 0;
1913
+ color: ${PALETTE.success};
1914
+ font-weight: 500;
1915
+ font-size: 0.85rem;
1916
+ }
1917
+ .install-tile-running, .install-tile-pending {
1918
+ border-color: ${PALETTE.warn};
1919
+ }
1920
+ .install-tile-succeeded {
1921
+ background: ${PALETTE.accentSoft};
1922
+ border-color: ${PALETTE.accent};
1923
+ }
1924
+ .install-tile-failed {
1925
+ border-color: ${PALETTE.danger};
1926
+ background: ${PALETTE.dangerSoft};
1927
+ }
1928
+ .install-tile-log {
1929
+ margin: 0;
1930
+ font-size: 0.78rem;
1931
+ }
972
1932
  .alt-path {
973
1933
  margin-top: 1.25rem;
974
1934
  border-top: 1px solid ${PALETTE.borderLight};
@@ -1045,6 +2005,90 @@ const STYLES = `
1045
2005
  }
1046
2006
  .done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
1047
2007
 
2008
+ /* expose step (hub#268 Item 2). Vertical stack of radio cards;
2009
+ each label is the full clickable hit target. */
2010
+ .expose-form { gap: 0.65rem; }
2011
+ .expose-option {
2012
+ display: flex;
2013
+ align-items: flex-start;
2014
+ gap: 0.65rem;
2015
+ padding: 0.85rem 1rem;
2016
+ border: 1px solid ${PALETTE.border};
2017
+ border-radius: 8px;
2018
+ cursor: pointer;
2019
+ transition: border-color 0.15s ease, background 0.15s ease;
2020
+ background: ${PALETTE.cardBg};
2021
+ }
2022
+ .expose-option:hover { border-color: ${PALETTE.accent}; }
2023
+ .expose-option input[type=radio] {
2024
+ margin-top: 0.25rem;
2025
+ accent-color: ${PALETTE.accent};
2026
+ flex-shrink: 0;
2027
+ }
2028
+ .expose-option-body {
2029
+ display: flex;
2030
+ flex-direction: column;
2031
+ gap: 0.25rem;
2032
+ min-width: 0;
2033
+ }
2034
+ .expose-option-title {
2035
+ font-weight: 600;
2036
+ color: ${PALETTE.fg};
2037
+ font-size: 0.95rem;
2038
+ }
2039
+ .expose-option-desc {
2040
+ color: ${PALETTE.fgMuted};
2041
+ font-size: 0.88rem;
2042
+ line-height: 1.45;
2043
+ }
2044
+ .expose-option-cmd {
2045
+ background: ${PALETTE.bg};
2046
+ border: 1px solid ${PALETTE.borderLight};
2047
+ border-radius: 6px;
2048
+ padding: 0.4rem 0.6rem;
2049
+ font-family: ${FONT_MONO};
2050
+ font-size: 0.82rem;
2051
+ margin: 0.25rem 0;
2052
+ overflow-x: auto;
2053
+ }
2054
+
2055
+ /* reachable tile on the done step. Lives outside the .done-grid so it
2056
+ spans the full width — the URL itself is the headline. */
2057
+ .reachable {
2058
+ background: ${PALETTE.accentSoft};
2059
+ border-left: 3px solid ${PALETTE.accent};
2060
+ border-radius: 0 8px 8px 0;
2061
+ padding: 0.75rem 1rem;
2062
+ margin: 0 0 1rem;
2063
+ }
2064
+ .reachable h2 {
2065
+ margin: 0 0 0.4rem;
2066
+ text-transform: none;
2067
+ letter-spacing: 0;
2068
+ font-size: 0.9rem;
2069
+ color: ${PALETTE.accent};
2070
+ }
2071
+ .reachable-url {
2072
+ margin: 0.2rem 0;
2073
+ font-size: 0.95rem;
2074
+ }
2075
+ .reachable-url code {
2076
+ background: ${PALETTE.cardBg};
2077
+ border: 1px solid ${PALETTE.borderLight};
2078
+ padding: 0.1rem 0.4rem;
2079
+ border-radius: 4px;
2080
+ }
2081
+ .reachable pre {
2082
+ background: ${PALETTE.cardBg};
2083
+ border: 1px solid ${PALETTE.borderLight};
2084
+ border-radius: 6px;
2085
+ padding: 0.5rem 0.75rem;
2086
+ overflow-x: auto;
2087
+ font-size: 0.82rem;
2088
+ margin: 0.4rem 0;
2089
+ }
2090
+ .reachable .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
2091
+
1048
2092
  code {
1049
2093
  background: ${PALETTE.borderLight};
1050
2094
  padding: 0.05rem 0.3rem;