@openparachute/hub 0.5.7 → 0.5.10-rc.10

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 (85) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +70 -323
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-modules-ops.test.ts +658 -0
  8. package/src/__tests__/api-modules.test.ts +426 -0
  9. package/src/__tests__/api-revocation-list.test.ts +198 -0
  10. package/src/__tests__/api-revoke-token.test.ts +320 -0
  11. package/src/__tests__/api-tokens.test.ts +629 -0
  12. package/src/__tests__/auth.test.ts +680 -16
  13. package/src/__tests__/csrf.test.ts +40 -1
  14. package/src/__tests__/expose-2fa-warning.test.ts +3 -5
  15. package/src/__tests__/expose-cloudflare.test.ts +1 -1
  16. package/src/__tests__/expose.test.ts +2 -2
  17. package/src/__tests__/hub-server.test.ts +584 -67
  18. package/src/__tests__/hub-settings.test.ts +377 -0
  19. package/src/__tests__/hub.test.ts +123 -53
  20. package/src/__tests__/install-source.test.ts +249 -0
  21. package/src/__tests__/jwt-sign.test.ts +205 -0
  22. package/src/__tests__/module-manifest.test.ts +48 -0
  23. package/src/__tests__/oauth-handlers.test.ts +522 -5
  24. package/src/__tests__/operator-token.test.ts +427 -3
  25. package/src/__tests__/origin-check.test.ts +220 -0
  26. package/src/__tests__/request-protocol.test.ts +54 -0
  27. package/src/__tests__/serve-boot.test.ts +193 -0
  28. package/src/__tests__/serve.test.ts +100 -0
  29. package/src/__tests__/sessions.test.ts +25 -2
  30. package/src/__tests__/setup-gate.test.ts +222 -0
  31. package/src/__tests__/setup-wizard.test.ts +2089 -0
  32. package/src/__tests__/status.test.ts +199 -0
  33. package/src/__tests__/supervisor.test.ts +482 -0
  34. package/src/__tests__/upgrade.test.ts +247 -4
  35. package/src/__tests__/vault-name.test.ts +79 -0
  36. package/src/__tests__/well-known.test.ts +69 -0
  37. package/src/admin-clients.ts +139 -0
  38. package/src/admin-handlers.ts +37 -254
  39. package/src/admin-host-admin-token.ts +25 -10
  40. package/src/admin-login-ui.ts +256 -0
  41. package/src/admin-vault-admin-token.ts +1 -1
  42. package/src/api-me.ts +124 -0
  43. package/src/api-mint-token.ts +239 -0
  44. package/src/api-modules-ops.ts +585 -0
  45. package/src/api-modules.ts +367 -0
  46. package/src/api-revocation-list.ts +59 -0
  47. package/src/api-revoke-token.ts +153 -0
  48. package/src/api-tokens.ts +224 -0
  49. package/src/cli.ts +28 -0
  50. package/src/commands/auth.ts +408 -51
  51. package/src/commands/expose-2fa-warning.ts +6 -6
  52. package/src/commands/serve-boot.ts +133 -0
  53. package/src/commands/serve.ts +214 -0
  54. package/src/commands/status.ts +74 -10
  55. package/src/commands/upgrade.ts +33 -6
  56. package/src/csrf.ts +34 -13
  57. package/src/help.ts +55 -5
  58. package/src/hub-control.ts +1 -0
  59. package/src/hub-db.ts +87 -0
  60. package/src/hub-server.ts +767 -136
  61. package/src/hub-settings.ts +259 -0
  62. package/src/hub.ts +298 -150
  63. package/src/install-source.ts +291 -0
  64. package/src/jwt-sign.ts +265 -5
  65. package/src/module-manifest.ts +48 -10
  66. package/src/oauth-handlers.ts +262 -56
  67. package/src/oauth-ui.ts +23 -2
  68. package/src/operator-token.ts +349 -18
  69. package/src/origin-check.ts +127 -0
  70. package/src/rate-limit.ts +5 -2
  71. package/src/request-protocol.ts +48 -0
  72. package/src/scope-explanations.ts +33 -2
  73. package/src/sessions.ts +30 -18
  74. package/src/setup-wizard.ts +2009 -0
  75. package/src/supervisor.ts +411 -0
  76. package/src/vault-name.ts +71 -0
  77. package/src/well-known.ts +54 -1
  78. package/web/ui/dist/assets/index-BDSEsaBY.css +1 -0
  79. package/web/ui/dist/assets/index-CP07NbdF.js +61 -0
  80. package/web/ui/dist/index.html +2 -2
  81. package/src/__tests__/admin-config.test.ts +0 -281
  82. package/src/admin-config-ui.ts +0 -534
  83. package/src/admin-config.ts +0 -226
  84. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  85. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,658 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ API_MODULES_OPS_REQUIRED_SCOPE,
7
+ _resetOperationsRegistryForTests,
8
+ handleInstall,
9
+ handleOperationGet,
10
+ handleRestart,
11
+ handleUninstall,
12
+ handleUpgrade,
13
+ parseModulesPath,
14
+ } from "../api-modules-ops.ts";
15
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
16
+ import { setModuleInstallChannel } from "../hub-settings.ts";
17
+ import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
18
+ import { rotateSigningKey } from "../signing-keys.ts";
19
+ import { type SpawnRequest, type SupervisedProc, Supervisor } from "../supervisor.ts";
20
+ import { createUser } from "../users.ts";
21
+
22
+ const ISSUER = "http://127.0.0.1:1939";
23
+
24
+ interface Harness {
25
+ dir: string;
26
+ manifestPath: string;
27
+ db: ReturnType<typeof openHubDb>;
28
+ userId: string;
29
+ cleanup: () => void;
30
+ }
31
+
32
+ async function makeHarness(): Promise<Harness> {
33
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-modules-ops-"));
34
+ const db = openHubDb(hubDbPath(dir));
35
+ rotateSigningKey(db);
36
+ const user = await createUser(db, "owner", "pw");
37
+ return {
38
+ dir,
39
+ manifestPath: join(dir, "services.json"),
40
+ db,
41
+ userId: user.id,
42
+ cleanup: () => {
43
+ db.close();
44
+ rmSync(dir, { recursive: true, force: true });
45
+ },
46
+ };
47
+ }
48
+
49
+ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
50
+ const signed = await signAccessToken(h.db, {
51
+ sub: h.userId,
52
+ scopes,
53
+ audience: "parachute-hub",
54
+ clientId: "parachute-hub",
55
+ issuer: ISSUER,
56
+ ttlSeconds: 3600,
57
+ });
58
+ recordTokenMint(h.db, {
59
+ jti: signed.jti,
60
+ createdVia: "operator_mint",
61
+ subject: h.userId,
62
+ clientId: "parachute-hub",
63
+ scopes,
64
+ expiresAt: signed.expiresAt,
65
+ });
66
+ return signed.token;
67
+ }
68
+
69
+ function postReq(path: string, headers: Record<string, string>): Request {
70
+ return new Request(`http://localhost${path}`, { method: "POST", headers });
71
+ }
72
+
73
+ function getReq(path: string, headers: Record<string, string>): Request {
74
+ return new Request(`http://localhost${path}`, { method: "GET", headers });
75
+ }
76
+
77
+ function makeIdleSupervisor(): {
78
+ supervisor: Supervisor;
79
+ spawns: SpawnRequest[];
80
+ } {
81
+ const spawns: SpawnRequest[] = [];
82
+ const spawnFn = (req: SpawnRequest): SupervisedProc => {
83
+ spawns.push(req);
84
+ // The fake's `exited` resolves when kill() is called, mirroring a
85
+ // well-behaved child that exits on SIGTERM. Without this, the
86
+ // supervisor's `restart()` awaits forever after the stop signal.
87
+ let resolveExit!: (c: number | null) => void;
88
+ const exited = new Promise<number | null>((r) => {
89
+ resolveExit = r;
90
+ });
91
+ return {
92
+ pid: 7777,
93
+ exited,
94
+ stdout: null,
95
+ stderr: null,
96
+ kill: () => resolveExit(0),
97
+ };
98
+ };
99
+ return { supervisor: new Supervisor({ spawnFn }), spawns };
100
+ }
101
+
102
+ function writeManifest(path: string, services: unknown[]): void {
103
+ writeFileSync(path, JSON.stringify({ services }));
104
+ }
105
+
106
+ /** Run a no-op shell — production calls `bun add`/`bun remove`; tests don't. */
107
+ function alwaysOkRun(): {
108
+ run: (cmd: readonly string[]) => Promise<number>;
109
+ calls: string[][];
110
+ } {
111
+ const calls: string[][] = [];
112
+ return {
113
+ calls,
114
+ run: async (cmd) => {
115
+ calls.push([...cmd]);
116
+ return 0;
117
+ },
118
+ };
119
+ }
120
+
121
+ describe("parseModulesPath", () => {
122
+ test("recognizes curated short + action", () => {
123
+ expect(parseModulesPath("/api/modules/vault/install")).toEqual({
124
+ short: "vault",
125
+ rest: "install",
126
+ });
127
+ expect(parseModulesPath("/api/modules/scribe/upgrade")).toEqual({
128
+ short: "scribe",
129
+ rest: "upgrade",
130
+ });
131
+ });
132
+
133
+ test("rejects non-curated shorts (no marketplace yet)", () => {
134
+ // Channel exists in FIRST_PARTY_FALLBACKS but is exploration, not
135
+ // in CURATED_MODULES — the v0.6 surface refuses to drive it via
136
+ // /api/modules.
137
+ expect(parseModulesPath("/api/modules/channel/install")).toBeUndefined();
138
+ expect(parseModulesPath("/api/modules/random/install")).toBeUndefined();
139
+ });
140
+
141
+ test("rejects malformed paths", () => {
142
+ expect(parseModulesPath("/api/modules/")).toBeUndefined();
143
+ expect(parseModulesPath("/api/modules/vault")).toBeUndefined();
144
+ expect(parseModulesPath("/something/else")).toBeUndefined();
145
+ });
146
+ });
147
+
148
+ describe("POST /api/modules/:short/install", () => {
149
+ let h: Harness;
150
+ beforeEach(async () => {
151
+ h = await makeHarness();
152
+ _resetOperationsRegistryForTests();
153
+ });
154
+ afterEach(() => h.cleanup());
155
+
156
+ test("returns 401 on missing bearer", async () => {
157
+ const { supervisor } = makeIdleSupervisor();
158
+ const res = await handleInstall(postReq("/api/modules/vault/install", {}), "vault", {
159
+ db: h.db,
160
+ issuer: ISSUER,
161
+ manifestPath: h.manifestPath,
162
+ configDir: h.dir,
163
+ supervisor,
164
+ run: async () => 0,
165
+ });
166
+ expect(res.status).toBe(401);
167
+ });
168
+
169
+ test("returns 403 on bearer without parachute:host:admin scope", async () => {
170
+ const { supervisor } = makeIdleSupervisor();
171
+ const bearer = await mintBearer(h, ["scribe:transcribe"]);
172
+ const res = await handleInstall(
173
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
174
+ "vault",
175
+ {
176
+ db: h.db,
177
+ issuer: ISSUER,
178
+ manifestPath: h.manifestPath,
179
+ configDir: h.dir,
180
+ supervisor,
181
+ run: async () => 0,
182
+ },
183
+ );
184
+ expect(res.status).toBe(403);
185
+ });
186
+
187
+ test("returns 403 on bearer with only :host:auth (not :host:admin) — destructive ops elevated", async () => {
188
+ // `:host:auth` is the read-only catalog scope (`GET /api/modules`).
189
+ // Destructive POSTs are admin-only. Mint a token that carries
190
+ // *only* `:auth` and confirm install is refused — the boundary
191
+ // that keeps automation callers from uninstalling vault.
192
+ const { supervisor } = makeIdleSupervisor();
193
+ const bearer = await mintBearer(h, ["parachute:host:auth"]);
194
+ const res = await handleInstall(
195
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
196
+ "vault",
197
+ {
198
+ db: h.db,
199
+ issuer: ISSUER,
200
+ manifestPath: h.manifestPath,
201
+ configDir: h.dir,
202
+ supervisor,
203
+ run: async () => 0,
204
+ },
205
+ );
206
+ expect(res.status).toBe(403);
207
+ const body = (await res.json()) as { error: string; error_description: string };
208
+ expect(body.error).toBe("insufficient_scope");
209
+ expect(body.error_description).toContain("parachute:host:admin");
210
+ });
211
+
212
+ test("202 + operation_id, runs bun add + seeds services.json + spawns", async () => {
213
+ const { supervisor, spawns } = makeIdleSupervisor();
214
+ const { run, calls } = alwaysOkRun();
215
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
216
+ const res = await handleInstall(
217
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
218
+ "vault",
219
+ {
220
+ db: h.db,
221
+ issuer: ISSUER,
222
+ manifestPath: h.manifestPath,
223
+ configDir: h.dir,
224
+ supervisor,
225
+ run,
226
+ },
227
+ );
228
+ expect(res.status).toBe(202);
229
+ const body = (await res.json()) as { operation_id: string };
230
+ expect(body.operation_id).toBeDefined();
231
+
232
+ // Wait a microtask for the async install to settle. The
233
+ // alwaysOkRun returns immediately, so the chain
234
+ // bun-add → seed → spawn happens within one microtask
235
+ // batch — give it two ticks to be safe.
236
+ await new Promise((r) => setTimeout(r, 10));
237
+
238
+ // `bun add` was called with the @latest spec.
239
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
240
+ // services.json now has the vault row (the seed-on-missing path).
241
+ const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
242
+ services: Array<{ name: string }>;
243
+ };
244
+ expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(true);
245
+ // Supervisor was handed the spawn.
246
+ expect(spawns.find((s) => s.short === "vault")?.cmd).toEqual(["parachute-vault", "serve"]);
247
+ });
248
+
249
+ test("idempotent: already-installed + running returns succeeded immediately", async () => {
250
+ // Pre-seed services.json + supervisor state.
251
+ writeManifest(h.manifestPath, [
252
+ {
253
+ name: "parachute-vault",
254
+ port: 1940,
255
+ paths: ["/vault/default"],
256
+ health: "/vault/default/health",
257
+ version: "0.4.5",
258
+ },
259
+ ]);
260
+ const { supervisor } = makeIdleSupervisor();
261
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
262
+
263
+ const { run, calls } = alwaysOkRun();
264
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
265
+ const res = await handleInstall(
266
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
267
+ "vault",
268
+ {
269
+ db: h.db,
270
+ issuer: ISSUER,
271
+ manifestPath: h.manifestPath,
272
+ configDir: h.dir,
273
+ supervisor,
274
+ run,
275
+ },
276
+ );
277
+ expect(res.status).toBe(202);
278
+ const body = (await res.json()) as { operation_id: string };
279
+
280
+ // bun add was NOT called — short-circuit hit.
281
+ expect(calls).toEqual([]);
282
+
283
+ // The operation record is already in succeeded state.
284
+ const opRes = await handleOperationGet(
285
+ getReq(`/api/modules/operations/${body.operation_id}`, {
286
+ authorization: `Bearer ${bearer}`,
287
+ }),
288
+ body.operation_id,
289
+ {
290
+ db: h.db,
291
+ issuer: ISSUER,
292
+ manifestPath: h.manifestPath,
293
+ configDir: h.dir,
294
+ supervisor,
295
+ run,
296
+ },
297
+ );
298
+ const op = (await opRes.json()) as { status: string };
299
+ expect(op.status).toBe("succeeded");
300
+ });
301
+
302
+ test("uses the rc channel when hub_settings.module_install_channel = rc (hub#275)", async () => {
303
+ // Operator's set the channel via the SPA toggle / env var bootstrap;
304
+ // the next install must construct `<pkg>@rc` rather than `<pkg>@latest`.
305
+ setModuleInstallChannel(h.db, "rc");
306
+ const { supervisor } = makeIdleSupervisor();
307
+ const { run, calls } = alwaysOkRun();
308
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
309
+ const res = await handleInstall(
310
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
311
+ "vault",
312
+ {
313
+ db: h.db,
314
+ issuer: ISSUER,
315
+ manifestPath: h.manifestPath,
316
+ configDir: h.dir,
317
+ supervisor,
318
+ run,
319
+ },
320
+ );
321
+ expect(res.status).toBe(202);
322
+ await new Promise((r) => setTimeout(r, 10));
323
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
324
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
325
+ });
326
+
327
+ test("toggling channel back to latest takes effect on next install (no restart)", async () => {
328
+ setModuleInstallChannel(h.db, "rc");
329
+ setModuleInstallChannel(h.db, "latest");
330
+ const { supervisor } = makeIdleSupervisor();
331
+ const { run, calls } = alwaysOkRun();
332
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
333
+ await handleInstall(
334
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
335
+ "vault",
336
+ {
337
+ db: h.db,
338
+ issuer: ISSUER,
339
+ manifestPath: h.manifestPath,
340
+ configDir: h.dir,
341
+ supervisor,
342
+ run,
343
+ },
344
+ );
345
+ await new Promise((r) => setTimeout(r, 10));
346
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
347
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
348
+ });
349
+
350
+ test("failed bun-add surfaces failed status on the operation", async () => {
351
+ const { supervisor } = makeIdleSupervisor();
352
+ // Run returns 1 + findGlobalInstall returns null = real failure.
353
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
354
+ const deps = {
355
+ db: h.db,
356
+ issuer: ISSUER,
357
+ manifestPath: h.manifestPath,
358
+ configDir: h.dir,
359
+ supervisor,
360
+ run: async () => 1,
361
+ findGlobalInstall: () => null,
362
+ };
363
+ const res = await handleInstall(
364
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
365
+ "vault",
366
+ deps,
367
+ );
368
+ const body = (await res.json()) as { operation_id: string };
369
+ await new Promise((r) => setTimeout(r, 10));
370
+ const opRes = await handleOperationGet(
371
+ getReq(`/api/modules/operations/${body.operation_id}`, {
372
+ authorization: `Bearer ${bearer}`,
373
+ }),
374
+ body.operation_id,
375
+ deps,
376
+ );
377
+ const op = (await opRes.json()) as { status: string; error?: string };
378
+ expect(op.status).toBe("failed");
379
+ expect(op.error).toMatch(/bun add -g exited 1/);
380
+ });
381
+ });
382
+
383
+ describe("POST /api/modules/:short/restart", () => {
384
+ let h: Harness;
385
+ beforeEach(async () => {
386
+ h = await makeHarness();
387
+ _resetOperationsRegistryForTests();
388
+ });
389
+ afterEach(() => h.cleanup());
390
+
391
+ test("404 not_supervised when module isn't running", async () => {
392
+ const { supervisor } = makeIdleSupervisor();
393
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
394
+ const res = await handleRestart(
395
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
396
+ "vault",
397
+ {
398
+ db: h.db,
399
+ issuer: ISSUER,
400
+ manifestPath: h.manifestPath,
401
+ configDir: h.dir,
402
+ supervisor,
403
+ run: async () => 0,
404
+ },
405
+ );
406
+ expect(res.status).toBe(404);
407
+ const body = (await res.json()) as { error: string };
408
+ expect(body.error).toBe("not_supervised");
409
+ });
410
+
411
+ test("returns new state on success", async () => {
412
+ const { supervisor } = makeIdleSupervisor();
413
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
414
+
415
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
416
+ const res = await handleRestart(
417
+ postReq("/api/modules/vault/restart", { authorization: `Bearer ${bearer}` }),
418
+ "vault",
419
+ {
420
+ db: h.db,
421
+ issuer: ISSUER,
422
+ manifestPath: h.manifestPath,
423
+ configDir: h.dir,
424
+ supervisor,
425
+ run: async () => 0,
426
+ },
427
+ );
428
+ expect(res.status).toBe(200);
429
+ const body = (await res.json()) as { short: string; state: { status: string } };
430
+ expect(body.short).toBe("vault");
431
+ // restart sets the state to either restarting or running depending
432
+ // on timing — either is acceptable here as long as it's not crashed/stopped.
433
+ expect(["restarting", "running", "starting"]).toContain(body.state.status);
434
+ });
435
+ });
436
+
437
+ describe("POST /api/modules/:short/upgrade", () => {
438
+ let h: Harness;
439
+ beforeEach(async () => {
440
+ h = await makeHarness();
441
+ _resetOperationsRegistryForTests();
442
+ });
443
+ afterEach(() => h.cleanup());
444
+
445
+ test("202 + bun add @latest + restart on already-running module", async () => {
446
+ const { supervisor } = makeIdleSupervisor();
447
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
448
+
449
+ const { run, calls } = alwaysOkRun();
450
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
451
+ const res = await handleUpgrade(
452
+ postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
453
+ "vault",
454
+ {
455
+ db: h.db,
456
+ issuer: ISSUER,
457
+ manifestPath: h.manifestPath,
458
+ configDir: h.dir,
459
+ supervisor,
460
+ run,
461
+ },
462
+ );
463
+ expect(res.status).toBe(202);
464
+ await new Promise((r) => setTimeout(r, 10));
465
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
466
+ });
467
+
468
+ test("uses the rc channel when hub_settings.module_install_channel = rc (hub#275)", async () => {
469
+ setModuleInstallChannel(h.db, "rc");
470
+ const { supervisor } = makeIdleSupervisor();
471
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
472
+
473
+ const { run, calls } = alwaysOkRun();
474
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
475
+ const res = await handleUpgrade(
476
+ postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
477
+ "vault",
478
+ {
479
+ db: h.db,
480
+ issuer: ISSUER,
481
+ manifestPath: h.manifestPath,
482
+ configDir: h.dir,
483
+ supervisor,
484
+ run,
485
+ },
486
+ );
487
+ expect(res.status).toBe(202);
488
+ await new Promise((r) => setTimeout(r, 10));
489
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
490
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
491
+ });
492
+
493
+ test("fails with 'try install first' when module is installed but never supervised", async () => {
494
+ // Module has a services.json row (e.g. seeded by `parachute install`
495
+ // pre-supervisor era) but the supervisor never spawned it.
496
+ // `bun add -g` succeeds, then `supervisor.restart()` returns
497
+ // undefined because there's no entry in the Map. The operation
498
+ // should land in `failed` with the canonical "try install first"
499
+ // message rather than silently succeed (hub#265).
500
+ writeManifest(h.manifestPath, [
501
+ {
502
+ name: "parachute-vault",
503
+ port: 1940,
504
+ paths: ["/vault/default"],
505
+ health: "/vault/default/health",
506
+ version: "0.4.5",
507
+ },
508
+ ]);
509
+ const { supervisor, spawns } = makeIdleSupervisor();
510
+ // Intentionally do NOT call supervisor.start(...) — that's the
511
+ // path under test.
512
+
513
+ const { run, calls } = alwaysOkRun();
514
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
515
+ const deps = {
516
+ db: h.db,
517
+ issuer: ISSUER,
518
+ manifestPath: h.manifestPath,
519
+ configDir: h.dir,
520
+ supervisor,
521
+ run,
522
+ };
523
+ const res = await handleUpgrade(
524
+ postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
525
+ "vault",
526
+ deps,
527
+ );
528
+ expect(res.status).toBe(202);
529
+ const body = (await res.json()) as { operation_id: string };
530
+ // Give the async runUpgrade chain a tick to settle.
531
+ await new Promise((r) => setTimeout(r, 10));
532
+
533
+ // bun add was still attempted (it's the first step).
534
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
535
+ // No supervisor spawn ever happened — confirms the missing
536
+ // supervisor entry is what we exercised, not some other branch.
537
+ expect(spawns).toEqual([]);
538
+
539
+ // Poll the operation: status `failed`, message points the
540
+ // operator at the install path.
541
+ const opRes = await handleOperationGet(
542
+ getReq(`/api/modules/operations/${body.operation_id}`, {
543
+ authorization: `Bearer ${bearer}`,
544
+ }),
545
+ body.operation_id,
546
+ deps,
547
+ );
548
+ const op = (await opRes.json()) as {
549
+ status: string;
550
+ error?: string;
551
+ log: string[];
552
+ };
553
+ expect(op.status).toBe("failed");
554
+ expect(op.error).toMatch(/supervisor restart found no module/);
555
+ expect(op.log.join(" ")).toMatch(/try install first/);
556
+ });
557
+ });
558
+
559
+ describe("POST /api/modules/:short/uninstall", () => {
560
+ let h: Harness;
561
+ beforeEach(async () => {
562
+ h = await makeHarness();
563
+ _resetOperationsRegistryForTests();
564
+ });
565
+ afterEach(() => h.cleanup());
566
+
567
+ test("stops child + removes services.json row + runs bun remove", async () => {
568
+ writeManifest(h.manifestPath, [
569
+ {
570
+ name: "parachute-vault",
571
+ port: 1940,
572
+ paths: ["/vault/default"],
573
+ health: "/vault/default/health",
574
+ version: "0.4.5",
575
+ },
576
+ ]);
577
+ const { supervisor } = makeIdleSupervisor();
578
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
579
+
580
+ const { run, calls } = alwaysOkRun();
581
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
582
+ const res = await handleUninstall(
583
+ postReq("/api/modules/vault/uninstall", { authorization: `Bearer ${bearer}` }),
584
+ "vault",
585
+ {
586
+ db: h.db,
587
+ issuer: ISSUER,
588
+ manifestPath: h.manifestPath,
589
+ configDir: h.dir,
590
+ supervisor,
591
+ run,
592
+ },
593
+ );
594
+ expect(res.status).toBe(200);
595
+ const body = (await res.json()) as { short: string; log: string[] };
596
+ expect(body.short).toBe("vault");
597
+ // The log captures each step's outcome.
598
+ expect(body.log.join(" ")).toMatch(/supervisor stopped/);
599
+ expect(body.log.join(" ")).toMatch(/removed parachute-vault from services.json/);
600
+
601
+ // services.json row is gone.
602
+ const manifest = JSON.parse(readFileSync(h.manifestPath, "utf8")) as {
603
+ services: Array<{ name: string }>;
604
+ };
605
+ expect(manifest.services.some((s) => s.name === "parachute-vault")).toBe(false);
606
+ // bun remove was called.
607
+ expect(calls).toContainEqual(["bun", "remove", "-g", "@openparachute/vault"]);
608
+ });
609
+
610
+ test("idempotent on never-installed module", async () => {
611
+ const { supervisor } = makeIdleSupervisor();
612
+ const { run } = alwaysOkRun();
613
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
614
+ const res = await handleUninstall(
615
+ postReq("/api/modules/vault/uninstall", { authorization: `Bearer ${bearer}` }),
616
+ "vault",
617
+ {
618
+ db: h.db,
619
+ issuer: ISSUER,
620
+ manifestPath: h.manifestPath,
621
+ configDir: h.dir,
622
+ supervisor,
623
+ run,
624
+ },
625
+ );
626
+ expect(res.status).toBe(200);
627
+ const body = (await res.json()) as { log: string[] };
628
+ expect(body.log.join(" ")).toMatch(/not supervised/);
629
+ expect(body.log.join(" ")).toMatch(/not in services.json/);
630
+ });
631
+ });
632
+
633
+ describe("GET /api/modules/operations/:id", () => {
634
+ let h: Harness;
635
+ beforeEach(async () => {
636
+ h = await makeHarness();
637
+ _resetOperationsRegistryForTests();
638
+ });
639
+ afterEach(() => h.cleanup());
640
+
641
+ test("404 on unknown id", async () => {
642
+ const { supervisor } = makeIdleSupervisor();
643
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
644
+ const res = await handleOperationGet(
645
+ getReq("/api/modules/operations/no-such-id", { authorization: `Bearer ${bearer}` }),
646
+ "no-such-id",
647
+ {
648
+ db: h.db,
649
+ issuer: ISSUER,
650
+ manifestPath: h.manifestPath,
651
+ configDir: h.dir,
652
+ supervisor,
653
+ run: async () => 0,
654
+ },
655
+ );
656
+ expect(res.status).toBe(404);
657
+ });
658
+ });