@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.
- package/README.md +109 -15
- package/package.json +7 -3
- package/src/__tests__/account-home-ui.test.ts +251 -15
- package/src/__tests__/account-vault-token.test.ts +355 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-mint-token.test.ts +693 -5
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +45 -0
- package/src/__tests__/api-modules.test.ts +92 -75
- package/src/__tests__/api-ready.test.ts +135 -0
- package/src/__tests__/api-revoke-token.test.ts +384 -0
- package/src/__tests__/api-users.test.ts +7 -2
- package/src/__tests__/auth.test.ts +157 -30
- package/src/__tests__/cli.test.ts +44 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-2fa-warning.test.ts +31 -17
- package/src/__tests__/expose-auth-preflight.test.ts +71 -72
- package/src/__tests__/expose-cloudflare.test.ts +582 -11
- package/src/__tests__/expose-interactive.test.ts +10 -4
- package/src/__tests__/expose-public-auto.test.ts +5 -1
- package/src/__tests__/expose.test.ts +52 -2
- package/src/__tests__/hub-server.test.ts +396 -10
- package/src/__tests__/hub.test.ts +85 -6
- package/src/__tests__/init.test.ts +928 -0
- package/src/__tests__/lifecycle.test.ts +464 -2
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/oauth-handlers.test.ts +1252 -83
- package/src/__tests__/oauth-ui.test.ts +12 -1
- package/src/__tests__/operator-token-issuer-self-heal.test.ts +412 -0
- package/src/__tests__/proxy-error-ui.test.ts +212 -0
- package/src/__tests__/proxy-state.test.ts +192 -0
- package/src/__tests__/resource-binding.test.ts +97 -0
- package/src/__tests__/scope-explanations.test.ts +77 -12
- package/src/__tests__/services-manifest.test.ts +122 -4
- package/src/__tests__/setup-wizard.test.ts +633 -53
- package/src/__tests__/status.test.ts +36 -0
- package/src/__tests__/two-factor-flow.test.ts +602 -0
- package/src/__tests__/two-factor.test.ts +183 -0
- package/src/__tests__/upgrade.test.ts +78 -1
- package/src/__tests__/users.test.ts +68 -0
- package/src/__tests__/vault-auth-status.test.ts +312 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +263 -0
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +488 -38
- package/src/account-vault-token.ts +282 -0
- package/src/admin-handlers.ts +159 -4
- package/src/admin-login-ui.ts +49 -5
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +14 -0
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +49 -11
- package/src/api-modules.ts +29 -12
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +29 -3
- package/src/cli.ts +112 -25
- package/src/clients.ts +18 -6
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +82 -20
- package/src/commands/auth.ts +165 -24
- package/src/commands/expose-2fa-warning.ts +34 -32
- package/src/commands/expose-auth-preflight.ts +89 -78
- package/src/commands/expose-cloudflare.ts +471 -16
- package/src/commands/expose-interactive.ts +10 -11
- package/src/commands/expose-public-auto.ts +6 -4
- package/src/commands/expose.ts +8 -0
- package/src/commands/init.ts +594 -0
- package/src/commands/install.ts +33 -2
- package/src/commands/lifecycle.ts +386 -17
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +22 -0
- package/src/commands/upgrade.ts +55 -11
- package/src/commands/wizard.ts +847 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -15
- package/src/hub-db.ts +39 -1
- package/src/hub-server.ts +119 -13
- package/src/hub-settings.ts +11 -0
- package/src/hub.ts +82 -14
- package/src/oauth-handlers.ts +298 -21
- package/src/oauth-ui.ts +10 -0
- package/src/operator-token.ts +151 -0
- package/src/pending-login.ts +116 -0
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/rate-limit.ts +51 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +131 -14
- package/src/services-manifest.ts +112 -0
- package/src/setup-wizard.ts +738 -125
- package/src/tailscale/run.ts +28 -11
- package/src/totp.ts +201 -0
- package/src/two-factor-handlers.ts +287 -0
- package/src/two-factor-store.ts +181 -0
- package/src/two-factor-ui.ts +462 -0
- package/src/users.ts +58 -0
- package/src/vault/auth-status.ts +200 -25
- package/src/vault-hub-origin-env.ts +163 -0
- package/web/ui/dist/assets/index-BiBlvEaj.css +1 -0
- package/web/ui/dist/assets/index-CIN3mnmf.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/src/__tests__/vault-tokens-create-interactive.test.ts +0 -183
- package/src/commands/vault-tokens-create-interactive.ts +0 -143
- package/web/ui/dist/assets/index-7DtAXz7y.css +0 -1
- 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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
--
|
|
488
|
-
--
|
|
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 --
|
|
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
|
|
450
|
-
* (service crashed, port shifted, mid-
|
|
451
|
-
*
|
|
452
|
-
*
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
package/src/hub-settings.ts
CHANGED
|
@@ -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
|