@openparachute/hub 0.7.4-rc.13 → 0.7.4-rc.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.13",
3
+ "version": "0.7.4-rc.15",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -527,7 +527,13 @@ describe("admin-lock management API", () => {
527
527
  const r2 = await handleAdminLock(lockReq("/heartbeat", cookie, {}), "/heartbeat", {
528
528
  db: harness.db,
529
529
  });
530
- expect(((await r2.json()) as { locked: boolean }).locked).toBe(false);
530
+ const body2 = (await r2.json()) as { locked: boolean; idle_seconds?: number };
531
+ expect(body2.locked).toBe(false);
532
+ // The heartbeat MUST carry idle_seconds — the client re-anchors its local
533
+ // idle timer from it on every heartbeat. Omitting it poisoned the timer
534
+ // (undefined → NaN → instant re-lock). Regression guard for the PIN
535
+ // re-prompt loop.
536
+ expect(body2.idle_seconds).toBe(getIdleSeconds(harness.db));
531
537
  });
532
538
 
533
539
  test("unlock brute-force limiter: 6th attempt is 429", async () => {
@@ -0,0 +1,464 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { type CheckResult, type DoctorDeps, doctor } from "../commands/doctor.ts";
6
+ import { writePid } from "../process-state.ts";
7
+
8
+ /**
9
+ * Doctor tests. The headline is the fresh-install-green guard (#717): a
10
+ * sandboxed PARACHUTE_HOME with a minimal-but-current services.json + a valid
11
+ * operator.token → ALL GREEN, zero WARN/FAIL. Every other test drives ONE
12
+ * failure mode and asserts that check fails while the others stay green.
13
+ *
14
+ * Every external side effect is stubbed through the `deps` seam — no real
15
+ * network probe, no real launchd/systemd query, no touching `~/.parachute`. The
16
+ * only real fs is the sandboxed PARACHUTE_HOME (services.json / operator.token /
17
+ * pidfiles) so the on-disk readers exercise genuine state.
18
+ */
19
+
20
+ interface Harness {
21
+ configDir: string;
22
+ manifestPath: string;
23
+ cleanup: () => void;
24
+ }
25
+
26
+ function makeHarness(): Harness {
27
+ const dir = mkdtempSync(join(tmpdir(), "parachute-doctor-test-"));
28
+ return {
29
+ configDir: dir,
30
+ manifestPath: join(dir, "services.json"),
31
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
32
+ };
33
+ }
34
+
35
+ /** A minimal-but-current services.json with the canonical vault row. */
36
+ function seedCurrentManifest(manifestPath: string): void {
37
+ const services = [
38
+ {
39
+ name: "parachute-vault",
40
+ port: 1940,
41
+ paths: ["/vault/default"],
42
+ health: "/health",
43
+ version: "0.7.4",
44
+ },
45
+ ];
46
+ writeFileSync(manifestPath, JSON.stringify({ services }, null, 2));
47
+ }
48
+
49
+ /** A hand-rolled (unsigned) JWT — doctor DECODES `iss`, never verifies it. */
50
+ function fakeOperatorToken(iss: string): string {
51
+ const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString("base64url");
52
+ return [
53
+ b64({ alg: "none", typ: "JWT" }),
54
+ b64({ iss, aud: "operator", sub: "u1", pa_scope_set: "admin" }),
55
+ "sig",
56
+ ].join(".");
57
+ }
58
+
59
+ function seedOperatorToken(configDir: string, iss = "http://127.0.0.1:1939"): void {
60
+ writeFileSync(join(configDir, "operator.token"), `${fakeOperatorToken(iss)}\n`, { mode: 0o600 });
61
+ }
62
+
63
+ /**
64
+ * Deps for a HEALTHY box: hub answers /health, the vault module answers /health,
65
+ * the manager reports active, every bin resolves on PATH, nothing exposed.
66
+ * Individual tests override one field to drive a specific failure.
67
+ */
68
+ function healthyDeps(over: Partial<DoctorDeps> = {}): DoctorDeps {
69
+ return {
70
+ probeHubHealth: async () => true,
71
+ probeModuleHealth: async () => true,
72
+ probePublicHealth: async () => true,
73
+ queryHubUnitState: () => ({ state: "active" }),
74
+ // A `which` that resolves everything — so the bin exec-bit check passes
75
+ // without the real module binaries being on the test host's PATH.
76
+ which: (binary) => `/usr/local/bin/${binary}`,
77
+ now: () => new Date("2026-06-27T00:00:00Z"),
78
+ ...over,
79
+ };
80
+ }
81
+
82
+ async function runDoctor(
83
+ h: Harness,
84
+ deps: DoctorDeps,
85
+ ): Promise<{ code: number; checks: CheckResult[] }> {
86
+ const lines: string[] = [];
87
+ const code = await doctor({
88
+ configDir: h.configDir,
89
+ manifestPath: h.manifestPath,
90
+ print: (l) => lines.push(l),
91
+ json: true,
92
+ deps,
93
+ });
94
+ const payload = JSON.parse(lines.join("\n")) as { checks: CheckResult[] };
95
+ return { code, checks: payload.checks };
96
+ }
97
+
98
+ function byName(checks: CheckResult[], name: string): CheckResult | undefined {
99
+ return checks.find((c) => c.name === name);
100
+ }
101
+
102
+ function expectNoUnexpectedNonPass(checks: CheckResult[], allowedFailing: string[]): void {
103
+ const offenders = checks.filter((c) => c.status !== "pass" && !allowedFailing.includes(c.name));
104
+ if (offenders.length > 0) {
105
+ throw new Error(
106
+ `unexpected non-pass checks: ${offenders.map((c) => `${c.name}=${c.status}`).join(", ")}`,
107
+ );
108
+ }
109
+ }
110
+
111
+ describe("doctor — the fresh-install-green headline guard (#717)", () => {
112
+ test("a minimal-but-current install with a valid operator.token → ALL GREEN, exit 0", async () => {
113
+ const h = makeHarness();
114
+ try {
115
+ seedCurrentManifest(h.manifestPath);
116
+ seedOperatorToken(h.configDir);
117
+ const { code, checks } = await runDoctor(h, healthyDeps());
118
+
119
+ // The load-bearing assertion: not a single WARN or FAIL anywhere.
120
+ const nonPass = checks.filter((c) => c.status !== "pass");
121
+ expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
122
+ expect(checks.every((c) => c.status === "pass")).toBe(true);
123
+ expect(code).toBe(0);
124
+ } finally {
125
+ h.cleanup();
126
+ }
127
+ });
128
+
129
+ test("a brand-new box (no services.json, no operator.token) → ALL GREEN, exit 0", async () => {
130
+ const h = makeHarness();
131
+ try {
132
+ // Nothing seeded at all — the truly-fresh case before `parachute init`.
133
+ const { code, checks } = await runDoctor(h, healthyDeps());
134
+ const nonPass = checks.filter((c) => c.status !== "pass");
135
+ expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
136
+ expect(code).toBe(0);
137
+ } finally {
138
+ h.cleanup();
139
+ }
140
+ });
141
+
142
+ test("an EXPOSED + reachable box stays GREEN", async () => {
143
+ const h = makeHarness();
144
+ try {
145
+ seedCurrentManifest(h.manifestPath);
146
+ seedOperatorToken(h.configDir, "https://vault.example.com");
147
+ writeFileSync(
148
+ join(h.configDir, "expose-state.json"),
149
+ JSON.stringify({
150
+ version: 1,
151
+ layer: "public",
152
+ mode: "path",
153
+ canonicalFqdn: "vault.example.com",
154
+ port: 1939,
155
+ funnel: true,
156
+ entries: [],
157
+ hubOrigin: "https://vault.example.com",
158
+ }),
159
+ );
160
+ const { code, checks } = await runDoctor(
161
+ h,
162
+ healthyDeps({ probePublicHealth: async () => true }),
163
+ );
164
+ const nonPass = checks.filter((c) => c.status !== "pass");
165
+ expect(nonPass.map((c) => `${c.name}=${c.status}`)).toEqual([]);
166
+ expect(code).toBe(0);
167
+ } finally {
168
+ h.cleanup();
169
+ }
170
+ });
171
+ });
172
+
173
+ describe("doctor — failure modes (each detected in isolation; others stay green)", () => {
174
+ test("hub down → hub-reachable FAILs, exit 1, modules check WARNs (not N fails)", async () => {
175
+ const h = makeHarness();
176
+ try {
177
+ seedCurrentManifest(h.manifestPath);
178
+ seedOperatorToken(h.configDir);
179
+ const { code, checks } = await runDoctor(
180
+ h,
181
+ healthyDeps({
182
+ probeHubHealth: async () => false,
183
+ queryHubUnitState: () => ({ state: "inactive" }),
184
+ }),
185
+ );
186
+ expect(byName(checks, "hub-reachable")?.status).toBe("fail");
187
+ // A down hub → don't pile N module FAILs; surface one WARN pointing at the hub.
188
+ expect(byName(checks, "modules-alive")?.status).toBe("warn");
189
+ expect(code).toBe(1);
190
+ // Everything else stays green.
191
+ expectNoUnexpectedNonPass(checks, ["hub-reachable", "modules-alive"]);
192
+ } finally {
193
+ h.cleanup();
194
+ }
195
+ });
196
+
197
+ test("a configured module that doesn't answer /health on a healthy hub → that module FAILs", async () => {
198
+ const h = makeHarness();
199
+ try {
200
+ seedCurrentManifest(h.manifestPath);
201
+ seedOperatorToken(h.configDir);
202
+ const { code, checks } = await runDoctor(
203
+ h,
204
+ healthyDeps({ probeModuleHealth: async () => false }),
205
+ );
206
+ const mod = byName(checks, "module-alive:vault");
207
+ expect(mod?.status).toBe("fail");
208
+ expect(mod?.fix).toContain("parachute restart vault");
209
+ expect(code).toBe(1);
210
+ expectNoUnexpectedNonPass(checks, ["module-alive:vault"]);
211
+ } finally {
212
+ h.cleanup();
213
+ }
214
+ });
215
+
216
+ test("missing operator token → operator-token PASSES (feature-not-configured, NOT a failure)", async () => {
217
+ const h = makeHarness();
218
+ try {
219
+ seedCurrentManifest(h.manifestPath);
220
+ // No operator.token seeded.
221
+ const { code, checks } = await runDoctor(h, healthyDeps());
222
+ expect(byName(checks, "operator-token")?.status).toBe("pass");
223
+ expect(code).toBe(0);
224
+ } finally {
225
+ h.cleanup();
226
+ }
227
+ });
228
+
229
+ test("corrupt operator token (not a JWT) → operator-token FAILs, exit 1", async () => {
230
+ const h = makeHarness();
231
+ try {
232
+ seedCurrentManifest(h.manifestPath);
233
+ writeFileSync(join(h.configDir, "operator.token"), "not-a-jwt\n", { mode: 0o600 });
234
+ const { code, checks } = await runDoctor(h, healthyDeps());
235
+ expect(byName(checks, "operator-token")?.status).toBe("fail");
236
+ expect(byName(checks, "operator-token")?.detail).toContain("decodable");
237
+ expect(code).toBe(1);
238
+ expectNoUnexpectedNonPass(checks, ["operator-token"]);
239
+ } finally {
240
+ h.cleanup();
241
+ }
242
+ });
243
+
244
+ test("issuer mismatch (foreign iss) → operator-token FAILs with the 'not signed in' detail", async () => {
245
+ const h = makeHarness();
246
+ try {
247
+ seedCurrentManifest(h.manifestPath);
248
+ // An `iss` that is neither loopback nor any exposed/env origin.
249
+ seedOperatorToken(h.configDir, "https://stale.example.com");
250
+ const { code, checks } = await runDoctor(h, healthyDeps());
251
+ const op = byName(checks, "operator-token");
252
+ expect(op?.status).toBe("fail");
253
+ expect(op?.detail).toContain("not signed in");
254
+ expect(op?.fix).toContain("start hub");
255
+ expect(code).toBe(1);
256
+ expectNoUnexpectedNonPass(checks, ["operator-token"]);
257
+ } finally {
258
+ h.cleanup();
259
+ }
260
+ });
261
+
262
+ test("module bin missing the exec bit (100644) → module-bin FAILs with a chmod +x fix", async () => {
263
+ const h = makeHarness();
264
+ try {
265
+ seedCurrentManifest(h.manifestPath);
266
+ seedOperatorToken(h.configDir);
267
+ // `which` returns null (Bun.which requires X_OK → null on a 100644 bin),
268
+ // and the secondary probe finds the present-but-non-executable file.
269
+ const { code, checks } = await runDoctor(
270
+ h,
271
+ healthyDeps({
272
+ which: () => null,
273
+ findNonExecutable: (binary) => `/usr/local/bin/${binary}`,
274
+ }),
275
+ );
276
+ const bin = byName(checks, "module-bin:vault");
277
+ expect(bin?.status).toBe("fail");
278
+ expect(bin?.detail).toContain("NOT executable");
279
+ expect(bin?.fix).toContain("chmod +x");
280
+ expect(code).toBe(1);
281
+ expectNoUnexpectedNonPass(checks, ["module-bin:vault"]);
282
+ } finally {
283
+ h.cleanup();
284
+ }
285
+ });
286
+
287
+ test("module bin genuinely not on PATH → module-bin FAILs with a reinstall fix", async () => {
288
+ const h = makeHarness();
289
+ try {
290
+ seedCurrentManifest(h.manifestPath);
291
+ seedOperatorToken(h.configDir);
292
+ const { code, checks } = await runDoctor(
293
+ h,
294
+ healthyDeps({ which: () => null, findNonExecutable: () => null }),
295
+ );
296
+ const bin = byName(checks, "module-bin:vault");
297
+ expect(bin?.status).toBe("fail");
298
+ expect(bin?.fix).toContain("parachute install vault");
299
+ expect(code).toBe(1);
300
+ expectNoUnexpectedNonPass(checks, ["module-bin:vault"]);
301
+ } finally {
302
+ h.cleanup();
303
+ }
304
+ });
305
+
306
+ test("malformed services.json → services-manifest FAILs, exit 1", async () => {
307
+ const h = makeHarness();
308
+ try {
309
+ // A row missing the required `port` field → strict readManifest throws.
310
+ writeFileSync(
311
+ h.manifestPath,
312
+ JSON.stringify({
313
+ services: [{ name: "parachute-vault", paths: ["/v"], health: "/health", version: "1" }],
314
+ }),
315
+ );
316
+ seedOperatorToken(h.configDir);
317
+ const { code, checks } = await runDoctor(h, healthyDeps());
318
+ expect(byName(checks, "services-manifest")?.status).toBe("fail");
319
+ expect(code).toBe(1);
320
+ } finally {
321
+ h.cleanup();
322
+ }
323
+ });
324
+
325
+ test("legacy detached install (hub pidfile present) → migration WARNs, exit STAYS 0", async () => {
326
+ const h = makeHarness();
327
+ try {
328
+ seedCurrentManifest(h.manifestPath);
329
+ seedOperatorToken(h.configDir);
330
+ // The detached-era fingerprint: a hub pidfile.
331
+ mkdirSync(join(h.configDir, "hub", "run"), { recursive: true });
332
+ writePid("hub", 12345, h.configDir);
333
+ const { code, checks } = await runDoctor(h, healthyDeps());
334
+ expect(byName(checks, "migration-detached")?.status).toBe("warn");
335
+ expect(byName(checks, "migration-detached")?.fix).toContain("--to-supervised");
336
+ // Title must describe the DETECTED condition, not its absence — a warn
337
+ // titled "No legacy detached install" is the title-vs-status bug.
338
+ const detachedTitle = byName(checks, "migration-detached")?.title ?? "";
339
+ expect(detachedTitle.toLowerCase()).toContain("detached");
340
+ expect(detachedTitle).not.toMatch(/^no /i);
341
+ // A WARN is advisory — exit code stays 0.
342
+ expect(code).toBe(0);
343
+ expectNoUnexpectedNonPass(checks, ["migration-detached"]);
344
+ } finally {
345
+ h.cleanup();
346
+ }
347
+ });
348
+
349
+ test("known cruft at the ecosystem root → migration WARNs, exit STAYS 0", async () => {
350
+ const h = makeHarness();
351
+ try {
352
+ seedCurrentManifest(h.manifestPath);
353
+ seedOperatorToken(h.configDir);
354
+ // `server.yaml` is an explicit KNOWN_CRUFT rule (legacy server config).
355
+ writeFileSync(join(h.configDir, "server.yaml"), "legacy: true\n");
356
+ const { code, checks } = await runDoctor(h, healthyDeps());
357
+ expect(byName(checks, "migration-cruft")?.status).toBe("warn");
358
+ expect(byName(checks, "migration-cruft")?.fix).toBe("parachute migrate");
359
+ // Title must describe the DETECTED condition, not its absence.
360
+ const cruftTitle = byName(checks, "migration-cruft")?.title ?? "";
361
+ expect(cruftTitle.toLowerCase()).toContain("cruft");
362
+ expect(cruftTitle).not.toMatch(/^no /i);
363
+ expect(code).toBe(0);
364
+ expectNoUnexpectedNonPass(checks, ["migration-cruft"]);
365
+ } finally {
366
+ h.cleanup();
367
+ }
368
+ });
369
+
370
+ test("an UNKNOWN file at the root does NOT trip migration (allowlist, not blocklist)", async () => {
371
+ const h = makeHarness();
372
+ try {
373
+ seedCurrentManifest(h.manifestPath);
374
+ seedOperatorToken(h.configDir);
375
+ // A file doctor has never heard of — the exact thing #717 forbids flagging.
376
+ writeFileSync(join(h.configDir, "my-own-thing.json"), "{}\n");
377
+ const { code, checks } = await runDoctor(h, healthyDeps());
378
+ // Migration stays a single PASS — no false WARN on the unfamiliar file.
379
+ expect(byName(checks, "migration")?.status).toBe("pass");
380
+ expect(code).toBe(0);
381
+ } finally {
382
+ h.cleanup();
383
+ }
384
+ });
385
+ });
386
+
387
+ describe("doctor — Tier 2 exposure (guarded hard)", () => {
388
+ test("not exposed → 'loopback only' is benign info (PASS), not a warning", async () => {
389
+ const h = makeHarness();
390
+ try {
391
+ seedCurrentManifest(h.manifestPath);
392
+ seedOperatorToken(h.configDir);
393
+ // No expose-state.json — the loopback-only box.
394
+ const { code, checks } = await runDoctor(h, healthyDeps());
395
+ const ex = byName(checks, "exposure");
396
+ expect(ex?.status).toBe("pass");
397
+ expect(ex?.detail).toContain("loopback only");
398
+ expect(code).toBe(0);
399
+ } finally {
400
+ h.cleanup();
401
+ }
402
+ });
403
+
404
+ test("exposed but the public origin doesn't answer → WARN (never FAIL), exit STAYS 0", async () => {
405
+ const h = makeHarness();
406
+ try {
407
+ seedCurrentManifest(h.manifestPath);
408
+ seedOperatorToken(h.configDir, "https://vault.example.com");
409
+ writeFileSync(
410
+ join(h.configDir, "expose-state.json"),
411
+ JSON.stringify({
412
+ version: 1,
413
+ layer: "public",
414
+ mode: "path",
415
+ canonicalFqdn: "vault.example.com",
416
+ port: 1939,
417
+ funnel: true,
418
+ entries: [],
419
+ hubOrigin: "https://vault.example.com",
420
+ }),
421
+ );
422
+ const { code, checks } = await runDoctor(
423
+ h,
424
+ healthyDeps({ probePublicHealth: async () => false }),
425
+ );
426
+ expect(byName(checks, "exposure")?.status).toBe("warn");
427
+ expect(code).toBe(0);
428
+ expectNoUnexpectedNonPass(checks, ["exposure"]);
429
+ } finally {
430
+ h.cleanup();
431
+ }
432
+ });
433
+ });
434
+
435
+ describe("doctor — version drift (cosmetic; never FAIL)", () => {
436
+ test("a 0.0.0-linked stopgap version → WARN labeled cosmetic, exit STAYS 0", async () => {
437
+ const h = makeHarness();
438
+ try {
439
+ writeFileSync(
440
+ h.manifestPath,
441
+ JSON.stringify({
442
+ services: [
443
+ {
444
+ name: "parachute-vault",
445
+ port: 1940,
446
+ paths: ["/vault/default"],
447
+ health: "/health",
448
+ version: "0.0.0-linked",
449
+ },
450
+ ],
451
+ }),
452
+ );
453
+ seedOperatorToken(h.configDir);
454
+ const { code, checks } = await runDoctor(h, healthyDeps());
455
+ const vd = byName(checks, "version-drift");
456
+ expect(vd?.status).toBe("warn");
457
+ expect(vd?.detail).toContain("cosmetic");
458
+ expect(code).toBe(0);
459
+ expectNoUnexpectedNonPass(checks, ["version-drift"]);
460
+ } finally {
461
+ h.cleanup();
462
+ }
463
+ });
464
+ });
@@ -159,8 +159,15 @@ export async function handleAdminLock(
159
159
  case "/heartbeat":
160
160
  // Slide the idle window forward if (and only if) currently unlocked.
161
161
  refreshActivity(gate.sessionId, getIdleSeconds(db), now().getTime());
162
+ // `idle_seconds` is part of the response so the heartbeat fulfills the
163
+ // same `AdminLockStatus` shape as GET status — the client re-anchors its
164
+ // local idle timer from it on every heartbeat, so it MUST be present.
165
+ // Omitting it poisoned the client timer with `undefined` (→ NaN → instant
166
+ // re-lock), the bug this fixes. It also lets a live session pick up an
167
+ // idle-window change the operator made in Settings mid-session.
162
168
  return json(200, {
163
169
  locked: isLockConfigured(db) && !isSessionUnlocked(gate.sessionId, now().getTime()),
170
+ idle_seconds: getIdleSeconds(db),
164
171
  unlock_seconds_remaining: unlockSecondsRemaining(gate.sessionId, now().getTime()),
165
172
  });
166
173
  default:
package/src/cli.ts CHANGED
@@ -22,6 +22,7 @@ import type { setup } from "./commands/setup.ts";
22
22
  import type { upgrade } from "./commands/upgrade.ts";
23
23
  import { ExposeStateError } from "./expose-state.ts";
24
24
  import {
25
+ doctorHelp,
25
26
  exposeHelp,
26
27
  initHelp,
27
28
  installHelp,
@@ -574,6 +575,23 @@ async function main(argv: string[]): Promise<number> {
574
575
  return await mod.status({ supervisor: {} });
575
576
  }
576
577
 
578
+ case "doctor": {
579
+ if (isHelpFlag(rest[0])) {
580
+ console.log(doctorHelp());
581
+ return 0;
582
+ }
583
+ const json = rest.includes("--json");
584
+ const unknown = rest.find((a) => a !== "--json");
585
+ if (unknown !== undefined) {
586
+ console.error(`parachute doctor: unknown argument "${unknown}"`);
587
+ console.error("usage: parachute doctor [--json]");
588
+ return 1;
589
+ }
590
+ const mod = await loadCommand("doctor", () => import("./commands/doctor.ts"));
591
+ if (!mod) return 1;
592
+ return await mod.doctor({ json });
593
+ }
594
+
577
595
  case "expose": {
578
596
  const hubExtract = extractHubOrigin(rest);
579
597
  if (hubExtract.error) {