@openparachute/hub 0.5.13 → 0.5.14-rc.1

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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +140 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules.test.ts +32 -32
  8. package/src/__tests__/api-users.test.ts +192 -2
  9. package/src/__tests__/chrome-strip.test.ts +15 -15
  10. package/src/__tests__/hub-server.test.ts +23 -23
  11. package/src/__tests__/notes-redirect.test.ts +20 -20
  12. package/src/__tests__/services-manifest.test.ts +40 -40
  13. package/src/__tests__/setup-wizard.test.ts +157 -19
  14. package/src/__tests__/setup.test.ts +1 -1
  15. package/src/__tests__/status.test.ts +39 -0
  16. package/src/__tests__/users.test.ts +261 -0
  17. package/src/__tests__/well-known.test.ts +9 -9
  18. package/src/account-home-ui.ts +404 -0
  19. package/src/admin-handlers.ts +49 -17
  20. package/src/admin-host-admin-token.ts +25 -0
  21. package/src/admin-vault-admin-token.ts +17 -0
  22. package/src/api-account.ts +72 -6
  23. package/src/api-modules.ts +3 -3
  24. package/src/api-users.ts +173 -12
  25. package/src/chrome-strip.ts +6 -6
  26. package/src/commands/status.ts +10 -1
  27. package/src/help.ts +2 -2
  28. package/src/hub-server.ts +50 -10
  29. package/src/hub-settings.ts +2 -2
  30. package/src/hub.ts +6 -6
  31. package/src/notes-redirect.ts +5 -5
  32. package/src/service-spec.ts +39 -18
  33. package/src/setup-wizard.ts +335 -28
  34. package/src/users.ts +112 -0
  35. package/web/ui/dist/assets/index-Qf56GsGm.js +61 -0
  36. package/web/ui/dist/index.html +1 -1
  37. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -68,7 +68,7 @@ export const PORT_RESERVATIONS: readonly PortReservation[] = [
68
68
  // fallback-port walker (`assignPort` in port-assign.ts) from handing this
69
69
  // port out to a colliding third-party module. The matching KNOWN_MODULES
70
70
  // row carries the canonicalPort + paths for status/expose surfaces.
71
- { port: 1946, name: "parachute-app", status: "assigned" },
71
+ { port: 1946, name: "parachute-surface", status: "assigned" },
72
72
  { port: 1947, name: "unassigned", status: "reserved" },
73
73
  { port: 1948, name: "unassigned", status: "reserved" },
74
74
  { port: 1949, name: "unassigned", status: "reserved" },
@@ -281,7 +281,7 @@ const NOTES_FALLBACK: FirstPartyFallback = {
281
281
  name: "notes",
282
282
  manifestName: "parachute-notes",
283
283
  displayName: "Notes",
284
- tagline: "Notes PWA — daemon deprecated 2026-05-22; install `app` for the current path.",
284
+ tagline: "Notes PWA — daemon deprecated 2026-05-22; install `surface` for the current path.",
285
285
  port: 1942,
286
286
  paths: ["/notes"],
287
287
  health: "/notes/health",
@@ -462,28 +462,29 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
462
462
  hasAuth: true,
463
463
  },
464
464
  },
465
- app: {
466
- short: "app",
467
- package: "@openparachute/app",
468
- manifestName: "parachute-app",
465
+ surface: {
466
+ short: "surface",
467
+ package: "@openparachute/surface",
468
+ manifestName: "parachute-surface",
469
469
  canonicalPort: 1946,
470
- displayName: "App",
470
+ displayName: "Surface",
471
471
  // Tagline telegraphs the auto-bootstrap so wizard + admin-SPA copy explain
472
- // the architecture: installing `app` brings Notes (and other UIs) along
473
- // via the Phase 2.1 bootstrap-default-apps step. The notes-daemon path
474
- // still exists as a back-compat install (CURATED_MODULES still lists
475
- // `notes`) but `app` is the recommended first install post-vault.
476
- tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
477
- canonicalPaths: ["/app", "/.parachute"],
478
- canonicalHealth: "/app/healthz",
472
+ // the architecture: installing `surface` brings Notes (and other UIs)
473
+ // along via the Phase 2.1 bootstrap-default-apps step. The notes-daemon
474
+ // path still exists as a back-compat install (CURATED_MODULES still
475
+ // lists `notes`) but `surface` is the recommended first install
476
+ // post-vault. Renamed from `app` 2026-05-27 per patterns#102.
477
+ tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
478
+ canonicalPaths: ["/surface", "/.parachute"],
479
+ canonicalHealth: "/surface/healthz",
479
480
  canonicalStripPrefix: false,
480
481
  extras: {
481
482
  // Backward-compat startCmd — same rationale as scribe / vault / runner
482
483
  // above. Post-self-register, lifecycle reads module.json's startCmd via
483
484
  // `composeKnownModuleSpec` and that path wins.
484
- startCmd: () => ["parachute-app", "serve"],
485
- // App's admin + per-UI surfaces gate behind hub-issued JWTs (design
486
- // doc §6 same-hub auto-trust + scope `app:admin`). Surfaces in
485
+ startCmd: () => ["parachute-surface", "serve"],
486
+ // Surface's admin + per-UI surfaces gate behind hub-issued JWTs (design
487
+ // doc §6 same-hub auto-trust + scope `surface:admin`). Surfaces in
487
488
  // `parachute status` as auth-required by default, same posture as vault
488
489
  // + runner.
489
490
  hasAuth: true,
@@ -516,7 +517,27 @@ export const KNOWN_MODULES: Record<string, KnownModule> = {
516
517
  export const RETIRED_MODULES: Record<string, { retiredAt: string; replacement?: string }> = {
517
518
  agent: {
518
519
  retiredAt: "2026-05-20",
519
- replacement: "parachute-app or parachute-runner (depending on use case)",
520
+ replacement: "parachute-surface or parachute-runner (depending on use case)",
521
+ },
522
+ // 2026-05-20 retirement caught both forms of legacy rows.
523
+ "parachute-agent": {
524
+ retiredAt: "2026-05-20",
525
+ replacement: "parachute-surface or parachute-runner (depending on use case)",
526
+ },
527
+ // The `parachute-app` row name retires 2026-05-27 along with the
528
+ // app → surface rename (patterns#102). Operators upgrading from
529
+ // 0.5.13-stable will have a `parachute-app` row in services.json
530
+ // pointing at the now-removed @openparachute/app package; this entry
531
+ // drops it on load + steers them at `parachute install surface`.
532
+ // The short-name `app` form is included for legacy rows that used
533
+ // the short name as the `name` field.
534
+ app: {
535
+ retiredAt: "2026-05-27",
536
+ replacement: "parachute-surface (renamed from parachute-app — `parachute install surface`)",
537
+ },
538
+ "parachute-app": {
539
+ retiredAt: "2026-05-27",
540
+ replacement: "parachute-surface (renamed from parachute-app — `parachute install surface`)",
520
541
  },
521
542
  };
522
543
 
@@ -38,6 +38,8 @@
38
38
  */
39
39
 
40
40
  import type { Database } from "bun:sqlite";
41
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
42
+ import { join } from "node:path";
41
43
  import { type OperationsRegistry, runInstall, specFor } from "./api-modules-ops.ts";
42
44
  import { CURATED_MODULES, type CuratedModuleShort } from "./api-modules.ts";
43
45
  import { brandMarkSvg, WORDMARK_TEXT } from "./brand.ts";
@@ -449,6 +451,14 @@ export interface RenderVaultStepProps {
449
451
  errorMessage?: string;
450
452
  /** Pre-fill the vault name input after a validation failure. */
451
453
  vaultName?: string;
454
+ /**
455
+ * When the runtime is a hosted container (Render / Fly), the scribe
456
+ * sub-form hides the "local provider" option — Whisper / parakeet
457
+ * don't run usefully in the constrained container. Defaults to false
458
+ * (treat as self-host, show local option) — production wizard renders
459
+ * always pass an explicit value via detectAutoExposeMode.
460
+ */
461
+ cloudHost?: boolean;
452
462
  /**
453
463
  * When an install op is in progress, render the polling shape: no
454
464
  * form, just the op log + auto-refresh.
@@ -458,11 +468,18 @@ export interface RenderVaultStepProps {
458
468
  status: "pending" | "running" | "succeeded" | "failed";
459
469
  log: readonly string[];
460
470
  error?: string;
471
+ /**
472
+ * Optional scribe install op_id, threaded through so the success
473
+ * redirect carries `&op_scribe=<id>` and the done step picks up the
474
+ * in-flight scribe install via the existing per-tile op-poll
475
+ * mechanism (`buildInstallTiles` reads `op_<short>` query param).
476
+ */
477
+ scribeOpId?: string;
461
478
  };
462
479
  }
463
480
 
464
481
  export function renderVaultStep(props: RenderVaultStepProps): string {
465
- const { csrfToken, errorMessage, operation, vaultName } = props;
482
+ const { csrfToken, errorMessage, operation, vaultName, cloudHost } = props;
466
483
  if (operation) return renderVaultOpStep({ operation });
467
484
  const error = errorMessage ? `<p class="error-banner">${escapeHtml(errorMessage)}</p>` : "";
468
485
  // hub#267: the typed name now flows end-to-end via
@@ -523,12 +540,98 @@ export function renderVaultStep(props: RenderVaultStepProps): string {
523
540
  <span class="field-hint">lowercase letters, digits, <code>-</code>, <code>_</code>;
524
541
  2–32 chars. Leave blank for <code>${DEFAULT_VAULT_NAME}</code>.</span>
525
542
  </label>
543
+ ${renderScribeSubForm(cloudHost === true)}
526
544
  <button type="submit" class="btn btn-primary">Create vault & finish</button>
527
545
  </form>
528
546
  </div>`;
529
547
  return baseDocument("Set up your Parachute hub — vault", body);
530
548
  }
531
549
 
550
+ /**
551
+ * Scribe install sub-form embedded in the vault step (folded in
552
+ * 2026-05-27 per Aaron's team-meeting directive: "folding the scribe
553
+ * question into the vault step is a good idea"). Operator answers
554
+ * scribe-related questions in the same form as vault name, the POST
555
+ * handler kicks both installs in parallel, and the done screen polls
556
+ * scribe's progress via the existing per-tile op-poll mechanism.
557
+ *
558
+ * The provider list adapts to the runtime context:
559
+ * - Cloud container (Render / Fly): local transcribers (parakeet,
560
+ * whisper) don't fit in 512MB + can't reach hardware acceleration.
561
+ * We hide them. Groq is the default (fast cloud Whisper, ~$0.04/hr
562
+ * of audio); OpenAI is the alternative.
563
+ * - Local (Mac / Linux): parakeet-mlx is the default on Mac (silicon
564
+ * MLX); falls back to onnx-asr cross-platform. Cloud providers
565
+ * stay available as choices for operators who'd rather pay than
566
+ * run local inference.
567
+ *
568
+ * The API key input shows conditionally — only when a cloud provider
569
+ * is selected. It's a plain text input (no `type=password`) because
570
+ * (a) the operator just pasted it from their provider's dashboard, and
571
+ * (b) showing it lets them verify they pasted correctly before submit.
572
+ * Mode-switching between providers via the radio is handled by an
573
+ * inline `<script>` block — no SPA bundle, no module deps.
574
+ *
575
+ * The "Skip — no transcription" option is third and unchecked by
576
+ * default. Most operators want voice transcription once they know
577
+ * they can; the default-on posture matches the auto-transcribe default
578
+ * flip that landed in vault#373.
579
+ */
580
+ function renderScribeSubForm(cloudHost: boolean): string {
581
+ const localBlock = cloudHost
582
+ ? ""
583
+ : `
584
+ <label class="scribe-provider-option">
585
+ <input type="radio" name="scribe_provider" value="local"${cloudHost ? "" : " checked"} data-needs-key="false" />
586
+ <span class="provider-name">Local <small>(Mac MLX or ONNX — no API key needed)</small></span>
587
+ </label>`;
588
+ const groqDefault = cloudHost ? " checked" : "";
589
+ return `
590
+ <details class="scribe-suboptions" open>
591
+ <summary class="cursor-pointer">
592
+ <span class="field-label">Enable voice transcription</span>
593
+ <span class="field-hint"> · Scribe installs alongside vault, transcribes audio attachments automatically</span>
594
+ </summary>
595
+ <div class="scribe-provider-block">
596
+ <p class="field-hint">Pick a transcription provider. You can change this later in <code>/admin/modules</code>.</p>
597
+ <div class="scribe-provider-list">
598
+ ${localBlock}
599
+ <label class="scribe-provider-option">
600
+ <input type="radio" name="scribe_provider" value="groq"${groqDefault} data-needs-key="true" />
601
+ <span class="provider-name">Groq <small>(~\$0.04/hr of audio, fast)</small></span>
602
+ </label>
603
+ <label class="scribe-provider-option">
604
+ <input type="radio" name="scribe_provider" value="openai" data-needs-key="true" />
605
+ <span class="provider-name">OpenAI Whisper <small>(~\$0.36/hr of audio)</small></span>
606
+ </label>
607
+ <label class="scribe-provider-option">
608
+ <input type="radio" name="scribe_provider" value="none" data-needs-key="false" />
609
+ <span class="provider-name">Skip — no transcription</span>
610
+ </label>
611
+ </div>
612
+ <label class="field scribe-api-key-field" data-shows-on="cloud">
613
+ <span class="field-label">API key</span>
614
+ <input type="text" name="scribe_api_key" autocomplete="off" placeholder="gsk_… or sk-…" />
615
+ <span class="field-hint">Pasted directly into <code>~/.parachute/scribe/config.json</code> on this hub (file mode 0o600). Leave blank to skip and set later in the admin SPA.</span>
616
+ </label>
617
+ </div>
618
+ </details>
619
+ <script>
620
+ (function () {
621
+ var radios = document.querySelectorAll('input[name="scribe_provider"]');
622
+ var keyField = document.querySelector('.scribe-api-key-field');
623
+ function sync() {
624
+ var selected = document.querySelector('input[name="scribe_provider"]:checked');
625
+ var needsKey = selected && selected.dataset.needsKey === "true";
626
+ if (keyField) keyField.style.display = needsKey ? "" : "none";
627
+ }
628
+ radios.forEach(function (r) { r.addEventListener("change", sync); });
629
+ sync();
630
+ })();
631
+ </script>
632
+ `;
633
+ }
634
+
532
635
  function renderVaultOpStep(props: {
533
636
  operation: NonNullable<RenderVaultStepProps["operation"]>;
534
637
  }): string {
@@ -567,7 +670,7 @@ function renderVaultOpStep(props: {
567
670
  </section>
568
671
  ${
569
672
  operation.status === "succeeded"
570
- ? '<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1" />'
673
+ ? `<meta http-equiv="refresh" content="1; url=/admin/setup?just_finished=1${operation.scribeOpId ? `&op_scribe=${encodeURIComponent(operation.scribeOpId)}` : ""}" />`
571
674
  : ""
572
675
  }
573
676
  </div>`;
@@ -721,7 +824,7 @@ export interface RenderDoneStepProps {
721
824
  /**
722
825
  * Whether parachute-app is installed alongside the vault. Drives the
723
826
  * "Start using your vault" lead tile (hub#342): when true, the tile
724
- * links to `/app/notes/` (the canonical user-facing surface — App
827
+ * links to `/surface/notes/` (the canonical user-facing surface — App
725
828
  * auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
726
829
  * false, it falls back to the vault's own admin UI at
727
830
  * `/vault/<name>/admin/` so the operator still has a single obvious
@@ -738,7 +841,7 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
738
841
  const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
739
842
  const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
740
843
  const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
741
- const startTile = renderStartUsingTile(vaultName, appInstalled === true);
844
+ const startTile = renderStartUsingTile(vaultName, appInstalled === true, hubOrigin);
742
845
  // The done-grid hosts the MCP-connect tile + the admin-UI fallback.
743
846
  // The install tiles sit above it as a "what's next?" surface (curated
744
847
  // catalog of modules an operator might want next). The "Start using
@@ -762,6 +865,7 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
762
865
  </div>
763
866
  ${reachable}
764
867
  ${startTile}
868
+ ${renderStarterPromptsSection()}
765
869
  ${installSection}
766
870
  <section class="done-grid">
767
871
  ${mcpTile}
@@ -941,7 +1045,7 @@ function renderMcpTile(
941
1045
  * command, admin UI, additional module installs).
942
1046
  *
943
1047
  * Two shapes:
944
- * - **App installed** → primary tile targets `/app/notes/` (the
1048
+ * - **App installed** → primary tile targets `/surface/notes/` (the
945
1049
  * Notes app reading the just-created vault). This is the
946
1050
  * canonical surface post-Notes-as-app migration (parachute-app §17).
947
1051
  * - **App NOT installed** → primary tile targets the vault's own
@@ -953,26 +1057,98 @@ function renderMcpTile(
953
1057
  * "start using parachute" — not three competing tiles where the
954
1058
  * "real" entry point is buried under the MCP command pre-hub#342.
955
1059
  */
956
- function renderStartUsingTile(vaultName: string, appInstalled: boolean): string {
1060
+ /**
1061
+ * Lead "Start using your vault" tile. Points at the canonical
1062
+ * notes.parachute.computer hosted PWA as the primary CTA — with the
1063
+ * operator's own hub URL pre-filled via `?url=` so the connect screen
1064
+ * auto-populates + auto-focuses (notes-ui AddVault route, see
1065
+ * parachute-app/packages/notes-ui/src/app/routes/AddVault.tsx).
1066
+ *
1067
+ * Aaron 2026-05-27 directive: "skipping the local surface install for
1068
+ * most operators is good ... showing notes.parachute.computer more
1069
+ * prominently is a good idea." The notes.parachute.computer PWA is the
1070
+ * canonical user-facing UI; operators no longer need to install the
1071
+ * Surface module locally to use Notes. They still can (local install
1072
+ * works the same way), but the wizard doesn't push them toward it as
1073
+ * the default.
1074
+ *
1075
+ * Secondary CTA: "Open vault admin" (the vault's own admin UI on this
1076
+ * hub) for operators who want to look at raw vault state.
1077
+ *
1078
+ * `appInstalled` is no longer load-bearing for the primary path —
1079
+ * notes.parachute.computer works regardless of whether Surface is
1080
+ * installed locally. Kept in the signature so the older test fixtures
1081
+ * + the boolean flag stay coherent; only the secondary fallback message
1082
+ * differs based on it.
1083
+ */
1084
+ function renderStartUsingTile(
1085
+ vaultName: string,
1086
+ appInstalled: boolean,
1087
+ hubOrigin: string,
1088
+ ): string {
957
1089
  const safeVault = escapeHtml(vaultName);
958
1090
  // Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
959
1091
  // a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
960
1092
  const urlVault = encodeURIComponent(vaultName);
961
- if (appInstalled) {
962
- return `<section class="start-using" data-testid="start-using-tile">
963
- <h2>Start using your vault</h2>
964
- <p>Notes is installed and ready. Capture your first note in the
965
- Notes app — it reads from <code>${safeVault}</code> directly.</p>
966
- <p><a class="btn btn-primary" href="/app/notes/">Open Notes</a></p>
967
- </section>`;
968
- }
1093
+ // The `?url=` query param is consumed by notes-ui's AddVault route
1094
+ // (packages/notes-ui/src/app/routes/AddVault.tsx) — it pre-fills the
1095
+ // vault URL input + auto-focuses Submit.
1096
+ const vaultUrlForAdd = encodeURIComponent(
1097
+ `${hubOrigin.replace(/\/+$/, "")}/vault/${vaultName}`,
1098
+ );
1099
+ // For appInstalled=false case (Surface NOT installed locally),
1100
+ // notes.parachute.computer is the recommended path. For appInstalled=true,
1101
+ // we mention the local option as a secondary affordance.
1102
+ const localNotesFallback = appInstalled
1103
+ ? `<p class="start-using-secondary">
1104
+ <a href="/surface/notes/">Or use Notes installed locally on this hub →</a>
1105
+ </p>`
1106
+ : "";
969
1107
  return `<section class="start-using" data-testid="start-using-tile">
970
1108
  <h2>Start using your vault</h2>
971
- <p>Your vault <code>${safeVault}</code> is provisioned. Install
972
- <strong>App</strong> below (it bundles the Notes UI) to start
973
- capturing or open the vault's admin UI now to see what's
974
- inside.</p>
975
- <p><a class="btn btn-primary" href="/vault/${urlVault}/admin/">Open vault admin</a></p>
1109
+ <p>Open Notes — the canonical browser UI for your vault <code>${safeVault}</code>.
1110
+ It connects to your hub over HTTPS and remembers your URL after the first OAuth.</p>
1111
+ <p><a class="btn btn-primary" href="https://notes.parachute.computer/add?url=${vaultUrlForAdd}" target="_blank" rel="noopener">Open Notes ↗</a></p>
1112
+ <p class="start-using-secondary">
1113
+ <a href="/vault/${urlVault}/admin/">Or browse the vault's admin UI →</a>
1114
+ </p>
1115
+ ${localNotesFallback}
1116
+ </section>`;
1117
+ }
1118
+
1119
+ /**
1120
+ * Starter-prompts tile on the done screen. Surfaces the two
1121
+ * interview-style prompts hosted at parachute.computer:
1122
+ *
1123
+ * 1. "Help me set up my vault" — AI interviews the operator about
1124
+ * where their data lives + proposes a tag/path structure
1125
+ * (parachute.computer/onboarding/vault-setup/).
1126
+ * 2. "Build a custom UI" — AI builds a static SPA against the vault's
1127
+ * HTTP API, hosted on the operator's own GitHub Pages
1128
+ * (parachute.computer/onboarding/surface-build/).
1129
+ *
1130
+ * Aaron 2026-05-27 directive: ship these as the "first AI assist"
1131
+ * surface so freshly-onboarded operators have a clear next thing to
1132
+ * do beyond clicking around the admin UI. The prompts live on
1133
+ * parachute.computer rather than embedded in the wizard so they can
1134
+ * be iterated without a hub release; the wizard just links.
1135
+ */
1136
+ function renderStarterPromptsSection(): string {
1137
+ return `<section class="starter-prompts" data-testid="starter-prompts">
1138
+ <h2>Get help from your AI</h2>
1139
+ <p class="starter-prompts-subtitle">Two interview-style prompts to paste into Claude Code or Codex once your vault's MCP is wired up.</p>
1140
+ <div class="starter-prompts-grid">
1141
+ <a class="starter-prompt-tile" href="https://parachute.computer/onboarding/vault-setup/" target="_blank" rel="noopener">
1142
+ <h3>Set up your vault</h3>
1143
+ <p>Interview-style. AI asks where your notes live now + proposes a tag &amp; path structure that fits how you actually think.</p>
1144
+ <p class="starter-prompt-cta">Open prompt ↗</p>
1145
+ </a>
1146
+ <a class="starter-prompt-tile" href="https://parachute.computer/onboarding/surface-build/" target="_blank" rel="noopener">
1147
+ <h3>Build a custom UI</h3>
1148
+ <p>AI generates a static SPA hosted on your own GitHub Pages — talks to your vault over HTTP. Notes UI works as a reference.</p>
1149
+ <p class="starter-prompt-cta">Open prompt ↗</p>
1150
+ </a>
1151
+ </div>
976
1152
  </section>`;
977
1153
  }
978
1154
 
@@ -1079,7 +1255,7 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
1079
1255
  * surface decision.
1080
1256
  */
1081
1257
  const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
1082
- app: "/app/notes/",
1258
+ surface: "/surface/notes/",
1083
1259
  notes: "/notes/",
1084
1260
  // Omitted: scribe + runner. They don't ship an admin SPA yet
1085
1261
  // (scribe#53, runner#8 track). Pointing "Use it now" at /scribe/admin
@@ -1229,9 +1405,9 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1229
1405
  const installTiles = buildInstallTiles(url, deps);
1230
1406
  // hub#342: drive the lead "Start using your vault" tile's target.
1231
1407
  // When parachute-app is installed alongside vault, the tile links
1232
- // to `/app/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1408
+ // to `/surface/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
1233
1409
  // §17). Otherwise it falls back to the vault's own admin UI.
1234
- const appInstalled = isModuleInstalled("app", deps.manifestPath);
1410
+ const appInstalled = isModuleInstalled("surface", deps.manifestPath);
1235
1411
  const doneProps: RenderDoneStepProps = {
1236
1412
  vaultName,
1237
1413
  hubOrigin: deps.issuer,
@@ -1270,25 +1446,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1270
1446
  // Step 3 (vault) with an op in flight — render the poll page.
1271
1447
  if (state.hasAdmin && !state.hasVault) {
1272
1448
  const opId = url.searchParams.get("op");
1449
+ const cloudHost = detectAutoExposeMode(deps.env ?? process.env) === "public";
1273
1450
  if (opId) {
1274
1451
  const registry = deps.registry;
1275
1452
  const op = registry?.get(opId);
1276
1453
  if (op) {
1454
+ // Carry the scribe op_id forward via the query param so the
1455
+ // op-poll page's success-redirect threads it into the done
1456
+ // step's URL (where buildInstallTiles picks it up via the
1457
+ // existing per-tile `op_scribe` mechanism).
1458
+ const scribeOpIdParam = url.searchParams.get("op_scribe") ?? undefined;
1277
1459
  return new Response(
1278
1460
  renderVaultStep({
1279
1461
  csrfToken: csrf.token,
1462
+ cloudHost,
1280
1463
  operation: {
1281
1464
  id: op.id,
1282
1465
  status: op.status,
1283
1466
  log: op.log,
1284
1467
  ...(op.error !== undefined ? { error: op.error } : {}),
1468
+ ...(scribeOpIdParam !== undefined ? { scribeOpId: scribeOpIdParam } : {}),
1285
1469
  },
1286
1470
  }),
1287
1471
  { status: 200, headers: extraHeaders },
1288
1472
  );
1289
1473
  }
1290
1474
  }
1291
- return new Response(renderVaultStep({ csrfToken: csrf.token }), {
1475
+ return new Response(renderVaultStep({ csrfToken: csrf.token, cloudHost }), {
1292
1476
  status: 200,
1293
1477
  headers: extraHeaders,
1294
1478
  });
@@ -1609,7 +1793,130 @@ export async function handleSetupVaultPost(req: Request, deps: SetupWizardDeps):
1609
1793
  "[setup-wizard] handleSetupVaultPost called with no operations registry — install will NOT run. Wire deps.registry in the dispatcher.",
1610
1794
  );
1611
1795
  }
1612
- return redirect(`/admin/setup?op=${encodeURIComponent(op.id)}`);
1796
+ // Scribe sub-form fold (2026-05-27). The vault step's form lets
1797
+ // the operator answer "do you also want voice transcription?" in
1798
+ // the same submission. If they did, we (a) write the provider +
1799
+ // API key to `~/.parachute/surface/config.json` so scribe finds
1800
+ // them on first boot, and (b) kick a scribe install op in
1801
+ // parallel with vault install. The vault op-poll page threads the
1802
+ // scribe op_id through its success-redirect so the done step can
1803
+ // poll scribe progress via the existing per-tile mechanism.
1804
+ const scribeProvider = String(form.get("scribe_provider") ?? "").trim();
1805
+ let scribeOpId: string | undefined;
1806
+ if (scribeProvider !== "" && scribeProvider !== "none") {
1807
+ const scribeApiKey = String(form.get("scribe_api_key") ?? "").trim();
1808
+ // Write scribe config FIRST so scribe's first boot picks up the
1809
+ // provider + key without a second config edit. We don't fail the
1810
+ // wizard on a config-write error — log it + carry on; scribe will
1811
+ // boot with defaults + the operator can fix via /scribe/admin.
1812
+ try {
1813
+ writeScribeConfigForWizard(deps.configDir, scribeProvider, scribeApiKey);
1814
+ } catch (err) {
1815
+ console.warn(
1816
+ `[setup-wizard] failed to write scribe config: ${err instanceof Error ? err.message : String(err)} — kicking install anyway, operator can configure later.`,
1817
+ );
1818
+ }
1819
+ // Kick scribe install in parallel. Don't block on it; the done
1820
+ // step's per-tile op-poll surfaces progress.
1821
+ if (registry) {
1822
+ const scribeSpec = specFor("scribe");
1823
+ const scribeOp = registry.create("install", "scribe");
1824
+ scribeOpId = scribeOp.id;
1825
+ void runInstall(scribeOp.id, "scribe", scribeSpec, {
1826
+ db: deps.db,
1827
+ issuer: deps.issuer,
1828
+ manifestPath: deps.manifestPath,
1829
+ configDir: deps.configDir,
1830
+ supervisor: deps.supervisor,
1831
+ registry,
1832
+ ...(deps.run ? { run: deps.run } : {}),
1833
+ }).catch((err) => {
1834
+ const msg = err instanceof Error ? err.message : String(err);
1835
+ registry.update(
1836
+ scribeOp.id,
1837
+ { status: "failed", error: msg },
1838
+ `scribe install failed: ${msg}`,
1839
+ );
1840
+ });
1841
+ }
1842
+ }
1843
+ const redirectUrl = scribeOpId
1844
+ ? `/admin/setup?op=${encodeURIComponent(op.id)}&op_scribe=${encodeURIComponent(scribeOpId)}`
1845
+ : `/admin/setup?op=${encodeURIComponent(op.id)}`;
1846
+ return redirect(redirectUrl);
1847
+ }
1848
+
1849
+ /**
1850
+ * Write a minimal scribe config that selects the operator's chosen
1851
+ * transcribe provider + API key (when applicable). Idempotent: reads
1852
+ * any existing config, merges, writes back. File mode 0o600 — the
1853
+ * config holds API keys, owner-only.
1854
+ *
1855
+ * Lives in setup-wizard.ts (not scribe's own config-write.ts) because
1856
+ * (a) it's a one-time wizard write — the SPA's PUT /.parachute/config
1857
+ * surface is the canonical post-setup path, and (b) hub doesn't
1858
+ * import scribe-internal modules. The shape of `scribe-config.json`
1859
+ * is documented in parachute-scribe/src/config.ts; the fields we set
1860
+ * (transcribe.provider + transcribeProviders.<name>.apiKey) are
1861
+ * stable.
1862
+ */
1863
+ function writeScribeConfigForWizard(
1864
+ configDir: string,
1865
+ provider: string,
1866
+ apiKey: string,
1867
+ ): void {
1868
+ // For `local` (Mac MLX / cross-platform ONNX), just set the
1869
+ // provider name — no key needed.
1870
+ if (provider === "local") {
1871
+ persistScribeConfig(configDir, { transcribe: { provider: "parakeet-mlx" } });
1872
+ return;
1873
+ }
1874
+ // Cloud providers need a key. Empty key → just set provider; the
1875
+ // operator can paste the key later via /scribe/admin without a
1876
+ // restart (per provider-config.ts's per-request precedence).
1877
+ const update: Record<string, unknown> = { transcribe: { provider } };
1878
+ if (apiKey !== "") {
1879
+ update.transcribeProviders = { [provider]: { apiKey } };
1880
+ }
1881
+ persistScribeConfig(configDir, update);
1882
+ }
1883
+
1884
+ /**
1885
+ * Merge-write to scribe's config file at `<configDir>/scribe/config.json`.
1886
+ * Reads existing JSON when present, deep-merges `update`, writes back at
1887
+ * mode 0o600. Creates the parent dir if missing.
1888
+ */
1889
+ function persistScribeConfig(configDir: string, update: Record<string, unknown>): void {
1890
+ const scribeDir = join(configDir, "scribe");
1891
+ const configPath = join(scribeDir, "config.json");
1892
+ mkdirSync(scribeDir, { recursive: true });
1893
+ let existing: Record<string, unknown> = {};
1894
+ if (existsSync(configPath)) {
1895
+ try {
1896
+ existing = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
1897
+ } catch {
1898
+ // Malformed existing config — treat as empty + overwrite.
1899
+ existing = {};
1900
+ }
1901
+ }
1902
+ // Shallow merge at top level, deep merge for the two known sub-blocks
1903
+ // we touch (transcribe + transcribeProviders).
1904
+ const merged: Record<string, unknown> = { ...existing };
1905
+ for (const [key, value] of Object.entries(update)) {
1906
+ if (
1907
+ typeof value === "object" &&
1908
+ value !== null &&
1909
+ !Array.isArray(value) &&
1910
+ typeof merged[key] === "object" &&
1911
+ merged[key] !== null &&
1912
+ !Array.isArray(merged[key])
1913
+ ) {
1914
+ merged[key] = { ...(merged[key] as Record<string, unknown>), ...value };
1915
+ } else {
1916
+ merged[key] = value;
1917
+ }
1918
+ }
1919
+ writeFileSync(configPath, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
1613
1920
  }
1614
1921
 
1615
1922
  /**
@@ -1711,9 +2018,9 @@ const INSTALL_TILE_PROPS: ReadonlyArray<{
1711
2018
  tagline: string;
1712
2019
  }> = [
1713
2020
  {
1714
- short: "app",
1715
- displayName: "App",
1716
- tagline: "Host module for Parachute UIs — auto-installs Notes on first boot.",
2021
+ short: "surface",
2022
+ displayName: "Surface",
2023
+ tagline: "Host module for Parachute surfaces — auto-installs Notes on first boot.",
1717
2024
  },
1718
2025
  {
1719
2026
  short: "scribe",
@@ -1882,7 +2189,7 @@ function validateAccountFields(input: {
1882
2189
  * Whether a given curated module is currently installed (has a row in
1883
2190
  * services.json keyed by its canonical `manifestName`). Used by the
1884
2191
  * done-step renderer (hub#342) to decide whether to point the "Start
1885
- * using your vault" tile at `/app/notes/` (App installed → Notes UI
2192
+ * using your vault" tile at `/surface/notes/` (App installed → Notes UI
1886
2193
  * auto-bootstrapped) vs the vault's own admin UI. Cheap manifest read
1887
2194
  * shared with `buildInstallTiles`.
1888
2195
  */