@openparachute/hub 0.6.2 → 0.6.3-rc.1

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.6.2",
3
+ "version": "0.6.3-rc.1",
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": {
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { MissingDependencyError, lookupDep } from "@openparachute/depcheck";
@@ -7,8 +7,11 @@ import {
7
7
  API_MODULES_OPS_REQUIRED_SCOPE,
8
8
  _resetOperationsRegistryForTests,
9
9
  handleInstall,
10
+ handleLogs,
10
11
  handleOperationGet,
11
12
  handleRestart,
13
+ handleStart,
14
+ handleStop,
12
15
  handleUninstall,
13
16
  handleUpgrade,
14
17
  parseModulesPath,
@@ -88,6 +91,11 @@ function makeIdleSupervisor(): {
88
91
  spawns: SpawnRequest[];
89
92
  } {
90
93
  const spawns: SpawnRequest[] = [];
94
+ // Track each spawned proc by pid so the injected group-aware `killFn` can
95
+ // forward the signal to the right fake (post-hub#88, `stop()` signals via
96
+ // `killFn`, not `proc.kill` — so without this seam the fake's `exited` never
97
+ // resolves and `stop`/`restart` time out).
98
+ const byPid = new Map<number, SupervisedProc & { kill: () => void }>();
91
99
  const spawnFn = (req: SpawnRequest): SupervisedProc => {
92
100
  spawns.push(req);
93
101
  // The fake's `exited` resolves when kill() is called, mirroring a
@@ -97,15 +105,22 @@ function makeIdleSupervisor(): {
97
105
  const exited = new Promise<number | null>((r) => {
98
106
  resolveExit = r;
99
107
  });
100
- return {
108
+ const proc: SupervisedProc & { kill: () => void } = {
101
109
  pid: 7777,
102
110
  exited,
103
111
  stdout: null,
104
112
  stderr: null,
105
113
  kill: () => resolveExit(0),
106
114
  };
115
+ byPid.set(proc.pid, proc);
116
+ return proc;
117
+ };
118
+ // Mirrors production's group-aware kill: the supervisor passes the leader
119
+ // pid, we forward to the matching fake's `kill` (which resolves `exited`).
120
+ const killFn = (pid: number): void => {
121
+ byPid.get(Math.abs(pid))?.kill();
107
122
  };
108
- return { supervisor: new Supervisor({ spawnFn }), spawns };
123
+ return { supervisor: new Supervisor({ spawnFn, killFn }), spawns };
109
124
  }
110
125
 
111
126
  function writeManifest(path: string, services: unknown[]): void {
@@ -152,6 +167,17 @@ describe("parseModulesPath", () => {
152
167
  });
153
168
  });
154
169
 
170
+ test("recognizes the Phase 1 start / stop verbs", () => {
171
+ expect(parseModulesPath("/api/modules/vault/start")).toEqual({
172
+ short: "vault",
173
+ rest: "start",
174
+ });
175
+ expect(parseModulesPath("/api/modules/scribe/stop")).toEqual({
176
+ short: "scribe",
177
+ rest: "stop",
178
+ });
179
+ });
180
+
155
181
  test("rejects non-curated shorts (no marketplace yet)", () => {
156
182
  // Channel exists in FIRST_PARTY_FALLBACKS but is exploration, not
157
183
  // in CURATED_MODULES — the v0.6 surface refuses to drive it via
@@ -740,6 +766,207 @@ describe("POST /api/modules/:short/install", () => {
740
766
  });
741
767
  });
742
768
 
769
+ describe("POST /api/modules/:short/start", () => {
770
+ let h: Harness;
771
+ beforeEach(async () => {
772
+ h = await makeHarness();
773
+ _resetOperationsRegistryForTests();
774
+ });
775
+ afterEach(() => h.cleanup());
776
+
777
+ /** Seed a minimal installed vault row (in services.json) for the start tests. */
778
+ function seedVault(port = 1940): void {
779
+ writeManifest(h.manifestPath, [
780
+ {
781
+ name: "parachute-vault",
782
+ port,
783
+ paths: ["/vault/default"],
784
+ health: "/vault/default/health",
785
+ version: "0.0.0-linked",
786
+ },
787
+ ]);
788
+ }
789
+
790
+ test("401 on missing bearer", async () => {
791
+ seedVault();
792
+ const { supervisor } = makeIdleSupervisor();
793
+ const res = await handleStart(postReq("/api/modules/vault/start", {}), "vault", {
794
+ db: h.db,
795
+ issuer: ISSUER,
796
+ manifestPath: h.manifestPath,
797
+ configDir: h.dir,
798
+ supervisor,
799
+ });
800
+ expect(res.status).toBe(401);
801
+ });
802
+
803
+ test("403 on bearer without parachute:host:admin (host-admin gated)", async () => {
804
+ seedVault();
805
+ const { supervisor } = makeIdleSupervisor();
806
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
807
+ const res = await handleStart(
808
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
809
+ "vault",
810
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
811
+ );
812
+ expect(res.status).toBe(403);
813
+ });
814
+
815
+ test("pure supervisor.start of an installed module — NOT install (no bun add)", async () => {
816
+ seedVault();
817
+ const { supervisor, spawns } = makeIdleSupervisor();
818
+ const { run, calls } = alwaysOkRun();
819
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
820
+ const res = await handleStart(
821
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
822
+ "vault",
823
+ {
824
+ db: h.db,
825
+ issuer: ISSUER,
826
+ manifestPath: h.manifestPath,
827
+ configDir: h.dir,
828
+ supervisor,
829
+ run,
830
+ },
831
+ );
832
+ expect(res.status).toBe(200);
833
+ const body = (await res.json()) as { short: string; state: { status: string } };
834
+ expect(body.short).toBe("vault");
835
+ expect(["starting", "running"]).toContain(body.state.status);
836
+ // The supervisor was handed the boot-derived spawn request.
837
+ expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
838
+ // Crucially: start is a PURE spawn — it must NOT run the install path.
839
+ expect(calls).toEqual([]);
840
+ });
841
+
842
+ test("start carries boot-derived PORT + PARACHUTE_HUB_ORIGIN child env", async () => {
843
+ seedVault(1940);
844
+ const { supervisor, spawns } = makeIdleSupervisor();
845
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
846
+ await handleStart(
847
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
848
+ "vault",
849
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
850
+ );
851
+ expect(spawns.length).toBe(1);
852
+ expect(spawns[0]?.env?.PORT).toBe("1940");
853
+ expect(spawns[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe(ISSUER);
854
+ });
855
+
856
+ test("start layers the per-service .env into the supervisor spawn env", async () => {
857
+ // The boot-derived spawn contract (buildModuleSpawnRequest) reads
858
+ // `<configDir>/<short>/.env` and merges it into the child env. This is
859
+ // the asymmetry the spawnSupervised doc comment calls out: install spawns
860
+ // with install-env only; the operator-written `.env` is layered in on the
861
+ // next `start` / boot. Prove a distinctive var written to vault's `.env`
862
+ // reaches the recorded SpawnRequest.
863
+ seedVault(1940);
864
+ mkdirSync(join(h.dir, "vault"), { recursive: true });
865
+ writeFileSync(join(h.dir, "vault", ".env"), "MY_CUSTOM_VAR=sentinel123\n");
866
+ const { supervisor, spawns } = makeIdleSupervisor();
867
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
868
+ await handleStart(
869
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
870
+ "vault",
871
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
872
+ );
873
+ expect(spawns.length).toBe(1);
874
+ expect(spawns[0]?.env?.MY_CUSTOM_VAR).toBe("sentinel123");
875
+ });
876
+
877
+ test("400 not_installed when the module isn't in services.json (no silent install)", async () => {
878
+ // No seedVault — services.json has no vault row.
879
+ const { supervisor, spawns } = makeIdleSupervisor();
880
+ const { run, calls } = alwaysOkRun();
881
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
882
+ const res = await handleStart(
883
+ postReq("/api/modules/vault/start", { authorization: `Bearer ${bearer}` }),
884
+ "vault",
885
+ {
886
+ db: h.db,
887
+ issuer: ISSUER,
888
+ manifestPath: h.manifestPath,
889
+ configDir: h.dir,
890
+ supervisor,
891
+ run,
892
+ },
893
+ );
894
+ expect(res.status).toBe(400);
895
+ const body = (await res.json()) as { error: string };
896
+ expect(body.error).toBe("not_installed");
897
+ // Did NOT spawn + did NOT install.
898
+ expect(spawns).toEqual([]);
899
+ expect(calls).toEqual([]);
900
+ });
901
+ });
902
+
903
+ describe("POST /api/modules/:short/stop", () => {
904
+ let h: Harness;
905
+ beforeEach(async () => {
906
+ h = await makeHarness();
907
+ _resetOperationsRegistryForTests();
908
+ });
909
+ afterEach(() => h.cleanup());
910
+
911
+ test("401 on missing bearer", async () => {
912
+ const { supervisor } = makeIdleSupervisor();
913
+ const res = await handleStop(postReq("/api/modules/vault/stop", {}), "vault", {
914
+ db: h.db,
915
+ issuer: ISSUER,
916
+ manifestPath: h.manifestPath,
917
+ configDir: h.dir,
918
+ supervisor,
919
+ });
920
+ expect(res.status).toBe(401);
921
+ });
922
+
923
+ test("403 on bearer without parachute:host:admin (host-admin gated)", async () => {
924
+ const { supervisor } = makeIdleSupervisor();
925
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
926
+ const res = await handleStop(
927
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
928
+ "vault",
929
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
930
+ );
931
+ expect(res.status).toBe(403);
932
+ });
933
+
934
+ test("calls supervisor.stop on a running module → stopped: true", async () => {
935
+ const { supervisor } = makeIdleSupervisor();
936
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
937
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
938
+ const res = await handleStop(
939
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
940
+ "vault",
941
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
942
+ );
943
+ expect(res.status).toBe(200);
944
+ const body = (await res.json()) as {
945
+ short: string;
946
+ stopped: boolean;
947
+ state: { status: string };
948
+ };
949
+ expect(body.short).toBe("vault");
950
+ expect(body.stopped).toBe(true);
951
+ expect(body.state.status).toBe("stopped");
952
+ // Supervisor truly transitioned the module to stopped.
953
+ expect(supervisor.get("vault")?.status).toBe("stopped");
954
+ });
955
+
956
+ test("idempotent: stopping a not-supervised module → stopped: false (no error)", async () => {
957
+ const { supervisor } = makeIdleSupervisor();
958
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
959
+ const res = await handleStop(
960
+ postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
961
+ "vault",
962
+ { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
963
+ );
964
+ expect(res.status).toBe(200);
965
+ const body = (await res.json()) as { short: string; stopped: boolean };
966
+ expect(body.stopped).toBe(false);
967
+ });
968
+ });
969
+
743
970
  describe("POST /api/modules/:short/restart", () => {
744
971
  let h: Harness;
745
972
  beforeEach(async () => {
@@ -1415,3 +1642,132 @@ describe("well-known regen after module ops", () => {
1415
1642
  expect(second).toBe(first);
1416
1643
  });
1417
1644
  });
1645
+
1646
+ describe("GET /api/modules/:short/logs (§6.5 ring-buffer tap)", () => {
1647
+ let h: Harness;
1648
+ beforeEach(async () => {
1649
+ h = await makeHarness();
1650
+ _resetOperationsRegistryForTests();
1651
+ });
1652
+ afterEach(() => h.cleanup());
1653
+
1654
+ /**
1655
+ * Supervisor whose child exposes a controllable stdout stream so the test
1656
+ * can push lines into the ring buffer, then tap them through the endpoint.
1657
+ */
1658
+ function makeEmittingSupervisor(): {
1659
+ supervisor: Supervisor;
1660
+ emit: (chunk: string) => void;
1661
+ } {
1662
+ let controller!: ReadableStreamDefaultController<Uint8Array>;
1663
+ const stdout = new ReadableStream<Uint8Array>({
1664
+ start(c) {
1665
+ controller = c;
1666
+ },
1667
+ });
1668
+ const enc = new TextEncoder();
1669
+ const spawnFn = (): SupervisedProc => ({
1670
+ pid: 4321,
1671
+ exited: new Promise<number | null>(() => {}),
1672
+ stdout,
1673
+ stderr: null,
1674
+ kill: () => {},
1675
+ });
1676
+ return {
1677
+ supervisor: new Supervisor({ spawnFn, killFn: () => {} }),
1678
+ emit: (chunk) => controller.enqueue(enc.encode(chunk)),
1679
+ };
1680
+ }
1681
+
1682
+ function logsDeps(supervisor: Supervisor) {
1683
+ return { db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor };
1684
+ }
1685
+
1686
+ test("401 on missing bearer", async () => {
1687
+ const { supervisor } = makeEmittingSupervisor();
1688
+ const res = await handleLogs(
1689
+ getReq("/api/modules/vault/logs", {}),
1690
+ "vault",
1691
+ logsDeps(supervisor),
1692
+ );
1693
+ expect(res.status).toBe(401);
1694
+ });
1695
+
1696
+ test("403 on bearer without parachute:host:admin (host-admin gated)", async () => {
1697
+ const { supervisor } = makeEmittingSupervisor();
1698
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
1699
+ const res = await handleLogs(
1700
+ getReq("/api/modules/vault/logs", { authorization: `Bearer ${bearer}` }),
1701
+ "vault",
1702
+ logsDeps(supervisor),
1703
+ );
1704
+ expect(res.status).toBe(403);
1705
+ });
1706
+
1707
+ test("405 on non-GET", async () => {
1708
+ const { supervisor } = makeEmittingSupervisor();
1709
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1710
+ const res = await handleLogs(
1711
+ postReq("/api/modules/vault/logs", { authorization: `Bearer ${bearer}` }),
1712
+ "vault",
1713
+ logsDeps(supervisor),
1714
+ );
1715
+ expect(res.status).toBe(405);
1716
+ });
1717
+
1718
+ test("404 not_supervised for a module that isn't supervised", async () => {
1719
+ const { supervisor } = makeEmittingSupervisor();
1720
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1721
+ const res = await handleLogs(
1722
+ getReq("/api/modules/vault/logs", { authorization: `Bearer ${bearer}` }),
1723
+ "vault",
1724
+ logsDeps(supervisor),
1725
+ );
1726
+ expect(res.status).toBe(404);
1727
+ const body = (await res.json()) as { error: string };
1728
+ expect(body.error).toBe("not_supervised");
1729
+ });
1730
+
1731
+ test("replays the ring buffer (lines emitted before the tap) as lines + text", async () => {
1732
+ const { supervisor, emit } = makeEmittingSupervisor();
1733
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
1734
+ // Output happens BEFORE the operator opens the logs view.
1735
+ emit("booting\n");
1736
+ emit("FATAL: boom\n");
1737
+ await new Promise((r) => setTimeout(r, 20));
1738
+
1739
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1740
+ const res = await handleLogs(
1741
+ getReq("/api/modules/vault/logs", { authorization: `Bearer ${bearer}` }),
1742
+ "vault",
1743
+ logsDeps(supervisor),
1744
+ );
1745
+ expect(res.status).toBe(200);
1746
+ const body = (await res.json()) as { short: string; lines: string[]; text: string };
1747
+ expect(body.short).toBe("vault");
1748
+ expect(body.lines).toEqual(["[vault] booting\n", "[vault] FATAL: boom\n"]);
1749
+ expect(body.text).toBe("[vault] booting\n[vault] FATAL: boom\n");
1750
+ });
1751
+
1752
+ test("?follow=1 streams the buffered replay first (text/plain)", async () => {
1753
+ const { supervisor, emit } = makeEmittingSupervisor();
1754
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
1755
+ emit("pre-connect line\n");
1756
+ await new Promise((r) => setTimeout(r, 20));
1757
+
1758
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
1759
+ const res = await handleLogs(
1760
+ getReq("/api/modules/vault/logs?follow=1", { authorization: `Bearer ${bearer}` }),
1761
+ "vault",
1762
+ logsDeps(supervisor),
1763
+ );
1764
+ expect(res.status).toBe(200);
1765
+ expect(res.headers.get("content-type")).toContain("text/plain");
1766
+ // Read the first chunk — it must contain the replayed buffer line.
1767
+ const reader = res.body!.getReader();
1768
+ const { value } = await reader.read();
1769
+ const text = new TextDecoder().decode(value);
1770
+ expect(text).toContain("[vault] pre-connect line\n");
1771
+ await reader.cancel();
1772
+ });
1773
+ });
@@ -325,6 +325,60 @@ describe("GET /api/modules", () => {
325
325
  expect(body.supervisor_available).toBe(true);
326
326
  });
327
327
 
328
+ test("projects the supervisor's structured startError onto supervisor_start_error (§6.4/#188)", async () => {
329
+ // The Phase 3c `parachute status` supervisor arm reads this field to show
330
+ // the SAME friendly missing-dependency note the detached path persists. A
331
+ // fake supervisor whose list() returns a `crashed` state carrying a
332
+ // structured `startError` exercises the projection.
333
+ writeManifest(h.manifestPath, [
334
+ {
335
+ name: "parachute-vault",
336
+ port: 1940,
337
+ paths: ["/vault/default"],
338
+ health: "/vault/default/health",
339
+ version: "0.4.5",
340
+ },
341
+ ]);
342
+ const fakeSupervisor = {
343
+ list: () => [
344
+ {
345
+ short: "vault",
346
+ status: "crashed" as const,
347
+ restartsInWindow: 1,
348
+ startError: {
349
+ error_type: "missing_dependency",
350
+ error_description: "parachute-vault is required",
351
+ binary: "parachute-vault",
352
+ at: "2026-06-01T00:00:00Z",
353
+ },
354
+ },
355
+ ],
356
+ } as unknown as Supervisor;
357
+
358
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
359
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
360
+ db: h.db,
361
+ issuer: ISSUER,
362
+ manifestPath: h.manifestPath,
363
+ supervisor: fakeSupervisor,
364
+ fetchLatestVersion: async () => null,
365
+ });
366
+ const body = (await res.json()) as {
367
+ modules: Array<{
368
+ short: string;
369
+ supervisor_status: string | null;
370
+ supervisor_start_error: { binary?: string; error_type?: string } | null;
371
+ }>;
372
+ };
373
+ const vault = body.modules.find((m) => m.short === "vault");
374
+ expect(vault?.supervisor_status).toBe("crashed");
375
+ expect(vault?.supervisor_start_error?.binary).toBe("parachute-vault");
376
+ expect(vault?.supervisor_start_error?.error_type).toBe("missing_dependency");
377
+ // A module with no supervisor entry surfaces null (uniform wire shape).
378
+ const scribe = body.modules.find((m) => m.short === "scribe");
379
+ expect(scribe?.supervisor_start_error).toBeNull();
380
+ });
381
+
328
382
  test("populates management_url from a relative managementUrl + module mount (hub#342)", async () => {
329
383
  // Vault declares `managementUrl: "/admin"` in its module.json — hub
330
384
  // resolves that against the entry's mount path (`/vault/default`)