@openparachute/hub 0.6.3 → 0.6.4-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +1 -2
  2. package/src/__tests__/account-home-ui.test.ts +344 -110
  3. package/src/__tests__/account-mirror.test.ts +156 -0
  4. package/src/__tests__/account-setup.test.ts +880 -0
  5. package/src/__tests__/account-usage.test.ts +137 -0
  6. package/src/__tests__/account-vault-admin-token.test.ts +301 -0
  7. package/src/__tests__/account-vault-token.test.ts +53 -1
  8. package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
  9. package/src/__tests__/admin-vaults.test.ts +20 -0
  10. package/src/__tests__/api-account.test.ts +236 -4
  11. package/src/__tests__/api-invites.test.ts +217 -0
  12. package/src/__tests__/api-mint-token.test.ts +259 -10
  13. package/src/__tests__/api-modules-ops.test.ts +195 -3
  14. package/src/__tests__/api-modules.test.ts +40 -4
  15. package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
  16. package/src/__tests__/auto-wire.test.ts +101 -1
  17. package/src/__tests__/cli.test.ts +188 -2
  18. package/src/__tests__/cloudflare-state.test.ts +104 -0
  19. package/src/__tests__/expose-2fa-warning.test.ts +11 -8
  20. package/src/__tests__/expose-cloudflare.test.ts +135 -9
  21. package/src/__tests__/expose-interactive.test.ts +234 -7
  22. package/src/__tests__/expose-supervisor-version.test.ts +104 -0
  23. package/src/__tests__/expose.test.ts +10 -5
  24. package/src/__tests__/grants.test.ts +197 -8
  25. package/src/__tests__/hub-origin-resolution.test.ts +179 -25
  26. package/src/__tests__/hub-server.test.ts +761 -13
  27. package/src/__tests__/hub-unit.test.ts +185 -0
  28. package/src/__tests__/init.test.ts +579 -3
  29. package/src/__tests__/install.test.ts +448 -2
  30. package/src/__tests__/invites.test.ts +220 -0
  31. package/src/__tests__/launchctl-guard.test.ts +185 -0
  32. package/src/__tests__/migrate-cutover.test.ts +33 -0
  33. package/src/__tests__/module-ops-client.test.ts +68 -0
  34. package/src/__tests__/scope-explanations.test.ts +16 -0
  35. package/src/__tests__/serve-boot.test.ts +74 -1
  36. package/src/__tests__/serve.test.ts +121 -7
  37. package/src/__tests__/setup-wizard.test.ts +110 -0
  38. package/src/__tests__/spawn-path.test.ts +191 -0
  39. package/src/__tests__/status.test.ts +64 -0
  40. package/src/__tests__/supervisor.test.ts +374 -0
  41. package/src/__tests__/users.test.ts +66 -0
  42. package/src/__tests__/well-known.test.ts +25 -0
  43. package/src/__tests__/wizard.test.ts +72 -1
  44. package/src/account-home-ui.ts +481 -235
  45. package/src/account-mirror.ts +126 -0
  46. package/src/account-setup.ts +381 -0
  47. package/src/account-usage.ts +118 -0
  48. package/src/account-vault-admin-token.ts +242 -0
  49. package/src/account-vault-token.ts +36 -2
  50. package/src/admin-login-ui.ts +121 -0
  51. package/src/admin-vault-admin-token.ts +8 -2
  52. package/src/admin-vaults.ts +137 -29
  53. package/src/api-account.ts +118 -1
  54. package/src/api-invites.ts +345 -0
  55. package/src/api-mint-token.ts +81 -0
  56. package/src/api-modules-ops.ts +168 -53
  57. package/src/api-modules.ts +36 -0
  58. package/src/auto-wire.ts +87 -0
  59. package/src/cli.ts +128 -34
  60. package/src/cloudflare/detect.ts +1 -1
  61. package/src/cloudflare/state.ts +104 -8
  62. package/src/commands/expose-2fa-warning.ts +17 -13
  63. package/src/commands/expose-cloudflare.ts +103 -36
  64. package/src/commands/expose-interactive.ts +163 -17
  65. package/src/commands/expose-supervisor.ts +45 -0
  66. package/src/commands/init.ts +183 -4
  67. package/src/commands/install.ts +321 -3
  68. package/src/commands/migrate-cutover.ts +12 -5
  69. package/src/commands/serve-boot.ts +33 -3
  70. package/src/commands/serve.ts +158 -37
  71. package/src/commands/status.ts +9 -1
  72. package/src/commands/wizard.ts +36 -2
  73. package/src/grants.ts +113 -0
  74. package/src/help.ts +18 -5
  75. package/src/hub-db.ts +70 -2
  76. package/src/hub-server.ts +438 -41
  77. package/src/hub-settings.ts +3 -3
  78. package/src/hub-unit.ts +259 -9
  79. package/src/invites.ts +291 -0
  80. package/src/launchctl-guard.ts +131 -0
  81. package/src/managed-unit.ts +13 -3
  82. package/src/migrate-offer.ts +15 -6
  83. package/src/module-ops-client.ts +47 -22
  84. package/src/scope-attenuation.ts +19 -0
  85. package/src/scope-explanations.ts +9 -1
  86. package/src/service-spec.ts +17 -4
  87. package/src/setup-wizard.ts +34 -2
  88. package/src/spawn-path.ts +148 -0
  89. package/src/supervisor.ts +232 -7
  90. package/src/users.ts +54 -8
  91. package/src/vault-hub-origin-env.ts +28 -0
  92. package/src/vault-name.ts +13 -1
  93. package/src/well-known.ts +13 -0
  94. package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
  95. package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
  96. package/web/ui/dist/index.html +2 -2
  97. 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);
@@ -255,6 +255,14 @@ interface WizardStateSnapshot {
255
255
  hasVault: boolean;
256
256
  hasExposeMode: boolean;
257
257
  requireBootstrapToken: boolean;
258
+ /**
259
+ * The actual bootstrap token, present ONLY when the wizard-state probe ran
260
+ * over loopback (the on-box operator's own shell — hub#576). The hub returns
261
+ * it so the CLI wizard can satisfy the first-claim gate transparently without
262
+ * the operator copy-pasting it from the startup logs. Absent on any
263
+ * public/tailnet probe.
264
+ */
265
+ bootstrapToken?: string;
258
266
  csrfToken: string;
259
267
  /** Optional URL to redirect to (when state is fully done — 301 to /login). */
260
268
  redirectTo?: string;
@@ -294,7 +302,7 @@ async function fetchWizardState(
294
302
  );
295
303
  }
296
304
  const body = res.json as Partial<WizardStateSnapshot> & { csrfToken?: string };
297
- return {
305
+ const snapshot: WizardStateSnapshot = {
298
306
  step: body.step ?? "welcome",
299
307
  hasAdmin: Boolean(body.hasAdmin),
300
308
  hasVault: Boolean(body.hasVault),
@@ -302,6 +310,12 @@ async function fetchWizardState(
302
310
  requireBootstrapToken: Boolean(body.requireBootstrapToken),
303
311
  csrfToken: typeof body.csrfToken === "string" ? body.csrfToken : (jar.csrf ?? ""),
304
312
  };
313
+ // hub#576: the loopback probe carries the actual token. Thread it through so
314
+ // the account step can submit it without prompting the operator.
315
+ if (typeof body.bootstrapToken === "string" && body.bootstrapToken.length > 0) {
316
+ snapshot.bootstrapToken = body.bootstrapToken;
317
+ }
318
+ return snapshot;
305
319
  }
306
320
 
307
321
  /**
@@ -422,7 +436,27 @@ async function walkAccountStep(
422
436
  log(` ✗ ${pwErr}`);
423
437
  return 1;
424
438
  }
425
- let bootstrap = opts.bootstrapToken ?? process.env.PARACHUTE_BOOTSTRAP_TOKEN;
439
+ // Token resolution order (hub#576):
440
+ // 1. Explicit `--bootstrap-token` flag / `opts.bootstrapToken` (init passes
441
+ // this when it fetched the token from the loopback probe).
442
+ // 2. `PARACHUTE_BOOTSTRAP_TOKEN` env.
443
+ // 3. The token carried on the loopback wizard-state probe itself — the
444
+ // common on-box `parachute init` path: the hub handed us the value
445
+ // because we reached it over loopback, so we satisfy the gate
446
+ // transparently with no operator action.
447
+ // 4. Prompt — the fallback when none of the above apply (e.g. a remote
448
+ // `parachute init --cli-wizard` against a public hub, where the probe
449
+ // didn't carry the token). The operator reads it from the startup logs.
450
+ // Treat an empty / whitespace value at any level as "absent" so a falsy
451
+ // `PARACHUTE_BOOTSTRAP_TOKEN=` (exported-but-empty) doesn't suppress the
452
+ // loopback-probe token and silently submit a blank token.
453
+ const firstNonEmpty = (...vals: Array<string | undefined>): string | undefined =>
454
+ vals.find((v) => typeof v === "string" && v.trim().length > 0);
455
+ let bootstrap = firstNonEmpty(
456
+ opts.bootstrapToken,
457
+ process.env.PARACHUTE_BOOTSTRAP_TOKEN,
458
+ state.bootstrapToken,
459
+ );
426
460
  if (state.requireBootstrapToken && !bootstrap) {
427
461
  log("");
428
462
  log(" This hub is in container/serve mode and minted a one-time");
package/src/grants.ts CHANGED
@@ -188,6 +188,119 @@ export function isCoveredByGrantForClientName(
188
188
  return true;
189
189
  }
190
190
 
191
+ const VAULT_SCOPE_PREFIX_RE = /^vault:([^:]+):/;
192
+
193
+ /**
194
+ * Fixed `client_id`s the hub mints for its OWN first-party browser surfaces.
195
+ * A grant carrying one of these is a browser sign-in (the operator opened the
196
+ * admin SPA, the account-home friend surface, etc.) — NOT "the operator
197
+ * connected an AI to their vault." See `userHasExternalAiGrant` (hub#583).
198
+ *
199
+ * - `parachute-hub-spa` — hub admin SPA + vault admin SPA mints
200
+ * (`admin-host-admin-token.ts`, `admin-vault-admin-token.ts`,
201
+ * `account-vault-admin-token.ts`).
202
+ * - `parachute-account` — account-home friend-vault token mints
203
+ * (`account-vault-token.ts`).
204
+ */
205
+ const FIRST_PARTY_BROWSER_CLIENT_IDS = new Set(["parachute-hub-spa", "parachute-account"]);
206
+
207
+ /**
208
+ * `client_name`s of first-party browser SPAs that register dynamically via DCR
209
+ * (so their `client_id` is generated per-registration and can't be enumerated).
210
+ * Notes registers with `client_name: "Notes"` (the @openparachute/notes-ui PWA
211
+ * signing into a vault). Matched case-insensitively. See hub#583.
212
+ */
213
+ const FIRST_PARTY_BROWSER_CLIENT_NAMES = new Set(["notes"]);
214
+
215
+ /**
216
+ * True when a grant belongs to one of the hub's own first-party browser
217
+ * surfaces (admin SPA, account home, Notes PWA) rather than an external AI/MCP
218
+ * client (Claude, Cursor, …). Used to keep a browser sign-in from
219
+ * false-positiving the "you've connected your AI" onboarding signal (hub#583).
220
+ *
221
+ * Discriminates two ways because first-party surfaces register two ways: the
222
+ * hub-minted SPAs use fixed `client_id`s; DCR-registered SPAs (Notes) get a
223
+ * generated id but a stable `client_name`.
224
+ */
225
+ export function isFirstPartyBrowserClient(
226
+ clientId: string,
227
+ clientName: string | null | undefined,
228
+ ): boolean {
229
+ if (FIRST_PARTY_BROWSER_CLIENT_IDS.has(clientId)) return true;
230
+ if (clientName && FIRST_PARTY_BROWSER_CLIENT_NAMES.has(clientName.trim().toLowerCase())) {
231
+ return true;
232
+ }
233
+ return false;
234
+ }
235
+
236
+ interface GrantWithClientNameRow extends GrantRow {
237
+ client_name: string | null;
238
+ }
239
+
240
+ /**
241
+ * True when the user has approved at least one EXTERNAL AI/MCP client (Claude,
242
+ * Cursor, etc.) whose granted scopes touch `vaultName` — i.e. "has this person
243
+ * actually wired up an AI to this vault yet?" (hub#583).
244
+ *
245
+ * Stricter than {@link userHasVaultGrant}: it excludes grants from the hub's
246
+ * own first-party browser surfaces (admin SPA, account home, Notes PWA). Those
247
+ * are OAuth clients too — signing into Notes writes a vault-scoped grant — so
248
+ * the coarse "any vault grant" signal lit up the `/account/` onboarding
249
+ * checklist's "✓ You're connected" line even when no AI was ever connected.
250
+ * This is the detection the checklist should use.
251
+ *
252
+ * Trade-off: this fetches ALL of the user's grants (joined to `clients` for
253
+ * `client_name`) and filters in JS, rather than pushing the first-party
254
+ * exclusion into a WHERE clause. Fine at current scale — a user has a handful
255
+ * of grants, and the scope/client-name discrimination is awkward to express in
256
+ * SQL (scopes are a space-joined column, first-party names are an in-process
257
+ * set). If the grants table grows large per-user, add an index on
258
+ * `grants(user_id)` (already the PK prefix) and consider a `client_name`-aware
259
+ * WHERE filter.
260
+ */
261
+ export function userHasExternalAiGrant(db: Database, userId: string, vaultName: string): boolean {
262
+ const rows = db
263
+ .prepare(
264
+ `SELECT g.user_id, g.client_id, g.scopes, g.granted_at, c.client_name
265
+ FROM grants g
266
+ LEFT JOIN clients c ON g.client_id = c.client_id
267
+ WHERE g.user_id = ?`,
268
+ )
269
+ .all(userId) as GrantWithClientNameRow[];
270
+ for (const row of rows) {
271
+ if (isFirstPartyBrowserClient(row.client_id, row.client_name)) continue;
272
+ const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
273
+ for (const s of scopes) {
274
+ const m = s.match(VAULT_SCOPE_PREFIX_RE);
275
+ if (m && m[1] === vaultName) return true;
276
+ }
277
+ }
278
+ return false;
279
+ }
280
+
281
+ /**
282
+ * True when the user has approved at least one OAuth client whose granted
283
+ * scopes touch `vaultName` (any `vault:<name>:<verb>` scope). This is the
284
+ * "has this person actually connected an AI to this vault yet?" signal — the
285
+ * `/account/` onboarding checklist uses it to mark the "Connect your AI" step
286
+ * done (a grant row only lands once the user has clicked through the consent
287
+ * screen for a client wired to this vault).
288
+ *
289
+ * Mirrors the per-grant vault filter in `admin-grants.ts`; kept here so the
290
+ * detection lives next to the rest of the grants helpers and can be unit-tested
291
+ * without the admin route harness.
292
+ */
293
+ export function userHasVaultGrant(db: Database, userId: string, vaultName: string): boolean {
294
+ const grants = listGrantsForUser(db, userId);
295
+ for (const g of grants) {
296
+ for (const s of g.scopes) {
297
+ const m = s.match(VAULT_SCOPE_PREFIX_RE);
298
+ if (m && m[1] === vaultName) return true;
299
+ }
300
+ }
301
+ return false;
302
+ }
303
+
191
304
  /** All grants for a user, ordered most-recent first. Used by `parachute auth list-grants`. */
192
305
  export function listGrantsForUser(db: Database, userId: string): Grant[] {
193
306
  const rows = db
package/src/help.ts CHANGED
@@ -42,7 +42,7 @@ export function installHelp(): string {
42
42
  return `parachute install — install and register a Parachute service
43
43
 
44
44
  Usage:
45
- parachute install <service> [--channel rc|latest] [--tag <name>] [--no-start]
45
+ parachute install <service> [--channel rc|latest] [--tag <name>] [--no-start] [--interactive]
46
46
  parachute install all [--channel rc|latest] [--tag <name>] [--no-start]
47
47
  parachute install scribe [--scribe-provider <name>] [--scribe-key <key>]
48
48
 
@@ -52,7 +52,10 @@ Services:
52
52
 
53
53
  What it does:
54
54
  1. bun add -g @openparachute/<service>[@<tag>]
55
- 2. run any service-specific init (e.g. \`parachute-vault init\`)
55
+ 2. register + start the module under the hub supervisor (LIGHT by default —
56
+ no interactive interview; for vault: no vault-name / MCP / token prompts
57
+ and no competing standalone daemon). Pass \`--interactive\` to run the
58
+ service's own full setup (e.g. \`parachute-vault init\`) instead.
56
59
  3. assign a canonical port (1939–1949) and reflect it in
57
60
  \`~/.parachute/services.json\` — the single source of truth at boot
58
61
  (services follow a 4-tier resolvePort ladder; services.json wins).
@@ -73,6 +76,15 @@ Flags:
73
76
  Skipped if the package is already \`bun link\`-ed locally.
74
77
  --no-start skip the post-install daemon start. For piped / CI
75
78
  installs that own their own process model.
79
+ --interactive run the module's full interactive setup instead of
80
+ the light default. For vault: the vault-name /
81
+ "install MCP in Claude Code?" / "mint a token?"
82
+ interview + its own standalone daemon registration.
83
+ On a supervised hub that standalone daemon can RACE
84
+ the supervisor for the module's port (EADDRINUSE
85
+ crash-loop, #580) — prefer the light default + manage
86
+ from the admin UI unless you specifically want the
87
+ old interview.
76
88
  --scribe-provider <name> set scribe's transcription provider non-interactively.
77
89
  Known: parakeet-mlx (default), onnx-asr, whisper, groq, openai.
78
90
  Skips the interactive picker.
@@ -89,9 +101,10 @@ Environment:
89
101
  and \`--tag\`. Defaults to \`latest\` when unset.
90
102
 
91
103
  Examples:
92
- parachute install vault # installs, runs init, starts vault
104
+ parachute install vault # light: installs + starts vault, points you at the admin UI
105
+ parachute install vault --interactive # full interactive vault init (name / MCP / token prompts)
93
106
  parachute install surface # installs surface (auto-bootstraps Notes)
94
- parachute install notes # back-compat: legacy notes-daemon (Phase 2 deprecating)
107
+ parachute install notes # legacy notes-daemon deprecated; use \`parachute install surface\` instead
95
108
  parachute install scribe # installs, prompts for provider, starts scribe
96
109
  parachute install scribe --scribe-provider groq --scribe-key gsk_…
97
110
  # non-interactive scribe setup
@@ -147,7 +160,7 @@ Flags:
147
160
  --no-expose-prompt skip the exposure question; fall through to localhost URL
148
161
  --expose <choice> non-interactive exposure override:
149
162
  none — stay loopback-only
150
- tailnet — set up Tailscale Funnel (private to your tailnet)
163
+ tailnet — set up Tailscale serve (private to your tailnet)
151
164
  cloudflare — set up Cloudflare Tunnel (your own domain)
152
165
  --cli-wizard skip the "browser or CLI?" prompt and walk the wizard
153
166
  in this terminal (hub#168 Cut 4)
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 {