@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
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}
@@ -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: owner)
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
 
@@ -201,6 +326,7 @@ Usage:
201
326
  parachute expose public --tailnet
202
327
  parachute expose public --cloudflare --domain <hostname>
203
328
  parachute expose public off --cloudflare
329
+ parachute expose cloudflare --domain <hostname> # alias for the above
204
330
 
205
331
  Status:
206
332
  tailnet is the supported exposure shape. The hub's OAuth + per-module
@@ -256,6 +382,7 @@ Examples:
256
382
  parachute expose public off # stop the Funnel
257
383
  parachute expose public --cloudflare --domain vault.example.com
258
384
  # stable URL via cloudflared
385
+ parachute expose cloudflare --domain vault.example.com # alias for the line above
259
386
  parachute expose public off --cloudflare # stop the cloudflared tunnel
260
387
 
261
388
  Tailscale Funnel constraints:
@@ -466,29 +593,44 @@ Examples:
466
593
  }
467
594
 
468
595
  export function migrateHelp(): string {
469
- return `parachute migrate — archive legacy files at the ecosystem root
596
+ return `parachute migrate — archive known-legacy files at the ecosystem root
470
597
 
471
598
  Usage:
472
- parachute migrate [--dry-run] [--yes]
599
+ parachute migrate [--list] [--dry-run] [--yes]
473
600
 
474
601
  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.
602
+ Scans ~/.parachute/ for files and directories that match the
603
+ known-legacy allowlist (daily.db*, server.yaml, channel.log/err,
604
+ channel.start.sh, top-level logs/, tokens.db*, and the legacy lens/
605
+ directory). Matching entries are moved under
606
+ ~/.parachute/.archive-<YYYY-MM-DD>/never deleted.
607
+
608
+ Anything *not* on the allowlist is left in place with an "[unknown —
609
+ skipping]" note. The hub doesn't presume to know what every module
610
+ (or your own setup) puts at the root, so the default is conservative:
611
+ if it isn't a known-legacy pattern, migrate leaves it alone. Remove
612
+ unknowns manually if you're sure.
613
+
614
+ Dotfiles at the root (.env, .DS_Store, prior .archive-* dirs) are
615
+ never touched.
616
+
617
+ Safety:
618
+ - Refuses to sweep while any service is running — stop them first
619
+ (\`parachute stop\`) or preview with \`--list\`.
620
+ - SQLite-shape files (\`*.db\`, \`*.db-wal\`, \`*.db-shm\`) get a
621
+ \`[live-db]\` label and pull an extra confirmation; wal/shm
622
+ consistency depends on all three moving together.
623
+ - Plan annotates each entry: \`[safe]\` / \`[live-db]\` /
624
+ \`[unknown — skipping]\`, with skipped items printed last.
625
+ - In a non-TTY shell (CI / piped), refuses without \`--yes\`.
485
626
 
486
627
  Flags:
487
- --dry-run print the plan; make no changes
488
- --yes, -y skip the confirmation prompt
628
+ --list print the plan; make no changes (friendly preview)
629
+ --dry-run synonym for --list (kept for back-compat)
630
+ --yes, -y skip the confirmation prompt; required in non-TTY shells
489
631
 
490
632
  Examples:
491
- parachute migrate --dry-run see what would move, without touching anything
633
+ parachute migrate --list see what would move, without touching anything
492
634
  parachute migrate interactive sweep (prompts before acting)
493
635
  parachute migrate --yes sweep without prompting
494
636
  `;
package/src/hub-db.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * Hub-local SQLite database. Opens `~/.parachute/hub.db` (overridable via
3
3
  * `$PARACHUTE_HOME`). Holds everything the hub owns as the ecosystem's OAuth
4
4
  * issuer — signing keys (v1), users + opaque refresh tokens (v2), OAuth
5
- * clients + auth-codes + grants + browser sessions (v3).
5
+ * clients + auth-codes + grants + browser sessions (v3), and TOTP 2FA
6
+ * enrollment on the users row (v11, hub#473).
6
7
  *
7
8
  * Each open() runs `migrate()` to bring the schema up to date. A
8
9
  * `schema_version` table records every applied migration so re-opens are
@@ -320,6 +321,43 @@ const MIGRATIONS: readonly Migration[] = [
320
321
  ALTER TABLE users DROP COLUMN assigned_vault;
321
322
  `,
322
323
  },
324
+ {
325
+ version: 11,
326
+ sql: `
327
+ -- Real TOTP 2FA at the hub login layer (hub#473). Three nullable
328
+ -- columns on \`users\`:
329
+ --
330
+ -- * totp_secret (TEXT, nullable) — the base32-encoded RFC 6238 TOTP
331
+ -- secret. NULL means "2FA not enrolled" — the canonical "is 2FA on
332
+ -- for this user?" signal. Stored as the plaintext base32 string
333
+ -- (not encrypted at rest): hub.db already holds the argon2id
334
+ -- password hashes AND the OAuth signing private keys in plaintext
335
+ -- PEM (signing_keys.private_key_pem), so the TOTP secret sits at
336
+ -- the same operator-local trust boundary — encrypting one column
337
+ -- while leaving the signing key in the clear would be security
338
+ -- theatre. A future at-rest-encryption pass (hub#474 follow-up)
339
+ -- would cover all three (password hashes are already one-way; the
340
+ -- signing key + TOTP secret are the recoverable secrets).
341
+ -- * totp_backup_codes (TEXT, nullable) — JSON array of argon2id-HASHED
342
+ -- single-use recovery codes. Same hash family as passwords
343
+ -- (@node-rs/argon2). Plaintext codes are shown to the user exactly
344
+ -- once at enrollment and never stored. A code is removed from the
345
+ -- array when consumed. NULL / "[]" means "no backup codes left."
346
+ -- * totp_enrolled_at (TEXT, nullable) — ISO-8601 timestamp of the
347
+ -- last successful enrollment. NULL until first enroll; informational
348
+ -- (admin UI / account page "2FA enabled since …").
349
+ --
350
+ -- Backfill: every existing user pre-dates this migration and gets NULL
351
+ -- for all three — i.e. "2FA not enrolled." Their /login flow stays
352
+ -- password-only (the login handler only requires a TOTP step when
353
+ -- totp_secret IS NOT NULL), so existing operators keep signing in
354
+ -- exactly as before. No backfill UPDATE needed — the column default is
355
+ -- NULL.
356
+ ALTER TABLE users ADD COLUMN totp_secret TEXT;
357
+ ALTER TABLE users ADD COLUMN totp_backup_codes TEXT;
358
+ ALTER TABLE users ADD COLUMN totp_enrolled_at TEXT;
359
+ `,
360
+ },
323
361
  ];
324
362
 
325
363
  export function openHubDb(path: string = hubDbPath()): Database {
package/src/hub-server.ts CHANGED
@@ -67,11 +67,20 @@
67
67
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
68
68
  * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
69
69
  * /login (GET + POST) → operator password login
70
+ * /login/2fa (POST) → second-factor (TOTP/backup) step
71
+ * (hub#473; reached after a correct
72
+ * password for a 2FA-enrolled user)
70
73
  * /logout (POST) → end admin session
71
74
  * /account/change-password (GET + POST) → user self-service change-password
72
75
  * (force-redirect target for users
73
76
  * with password_changed=false; also
74
77
  * reachable directly to rotate)
78
+ * /account/2fa (GET + POST) → user self-service 2FA enroll/disenroll
79
+ * (hub#473; QR + backup codes)
80
+ * /account/vault-token/<name> (POST) → friend mints a scoped
81
+ * vault:<name>:read|write bearer for
82
+ * an ASSIGNED vault (headless clients;
83
+ * session + assignment + scope-capped)
75
84
  * /admin/config* → 301 → /admin/vaults (legacy
76
85
  * portal retired post-SPA-rework)
77
86
  *
@@ -108,11 +117,13 @@ import { existsSync } from "node:fs";
108
117
  import { dirname, join, resolve } from "node:path";
109
118
  import { fileURLToPath } from "node:url";
110
119
  import pkg from "../package.json" with { type: "json" };
120
+ import { handleAccountVaultTokenPost } from "./account-vault-token.ts";
111
121
  import { handleApproveClient, handleGetClient } from "./admin-clients.ts";
112
122
  import { handleListGrants, handleRevokeGrant } from "./admin-grants.ts";
113
123
  import {
114
124
  handleAdminLoginGet,
115
125
  handleAdminLoginPost,
126
+ handleAdminLoginTotpPost,
116
127
  handleAdminLogoutPost,
117
128
  } from "./admin-handlers.ts";
118
129
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
@@ -137,6 +148,7 @@ import {
137
148
  parseModulesPath,
138
149
  } from "./api-modules-ops.ts";
139
150
  import { handleApiModules, handleApiModulesChannel } from "./api-modules.ts";
151
+ import { handleApiReady } from "./api-ready.ts";
140
152
  import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
141
153
  import { handleApiRevokeToken } from "./api-revoke-token.ts";
142
154
  import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
@@ -177,6 +189,8 @@ import {
177
189
  import { renderNotFoundPage } from "./oauth-ui.ts";
178
190
  import { buildHubBoundOrigins } from "./origin-check.ts";
179
191
  import { clearPid, writePid } from "./process-state.ts";
192
+ import { toResponse as proxyErrorToResponse, renderProxyError } from "./proxy-error-ui.ts";
193
+ import { classifyUpstream } from "./proxy-state.ts";
180
194
  import { isHttpsRequest } from "./request-protocol.ts";
181
195
  import {
182
196
  FIRST_PARTY_FALLBACKS,
@@ -196,6 +210,7 @@ import {
196
210
  } from "./setup-wizard.ts";
197
211
  import { getAllPublicKeys } from "./signing-keys.ts";
198
212
  import type { Supervisor } from "./supervisor.ts";
213
+ import { handleTwoFactorGet, handleTwoFactorPost } from "./two-factor-handlers.ts";
199
214
  import { getUserById, userCount } from "./users.ts";
200
215
  import {
201
216
  WELL_KNOWN_DIR,
@@ -446,10 +461,21 @@ export function layerOf(req: Request): RequestLayer {
446
461
  * / agent / vault want the prefix), so the decision lives one layer up in
447
462
  * `proxyToService` / `proxyToVault`.
448
463
  *
449
- * Returns 502 when the loopback fetch fails — port valid, target unreachable
450
- * (service crashed, port shifted, mid-restart). `serviceLabel` is folded into
451
- * the error message so 502 bodies say `vault upstream unreachable` /
452
- * `scribe upstream unreachable` etc.
464
+ * Returns a boot-readiness-classified response when the loopback fetch fails
465
+ * — port valid, target unreachable (service crashed, port shifted, mid-
466
+ * restart, OR module is still inside its boot window). The response is
467
+ * classified by `classifyUpstream` into:
468
+ *
469
+ * - **transient** (still booting): 503 + Retry-After. HTML page polls
470
+ * /api/ready up to 5 attempts on a 2s cadence; JSON includes
471
+ * retry_after_ms + max_attempts.
472
+ * - **persistent** (crashed / never started): 502, no auto-retry. HTML
473
+ * surfaces a /admin/modules link; JSON includes admin_url.
474
+ *
475
+ * `serviceLabel` is the services.json entry name (`parachute-vault`,
476
+ * `scribe`, …) folded into the response body for operator clarity.
477
+ * `short` is the canonical short (`vault`/`scribe`/`notes`) — used as
478
+ * the supervisor map key + pidfile directory key for classification.
453
479
  *
454
480
  * Hop-by-hop notes: WebSocket upgrades and HTTP/2 trailers don't traverse
455
481
  * fetch-based proxies cleanly. No on-box service uses either today; if one
@@ -460,6 +486,8 @@ async function proxyRequest(
460
486
  req: Request,
461
487
  port: number,
462
488
  serviceLabel: string,
489
+ short: string,
490
+ supervisor: Supervisor | undefined,
463
491
  targetPath?: string,
464
492
  ): Promise<Response> {
465
493
  const url = new URL(req.url);
@@ -519,10 +547,20 @@ async function proxyRequest(
519
547
  return await fetch(upstream, init);
520
548
  } catch (err) {
521
549
  const msg = err instanceof Error ? err.message : String(err);
522
- return new Response(JSON.stringify({ error: `${serviceLabel} upstream unreachable: ${msg}` }), {
523
- status: 502,
524
- headers: { "content-type": "application/json" },
550
+ // Classify the failure (transient boot-window vs persistent crash) and
551
+ // render either an HTML page or a JSON error per the request's Accept.
552
+ // See `proxy-state.ts` for the classification logic + `proxy-error-ui.ts`
553
+ // for the two response shapes (closes hub#443).
554
+ const classifyOpts: Parameters<typeof classifyUpstream>[1] = {};
555
+ if (supervisor !== undefined) classifyOpts.supervisor = supervisor;
556
+ const state = classifyUpstream(short, classifyOpts);
557
+ const rendered = renderProxyError(req, {
558
+ short,
559
+ serviceLabel,
560
+ state,
561
+ upstreamError: msg,
525
562
  });
563
+ return proxyErrorToResponse(rendered);
526
564
  }
527
565
  }
528
566
 
@@ -536,7 +574,11 @@ async function proxyRequest(
536
574
  * fall through to the SPA shell fallback for unknown vault names (the seam
537
575
  * #173 introduced).
538
576
  */
539
- async function proxyToVault(req: Request, manifestPath: string): Promise<Response | undefined> {
577
+ async function proxyToVault(
578
+ req: Request,
579
+ manifestPath: string,
580
+ supervisor: Supervisor | undefined,
581
+ ): Promise<Response | undefined> {
540
582
  // Lenient — see hub#406. One bad services.json row no longer takes
541
583
  // down vault routing the way it used to take down /admin/setup and
542
584
  // /api/modules (the symptom Aaron hit 2026-05-26).
@@ -557,7 +599,11 @@ async function proxyToVault(req: Request, manifestPath: string): Promise<Respons
557
599
  // both proxies keeps the dispatch surface consistent for future readers.
558
600
  const stripPrefix = stripPrefixFor(match.entry);
559
601
  const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
560
- return proxyRequest(req, match.port, "vault", targetPath);
602
+ // Vault's short is the literal "vault" — fixed by KNOWN_MODULES. Multiple
603
+ // vault instances share the same supervisor key under hub's current
604
+ // single-vault-per-hub model; if multi-vault-per-hub ever ships, the
605
+ // classifier will need a per-instance key.
606
+ return proxyRequest(req, match.port, "vault", "vault", supervisor, targetPath);
561
607
  }
562
608
 
563
609
  /**
@@ -613,7 +659,11 @@ export function findServiceUpstream(
613
659
  *
614
660
  * Returns `undefined` when no service claims the pathname; caller 404s.
615
661
  */
616
- async function proxyToService(req: Request, manifestPath: string): Promise<Response | undefined> {
662
+ async function proxyToService(
663
+ req: Request,
664
+ manifestPath: string,
665
+ supervisor: Supervisor | undefined,
666
+ ): Promise<Response | undefined> {
617
667
  // Lenient read on the hot-path — a single malformed services.json
618
668
  // entry (e.g. a module installed at a buggy version that wrote
619
669
  // `port: 0`) used to cascade into 500s for every route on this hub
@@ -649,7 +699,13 @@ async function proxyToService(req: Request, manifestPath: string): Promise<Respo
649
699
  // services).
650
700
  const stripPrefix = stripPrefixFor(match.entry);
651
701
  const targetPath = stripPrefix ? url.pathname.slice(match.mount.length) || "/" : undefined;
652
- return proxyRequest(req, match.port, match.entry.name, targetPath);
702
+ // Resolve canonical short for classification — falls back to the
703
+ // services.json name when the entry isn't a KNOWN_MODULES / FALLBACK
704
+ // shape (third-party services have no canonical short; the classifier
705
+ // will land in "persistent" by default which is the safer choice for
706
+ // unknown lifecycle).
707
+ const short = shortNameForManifest(match.entry.name) ?? match.entry.name;
708
+ return proxyRequest(req, match.port, match.entry.name, short, supervisor, targetPath);
653
709
  }
654
710
 
655
711
  /**
@@ -1262,6 +1318,16 @@ export function hubFetch(
1262
1318
  );
1263
1319
  }
1264
1320
 
1321
+ // Boot-readiness probe (hub#443). Used by the transient-state proxy
1322
+ // error page's inline poll script to detect when a still-booting
1323
+ // module has come up. Public + DB-free so it works during the pre-
1324
+ // admin lockout (the page that polls it is itself served pre-auth).
1325
+ if (pathname === "/api/ready") {
1326
+ const readyDeps: Parameters<typeof handleApiReady>[1] = {};
1327
+ if (deps?.supervisor !== undefined) readyDeps.supervisor = deps.supervisor;
1328
+ return handleApiReady(req, readyDeps);
1329
+ }
1330
+
1265
1331
  // First-boot setup wizard (hub#259). Three steps server-rendered:
1266
1332
  // GET /admin/setup — derive state, render the right step
1267
1333
  // POST /admin/setup/account — create the admin row, set session
@@ -1975,6 +2041,17 @@ export function hubFetch(
1975
2041
  return new Response("method not allowed", { status: 405 });
1976
2042
  }
1977
2043
 
2044
+ // /login/2fa — second-factor step (hub#473). POST-only: reached only
2045
+ // after a correct password POST for a 2FA-enrolled user handed back a
2046
+ // pending-login cookie + rendered the challenge page. A bare GET (e.g.
2047
+ // browser back button) has no form to render usefully, so 405 → the
2048
+ // operator restarts at /login.
2049
+ if (pathname === "/login/2fa") {
2050
+ if (!getDb) return dbNotConfigured();
2051
+ if (req.method === "POST") return handleAdminLoginTotpPost(getDb(), req);
2052
+ return new Response("method not allowed", { status: 405 });
2053
+ }
2054
+
1978
2055
  if (pathname === "/logout") {
1979
2056
  if (!getDb) return dbNotConfigured();
1980
2057
  if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
@@ -2002,6 +2079,35 @@ export function hubFetch(
2002
2079
  return new Response("method not allowed", { status: 405 });
2003
2080
  }
2004
2081
 
2082
+ // /account/2fa — user self-service TOTP 2FA enroll / disenroll (hub#473).
2083
+ // Both GET (render state) and POST (start/confirm/disable) require an
2084
+ // active session; the handler does the session check + 302 to /login when
2085
+ // missing, same posture as /account/change-password.
2086
+ if (pathname === "/account/2fa") {
2087
+ if (!getDb) return dbNotConfigured();
2088
+ const twoFactorDeps = { db: getDb() };
2089
+ if (req.method === "GET") return handleTwoFactorGet(req, twoFactorDeps);
2090
+ if (req.method === "POST") return handleTwoFactorPost(req, twoFactorDeps);
2091
+ return new Response("method not allowed", { status: 405 });
2092
+ }
2093
+
2094
+ // /account/vault-token/<name> — friend-facing scoped vault token mint.
2095
+ // POST-only, session-gated, assignment-capped: a non-admin friend mints a
2096
+ // `vault:<name>:read|write` bearer for a vault they're ASSIGNED to, for
2097
+ // scripts / headless clients that can't do browser OAuth. The handler
2098
+ // enforces session → assignment → scope-cap (never `:admin`, never a
2099
+ // vault outside the assignment, never a broader verb than the role
2100
+ // grants) + CSRF + per-user rate limit. Must precede the `/account/`
2101
+ // match below (more specific prefix). See `account-vault-token.ts`.
2102
+ if (pathname.startsWith("/account/vault-token/")) {
2103
+ if (!getDb) return dbNotConfigured();
2104
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
2105
+ const vaultName = decodeURIComponent(pathname.slice("/account/vault-token/".length));
2106
+ const db = getDb();
2107
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer);
2108
+ return handleAccountVaultTokenPost(req, vaultName, { db, hubOrigin });
2109
+ }
2110
+
2005
2111
  // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
2006
2112
  // Companion to the first-admin gate on `/admin/host-admin-token`: a
2007
2113
  // signed-in non-admin (friend) lands here instead of bouncing against
@@ -2041,7 +2147,7 @@ export function hubFetch(
2041
2147
  // here anymore (the SPA moved to /admin), so we can't accidentally
2042
2148
  // mask a backend 404 with HTML.
2043
2149
  if (pathname.startsWith("/vault/")) {
2044
- const proxied = await proxyToVault(req, manifestPath);
2150
+ const proxied = await proxyToVault(req, manifestPath, deps?.supervisor);
2045
2151
  if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2046
2152
  return new Response("not found", { status: 404 });
2047
2153
  }
@@ -2068,7 +2174,7 @@ export function hubFetch(
2068
2174
  // here only after every hub-owned prefix above has had its turn — so
2069
2175
  // `/`, `/admin/*`, `/oauth/*`, `/.well-known/*`, `/hub/*`, `/vault/*`,
2070
2176
  // `/api/*` are excluded by ordering, not by an explicit denylist (#182).
2071
- const proxied = await proxyToService(req, manifestPath);
2177
+ const proxied = await proxyToService(req, manifestPath, deps?.supervisor);
2072
2178
  if (proxied) return decorateWithChrome(proxied, req, pathname, getDb);
2073
2179
 
2074
2180
  // Branded fall-through 404 (closes hub#392) — the operator who mistyped
@@ -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