@openparachute/hub 0.6.1 → 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 +1 -1
- package/src/__tests__/account-home-ui.test.ts +34 -0
- package/src/__tests__/api-modules-ops.test.ts +359 -3
- package/src/__tests__/api-modules.test.ts +54 -0
- package/src/__tests__/cloudflare-connector-service.test.ts +441 -0
- package/src/__tests__/expose-cloudflare.test.ts +272 -0
- package/src/__tests__/hub-unit.test.ts +574 -0
- package/src/__tests__/init.test.ts +219 -2
- package/src/__tests__/lifecycle.test.ts +423 -0
- package/src/__tests__/managed-unit.test.ts +575 -0
- package/src/__tests__/module-ops-client.test.ts +556 -0
- package/src/__tests__/port-probe.test.ts +23 -0
- package/src/__tests__/setup-wizard.test.ts +130 -0
- package/src/__tests__/status-supervisor.test.ts +569 -0
- package/src/__tests__/supervisor.test.ts +471 -6
- package/src/account-home-ui.ts +4 -1
- package/src/api-modules-ops.ts +221 -0
- package/src/api-modules.ts +18 -2
- package/src/cli.ts +14 -4
- package/src/cloudflare/connector-service.ts +273 -0
- package/src/cloudflare/state.ts +13 -1
- package/src/commands/expose-cloudflare.ts +143 -10
- package/src/commands/init.ts +225 -12
- package/src/commands/lifecycle.ts +366 -38
- package/src/commands/serve-boot.ts +71 -25
- package/src/commands/status.ts +596 -49
- package/src/hub-server.ts +11 -0
- package/src/hub-unit.ts +735 -0
- package/src/managed-unit.ts +674 -0
- package/src/module-ops-client.ts +457 -0
- package/src/port-probe.ts +50 -0
- package/src/setup-wizard.ts +80 -1
- package/src/supervisor.ts +360 -14
package/package.json
CHANGED
|
@@ -74,6 +74,40 @@ describe("renderAccountHome", () => {
|
|
|
74
74
|
expect(html).toContain('data-testid="connect-any-client-hint"');
|
|
75
75
|
// Notes CTA still present, now framed as the browser-UI option.
|
|
76
76
|
expect(html).toContain('data-testid="open-notes-cta"');
|
|
77
|
+
// Import-notes CTA deep-links to the Notes-UI /import route for the same
|
|
78
|
+
// vault, mirroring the Open-Notes target-resolution (same hosted origin,
|
|
79
|
+
// same `?url=${hubOrigin}/vault/<name>` vault-targeting param).
|
|
80
|
+
expect(html).toContain(`https://notes.parachute.computer/import?url=${encodedVaultUrl}`);
|
|
81
|
+
expect(html).toContain('data-testid="import-notes-cta"');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("assigned-vault branch — import-notes CTA gated alongside open-notes (no dead link)", () => {
|
|
85
|
+
// Both CTAs render together for an assigned vault and are absent together
|
|
86
|
+
// in the no-vault branches — so we never surface an Import link that points
|
|
87
|
+
// at a vault the user can't reach.
|
|
88
|
+
const html = renderAccountHome({
|
|
89
|
+
username: "alice",
|
|
90
|
+
assignedVaults: ["alice"],
|
|
91
|
+
passwordChanged: true,
|
|
92
|
+
hubOrigin: HUB_ORIGIN,
|
|
93
|
+
isFirstAdmin: false,
|
|
94
|
+
csrfToken: CSRF,
|
|
95
|
+
twoFactorEnabled: false,
|
|
96
|
+
});
|
|
97
|
+
expect(html).toContain('data-testid="open-notes-cta"');
|
|
98
|
+
expect(html).toContain('data-testid="import-notes-cta"');
|
|
99
|
+
|
|
100
|
+
const adminHtml = renderAccountHome({
|
|
101
|
+
username: "admin",
|
|
102
|
+
assignedVaults: [],
|
|
103
|
+
passwordChanged: true,
|
|
104
|
+
hubOrigin: HUB_ORIGIN,
|
|
105
|
+
isFirstAdmin: true,
|
|
106
|
+
csrfToken: CSRF,
|
|
107
|
+
twoFactorEnabled: false,
|
|
108
|
+
});
|
|
109
|
+
expect(adminHtml).not.toContain('data-testid="import-notes-cta"');
|
|
110
|
+
expect(adminHtml).not.toContain("notes.parachute.computer/import");
|
|
77
111
|
});
|
|
78
112
|
|
|
79
113
|
test("assigned-vault branch — trailing slash on hubOrigin is normalized", () => {
|
|
@@ -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
|
-
|
|
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`)
|