@openparachute/hub 0.5.13 → 0.5.14-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +109 -15
  2. package/package.json +2 -2
  3. package/src/__tests__/account-home-ui.test.ts +205 -0
  4. package/src/__tests__/admin-handlers.test.ts +74 -0
  5. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  6. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  7. package/src/__tests__/admin-vaults.test.ts +70 -4
  8. package/src/__tests__/api-account.test.ts +191 -1
  9. package/src/__tests__/api-mint-token.test.ts +682 -3
  10. package/src/__tests__/api-modules-config.test.ts +16 -10
  11. package/src/__tests__/api-modules-ops.test.ts +97 -0
  12. package/src/__tests__/api-modules.test.ts +100 -83
  13. package/src/__tests__/api-ready.test.ts +135 -0
  14. package/src/__tests__/api-revoke-token.test.ts +384 -0
  15. package/src/__tests__/api-users.test.ts +390 -13
  16. package/src/__tests__/chrome-strip.test.ts +15 -15
  17. package/src/__tests__/cli.test.ts +7 -5
  18. package/src/__tests__/cloudflare-detect.test.ts +60 -5
  19. package/src/__tests__/expose-auth-preflight.test.ts +58 -50
  20. package/src/__tests__/expose-cloudflare.test.ts +114 -3
  21. package/src/__tests__/expose-interactive.test.ts +10 -4
  22. package/src/__tests__/expose-public-auto.test.ts +5 -1
  23. package/src/__tests__/expose.test.ts +49 -1
  24. package/src/__tests__/hub-db.test.ts +194 -29
  25. package/src/__tests__/hub-server.test.ts +322 -33
  26. package/src/__tests__/hub.test.ts +11 -0
  27. package/src/__tests__/init.test.ts +827 -0
  28. package/src/__tests__/lifecycle.test.ts +33 -1
  29. package/src/__tests__/migrate.test.ts +433 -51
  30. package/src/__tests__/notes-redirect.test.ts +20 -20
  31. package/src/__tests__/oauth-handlers.test.ts +1060 -29
  32. package/src/__tests__/oauth-ui.test.ts +12 -1
  33. package/src/__tests__/proxy-error-ui.test.ts +212 -0
  34. package/src/__tests__/proxy-state.test.ts +192 -0
  35. package/src/__tests__/resource-binding.test.ts +97 -0
  36. package/src/__tests__/scope-explanations.test.ts +36 -0
  37. package/src/__tests__/serve.test.ts +9 -9
  38. package/src/__tests__/services-manifest.test.ts +40 -40
  39. package/src/__tests__/setup-wizard.test.ts +1114 -66
  40. package/src/__tests__/setup.test.ts +1 -1
  41. package/src/__tests__/status.test.ts +39 -0
  42. package/src/__tests__/users.test.ts +396 -9
  43. package/src/__tests__/vault-auth-status.test.ts +271 -11
  44. package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
  45. package/src/__tests__/well-known.test.ts +9 -9
  46. package/src/__tests__/wizard.test.ts +372 -0
  47. package/src/account-home-ui.ts +547 -0
  48. package/src/admin-handlers.ts +49 -17
  49. package/src/admin-host-admin-token.ts +25 -0
  50. package/src/admin-login-ui.ts +4 -4
  51. package/src/admin-vault-admin-token.ts +17 -0
  52. package/src/admin-vaults.ts +48 -15
  53. package/src/api-account.ts +72 -6
  54. package/src/api-mint-token.ts +132 -24
  55. package/src/api-modules-ops.ts +52 -16
  56. package/src/api-modules.ts +31 -14
  57. package/src/api-ready.ts +102 -0
  58. package/src/api-revoke-token.ts +107 -21
  59. package/src/api-users.ts +497 -58
  60. package/src/bun-link.ts +55 -0
  61. package/src/chrome-strip.ts +6 -6
  62. package/src/cli.ts +93 -24
  63. package/src/cloudflare/config.ts +10 -4
  64. package/src/cloudflare/detect.ts +73 -6
  65. package/src/commands/expose-auth-preflight.ts +55 -63
  66. package/src/commands/expose-cloudflare.ts +114 -10
  67. package/src/commands/expose-interactive.ts +10 -11
  68. package/src/commands/expose-public-auto.ts +6 -4
  69. package/src/commands/expose.ts +8 -0
  70. package/src/commands/init.ts +563 -0
  71. package/src/commands/install.ts +41 -23
  72. package/src/commands/lifecycle.ts +12 -0
  73. package/src/commands/migrate.ts +293 -41
  74. package/src/commands/status.ts +10 -1
  75. package/src/commands/wizard.ts +843 -0
  76. package/src/env-file.ts +10 -0
  77. package/src/help.ts +157 -17
  78. package/src/hub-db.ts +42 -0
  79. package/src/hub-server.ts +136 -23
  80. package/src/hub-settings.ts +13 -2
  81. package/src/hub.ts +16 -9
  82. package/src/notes-redirect.ts +5 -5
  83. package/src/oauth-handlers.ts +342 -173
  84. package/src/oauth-ui.ts +28 -2
  85. package/src/proxy-error-ui.ts +506 -0
  86. package/src/proxy-state.ts +131 -0
  87. package/src/resource-binding.ts +134 -0
  88. package/src/scope-attenuation.ts +85 -0
  89. package/src/scope-explanations.ts +94 -5
  90. package/src/service-spec.ts +39 -18
  91. package/src/setup-wizard.ts +1173 -117
  92. package/src/users.ts +307 -29
  93. package/src/vault/auth-status.ts +152 -25
  94. package/src/vault-hub-origin-env.ts +100 -0
  95. package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
  96. package/web/ui/dist/assets/index-B28SdMSz.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
  99. package/src/commands/vault-tokens-create-interactive.ts +0 -143
  100. package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
  101. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
package/src/env-file.ts CHANGED
@@ -68,6 +68,16 @@ export function upsertEnvLine(lines: string[], key: string, value: string): stri
68
68
  return next;
69
69
  }
70
70
 
71
+ /**
72
+ * Drop every `KEY=…` line for `key`, preserving the order of the rest.
73
+ * Returns a new array; the input is untouched. No-op (returns a copy) when
74
+ * the key isn't present.
75
+ */
76
+ export function removeEnvLine(lines: string[], key: string): string[] {
77
+ const prefix = `${key}=`;
78
+ return lines.filter((line) => !line.startsWith(prefix));
79
+ }
80
+
71
81
  export function writeEnvFile(path: string, lines: readonly string[]): void {
72
82
  mkdirSync(dirname(path), { recursive: true });
73
83
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
package/src/help.ts CHANGED
@@ -5,7 +5,14 @@ export function topLevelHelp(): string {
5
5
  const services = knownServices().join(" | ");
6
6
  return `parachute ${pkg.version} — top-level CLI for the Parachute ecosystem
7
7
 
8
+ Fresh install? Start here — works the same on a laptop or a remote server:
9
+ parachute init one quick step → admin wizard in your browser
10
+ (offers optional exposure on remote boxes)
11
+
8
12
  Usage:
13
+ parachute init bring hub up, offer exposure, open admin wizard
14
+ (also lets you walk the wizard in the CLI; --cli-wizard)
15
+ parachute setup-wizard --hub-url <url> walk /admin/setup in the terminal
9
16
  parachute setup interactive walk-through: install services + configure
10
17
  parachute install <service> install and register a service
11
18
  services: ${services}
@@ -82,7 +89,7 @@ Environment:
82
89
 
83
90
  Examples:
84
91
  parachute install vault # installs, runs init, starts vault
85
- parachute install app # installs app (auto-bootstraps Notes)
92
+ parachute install surface # installs surface (auto-bootstraps Notes)
86
93
  parachute install notes # back-compat: legacy notes-daemon (Phase 2 deprecating)
87
94
  parachute install scribe # installs, prompts for provider, starts scribe
88
95
  parachute install scribe --scribe-provider groq --scribe-key gsk_…
@@ -100,6 +107,124 @@ Aliases:
100
107
  `;
101
108
  }
102
109
 
110
+ export function initHelp(): string {
111
+ return `parachute init — get the admin wizard open in one step
112
+
113
+ Usage:
114
+ parachute init [--no-browser] [--no-expose-prompt]
115
+ [--expose none|tailnet|cloudflare]
116
+ [--cli-wizard | --browser-wizard]
117
+
118
+ What it does:
119
+ Fresh-install front door, one command for both laptops AND remote
120
+ servers (EC2, DigitalOcean, Hetzner, any VPS). The admin SPA already
121
+ walks operators through the rest (install vault, set up the admin
122
+ user, install scribe / runner / app); this command's only job is to
123
+ get you to that wizard.
124
+
125
+ Idempotent — every re-run is safe:
126
+ 1. If the hub isn't running, start it.
127
+ 2. If the hub isn't already exposed, in a terminal, offer to set up
128
+ exposure (Tailscale Funnel, Cloudflare Tunnel, or stay loopback).
129
+ The default highlights "no thanks" on laptops and Cloudflare on
130
+ servers (SSH session detected). Skip with --no-expose-prompt or
131
+ pin non-interactively with --expose.
132
+ 3. Print the canonical admin URL (loopback when not exposed, the
133
+ tailnet / cloudflare FQDN when exposure is active).
134
+ 4. In a terminal, offer to open the URL in your browser
135
+ (macOS \`open\`, Linux \`xdg-open\`). Skip with --no-browser or
136
+ run from a non-TTY shell.
137
+
138
+ If your hub is up + exposure is already set up + a vault is already
139
+ configured, init just confirms "looks good — here's your URL" and
140
+ exits 0.
141
+
142
+ Flags:
143
+ --no-browser just print the URL; don't offer to launch a browser
144
+ --no-expose-prompt skip the exposure question; fall through to localhost URL
145
+ --expose <choice> non-interactive exposure override:
146
+ none — stay loopback-only
147
+ tailnet — set up Tailscale Funnel (private to your tailnet)
148
+ cloudflare — set up Cloudflare Tunnel (your own domain)
149
+ --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
150
+ in this terminal (hub#168 Cut 4)
151
+ --browser-wizard skip the prompt and open the browser wizard directly
152
+
153
+ Examples:
154
+ parachute init # laptop: prompts, defaults to "no expose"
155
+ parachute init # ssh'd server: prompts, defaults to Cloudflare
156
+ parachute init --no-expose-prompt # skip the question; just print localhost URL
157
+ parachute init --expose cloudflare # CI/scripted: chain straight into Cloudflare
158
+ parachute init --expose tailnet # CI/scripted: chain straight into Tailscale
159
+ parachute init --no-browser # don't shell out to open / xdg-open
160
+ parachute init --cli-wizard # walk the wizard in this terminal (hub#168)
161
+ `;
162
+ }
163
+
164
+ export function setupWizardHelp(): string {
165
+ return `parachute setup-wizard — terminal mirror of /admin/setup (hub#168)
166
+
167
+ Usage:
168
+ parachute setup-wizard --hub-url <url>
169
+ [--account-username <name>] [--account-password <pw>]
170
+ [--bootstrap-token <token>]
171
+ [--vault-mode create|import|skip] [--vault-name <name>]
172
+ [--vault-import-url <url>] [--vault-import-pat <pat>] [--vault-import-replace]
173
+ [--expose-mode localhost|tailnet|public]
174
+
175
+ What it does:
176
+ Walks the same three-step setup flow the browser wizard does, in your
177
+ terminal. Hits the same backend handlers (POST /admin/setup/account,
178
+ /admin/setup/vault, /admin/setup/expose) — so any wizard bug fix lands
179
+ in both surfaces.
180
+
181
+ Step 1: admin account (username + password).
182
+ Step 2: vault (create / import from git / skip).
183
+ Step 3: expose mode (localhost / tailnet / public).
184
+
185
+ Idempotent: re-running picks up at the next undone step. Already-done
186
+ steps are skipped without prompts. Same as the browser wizard.
187
+
188
+ Run-from-flag: every prompt accepts a paired CLI flag so an entirely
189
+ scripted setup works (CI, ansible, etc.). Mirrors the existing
190
+ PARACHUTE_INITIAL_ADMIN_* env-seed path.
191
+
192
+ Flags:
193
+ --hub-url <url> base URL of the hub (required; e.g.
194
+ http://127.0.0.1:1939). \`parachute init\`
195
+ passes this in when chaining; standalone
196
+ callers supply it explicitly.
197
+ --account-username <name> pre-supply the admin username (default: admin)
198
+ --account-password <pw> pre-supply the admin password (required when
199
+ non-interactive)
200
+ --bootstrap-token <token> one-time bootstrap token when the hub is in
201
+ container/serve mode. Reads PARACHUTE_BOOTSTRAP_TOKEN
202
+ from the env if not passed.
203
+ --vault-mode create|import|skip pre-pick the vault step's branch
204
+ --vault-name <name> pre-supply the vault name (create / import)
205
+ --vault-import-url <url> remote URL for import mode (HTTPS or SSH git URL)
206
+ --vault-import-pat <pat> PAT for private repo import (optional)
207
+ --vault-import-replace use replace mode (default is merge)
208
+ --skip-vault shorthand for --vault-mode skip
209
+ --expose-mode <mode> pre-pick the expose-mode answer
210
+
211
+ Examples:
212
+ parachute setup-wizard --hub-url http://127.0.0.1:1939
213
+ # walks all three steps interactively
214
+
215
+ parachute setup-wizard --hub-url http://127.0.0.1:1939 \\
216
+ --account-username admin --account-password 'long-pw-here' \\
217
+ --vault-mode create --vault-name default \\
218
+ --expose-mode localhost
219
+ # fully non-interactive (CI-friendly)
220
+
221
+ parachute setup-wizard --hub-url http://127.0.0.1:1939 \\
222
+ --vault-import-url https://github.com/me/my-vault.git \\
223
+ --vault-import-pat ghp_xxx
224
+ # imports an existing vault export from GitHub
225
+ `;
226
+ }
227
+
103
228
  export function setupHelp(): string {
104
229
  return `parachute setup — interactive walk-through to install + configure services
105
230
 
@@ -188,7 +313,7 @@ Example:
188
313
  parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
189
314
  → http://127.0.0.1:1940/vault/default/mcp
190
315
  parachute-app 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
191
- → http://127.0.0.1:1946/app/notes
316
+ → http://127.0.0.1:1946/surface/notes
192
317
  `;
193
318
  }
194
319
 
@@ -466,29 +591,44 @@ Examples:
466
591
  }
467
592
 
468
593
  export function migrateHelp(): string {
469
- return `parachute migrate — archive legacy files at the ecosystem root
594
+ return `parachute migrate — archive known-legacy files at the ecosystem root
470
595
 
471
596
  Usage:
472
- parachute migrate [--dry-run] [--yes]
597
+ parachute migrate [--list] [--dry-run] [--yes]
473
598
 
474
599
  What it does:
475
- Scans ~/.parachute/ for files and directories that don't belong to the
476
- post-restructure layout. Recognized entries — per-service dirs
477
- (vault/, notes/, scribe/, channel/, hub/; legacy lens/ also kept),
478
- services.json,
479
- expose-state.json, well-known/stay in place. Anything else (plus
480
- known legacy cruft like daily.db, server.yaml) is moved under
481
- ~/.parachute/.archive-<YYYY-MM-DD>/, never deleted.
482
-
483
- Dotfiles at the root (.env, .DS_Store, prior .archive-* dirs) are left
484
- alone.
600
+ Scans ~/.parachute/ for files and directories that match the
601
+ known-legacy allowlist (daily.db*, server.yaml, channel.log/err,
602
+ channel.start.sh, top-level logs/, tokens.db*, and the legacy lens/
603
+ directory). Matching entries are moved under
604
+ ~/.parachute/.archive-<YYYY-MM-DD>/never deleted.
605
+
606
+ Anything *not* on the allowlist is left in place with an "[unknown —
607
+ skipping]" note. The hub doesn't presume to know what every module
608
+ (or your own setup) puts at the root, so the default is conservative:
609
+ if it isn't a known-legacy pattern, migrate leaves it alone. Remove
610
+ unknowns manually if you're sure.
611
+
612
+ Dotfiles at the root (.env, .DS_Store, prior .archive-* dirs) are
613
+ never touched.
614
+
615
+ Safety:
616
+ - Refuses to sweep while any service is running — stop them first
617
+ (\`parachute stop\`) or preview with \`--list\`.
618
+ - SQLite-shape files (\`*.db\`, \`*.db-wal\`, \`*.db-shm\`) get a
619
+ \`[live-db]\` label and pull an extra confirmation; wal/shm
620
+ consistency depends on all three moving together.
621
+ - Plan annotates each entry: \`[safe]\` / \`[live-db]\` /
622
+ \`[unknown — skipping]\`, with skipped items printed last.
623
+ - In a non-TTY shell (CI / piped), refuses without \`--yes\`.
485
624
 
486
625
  Flags:
487
- --dry-run print the plan; make no changes
488
- --yes, -y skip the confirmation prompt
626
+ --list print the plan; make no changes (friendly preview)
627
+ --dry-run synonym for --list (kept for back-compat)
628
+ --yes, -y skip the confirmation prompt; required in non-TTY shells
489
629
 
490
630
  Examples:
491
- parachute migrate --dry-run see what would move, without touching anything
631
+ parachute migrate --list see what would move, without touching anything
492
632
  parachute migrate interactive sweep (prompts before acting)
493
633
  parachute migrate --yes sweep without prompting
494
634
  `;
package/src/hub-db.ts CHANGED
@@ -278,6 +278,48 @@ const MIGRATIONS: readonly Migration[] = [
278
278
  UPDATE clients SET same_hub = 0;
279
279
  `,
280
280
  },
281
+ {
282
+ version: 10,
283
+ sql: `
284
+ -- Multi-user Phase 2 PR 2 (hub#252 follow-up, design
285
+ -- 2026-05-20-multi-user-phase-1.md §Phase 2). Lifts the single
286
+ -- \`users.assigned_vault TEXT\` column into a many-to-many
287
+ -- \`user_vaults\` table so one user can have access to multiple
288
+ -- vaults (e.g. a personal vault + a family-shared vault).
289
+ --
290
+ -- Schema:
291
+ -- * (user_id, vault_name) composite PK — one row per (user, vault).
292
+ -- ON DELETE CASCADE on user_id so user deletion drops the
293
+ -- assignments without us having to clean up manually.
294
+ -- * \`role\` TEXT DEFAULT 'write' — reserved for forward-compat per-
295
+ -- vault role granularity. Phase 1 had no role model; this column
296
+ -- gives later PRs a column to land scope-narrowing in without a
297
+ -- second migration. All backfilled rows default to 'write'.
298
+ -- * index on \`vault_name\` for the inverse lookup ("who has access
299
+ -- to vault X?") — useful when admin removes a vault and we want
300
+ -- to warn about pinned users.
301
+ --
302
+ -- Backfill: every existing row in \`users\` with a non-null
303
+ -- \`assigned_vault\` becomes a single (user_id, vault_name) row in
304
+ -- \`user_vaults\`. Rows with NULL \`assigned_vault\` (admin posture)
305
+ -- get no \`user_vaults\` entry — they remain "no narrowing" per
306
+ -- vaultScopeForUser semantics. After backfill the \`assigned_vault\`
307
+ -- column is dropped.
308
+ CREATE TABLE user_vaults (
309
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
310
+ vault_name TEXT NOT NULL,
311
+ role TEXT NOT NULL DEFAULT 'write',
312
+ created_at TEXT NOT NULL,
313
+ PRIMARY KEY (user_id, vault_name)
314
+ );
315
+ CREATE INDEX user_vaults_vault ON user_vaults (vault_name);
316
+ INSERT INTO user_vaults (user_id, vault_name, role, created_at)
317
+ SELECT id, assigned_vault, 'write', created_at
318
+ FROM users
319
+ WHERE assigned_vault IS NOT NULL;
320
+ ALTER TABLE users DROP COLUMN assigned_vault;
321
+ `,
322
+ },
281
323
  ];
282
324
 
283
325
  export function openHubDb(path: string = hubDbPath()): Database {
package/src/hub-server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * /admin/login, /admin/logout → 301 → /login, /logout
24
24
  *
25
25
  * # Notes-as-app migration Phase 2 (parachute-app design doc §16).
26
- * /notes, /notes/, /notes/* → 301 → /app/notes[/...]
26
+ * /notes, /notes/, /notes/* → 301 → /surface/notes[/...]
27
27
  * (opt-out via
28
28
  * hub_settings.notes_redirect_disabled)
29
29
  *
@@ -65,6 +65,7 @@
65
65
  * /api/users (GET + POST) → list / create user (host:admin)
66
66
  * /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
67
67
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
68
+ * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
68
69
  * /login (GET + POST) → operator password login
69
70
  * /logout (POST) → end admin session
70
71
  * /account/change-password (GET + POST) → user self-service change-password
@@ -117,7 +118,11 @@ import {
117
118
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
118
119
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
119
120
  import { handleCreateVault } from "./admin-vaults.ts";
120
- import { handleAccountChangePasswordGet, handleAccountChangePasswordPost } from "./api-account.ts";
121
+ import {
122
+ handleAccountChangePasswordGet,
123
+ handleAccountChangePasswordPost,
124
+ handleAccountHomeGet,
125
+ } from "./api-account.ts";
121
126
  import { handleApiHub } from "./api-hub.ts";
122
127
  import { handleApiMe } from "./api-me.ts";
123
128
  import { handleApiMintToken } from "./api-mint-token.ts";
@@ -132,6 +137,7 @@ import {
132
137
  parseModulesPath,
133
138
  } from "./api-modules-ops.ts";
134
139
  import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
140
+ import { handleApiReady } from "./api-ready.ts";
135
141
  import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
136
142
  import { handleApiRevokeToken } from "./api-revoke-token.ts";
137
143
  import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
@@ -141,6 +147,8 @@ import {
141
147
  handleDeleteUser,
142
148
  handleListUsers,
143
149
  handleListVaults,
150
+ handleResetUserPassword,
151
+ handleUpdateUserVaults,
144
152
  } from "./api-users.ts";
145
153
  import { buildChromeForRequest, injectChromeIntoResponse } from "./chrome-strip.ts";
146
154
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
@@ -170,6 +178,8 @@ import {
170
178
  import { renderNotFoundPage } from "./oauth-ui.ts";
171
179
  import { buildHubBoundOrigins } from "./origin-check.ts";
172
180
  import { clearPid, writePid } from "./process-state.ts";
181
+ import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
182
+ import { classifyUpstream } from "./proxy-state.ts";
173
183
  import { isHttpsRequest } from "./request-protocol.ts";
174
184
  import {
175
185
  FIRST_PARTY_FALLBACKS,
@@ -266,10 +276,7 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
266
276
  if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
267
277
  if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
268
278
  if (issuer === undefined) {
269
- const fromEnv =
270
- env.PARACHUTE_HUB_ORIGIN ??
271
- env.RENDER_EXTERNAL_URL ??
272
- flyDefaultOrigin(env);
279
+ const fromEnv = env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(env);
273
280
  if (fromEnv) issuer = fromEnv.replace(/\/+$/, "") || undefined;
274
281
  }
275
282
  return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
@@ -442,10 +449,21 @@ export function layerOf(req: Request): RequestLayer {
442
449
  * / agent / vault want the prefix), so the decision lives one layer up in
443
450
  * `proxyToService` / `proxyToVault`.
444
451
  *
445
- * Returns 502 when the loopback fetch fails — port valid, target unreachable
446
- * (service crashed, port shifted, mid-restart). `serviceLabel` is folded into
447
- * the error message so 502 bodies say `vault upstream unreachable` /
448
- * `scribe upstream unreachable` etc.
452
+ * Returns a boot-readiness-classified response when the loopback fetch fails
453
+ * — port valid, target unreachable (service crashed, port shifted, mid-
454
+ * restart, OR module is still inside its boot window). The response is
455
+ * classified by `classifyUpstream` into:
456
+ *
457
+ * - **transient** (still booting): 503 + Retry-After. HTML page polls
458
+ * /api/ready up to 5 attempts on a 2s cadence; JSON includes
459
+ * retry_after_ms + max_attempts.
460
+ * - **persistent** (crashed / never started): 502, no auto-retry. HTML
461
+ * surfaces a /admin/modules link; JSON includes admin_url.
462
+ *
463
+ * `serviceLabel` is the services.json entry name (`parachute-vault`,
464
+ * `scribe`, …) folded into the response body for operator clarity.
465
+ * `short` is the canonical short (`vault`/`scribe`/`notes`) — used as
466
+ * the supervisor map key + pidfile directory key for classification.
449
467
  *
450
468
  * Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
451
469
  * fetch-based proxies cleanly. No on-box service uses either today; if one
@@ -456,6 +474,8 @@ async function proxyRequest(
456
474
  req: Request,
457
475
  port: number,
458
476
  serviceLabel: string,
477
+ short: string,
478
+ supervisor: Supervisor | undefined,
459
479
  targetPath?: string,
460
480
  ): Promise<Response> {
461
481
  const url = new URL(req.url);
@@ -515,10 +535,20 @@ async function proxyRequest(
515
535
  return await fetch(upstream, init);
516
536
  } catch (err) {
517
537
  const msg = err instanceof Error ? err.message : String(err);
518
- return new Response(JSON.stringify({ error: `${serviceLabel} upstream unreachable: ${msg}` }), {
519
- status: 502,
520
- headers: { "content-type": "application/json" },
538
+ // Classify the failure (transient boot-window vs persistent crash) and
539
+ // render either an HTML page or a JSON error per the request's Accept.
540
+ // See `proxy-state.ts` for the classification logic + `proxy-error-ui.ts`
541
+ // for the two response shapes (closes hub#443).
542
+ const classifyOpts: Parameters<typeof classifyUpstream>[1] = {};
543
+ if (supervisor !== undefined) classifyOpts.supervisor = supervisor;
544
+ const state = classifyUpstream(short, classifyOpts);
545
+ const rendered = renderProxyError(req, {
546
+ short,
547
+ serviceLabel,
548
+ state,
549
+ upstreamError: msg,
521
550
  });
551
+ return proxyErrorToResponse(rendered);
522
552
  }
523
553
  }
524
554
 
@@ -532,7 +562,11 @@ async function proxyRequest(
532
562
  * fall through to the SPA shell fallback for unknown vault names (the seam
533
563
  * #173 introduced).
534
564
  */
535
- async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
565
+ async function proxyToVault(
566
+ req: Request,
567
+ manifestPath: string,
568
+ supervisor: Supervisor | undefined,
569
+ ): Promise<Response | undefined> {
536
570
  // Lenient — see hub#406. One bad services.json row no longer takes
537
571
  // down vault routing the way it used to take down /admin/setup and
538
572
  // /api/modules (the symptom Aaron hit 2026-05-26).
@@ -553,7 +587,11 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
553
587
  // both proxies keeps the dispatch surface consistent for future readers.
554
588
  const stripPrefix = stripPrefixFor(match.entry);
555
589
  const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
556
- return proxyRequest(req, match.port, "vault", targetPath);
590
+ // Vault's short is the literal "vault" — fixed by KNOWN_MODULES. Multiple
591
+ // vault instances share the same supervisor key under hub's current
592
+ // single-vault-per-hub model; if multi-vault-per-hub ever ships, the
593
+ // classifier will need a per-instance key.
594
+ return proxyRequest(req, match.port, "vault", "vault", supervisor, targetPath);
557
595
  }
558
596
 
559
597
  /**
@@ -609,7 +647,11 @@ export function findServiceUpstream(
609
647
  *
610
648
  * Returns `undefined` when no service claims the pathname; caller 404s.
611
649
  */
612
- async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
650
+ async function proxyToService(
651
+ req: Request,
652
+ manifestPath: string,
653
+ supervisor: Supervisor | undefined,
654
+ ): Promise<Response | undefined> {
613
655
  // Lenient read on the hot-path — a single malformed services.json
614
656
  // entry (e.g. a module installed at a buggy version that wrote
615
657
  // `port: 0`) used to cascade into 500s for every route on this hub
@@ -645,7 +687,13 @@ async function proxyToService(req: Request, manifestPath: string): Promise<Respo
645
687
  // services).
646
688
  const stripPrefix = stripPrefixFor(match.entry);
647
689
  const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
648
- return proxyRequest(req, match.port, match.entry.name, targetPath);
690
+ // Resolve canonical short for classification — falls back to the
691
+ // services.json name when the entry isn't a KNOWN_MODULES / FALLBACK
692
+ // shape (third-party services have no canonical short; the classifier
693
+ // will land in "persistent" by default which is the safer choice for
694
+ // unknown lifecycle).
695
+ const short = shortNameForManifest(match.entry.name) ?? match.entry.name;
696
+ return proxyRequest(req, match.port, match.entry.name, short, supervisor, targetPath);
649
697
  }
650
698
 
651
699
  /**
@@ -1118,8 +1166,7 @@ export function hubFetch(
1118
1166
  // browser POSTs and must be trusted even when the operator's
1119
1167
  // configured issuer points elsewhere. See origin-check.ts
1120
1168
  // jsdoc for the failure case this closes.
1121
- platformOrigin:
1122
- process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1169
+ platformOrigin: process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1123
1170
  }),
1124
1171
  };
1125
1172
  };
@@ -1199,7 +1246,7 @@ export function hubFetch(
1199
1246
  }
1200
1247
 
1201
1248
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
1202
- // `/notes/*` 301-redirects to `/app/notes/*` so legacy bookmarks land on
1249
+ // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land on
1203
1250
  // the apps-hosted Notes. Default-on; operators on notes-as-module-only
1204
1251
  // installs can opt out via `hub_settings.notes_redirect_disabled = true`
1205
1252
  // (see hub-settings.ts). The opt-out exists so a legacy operator
@@ -1259,6 +1306,16 @@ export function hubFetch(
1259
1306
  );
1260
1307
  }
1261
1308
 
1309
+ // Boot-readiness probe (hub#443). Used by the transient-state proxy
1310
+ // error page's inline poll script to detect when a still-booting
1311
+ // module has come up. Public + DB-free so it works during the pre-
1312
+ // admin lockout (the page that polls it is itself served pre-auth).
1313
+ if (pathname === "/api/ready") {
1314
+ const readyDeps: Parameters<typeof handleApiReady>[1] = {};
1315
+ if (deps?.supervisor !== undefined) readyDeps.supervisor = deps.supervisor;
1316
+ return handleApiReady(req, readyDeps);
1317
+ }
1318
+
1262
1319
  // First-boot setup wizard (hub#259). Three steps server-rendered:
1263
1320
  // GET /admin/setup — derive state, render the right step
1264
1321
  // POST /admin/setup/account — create the admin row, set session
@@ -1908,6 +1965,44 @@ export function hubFetch(
1908
1965
  manifestPath,
1909
1966
  });
1910
1967
  }
1968
+ // Phase 2 PR 1 — `/api/users/:id/reset-password` (admin-initiated
1969
+ // password reset for non-admin users). Routed BEFORE the per-id DELETE
1970
+ // catch-all so the trailing `/reset-password` segment isn't mistaken
1971
+ // for part of a user id. Same `host:admin` Bearer gate as the other
1972
+ // /api/users surfaces.
1973
+ {
1974
+ const resetMatch = pathname.match(/^\/api\/users\/([^/]+)\/reset-password$/);
1975
+ if (resetMatch) {
1976
+ if (!getDb) return dbNotConfigured();
1977
+ const id = decodeURIComponent(resetMatch[1] ?? "");
1978
+ if (!id) {
1979
+ return new Response("not found", { status: 404 });
1980
+ }
1981
+ return handleResetUserPassword(req, id, {
1982
+ db: getDb(),
1983
+ issuer: oauthDeps(req).issuer,
1984
+ manifestPath,
1985
+ });
1986
+ }
1987
+ }
1988
+ // Phase 2 PR 2 — `/api/users/:id/vaults` (replace a user's vault
1989
+ // assignments). Routed before the per-id DELETE catch-all so the
1990
+ // trailing `/vaults` segment isn't mistaken for part of a user id.
1991
+ {
1992
+ const vaultsMatch = pathname.match(/^\/api\/users\/([^/]+)\/vaults$/);
1993
+ if (vaultsMatch) {
1994
+ if (!getDb) return dbNotConfigured();
1995
+ const id = decodeURIComponent(vaultsMatch[1] ?? "");
1996
+ if (!id) {
1997
+ return new Response("not found", { status: 404 });
1998
+ }
1999
+ return handleUpdateUserVaults(req, id, {
2000
+ db: getDb(),
2001
+ issuer: oauthDeps(req).issuer,
2002
+ manifestPath,
2003
+ });
2004
+ }
2005
+ }
1911
2006
  if (pathname.startsWith("/api/users/")) {
1912
2007
  if (!getDb) return dbNotConfigured();
1913
2008
  const id = decodeURIComponent(pathname.slice("/api/users/".length));
@@ -1961,6 +2056,24 @@ export function hubFetch(
1961
2056
  return new Response("method not allowed", { status: 405 });
1962
2057
  }
1963
2058
 
2059
+ // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
2060
+ // Companion to the first-admin gate on `/admin/host-admin-token`: a
2061
+ // signed-in non-admin (friend) lands here instead of bouncing against
2062
+ // a 403 wall on the admin SPA. Admin users also land here when they
2063
+ // hit `/account/` directly, with a "you're the administrator → /admin/"
2064
+ // exit ramp. Bare `/account` 301-redirects to `/account/` so links
2065
+ // without the trailing slash work.
2066
+ if (pathname === "/account" || pathname === "/account/") {
2067
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2068
+ if (pathname === "/account") {
2069
+ return new Response(null, { status: 301, headers: { location: "/account/" } });
2070
+ }
2071
+ if (!getDb) return dbNotConfigured();
2072
+ const db = getDb();
2073
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer);
2074
+ return handleAccountHomeGet(req, { db, hubOrigin });
2075
+ }
2076
+
1964
2077
  // Legacy `/admin/config` (server-rendered module-config portal, #46)
1965
2078
  // retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
1966
2079
  // post-login redirect lands somewhere useful. The route stays here in
@@ -1982,7 +2095,7 @@ export function hubFetch(
1982
2095
  // here anymore (the SPA moved to /admin), so we can't accidentally
1983
2096
  // mask a backend 404 with HTML.
1984
2097
  if (pathname.startsWith("/vault/")) {
1985
- const proxied = await proxyToVault(req, manifestPath);
2098
+ const proxied = await proxyToVault(req, manifestPath, deps?.supervisor);
1986
2099
  if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
1987
2100
  return new Response("not found", { status: 404 });
1988
2101
  }
@@ -2009,7 +2122,7 @@ export function hubFetch(
2009
2122
  // here only after every hub-owned prefix above has had its turn — so
2010
2123
  // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
2011
2124
  // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
2012
- const proxied = await proxyToService(req, manifestPath);
2125
+ const proxied = await proxyToService(req, manifestPath, deps?.supervisor);
2013
2126
  if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2014
2127
 
2015
2128
  // Branded fall-through 404 (closes hub#392) — the operator who mistyped
@@ -2032,7 +2145,7 @@ export function hubFetch(
2032
2145
  * Inject the persistent chrome strip (workstream G) into a proxied response.
2033
2146
  *
2034
2147
  * Skips the rewrite when the response is non-200, non-HTML, on an opt-out
2035
- * path (e.g. `/app/notes/*`), or larger than `MAX_INJECT_SIZE_BYTES`.
2148
+ * path (e.g. `/surface/notes/*`), or larger than `MAX_INJECT_SIZE_BYTES`.
2036
2149
  * `injectChromeIntoResponse` is the no-side-effects implementation; this
2037
2150
  * wrapper threads in the session-aware chrome HTML and a `set-cookie`
2038
2151
  * append when a fresh CSRF cookie was minted.
@@ -42,6 +42,17 @@ export type HubSettingKey =
42
42
  // (vault's first-boot may write its own paths shape that the wizard
43
43
  // can't trust to match `<name>` exactly until the spawn settles).
44
44
  | "setup_vault_name"
45
+ // hub#168 Cut 2: operator explicitly chose Skip on the vault step.
46
+ // The vault module is installed (init.ts ran `install vault
47
+ // --no-create` per Cut 1), but no first-vault instance was created
48
+ // or imported. `deriveWizardState` consults this flag to advance
49
+ // past the vault step on subsequent GETs even though `hasVault`
50
+ // remains false. Value is the literal string "true" when set; absent
51
+ // means "operator hasn't skipped". Cleared if the operator later
52
+ // creates a vault from the admin SPA — that path can either delete
53
+ // this row directly or let the next wizard GET notice `hasVault ===
54
+ // true` and ignore the skip flag.
55
+ | "setup_vault_skipped"
45
56
  // hub#275: which dist-tag the runtime module installer uses
46
57
  // (`bun add -g <pkg>@<channel>`). `"latest"` (default) tracks the
47
58
  // stable channel; `"rc"` follows the release-candidate chain so
@@ -71,7 +82,7 @@ export type HubSettingKey =
71
82
  | "hub_origin"
72
83
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
73
84
  // When unset (default) or "false", hub serves a 301 redirect from
74
- // `/notes/*` → `/app/notes/*` so existing bookmarks transparently
85
+ // `/notes/*` → `/surface/notes/*` so existing bookmarks transparently
75
86
  // follow the operator to the apps-hosted Notes. When "true", the
76
87
  // redirect is skipped and `/notes/*` falls through to the existing
77
88
  // services.json-driven proxy — the escape hatch for operators
@@ -372,7 +383,7 @@ export function setHubOrigin(db: Database, value: string | null): void {
372
383
  // --- domain helpers: notes-as-app redirect (parachute-app §16 Phase 2) ----
373
384
 
374
385
  /**
375
- * Read whether the `/notes/*` → `/app/notes/*` redirect is disabled. Default
386
+ * Read whether the `/notes/*` → `/surface/notes/*` redirect is disabled. Default
376
387
  * is `false` (redirect on) — Phase 2 migrates operators to apps-hosted
377
388
  * Notes, so the bookmark-friendly path is the default-on behavior. Only an
378
389
  * operator running notes-as-a-module without parachute-app installed should