@openparachute/hub 0.6.2 → 0.6.3-rc.2

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 (58) hide show
  1. package/README.md +87 -35
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub-upgrade.test.ts +690 -0
  4. package/src/__tests__/api-modules-ops.test.ts +359 -3
  5. package/src/__tests__/api-modules.test.ts +54 -0
  6. package/src/__tests__/expose-cloudflare.test.ts +163 -72
  7. package/src/__tests__/expose-off-auto.test.ts +26 -1
  8. package/src/__tests__/expose.test.ts +260 -240
  9. package/src/__tests__/hub-control.test.ts +1 -242
  10. package/src/__tests__/hub-server.test.ts +64 -0
  11. package/src/__tests__/hub-unit.test.ts +574 -0
  12. package/src/__tests__/init.test.ts +219 -2
  13. package/src/__tests__/lifecycle.test.ts +416 -1448
  14. package/src/__tests__/managed-unit.test.ts +575 -0
  15. package/src/__tests__/migrate-cutover.test.ts +840 -0
  16. package/src/__tests__/migrate-offer.test.ts +240 -0
  17. package/src/__tests__/migrate.test.ts +132 -0
  18. package/src/__tests__/module-ops-client.test.ts +556 -0
  19. package/src/__tests__/port-probe.test.ts +23 -0
  20. package/src/__tests__/setup-wizard.test.ts +130 -0
  21. package/src/__tests__/status-supervisor.test.ts +504 -0
  22. package/src/__tests__/status.test.ts +157 -708
  23. package/src/__tests__/supervisor.test.ts +471 -6
  24. package/src/__tests__/upgrade.test.ts +351 -5
  25. package/src/api-hub-upgrade.ts +384 -0
  26. package/src/api-hub.ts +2 -1
  27. package/src/api-modules-ops.ts +221 -0
  28. package/src/api-modules.ts +18 -2
  29. package/src/cli.ts +97 -12
  30. package/src/cloudflare/connector-service.ts +117 -322
  31. package/src/commands/expose-cloudflare.ts +63 -71
  32. package/src/commands/expose-supervisor.ts +247 -0
  33. package/src/commands/expose.ts +59 -48
  34. package/src/commands/init.ts +225 -12
  35. package/src/commands/lifecycle.ts +455 -816
  36. package/src/commands/migrate-cutover.ts +837 -0
  37. package/src/commands/migrate.ts +71 -2
  38. package/src/commands/serve-boot.ts +71 -25
  39. package/src/commands/status.ts +535 -235
  40. package/src/commands/upgrade.ts +100 -2
  41. package/src/help.ts +128 -68
  42. package/src/hub-control.ts +23 -162
  43. package/src/hub-server.ts +39 -0
  44. package/src/hub-unit.ts +735 -0
  45. package/src/hub-upgrade-helper.ts +306 -0
  46. package/src/hub-upgrade-mode.ts +209 -0
  47. package/src/hub-upgrade-status.ts +150 -0
  48. package/src/managed-unit.ts +692 -0
  49. package/src/migrate-offer.ts +186 -0
  50. package/src/module-ops-client.ts +457 -0
  51. package/src/port-probe.ts +50 -0
  52. package/src/process-state.ts +19 -3
  53. package/src/setup-wizard.ts +80 -1
  54. package/src/supervisor.ts +389 -38
  55. package/web/ui/dist/assets/index-D_6AFvZy.js +61 -0
  56. package/web/ui/dist/assets/{index-BiBlvEaj.css → index-mz8XcVPP.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-CIN3mnmf.js +0 -61
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Loopback TCP-port readiness probe — the tiny "is something listening on
3
+ * 127.0.0.1:<port>?" primitive shared by the detached `commands/lifecycle.ts`
4
+ * start path and the in-process `supervisor.ts` (design 2026-06-01 §6.5).
5
+ *
6
+ * Factored out of `lifecycle.ts` so the supervisor can reach the probe without
7
+ * importing all of lifecycle's heavy graph (hub-db, operator-token,
8
+ * services-manifest, …) into a module that hub-server / proxy-state / the
9
+ * module-ops API all depend on. `lifecycle.ts` re-exports `defaultPortListening`
10
+ * + `PortListeningFn` so its public API is unchanged; both files share THIS
11
+ * one implementation, so they can't drift.
12
+ *
13
+ * `node:net` rather than `Bun.connect` because the latter has no clean
14
+ * "connection refused → false" without a custom socket handler, and the net
15
+ * Socket's `error`/`connect` events map directly onto the boolean we want.
16
+ */
17
+
18
+ import { Socket } from "node:net";
19
+
20
+ /**
21
+ * "Is something listening on this TCP port on loopback?" seam. Pairs with the
22
+ * spawn-then-die settle to catch the alive-but-never-bound failure shape
23
+ * (hub#487): a service that lives long enough to clear a liveness check but
24
+ * never binds its port (port already held by an orphan / a bun-linked
25
+ * resolution failure that lingers). Tests inject a deterministic stub;
26
+ * production uses {@link defaultPortListening}.
27
+ */
28
+ export type PortListeningFn = (port: number) => Promise<boolean>;
29
+
30
+ /**
31
+ * Connect-probe: open a TCP socket to 127.0.0.1:<port> and see if it's
32
+ * accepted. A successful connect means *something* is listening; we close
33
+ * immediately. Connection refused / timeout means nothing is bound yet.
34
+ */
35
+ export const defaultPortListening: PortListeningFn = (port) =>
36
+ new Promise((resolve) => {
37
+ const socket = new Socket();
38
+ let settled = false;
39
+ const done = (listening: boolean) => {
40
+ if (settled) return;
41
+ settled = true;
42
+ socket.destroy();
43
+ resolve(listening);
44
+ };
45
+ socket.setTimeout(1000);
46
+ socket.once("connect", () => done(true));
47
+ socket.once("timeout", () => done(false));
48
+ socket.once("error", () => done(false));
49
+ socket.connect(port, "127.0.0.1");
50
+ });
@@ -11,6 +11,13 @@ import { CONFIG_DIR } from "./config.ts";
11
11
  * `pid file present` + `process.kill(pid, 0)` succeeds. A stale PID file
12
12
  * (process died without cleanup) reads as stopped; writers of the PID
13
13
  * file own removing it on clean shutdown.
14
+ *
15
+ * Phase 5b retired the detached module/hub spawners that *wrote* per-service
16
+ * pidfiles. The pidfile READERS (`readPid` / `processState`) are deliberately
17
+ * kept (design §7.5) so the migrate detector (`hasPriorDetachedInstall`) can
18
+ * still see a prior detached install for one release. `writePid` / `clearPid`
19
+ * remain too — `serve` (hub-server.ts) writes its own `hub` pidfile so
20
+ * `parachute stop hub` / `migrate` can find a serve-mode hub.
14
21
  */
15
22
 
16
23
  export function serviceDir(svc: string, configDir: string = CONFIG_DIR): string {
@@ -70,12 +77,21 @@ export const defaultAlive: AliveFn = (pid: number) => {
70
77
  };
71
78
 
72
79
  /**
73
- * Three-state rather than two so we don't lie about services we can't see:
80
+ * Three-state, kept for legacy detection only.
81
+ *
82
+ * Phase 5b retired the detached spawners that wrote per-service pidfiles, so in
83
+ * the steady (supervised) state no module has a pidfile and module run-state
84
+ * comes from the supervisor (`supervisor.list()`), not from here. This reader
85
+ * survives so the `migrate` detector (`hasPriorDetachedInstall`) can still
86
+ * recognize a pre-cutover box, and so `serve` / `parachute stop hub` can find a
87
+ * serve-mode hub's own `hub` pidfile.
74
88
  *
75
89
  * - `running` — PID file present, `kill(pid, 0)` succeeds.
76
90
  * - `stopped` — PID file present, process gone (stale pidfile, or cleanly shut down).
77
- * - `unknown` — no PID file. Service may be externally managed (user ran
78
- * `parachute-vault serve` directly, or legacy launchd-era). Don't claim stopped.
91
+ * - `unknown` — no PID file. In a supervised install this is the normal case
92
+ * for modules (no pidfile is written); the "externally-managed / legacy
93
+ * launchd-era" reading is now purely about detecting a *prior detached*
94
+ * install, not a live signal. Don't claim stopped.
79
95
  */
80
96
  export interface ProcessState {
81
97
  status: "running" | "stopped" | "unknown";
@@ -67,7 +67,13 @@ import {
67
67
  } from "./hub-settings.ts";
68
68
  import { signAccessToken } from "./jwt-sign.ts";
69
69
  import { escapeHtml } from "./oauth-ui.ts";
70
- import { mintOperatorToken } from "./operator-token.ts";
70
+ import {
71
+ type IssueOperatorTokenResult,
72
+ type MintOperatorTokenOpts,
73
+ issueOperatorToken,
74
+ mintOperatorToken,
75
+ readOperatorTokenFile,
76
+ } from "./operator-token.ts";
71
77
  import { isHttpsRequest } from "./request-protocol.ts";
72
78
  import { findService, readManifestLenient } from "./services-manifest.ts";
73
79
  import {
@@ -377,6 +383,22 @@ export interface SetupWizardDeps {
377
383
  * `readExposeStateFn` seam.
378
384
  */
379
385
  readExposeStateFn?: () => ExposeState | undefined;
386
+ /**
387
+ * Test seam for the fresh-box operator-token closure (design §3.1 /
388
+ * Phase 3b Deliverable A). After the wizard creates the first admin, it
389
+ * persists `~/.parachute/operator.token` so the box has a CLI operator
390
+ * credential the moment it gains an admin — without it, the Phase 3b
391
+ * per-module verbs (`parachute start/stop/restart <svc>` driving the
392
+ * supervisor) would 401 on a freshly-bootstrapped box. Production omits
393
+ * this and uses the real {@link issueOperatorToken}; tests inject a stub
394
+ * to assert the call (or to make it throw and prove a token-write failure
395
+ * never fails account creation).
396
+ */
397
+ issueOperatorToken?: (
398
+ db: Database,
399
+ userId: string,
400
+ opts: MintOperatorTokenOpts & { dir?: string },
401
+ ) => Promise<IssueOperatorTokenResult>;
380
402
  }
381
403
 
382
404
  /**
@@ -1888,6 +1910,16 @@ export async function handleSetupAccountPost(
1888
1910
  // any racer who saw it over the operator's shoulder during the
1889
1911
  // window between log-print and form-submit.
1890
1912
  if (requireToken) consumeBootstrapToken();
1913
+ // Fresh-box operator-token closure (design §3.1 / Phase 3b Deliverable A).
1914
+ // The box now has its first admin — persist `operator.token` so it has a
1915
+ // CLI operator credential immediately. Without it, the Phase 3b per-module
1916
+ // verbs (start/stop/restart <svc> driving the supervisor over the
1917
+ // module-ops API) would 401 on a box bootstrapped purely through the
1918
+ // wizard. Runs AFTER the admin row + bootstrap-token are committed so a
1919
+ // half-written admin never gains a token; guarded so an existing token is
1920
+ // never clobbered; wrapped so a token-write failure NEVER fails the
1921
+ // account creation the operator just completed.
1922
+ await ensureOperatorTokenForFirstAdmin(deps, user.id);
1891
1923
  const session = createSession(deps.db, { userId: user.id });
1892
1924
  const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
1893
1925
  secure: isHttpsRequest(req),
@@ -1927,6 +1959,53 @@ export async function handleSetupAccountPost(
1927
1959
  }
1928
1960
  }
1929
1961
 
1962
+ /**
1963
+ * Persist `~/.parachute/operator.token` for the just-created first admin
1964
+ * (design §3.1 / Phase 3b Deliverable A). The 3a reviewer flagged that a fresh
1965
+ * `init`→wizard flow ends with NO operator token on disk, so the Phase 3b
1966
+ * per-module verbs — `parachute start/stop/restart <svc>`, which now drive the
1967
+ * supervisor over the host-admin-gated module-ops API — would 401 on such a
1968
+ * box. Minting the token here makes the box have a CLI operator credential the
1969
+ * moment it gains an admin.
1970
+ *
1971
+ * Three invariants:
1972
+ * - Mints under the `admin` scope-set (the default), which carries
1973
+ * `parachute:host:admin` — exactly the scope `api-modules-ops.ts` gates on.
1974
+ * `issueOperatorToken` writes it 0600 (`writeOperatorTokenFile`).
1975
+ * - Guarded by `readOperatorTokenFile() === null`: never clobber a token an
1976
+ * operator already minted (`auth set-password` / `rotate-operator`, or a
1977
+ * prior init).
1978
+ * - Wrapped in try/catch so a token-write failure NEVER fails the account
1979
+ * creation the operator just completed — they have an admin row + session
1980
+ * either way, and `parachute auth rotate-operator` is the documented
1981
+ * recovery for a missing token.
1982
+ *
1983
+ * Uses `deps.issuer` as the `iss` claim — the same pre-resolved origin the rest
1984
+ * of the wizard's mints use (`handleSetupExposePost`). The hub-server derives
1985
+ * that origin the same way `commands/auth.ts:resolveHubIssuer` does — semantically
1986
+ * equivalent, structurally different: this path takes a pre-resolved `deps.issuer`
1987
+ * while `auth.ts` reads expose-state inline at call time. `start hub` self-heals a
1988
+ * stale `iss` later if the box is exposed after init (hub#481), so an
1989
+ * init-at-loopback mint is correct here.
1990
+ */
1991
+ async function ensureOperatorTokenForFirstAdmin(
1992
+ deps: SetupWizardDeps,
1993
+ userId: string,
1994
+ ): Promise<void> {
1995
+ const issue = deps.issueOperatorToken ?? issueOperatorToken;
1996
+ try {
1997
+ const existing = await readOperatorTokenFile(deps.configDir);
1998
+ if (existing !== null) return;
1999
+ await issue(deps.db, userId, { issuer: deps.issuer, dir: deps.configDir });
2000
+ } catch (err) {
2001
+ // Non-fatal: the admin + session were already committed. Log for the
2002
+ // operator's debugging; they can recover with `parachute auth
2003
+ // rotate-operator` from a shell on the box.
2004
+ const msg = err instanceof Error ? err.message : String(err);
2005
+ console.warn(`[setup-wizard] operator-token closure skipped for new admin: ${msg}`);
2006
+ }
2007
+ }
2008
+
1930
2009
  /**
1931
2010
  * Static error page surfaced when an `/admin/setup/account` POST arrives
1932
2011
  * after the bootstrap token has already been consumed by a successful