@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.
- package/README.md +109 -15
- package/package.json +2 -2
- package/src/__tests__/account-home-ui.test.ts +205 -0
- package/src/__tests__/admin-handlers.test.ts +74 -0
- package/src/__tests__/admin-host-admin-token.test.ts +62 -0
- package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
- package/src/__tests__/admin-vaults.test.ts +70 -4
- package/src/__tests__/api-account.test.ts +191 -1
- package/src/__tests__/api-mint-token.test.ts +682 -3
- package/src/__tests__/api-modules-config.test.ts +16 -10
- package/src/__tests__/api-modules-ops.test.ts +97 -0
- package/src/__tests__/api-modules.test.ts +100 -83
- 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 +390 -13
- package/src/__tests__/chrome-strip.test.ts +15 -15
- package/src/__tests__/cli.test.ts +7 -5
- package/src/__tests__/cloudflare-detect.test.ts +60 -5
- package/src/__tests__/expose-auth-preflight.test.ts +58 -50
- package/src/__tests__/expose-cloudflare.test.ts +114 -3
- 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 +49 -1
- package/src/__tests__/hub-db.test.ts +194 -29
- package/src/__tests__/hub-server.test.ts +322 -33
- package/src/__tests__/hub.test.ts +11 -0
- package/src/__tests__/init.test.ts +827 -0
- package/src/__tests__/lifecycle.test.ts +33 -1
- package/src/__tests__/migrate.test.ts +433 -51
- package/src/__tests__/notes-redirect.test.ts +20 -20
- package/src/__tests__/oauth-handlers.test.ts +1060 -29
- package/src/__tests__/oauth-ui.test.ts +12 -1
- 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 +36 -0
- package/src/__tests__/serve.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +40 -40
- package/src/__tests__/setup-wizard.test.ts +1114 -66
- package/src/__tests__/setup.test.ts +1 -1
- package/src/__tests__/status.test.ts +39 -0
- package/src/__tests__/users.test.ts +396 -9
- package/src/__tests__/vault-auth-status.test.ts +271 -11
- package/src/__tests__/vault-hub-origin-env.test.ts +126 -0
- package/src/__tests__/well-known.test.ts +9 -9
- package/src/__tests__/wizard.test.ts +372 -0
- package/src/account-home-ui.ts +547 -0
- package/src/admin-handlers.ts +49 -17
- package/src/admin-host-admin-token.ts +25 -0
- package/src/admin-login-ui.ts +4 -4
- package/src/admin-vault-admin-token.ts +17 -0
- package/src/admin-vaults.ts +48 -15
- package/src/api-account.ts +72 -6
- package/src/api-mint-token.ts +132 -24
- package/src/api-modules-ops.ts +52 -16
- package/src/api-modules.ts +31 -14
- package/src/api-ready.ts +102 -0
- package/src/api-revoke-token.ts +107 -21
- package/src/api-users.ts +497 -58
- package/src/bun-link.ts +55 -0
- package/src/chrome-strip.ts +6 -6
- package/src/cli.ts +93 -24
- package/src/cloudflare/config.ts +10 -4
- package/src/cloudflare/detect.ts +73 -6
- package/src/commands/expose-auth-preflight.ts +55 -63
- package/src/commands/expose-cloudflare.ts +114 -10
- 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 +563 -0
- package/src/commands/install.ts +41 -23
- package/src/commands/lifecycle.ts +12 -0
- package/src/commands/migrate.ts +293 -41
- package/src/commands/status.ts +10 -1
- package/src/commands/wizard.ts +843 -0
- package/src/env-file.ts +10 -0
- package/src/help.ts +157 -17
- package/src/hub-db.ts +42 -0
- package/src/hub-server.ts +136 -23
- package/src/hub-settings.ts +13 -2
- package/src/hub.ts +16 -9
- package/src/notes-redirect.ts +5 -5
- package/src/oauth-handlers.ts +342 -173
- package/src/oauth-ui.ts +28 -2
- package/src/proxy-error-ui.ts +506 -0
- package/src/proxy-state.ts +131 -0
- package/src/resource-binding.ts +134 -0
- package/src/scope-attenuation.ts +85 -0
- package/src/scope-explanations.ts +94 -5
- package/src/service-spec.ts +39 -18
- package/src/setup-wizard.ts +1173 -117
- package/src/users.ts +307 -29
- package/src/vault/auth-status.ts +152 -25
- package/src/vault-hub-origin-env.ts +100 -0
- package/web/ui/dist/assets/index-2SSK7JbM.js +61 -0
- package/web/ui/dist/assets/index-B28SdMSz.css +1 -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-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
|
|
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/
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
--
|
|
488
|
-
--
|
|
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 --
|
|
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 → /
|
|
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 {
|
|
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
|
|
446
|
-
* (service crashed, port shifted, mid-
|
|
447
|
-
*
|
|
448
|
-
*
|
|
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
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 `/
|
|
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. `/
|
|
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.
|
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
|
|
@@ -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/*` → `/
|
|
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/*` → `/
|
|
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
|