@openparachute/hub 0.6.3 → 0.6.4-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +609 -0
  3. package/src/__tests__/account-usage.test.ts +137 -0
  4. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  5. package/src/__tests__/account-vault-token.test.ts +53 -1
  6. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  7. package/src/__tests__/admin-vaults.test.ts +20 -0
  8. package/src/__tests__/api-account.test.ts +125 -4
  9. package/src/__tests__/api-invites.test.ts +180 -0
  10. package/src/__tests__/api-mint-token.test.ts +259 -10
  11. package/src/__tests__/api-modules-ops.test.ts +187 -1
  12. package/src/__tests__/api-modules.test.ts +40 -4
  13. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  14. package/src/__tests__/auto-wire.test.ts +101 -1
  15. package/src/__tests__/cli.test.ts +188 -2
  16. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  17. package/src/__tests__/expose-cloudflare.test.ts +5 -4
  18. package/src/__tests__/expose.test.ts +10 -5
  19. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  20. package/src/__tests__/hub-server.test.ts +628 -13
  21. package/src/__tests__/hub-unit.test.ts +4 -0
  22. package/src/__tests__/invites.test.ts +220 -0
  23. package/src/__tests__/launchctl-guard.test.ts +185 -0
  24. package/src/__tests__/migrate-cutover.test.ts +32 -0
  25. package/src/__tests__/module-ops-client.test.ts +68 -0
  26. package/src/__tests__/scope-explanations.test.ts +16 -0
  27. package/src/__tests__/serve-boot.test.ts +74 -1
  28. package/src/__tests__/serve.test.ts +121 -7
  29. package/src/__tests__/spawn-path.test.ts +191 -0
  30. package/src/__tests__/status.test.ts +64 -0
  31. package/src/__tests__/supervisor.test.ts +177 -0
  32. package/src/__tests__/users.test.ts +27 -0
  33. package/src/account-home-ui.ts +82 -9
  34. package/src/account-setup.ts +342 -0
  35. package/src/account-usage.ts +118 -0
  36. package/src/account-vault-admin-token.ts +242 -0
  37. package/src/account-vault-token.ts +27 -2
  38. package/src/admin-login-ui.ts +94 -0
  39. package/src/admin-vault-admin-token.ts +8 -2
  40. package/src/admin-vaults.ts +137 -29
  41. package/src/api-account.ts +54 -1
  42. package/src/api-invites.ts +347 -0
  43. package/src/api-mint-token.ts +81 -0
  44. package/src/api-modules-ops.ts +168 -53
  45. package/src/api-modules.ts +36 -0
  46. package/src/auto-wire.ts +87 -0
  47. package/src/cli.ts +122 -32
  48. package/src/commands/expose-2fa-warning.ts +17 -13
  49. package/src/commands/migrate-cutover.ts +12 -5
  50. package/src/commands/serve-boot.ts +33 -3
  51. package/src/commands/serve.ts +158 -37
  52. package/src/commands/status.ts +9 -1
  53. package/src/hub-db.ts +70 -2
  54. package/src/hub-server.ts +399 -41
  55. package/src/hub-unit.ts +4 -9
  56. package/src/invites.ts +291 -0
  57. package/src/launchctl-guard.ts +131 -0
  58. package/src/managed-unit.ts +13 -3
  59. package/src/migrate-offer.ts +15 -6
  60. package/src/module-ops-client.ts +47 -22
  61. package/src/scope-attenuation.ts +19 -0
  62. package/src/scope-explanations.ts +9 -1
  63. package/src/service-spec.ts +8 -3
  64. package/src/spawn-path.ts +148 -0
  65. package/src/supervisor.ts +84 -7
  66. package/src/users.ts +42 -4
  67. package/src/vault-hub-origin-env.ts +28 -0
  68. package/src/vault-name.ts +13 -1
  69. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  70. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
@@ -26,17 +26,21 @@
26
26
 
27
27
  import { existsSync, mkdirSync } from "node:fs";
28
28
  import { join } from "node:path";
29
+ import { selfHealScribeAuth } from "../auto-wire.ts";
29
30
  import { generateBootstrapToken } from "../bootstrap-token.ts";
30
31
  // NOTE: CONFIG_DIR/WELL_KNOWN_DIR/SERVICES_MANIFEST_PATH are evaluated at
31
32
  // import time from process.env.PARACHUTE_HOME. The `env` parameter on
32
33
  // `serve()` cannot reroute them — set PARACHUTE_HOME before importing for
33
34
  // path isolation.
34
35
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
36
+ import { readExposeState } from "../expose-state.ts";
35
37
  import { hubDbPath, openHubDb } from "../hub-db.ts";
36
38
  import { hubFetch } from "../hub-server.ts";
37
39
  import { writeHubFile } from "../hub.ts";
40
+ import { enrichedPath } from "../spawn-path.ts";
38
41
  import { Supervisor } from "../supervisor.ts";
39
42
  import { createUser, userCount } from "../users.ts";
43
+ import { sanitizePublicOrigin } from "../vault-hub-origin-env.ts";
40
44
  import { WELL_KNOWN_DIR } from "../well-known.ts";
41
45
  import { bootSupervisedModules } from "./serve-boot.ts";
42
46
 
@@ -101,6 +105,22 @@ export function formatListeningBanner(args: {
101
105
  return `parachute serve: listening on http://${displayHost}:${port}${boundNote} (PARACHUTE_HOME=${configDir}, db=${dbPath}, issuer=${issuer ?? "<request-origin>"}, admin=${adminBootstrap})`;
102
106
  }
103
107
 
108
+ /**
109
+ * Map a `Bun.serve` bind failure to a clear "another supervisor is running"
110
+ * message when it's a port-in-use error, or `null` for any other error (so the
111
+ * caller re-throws the original). Keeps a duplicate-supervisor start from
112
+ * surfacing as a raw `EADDRINUSE` stack — the operator's actionable next step
113
+ * is "stop the other instance," not a backtrace. See hub#536. Exported for
114
+ * testing (the bind itself isn't seam-injectable).
115
+ */
116
+ export function hubPortConflictMessage(err: unknown, port: number): string | null {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ if (/EADDRINUSE|address already in use|in use/i.test(msg)) {
119
+ return `parachute serve: hub port ${port} is already in use — another hub/supervisor is running. Refusing to start a duplicate supervisor (it would fight the live one over module ports). Stop the other instance first, then retry.`;
120
+ }
121
+ return null;
122
+ }
123
+
104
124
  function parsePort(raw: string | undefined): number | undefined {
105
125
  if (raw === undefined || raw === "") return undefined;
106
126
  const n = Number.parseInt(raw, 10);
@@ -124,7 +144,21 @@ function parsePort(raw: string | undefined): number | undefined {
124
144
  * operator can't know the URL at deploy time. Without this, supervised
125
145
  * modules' iss-validation breaks on hub-minted tokens (iss-mismatch
126
146
  * every time).
127
- * 4. None (returns undefined). Hub falls back to per-request derivation
147
+ * 4. `expose-state.json`'s `hubOrigin` the canonical public origin a
148
+ * live tailscale/cloudflare exposure recorded (e.g.
149
+ * `https://parachute.taildf9ce2.ts.net`). This is the load-bearing
150
+ * tier for the **owner-operated reboot-persistent path**: the launchd
151
+ * plist / systemd unit that keeps `parachute serve` alive carries no
152
+ * `PARACHUTE_HUB_ORIGIN` env, so on every reboot the hub would
153
+ * otherwise boot issuer-less (tier 5), stamp `iss` from the per-request
154
+ * origin, and inject nothing into children — vault then defaults to
155
+ * loopback and rejects hub-minted tokens with `unexpected "iss" claim
156
+ * value` until it restarts. Reading the exposed origin off disk makes
157
+ * `iss` deterministic across reboots with zero operator action. Guarded
158
+ * to a non-loopback `http(s)` origin (a loopback value here would
159
+ * re-pin the degraded mode; expose-state should never carry one, but we
160
+ * defend anyway).
161
+ * 5. None (returns undefined). Hub falls back to per-request derivation
128
162
  * via `resolveIssuer` in hub-server.ts — works for `/.well-known`
129
163
  * discovery but supervised modules with cached iss expectations
130
164
  * won't have a static value to validate against, so OAuth flows
@@ -136,18 +170,60 @@ function parsePort(raw: string | undefined): number | undefined {
136
170
  *
137
171
  * Trailing slashes are stripped for canonical-form comparison; empty
138
172
  * strings collapse to undefined.
173
+ *
174
+ * `readExpose` is injectable so tests exercise the expose-state tier
175
+ * without touching the real `~/.parachute`. The default reads
176
+ * `expose-state.json` and swallows a malformed-file throw (a corrupt state
177
+ * file must never crash startup — fall through to the request-origin mode);
178
+ * the `readExpose()` call is additionally try/catch-wrapped here so even an
179
+ * injected non-swallowing reader can't crash startup.
180
+ *
181
+ * KNOWN ASTERISK (tracked in #532): this resolves the issuer at boot, so a
182
+ * child module spawned during a *pre-expose* boot — hub started before the
183
+ * first-ever `parachute expose` — gets no `PARACHUTE_HUB_ORIGIN` injected
184
+ * until it's restarted after the exposure exists. Once an exposure is
185
+ * recorded, every subsequent reboot picks it up here automatically. The
186
+ * remaining gap (rebuild the live spawn-env on `supervisor.restart` so the
187
+ * first exposure propagates to already-running children without a manual
188
+ * restart) is the deferred #532 follow-up; not implemented in this PR.
139
189
  */
140
190
  export function resolveStartupIssuer(
141
191
  opts: { issuer?: string },
142
192
  env: NodeJS.ProcessEnv,
193
+ readExpose: () => string | undefined = defaultReadExposeHubOrigin,
143
194
  ): string | undefined {
144
195
  const flyOrigin = flyDefaultOriginFromEnv(env);
145
- return (
146
- (opts.issuer ?? env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL ?? flyOrigin)?.replace(
147
- /\/+$/,
148
- "",
149
- ) || undefined
150
- );
196
+ const explicit = (
197
+ opts.issuer ??
198
+ env.PARACHUTE_HUB_ORIGIN ??
199
+ env.RENDER_EXTERNAL_URL ??
200
+ flyOrigin
201
+ )?.replace(/\/+$/, "");
202
+ if (explicit) return explicit;
203
+ // No flag / env / platform origin set — fall back to the exposed origin
204
+ // recorded on disk. `sanitizePublicOrigin` applies the same non-loopback
205
+ // http(s) guard as the hub-server chokepoint (#531) so a stray loopback
206
+ // value never pins the degraded request-origin mode.
207
+ let raw: string | undefined;
208
+ try {
209
+ raw = readExpose();
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ return sanitizePublicOrigin(raw);
214
+ }
215
+
216
+ /**
217
+ * Read `expose-state.json`'s `hubOrigin` for the startup-issuer fallback,
218
+ * swallowing a malformed-file throw. Kept separate so it can be passed as
219
+ * the default `readExpose` arg and stubbed in tests.
220
+ */
221
+ function defaultReadExposeHubOrigin(): string | undefined {
222
+ try {
223
+ return readExposeState()?.hubOrigin;
224
+ } catch {
225
+ return undefined;
226
+ }
151
227
  }
152
228
 
153
229
  /**
@@ -242,6 +318,16 @@ export async function serve(opts: ServeOpts = {}): Promise<{
242
318
  const env = opts.env ?? process.env;
243
319
  const log = opts.log ?? ((line) => console.log(line));
244
320
 
321
+ // PATH enrichment (hub launchd-PATH regression): the launchd/systemd hub unit
322
+ // bakes a minimal PATH. Enrich the hub's OWN process PATH so its `Bun.which`
323
+ // probes (cloudflared / tailscale detection, etc.) see operator-tool dirs
324
+ // (`$HOME/.local/bin`, brew bin) too — and so any child that inherits raw
325
+ // `process.env` (not the explicit per-child env) starts from the enriched
326
+ // PATH. The per-child spawn env is enriched independently in
327
+ // `buildModuleSpawnRequest` / `spawnSupervised`. See `spawn-path.ts`. Only
328
+ // mutate the live process env, never a test-injected `opts.env`.
329
+ if (!opts.env) process.env.PATH = enrichedPath(process.env);
330
+
245
331
  const envPort = parsePort(env.PORT);
246
332
  const port = opts.port ?? envPort ?? DEFAULT_PORT;
247
333
  const issuer = resolveStartupIssuer(opts, env);
@@ -276,8 +362,71 @@ export async function serve(opts: ServeOpts = {}): Promise<{
276
362
 
277
363
  const supervisor = opts.supervisor ?? new Supervisor();
278
364
 
279
- // Boot already-installed modules from services.json. In a container,
280
- // this is the path that re-spawns vault / notes / scribe after a
365
+ // Claim the hub port FIRST before booting a single supervised module. If
366
+ // another hub/supervisor already owns it, `Bun.serve` throws here and we
367
+ // exit immediately. The prior order (boot modules, *then* bind) let a
368
+ // duplicate `serve` spawn + port-race the live hub's children over their
369
+ // module ports before it ever hit the hub-port conflict — the
370
+ // dual-supervisor crash loop in hub#536. Binding first makes a duplicate
371
+ // fail fast and cleanly, leaving the live hub's children untouched.
372
+ let server: ReturnType<typeof Bun.serve>;
373
+ try {
374
+ server = Bun.serve({
375
+ port,
376
+ hostname,
377
+ // Hold idle keep-alive connections for Bun's maximum 255s so reverse-
378
+ // proxy edges (Render, Cloudflare, fly.io) don't race us when reusing
379
+ // pooled connections. See `src/hub-server.ts` for the full rationale —
380
+ // this is the active code path for `bun src/cli.ts serve` (the Docker
381
+ // CMD), so the fix has to land here too. Closes hub#399.
382
+ idleTimeout: 255,
383
+ fetch: hubFetch(WELL_KNOWN_DIR, {
384
+ getDb: () => db,
385
+ issuer,
386
+ loopbackPort: port,
387
+ supervisor,
388
+ }),
389
+ });
390
+ } catch (err) {
391
+ const conflict = hubPortConflictMessage(err, port);
392
+ if (conflict) throw new Error(conflict);
393
+ throw err;
394
+ }
395
+
396
+ log(
397
+ formatListeningBanner({
398
+ hostname,
399
+ port,
400
+ configDir: CONFIG_DIR,
401
+ dbPath,
402
+ issuer,
403
+ adminBootstrap,
404
+ }),
405
+ );
406
+
407
+ // Self-heal scribe's auth token from vault's .env (item H) BEFORE booting
408
+ // modules, so scribe's first boot below reads the synced config. Closes the
409
+ // "scribe installed pre-auto-wire boots auth-OPEN over loopback" gap: every
410
+ // `serve` start re-syncs scribe's `auth.required_token` to vault's
411
+ // SCRIBE_AUTH_TOKEN. Fully idempotent — no-op when there's nothing to sync or
412
+ // the two already match; logs only when it heals. Mirrors the issuer
413
+ // self-heal pattern in vault-hub-origin-env.ts. Skipped in tests via
414
+ // `opts.skipModuleBoot` (which also gates the boot it feeds).
415
+ if (!opts.skipModuleBoot) {
416
+ try {
417
+ selfHealScribeAuth({ configDir: CONFIG_DIR, log });
418
+ } catch (err) {
419
+ // A self-heal failure must never block the hub from starting — scribe
420
+ // just keeps whatever auth state it had. Log and move on.
421
+ log(
422
+ `parachute serve: scribe auth self-heal failed (${err instanceof Error ? err.message : String(err)}); continuing.`,
423
+ );
424
+ }
425
+ }
426
+
427
+ // Boot already-installed modules from services.json — now that we own the
428
+ // hub port (above), we're guaranteed to be the sole supervisor. In a
429
+ // container, this is the path that re-spawns vault / notes / scribe after a
281
430
  // restart — the persistent disk preserved both the install (in
282
431
  // `$BUN_INSTALL/install/global/node_modules`) and the row that says
283
432
  // "this module is registered + active." Idempotent: the supervisor
@@ -304,34 +453,6 @@ export async function serve(opts: ServeOpts = {}): Promise<{
304
453
  }
305
454
  }
306
455
 
307
- const server = Bun.serve({
308
- port,
309
- hostname,
310
- // Hold idle keep-alive connections for Bun's maximum 255s so reverse-
311
- // proxy edges (Render, Cloudflare, fly.io) don't race us when reusing
312
- // pooled connections. See `src/hub-server.ts` for the full rationale —
313
- // this is the active code path for `bun src/cli.ts serve` (the Docker
314
- // CMD), so the fix has to land here too. Closes hub#399.
315
- idleTimeout: 255,
316
- fetch: hubFetch(WELL_KNOWN_DIR, {
317
- getDb: () => db,
318
- issuer,
319
- loopbackPort: port,
320
- supervisor,
321
- }),
322
- });
323
-
324
- log(
325
- formatListeningBanner({
326
- hostname,
327
- port,
328
- configDir: CONFIG_DIR,
329
- dbPath,
330
- issuer,
331
- adminBootstrap,
332
- }),
333
- );
334
-
335
456
  return {
336
457
  result: {
337
458
  port,
@@ -20,6 +20,7 @@ import {
20
20
  import {
21
21
  type DriveModuleOpDeps,
22
22
  type ModuleStatesResult,
23
+ type ModuleStateSnapshot,
23
24
  NoOperatorTokenError,
24
25
  OperatorTokenExpiredError,
25
26
  fetchModuleStates as fetchModuleStatesImpl,
@@ -487,10 +488,17 @@ async function buildSupervisorRows(args: BuildSupervisorRowsArgs): Promise<Statu
487
488
  }
488
489
  }
489
490
 
490
- const stateByShort = new Map<string, ModuleStatesResult["modules"][number]>();
491
+ const stateByShort = new Map<string, ModuleStateSnapshot>();
491
492
  for (const m of states?.modules ?? []) {
492
493
  if (m.short) stateByShort.set(m.short, m);
493
494
  }
495
+ // Fall back to the full `supervised` snapshot for modules the curated
496
+ // `modules` catalog omits — e.g. the `surface` UI host, which the supervisor
497
+ // runs but isn't a curated installable. Without this it'd map to `inactive`
498
+ // despite running (hub#539). Curated entries already in the map win (richer).
499
+ for (const m of states?.supervised ?? []) {
500
+ if (m.short && !stateByShort.has(m.short)) stateByShort.set(m.short, m);
501
+ }
494
502
 
495
503
  const rows: StatusRow[] = manifest.services.map((entry) => {
496
504
  const base = manifestRowBase(entry, installSourceDeps);
package/src/hub-db.ts CHANGED
@@ -2,8 +2,9 @@
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), and TOTP 2FA
6
- * enrollment on the users row (v11, hub#473).
5
+ * clients + auth-codes + grants + browser sessions (v3), TOTP 2FA
6
+ * enrollment on the users row (v11, hub#473), and one-time invite links
7
+ * (v12, the `invites` table).
7
8
  *
8
9
  * Each open() runs `migrate()` to bring the schema up to date. A
9
10
  * `schema_version` table records every applied migration so re-opens are
@@ -358,6 +359,73 @@ const MIGRATIONS: readonly Migration[] = [
358
359
  ALTER TABLE users ADD COLUMN totp_enrolled_at TEXT;
359
360
  `,
360
361
  },
362
+ {
363
+ version: 12,
364
+ sql: `
365
+ -- One-time, expiring invite links (design
366
+ -- 2026-06-04-individual-users-and-vault-operations.md §7). An admin
367
+ -- generates a link; the recipient opens it, picks a username +
368
+ -- password, and gets their OWN freshly-provisioned vault as owner.
369
+ --
370
+ -- The row stores sha256(token), NOT the raw token. Invites are
371
+ -- longer-lived than the 60s OAuth auth-codes (default 7-day expiry),
372
+ -- so a DB read must not be enough to replay the link — the raw token
373
+ -- is emitted exactly once at creation and never persisted, exactly
374
+ -- like the bootstrap token. Lookup hashes the URL token and selects
375
+ -- by the hash; the hash is the primary key.
376
+ --
377
+ -- Columns:
378
+ -- * token (TEXT PK) — sha256(raw token), hex. Never the raw value.
379
+ -- * created_by (TEXT) — admin user id that issued the invite
380
+ -- (FK users.id; ON DELETE SET NULL so
381
+ -- deleting the issuer doesn't orphan-block
382
+ -- the audit row).
383
+ -- * vault_name (TEXT) — nullable. When set, the invite pins the
384
+ -- vault name (redeemer can't squat names).
385
+ -- When NULL + provision_vault=1 the redeemer
386
+ -- names their own vault at redeem time.
387
+ -- * role (TEXT DEFAULT 'write') — the user_vaults role the redeemed
388
+ -- user gets on their vault. 'write' = owner
389
+ -- (full vault admin per vaultVerbsForRole).
390
+ -- Carried so the shared-into-existing-vault
391
+ -- case is a later policy change, not a
392
+ -- migration.
393
+ -- * provision_vault (INTEGER) — 1 = provision a NEW vault for the
394
+ -- redeemer (the primary flow); 0 = account
395
+ -- only / assign an existing vault.
396
+ -- * default_mirror (TEXT) — nullable; wires the §3 default-mirror knob
397
+ -- ('internal' | 'off') through to the
398
+ -- provisioned vault. NULL = vault's own
399
+ -- default.
400
+ -- * expires_at (TEXT) — ISO-8601; redeem rejects past this.
401
+ -- * used_at (TEXT) — ISO-8601 stamp set at redeem. Single-use:
402
+ -- a second redeem sees this set and is
403
+ -- rejected. Stamped only AFTER the user row
404
+ -- commits, so a createUser failure leaves
405
+ -- the invite re-usable.
406
+ -- * redeemed_user_id (TEXT) — the user id the invite created (FK
407
+ -- users.id; ON DELETE SET NULL).
408
+ -- * revoked_at (TEXT) — ISO-8601 stamp when the admin revokes the
409
+ -- invite before redemption.
410
+ -- * created_at (TEXT) — ISO-8601.
411
+ --
412
+ -- No backfill — no invites pre-date this migration.
413
+ CREATE TABLE invites (
414
+ token TEXT PRIMARY KEY,
415
+ created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
416
+ vault_name TEXT,
417
+ role TEXT NOT NULL DEFAULT 'write',
418
+ provision_vault INTEGER NOT NULL DEFAULT 1,
419
+ default_mirror TEXT,
420
+ expires_at TEXT NOT NULL,
421
+ used_at TEXT,
422
+ redeemed_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
423
+ revoked_at TEXT,
424
+ created_at TEXT NOT NULL
425
+ );
426
+ CREATE INDEX invites_created_at ON invites (created_at);
427
+ `,
428
+ },
361
429
  ];
362
430
 
363
431
  export function openHubDb(path: string = hubDbPath()): Database {