@openparachute/hub 0.5.7 → 0.5.10-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,2009 @@
1
+ /**
2
+ * First-boot setup wizard at `/admin/setup` (hub#259).
3
+ *
4
+ * Server-rendered, three-step form that walks a fresh operator through:
5
+ *
6
+ * 1. Welcome — what they're about to set up (admin account + first vault).
7
+ * 2. Account — username + password → POST `/admin/setup/account`.
8
+ * Creates the admin row via `createUser`, sets a `parachute_hub_session`
9
+ * cookie + a `parachute_hub_csrf` cookie, redirects back to
10
+ * `/admin/setup`.
11
+ * 3. Vault — pick a name (default `default`) → POST `/admin/setup/vault`.
12
+ * Gated by the just-minted admin session cookie. Drives the same
13
+ * `runInstall` path the `/api/modules/:short/install` API uses, just
14
+ * without re-fabricating an HTTP request + bearer. Returns + redirects
15
+ * to `/admin/setup?op=<id>` so the same wizard page polls the
16
+ * operation registry.
17
+ * 4. Done — links to the admin SPA, MCP install hints, "what's next."
18
+ *
19
+ * The wizard is server-rendered (no SPA bundle, no JS). Step 3's progress
20
+ * poll is a `<meta http-equiv="refresh" content="2">` — works without JS
21
+ * and is fine for a 30-second one-shot install on first boot.
22
+ *
23
+ * Idempotency: the rendered step is derived from DB + services.json on
24
+ * every GET. If a user already exists but no vault, the wizard resumes
25
+ * at step 3. If both exist, it resumes at step 4. Once both exist + a
26
+ * full minute has elapsed since the user was created, subsequent GETs
27
+ * 301 to `/login` (the canonical post-setup entry).
28
+ *
29
+ * No email collection (the brief). Magic-link recovery is a later phase.
30
+ * No 2FA in this wizard either — adds it later; the launch posture is
31
+ * "username + password is fine for a fresh hub."
32
+ *
33
+ * History: replaces the static placeholder `renderSetupPlaceholder` from
34
+ * the hub#258 setup-gate scaffold. The env-var seed path
35
+ * (`PARACHUTE_INITIAL_ADMIN_USERNAME` + `PARACHUTE_INITIAL_ADMIN_PASSWORD`)
36
+ * still works for container operators who prefer to bake the admin into
37
+ * the boot path; documented as an alternative on the welcome screen.
38
+ */
39
+
40
+ import type { Database } from "bun:sqlite";
41
+ import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
42
+ import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
43
+ import {
44
+ CSRF_FIELD_NAME,
45
+ ensureCsrfToken,
46
+ renderCsrfHiddenInput,
47
+ verifyCsrfToken,
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";
58
+ import { escapeHtml } from "./oauth-ui.ts";
59
+ import { mintOperatorToken } from "./operator-token.ts";
60
+ import { isHttpsRequest } from "./request-protocol.ts";
61
+ import { findService, readManifest } from "./services-manifest.ts";
62
+ import {
63
+ SESSION_TTL_MS,
64
+ buildSessionCookie,
65
+ createSession,
66
+ findActiveSession,
67
+ } from "./sessions.ts";
68
+ import type { Supervisor } from "./supervisor.ts";
69
+ import { createUser, userCount } from "./users.ts";
70
+ import { DEFAULT_VAULT_NAME, validateVaultName } from "./vault-name.ts";
71
+
72
+ // --- shared chrome --------------------------------------------------------
73
+
74
+ const PALETTE = {
75
+ bg: "#faf8f4",
76
+ fg: "#2c2a26",
77
+ fgMuted: "#6b6860",
78
+ fgDim: "#9a9690",
79
+ accent: "#4a7c59",
80
+ accentHover: "#3d6849",
81
+ accentSoft: "rgba(74, 124, 89, 0.08)",
82
+ border: "#e4e0d8",
83
+ borderLight: "#ece9e2",
84
+ cardBg: "#ffffff",
85
+ danger: "#a3392b",
86
+ dangerSoft: "rgba(163, 57, 43, 0.08)",
87
+ success: "#3d6849",
88
+ warn: "#d4a017",
89
+ warnSoft: "#fff8e1",
90
+ } as const;
91
+
92
+ const FONT_SERIF = `Georgia, "Times New Roman", serif`;
93
+ const FONT_SANS = `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif`;
94
+ const FONT_MONO = `ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace`;
95
+
96
+ function escapeAttr(s: string): string {
97
+ return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
98
+ }
99
+
100
+ // --- state derivation ----------------------------------------------------
101
+
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";
111
+
112
+ export interface DerivedWizardState {
113
+ /** Current step the wizard should render. */
114
+ step: WizardStep;
115
+ /** Whether at least one user row exists. */
116
+ hasAdmin: boolean;
117
+ /** Whether the first vault (curated) has been provisioned in services.json. */
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;
126
+ }
127
+
128
+ /**
129
+ * Vault is the canonical first-vault target for the wizard. The brief
130
+ * specifies "first vault — pick a name (default: `default`)" and the
131
+ * curated module list is what install / supervisor speak.
132
+ */
133
+ export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
134
+
135
+ /**
136
+ * Read DB + services.json to decide which step the wizard should render.
137
+ * Pure, idempotent — re-running the wizard after partial setup picks up
138
+ * where it left off.
139
+ */
140
+ export function deriveWizardState(deps: {
141
+ db: Database;
142
+ manifestPath: string;
143
+ }): DerivedWizardState {
144
+ const hasAdmin = userCount(deps.db) > 0;
145
+ // The wizard's first-vault provisioning uses the curated `vault` short,
146
+ // which maps to `parachute-vault` in services.json.
147
+ const vaultSpec = specFor(FIRST_VAULT_SHORT);
148
+ const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
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;
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.
158
+ if (!hasAdmin) step = "welcome";
159
+ else if (!hasVault) step = "vault";
160
+ else if (!hasExposeMode) step = "expose";
161
+ else step = "done";
162
+ return { step, hasAdmin, hasVault, hasExposeMode };
163
+ }
164
+
165
+ // --- handler types -------------------------------------------------------
166
+
167
+ export interface SetupWizardDeps {
168
+ db: Database;
169
+ manifestPath: string;
170
+ configDir: string;
171
+ /**
172
+ * Optional supervisor. Present under `parachute serve` (container
173
+ * mode); absent under the on-box CLI surface. The wizard refuses
174
+ * step-3 POSTs when absent — the operator is expected to use the CLI
175
+ * (`parachute install vault`) in that posture, not the web wizard.
176
+ */
177
+ supervisor?: Supervisor;
178
+ /**
179
+ * Hub origin string for the JWT `iss` claim plumbed through to install
180
+ * ops. Defaults to the hub's own loopback issuer when unset (consistent
181
+ * with the rest of hub-server.ts when no PARACHUTE_HUB_ORIGIN is
182
+ * configured).
183
+ */
184
+ issuer: string;
185
+ /**
186
+ * Test seam: inject an operations registry so the wizard's tests can
187
+ * observe its install op without colliding with the default
188
+ * process-singleton. Production omits this; both the API surface and
189
+ * the wizard then share the same default registry, which is correct —
190
+ * an `/api/modules/operations/:id` poll from the SPA can pick up an
191
+ * op created by the wizard if for some reason a stale tab is open.
192
+ */
193
+ registry?: OperationsRegistry;
194
+ /** Test seam: stub `bun add` / `bun remove` runner. */
195
+ run?: (cmd: readonly string[]) => Promise<number>;
196
+ }
197
+
198
+ // --- rendering -----------------------------------------------------------
199
+
200
+ function baseDocument(title: string, body: string, autoRefresh?: number): string {
201
+ const refresh = autoRefresh ? `<meta http-equiv="refresh" content="${autoRefresh}" />` : "";
202
+ return `<!doctype html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="utf-8" />
206
+ <title>${escapeHtml(title)}</title>
207
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
208
+ <meta name="referrer" content="no-referrer" />
209
+ ${refresh}
210
+ <style>${STYLES}</style>
211
+ </head>
212
+ <body>
213
+ <main>
214
+ ${body}
215
+ </main>
216
+ </body>
217
+ </html>`;
218
+ }
219
+
220
+ function header(currentStep: WizardStep): string {
221
+ const stepOrder: WizardStep[] = ["welcome", "account", "vault", "expose", "done"];
222
+ // Step 1 (welcome) + step 2 (account) collapse on the rendered page —
223
+ // we show them as a single combined form. The progress bar still names
224
+ // them separately so the operator sees the shape.
225
+ const labels: Record<WizardStep, string> = {
226
+ welcome: "Welcome",
227
+ account: "Account",
228
+ vault: "Vault",
229
+ expose: "Expose",
230
+ done: "Done",
231
+ };
232
+ const items = stepOrder
233
+ .map((s) => {
234
+ const current = s === currentStep;
235
+ const past = stepOrder.indexOf(s) < stepOrder.indexOf(currentStep);
236
+ const cls = current ? "step current" : past ? "step past" : "step";
237
+ const marker = past ? "✓" : `${stepOrder.indexOf(s) + 1}`;
238
+ return `<li class="${cls}"><span class="step-marker">${marker}</span><span class="step-label">${escapeHtml(labels[s])}</span></li>`;
239
+ })
240
+ .join("");
241
+ return `
242
+ <div class="brand">
243
+ <span class="brand-mark">⌬</span>
244
+ <span class="brand-name">Parachute</span>
245
+ <span class="brand-tag">first-boot setup</span>
246
+ </div>
247
+ <ol class="steps">${items}</ol>`;
248
+ }
249
+
250
+ // --- step 1 + 2: welcome + account ---------------------------------------
251
+
252
+ export interface RenderAccountStepProps {
253
+ csrfToken: string;
254
+ errorMessage?: string;
255
+ /** Pre-fill the username field after a validation failure. */
256
+ username?: string;
257
+ }
258
+
259
+ export function renderAccountStep(props: RenderAccountStepProps): string {
260
+ const { csrfToken, errorMessage, username } = props;
261
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
262
+ const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
263
+ const body = `
264
+ <div class="card">
265
+ <div class="card-header">
266
+ ${header("welcome")}
267
+ <h1>Welcome to your Parachute hub</h1>
268
+ <p class="subtitle">Two quick steps and you'll have a working stack —
269
+ an admin account and your first vault. No email, no signup; this
270
+ all stays on your machine (or your container).</p>
271
+ </div>
272
+ <section class="explainer">
273
+ <h2>Why this step</h2>
274
+ <p>A Parachute hub needs one admin operator before anything else can
275
+ run — OAuth issuance, vault provisioning, the admin UI all need
276
+ an identity behind them.</p>
277
+ <h2>What's next</h2>
278
+ <p>After this you'll name your first vault. The hub will install it
279
+ and issue a token your Claude Code MCP client can use.</p>
280
+ </section>
281
+ ${error}
282
+ <form method="POST" action="/admin/setup/account" class="auth-form">
283
+ ${renderCsrfHiddenInput(csrfToken)}
284
+ <label class="field">
285
+ <span class="field-label">Username</span>
286
+ <input type="text" name="username" autocomplete="username"
287
+ autofocus required minlength="2" maxlength="64"
288
+ pattern="[A-Za-z0-9_.-]+" title="letters, digits, _ . - (2–64 chars)"
289
+ ${usernameAttr} />
290
+ <span class="field-hint">letters, digits, <code>_</code>, <code>.</code>, <code>-</code></span>
291
+ </label>
292
+ <label class="field">
293
+ <span class="field-label">Password</span>
294
+ <input type="password" name="password" autocomplete="new-password"
295
+ required minlength="8" />
296
+ <span class="field-hint">at least 8 characters</span>
297
+ </label>
298
+ <label class="field">
299
+ <span class="field-label">Confirm password</span>
300
+ <input type="password" name="password_confirm" autocomplete="new-password"
301
+ required minlength="8" />
302
+ </label>
303
+ <button type="submit" class="btn btn-primary">Create admin & continue</button>
304
+ </form>
305
+ <details class="alt-path">
306
+ <summary>Prefer to seed via env vars?</summary>
307
+ <p>Set <code>PARACHUTE_INITIAL_ADMIN_USERNAME</code> and
308
+ <code>PARACHUTE_INITIAL_ADMIN_PASSWORD</code> on the container and
309
+ restart. The hub will create the admin row on next boot and skip this
310
+ wizard.</p>
311
+ </details>
312
+ </div>`;
313
+ return baseDocument("Set up your Parachute hub — account", body);
314
+ }
315
+
316
+ // --- step 3: vault -------------------------------------------------------
317
+
318
+ export interface RenderVaultStepProps {
319
+ csrfToken: string;
320
+ errorMessage?: string;
321
+ /** Pre-fill the vault name input after a validation failure. */
322
+ vaultName?: string;
323
+ /**
324
+ * When an install op is in progress, render the polling shape: no
325
+ * form, just the op log + auto-refresh.
326
+ */
327
+ operation?: {
328
+ id: string;
329
+ status: "pending" | "running" | "succeeded" | "failed";
330
+ log: readonly string[];
331
+ error?: string;
332
+ };
333
+ }
334
+
335
+ export function renderVaultStep(props: RenderVaultStepProps): string {
336
+ const { csrfToken, errorMessage, operation, vaultName } = props;
337
+ if (operation) return renderVaultOpStep({ operation });
338
+ const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
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;
351
+ const body = `
352
+ <div class="card">
353
+ <div class="card-header">
354
+ ${header("vault")}
355
+ <h1>Create your first vault</h1>
356
+ <p class="subtitle">A vault is the per-workspace SQLite store + MCP
357
+ surface Claude reads and writes through. You can have many vaults
358
+ on one hub; this is just the first.</p>
359
+ </div>
360
+ <section class="explainer">
361
+ <h2>Why this step</h2>
362
+ <p>The wizard provisions a vault module at the path
363
+ <code>/vault/&lt;name&gt;</code> and issues you an operator token —
364
+ the same shape <code>parachute install vault</code> produces from
365
+ the CLI. We're doing both in one click.</p>
366
+ <h2>What's next</h2>
367
+ <p>You'll land on a success screen with copy-paste MCP install
368
+ instructions for Claude Code and a link to the admin UI, where
369
+ you can rename or add additional vaults.</p>
370
+ </section>
371
+ <section class="preview">
372
+ <p class="preview-label">About to create</p>
373
+ <div class="preview-card">
374
+ <span class="preview-key">vault:</span>
375
+ <span class="preview-val" id="preview-vault-name">${previewName}</span>
376
+ <span class="preview-fine">— admin: you, MCP-ready for Claude Code</span>
377
+ </div>
378
+ <p class="preview-fine">
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>.
382
+ </p>
383
+ </section>
384
+ ${error}
385
+ <form method="POST" action="/admin/setup/vault" class="auth-form">
386
+ ${renderCsrfHiddenInput(csrfToken)}
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>
398
+ </form>
399
+ </div>`;
400
+ return baseDocument("Set up your Parachute hub — vault", body);
401
+ }
402
+
403
+ function renderVaultOpStep(props: {
404
+ operation: NonNullable<RenderVaultStepProps["operation"]>;
405
+ }): string {
406
+ const { operation } = props;
407
+ const terminal = operation.status === "succeeded" || operation.status === "failed";
408
+ const logLines = operation.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
409
+ const errBanner = operation.error
410
+ ? `<p class="error-banner">${escapeHtml(operation.error)}</p>`
411
+ : "";
412
+ // Auto-refresh every 2s until terminal. When succeeded we redirect via
413
+ // a tiny refresh-to-/admin/setup?just_finished=1 so the wizard
414
+ // re-renders the success screen one more time (with the MCP install
415
+ // command + vault name) before subsequent bare GETs 301 to /login.
416
+ // Without the `?just_finished=1` query, the success state derives as
417
+ // "complete" + GET 301s, and the operator never sees the done page.
418
+ // When failed we leave the operator on this screen so they can read
419
+ // the log.
420
+ const refresh = terminal ? undefined : 2;
421
+ const body = `
422
+ <div class="card">
423
+ <div class="card-header">
424
+ ${header("vault")}
425
+ <h1>${operation.status === "succeeded" ? "Vault ready" : "Provisioning your vault…"}</h1>
426
+ <p class="subtitle">${
427
+ operation.status === "failed"
428
+ ? "Something went wrong — see the log below."
429
+ : operation.status === "succeeded"
430
+ ? "All set. Continuing to the success screen…"
431
+ : "This usually takes 10–60 seconds. The page refreshes itself."
432
+ }</p>
433
+ </div>
434
+ ${errBanner}
435
+ <section class="op-log">
436
+ <p class="op-status op-${operation.status}">status: ${operation.status}</p>
437
+ <ol class="log-lines">${logLines}</ol>
438
+ </section>
439
+ ${
440
+ operation.status === "succeeded"
441
+ ? '<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1" />'
442
+ : ""
443
+ }
444
+ </div>`;
445
+ return baseDocument("Set up your Parachute hub — vault", body, refresh);
446
+ }
447
+
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
+ }
562
+
563
+ export interface RenderDoneStepProps {
564
+ vaultName: string;
565
+ /** Hub origin used in copy-pastable MCP install commands. */
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[];
592
+ }
593
+
594
+ export function renderDoneStep(props: RenderDoneStepProps): string {
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.
608
+ const body = `
609
+ <div class="card">
610
+ <div class="card-header">
611
+ ${header("done")}
612
+ <h1>You're set up</h1>
613
+ <p class="subtitle">Your hub is ready. Here's what to do next.</p>
614
+ </div>
615
+ ${reachable}
616
+ ${installSection}
617
+ <section class="done-grid">
618
+ ${mcpTile}
619
+ <div class="done-tile">
620
+ <h2>Open the admin UI</h2>
621
+ <p>Manage vaults, tokens, OAuth grants, and module updates.</p>
622
+ <p><a class="btn btn-secondary" href="/admin/modules">Go to admin</a></p>
623
+ </div>
624
+ </section>
625
+ <section class="explainer">
626
+ <h2>What just happened</h2>
627
+ <ul>
628
+ <li>An admin operator account was created on this hub.</li>
629
+ <li>A vault named <code>${escapeHtml(vaultName)}</code> was installed and started.</li>
630
+ <li>OAuth issuer + JWKS keys were minted (visible at
631
+ <code>/.well-known/oauth-authorization-server</code>).</li>
632
+ </ul>
633
+ <p>This wizard won't come back — the next visitor to <code>/</code>
634
+ sees the discovery page; visitors to <code>/admin</code> are routed
635
+ to <code>/login</code>.</p>
636
+ </section>
637
+ </div>`;
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). Render the full command with
669
+ // the Bearer header pre-filled. The `--header` value is shell-
670
+ // quoted (double quotes) — bash + zsh both consume it as one arg.
671
+ const fullCmd = `${bareCmd} --header "Authorization: Bearer ${mintedToken}"`;
672
+ return `<div class="done-tile">
673
+ <h2>Connect Claude Code (MCP)</h2>
674
+ <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
675
+ <div class="mcp-cmd-wrap">
676
+ <pre id="mcp-cmd">${escapeHtml(fullCmd)}</pre>
677
+ <button type="button" class="btn btn-copy" data-target="mcp-cmd"
678
+ onclick="(function(b){var el=document.getElementById(b.dataset.target);if(!el)return;navigator.clipboard.writeText(el.textContent||'').then(function(){b.textContent='Copied ✓';setTimeout(function(){b.textContent='Copy';},2000);});})(this)">Copy</button>
679
+ </div>
680
+ <p class="fine">We minted this token for your first MCP connection.
681
+ It's a full-scope operator token tied to your admin account; manage
682
+ and revoke tokens at <a href="/admin/tokens"><code>/admin/tokens</code></a>.</p>
683
+ </div>`;
684
+ }
685
+ return `<div class="done-tile">
686
+ <h2>Connect Claude Code (MCP)</h2>
687
+ <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
688
+ <pre>${escapeHtml(bareCmd)}</pre>
689
+ <p class="fine">Mint an operator token at
690
+ <a href="/admin/tokens"><code>/admin/tokens</code></a> and append
691
+ <code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
692
+ </div>`;
693
+ }
694
+
695
+ /**
696
+ * The "What's next?" install-tiles row (hub#272 Item B). One tile per
697
+ * curated module the operator might want next (Notes, Scribe). Each
698
+ * tile is either an install form (POST → /admin/setup/install/<short>
699
+ * → 303 to /admin/setup?op_<short>=<id>) or an op-poll panel mirroring
700
+ * the vault-step's op-poll shape.
701
+ */
702
+ function renderInstallTiles(tiles: readonly ModuleInstallTileState[]): string {
703
+ const items = tiles.map((t) => renderInstallTile(t)).join("");
704
+ return `<section class="install-tiles">
705
+ <h2 class="install-tiles-heading">What's next?</h2>
706
+ <p class="install-tiles-subtitle">Install another module — these run alongside your vault on the same hub.</p>
707
+ <div class="install-grid">${items}</div>
708
+ </section>`;
709
+ }
710
+
711
+ function renderInstallTile(tile: ModuleInstallTileState): string {
712
+ const safeShort = escapeHtml(tile.short);
713
+ const safeName = escapeHtml(tile.displayName);
714
+ const safeTagline = escapeHtml(tile.tagline);
715
+ if (tile.operation) {
716
+ const op = tile.operation;
717
+ const logLines = op.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
718
+ const errBanner = op.error ? `<p class="error-banner">${escapeHtml(op.error)}</p>` : "";
719
+ // Terminal state (succeeded / failed) gets either a confirmation
720
+ // link or a retry form. Pending / running renders the live log
721
+ // panel and relies on the parent `<meta http-equiv="refresh">` for
722
+ // the next tick — no per-tile refresh needed (one full-page reload
723
+ // catches every in-flight op at once).
724
+ let actions = "";
725
+ if (op.status === "succeeded") {
726
+ actions = `<p><a class="btn btn-secondary" href="/admin/modules">Manage modules</a></p>`;
727
+ } else if (op.status === "failed") {
728
+ actions = `<form method="POST" action="/admin/setup/install/${safeShort}" class="install-retry">
729
+ ${renderInstallTileCsrfPlaceholder()}
730
+ <button type="submit" class="btn btn-secondary">Retry install</button>
731
+ </form>`;
732
+ }
733
+ return `<div class="install-tile install-tile-${op.status}">
734
+ <h3>${safeName}</h3>
735
+ <p class="install-tile-tagline">${safeTagline}</p>
736
+ ${errBanner}
737
+ <section class="op-log install-tile-log">
738
+ <p class="op-status op-${op.status}">status: ${op.status}</p>
739
+ <ol class="log-lines">${logLines}</ol>
740
+ </section>
741
+ ${actions}
742
+ </div>`;
743
+ }
744
+ if (tile.alreadyInstalled) {
745
+ return `<div class="install-tile install-tile-installed">
746
+ <h3>${safeName}</h3>
747
+ <p class="install-tile-tagline">${safeTagline}</p>
748
+ <p class="install-tile-status">Already installed.</p>
749
+ <p><a class="btn btn-secondary" href="/admin/modules">Manage in admin</a></p>
750
+ </div>`;
751
+ }
752
+ return `<div class="install-tile">
753
+ <h3>${safeName}</h3>
754
+ <p class="install-tile-tagline">${safeTagline}</p>
755
+ <form method="POST" action="/admin/setup/install/${safeShort}" class="install-tile-form">
756
+ ${renderInstallTileCsrfPlaceholder()}
757
+ <button type="submit" class="btn btn-primary">Install ${safeName}</button>
758
+ </form>
759
+ </div>`;
760
+ }
761
+
762
+ /**
763
+ * CSRF token placeholder for install-tile forms. The token comes from
764
+ * the wizard's per-request CSRF cookie; rendered by the parent step's
765
+ * `csrfToken` plumbing. Threaded through `renderDoneStep` props rather
766
+ * than read here directly because the tile renderer is a pure function
767
+ * the test surface can exercise without a request object.
768
+ *
769
+ * Currently rendered as a marker that the parent renderer rewrites
770
+ * before serving — keeps the per-tile shape pure but avoids dragging
771
+ * a CSRF token argument into every tile-shape function.
772
+ */
773
+ function renderInstallTileCsrfPlaceholder(): string {
774
+ return INSTALL_TILE_CSRF_PLACEHOLDER;
775
+ }
776
+
777
+ const INSTALL_TILE_CSRF_PLACEHOLDER = "__INSTALL_TILE_CSRF__";
778
+
779
+ /**
780
+ * Render the "Your hub is reachable at" tile on the done step, shaped by
781
+ * the operator's expose-mode choice. Always surfaces the loopback URL as
782
+ * an anchor (the operator's own browser hits the wizard on it); the
783
+ * tail-end instructions reframe based on what they picked.
784
+ */
785
+ function renderReachableTile(mode: SetupExposeMode, hubOrigin: string): string {
786
+ const safeOrigin = escapeHtml(hubOrigin);
787
+ if (mode === "localhost") {
788
+ return `<section class="reachable">
789
+ <h2>Your hub is reachable at</h2>
790
+ <p class="reachable-url"><code>${safeOrigin}</code></p>
791
+ <p class="fine">Local to this machine only. Want to share it with your
792
+ other devices? Re-visit setup later from the admin UI or run
793
+ <code>tailscale serve --bg --https=1939 http://localhost:1939</code>
794
+ from a terminal.</p>
795
+ </section>`;
796
+ }
797
+ if (mode === "tailnet") {
798
+ return `<section class="reachable">
799
+ <h2>Your hub is reachable at</h2>
800
+ <p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
801
+ <p class="reachable-url">Plus your tailnet URL once you run:</p>
802
+ <pre>tailscale serve --bg --https=1939 http://localhost:1939</pre>
803
+ <p class="fine">The Tailscale CLI prints the public hostname (e.g.
804
+ <code>my-mac.tailnet-name.ts.net</code>); use that on your phone /
805
+ other devices.</p>
806
+ </section>`;
807
+ }
808
+ // public
809
+ return `<section class="reachable">
810
+ <h2>Your hub is reachable at</h2>
811
+ <p class="reachable-url"><code>${safeOrigin}</code> (loopback, this machine)</p>
812
+ <p class="fine">Wire a reverse proxy on your domain to
813
+ <code>${safeOrigin}</code>, then set <code>PARACHUTE_HUB_ORIGIN</code>
814
+ to your public URL and restart the hub. See the
815
+ <a href="https://parachute.computer/docs/deploy">deploy guide</a>
816
+ for nginx / Caddy / Cloudflare Tunnel examples.</p>
817
+ </section>`;
818
+ }
819
+
820
+ // --- handler entry points ------------------------------------------------
821
+
822
+ /**
823
+ * GET `/admin/setup`. Derives state, renders the appropriate step.
824
+ *
825
+ * Once the wizard's work is done (admin + vault both exist), GET 301s
826
+ * to `/login` so a stale bookmark lands somewhere useful — UNLESS the
827
+ * caller's `?just_finished=1` query is set, in which case we render the
828
+ * step-4 done screen one more time. The wizard's own success redirect
829
+ * uses `?just_finished=1` so the operator sees step 4 even though state
830
+ * is already "complete."
831
+ */
832
+ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
833
+ const url = new URL(req.url);
834
+ const state = deriveWizardState(deps);
835
+ const csrf = ensureCsrfToken(req);
836
+ const extraHeaders: Record<string, string> = {
837
+ "content-type": "text/html; charset=utf-8",
838
+ };
839
+ if (csrf.setCookie) extraHeaders["set-cookie"] = csrf.setCookie;
840
+
841
+ // Setup fully complete (including expose-mode choice) — redirect to
842
+ // /login unless we're rendering the success page once. The success
843
+ // page sets `?just_finished=1` and the session cookie is on the
844
+ // request from step 2.
845
+ if (state.hasAdmin && state.hasVault && state.hasExposeMode) {
846
+ if (url.searchParams.get("just_finished") === "1") {
847
+ // hub#274 security fold: session-gate this branch. The
848
+ // `?just_finished=1` GET reads + consumes `setup_minted_token`
849
+ // (full-scope operator JWT) below; without a session check, any
850
+ // HTTP client that races the operator's browser between the
851
+ // expose POST (which writes the row) and the done GET (which
852
+ // reads it) walks off with admin-scope creds. The dispatcher
853
+ // in `hub-server.ts`'s `shouldGateForSetup` lets `/admin/setup*`
854
+ // through the pre-admin lockout, and that path stays open
855
+ // post-setup — so this gate has to live here, not at the
856
+ // dispatcher layer.
857
+ //
858
+ // A legitimate operator carrying the session cookie minted on
859
+ // the account POST sails through. A drive-by GET without the
860
+ // cookie 302s to /login: if it's a stale bookmark in the
861
+ // operator's other tab, they sign in + the row is already
862
+ // consumed by the legitimate done-GET (the single-use shape
863
+ // guarantees they see the fallback shape, never the secret).
864
+ // If it's an attacker, they can't pass /login without the
865
+ // password.
866
+ const session = findActiveSession(deps.db, req);
867
+ if (!session) {
868
+ // Preserve the CSRF set-cookie header on the 302 — same shape as
869
+ // every other branch of this handler. Without it, a freshly
870
+ // assigned CSRF token would be lost across the redirect, and
871
+ // form posts from a sign-in-then-come-back flow would 400 on
872
+ // their first attempt.
873
+ const redirectHeaders: Record<string, string> = { location: "/login" };
874
+ if (csrf.setCookie) redirectHeaders["set-cookie"] = csrf.setCookie;
875
+ return new Response(null, {
876
+ status: 302,
877
+ headers: redirectHeaders,
878
+ });
879
+ }
880
+ const stored = getSetting(deps.db, "setup_expose_mode");
881
+ const exposeMode = isSetupExposeMode(stored) ? stored : undefined;
882
+ // hub#272 Item A: read + consume the single-use minted-token row.
883
+ // Render-and-forget keeps the secret from re-appearing on
884
+ // refresh / back-button. The mint is non-fatal (see expose POST);
885
+ // its absence renders the bare MCP command + a hint at
886
+ // /admin/tokens.
887
+ const mintedToken = getSetting(deps.db, "setup_minted_token");
888
+ if (mintedToken) deleteSetting(deps.db, "setup_minted_token");
889
+ // hub#267: the operator-typed vault name lives in hub_settings
890
+ // (persisted by handleSetupVaultPost). Fall back to scanning
891
+ // services.json — covers wizard runs from before this PR where
892
+ // setup_vault_name wasn't written. The services.json read
893
+ // returns the path-tail; vault's own first-boot write produces
894
+ // the canonical name so the two should agree once the vault
895
+ // boots authoritatively.
896
+ const storedName = getSetting(deps.db, "setup_vault_name");
897
+ const vaultName = storedName ?? firstVaultName(deps.manifestPath);
898
+ // Module install tiles (hub#272 Item B). One per curated module
899
+ // other than vault (which the wizard already provisioned).
900
+ const installTiles = buildInstallTiles(url, deps);
901
+ const doneProps: RenderDoneStepProps = {
902
+ vaultName,
903
+ hubOrigin: deps.issuer,
904
+ installTiles,
905
+ };
906
+ if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
907
+ if (mintedToken) doneProps.mintedToken = mintedToken;
908
+ // Substitute CSRF placeholder for the install-tile forms with
909
+ // the current CSRF token. Keeping the per-tile renderer pure
910
+ // means the substitution lives here (one rewrite per render).
911
+ const html = renderDoneStep(doneProps).replaceAll(
912
+ INSTALL_TILE_CSRF_PLACEHOLDER,
913
+ renderCsrfHiddenInput(csrf.token),
914
+ );
915
+ return new Response(html, {
916
+ status: 200,
917
+ headers: extraHeaders,
918
+ });
919
+ }
920
+ return new Response(null, { status: 301, headers: { location: "/login" } });
921
+ }
922
+
923
+ // Expose step (hub#268 Item 2). Admin + vault exist, but the operator
924
+ // hasn't picked an expose mode yet. The wizard form posts to
925
+ // /admin/setup/expose. Gated on having an admin session (the session
926
+ // cookie was minted on step 2); on a stale tab without it, the post
927
+ // handler shows the no-session error.
928
+ if (state.hasAdmin && state.hasVault && !state.hasExposeMode) {
929
+ return new Response(renderExposeStep({ csrfToken: csrf.token }), {
930
+ status: 200,
931
+ headers: extraHeaders,
932
+ });
933
+ }
934
+
935
+ // Step 3 (vault) with an op in flight — render the poll page.
936
+ if (state.hasAdmin && !state.hasVault) {
937
+ const opId = url.searchParams.get("op");
938
+ if (opId) {
939
+ const registry = deps.registry;
940
+ const op = registry?.get(opId);
941
+ if (op) {
942
+ return new Response(
943
+ renderVaultStep({
944
+ csrfToken: csrf.token,
945
+ operation: {
946
+ id: op.id,
947
+ status: op.status,
948
+ log: op.log,
949
+ ...(op.error !== undefined ? { error: op.error } : {}),
950
+ },
951
+ }),
952
+ { status: 200, headers: extraHeaders },
953
+ );
954
+ }
955
+ }
956
+ return new Response(renderVaultStep({ csrfToken: csrf.token }), {
957
+ status: 200,
958
+ headers: extraHeaders,
959
+ });
960
+ }
961
+
962
+ // Step 1+2 (no admin yet).
963
+ return new Response(renderAccountStep({ csrfToken: csrf.token }), {
964
+ status: 200,
965
+ headers: extraHeaders,
966
+ });
967
+ }
968
+
969
+ /**
970
+ * POST `/admin/setup/account`. Form-encoded.
971
+ *
972
+ * Validates CSRF + form fields, creates the admin row, mints a session
973
+ * cookie, redirects to `/admin/setup` (which then renders step 3).
974
+ *
975
+ * Rejects if a user already exists — re-arriving here after step 2 is
976
+ * either a stale tab or a malicious double-submit; either way the right
977
+ * answer is "you're done with this step, go to /admin/setup."
978
+ */
979
+ export async function handleSetupAccountPost(
980
+ req: Request,
981
+ deps: SetupWizardDeps,
982
+ ): Promise<Response> {
983
+ const form = await req.formData();
984
+ const formCsrf = form.get(CSRF_FIELD_NAME);
985
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
986
+ return badRequestPage("Invalid form submission", "Reload and try again.");
987
+ }
988
+ // Already-bootstrapped: bounce. The wizard's GET state will resolve to
989
+ // step 3 or step 4 on the next request.
990
+ if (userCount(deps.db) > 0) {
991
+ return redirect("/admin/setup");
992
+ }
993
+ const username = String(form.get("username") ?? "").trim();
994
+ const password = String(form.get("password") ?? "");
995
+ const confirm = String(form.get("password_confirm") ?? "");
996
+ const csrfToken = typeof formCsrf === "string" ? formCsrf : "";
997
+ const fieldErr = validateAccountFields({ username, password, confirm });
998
+ if (fieldErr) {
999
+ return htmlResponse(renderAccountStep({ csrfToken, username, errorMessage: fieldErr }), 400);
1000
+ }
1001
+ try {
1002
+ const user = await createUser(deps.db, username, password);
1003
+ const session = createSession(deps.db, { userId: user.id });
1004
+ const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1005
+ secure: isHttpsRequest(req),
1006
+ });
1007
+ return redirect("/admin/setup", { "set-cookie": cookie });
1008
+ } catch (err) {
1009
+ // Log the raw error server-side for the operator's debugging, but
1010
+ // surface a fixed string to the browser — raw SQLite / argon2
1011
+ // messages leak schema details and aren't actionable for the
1012
+ // person filling out the form. The likely cause for a sane input
1013
+ // is the username-taken UNIQUE collision (createUser raises
1014
+ // UsernameTakenError); other paths (filesystem, argon2 native)
1015
+ // are rare and the same generic message lands the operator at the
1016
+ // right place: retry, or `parachute auth set-password` from the
1017
+ // shell.
1018
+ const msg = err instanceof Error ? err.message : String(err);
1019
+ console.warn(`[setup-wizard] createUser failed for "${username}": ${msg}`);
1020
+ return htmlResponse(
1021
+ renderAccountStep({
1022
+ csrfToken,
1023
+ username,
1024
+ errorMessage: "Failed to create account. The username may already be taken.",
1025
+ }),
1026
+ 400,
1027
+ );
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * POST `/admin/setup/vault`. Form-encoded.
1033
+ *
1034
+ * Gated by the admin session cookie set at step 2 — a stale tab without
1035
+ * the cookie won't accidentally try to provision a vault. The session is
1036
+ * also valid evidence that the operator who created the admin is the
1037
+ * same one driving step 3 (they're necessarily the only user in
1038
+ * single-user mode).
1039
+ *
1040
+ * Drives `runInstall` directly (not the bearer-gated `handleInstall`).
1041
+ * The bearer check exists to keep narrow `:auth`-scope automation
1042
+ * tokens from hitting destructive endpoints; the wizard is already
1043
+ * gated on session + on "no vault exists yet," so a separate
1044
+ * bearer-mint dance would be pure ceremony.
1045
+ *
1046
+ * Returns a 303-redirect to `/admin/setup?op=<id>` so the wizard's
1047
+ * polling GET shape kicks in. The actual `bun add` runs in the
1048
+ * background; failures surface in the op log.
1049
+ */
1050
+ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps): Promise<Response> {
1051
+ if (!deps.supervisor) {
1052
+ return badRequestPage(
1053
+ "Module supervisor unavailable",
1054
+ "The first-boot wizard needs container-mode `parachute serve` to install modules. " +
1055
+ "On the on-box CLI surface, run `parachute install vault` directly.",
1056
+ );
1057
+ }
1058
+ const form = await req.formData();
1059
+ const formCsrf = form.get(CSRF_FIELD_NAME);
1060
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1061
+ return badRequestPage("Invalid form submission", "Reload and try again.");
1062
+ }
1063
+ const session = findActiveSession(deps.db, req);
1064
+ if (!session) {
1065
+ return badRequestPage(
1066
+ "No admin session",
1067
+ "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
1068
+ );
1069
+ }
1070
+ // Already done — short-circuit to the done step.
1071
+ const state = deriveWizardState(deps);
1072
+ if (state.hasVault) return redirect("/admin/setup?just_finished=1");
1073
+
1074
+ // hub#267: the operator-typed vault name is now threaded all the way
1075
+ // through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
1076
+ // shipped the env-var read in vault's `server.ts`). Empty input
1077
+ // falls back to the canonical `DEFAULT_VAULT_NAME` so the "just give
1078
+ // me a vault" path still works without typing anything.
1079
+ const csrfTokenStr = typeof formCsrf === "string" ? formCsrf : "";
1080
+ const rawName = String(form.get("vault_name") ?? "").trim();
1081
+ let vaultName: string;
1082
+ if (rawName === "") {
1083
+ vaultName = DEFAULT_VAULT_NAME;
1084
+ } else {
1085
+ const v = validateVaultName(rawName);
1086
+ if (!v.ok) {
1087
+ return htmlResponse(
1088
+ renderVaultStep({
1089
+ csrfToken: csrfTokenStr,
1090
+ vaultName: rawName,
1091
+ errorMessage: v.error,
1092
+ }),
1093
+ 400,
1094
+ );
1095
+ }
1096
+ vaultName = v.name;
1097
+ }
1098
+ // Persist for the done-step renderer. Vault overwrites services.json
1099
+ // on its first authoritative boot, but until that completes the wizard
1100
+ // needs a stable source of truth for the typed name — both for the
1101
+ // op-poll page subtitle and the post-redirect done step.
1102
+ setSetting(deps.db, "setup_vault_name", vaultName);
1103
+ const registry = deps.registry;
1104
+ const vaultSpec = specFor(FIRST_VAULT_SHORT);
1105
+
1106
+ // Idempotent short-circuit: if the supervisor is already running (or
1107
+ // mid-spawn) for vault — i.e. a previous POST already kicked off
1108
+ // `runInstall` and beat us to spawning — return a synthesized
1109
+ // succeeded op instead of firing a second `bun add -g`. Mirrors the
1110
+ // pattern in `handleInstall` (api-modules-ops.ts). Without this,
1111
+ // two concurrent POSTs both pass `state.hasVault === false` (the
1112
+ // services.json seed is the only signal that step exits, and it's
1113
+ // written by `runInstall` *after* `bun add` returns), and each
1114
+ // fires its own install — wasted work and a possible race on the
1115
+ // seed/spawn writes. Low risk on first-boot in practice, but the
1116
+ // fix is cheap and matches the API surface's posture.
1117
+ const supervisorState = deps.supervisor.get(FIRST_VAULT_SHORT);
1118
+ if (
1119
+ supervisorState?.status === "running" ||
1120
+ supervisorState?.status === "starting" ||
1121
+ supervisorState?.status === "restarting"
1122
+ ) {
1123
+ if (registry) {
1124
+ const op = registry.create("install", FIRST_VAULT_SHORT);
1125
+ registry.update(
1126
+ op.id,
1127
+ { status: "succeeded" },
1128
+ `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
1129
+ );
1130
+ return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
1131
+ }
1132
+ return redirect("/admin/setup");
1133
+ }
1134
+
1135
+ const op = registry
1136
+ ? registry.create("install", FIRST_VAULT_SHORT)
1137
+ : { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
1138
+ if (registry) {
1139
+ // hub#267: thread the typed name through `PARACHUTE_VAULT_NAME` so
1140
+ // vault's first-boot path (vault#342) names the created vault
1141
+ // accordingly. Skip the env override when the operator left the
1142
+ // field blank — vault's `resolveFirstBootVaultName` defaults to
1143
+ // `default` on absent env vars, so this preserves the prior
1144
+ // behaviour for the empty-input case.
1145
+ //
1146
+ // If the operator typed "default" explicitly, treat the same as
1147
+ // blank — vault's first-boot defaults to "default" anyway, so
1148
+ // skipping the env override is correct (the comparison below
1149
+ // catches both blank-trimmed-to-DEFAULT and typed-"default").
1150
+ const spawnEnv: Record<string, string> = {};
1151
+ if (vaultName !== DEFAULT_VAULT_NAME) {
1152
+ spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
1153
+ }
1154
+ void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
1155
+ db: deps.db,
1156
+ issuer: deps.issuer,
1157
+ manifestPath: deps.manifestPath,
1158
+ configDir: deps.configDir,
1159
+ supervisor: deps.supervisor,
1160
+ registry,
1161
+ ...(deps.run ? { run: deps.run } : {}),
1162
+ ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
1163
+ }).catch((err) => {
1164
+ const msg = err instanceof Error ? err.message : String(err);
1165
+ registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
1166
+ });
1167
+ } else {
1168
+ // No registry wired (test-only path; production always passes one).
1169
+ // Log a visible warning so future mis-wirings are debuggable —
1170
+ // silent swallow here would make the wizard appear to hang.
1171
+ console.warn(
1172
+ "[setup-wizard] handleSetupVaultPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
1173
+ );
1174
+ }
1175
+ return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
1176
+ }
1177
+
1178
+ /**
1179
+ * POST `/admin/setup/expose`. Form-encoded.
1180
+ *
1181
+ * Persists the operator's "how will this hub be reached?" answer to
1182
+ * `hub_settings.setup_expose_mode` (hub#268 Item 2). Three valid values:
1183
+ * `localhost`, `tailnet`, `public`.
1184
+ *
1185
+ * This is also the transition where the wizard considers itself "done"
1186
+ * for the auto-approve-first-client feature (hub#268 Item 3): we open a
1187
+ * 60-minute window where the next OAuth client registration is
1188
+ * auto-approved. Reasoning lives in `hub-settings.ts`; the wizard just
1189
+ * fires it on the only event that means "operator just finished the
1190
+ * canonical onboarding."
1191
+ *
1192
+ * Gated on an admin session cookie like the vault POST is — same shape,
1193
+ * same reason.
1194
+ */
1195
+ export async function handleSetupExposePost(
1196
+ req: Request,
1197
+ deps: SetupWizardDeps,
1198
+ ): Promise<Response> {
1199
+ const form = await req.formData();
1200
+ const formCsrf = form.get(CSRF_FIELD_NAME);
1201
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1202
+ return badRequestPage("Invalid form submission", "Reload and try again.");
1203
+ }
1204
+ const session = findActiveSession(deps.db, req);
1205
+ if (!session) {
1206
+ return badRequestPage(
1207
+ "No admin session",
1208
+ "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
1209
+ );
1210
+ }
1211
+ // Already done — short-circuit to the success screen. Belt-and-braces:
1212
+ // the wizard's GET shape catches this case too, but a direct POST
1213
+ // (curl, tab race) shouldn't double-fire the auto-approve window.
1214
+ if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
1215
+ return redirect("/admin/setup?just_finished=1");
1216
+ }
1217
+ const rawMode = form.get("expose_mode");
1218
+ if (!isSetupExposeMode(rawMode)) {
1219
+ return htmlResponse(
1220
+ renderExposeStep({
1221
+ csrfToken: typeof formCsrf === "string" ? formCsrf : "",
1222
+ errorMessage: `Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
1223
+ }),
1224
+ 400,
1225
+ );
1226
+ }
1227
+ setSetting(deps.db, "setup_expose_mode", rawMode);
1228
+ // hub#268 Item 3: open the 60-minute auto-approve window for the first
1229
+ // OAuth client registration. Logged so an operator chasing odd behavior
1230
+ // can see it fired.
1231
+ openFirstClientAutoApproveWindow(deps.db);
1232
+ console.log(
1233
+ `[setup-wizard] opened first-client auto-approve window (60min) after expose-mode=${rawMode}`,
1234
+ );
1235
+ // hub#272 Item A: auto-mint an operator token under the broad `admin`
1236
+ // scope-set + persist it once so the done-step renderer can pre-fill
1237
+ // the MCP install command with a Bearer header. The token is single-
1238
+ // use surface on the done page — the renderer deletes it from
1239
+ // hub_settings after one read so a stale tab refresh / back button
1240
+ // doesn't re-disclose the secret. The jti is still in the `tokens`
1241
+ // registry so revocation via the admin UI works as usual. Failures
1242
+ // are non-fatal: the done page falls back to the un-headered MCP
1243
+ // command + a "mint manually at /admin/tokens" hint.
1244
+ try {
1245
+ const minted = await mintOperatorToken(deps.db, session.userId, {
1246
+ issuer: deps.issuer,
1247
+ scopeSet: "admin",
1248
+ });
1249
+ setSetting(deps.db, "setup_minted_token", minted.token);
1250
+ console.log(
1251
+ `[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
1252
+ );
1253
+ } catch (err) {
1254
+ const msg = err instanceof Error ? err.message : String(err);
1255
+ console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
1256
+ }
1257
+ return redirect("/admin/setup?just_finished=1");
1258
+ }
1259
+
1260
+ // --- step 5 helpers: install tiles --------------------------------------
1261
+
1262
+ /**
1263
+ * Curated module short → display props rendered on the done-screen
1264
+ * install tiles. Order matters — list order is render order. Vault is
1265
+ * intentionally excluded (the wizard already provisioned it).
1266
+ *
1267
+ * `tagline` mirrors each module's `displayName + tagline` from
1268
+ * `FIRST_PARTY_FALLBACKS` (`src/service-spec.ts`); kept verbatim here
1269
+ * so the wizard isn't coupled to service-spec internals.
1270
+ */
1271
+ const INSTALL_TILE_PROPS: ReadonlyArray<{
1272
+ short: CuratedModuleShort;
1273
+ displayName: string;
1274
+ tagline: string;
1275
+ }> = [
1276
+ { short: "notes", displayName: "Notes", tagline: "Notes PWA backed by your vault." },
1277
+ {
1278
+ short: "scribe",
1279
+ displayName: "Scribe",
1280
+ tagline: "Local audio transcription for vault recordings.",
1281
+ },
1282
+ ];
1283
+
1284
+ /**
1285
+ * Construct the install-tile state array for the done step. Reads the
1286
+ * URL's `?op_<short>=<id>` query (per-module op-poll), the services.json
1287
+ * manifest (already-installed detection), and the operations registry
1288
+ * (op status snapshot). Pure-ish — only the registry call is impure.
1289
+ */
1290
+ function buildInstallTiles(url: URL, deps: SetupWizardDeps): ModuleInstallTileState[] {
1291
+ const manifest = readManifest(deps.manifestPath);
1292
+ return INSTALL_TILE_PROPS.filter((p) =>
1293
+ (CURATED_MODULES as readonly string[]).includes(p.short),
1294
+ ).map((p) => {
1295
+ const spec = specFor(p.short);
1296
+ const alreadyInstalled = manifest.services.some((s) => s.name === spec.manifestName);
1297
+ const tile: ModuleInstallTileState = {
1298
+ short: p.short,
1299
+ displayName: p.displayName,
1300
+ tagline: p.tagline,
1301
+ alreadyInstalled,
1302
+ };
1303
+ const opId = url.searchParams.get(`op_${p.short}`);
1304
+ if (opId && deps.registry) {
1305
+ const op = deps.registry.get(opId);
1306
+ if (op) {
1307
+ tile.operation = {
1308
+ id: op.id,
1309
+ status: op.status,
1310
+ log: op.log,
1311
+ ...(op.error !== undefined ? { error: op.error } : {}),
1312
+ };
1313
+ }
1314
+ }
1315
+ return tile;
1316
+ });
1317
+ }
1318
+
1319
+ /**
1320
+ * POST `/admin/setup/install/<short>`. Form-encoded, session-gated.
1321
+ *
1322
+ * Kicks off the same `runInstall` pipeline `/api/modules/<short>/install`
1323
+ * uses (hub#260) but from the wizard's session-cookie surface — no
1324
+ * separate bearer mint dance for the operator who just finished the
1325
+ * wizard.
1326
+ *
1327
+ * Returns 303 to `/admin/setup?just_finished=1&op_<short>=<opId>` so
1328
+ * the done-screen renderer picks up the op via `buildInstallTiles`.
1329
+ * Multiple in-flight installs are supported (query keeps `op_<short>`
1330
+ * per module); the auto-refresh meta keeps polling while any module
1331
+ * is pending/running.
1332
+ *
1333
+ * Rejects when:
1334
+ * * `short` isn't a curated module short
1335
+ * * `short === "vault"` — the wizard's vault step owns that
1336
+ * * session cookie missing
1337
+ * * CSRF token missing or wrong
1338
+ * * supervisor isn't wired (CLI-mode hub)
1339
+ */
1340
+ export async function handleSetupInstallPost(
1341
+ req: Request,
1342
+ short: string,
1343
+ deps: SetupWizardDeps,
1344
+ ): Promise<Response> {
1345
+ if (!deps.supervisor) {
1346
+ return badRequestPage(
1347
+ "Module supervisor unavailable",
1348
+ `Module installs from the wizard require container-mode \`parachute serve\`. On the on-box CLI surface, run \`parachute install ${short}\` directly.`,
1349
+ );
1350
+ }
1351
+ if (!(CURATED_MODULES as readonly string[]).includes(short) || short === "vault") {
1352
+ return badRequestPage(
1353
+ "Unknown module",
1354
+ `"${short}" is not an installable wizard module. Pick from the done-screen tiles.`,
1355
+ );
1356
+ }
1357
+ const form = await req.formData();
1358
+ const formCsrf = form.get(CSRF_FIELD_NAME);
1359
+ if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1360
+ return badRequestPage("Invalid form submission", "Reload and try again.");
1361
+ }
1362
+ const session = findActiveSession(deps.db, req);
1363
+ if (!session) {
1364
+ return badRequestPage(
1365
+ "No admin session",
1366
+ "Sign in to continue. The wizard's session cookie was set at step 2; clearing cookies between steps lands you here.",
1367
+ );
1368
+ }
1369
+ const moduleShort = short as CuratedModuleShort;
1370
+ const spec = specFor(moduleShort);
1371
+ const registry = deps.registry;
1372
+ // Idempotent short-circuit: if already supervised + running, return a
1373
+ // synthesized succeeded op rather than firing a second `bun add`.
1374
+ // Mirrors `handleSetupVaultPost` + `handleInstall`.
1375
+ const supervisorState = deps.supervisor.get(moduleShort);
1376
+ if (
1377
+ supervisorState?.status === "running" ||
1378
+ supervisorState?.status === "starting" ||
1379
+ supervisorState?.status === "restarting"
1380
+ ) {
1381
+ if (registry) {
1382
+ const op = registry.create("install", moduleShort);
1383
+ registry.update(
1384
+ op.id,
1385
+ { status: "succeeded" },
1386
+ `${moduleShort} already supervised (status=${supervisorState.status})`,
1387
+ );
1388
+ return redirect(
1389
+ `/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`,
1390
+ );
1391
+ }
1392
+ return redirect("/admin/setup?just_finished=1");
1393
+ }
1394
+ const op = registry
1395
+ ? registry.create("install", moduleShort)
1396
+ : { id: cryptoRandomId(), status: "pending" as const, log: [] as string[] };
1397
+ if (registry) {
1398
+ void runInstall(op.id, moduleShort, spec, {
1399
+ db: deps.db,
1400
+ issuer: deps.issuer,
1401
+ manifestPath: deps.manifestPath,
1402
+ configDir: deps.configDir,
1403
+ supervisor: deps.supervisor,
1404
+ registry,
1405
+ ...(deps.run ? { run: deps.run } : {}),
1406
+ }).catch((err) => {
1407
+ const msg = err instanceof Error ? err.message : String(err);
1408
+ registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
1409
+ });
1410
+ } else {
1411
+ console.warn(
1412
+ "[setup-wizard] handleSetupInstallPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
1413
+ );
1414
+ }
1415
+ return redirect(`/admin/setup?just_finished=1&op_${moduleShort}=${encodeURIComponent(op.id)}`);
1416
+ }
1417
+
1418
+ // --- helpers ------------------------------------------------------------
1419
+
1420
+ function validateAccountFields(input: {
1421
+ username: string;
1422
+ password: string;
1423
+ confirm: string;
1424
+ }): string | undefined {
1425
+ if (input.username.length < 2 || input.username.length > 64) {
1426
+ return "Username must be 2–64 characters.";
1427
+ }
1428
+ if (!/^[A-Za-z0-9_.-]+$/.test(input.username)) {
1429
+ return "Username may use letters, digits, underscore, period, hyphen.";
1430
+ }
1431
+ if (input.password.length < 8) {
1432
+ return "Password must be at least 8 characters.";
1433
+ }
1434
+ if (input.password !== input.confirm) {
1435
+ return "Passwords do not match.";
1436
+ }
1437
+ return undefined;
1438
+ }
1439
+
1440
+ /**
1441
+ * Read the first vault's display name from services.json for the
1442
+ * step-4 success page. Falls back to "default" if for any reason the
1443
+ * entry's metadata isn't present.
1444
+ */
1445
+ function firstVaultName(manifestPath: string): string {
1446
+ const manifest = readManifest(manifestPath);
1447
+ // Match on the canonical vault manifestName from the curated spec.
1448
+ // (`CURATED_MODULES.includes("vault")` was a dead guard — vault is a
1449
+ // tuple-literal member, so the conjunct is always true.)
1450
+ const entry = manifest.services.find((s) => s.name === specFor("vault").manifestName);
1451
+ if (!entry) return "default";
1452
+ // services.json entries store the mount path (e.g. `/vault/default`).
1453
+ // Strip the canonical prefix to surface the display name.
1454
+ for (const p of entry.paths ?? []) {
1455
+ if (p.startsWith("/vault/")) {
1456
+ const tail = p.slice("/vault/".length).replace(/\/+$/, "");
1457
+ if (tail.length > 0) return tail;
1458
+ }
1459
+ }
1460
+ return "default";
1461
+ }
1462
+
1463
+ function htmlResponse(body: string, status = 200, extra: Record<string, string> = {}): Response {
1464
+ return new Response(body, {
1465
+ status,
1466
+ headers: { "content-type": "text/html; charset=utf-8", ...extra },
1467
+ });
1468
+ }
1469
+
1470
+ function redirect(location: string, extra: Record<string, string> = {}): Response {
1471
+ return new Response(null, { status: 303, headers: { location, ...extra } });
1472
+ }
1473
+
1474
+ function badRequestPage(title: string, message: string): Response {
1475
+ return htmlResponse(renderBadRequestPage(title, message), 400);
1476
+ }
1477
+
1478
+ function renderBadRequestPage(title: string, message: string): string {
1479
+ const body = `
1480
+ <div class="card">
1481
+ ${header("welcome")}
1482
+ <h1 class="error-title">${escapeHtml(title)}</h1>
1483
+ <p class="subtitle">${escapeHtml(message)}</p>
1484
+ <p><a class="btn btn-primary" href="/admin/setup">Restart setup</a></p>
1485
+ </div>`;
1486
+ return baseDocument(title, body);
1487
+ }
1488
+
1489
+ /**
1490
+ * Fallback op id when no registry is wired — the wizard's UX still
1491
+ * needs *something* to redirect to so the page doesn't hang. The
1492
+ * redirect's `op` query then resolves to "no op found," which renders
1493
+ * the bare step-3 form again. Production callers always pass a
1494
+ * registry (the dispatcher in `hub-server.ts` plugs in
1495
+ * `getDefaultOperationsRegistry()`); this branch is exercised only by
1496
+ * tests that deliberately omit it. `handleSetupVaultPost` logs a
1497
+ * `console.warn` when it takes this branch so a real-world
1498
+ * mis-wiring surfaces in the operator's logs instead of silently
1499
+ * swallowing the install.
1500
+ */
1501
+ function cryptoRandomId(): string {
1502
+ return `op-${Math.random().toString(36).slice(2, 10)}`;
1503
+ }
1504
+
1505
+ // --- styles -------------------------------------------------------------
1506
+
1507
+ const STYLES = `
1508
+ *, *::before, *::after { box-sizing: border-box; }
1509
+ html, body { margin: 0; padding: 0; }
1510
+ body {
1511
+ font-family: ${FONT_SANS};
1512
+ background: ${PALETTE.bg};
1513
+ color: ${PALETTE.fg};
1514
+ line-height: 1.55;
1515
+ min-height: 100vh;
1516
+ -webkit-font-smoothing: antialiased;
1517
+ -moz-osx-font-smoothing: grayscale;
1518
+ }
1519
+ main {
1520
+ display: flex;
1521
+ align-items: center;
1522
+ justify-content: center;
1523
+ min-height: 100vh;
1524
+ padding: 1.5rem;
1525
+ }
1526
+ .card {
1527
+ width: 100%;
1528
+ max-width: 38rem;
1529
+ background: ${PALETTE.cardBg};
1530
+ border: 1px solid ${PALETTE.border};
1531
+ border-radius: 12px;
1532
+ padding: 2rem 1.75rem;
1533
+ box-shadow: 0 1px 2px rgba(44, 42, 38, 0.04), 0 8px 24px rgba(44, 42, 38, 0.06);
1534
+ }
1535
+ .card-header { margin-bottom: 1.25rem; }
1536
+ .brand {
1537
+ display: flex;
1538
+ align-items: center;
1539
+ gap: 0.5rem;
1540
+ color: ${PALETTE.accent};
1541
+ font-weight: 500;
1542
+ font-size: 0.95rem;
1543
+ margin-bottom: 0.5rem;
1544
+ }
1545
+ .brand-mark { font-size: 1.1rem; line-height: 1; }
1546
+ .brand-name { letter-spacing: 0.01em; }
1547
+ .brand-tag {
1548
+ text-transform: uppercase;
1549
+ letter-spacing: 0.06em;
1550
+ font-size: 0.7rem;
1551
+ color: ${PALETTE.fgMuted};
1552
+ border: 1px solid ${PALETTE.borderLight};
1553
+ padding: 0.05rem 0.4rem;
1554
+ border-radius: 999px;
1555
+ }
1556
+ .steps {
1557
+ list-style: none;
1558
+ padding: 0;
1559
+ margin: 0 0 1rem;
1560
+ display: flex;
1561
+ gap: 0.4rem;
1562
+ font-size: 0.8rem;
1563
+ color: ${PALETTE.fgDim};
1564
+ flex-wrap: wrap;
1565
+ }
1566
+ .step {
1567
+ display: inline-flex;
1568
+ align-items: center;
1569
+ gap: 0.35rem;
1570
+ }
1571
+ .step + .step::before {
1572
+ content: "→";
1573
+ color: ${PALETTE.fgDim};
1574
+ margin-right: 0.2rem;
1575
+ }
1576
+ .step-marker {
1577
+ display: inline-flex;
1578
+ align-items: center;
1579
+ justify-content: center;
1580
+ width: 1.25rem;
1581
+ height: 1.25rem;
1582
+ border-radius: 999px;
1583
+ background: ${PALETTE.borderLight};
1584
+ color: ${PALETTE.fgMuted};
1585
+ font-size: 0.7rem;
1586
+ font-family: ${FONT_MONO};
1587
+ }
1588
+ .step.current .step-marker {
1589
+ background: ${PALETTE.accent};
1590
+ color: ${PALETTE.cardBg};
1591
+ }
1592
+ .step.past .step-marker {
1593
+ background: ${PALETTE.success};
1594
+ color: ${PALETTE.cardBg};
1595
+ }
1596
+ .step.current .step-label { color: ${PALETTE.fg}; font-weight: 500; }
1597
+ h1 {
1598
+ font-family: ${FONT_SERIF};
1599
+ font-weight: 400;
1600
+ font-size: 1.75rem;
1601
+ line-height: 1.2;
1602
+ margin: 0 0 0.4rem;
1603
+ color: ${PALETTE.fg};
1604
+ }
1605
+ h2 {
1606
+ font-family: ${FONT_SANS};
1607
+ font-size: 0.85rem;
1608
+ font-weight: 600;
1609
+ text-transform: uppercase;
1610
+ letter-spacing: 0.06em;
1611
+ color: ${PALETTE.fgMuted};
1612
+ margin: 1.25rem 0 0.4rem;
1613
+ }
1614
+ .subtitle { margin: 0 0 0.5rem; color: ${PALETTE.fgMuted}; font-size: 0.95rem; }
1615
+ .explainer {
1616
+ background: ${PALETTE.accentSoft};
1617
+ border: 1px solid ${PALETTE.borderLight};
1618
+ border-radius: 8px;
1619
+ padding: 0.5rem 1rem;
1620
+ margin: 1rem 0 1.25rem;
1621
+ font-size: 0.92rem;
1622
+ }
1623
+ .explainer h2 { margin-top: 0.75rem; }
1624
+ .explainer p { margin: 0 0 0.5rem; }
1625
+ .preview {
1626
+ margin: 1rem 0;
1627
+ }
1628
+ .preview-label {
1629
+ font-size: 0.75rem;
1630
+ text-transform: uppercase;
1631
+ letter-spacing: 0.06em;
1632
+ color: ${PALETTE.fgMuted};
1633
+ margin: 0 0 0.25rem;
1634
+ }
1635
+ .preview-card {
1636
+ background: ${PALETTE.warnSoft};
1637
+ border-left: 3px solid ${PALETTE.warn};
1638
+ padding: 0.6rem 0.9rem;
1639
+ border-radius: 0 6px 6px 0;
1640
+ font-family: ${FONT_MONO};
1641
+ font-size: 0.9rem;
1642
+ }
1643
+ .preview-key { color: ${PALETTE.fgMuted}; }
1644
+ .preview-val { font-weight: 600; color: ${PALETTE.fg}; }
1645
+ .preview-fine { color: ${PALETTE.fgMuted}; font-size: 0.85em; }
1646
+
1647
+ .auth-form { display: flex; flex-direction: column; gap: 0.9rem; }
1648
+ .field { display: flex; flex-direction: column; gap: 0.35rem; }
1649
+ .field-label {
1650
+ font-size: 0.85rem;
1651
+ font-weight: 500;
1652
+ color: ${PALETTE.fgMuted};
1653
+ letter-spacing: 0.01em;
1654
+ font-family: ${FONT_MONO};
1655
+ }
1656
+ .field-hint {
1657
+ font-size: 0.78rem;
1658
+ color: ${PALETTE.fgDim};
1659
+ }
1660
+ input[type=text], input[type=password] {
1661
+ font: inherit;
1662
+ width: 100%;
1663
+ padding: 0.6rem 0.75rem;
1664
+ border: 1px solid ${PALETTE.border};
1665
+ border-radius: 6px;
1666
+ background: ${PALETTE.bg};
1667
+ color: ${PALETTE.fg};
1668
+ transition: border-color 0.15s ease, background 0.15s ease;
1669
+ }
1670
+ input[type=text]:focus, input[type=password]:focus {
1671
+ outline: none;
1672
+ border-color: ${PALETTE.accent};
1673
+ background: ${PALETTE.cardBg};
1674
+ box-shadow: 0 0 0 3px ${PALETTE.accentSoft};
1675
+ }
1676
+ .btn {
1677
+ font: inherit;
1678
+ font-weight: 500;
1679
+ padding: 0.65rem 1.25rem;
1680
+ border-radius: 6px;
1681
+ border: 1px solid transparent;
1682
+ cursor: pointer;
1683
+ transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
1684
+ min-height: 2.5rem;
1685
+ text-decoration: none;
1686
+ display: inline-flex;
1687
+ align-items: center;
1688
+ justify-content: center;
1689
+ }
1690
+ .btn-primary {
1691
+ background: ${PALETTE.accent};
1692
+ color: ${PALETTE.cardBg};
1693
+ margin-top: 0.4rem;
1694
+ }
1695
+ .btn-primary:hover { background: ${PALETTE.accentHover}; }
1696
+ .btn-secondary {
1697
+ background: transparent;
1698
+ color: ${PALETTE.accent};
1699
+ border-color: ${PALETTE.accent};
1700
+ }
1701
+ .btn-secondary:hover {
1702
+ background: ${PALETTE.accentSoft};
1703
+ }
1704
+ /* Copy button rides at the right edge of the MCP command pre. Compact
1705
+ vertical sizing so it doesn't dwarf the snippet on narrow widths;
1706
+ full text wrap on the pre keeps the snippet readable behind it. */
1707
+ .mcp-cmd-wrap {
1708
+ position: relative;
1709
+ margin: 0.5rem 0;
1710
+ }
1711
+ .mcp-cmd-wrap pre {
1712
+ background: ${PALETTE.bg};
1713
+ border: 1px solid ${PALETTE.borderLight};
1714
+ border-radius: 6px;
1715
+ padding: 0.5rem 5.5rem 0.5rem 0.75rem;
1716
+ overflow-x: auto;
1717
+ font-size: 0.82rem;
1718
+ margin: 0;
1719
+ white-space: pre-wrap;
1720
+ word-break: break-all;
1721
+ }
1722
+ .btn-copy {
1723
+ position: absolute;
1724
+ top: 0.35rem;
1725
+ right: 0.35rem;
1726
+ padding: 0.25rem 0.6rem;
1727
+ font-size: 0.78rem;
1728
+ min-height: auto;
1729
+ background: ${PALETTE.cardBg};
1730
+ color: ${PALETTE.fg};
1731
+ border: 1px solid ${PALETTE.border};
1732
+ border-radius: 4px;
1733
+ cursor: pointer;
1734
+ }
1735
+ .btn-copy:hover {
1736
+ border-color: ${PALETTE.accent};
1737
+ color: ${PALETTE.accent};
1738
+ }
1739
+ /* Install-tile section (hub#272 Item B). Lives above the .done-grid;
1740
+ primary "what's next?" surface. Tiles render in a responsive grid
1741
+ that collapses to one column on narrow viewports. */
1742
+ .install-tiles {
1743
+ margin: 1rem 0 1.25rem;
1744
+ }
1745
+ .install-tiles-heading {
1746
+ margin: 0 0 0.25rem;
1747
+ text-transform: none;
1748
+ letter-spacing: 0;
1749
+ font-size: 1.05rem;
1750
+ color: ${PALETTE.fg};
1751
+ }
1752
+ .install-tiles-subtitle {
1753
+ margin: 0 0 0.75rem;
1754
+ color: ${PALETTE.fgMuted};
1755
+ font-size: 0.9rem;
1756
+ }
1757
+ .install-grid {
1758
+ display: grid;
1759
+ grid-template-columns: 1fr;
1760
+ gap: 0.75rem;
1761
+ }
1762
+ @media (min-width: 30rem) {
1763
+ .install-grid { grid-template-columns: 1fr 1fr; }
1764
+ }
1765
+ .install-tile {
1766
+ border: 1px solid ${PALETTE.borderLight};
1767
+ border-radius: 8px;
1768
+ padding: 0.75rem 0.9rem;
1769
+ background: ${PALETTE.cardBg};
1770
+ display: flex;
1771
+ flex-direction: column;
1772
+ gap: 0.4rem;
1773
+ }
1774
+ .install-tile h3 {
1775
+ margin: 0;
1776
+ font-family: ${FONT_SERIF};
1777
+ font-weight: 400;
1778
+ font-size: 1.1rem;
1779
+ color: ${PALETTE.fg};
1780
+ }
1781
+ .install-tile-tagline {
1782
+ margin: 0;
1783
+ color: ${PALETTE.fgMuted};
1784
+ font-size: 0.85rem;
1785
+ }
1786
+ .install-tile-form {
1787
+ margin: 0;
1788
+ }
1789
+ .install-tile-installed {
1790
+ background: ${PALETTE.accentSoft};
1791
+ border-color: ${PALETTE.accent};
1792
+ }
1793
+ .install-tile-status {
1794
+ margin: 0;
1795
+ color: ${PALETTE.success};
1796
+ font-weight: 500;
1797
+ font-size: 0.85rem;
1798
+ }
1799
+ .install-tile-running, .install-tile-pending {
1800
+ border-color: ${PALETTE.warn};
1801
+ }
1802
+ .install-tile-succeeded {
1803
+ background: ${PALETTE.accentSoft};
1804
+ border-color: ${PALETTE.accent};
1805
+ }
1806
+ .install-tile-failed {
1807
+ border-color: ${PALETTE.danger};
1808
+ background: ${PALETTE.dangerSoft};
1809
+ }
1810
+ .install-tile-log {
1811
+ margin: 0;
1812
+ font-size: 0.78rem;
1813
+ }
1814
+ .alt-path {
1815
+ margin-top: 1.25rem;
1816
+ border-top: 1px solid ${PALETTE.borderLight};
1817
+ padding-top: 0.75rem;
1818
+ font-size: 0.88rem;
1819
+ color: ${PALETTE.fgMuted};
1820
+ }
1821
+ .alt-path summary {
1822
+ cursor: pointer;
1823
+ font-weight: 500;
1824
+ color: ${PALETTE.fgMuted};
1825
+ }
1826
+ .alt-path p { margin: 0.5rem 0 0; }
1827
+
1828
+ .error-banner {
1829
+ background: ${PALETTE.dangerSoft};
1830
+ border: 1px solid ${PALETTE.danger};
1831
+ border-radius: 6px;
1832
+ color: ${PALETTE.danger};
1833
+ padding: 0.6rem 0.8rem;
1834
+ margin: 0 0 1rem;
1835
+ font-size: 0.9rem;
1836
+ }
1837
+ .error-title { color: ${PALETTE.danger}; }
1838
+
1839
+ .op-log {
1840
+ background: ${PALETTE.bg};
1841
+ border: 1px solid ${PALETTE.borderLight};
1842
+ border-radius: 8px;
1843
+ padding: 0.75rem 1rem;
1844
+ margin: 1rem 0;
1845
+ font-family: ${FONT_MONO};
1846
+ font-size: 0.85rem;
1847
+ }
1848
+ .op-status {
1849
+ margin: 0 0 0.5rem;
1850
+ font-weight: 600;
1851
+ color: ${PALETTE.fgMuted};
1852
+ }
1853
+ .op-succeeded { color: ${PALETTE.success}; }
1854
+ .op-failed { color: ${PALETTE.danger}; }
1855
+ .log-lines { margin: 0; padding-left: 1.25rem; color: ${PALETTE.fgMuted}; }
1856
+ .log-lines li { margin: 0.15rem 0; }
1857
+
1858
+ .done-grid {
1859
+ display: grid;
1860
+ grid-template-columns: 1fr;
1861
+ gap: 1rem;
1862
+ margin: 1rem 0;
1863
+ }
1864
+ @media (min-width: 36rem) {
1865
+ .done-grid { grid-template-columns: 1fr 1fr; }
1866
+ }
1867
+ .done-tile {
1868
+ border: 1px solid ${PALETTE.borderLight};
1869
+ border-radius: 8px;
1870
+ padding: 0.75rem 1rem;
1871
+ }
1872
+ .done-tile h2 {
1873
+ margin-top: 0;
1874
+ text-transform: none;
1875
+ letter-spacing: 0;
1876
+ font-size: 1.05rem;
1877
+ color: ${PALETTE.fg};
1878
+ }
1879
+ .done-tile pre {
1880
+ background: ${PALETTE.bg};
1881
+ border: 1px solid ${PALETTE.borderLight};
1882
+ border-radius: 6px;
1883
+ padding: 0.5rem 0.75rem;
1884
+ overflow-x: auto;
1885
+ font-size: 0.82rem;
1886
+ margin: 0.5rem 0;
1887
+ }
1888
+ .done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
1889
+
1890
+ /* expose step (hub#268 Item 2). Vertical stack of radio cards;
1891
+ each label is the full clickable hit target. */
1892
+ .expose-form { gap: 0.65rem; }
1893
+ .expose-option {
1894
+ display: flex;
1895
+ align-items: flex-start;
1896
+ gap: 0.65rem;
1897
+ padding: 0.85rem 1rem;
1898
+ border: 1px solid ${PALETTE.border};
1899
+ border-radius: 8px;
1900
+ cursor: pointer;
1901
+ transition: border-color 0.15s ease, background 0.15s ease;
1902
+ background: ${PALETTE.cardBg};
1903
+ }
1904
+ .expose-option:hover { border-color: ${PALETTE.accent}; }
1905
+ .expose-option input[type=radio] {
1906
+ margin-top: 0.25rem;
1907
+ accent-color: ${PALETTE.accent};
1908
+ flex-shrink: 0;
1909
+ }
1910
+ .expose-option-body {
1911
+ display: flex;
1912
+ flex-direction: column;
1913
+ gap: 0.25rem;
1914
+ min-width: 0;
1915
+ }
1916
+ .expose-option-title {
1917
+ font-weight: 600;
1918
+ color: ${PALETTE.fg};
1919
+ font-size: 0.95rem;
1920
+ }
1921
+ .expose-option-desc {
1922
+ color: ${PALETTE.fgMuted};
1923
+ font-size: 0.88rem;
1924
+ line-height: 1.45;
1925
+ }
1926
+ .expose-option-cmd {
1927
+ background: ${PALETTE.bg};
1928
+ border: 1px solid ${PALETTE.borderLight};
1929
+ border-radius: 6px;
1930
+ padding: 0.4rem 0.6rem;
1931
+ font-family: ${FONT_MONO};
1932
+ font-size: 0.82rem;
1933
+ margin: 0.25rem 0;
1934
+ overflow-x: auto;
1935
+ }
1936
+
1937
+ /* reachable tile on the done step. Lives outside the .done-grid so it
1938
+ spans the full width — the URL itself is the headline. */
1939
+ .reachable {
1940
+ background: ${PALETTE.accentSoft};
1941
+ border-left: 3px solid ${PALETTE.accent};
1942
+ border-radius: 0 8px 8px 0;
1943
+ padding: 0.75rem 1rem;
1944
+ margin: 0 0 1rem;
1945
+ }
1946
+ .reachable h2 {
1947
+ margin: 0 0 0.4rem;
1948
+ text-transform: none;
1949
+ letter-spacing: 0;
1950
+ font-size: 0.9rem;
1951
+ color: ${PALETTE.accent};
1952
+ }
1953
+ .reachable-url {
1954
+ margin: 0.2rem 0;
1955
+ font-size: 0.95rem;
1956
+ }
1957
+ .reachable-url code {
1958
+ background: ${PALETTE.cardBg};
1959
+ border: 1px solid ${PALETTE.borderLight};
1960
+ padding: 0.1rem 0.4rem;
1961
+ border-radius: 4px;
1962
+ }
1963
+ .reachable pre {
1964
+ background: ${PALETTE.cardBg};
1965
+ border: 1px solid ${PALETTE.borderLight};
1966
+ border-radius: 6px;
1967
+ padding: 0.5rem 0.75rem;
1968
+ overflow-x: auto;
1969
+ font-size: 0.82rem;
1970
+ margin: 0.4rem 0;
1971
+ }
1972
+ .reachable .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
1973
+
1974
+ code {
1975
+ background: ${PALETTE.borderLight};
1976
+ padding: 0.05rem 0.3rem;
1977
+ border-radius: 4px;
1978
+ font-family: ${FONT_MONO};
1979
+ font-size: 0.92em;
1980
+ }
1981
+ pre code {
1982
+ background: transparent;
1983
+ padding: 0;
1984
+ }
1985
+
1986
+ @media (max-width: 480px) {
1987
+ main { padding: 0.75rem; }
1988
+ .card { padding: 1.5rem 1.25rem; border-radius: 10px; }
1989
+ h1 { font-size: 1.5rem; }
1990
+ }
1991
+
1992
+ @media (prefers-color-scheme: dark) {
1993
+ body { background: #1a1815; color: #e8e4dc; }
1994
+ .card { background: #25221d; border-color: #3a362f; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); }
1995
+ h1 { color: #f0ece4; }
1996
+ .subtitle, .field-label, .field-hint, .step-label { color: #a8a29a; }
1997
+ input[type=text], input[type=password] {
1998
+ background: #1f1c18; border-color: #3a362f; color: #e8e4dc;
1999
+ }
2000
+ input[type=text]:focus, input[type=password]:focus {
2001
+ background: #25221d;
2002
+ }
2003
+ .brand-tag { border-color: #3a362f; color: #a8a29a; }
2004
+ .explainer { background: rgba(74, 124, 89, 0.12); border-color: #3a362f; }
2005
+ .preview-card { background: rgba(212, 160, 23, 0.15); }
2006
+ .done-tile { border-color: #3a362f; }
2007
+ .op-log { background: #1f1c18; border-color: #3a362f; }
2008
+ }
2009
+ `;