@openparachute/hub 0.6.5-rc.7 → 0.7.0

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 (52) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/account-setup.test.ts +34 -0
  3. package/src/__tests__/account-vault-admin-token.test.ts +35 -3
  4. package/src/__tests__/admin-channel-token.test.ts +173 -0
  5. package/src/__tests__/admin-connections.test.ts +1154 -0
  6. package/src/__tests__/admin-csrf-belt.test.ts +346 -0
  7. package/src/__tests__/admin-module-token.test.ts +311 -0
  8. package/src/__tests__/admin-vaults.test.ts +590 -0
  9. package/src/__tests__/api-modules-ops.test.ts +70 -5
  10. package/src/__tests__/api-modules.test.ts +262 -79
  11. package/src/__tests__/hub-db-liveness.test.ts +12 -7
  12. package/src/__tests__/hub-server.test.ts +319 -21
  13. package/src/__tests__/invites.test.ts +27 -0
  14. package/src/__tests__/module-manifest.test.ts +305 -8
  15. package/src/__tests__/serve-boot.test.ts +133 -2
  16. package/src/__tests__/service-spec-discovery.test.ts +109 -0
  17. package/src/__tests__/setup-gate.test.ts +13 -7
  18. package/src/__tests__/setup-wizard.test.ts +228 -1
  19. package/src/__tests__/vault-name.test.ts +20 -5
  20. package/src/__tests__/well-known.test.ts +44 -8
  21. package/src/account-vault-admin-token.ts +43 -14
  22. package/src/admin-channel-token.ts +135 -0
  23. package/src/admin-connections.ts +980 -0
  24. package/src/admin-module-token.ts +197 -0
  25. package/src/admin-vaults.ts +390 -12
  26. package/src/api-hub-upgrade.ts +4 -3
  27. package/src/api-modules-ops.ts +41 -16
  28. package/src/api-modules.ts +238 -116
  29. package/src/api-tokens.ts +8 -5
  30. package/src/commands/serve-boot.ts +80 -3
  31. package/src/commands/setup.ts +4 -4
  32. package/src/connections-store.ts +161 -0
  33. package/src/grants.ts +50 -0
  34. package/src/hub-db-liveness.ts +33 -17
  35. package/src/hub-server.ts +354 -61
  36. package/src/invites.ts +22 -0
  37. package/src/jwt-sign.ts +41 -1
  38. package/src/module-manifest.ts +429 -23
  39. package/src/origin-check.ts +106 -0
  40. package/src/proxy-error-ui.ts +1 -1
  41. package/src/service-spec.ts +132 -41
  42. package/src/setup-wizard.ts +68 -6
  43. package/src/users.ts +11 -0
  44. package/src/vault-name.ts +27 -7
  45. package/src/well-known.ts +41 -33
  46. package/web/ui/dist/assets/index-C-XzMVqN.js +61 -0
  47. package/web/ui/dist/assets/index-E_9wqjEm.css +1 -0
  48. package/web/ui/dist/index.html +2 -2
  49. package/src/__tests__/api-modules-config.test.ts +0 -882
  50. package/src/api-modules-config.ts +0 -421
  51. package/web/ui/dist/assets/index-BYYUeLGA.css +0 -1
  52. package/web/ui/dist/assets/index-D3cDUOOj.js +0 -61
package/src/api-tokens.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * "expires_at": "ISO-8601",
24
24
  * "revoked_at": "ISO-8601" | null,
25
25
  * "created_at": "ISO-8601",
26
- * "created_via": "oauth_refresh" | "cli_mint" | "operator_mint",
26
+ * "created_via": "oauth_refresh" | "cli_mint" | "operator_mint" | "connection_provision",
27
27
  * "permissions": "<json-string>" | null
28
28
  * }
29
29
  * ],
@@ -45,8 +45,10 @@
45
45
  * - `created_via=<value>` — narrow by mint provenance. One of
46
46
  * `oauth_refresh` (OAuth refresh-token rotation), `operator_mint`
47
47
  * (operator-token rotation via `parachute auth rotate-operator`),
48
- * or `cli_mint` (CLI / `POST /api/auth/mint-token`). Powers the
49
- * admin UI's "by source" filter pills (hub#212 Phase F).
48
+ * `cli_mint` (CLI / `POST /api/auth/mint-token`), or
49
+ * `connection_provision` (long-lived tokens the Connections engine
50
+ * mints — see admin-connections.ts). Powers the admin UI's
51
+ * "by source" filter pills (hub#212 Phase F).
50
52
  *
51
53
  * Why bearer-gated rather than session-cookie-gated: matches the rest
52
54
  * of `/api/auth/*` (mint-token, revoke-token), so an automation client
@@ -149,14 +151,15 @@ export async function handleApiTokens(req: Request, deps: ApiTokensDeps): Promis
149
151
  if (
150
152
  createdViaParam === "oauth_refresh" ||
151
153
  createdViaParam === "operator_mint" ||
152
- createdViaParam === "cli_mint"
154
+ createdViaParam === "cli_mint" ||
155
+ createdViaParam === "connection_provision"
153
156
  ) {
154
157
  createdVia = createdViaParam;
155
158
  } else if (createdViaParam !== null) {
156
159
  return jsonError(
157
160
  400,
158
161
  "invalid_request",
159
- "created_via must be one of: oauth_refresh | operator_mint | cli_mint",
162
+ "created_via must be one of: oauth_refresh | operator_mint | cli_mint | connection_provision",
160
163
  );
161
164
  }
162
165
  const cursor = url.searchParams.get("cursor");
@@ -22,11 +22,12 @@ import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
22
22
  import { ModuleManifestError } from "../module-manifest.ts";
23
23
  import {
24
24
  type ServiceSpec,
25
+ canonicalPortForManifest,
25
26
  getSpec,
26
27
  getSpecFromInstallDir,
27
28
  shortNameForManifest,
28
29
  } from "../service-spec.ts";
29
- import { type ServiceEntry, readManifestLenient } from "../services-manifest.ts";
30
+ import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
30
31
  import { enrichedPath } from "../spawn-path.ts";
31
32
  import type { Supervisor } from "../supervisor.ts";
32
33
 
@@ -69,6 +70,77 @@ export interface BuildSpawnRequestOpts {
69
70
  readonly extraEnv?: Record<string, string>;
70
71
  }
71
72
 
73
+ /**
74
+ * Snap a fixed-port first-party module's services.json `port` back to its
75
+ * compiled-in canonical value when it has DRIFTED, and persist the correction.
76
+ * Returns the entry to spawn with — reconciled when a drift was repaired, the
77
+ * original otherwise.
78
+ *
79
+ * Why (channel#41): the hub is the port authority, and a first-party module
80
+ * with a compiled-in canonical port (`canonicalPortForManifest` ≠ undefined —
81
+ * vault / scribe / surface / channel / notes / runner) has exactly ONE correct
82
+ * port: its canonical one. The injected `PORT`, the supervisor's readiness
83
+ * probe, and the reverse-proxy target are ALL derived from the services.json
84
+ * row's `port`. So once a transiently-wrong port lands in that row (the channel
85
+ * row was observed live carrying `19415` instead of `1941`), it self-perpetuates:
86
+ * the supervisor probes/proxies the wrong port forever and `/channel/*` routes
87
+ * to a dead port. The canonical port is authoritative, so we reconcile the row
88
+ * before spawn rather than honoring the drift.
89
+ *
90
+ * Safety for the other fixed-port modules (vault / scribe / surface): the
91
+ * canonical value is exactly what each ALREADY self-registers, so on the
92
+ * common path `entry.port === canonical` and this is a no-op. There is no
93
+ * legitimate "first-party module intentionally on a non-canonical port" case —
94
+ * install-time `assignPort` only walks off canonical on a genuine collision,
95
+ * and that's a same-range fallback for a DIFFERENT module, never a steady state
96
+ * for the canonical owner. If another row genuinely already owns the canonical
97
+ * port we DON'T rewrite (it would trip the write-side duplicate-port guard and
98
+ * isn't ours to take) — we leave the drift in place and let the supervisor's
99
+ * port-squatter detection surface it. Third-party modules (no canonical) are
100
+ * never touched.
101
+ */
102
+ export function reconcilePortToCanonical(
103
+ entry: ServiceEntry,
104
+ manifestPath: string,
105
+ log: (line: string) => void = () => {},
106
+ ): ServiceEntry {
107
+ const canonical = canonicalPortForManifest(entry.name);
108
+ if (canonical === undefined || entry.port === canonical) return entry;
109
+
110
+ // Don't steal a canonical port another row legitimately holds — that would
111
+ // trip `upsertService`'s duplicate-port guard. Re-read live so we see the
112
+ // current on-disk state (another module may have just registered).
113
+ const others = readManifestLenient(manifestPath).services.filter((s) => s.name !== entry.name);
114
+ if (others.some((s) => s.port === canonical)) {
115
+ log(
116
+ `[supervisor] ${entry.name}: services.json port ${entry.port} ≠ canonical ${canonical}, ` +
117
+ `but ${canonical} is held by another row — not reconciling; surfacing the drift.`,
118
+ );
119
+ return entry;
120
+ }
121
+
122
+ const reconciled: ServiceEntry = { ...entry, port: canonical };
123
+ try {
124
+ upsertService(reconciled, manifestPath);
125
+ log(
126
+ `[supervisor] ${entry.name}: reconciled drifted services.json port ${entry.port} → canonical ${canonical} (channel#41).`,
127
+ );
128
+ return reconciled;
129
+ } catch (err) {
130
+ // Best-effort: a failed reconcile must not block the boot. The write can
131
+ // fail for an I/O reason (permissions, races against an external writer) OR
132
+ // because `upsertService`'s duplicate-port guard tripped — e.g. another row
133
+ // raced onto the canonical port between the check above and this write.
134
+ // Either way, spawn with the canonical port in-memory so at least the child
135
+ // binds + the probe checks the right port this run; the proxy still reads
136
+ // the (stale) row until the next reconcile succeeds.
137
+ log(
138
+ `[supervisor] ${entry.name}: could not reconcile services.json port to canonical (${entry.port} → ${canonical}; write failed or canonical held): ${err} — spawning with canonical in-memory.`,
139
+ );
140
+ return reconciled;
141
+ }
142
+ }
143
+
72
144
  /**
73
145
  * Build the `Supervisor.start` request for a single module, identically on
74
146
  * both the serve-boot path and the `POST /api/modules/:short/start` handler.
@@ -166,7 +238,12 @@ export async function bootSupervisedModules(
166
238
  continue;
167
239
  }
168
240
 
169
- const cmd = spec.startCmd?.(entry);
241
+ // Snap a drifted fixed-port row back to canonical before we derive PORT /
242
+ // probe / proxy from it (channel#41). No-op on the common path where the
243
+ // module already sits on its canonical port.
244
+ const spawnEntry = reconcilePortToCanonical(entry, opts.manifestPath, log);
245
+
246
+ const cmd = spec.startCmd?.(spawnEntry);
170
247
  if (!cmd || cmd.length === 0) {
171
248
  log(`[supervisor] ${short}: spec resolved but no startCmd — skipping (CLI-only module).`);
172
249
  results.push({
@@ -182,7 +259,7 @@ export async function bootSupervisedModules(
182
259
  // .env merge, and PARACHUTE_HUB_ORIGIN propagation (hub#365) all live in the
183
260
  // shared `buildModuleSpawnRequest` so the `POST /api/modules/:short/start`
184
261
  // handler builds an identical request (design 2026-06-01 §3.3).
185
- const req = buildModuleSpawnRequest(short, entry, cmd, {
262
+ const req = buildModuleSpawnRequest(short, spawnEntry, cmd, {
186
263
  configDir: opts.configDir,
187
264
  ...(opts.hubOrigin !== undefined ? { hubOrigin: opts.hubOrigin } : {}),
188
265
  });
@@ -74,7 +74,7 @@ interface ServiceChoice {
74
74
  manifestName: string;
75
75
  /** Per-service URL composer used in the final-summary banner. Optional. */
76
76
  urlForEntry?: (entry: ServiceEntry) => string | undefined;
77
- /** Full spec when available (FIRST_PARTY_FALLBACKS shorts: notes / channel). */
77
+ /** Full spec when available (FIRST_PARTY_FALLBACKS shorts: notes). */
78
78
  spec?: ServiceSpec;
79
79
  }
80
80
 
@@ -114,9 +114,9 @@ function defaultAvailability(): InteractiveAvailability {
114
114
  * the service has a row in services.json.
115
115
  *
116
116
  * The full ServiceSpec is only available pre-install for FIRST_PARTY_FALLBACKS
117
- * shorts (notes / channel they carry a vendored manifest). KNOWN_MODULES
118
- * shorts (vault / scribe / runner) ship `.parachute/module.json` and
119
- * self-register; pre-install we know manifestName + the urlForEntry quirk
117
+ * shorts (notes — it carries a vendored manifest). KNOWN_MODULES shorts
118
+ * (vault / scribe / runner / channel / surface) ship `.parachute/module.json`
119
+ * and self-register; pre-install we know manifestName + the urlForEntry quirk
120
120
  * from `KNOWN_MODULES[short].extras`, which is all the survey/summary needs.
121
121
  */
122
122
  function surveyServices(manifestPath: string): ServiceChoice[] {
@@ -0,0 +1,161 @@
1
+ /**
2
+ * `connections.json` — the persisted registry of operator-created Connections
3
+ * (2026-06-09 modular-UI architecture, P5).
4
+ *
5
+ * A **connection** wires "when [EVENT] in [source module] (filter) → do [ACTION]
6
+ * in [sink module]". The hub is the only thing with cross-module authority
7
+ * (mint tokens, register vault triggers), so connections are hub-native + the
8
+ * record of what got provisioned lives here, in the hub state dir.
9
+ *
10
+ * One file, a flat array of records. Each record carries enough to (a) render
11
+ * the Connections list and (b) tear down what was provisioned — notably the
12
+ * `provisioned.triggerName` + `provisioned.vault` so DELETE can remove the exact
13
+ * vault trigger that was registered. We keep the store deliberately small +
14
+ * synchronous (Bun file I/O); the cardinality is "a handful of connections per
15
+ * hub", not a hot path.
16
+ */
17
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname } from "node:path";
19
+
20
+ /** The source side — an event a module emits, with an operator-set filter. */
21
+ export interface ConnectionSource {
22
+ /** Source module short name, e.g. `vault`. */
23
+ readonly module: string;
24
+ /** For vault events, which vault instance the event is scoped to. */
25
+ readonly vault?: string;
26
+ /** Event key declared in the source module's `module.json`, e.g. `note.created`. */
27
+ readonly event: string;
28
+ /**
29
+ * Operator-set filter, shaped by the event's `filterSchema`. For a vault
30
+ * event this maps to the trigger predicate (`tags` / `has_metadata` /
31
+ * `missing_metadata` / `has_content`).
32
+ */
33
+ readonly filter?: Record<string, unknown>;
34
+ }
35
+
36
+ /** The sink side — an action a module accepts (the sink is ALWAYS an action). */
37
+ export interface ConnectionSink {
38
+ /** Sink module short name, e.g. `channel`. */
39
+ readonly module: string;
40
+ /** Action key declared in the sink module's `module.json`, e.g. `message.deliver`. */
41
+ readonly action: string;
42
+ /** Action params, shaped by the action's `inputSchema` (e.g. `{ channel }`). */
43
+ readonly params?: Record<string, unknown>;
44
+ }
45
+
46
+ /** What the provisioning engine actually wired, for teardown + display. */
47
+ export interface ConnectionProvisioned {
48
+ /** How the action was provisioned, e.g. `vault-trigger`. */
49
+ readonly type: string;
50
+ /** The vault instance the trigger was registered on (vault-trigger). */
51
+ readonly vault?: string;
52
+ /** The exact vault trigger name registered — DELETE removes this. */
53
+ readonly triggerName?: string;
54
+ /**
55
+ * jtis of the LONG-LIVED tokens minted for this connection (the webhook
56
+ * bearer, and for a channel sink the vault-write reply token). Each is
57
+ * registered in the hub's tokens table (`created_via='connection_provision'`)
58
+ * at mint time so teardown can revoke them — an unregistered long-lived
59
+ * token is unrevocable by construction (hub-module-boundary charter,
60
+ * registered-mint rule). Records written before this field existed read back
61
+ * as `undefined`; their tokens were never registered and ride to expiry
62
+ * (surfaced as `legacy: true` in the list wire shape).
63
+ */
64
+ readonly mintedJtis?: readonly string[];
65
+ }
66
+
67
+ export interface ConnectionRecord {
68
+ readonly id: string;
69
+ readonly source: ConnectionSource;
70
+ readonly sink: ConnectionSink;
71
+ readonly provisioned: ConnectionProvisioned;
72
+ readonly createdAt: string;
73
+ /**
74
+ * Provenance — WHO requested this connection (modular-UI R2, module-initiated
75
+ * connections). A module-owned config UI that creates a connection on the
76
+ * operator's behalf (e.g. the channel admin page's "link to a vault" flow)
77
+ * labels itself here (e.g. `"channel"`); a connection built by hand in the
78
+ * hub's own Connections builder is `"custom"`. Lets the operator see which
79
+ * connections a module initiated vs which they wired themselves. Optional for
80
+ * back-compat: records written before R2 read back as `undefined`, which the
81
+ * SPA treats as `"custom"`.
82
+ */
83
+ readonly requestedBy?: string;
84
+ }
85
+
86
+ interface ConnectionsFile {
87
+ connections: ConnectionRecord[];
88
+ }
89
+
90
+ function emptyFile(): ConnectionsFile {
91
+ return { connections: [] };
92
+ }
93
+
94
+ /** Read the store. A missing/garbage file reads as empty (fresh hub). */
95
+ export function readConnections(storePath: string): ConnectionRecord[] {
96
+ let buf: string;
97
+ try {
98
+ buf = readFileSync(storePath, "utf8");
99
+ } catch {
100
+ return [];
101
+ }
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(buf);
105
+ } catch {
106
+ return [];
107
+ }
108
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
109
+ const arr = (parsed as { connections?: unknown }).connections;
110
+ if (!Array.isArray(arr)) return [];
111
+ // Lenient: drop any malformed row rather than failing the whole read, so one
112
+ // bad hand-edit doesn't take down the Connections view (mirrors the
113
+ // services.json lenient-read posture).
114
+ return arr.filter((r): r is ConnectionRecord => {
115
+ if (!r || typeof r !== "object") return false;
116
+ const rec = r as Record<string, unknown>;
117
+ return (
118
+ typeof rec.id === "string" &&
119
+ !!rec.source &&
120
+ typeof rec.source === "object" &&
121
+ !!rec.sink &&
122
+ typeof rec.sink === "object"
123
+ );
124
+ });
125
+ }
126
+
127
+ function writeAll(storePath: string, records: ConnectionRecord[]): void {
128
+ mkdirSync(dirname(storePath), { recursive: true });
129
+ const file: ConnectionsFile = { connections: records };
130
+ // Written WITHOUT 0o600 because this file holds NO secrets — the provisioned
131
+ // webhook bearer lives only in the vault trigger's row, never here; records
132
+ // carry source/sink/trigger-name metadata only. Consistent with the default
133
+ // perms on services.json / expose-state.json.
134
+ writeFileSync(storePath, `${JSON.stringify(file, null, 2)}\n`);
135
+ }
136
+
137
+ /** Upsert by id (replace an existing record with the same id, else append). */
138
+ export function putConnection(storePath: string, record: ConnectionRecord): void {
139
+ const records = readConnections(storePath);
140
+ const idx = records.findIndex((r) => r.id === record.id);
141
+ if (idx >= 0) records[idx] = record;
142
+ else records.push(record);
143
+ writeAll(storePath, records);
144
+ }
145
+
146
+ /** Remove a connection by id. Returns the removed record, or null if absent. */
147
+ export function removeConnection(storePath: string, id: string): ConnectionRecord | null {
148
+ const records = readConnections(storePath);
149
+ const idx = records.findIndex((r) => r.id === id);
150
+ if (idx < 0) return null;
151
+ const [removed] = records.splice(idx, 1);
152
+ writeAll(storePath, records);
153
+ return removed ?? null;
154
+ }
155
+
156
+ export function getConnection(storePath: string, id: string): ConnectionRecord | null {
157
+ return readConnections(storePath).find((r) => r.id === id) ?? null;
158
+ }
159
+
160
+ /** Re-export for the unused-import linter when only the type is consumed. */
161
+ export const _emptyConnectionsFile = emptyFile;
package/src/grants.ts CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
 
28
28
  import type { Database } from "bun:sqlite";
29
+ import { vaultScopeName } from "./scope-explanations.ts";
29
30
 
30
31
  export interface Grant {
31
32
  userId: string;
@@ -324,3 +325,52 @@ export function revokeGrant(db: Database, userId: string, clientId: string): boo
324
325
  .run(userId, clientId);
325
326
  return res.changes > 0;
326
327
  }
328
+
329
+ /**
330
+ * Vault-delete cascade step (B1, 2026-06-09 hub-module-boundary): REWRITE
331
+ * every grant whose scope list names the deleted vault, removing the
332
+ * `vault:<name>:<verb>` entries; DROP the row only when the rewrite leaves
333
+ * it empty.
334
+ *
335
+ * Rewrite-not-drop matters: a grant row is keyed (user, client) and its
336
+ * scope set spans every vault the user ever approved for that client —
337
+ * dropping the whole row over a single vault would silently revoke the
338
+ * client's consent on the user's OTHER vaults (over-revocation).
339
+ *
340
+ * Matching is the exact `vaultScopeName` segment comparison (regex on the
341
+ * `vault:<name>:<read|write|admin>` shape), never SQL LIKE — `_` in a vault
342
+ * name is a LIKE wildcard. Unnamed scopes (`vault:read`) and non-vault
343
+ * scopes are preserved.
344
+ */
345
+ export function rewriteGrantsRemovingVault(
346
+ db: Database,
347
+ vaultName: string,
348
+ ): { rewritten: number; dropped: number } {
349
+ return db.transaction(() => {
350
+ const rows = db
351
+ .prepare("SELECT user_id, client_id, scopes, granted_at FROM grants")
352
+ .all() as GrantRow[];
353
+ let rewritten = 0;
354
+ let dropped = 0;
355
+ for (const row of rows) {
356
+ const scopes = row.scopes.split(" ").filter((s) => s.length > 0);
357
+ const kept = scopes.filter((s) => vaultScopeName(s) !== vaultName);
358
+ if (kept.length === scopes.length) continue; // doesn't name the vault
359
+ if (kept.length === 0) {
360
+ db.prepare("DELETE FROM grants WHERE user_id = ? AND client_id = ?").run(
361
+ row.user_id,
362
+ row.client_id,
363
+ );
364
+ dropped++;
365
+ } else {
366
+ db.prepare("UPDATE grants SET scopes = ? WHERE user_id = ? AND client_id = ?").run(
367
+ kept.join(" "),
368
+ row.user_id,
369
+ row.client_id,
370
+ );
371
+ rewritten++;
372
+ }
373
+ }
374
+ return { rewritten, dropped };
375
+ })();
376
+ }
@@ -383,25 +383,41 @@ export function createDbHolder(initial: Database, deps: DbHolderDeps): DbHolder
383
383
  const verdict = classifyPathLiveness({ expected: currentInode, current: pathInode });
384
384
  if (verdict === "ok" || verdict === "unknown") return verdict;
385
385
 
386
- // Genuine wipe signal: the on-disk DB the handle points at is gone
387
- // ("gone") or was replaced underneath us ("replaced"). Trigger the SAME
388
- // reopen-or-exit machinery. When the path is gone, reopen's SELECT-1
389
- // verify fails exit platform manager restarts with a fresh on-disk
390
- // handle (seconds, not "never"). When replaced, we adopt the fresh inode.
386
+ if (verdict === "gone") {
387
+ // The whole state dir was wiped under the running hub (`rm -rf
388
+ // ~/.parachute`). We must NOT reopen-in-place here: `reopen` is
389
+ // `openHubDb`, which `mkdirSync`'s the dir back + opens a fresh EMPTY db,
390
+ // so its SELECT-1 verify would PASS and we'd "heal" into a half-recovered
391
+ // hub — empty db, but stale in-memory state, wiped well-known files, and
392
+ // supervised modules whose own state dirs are gone yet never re-spawned
393
+ // (#619 follow-up). The correct recovery for a full wipe is a clean
394
+ // process exit so the platform manager (systemd / launchd / container)
395
+ // restarts `parachute serve`, which re-bootstraps everything (well-known,
396
+ // admin seed, supervisor re-spawn). This restores the #610 design intent
397
+ // ("we exit, letting the platform manager restart") that the shared
398
+ // reopen-or-exit path silently defeated via openHubDb's mkdir-recursive.
399
+ log(
400
+ `parachute hub: db path ${deps.dbPath} no longer exists (state dir wiped under a running hub, #610); exiting so the platform manager restarts the hub with a freshly bootstrapped state dir.`,
401
+ );
402
+ exit(1);
403
+ return verdict;
404
+ }
405
+
406
+ // "replaced": the db FILE was swapped underneath us (e.g. a restore copied
407
+ // a new file over the same path) while the rest of the state dir is intact.
408
+ // Adopting the fresh inode in-place via reopen-or-exit is correct here — a
409
+ // process restart would be heavier than needed.
391
410
  //
392
- // ONE-TICK /health ANOMALY (intentional): on a "replaced" verdict the
393
- // reopenOrExit below heals SYNCHRONOUSLY, but we still RETURN "replaced"
394
- // for this one call — so the /health request that drove this probe reports
395
- // `db:"error: path-replaced"` even though the handle is now healthy; the
396
- // very next request reads `ok`. We don't mask it (returning "ok" here would
397
- // hide that a heal just happened, which is exactly what monitoring wants to
398
- // see). It's safe because #591's adoption probe checks only HTTP 200
399
- // (`res.ok`), not the specific `db` string, so a single transient error
400
- // string can't cascade.
411
+ // ONE-TICK /health ANOMALY (intentional): the reopenOrExit below heals
412
+ // SYNCHRONOUSLY, but we still RETURN "replaced" for this one call — so the
413
+ // /health request that drove this probe reports `db:"error: path-replaced"`
414
+ // even though the handle is now healthy; the very next request reads `ok`.
415
+ // We don't mask it (returning "ok" here would hide that a heal just
416
+ // happened, which is exactly what monitoring wants to see). It's safe
417
+ // because #591's adoption probe checks only HTTP 200 (`res.ok`), not the
418
+ // specific `db` string, so a single transient error string can't cascade.
401
419
  reopenOrExit(
402
- verdict === "gone"
403
- ? `db path ${deps.dbPath} no longer exists (state dir wiped under a running hub, #610)`
404
- : `db path ${deps.dbPath} now resolves to a different inode (DB file replaced underneath the open handle, #610)`,
420
+ `db path ${deps.dbPath} now resolves to a different inode (DB file replaced underneath the open handle, #610)`,
405
421
  );
406
422
  return verdict;
407
423
  },