@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.
- package/package.json +1 -2
- package/src/__tests__/account-home-ui.test.ts +344 -110
- package/src/__tests__/account-mirror.test.ts +156 -0
- package/src/__tests__/account-setup.test.ts +880 -0
- package/src/__tests__/account-usage.test.ts +137 -0
- package/src/__tests__/account-vault-admin-token.test.ts +301 -0
- package/src/__tests__/account-vault-token.test.ts +53 -1
- package/src/__tests__/admin-vault-admin-token.test.ts +17 -0
- package/src/__tests__/admin-vaults.test.ts +20 -0
- package/src/__tests__/api-account.test.ts +236 -4
- package/src/__tests__/api-invites.test.ts +217 -0
- package/src/__tests__/api-mint-token.test.ts +259 -10
- package/src/__tests__/api-modules-ops.test.ts +195 -3
- package/src/__tests__/api-modules.test.ts +40 -4
- package/src/__tests__/api-settings-hub-origin.test.ts +13 -8
- package/src/__tests__/auto-wire.test.ts +101 -1
- package/src/__tests__/cli.test.ts +188 -2
- package/src/__tests__/cloudflare-state.test.ts +104 -0
- package/src/__tests__/expose-2fa-warning.test.ts +11 -8
- package/src/__tests__/expose-cloudflare.test.ts +135 -9
- package/src/__tests__/expose-interactive.test.ts +234 -7
- package/src/__tests__/expose-supervisor-version.test.ts +104 -0
- package/src/__tests__/expose.test.ts +10 -5
- package/src/__tests__/grants.test.ts +197 -8
- package/src/__tests__/hub-origin-resolution.test.ts +179 -25
- package/src/__tests__/hub-server.test.ts +761 -13
- package/src/__tests__/hub-unit.test.ts +185 -0
- package/src/__tests__/init.test.ts +579 -3
- package/src/__tests__/install.test.ts +448 -2
- package/src/__tests__/invites.test.ts +220 -0
- package/src/__tests__/launchctl-guard.test.ts +185 -0
- package/src/__tests__/migrate-cutover.test.ts +33 -0
- package/src/__tests__/module-ops-client.test.ts +68 -0
- package/src/__tests__/scope-explanations.test.ts +16 -0
- package/src/__tests__/serve-boot.test.ts +74 -1
- package/src/__tests__/serve.test.ts +121 -7
- package/src/__tests__/setup-wizard.test.ts +110 -0
- package/src/__tests__/spawn-path.test.ts +191 -0
- package/src/__tests__/status.test.ts +64 -0
- package/src/__tests__/supervisor.test.ts +374 -0
- package/src/__tests__/users.test.ts +66 -0
- package/src/__tests__/well-known.test.ts +25 -0
- package/src/__tests__/wizard.test.ts +72 -1
- package/src/account-home-ui.ts +481 -235
- package/src/account-mirror.ts +126 -0
- package/src/account-setup.ts +381 -0
- package/src/account-usage.ts +118 -0
- package/src/account-vault-admin-token.ts +242 -0
- package/src/account-vault-token.ts +36 -2
- package/src/admin-login-ui.ts +121 -0
- package/src/admin-vault-admin-token.ts +8 -2
- package/src/admin-vaults.ts +137 -29
- package/src/api-account.ts +118 -1
- package/src/api-invites.ts +345 -0
- package/src/api-mint-token.ts +81 -0
- package/src/api-modules-ops.ts +168 -53
- package/src/api-modules.ts +36 -0
- package/src/auto-wire.ts +87 -0
- package/src/cli.ts +128 -34
- package/src/cloudflare/detect.ts +1 -1
- package/src/cloudflare/state.ts +104 -8
- package/src/commands/expose-2fa-warning.ts +17 -13
- package/src/commands/expose-cloudflare.ts +103 -36
- package/src/commands/expose-interactive.ts +163 -17
- package/src/commands/expose-supervisor.ts +45 -0
- package/src/commands/init.ts +183 -4
- package/src/commands/install.ts +321 -3
- package/src/commands/migrate-cutover.ts +12 -5
- package/src/commands/serve-boot.ts +33 -3
- package/src/commands/serve.ts +158 -37
- package/src/commands/status.ts +9 -1
- package/src/commands/wizard.ts +36 -2
- package/src/grants.ts +113 -0
- package/src/help.ts +18 -5
- package/src/hub-db.ts +70 -2
- package/src/hub-server.ts +438 -41
- package/src/hub-settings.ts +3 -3
- package/src/hub-unit.ts +259 -9
- package/src/invites.ts +291 -0
- package/src/launchctl-guard.ts +131 -0
- package/src/managed-unit.ts +13 -3
- package/src/migrate-offer.ts +15 -6
- package/src/module-ops-client.ts +47 -22
- package/src/scope-attenuation.ts +19 -0
- package/src/scope-explanations.ts +9 -1
- package/src/service-spec.ts +17 -4
- package/src/setup-wizard.ts +34 -2
- package/src/spawn-path.ts +148 -0
- package/src/supervisor.ts +232 -7
- package/src/users.ts +54 -8
- package/src/vault-hub-origin-env.ts +28 -0
- package/src/vault-name.ts +13 -1
- package/src/well-known.ts +13 -0
- package/web/ui/dist/assets/{index-mz8XcVPP.css → index-BYYUeLGA.css} +1 -1
- package/web/ui/dist/assets/index-D3cDUOOj.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-D_0TRjeo.js +0 -61
package/src/commands/serve.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
280
|
-
//
|
|
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,
|
package/src/commands/status.ts
CHANGED
|
@@ -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,
|
|
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/commands/wizard.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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 #
|
|
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
|
|
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),
|
|
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 {
|