@openparachute/hub 0.5.14-rc.2 → 0.5.14-rc.21

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 (106) hide show
  1. package/README.md +109 -15
  2. package/package.json +7 -3
  3. package/src/__tests__/account-home-ui.test.ts +251 -15
  4. package/src/__tests__/account-vault-token.test.ts +355 -0
  5. package/src/__tests__/admin-vaults.test.ts +70 -4
  6. package/src/__tests__/api-mint-token.test.ts +693 -5
  7. package/src/__tests__/api-modules-config.test.ts +16 -10
  8. package/src/__tests__/api-modules-ops.test.ts +45 -0
  9. package/src/__tests__/api-modules.test.ts +92 -75
  10. package/src/__tests__/api-ready.test.ts +135 -0
  11. package/src/__tests__/api-revoke-token.test.ts +384 -0
  12. package/src/__tests__/api-users.test.ts +7 -2
  13. package/src/__tests__/auth.test.ts +157 -30
  14. package/src/__tests__/cli.test.ts +44 -5
  15. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  16. package/src/__tests__/expose-2fa-warning.test.ts +31 -17
  17. package/src/__tests__/expose-auth-preflight.test.ts +71 -72
  18. package/src/__tests__/expose-cloudflare.test.ts +582 -11
  19. package/src/__tests__/expose-interactive.test.ts +10 -4
  20. package/src/__tests__/expose-public-auto.test.ts +5 -1
  21. package/src/__tests__/expose.test.ts +52 -2
  22. package/src/__tests__/hub-server.test.ts +396 -10
  23. package/src/__tests__/hub.test.ts +85 -6
  24. package/src/__tests__/init.test.ts +928 -0
  25. package/src/__tests__/lifecycle.test.ts +464 -2
  26. package/src/__tests__/migrate.test.ts +433 -51
  27. package/src/__tests__/oauth-handlers.test.ts +1252 -83
  28. package/src/__tests__/oauth-ui.test.ts +12 -1
  29. package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
  30. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  31. package/src/__tests__/proxy-state.test.ts +192 -0
  32. package/src/__tests__/resource-binding.test.ts +97 -0
  33. package/src/__tests__/scope-explanations.test.ts +77 -12
  34. package/src/__tests__/services-manifest.test.ts +122 -4
  35. package/src/__tests__/setup-wizard.test.ts +633 -53
  36. package/src/__tests__/status.test.ts +36 -0
  37. package/src/__tests__/two-factor-flow.test.ts +602 -0
  38. package/src/__tests__/two-factor.test.ts +183 -0
  39. package/src/__tests__/upgrade.test.ts +78 -1
  40. package/src/__tests__/users.test.ts +68 -0
  41. package/src/__tests__/vault-auth-status.test.ts +312 -11
  42. package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
  43. package/src/__tests__/wizard.test.ts +372 -0
  44. package/src/account-home-ui.ts +488 -38
  45. package/src/account-vault-token.ts +282 -0
  46. package/src/admin-handlers.ts +159 -4
  47. package/src/admin-login-ui.ts +49 -5
  48. package/src/admin-vaults.ts +48 -15
  49. package/src/api-account.ts +14 -0
  50. package/src/api-mint-token.ts +132 -24
  51. package/src/api-modules-ops.ts +49 -11
  52. package/src/api-modules.ts +29 -12
  53. package/src/api-ready.ts +102 -0
  54. package/src/api-revoke-token.ts +107 -21
  55. package/src/api-users.ts +29 -3
  56. package/src/cli.ts +112 -25
  57. package/src/clients.ts +18 -6
  58. package/src/cloudflare/config.ts +10 -4
  59. package/src/cloudflare/detect.ts +82 -20
  60. package/src/commands/auth.ts +165 -24
  61. package/src/commands/expose-2fa-warning.ts +34 -32
  62. package/src/commands/expose-auth-preflight.ts +89 -78
  63. package/src/commands/expose-cloudflare.ts +471 -16
  64. package/src/commands/expose-interactive.ts +10 -11
  65. package/src/commands/expose-public-auto.ts +6 -4
  66. package/src/commands/expose.ts +8 -0
  67. package/src/commands/init.ts +594 -0
  68. package/src/commands/install.ts +33 -2
  69. package/src/commands/lifecycle.ts +386 -17
  70. package/src/commands/migrate.ts +293 -41
  71. package/src/commands/status.ts +22 -0
  72. package/src/commands/upgrade.ts +55 -11
  73. package/src/commands/wizard.ts +847 -0
  74. package/src/env-file.ts +10 -0
  75. package/src/help.ts +157 -15
  76. package/src/hub-db.ts +39 -1
  77. package/src/hub-server.ts +119 -13
  78. package/src/hub-settings.ts +11 -0
  79. package/src/hub.ts +82 -14
  80. package/src/oauth-handlers.ts +298 -21
  81. package/src/oauth-ui.ts +10 -0
  82. package/src/operator-token.ts +151 -0
  83. package/src/pending-login.ts +116 -0
  84. package/src/proxy-error-ui.ts +506 -0
  85. package/src/proxy-state.ts +131 -0
  86. package/src/rate-limit.ts +51 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +131 -14
  90. package/src/services-manifest.ts +112 -0
  91. package/src/setup-wizard.ts +738 -125
  92. package/src/tailscale/run.ts +28 -11
  93. package/src/totp.ts +201 -0
  94. package/src/two-factor-handlers.ts +287 -0
  95. package/src/two-factor-store.ts +181 -0
  96. package/src/two-factor-ui.ts +462 -0
  97. package/src/users.ts +58 -0
  98. package/src/vault/auth-status.ts +200 -25
  99. package/src/vault-hub-origin-env.ts +163 -0
  100. package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
  101. package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
  102. package/web/ui/dist/index.html +2 -2
  103. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  104. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  105. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  106. package/web/ui/dist/assets/index-tRmPbbC7.js +0 -61
@@ -42,19 +42,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
42
  import { join } from "node:path";
43
43
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
44
44
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
45
- import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
46
45
  import {
47
46
  BOOTSTRAP_TOKEN_PREFIX,
48
47
  consumeBootstrapToken,
49
48
  getBootstrapToken,
50
49
  verifyBootstrapToken,
51
50
  } from "./bootstrap-token.ts";
51
+ import { WORDMARK_TEXT, brandMarkSvg } from "./brand.ts";
52
52
  import {
53
53
  CSRF_FIELD_NAME,
54
54
  ensureCsrfToken,
55
55
  renderCsrfHiddenInput,
56
56
  verifyCsrfToken,
57
57
  } from "./csrf.ts";
58
+ import { type ExposeState, readExposeState } from "./expose-state.ts";
58
59
  import {
59
60
  SETUP_EXPOSE_MODES,
60
61
  type SetupExposeMode,
@@ -64,6 +65,7 @@ import {
64
65
  openFirstClientAutoApproveWindow,
65
66
  setSetting,
66
67
  } from "./hub-settings.ts";
68
+ import { signAccessToken } from "./jwt-sign.ts";
67
69
  import { escapeHtml } from "./oauth-ui.ts";
68
70
  import { mintOperatorToken } from "./operator-token.ts";
69
71
  import { isHttpsRequest } from "./request-protocol.ts";
@@ -106,6 +108,71 @@ function escapeAttr(s: string): string {
106
108
  return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
107
109
  }
108
110
 
111
+ /**
112
+ * The CLI wizard (hub#168 Cut 3) sends `Accept: application/json` on
113
+ * every GET; the browser sends `Accept: text/html, …`. We branch the GET
114
+ * handler's response shape on this header. Same DB / state-derivation
115
+ * path both ways — only the rendering forks.
116
+ *
117
+ * POSTs from the CLI wizard send `Content-Type: application/json`;
118
+ * browser POSTs send `application/x-www-form-urlencoded`. The POST
119
+ * handlers parse-into-the-same-shape with `readBodyFields` below.
120
+ */
121
+ function wantsJsonResponse(req: Request): boolean {
122
+ const accept = req.headers.get("accept") ?? "";
123
+ return accept.includes("application/json");
124
+ }
125
+
126
+ /**
127
+ * Best-effort body parser shared by every wizard POST handler. Branches
128
+ * on Content-Type:
129
+ * * `application/json` → parses the body as JSON, projects each
130
+ * top-level field into a `Map<string, string>` (matches the
131
+ * FormData getter shape the rest of the handlers use).
132
+ * * Anything else → standard `req.formData()` (the historical browser
133
+ * path).
134
+ *
135
+ * Returns a tuple of `[fields, isJson]`. `isJson` lets the handler
136
+ * decide between a 303 redirect (browser) and a 200 JSON envelope
137
+ * (CLI). The fields-getter API is intentionally lossy on JSON arrays /
138
+ * nested objects — every wizard field today is a plain string, so the
139
+ * Map<string, string> shape is sufficient. If we ever need arrays here,
140
+ * extend with a `getAll(name)` shim.
141
+ */
142
+ async function readBodyFields(req: Request): Promise<{
143
+ get: (name: string) => string | null;
144
+ isJson: boolean;
145
+ }> {
146
+ const contentType = req.headers.get("content-type") ?? "";
147
+ if (contentType.includes("application/json")) {
148
+ let parsed: Record<string, unknown> = {};
149
+ try {
150
+ parsed = (await req.json()) as Record<string, unknown>;
151
+ } catch {
152
+ // Malformed JSON falls through to an empty map — the handlers'
153
+ // existing field-validation surfaces the right error message.
154
+ parsed = {};
155
+ }
156
+ return {
157
+ isJson: true,
158
+ get: (name: string) => {
159
+ const v = parsed[name];
160
+ if (typeof v === "string") return v;
161
+ if (typeof v === "number" || typeof v === "boolean") return String(v);
162
+ return null;
163
+ },
164
+ };
165
+ }
166
+ const form = await req.formData();
167
+ return {
168
+ isJson: false,
169
+ get: (name: string) => {
170
+ const v = form.get(name);
171
+ return typeof v === "string" ? v : null;
172
+ },
173
+ };
174
+ }
175
+
109
176
  // --- state derivation ----------------------------------------------------
110
177
 
111
178
  /**
@@ -141,13 +208,41 @@ export interface DerivedWizardState {
141
208
  */
142
209
  export const FIRST_VAULT_SHORT: CuratedModuleShort = "vault";
143
210
 
211
+ /**
212
+ * Map a live exposure layer (`expose-state.json`) onto the wizard's
213
+ * `setup_expose_mode`. The two enums overlap exactly on the layers the
214
+ * exposure file can carry: `ExposeLayer` is `tailnet | public`, both of
215
+ * which are valid `SetupExposeMode` values. (There's no `localhost`
216
+ * exposure layer — running nothing is the absence of a state file, which
217
+ * `readExposeState` reports as `undefined`, so a missing/unexposed hub
218
+ * never seeds and the wizard still asks.)
219
+ *
220
+ * Returns `undefined` when no exposure is live (or the reader throws on
221
+ * a malformed file — we swallow that and fall through to "still ask"
222
+ * rather than crashing the wizard GET).
223
+ */
224
+ function exposeModeFromLiveState(read: () => ExposeState | undefined): SetupExposeMode | undefined {
225
+ let state: ExposeState | undefined;
226
+ try {
227
+ state = read();
228
+ } catch {
229
+ // A corrupt expose-state.json shouldn't brick the wizard. Treat it
230
+ // as "no live exposure" and let the operator answer the step.
231
+ return undefined;
232
+ }
233
+ if (!state) return undefined;
234
+ // `ExposeLayer` ⊆ `SetupExposeMode` ("tailnet" | "public").
235
+ return state.layer;
236
+ }
237
+
144
238
  /**
145
239
  * Read DB + services.json to decide which step the wizard should render.
146
240
  * Idempotent — re-running after partial setup picks up where it left
147
241
  * off. Mostly read-only, with one specific write: on Render (or any
148
- * platform `detectAutoExposeMode` recognizes), the first call auto-
149
- * seeds `setup_expose_mode = "public"` so the wizard skips the expose
150
- * step. Subsequent calls find the setting present and are read-only.
242
+ * platform `detectAutoExposeMode` recognizes), OR when a live tailscale
243
+ * exposure (`expose-state.json`) is already up, the first call auto-
244
+ * seeds `setup_expose_mode` so the wizard skips the expose step.
245
+ * Subsequent calls find the setting present and are read-only.
151
246
  */
152
247
  export function deriveWizardState(deps: {
153
248
  db: Database;
@@ -158,13 +253,28 @@ export function deriveWizardState(deps: {
158
253
  * SetupWizardDeps.env.
159
254
  */
160
255
  env?: Record<string, string | undefined>;
256
+ /**
257
+ * Optional injected reader for the live exposure state
258
+ * (`~/.parachute/expose-state.json`). Defaults to the real
259
+ * `readExposeState`. Mirrors the `init.ts` seam (`readExposeStateFn`)
260
+ * so tests can drive the "a tailnet layer is already live" branch
261
+ * without writing a real state file. When `setup_expose_mode` is
262
+ * unset, the live exposure layer auto-seeds the setting (see below).
263
+ */
264
+ readExposeStateFn?: () => ExposeState | undefined;
161
265
  }): DerivedWizardState {
162
266
  const hasAdmin = userCount(deps.db) > 0;
163
267
  // The wizard's first-vault provisioning uses the curated `vault` short,
164
268
  // which maps to `parachute-vault` in services.json.
165
269
  const vaultSpec = specFor(FIRST_VAULT_SHORT);
166
270
  const vaultEntry = findService(vaultSpec.manifestName, deps.manifestPath);
167
- const hasVault = vaultEntry !== undefined;
271
+ // hub#168 Cut 2: `setup_vault_skipped === "true"` advances the wizard
272
+ // past the vault step even when no vault row exists. The operator
273
+ // explicitly chose Skip; the module is installed (Cut 1) but no
274
+ // instance was provisioned. Treat as "vault step is done" for the
275
+ // purposes of state-derivation so the wizard moves to expose.
276
+ const vaultSkipped = getSetting(deps.db, "setup_vault_skipped") === "true";
277
+ const hasVault = vaultEntry !== undefined || vaultSkipped;
168
278
  // Expose-mode is the operator's "how will this hub be reached?" answer
169
279
  // (hub#268 Item 2). Stored as a hub_setting; the wizard's expose step
170
280
  // sets it; absence means we should still ask. EXCEPT — if we're
@@ -180,6 +290,23 @@ export function deriveWizardState(deps: {
180
290
  ) {
181
291
  setSetting(deps.db, "setup_expose_mode", "public");
182
292
  }
293
+ // hub#406 team-onboarding bug: `setup_expose_mode` (the wizard's
294
+ // answer) and `expose-state.json` (the live tailscale exposure) are
295
+ // orthogonal axes. An operator who ran `parachute expose tailnet`
296
+ // before opening the wizard has a live tailnet layer but no
297
+ // `setup_expose_mode` setting — so the wizard re-asked "how will this
298
+ // hub be reached?" even though tailnet was already up. Auto-seed the
299
+ // setting from the live exposure layer (tailnet→"tailnet",
300
+ // public→"public") so the answered-by-action case is treated as
301
+ // satisfied, mirroring the Render/Fly auto-seed above. Reading the
302
+ // live state is injected for testability (defaults to the real
303
+ // reader); a malformed/missing file falls through to "still ask."
304
+ if (getSetting(deps.db, "setup_expose_mode") === undefined) {
305
+ const seeded = exposeModeFromLiveState(deps.readExposeStateFn ?? readExposeState);
306
+ if (seeded !== undefined) {
307
+ setSetting(deps.db, "setup_expose_mode", seeded);
308
+ }
309
+ }
183
310
  const hasExposeMode = getSetting(deps.db, "setup_expose_mode") !== undefined;
184
311
  let step: WizardStep;
185
312
  // Note: `"account"` is a visual-only step in the progress header —
@@ -242,6 +369,14 @@ export interface SetupWizardDeps {
242
369
  * without mutating the real process env.
243
370
  */
244
371
  env?: Record<string, string | undefined>;
372
+ /**
373
+ * Test seam: inject the live-exposure reader `deriveWizardState`
374
+ * consults to auto-seed `setup_expose_mode` from a live
375
+ * `parachute expose tailnet|public` (hub#406). Production omits this
376
+ * and the real `readExposeState` is used. Mirrors `init.ts`'s
377
+ * `readExposeStateFn` seam.
378
+ */
379
+ readExposeStateFn?: () => ExposeState | undefined;
245
380
  }
246
381
 
247
382
  /**
@@ -261,7 +396,9 @@ export interface SetupWizardDeps {
261
396
  * (RAILWAY_ENVIRONMENT), DigitalOcean App Platform (DIGITALOCEAN_APP_*),
262
397
  * etc. Each only auto-detects when the platform clearly owns the public URL.
263
398
  */
264
- export function detectAutoExposeMode(env: Record<string, string | undefined>): "public" | undefined {
399
+ export function detectAutoExposeMode(
400
+ env: Record<string, string | undefined>,
401
+ ): "public" | undefined {
265
402
  // Render always sets `RENDER_EXTERNAL_URL` to a real `https://` URL on
266
403
  // any web service. `startsWith("https://")` is the precise shape; we
267
404
  // also accept `http://` as a defensive fallback in case Render ever
@@ -372,7 +509,11 @@ export interface RenderAccountStepProps {
372
509
  export function renderAccountStep(props: RenderAccountStepProps): string {
373
510
  const { csrfToken, errorMessage, username, requireBootstrapToken, bootstrapToken } = props;
374
511
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
375
- const usernameAttr = username ? ` value="${escapeAttr(username)}"` : "";
512
+ // Pre-fill "owner" on a fresh render (no prior submission) so the web wizard's
513
+ // default matches the CLI paths (`set-password`, `setup-wizard`) + the
514
+ // operator.token convention. Operators can still type any name. On a
515
+ // validation-failure re-render we echo back what they typed instead.
516
+ const usernameAttr = ` value="${escapeAttr(username ?? "owner")}"`;
376
517
  const tokenAttr = bootstrapToken ? ` value="${escapeAttr(bootstrapToken)}"` : "";
377
518
  // Bootstrap-token field comes FIRST when required. An operator who
378
519
  // missed the log line is stopped here rather than after filling
@@ -494,6 +635,14 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
494
635
  const { csrfToken, errorMessage, operation, vaultName, cloudHost } = props;
495
636
  if (operation) return renderVaultOpStep({ operation });
496
637
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
638
+ // hub#168 Cut 2: three-branch vault step. The browser form now sends
639
+ // `mode=create|import|skip` along with the existing vault_name. Defaults
640
+ // to create when nothing's selected (back-compat with pre-#168 form
641
+ // posts that didn't ship a mode field — still works through the same
642
+ // handler). The radio's `data-shows` attribute drives an inline
643
+ // <script> block that hides import-specific fields when create/skip
644
+ // is selected. No SPA bundle, no module deps — same posture as the
645
+ // existing scribe sub-form's mode-switching JS.
497
646
  // hub#267: the typed name now flows end-to-end via
498
647
  // `PARACHUTE_VAULT_NAME`. Vault#342 added the env var read on
499
648
  // first-boot — hub spawns vault with the env var set and vault's
@@ -542,7 +691,25 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
542
691
  ${error}
543
692
  <form method="POST" action="/admin/setup/vault" class="auth-form">
544
693
  ${renderCsrfHiddenInput(csrfToken)}
545
- <label class="field">
694
+ <fieldset class="vault-mode-block">
695
+ <legend class="field-label">How do you want to start?</legend>
696
+ <label class="vault-mode-option">
697
+ <input type="radio" name="mode" value="create" checked data-shows="name" />
698
+ <span class="vault-mode-title">Create a new vault</span>
699
+ <span class="vault-mode-desc">Start fresh. The wizard creates an empty vault under the name below.</span>
700
+ </label>
701
+ <label class="vault-mode-option">
702
+ <input type="radio" name="mode" value="import" data-shows="name,import" />
703
+ <span class="vault-mode-title">Import from a git repo</span>
704
+ <span class="vault-mode-desc">Clone a previously-exported vault from GitHub / GitLab / any HTTPS git remote.</span>
705
+ </label>
706
+ <label class="vault-mode-option">
707
+ <input type="radio" name="mode" value="skip" data-shows="" />
708
+ <span class="vault-mode-title">Skip — create a vault later</span>
709
+ <span class="vault-mode-desc">The vault module is installed; create or import a vault any time from the admin UI.</span>
710
+ </label>
711
+ </fieldset>
712
+ <label class="field vault-name-field">
546
713
  <span class="field-label">Vault name</span>
547
714
  <input type="text" name="vault_name"
548
715
  autofocus minlength="2" maxlength="32"
@@ -552,9 +719,56 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
552
719
  <span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
553
720
  2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
554
721
  </label>
722
+ <fieldset class="vault-import-block" style="display: none;">
723
+ <legend class="field-label">Import source</legend>
724
+ <label class="field">
725
+ <span class="field-label">Remote URL</span>
726
+ <input type="text" name="remote_url" spellcheck="false" autocomplete="off"
727
+ placeholder="https://github.com/you/your-vault.git" />
728
+ <span class="field-hint">HTTPS or SSH clone URL. The repo must be a Parachute vault export — i.e. it carries a <code>.parachute/vault.yaml</code> at the root.</span>
729
+ </label>
730
+ <label class="field">
731
+ <span class="field-label">Personal access token (optional)</span>
732
+ <input type="password" name="pat" autocomplete="off"
733
+ placeholder="ghp_… / glpat-… / etc." />
734
+ <span class="field-hint">Required for private repos. Used in-memory for this import only — not stored. Set up push credentials later from the vault's mirror settings.</span>
735
+ </label>
736
+ <label class="vault-mode-option">
737
+ <input type="radio" name="import_mode" value="merge" checked />
738
+ <span class="vault-mode-title">Merge into a fresh vault (default)</span>
739
+ <span class="vault-mode-desc">Recommended on a brand-new install — the vault starts empty, so merge is effectively "import everything."</span>
740
+ </label>
741
+ <label class="vault-mode-option">
742
+ <input type="radio" name="import_mode" value="replace" />
743
+ <span class="vault-mode-title">Replace (wipes any existing notes first)</span>
744
+ <span class="vault-mode-desc">Only useful if you re-ran the wizard on an existing vault. Otherwise picks the same shape as merge.</span>
745
+ </label>
746
+ </fieldset>
555
747
  ${renderScribeSubForm(cloudHost === true)}
556
- <button type="submit" class="btn btn-primary">Create vault & finish</button>
748
+ <button type="submit" class="btn btn-primary">Continue</button>
557
749
  </form>
750
+ <script>
751
+ (function () {
752
+ // Show/hide vault-name + import block based on the picked mode.
753
+ // The radio carries data-shows listing the visible block suffixes
754
+ // (name, import); the show/hide loop reads them and flips display
755
+ // on the matching block. Skip mode hides everything below the
756
+ // mode picker.
757
+ var radios = document.querySelectorAll('input[name="mode"]');
758
+ var nameField = document.querySelector('.vault-name-field');
759
+ var importBlock = document.querySelector('.vault-import-block');
760
+ function sync() {
761
+ var picked = document.querySelector('input[name="mode"]:checked');
762
+ var shows = picked ? (picked.dataset.shows || '') : '';
763
+ var nameVisible = shows.indexOf('name') !== -1;
764
+ var importVisible = shows.indexOf('import') !== -1;
765
+ if (nameField) nameField.style.display = nameVisible ? '' : 'none';
766
+ if (importBlock) importBlock.style.display = importVisible ? '' : 'none';
767
+ }
768
+ radios.forEach(function (r) { r.addEventListener('change', sync); });
769
+ sync();
770
+ })();
771
+ </script>
558
772
  </div>`;
559
773
  return baseDocument("Set up your Parachute hub — vault", body);
560
774
  }
@@ -889,27 +1103,15 @@ export interface RenderDoneStepProps {
889
1103
  * shape.
890
1104
  */
891
1105
  installTiles?: readonly ModuleInstallTileState[];
892
- /**
893
- * Whether parachute-app is installed alongside the vault. Drives the
894
- * "Start using your vault" lead tile (hub#342): when true, the tile
895
- * links to `/surface/notes/` (the canonical user-facing surface — App
896
- * auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
897
- * false, it falls back to the vault's own admin UI at
898
- * `/vault/<name>/admin/` so the operator still has a single obvious
899
- * "start using parachute" target. Omitted = back-compat with tests
900
- * that render the done step without dependency-checking; defaults to
901
- * false (vault-admin fallback).
902
- */
903
- appInstalled?: boolean;
904
1106
  }
905
1107
 
906
1108
  export function renderDoneStep(props: RenderDoneStepProps): string {
907
- const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles, appInstalled } = props;
1109
+ const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
908
1110
  const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
909
1111
  const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
910
1112
  const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
911
1113
  const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
912
- const startTile = renderStartUsingTile(vaultName, appInstalled === true, hubOrigin);
1114
+ const startTile = renderStartUsingTile(vaultName, hubOrigin);
913
1115
  // The done-grid hosts the MCP-connect tile + the admin-UI fallback.
914
1116
  // The install tiles sit above it as a "what's next?" surface (curated
915
1117
  // catalog of modules an operator might want next). The "Start using
@@ -1097,14 +1299,18 @@ function renderMcpTile(
1097
1299
  <h2>Connect Claude Code (MCP)</h2>
1098
1300
  <p>Wire <code>vault:${safeVault}</code> into Claude Code as an MCP server:</p>
1099
1301
  <pre>${escapeHtml(bareCmd)}</pre>
1100
- <p class="fine">Mint an operator token at
1101
- <a href="/admin/tokens"><code>/admin/tokens</code></a> and append
1102
- <code>--header "Authorization: Bearer pvt_..."</code> on first use.</p>
1302
+ <p class="fine">No token needed the command triggers browser OAuth on
1303
+ first use (you sign in to this hub and approve access). For headless
1304
+ clients that can't do the browser flow, mint a hub token at
1305
+ <a href="/admin/tokens"><code>/admin/tokens</code></a> (or with
1306
+ <code>parachute auth mint-token</code>) and append
1307
+ <code>--header "Authorization: Bearer &lt;token&gt;"</code>.</p>
1103
1308
  </div>`;
1104
1309
  }
1105
1310
 
1106
1311
  /**
1107
- * The "Start using your vault" lead tile on the done step (hub#342).
1312
+ * The "Start using your vault" lead tile on the done step (hub#342,
1313
+ * Aaron 2026-05-27 simplification).
1108
1314
  *
1109
1315
  * Closes Aaron's "no clear way to go from setting up parachute to
1110
1316
  * actually using parachute" friction. Sits above the MCP / install
@@ -1112,48 +1318,20 @@ function renderMcpTile(
1112
1318
  * everything else on the done screen is operator-flavored (MCP
1113
1319
  * command, admin UI, additional module installs).
1114
1320
  *
1115
- * Two shapes:
1116
- * - **App installed** primary tile targets `/surface/notes/` (the
1117
- * Notes app reading the just-created vault). This is the
1118
- * canonical surface post-Notes-as-app migration (parachute-app §17).
1119
- * - **App NOT installed** → primary tile targets the vault's own
1120
- * admin UI at `/vault/<name>/admin/`. The copy explains that
1121
- * installing App + Notes is the recommended next step for a
1122
- * content-browsing surface, and points at the install tile below.
1123
- *
1124
- * Either way, the operator has ONE obvious click target that says
1125
- * "start using parachute" — not three competing tiles where the
1126
- * "real" entry point is buried under the MCP command pre-hub#342.
1127
- */
1128
- /**
1129
- * Lead "Start using your vault" tile. Points at the canonical
1130
- * notes.parachute.computer hosted PWA as the primary CTA — with the
1131
- * operator's own hub URL pre-filled via `?url=` so the connect screen
1132
- * auto-populates + auto-focuses (notes-ui AddVault route, see
1133
- * parachute-app/packages/notes-ui/src/app/routes/AddVault.tsx).
1134
- *
1135
- * Aaron 2026-05-27 directive: "skipping the local surface install for
1136
- * most operators is good ... showing notes.parachute.computer more
1137
- * prominently is a good idea." The notes.parachute.computer PWA is the
1138
- * canonical user-facing UI; operators no longer need to install the
1139
- * Surface module locally to use Notes. They still can (local install
1140
- * works the same way), but the wizard doesn't push them toward it as
1141
- * the default.
1142
- *
1321
+ * Points at the canonical notes.parachute.computer hosted PWA as the
1322
+ * primary CTA with the operator's own hub URL pre-filled via
1323
+ * `?url=` so the connect screen auto-populates + auto-focuses
1324
+ * (notes-ui AddVault route, see
1325
+ * parachute-surface/packages/notes-ui/src/app/routes/AddVault.tsx).
1143
1326
  * Secondary CTA: "Open vault admin" (the vault's own admin UI on this
1144
1327
  * hub) for operators who want to look at raw vault state.
1145
1328
  *
1146
- * `appInstalled` is no longer load-bearing for the primary path —
1147
- * notes.parachute.computer works regardless of whether Surface is
1148
- * installed locally. Kept in the signature so the older test fixtures
1149
- * + the boolean flag stay coherent; only the secondary fallback message
1150
- * differs based on it.
1329
+ * Previously varied by whether `parachute-surface` was installed
1330
+ * locally pointing at `/surface/notes/` in that case. Dropped
1331
+ * 2026-05-27: hub+vault+scribe is the focus; notes.parachute.computer
1332
+ * is canonical regardless of local surface install state.
1151
1333
  */
1152
- function renderStartUsingTile(
1153
- vaultName: string,
1154
- appInstalled: boolean,
1155
- hubOrigin: string,
1156
- ): string {
1334
+ function renderStartUsingTile(vaultName: string, hubOrigin: string): string {
1157
1335
  const safeVault = escapeHtml(vaultName);
1158
1336
  // Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
1159
1337
  // a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
@@ -1161,17 +1339,7 @@ function renderStartUsingTile(
1161
1339
  // The `?url=` query param is consumed by notes-ui's AddVault route
1162
1340
  // (packages/notes-ui/src/app/routes/AddVault.tsx) — it pre-fills the
1163
1341
  // vault URL input + auto-focuses Submit.
1164
- const vaultUrlForAdd = encodeURIComponent(
1165
- `${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`,
1166
- );
1167
- // For appInstalled=false case (Surface NOT installed locally),
1168
- // notes.parachute.computer is the recommended path. For appInstalled=true,
1169
- // we mention the local option as a secondary affordance.
1170
- const localNotesFallback = appInstalled
1171
- ? `<p class="start-using-secondary">
1172
- <a href="/surface/notes/">Or use Notes installed locally on this hub →</a>
1173
- </p>`
1174
- : "";
1342
+ const vaultUrlForAdd = encodeURIComponent(`${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`);
1175
1343
  return `<section class="start-using" data-testid="start-using-tile">
1176
1344
  <h2>Start using your vault</h2>
1177
1345
  <p>Open Notes — the canonical browser UI for your vault <code>${safeVault}</code>.
@@ -1180,7 +1348,6 @@ function renderStartUsingTile(
1180
1348
  <p class="start-using-secondary">
1181
1349
  <a href="/vault/${urlVault}/admin/">Or browse the vault's admin UI →</a>
1182
1350
  </p>
1183
- ${localNotesFallback}
1184
1351
  </section>`;
1185
1352
  }
1186
1353
 
@@ -1323,13 +1490,12 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
1323
1490
  * surface decision.
1324
1491
  */
1325
1492
  const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
1326
- surface: "/surface/notes/",
1327
- notes: "/notes/",
1328
- // Omitted: scribe + runner. They don't ship an admin SPA yet
1329
- // (scribe#53, runner#8 track). Pointing "Use it now" at /scribe/admin
1330
- // or /runner/admin today would 404 better to fall through to the
1331
- // "Manage modules" link than to send the operator into a dead end.
1332
- // Add the entry here once those modules ship their admin UI.
1493
+ // Empty: vault has its own lead "Start using" tile (the
1494
+ // notes.parachute.computer CTA), so it doesn't appear here. Scribe
1495
+ // doesn't ship an admin SPA at /scribe/admin/ that's useful for
1496
+ // first-time operators (the page exists but it's config-management;
1497
+ // not "use it"). Re-add per-module entries here if/when a module
1498
+ // ships a user-facing landing surface worth pointing at.
1333
1499
  };
1334
1500
 
1335
1501
  /**
@@ -1406,6 +1572,29 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1406
1572
  const url = new URL(req.url);
1407
1573
  const state = deriveWizardState(deps);
1408
1574
  const csrf = ensureCsrfToken(req);
1575
+ const wantsJson = wantsJsonResponse(req);
1576
+ // CLI wizard surface (hub#168 Cut 3): the GET endpoint doubles as a
1577
+ // state-probe API. Same state-derivation, same DB read; only the
1578
+ // response shape forks on Accept. Returning the JSON envelope before
1579
+ // the HTML rendering branches means the CLI gets the answer it needs
1580
+ // without the wizard having to render a 30KB HTML page per poll.
1581
+ if (wantsJson) {
1582
+ const requireToken = getBootstrapToken() !== undefined;
1583
+ const envelope = {
1584
+ step: state.step,
1585
+ hasAdmin: state.hasAdmin,
1586
+ hasVault: state.hasVault,
1587
+ hasExposeMode: state.hasExposeMode,
1588
+ requireBootstrapToken: requireToken,
1589
+ csrfToken: csrf.token,
1590
+ };
1591
+ const jsonHeaders: Record<string, string> = {
1592
+ "content-type": "application/json; charset=utf-8",
1593
+ "cache-control": "no-store",
1594
+ };
1595
+ if (csrf.setCookie) jsonHeaders["set-cookie"] = csrf.setCookie;
1596
+ return new Response(JSON.stringify(envelope), { status: 200, headers: jsonHeaders });
1597
+ }
1409
1598
  const extraHeaders: Record<string, string> = {
1410
1599
  "content-type": "text/html; charset=utf-8",
1411
1600
  };
@@ -1482,16 +1671,18 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1482
1671
  // Module install tiles (hub#272 Item B). One per curated module
1483
1672
  // other than vault (which the wizard already provisioned).
1484
1673
  const installTiles = buildInstallTiles(url, deps);
1485
- // hub#342: drive the lead "Start using your vault" tile's target.
1486
- // When parachute-app is installed alongside vault, the tile links
1487
- // to `/surface/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1488
- // §17). Otherwise it falls back to the vault's own admin UI.
1489
- const appInstalled = isModuleInstalled("surface", deps.manifestPath);
1674
+ // The lead "Start using your vault" tile points at
1675
+ // notes.parachute.computer/add always, regardless of any
1676
+ // local module install state. Prior versions of this code
1677
+ // checked `isModuleInstalled("surface", ...)` to switch to a
1678
+ // local `/surface/notes/` link, but the launch focus is
1679
+ // hub+vault+scribe and notes.parachute.computer is the
1680
+ // canonical Notes UI (Aaron-directed 2026-05-27). Dropped the
1681
+ // local-fallback branch.
1490
1682
  const doneProps: RenderDoneStepProps = {
1491
1683
  vaultName,
1492
1684
  hubOrigin: deps.issuer,
1493
1685
  installTiles,
1494
- appInstalled,
1495
1686
  };
1496
1687
  if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
1497
1688
  if (mintedToken) doneProps.mintedToken = mintedToken;
@@ -1590,9 +1781,19 @@ export async function handleSetupAccountPost(
1590
1781
  req: Request,
1591
1782
  deps: SetupWizardDeps,
1592
1783
  ): Promise<Response> {
1593
- const form = await req.formData();
1784
+ const form = await readBodyFields(req);
1785
+ // JSON callers (CLI wizard, hub#168 Cut 3) generally don't have a
1786
+ // pre-existing CSRF cookie because the GET that returned the JSON
1787
+ // envelope just set one — the CLI's fetch is the first request and
1788
+ // the verifyCsrfToken's double-submit check needs the cookie + body
1789
+ // value to match. The wizard's GET surface sets the cookie; the CLI
1790
+ // reads it back from `Set-Cookie` and threads it on subsequent POSTs,
1791
+ // matching the browser behavior. CSRF verification is shared.
1594
1792
  const formCsrf = form.get(CSRF_FIELD_NAME);
1595
1793
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1794
+ if (form.isJson) {
1795
+ return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
1796
+ }
1596
1797
  return badRequestPage("Invalid form submission", "Reload and try again.");
1597
1798
  }
1598
1799
  // Already-bootstrapped: bounce. The wizard's GET state will resolve to
@@ -1605,10 +1806,16 @@ export async function handleSetupAccountPost(
1605
1806
  const requireToken = getBootstrapToken() !== undefined;
1606
1807
  if (userCount(deps.db) > 0) {
1607
1808
  if (!requireToken) {
1809
+ if (form.isJson) {
1810
+ return jsonOkResponse({ step: "vault", message: "admin already exists" });
1811
+ }
1608
1812
  return redirect("/admin/setup");
1609
1813
  }
1610
1814
  // Defense in depth: a token was active but an admin already exists.
1611
1815
  // Treat as consumed.
1816
+ if (form.isJson) {
1817
+ return jsonErrorResponse(410, "Admin already claimed", "Bootstrap token was already used.");
1818
+ }
1612
1819
  return new Response(renderClaimAlreadyHappenedPage(), {
1613
1820
  status: 410,
1614
1821
  headers: { "content-type": "text/html; charset=utf-8" },
@@ -1622,6 +1829,13 @@ export async function handleSetupAccountPost(
1622
1829
  if (requireToken) {
1623
1830
  const suppliedToken = String(form.get("bootstrap_token") ?? "").trim();
1624
1831
  if (!verifyBootstrapToken(suppliedToken)) {
1832
+ if (form.isJson) {
1833
+ return jsonErrorResponse(
1834
+ 401,
1835
+ "Bootstrap token rejected",
1836
+ "Re-check the `parachute-bootstrap-…` line in your hub's startup logs.",
1837
+ );
1838
+ }
1625
1839
  const username = String(form.get("username") ?? "").trim();
1626
1840
  return htmlResponse(
1627
1841
  renderAccountStep({
@@ -1643,6 +1857,9 @@ export async function handleSetupAccountPost(
1643
1857
  const confirm = String(form.get("password_confirm") ?? "");
1644
1858
  const fieldErr = validateAccountFields({ username, password, confirm });
1645
1859
  if (fieldErr) {
1860
+ if (form.isJson) {
1861
+ return jsonErrorResponse(400, "Invalid account fields", fieldErr);
1862
+ }
1646
1863
  return htmlResponse(
1647
1864
  renderAccountStep({
1648
1865
  csrfToken,
@@ -1675,6 +1892,9 @@ export async function handleSetupAccountPost(
1675
1892
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1676
1893
  secure: isHttpsRequest(req),
1677
1894
  });
1895
+ if (form.isJson) {
1896
+ return jsonOkResponse({ step: "vault", message: "admin created" }, { "set-cookie": cookie });
1897
+ }
1678
1898
  return redirect("/admin/setup", { "set-cookie": cookie });
1679
1899
  } catch (err) {
1680
1900
  // Log the raw error server-side for the operator's debugging, but
@@ -1688,6 +1908,13 @@ export async function handleSetupAccountPost(
1688
1908
  // shell.
1689
1909
  const msg = err instanceof Error ? err.message : String(err);
1690
1910
  console.warn(`[setup-wizard] createUser failed for "${username}": ${msg}`);
1911
+ if (form.isJson) {
1912
+ return jsonErrorResponse(
1913
+ 400,
1914
+ "Account creation failed",
1915
+ "Failed to create account. The username may already be taken.",
1916
+ );
1917
+ }
1691
1918
  return htmlResponse(
1692
1919
  renderAccountStep({
1693
1920
  csrfToken,
@@ -1729,7 +1956,26 @@ function renderClaimAlreadyHappenedPage(): string {
1729
1956
  }
1730
1957
 
1731
1958
  /**
1732
- * POST `/admin/setup/vault`. Form-encoded.
1959
+ * POST `/admin/setup/vault`. Accepts `application/x-www-form-urlencoded`
1960
+ * (browser) and `application/json` (CLI wizard).
1961
+ *
1962
+ * Three modes (hub#168 Cut 2 — Aaron's 2026-05-28 directive): `mode`
1963
+ * field is the discriminant.
1964
+ * * `create` (default if absent — back-compat with the pre-hub#168
1965
+ * browser flow that didn't send `mode`): provision a new vault under
1966
+ * the typed name.
1967
+ * * `import`: provision an empty vault under the typed name, then
1968
+ * POST to vault's `/vault/<name>/.parachute/mirror/import` endpoint
1969
+ * with the supplied remote URL + optional PAT. Surfaces import
1970
+ * progress through the same op-poll machinery used by the create
1971
+ * path.
1972
+ * * `skip`: don't create or import anything. The wizard advances to
1973
+ * the expose step. The "vault module installed" signal is still
1974
+ * true (init.ts pre-installed it under hub#168 Cut 1), but no
1975
+ * instance exists — `deriveWizardState`'s `hasVault` reflects the
1976
+ * services.json shape, which `skip` leaves untouched. To make the
1977
+ * wizard *advance past* the vault step on skip we persist a
1978
+ * `setup_vault_skipped` flag that `deriveWizardState` consults.
1733
1979
  *
1734
1980
  * Gated by the admin session cookie set at step 2 — a stale tab without
1735
1981
  * the cookie won't accidentally try to provision a vault. The session is
@@ -1737,31 +1983,33 @@ function renderClaimAlreadyHappenedPage(): string {
1737
1983
  * same one driving step 3 (they're necessarily the only user in
1738
1984
  * single-user mode).
1739
1985
  *
1740
- * Drives `runInstall` directly (not the bearer-gated `handleInstall`).
1741
- * The bearer check exists to keep narrow `:auth`-scope automation
1742
- * tokens from hitting destructive endpoints; the wizard is already
1743
- * gated on session + on "no vault exists yet," so a separate
1744
- * bearer-mint dance would be pure ceremony.
1745
- *
1746
- * Returns a 303-redirect to `/admin/setup?op=<id>` so the wizard's
1747
- * polling GET shape kicks in. The actual `bun add` runs in the
1748
- * background; failures surface in the op log.
1986
+ * Browser shape: returns 303 to `/admin/setup?op=<id>` (create/import) or
1987
+ * `/admin/setup` (skip).
1988
+ * CLI shape: returns 200 JSON `{ op_id?, step }`.
1749
1989
  */
1750
1990
  export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps): Promise<Response> {
1751
- if (!deps.supervisor) {
1752
- return badRequestPage(
1753
- "Module supervisor unavailable",
1754
- "The first-boot wizard needs container-mode `parachute serve` to install modules. " +
1755
- "On the on-box CLI surface, run `parachute install vault` directly.",
1756
- );
1757
- }
1758
- const form = await req.formData();
1991
+ // Note: supervisor gate moved BELOW the mode check (hub#168 Cut 2) so
1992
+ // that `mode=skip` doesn't fail on the CLI hub surface (which doesn't
1993
+ // wire a supervisor — operators install vault via `parachute install
1994
+ // vault` on the on-box CLI path; the wizard's role there is the
1995
+ // account + skip + expose decisions only).
1996
+ const form = await readBodyFields(req);
1759
1997
  const formCsrf = form.get(CSRF_FIELD_NAME);
1760
1998
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
1999
+ if (form.isJson) {
2000
+ return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
2001
+ }
1761
2002
  return badRequestPage("Invalid form submission", "Reload and try again.");
1762
2003
  }
1763
2004
  const session = findActiveSession(deps.db, req);
1764
2005
  if (!session) {
2006
+ if (form.isJson) {
2007
+ return jsonErrorResponse(
2008
+ 401,
2009
+ "No admin session",
2010
+ "Sign in to continue setup. The session cookie was set on step 2.",
2011
+ );
2012
+ }
1765
2013
  return badRequestPage(
1766
2014
  "No admin session",
1767
2015
  "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
@@ -1769,7 +2017,66 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1769
2017
  }
1770
2018
  // Already done — short-circuit to the done step.
1771
2019
  const state = deriveWizardState(deps);
1772
- if (state.hasVault) return redirect("/admin/setup?just_finished=1");
2020
+ if (state.hasVault) {
2021
+ if (form.isJson) {
2022
+ return jsonOkResponse({ step: "expose", message: "vault already provisioned" });
2023
+ }
2024
+ return redirect("/admin/setup?just_finished=1");
2025
+ }
2026
+
2027
+ // Mode discriminant (hub#168 Cut 2). Default is "create" for back-
2028
+ // compat with the existing browser form — it doesn't send `mode`.
2029
+ const rawMode = String(form.get("mode") ?? "create").trim();
2030
+ if (rawMode !== "create" && rawMode !== "import" && rawMode !== "skip") {
2031
+ if (form.isJson) {
2032
+ return jsonErrorResponse(
2033
+ 400,
2034
+ "Invalid vault mode",
2035
+ `mode must be one of create, import, skip (got "${rawMode}")`,
2036
+ );
2037
+ }
2038
+ return badRequestPage("Invalid vault mode", "mode must be one of create, import, skip.");
2039
+ }
2040
+
2041
+ // Skip path (hub#168 Cut 2): module is already installed (init.ts
2042
+ // ran `install vault --no-create`); we just persist a flag that
2043
+ // `deriveWizardState` consults to skip the vault step on subsequent
2044
+ // GETs. No supervisor work, no op_id — runs without the supervisor.
2045
+ if (rawMode === "skip") {
2046
+ setSetting(deps.db, "setup_vault_skipped", "true");
2047
+ if (form.isJson) {
2048
+ return jsonOkResponse({ step: "expose", message: "vault step skipped" });
2049
+ }
2050
+ return redirect("/admin/setup");
2051
+ }
2052
+
2053
+ // Operator picked create or import — if they previously skipped (in
2054
+ // another tab / via back button), the skip flag would still claim
2055
+ // "vault step done" even after the vault row appears. Clear it
2056
+ // defensively so `deriveWizardState` consults the real vault entry
2057
+ // going forward.
2058
+ deleteSetting(deps.db, "setup_vault_skipped");
2059
+
2060
+ // Create / import paths need the supervisor — they spawn vault and
2061
+ // (for import) call vault's mirror endpoint. The CLI hub surface
2062
+ // doesn't wire a supervisor; operators are expected to use
2063
+ // `parachute install vault` directly there. Container/serve-mode
2064
+ // hub has one.
2065
+ if (!deps.supervisor) {
2066
+ if (form.isJson) {
2067
+ return jsonErrorResponse(
2068
+ 503,
2069
+ "Module supervisor unavailable",
2070
+ "The wizard's create/import paths need container-mode `parachute serve` to spawn vault. " +
2071
+ "On the on-box CLI surface, run `parachute install vault` first, then re-run the wizard with --vault-mode skip.",
2072
+ );
2073
+ }
2074
+ return badRequestPage(
2075
+ "Module supervisor unavailable",
2076
+ "The first-boot wizard needs container-mode `parachute serve` to install modules. " +
2077
+ "On the on-box CLI surface, run `parachute install vault` directly.",
2078
+ );
2079
+ }
1773
2080
 
1774
2081
  // hub#267: the operator-typed vault name is now threaded all the way
1775
2082
  // through to vault's first-boot via `PARACHUTE_VAULT_NAME` (vault#342
@@ -1784,6 +2091,9 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1784
2091
  } else {
1785
2092
  const v = validateVaultName(rawName);
1786
2093
  if (!v.ok) {
2094
+ if (form.isJson) {
2095
+ return jsonErrorResponse(400, "Invalid vault name", v.error);
2096
+ }
1787
2097
  return htmlResponse(
1788
2098
  renderVaultStep({
1789
2099
  csrfToken: csrfTokenStr,
@@ -1795,6 +2105,51 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1795
2105
  }
1796
2106
  vaultName = v.name;
1797
2107
  }
2108
+
2109
+ // Import path (hub#168 Cut 2): collect the remote URL + optional PAT
2110
+ // + replace flag up front so a malformed input fails fast before we
2111
+ // spawn the vault. The actual import POST to vault's
2112
+ // `/vault/<name>/.parachute/mirror/import` happens AFTER vault has
2113
+ // come up under the supervisor; the params are captured by closure
2114
+ // into the post-install `.then()` (see `importToRun` below).
2115
+ let importParams: { remoteUrl: string; pat?: string; mode: "merge" | "replace" } | undefined;
2116
+ if (rawMode === "import") {
2117
+ const remoteUrl = String(form.get("remote_url") ?? "").trim();
2118
+ if (remoteUrl === "") {
2119
+ if (form.isJson) {
2120
+ return jsonErrorResponse(
2121
+ 400,
2122
+ "Remote URL required",
2123
+ 'remote_url must be a non-empty HTTPS or SSH clone URL when mode="import".',
2124
+ );
2125
+ }
2126
+ return htmlResponse(
2127
+ renderVaultStep({
2128
+ csrfToken: csrfTokenStr,
2129
+ vaultName: rawName,
2130
+ errorMessage: "Remote URL is required to import a vault. Paste a git clone URL.",
2131
+ }),
2132
+ 400,
2133
+ );
2134
+ }
2135
+ const importMode = String(form.get("import_mode") ?? "merge").trim();
2136
+ if (importMode !== "merge" && importMode !== "replace") {
2137
+ const err = `import_mode must be "merge" or "replace" (got "${importMode}").`;
2138
+ if (form.isJson) {
2139
+ return jsonErrorResponse(400, "Invalid import_mode", err);
2140
+ }
2141
+ return htmlResponse(
2142
+ renderVaultStep({ csrfToken: csrfTokenStr, vaultName: rawName, errorMessage: err }),
2143
+ 400,
2144
+ );
2145
+ }
2146
+ const pat = String(form.get("pat") ?? "").trim();
2147
+ importParams = {
2148
+ remoteUrl,
2149
+ mode: importMode,
2150
+ ...(pat ? { pat } : {}),
2151
+ };
2152
+ }
1798
2153
  // Persist for the done-step renderer. Vault overwrites services.json
1799
2154
  // on its first authoritative boot, but until that completes the wizard
1800
2155
  // needs a stable source of truth for the typed name — both for the
@@ -1827,8 +2182,14 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1827
2182
  { status: "succeeded" },
1828
2183
  `${FIRST_VAULT_SHORT} already supervised (status=${supervisorState.status})`,
1829
2184
  );
2185
+ if (form.isJson) {
2186
+ return jsonOkResponse({ op_id: op.id, step: "vault", message: "vault already supervised" });
2187
+ }
1830
2188
  return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
1831
2189
  }
2190
+ if (form.isJson) {
2191
+ return jsonOkResponse({ step: "vault", message: "vault already supervised" });
2192
+ }
1832
2193
  return redirect("/admin/setup");
1833
2194
  }
1834
2195
 
@@ -1851,6 +2212,17 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1851
2212
  if (vaultName !== DEFAULT_VAULT_NAME) {
1852
2213
  spawnEnv.PARACHUTE_VAULT_NAME = vaultName;
1853
2214
  }
2215
+ // Capture importParams + deps in the runInstall promise chain — when
2216
+ // mode === "import", run the vault-side `/.parachute/mirror/import`
2217
+ // POST as a follow-up step once the supervised vault has come up
2218
+ // and confirmed healthy. The hub-side op_id stays the same so the
2219
+ // CLI / browser sees a single progress stream; we just append more
2220
+ // log lines while the import runs. On import error, the op is
2221
+ // marked failed so the caller surfaces a usable message.
2222
+ const importToRun = importParams;
2223
+ const vaultIssuer = deps.issuer;
2224
+ const importerUserId = session.userId;
2225
+ const vaultPort = vaultSpec.seedEntry?.().port ?? 1940;
1854
2226
  void runInstall(op.id, FIRST_VAULT_SHORT, vaultSpec, {
1855
2227
  db: deps.db,
1856
2228
  issuer: deps.issuer,
@@ -1861,10 +2233,61 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1861
2233
  ...(deps.run ? { run: deps.run } : {}),
1862
2234
  ...(deps.isLinked ? { isLinked: deps.isLinked } : {}),
1863
2235
  ...(Object.keys(spawnEnv).length > 0 ? { spawnEnv } : {}),
1864
- }).catch((err) => {
1865
- const msg = err instanceof Error ? err.message : String(err);
1866
- registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
1867
- });
2236
+ })
2237
+ .then(async () => {
2238
+ if (!importToRun) return;
2239
+ const opState = registry.get(op.id);
2240
+ if (!opState || opState.status !== "succeeded") return;
2241
+ // Import is a follow-up step: mark op back to running, POST to
2242
+ // vault, surface the result in the op log.
2243
+ registry.update(
2244
+ op.id,
2245
+ { status: "running" },
2246
+ `vault up — starting import from ${importToRun.remoteUrl} (mode=${importToRun.mode})`,
2247
+ );
2248
+ try {
2249
+ // Mint a short-lived per-vault admin Bearer for the import POST.
2250
+ // Vault validates audience `vault.<name>` + scope `vault:<name>:admin`
2251
+ // (see admin-vault-admin-token.ts for the canonical shape — same
2252
+ // contract the SPA Manage link uses). The token only needs to
2253
+ // live until vault accepts the HTTP request (the clone itself
2254
+ // happens inside vault after the auth check passes); 5 min is
2255
+ // a generous safety net covering the supervisor's boot-grace
2256
+ // retries on a sluggish host. Deliberate divergence from the
2257
+ // SPA's 10-min TTL because this token is one-shot, not refreshed.
2258
+ const minted = await signAccessToken(deps.db, {
2259
+ sub: importerUserId,
2260
+ scopes: [`vault:${vaultName}:admin`],
2261
+ audience: `vault.${vaultName}`,
2262
+ clientId: "parachute-hub-setup-wizard",
2263
+ issuer: vaultIssuer,
2264
+ ttlSeconds: 5 * 60,
2265
+ vaultScope: [vaultName],
2266
+ });
2267
+ const result = await postVaultImportImpl({
2268
+ vaultName,
2269
+ vaultPort,
2270
+ bearerToken: minted.token,
2271
+ remoteUrl: importToRun.remoteUrl,
2272
+ mode: importToRun.mode,
2273
+ ...(importToRun.pat ? { pat: importToRun.pat } : {}),
2274
+ });
2275
+ registry.update(
2276
+ op.id,
2277
+ { status: "succeeded" },
2278
+ `import succeeded — notes_imported=${result.notes_imported ?? 0}, tags_imported=${
2279
+ result.tags_imported ?? 0
2280
+ }, attachments_imported=${result.attachments_imported ?? 0}`,
2281
+ );
2282
+ } catch (err) {
2283
+ const msg = err instanceof Error ? err.message : String(err);
2284
+ registry.update(op.id, { status: "failed", error: msg }, `import failed: ${msg}`);
2285
+ }
2286
+ })
2287
+ .catch((err) => {
2288
+ const msg = err instanceof Error ? err.message : String(err);
2289
+ registry.update(op.id, { status: "failed", error: msg }, `install failed: ${msg}`);
2290
+ });
1868
2291
  } else {
1869
2292
  // No registry wired (test-only path; production always passes one).
1870
2293
  // Log a visible warning so future mis-wirings are debuggable —
@@ -1941,9 +2364,105 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1941
2364
  const redirectUrl = scribeOpId
1942
2365
  ? `/admin/setup?op=${encodeURIComponent(op.id)}&op_scribe=${encodeURIComponent(scribeOpId)}`
1943
2366
  : `/admin/setup?op=${encodeURIComponent(op.id)}`;
2367
+ if (form.isJson) {
2368
+ return jsonOkResponse({
2369
+ op_id: op.id,
2370
+ ...(scribeOpId ? { scribe_op_id: scribeOpId } : {}),
2371
+ step: "vault",
2372
+ mode: rawMode,
2373
+ });
2374
+ }
1944
2375
  return redirect(redirectUrl);
1945
2376
  }
1946
2377
 
2378
+ /**
2379
+ * POST the wizard-collected import params to vault's
2380
+ * `/vault/<name>/.parachute/mirror/import` endpoint. The caller mints
2381
+ * the per-vault admin Bearer (see `signAccessToken` use in the
2382
+ * `runInstall().then(...)` block above) and passes it in; vault gates
2383
+ * the endpoint on `vault:<name>:admin` upstream. Returns vault's
2384
+ * structured response or throws with a usable message.
2385
+ *
2386
+ * Lives in setup-wizard.ts (not as a vault-internal helper) because
2387
+ * vault doesn't import hub-internal code; the import POST is naturally
2388
+ * the wizard's job — it's the only caller until vault ships its own
2389
+ * admin SPA flow. Shape mirrors vault#390's contract:
2390
+ * POST /vault/<name>/.parachute/mirror/import
2391
+ * { remote_url, mode: "merge"|"replace", credentials: {kind, token}|null }
2392
+ * 200 { notes_imported, tags_imported, attachments_imported, warnings }
2393
+ *
2394
+ * Exported (with the `Impl` suffix) so tests can inject a stub fetcher
2395
+ * and assert the Authorization header without standing up a real vault.
2396
+ */
2397
+ export async function postVaultImportImpl(args: {
2398
+ vaultName: string;
2399
+ vaultPort: number;
2400
+ bearerToken: string;
2401
+ remoteUrl: string;
2402
+ mode: "merge" | "replace";
2403
+ pat?: string;
2404
+ fetcher?: typeof fetch;
2405
+ }): Promise<{
2406
+ notes_imported?: number;
2407
+ tags_imported?: number;
2408
+ attachments_imported?: number;
2409
+ warnings?: readonly string[];
2410
+ }> {
2411
+ const fetcher = args.fetcher ?? fetch;
2412
+ // Vault listens on its supervised port — talk directly to 127.0.0.1
2413
+ // rather than going through hub's path-routing proxy. Cuts one
2414
+ // network hop and avoids the operator-session/CSRF dance.
2415
+ const url = `http://127.0.0.1:${args.vaultPort}/vault/${encodeURIComponent(args.vaultName)}/.parachute/mirror/import`;
2416
+ const body: Record<string, unknown> = {
2417
+ remote_url: args.remoteUrl,
2418
+ mode: args.mode,
2419
+ };
2420
+ if (args.pat) {
2421
+ body.credentials = { kind: "pat", token: args.pat };
2422
+ } else {
2423
+ body.credentials = null;
2424
+ }
2425
+ // Best-effort retry — the supervisor's `start` returns before vault
2426
+ // accepts traffic; a tiny grace window covers the boot lag without
2427
+ // a tight poll loop. Three attempts spaced 1s apart.
2428
+ let lastErr: Error | undefined;
2429
+ for (let attempt = 0; attempt < 5; attempt++) {
2430
+ try {
2431
+ const res = await fetcher(url, {
2432
+ method: "POST",
2433
+ headers: {
2434
+ "content-type": "application/json",
2435
+ // Vault's `authenticateVaultRequest` rejects 401 before scope
2436
+ // check when no Bearer is present. The token must carry
2437
+ // `vault:<name>:admin` + audience `vault.<name>` — minted at
2438
+ // the call site via `signAccessToken` so this function stays
2439
+ // pure (no db / userId capture).
2440
+ authorization: `Bearer ${args.bearerToken}`,
2441
+ },
2442
+ body: JSON.stringify(body),
2443
+ });
2444
+ if (res.status === 200) {
2445
+ return (await res.json()) as Awaited<ReturnType<typeof postVaultImportImpl>>;
2446
+ }
2447
+ // Vault returns structured JSON errors per mirror-routes.ts:
2448
+ // 400 (validation), 409 (concurrent), 502 (clone failed), 500.
2449
+ const errBody = (await res.json().catch(() => ({}))) as { message?: string; error?: string };
2450
+ throw new Error(
2451
+ `vault import returned ${res.status}: ${errBody.message ?? errBody.error ?? "unknown"}`,
2452
+ );
2453
+ } catch (err) {
2454
+ lastErr = err instanceof Error ? err : new Error(String(err));
2455
+ // ECONNREFUSED / fetch failure → vault hasn't bound yet. Retry.
2456
+ if (lastErr.message.includes("ECONNREFUSED") || lastErr.message.includes("Failed to fetch")) {
2457
+ await new Promise((resolve) => setTimeout(resolve, 1000));
2458
+ continue;
2459
+ }
2460
+ throw lastErr;
2461
+ }
2462
+ }
2463
+ throw lastErr ?? new Error("vault import: exhausted retries");
2464
+ }
2465
+
1947
2466
  /**
1948
2467
  * Write a minimal scribe config that selects the operator's chosen
1949
2468
  * transcribe + cleanup providers + API keys (when applicable).
@@ -2075,13 +2594,19 @@ export async function handleSetupExposePost(
2075
2594
  req: Request,
2076
2595
  deps: SetupWizardDeps,
2077
2596
  ): Promise<Response> {
2078
- const form = await req.formData();
2597
+ const form = await readBodyFields(req);
2079
2598
  const formCsrf = form.get(CSRF_FIELD_NAME);
2080
2599
  if (!verifyCsrfToken(req, typeof formCsrf === "string" ? formCsrf : null)) {
2600
+ if (form.isJson) {
2601
+ return jsonErrorResponse(400, "Invalid form submission", "Reload and try again.");
2602
+ }
2081
2603
  return badRequestPage("Invalid form submission", "Reload and try again.");
2082
2604
  }
2083
2605
  const session = findActiveSession(deps.db, req);
2084
2606
  if (!session) {
2607
+ if (form.isJson) {
2608
+ return jsonErrorResponse(401, "No admin session", "Sign in to continue setup.");
2609
+ }
2085
2610
  return badRequestPage(
2086
2611
  "No admin session",
2087
2612
  "Sign in to continue setup. (The wizard sets a session cookie on step 2; clearing cookies between steps will land you here.)",
@@ -2091,10 +2616,20 @@ export async function handleSetupExposePost(
2091
2616
  // the wizard's GET shape catches this case too, but a direct POST
2092
2617
  // (curl, tab race) shouldn't double-fire the auto-approve window.
2093
2618
  if (getSetting(deps.db, "setup_expose_mode") !== undefined) {
2619
+ if (form.isJson) {
2620
+ return jsonOkResponse({ step: "done", message: "expose mode already set" });
2621
+ }
2094
2622
  return redirect("/admin/setup?just_finished=1");
2095
2623
  }
2096
2624
  const rawMode = form.get("expose_mode");
2097
2625
  if (!isSetupExposeMode(rawMode)) {
2626
+ if (form.isJson) {
2627
+ return jsonErrorResponse(
2628
+ 400,
2629
+ "Invalid expose_mode",
2630
+ `Pick one of: ${SETUP_EXPOSE_MODES.join(", ")}.`,
2631
+ );
2632
+ }
2098
2633
  return htmlResponse(
2099
2634
  renderExposeStep({
2100
2635
  csrfToken: typeof formCsrf === "string" ? formCsrf : "",
@@ -2120,12 +2655,14 @@ export async function handleSetupExposePost(
2120
2655
  // registry so revocation via the admin UI works as usual. Failures
2121
2656
  // are non-fatal: the done page falls back to the un-headered MCP
2122
2657
  // command + a "mint manually at /admin/tokens" hint.
2658
+ let mintedTokenForJson: string | undefined;
2123
2659
  try {
2124
2660
  const minted = await mintOperatorToken(deps.db, session.userId, {
2125
2661
  issuer: deps.issuer,
2126
2662
  scopeSet: "admin",
2127
2663
  });
2128
2664
  setSetting(deps.db, "setup_minted_token", minted.token);
2665
+ mintedTokenForJson = minted.token;
2129
2666
  console.log(
2130
2667
  `[setup-wizard] auto-minted operator token (jti=${minted.jti}, scope-set=admin) for done-screen MCP command`,
2131
2668
  );
@@ -2133,6 +2670,13 @@ export async function handleSetupExposePost(
2133
2670
  const msg = err instanceof Error ? err.message : String(err);
2134
2671
  console.warn(`[setup-wizard] failed to auto-mint operator token: ${msg}`);
2135
2672
  }
2673
+ if (form.isJson) {
2674
+ return jsonOkResponse({
2675
+ step: "done",
2676
+ message: "expose mode set",
2677
+ ...(mintedTokenForJson ? { minted_token: mintedTokenForJson } : {}),
2678
+ });
2679
+ }
2136
2680
  return redirect("/admin/setup?just_finished=1");
2137
2681
  }
2138
2682
 
@@ -2152,11 +2696,6 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
2152
2696
  displayName: string;
2153
2697
  tagline: string;
2154
2698
  }> = [
2155
- {
2156
- short: "surface",
2157
- displayName: "Surface",
2158
- tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
2159
- },
2160
2699
  {
2161
2700
  short: "scribe",
2162
2701
  displayName: "Scribe",
@@ -2323,11 +2862,10 @@ function validateAccountFields(input: {
2323
2862
 
2324
2863
  /**
2325
2864
  * Whether a given curated module is currently installed (has a row in
2326
- * services.json keyed by its canonical `manifestName`). Used by the
2327
- * done-step renderer (hub#342) to decide whether to point the "Start
2328
- * using your vault" tile at `/surface/notes/` (App installed Notes UI
2329
- * auto-bootstrapped) vs the vault's own admin UI. Cheap manifest read
2330
- * shared with `buildInstallTiles`.
2865
+ * services.json keyed by its canonical `manifestName`). Used by
2866
+ * `buildInstallTiles` to decide whether an install-tile row renders
2867
+ * the "install" form or the "already installed" state. Cheap manifest
2868
+ * read (no network).
2331
2869
  */
2332
2870
  function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
2333
2871
  const manifest = readManifestLenient(manifestPath);
@@ -2373,6 +2911,32 @@ function redirect(location: string, extra: Record<string, string> = {}): Respons
2373
2911
  return new Response(null, { status: 303, headers: { location, ...extra } });
2374
2912
  }
2375
2913
 
2914
+ /**
2915
+ * Structured JSON-200 helper for the CLI wizard surface (hub#168 Cut 3).
2916
+ * Mirrors the browser-redirect responses' header shape (extra cookies
2917
+ * pass through) without the 303 status that would force the CLI's
2918
+ * `fetch` to chase a non-existent location.
2919
+ */
2920
+ function jsonOkResponse(body: unknown, extra: Record<string, string> = {}): Response {
2921
+ return new Response(JSON.stringify(body), {
2922
+ status: 200,
2923
+ headers: { "content-type": "application/json; charset=utf-8", ...extra },
2924
+ });
2925
+ }
2926
+
2927
+ /**
2928
+ * Structured JSON-error helper for the CLI wizard surface (hub#168 Cut 3).
2929
+ * The browser path renders a full HTML error page; the CLI wants a
2930
+ * machine-parseable envelope with the same fields the rendered page
2931
+ * shows. Status code is the same as the HTML branch (400/401/410/etc).
2932
+ */
2933
+ function jsonErrorResponse(status: number, title: string, message: string): Response {
2934
+ return new Response(JSON.stringify({ error: title, message, status }), {
2935
+ status,
2936
+ headers: { "content-type": "application/json; charset=utf-8" },
2937
+ });
2938
+ }
2939
+
2376
2940
  function badRequestPage(title: string, message: string): Response {
2377
2941
  return htmlResponse(renderBadRequestPage(title, message), 400);
2378
2942
  }
@@ -2863,6 +3427,55 @@ const STYLES = `
2863
3427
  }
2864
3428
  .done-tile .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; }
2865
3429
 
3430
+ /* Vault-mode picker (hub#168 Cut 2). Three-option radio block at the
3431
+ top of the vault step, plus a collapsible import-only sub-form
3432
+ below. Shape mirrors .expose-option for visual consistency. */
3433
+ .vault-mode-block, .vault-import-block {
3434
+ border: 1px solid ${PALETTE.border};
3435
+ border-radius: 8px;
3436
+ padding: 0.75rem 0.9rem;
3437
+ margin: 0.4rem 0;
3438
+ background: ${PALETTE.cardBg};
3439
+ display: flex;
3440
+ flex-direction: column;
3441
+ gap: 0.6rem;
3442
+ }
3443
+ .vault-mode-block legend, .vault-import-block legend {
3444
+ padding: 0 0.4rem;
3445
+ font-size: 0.85rem;
3446
+ color: ${PALETTE.fgMuted};
3447
+ font-family: ${FONT_MONO};
3448
+ }
3449
+ .vault-mode-option {
3450
+ display: flex;
3451
+ align-items: flex-start;
3452
+ gap: 0.6rem;
3453
+ padding: 0.6rem 0.8rem;
3454
+ border: 1px solid ${PALETTE.borderLight};
3455
+ border-radius: 6px;
3456
+ cursor: pointer;
3457
+ transition: border-color 0.15s ease, background 0.15s ease;
3458
+ }
3459
+ .vault-mode-option:hover { border-color: ${PALETTE.accent}; }
3460
+ .vault-mode-option input[type=radio] {
3461
+ margin-top: 0.25rem;
3462
+ accent-color: ${PALETTE.accent};
3463
+ flex-shrink: 0;
3464
+ }
3465
+ .vault-mode-title {
3466
+ font-weight: 600;
3467
+ color: ${PALETTE.fg};
3468
+ font-size: 0.95rem;
3469
+ margin-left: 0.3rem;
3470
+ }
3471
+ .vault-mode-desc {
3472
+ color: ${PALETTE.fgMuted};
3473
+ font-size: 0.85rem;
3474
+ line-height: 1.45;
3475
+ flex-basis: 100%;
3476
+ margin-left: 1.7rem;
3477
+ }
3478
+
2866
3479
  /* expose step (hub#268 Item 2). Vertical stack of radio cards;
2867
3480
  each label is the full clickable hit target. */
2868
3481
  .expose-form { gap: 0.65rem; }