@openparachute/hub 0.5.13-rc.14 → 0.5.13-rc.23

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 (35) hide show
  1. package/README.md +18 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/api-hub.test.ts +251 -0
  4. package/src/__tests__/api-modules-ops.test.ts +257 -4
  5. package/src/__tests__/api-modules.test.ts +90 -0
  6. package/src/__tests__/cli.test.ts +13 -0
  7. package/src/__tests__/hub-server.test.ts +10 -13
  8. package/src/__tests__/install.test.ts +259 -24
  9. package/src/__tests__/lifecycle.test.ts +90 -13
  10. package/src/__tests__/module-manifest.test.ts +17 -57
  11. package/src/__tests__/post-install.test.ts +0 -2
  12. package/src/__tests__/scope-registry.test.ts +9 -9
  13. package/src/__tests__/services-manifest.test.ts +456 -43
  14. package/src/__tests__/setup-wizard.test.ts +228 -0
  15. package/src/__tests__/status.test.ts +4 -4
  16. package/src/__tests__/upgrade.test.ts +362 -3
  17. package/src/api-hub.ts +201 -0
  18. package/src/api-modules-ops.ts +79 -7
  19. package/src/api-modules.ts +97 -1
  20. package/src/cli.ts +50 -4
  21. package/src/commands/install.ts +108 -6
  22. package/src/commands/lifecycle.ts +20 -0
  23. package/src/commands/upgrade.ts +213 -27
  24. package/src/help.ts +54 -17
  25. package/src/hub-server.ts +18 -0
  26. package/src/hub.ts +71 -0
  27. package/src/module-manifest.ts +4 -34
  28. package/src/service-spec.ts +44 -67
  29. package/src/services-manifest.ts +163 -3
  30. package/src/setup-wizard.ts +205 -12
  31. package/web/ui/dist/assets/index-CG229ge6.js +61 -0
  32. package/web/ui/dist/assets/index-DArp3eO_.css +1 -0
  33. package/web/ui/dist/index.html +2 -2
  34. package/web/ui/dist/assets/index-D63mUkVX.js +0 -61
  35. package/web/ui/dist/assets/index-DliViliP.css +0 -1
package/README.md CHANGED
@@ -8,12 +8,30 @@ The hub coordinates the modules running on your machine: it installs them, runs
8
8
 
9
9
  ## Install
10
10
 
11
+ ### Local (Bun)
12
+
11
13
  ```sh
12
14
  bun add -g @openparachute/hub
13
15
  ```
14
16
 
15
17
  Prereqs: [Bun](https://bun.sh) 1.3.0 or later. `parachute expose` also requires [Tailscale](https://tailscale.com/download) **1.82 or newer** (installed + `tailscale up` run once); the `expose` path is under active polish for launch, so expect rough edges.
16
18
 
19
+ ### Hosted (Render)
20
+
21
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/ParachuteComputer/parachute-hub)
22
+
23
+ One-click Render deploy via the `render.yaml` Blueprint in this repo. Provisions a $7/mo Starter service + 1 GiB persistent disk + auto-deploys from `main`. Comes pre-configured with `PARACHUTE_INSTALL_CHANNEL=latest` so modules you install via the admin SPA (vault, app, scribe, runner) pull stable releases by default.
24
+
25
+ **Want pre-release / rc modules?** Set `PARACHUTE_INSTALL_CHANNEL=rc` in your Render dashboard env vars (useful for dev/testing against the rc release line).
26
+
27
+ After deploy:
28
+
29
+ 1. Open your Render service URL → wizard runs at `/admin/setup`
30
+ 2. Set custom domain (optional) → set `PARACHUTE_HUB_ORIGIN` env to match
31
+ 3. Install modules via the admin SPA at `/admin/modules` (or via the wizard)
32
+
33
+ Render's docs on Blueprints: <https://render.com/docs/blueprint-spec>
34
+
17
35
  ## First 5 minutes
18
36
 
19
37
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.13-rc.14",
3
+ "version": "0.5.13-rc.23",
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": {
@@ -0,0 +1,251 @@
1
+ /**
2
+ * `GET /api/hub` — hub version + uptime + install-source surface for the
3
+ * admin SPA's version badge.
4
+ *
5
+ * Tests assert the contract:
6
+ * - 405 on non-GET (matches the shape of other /api/* read endpoints).
7
+ * - 401/403 on missing or under-scoped bearer (host:admin required).
8
+ * - Happy path returns the expected shape + uptime increments between
9
+ * calls.
10
+ * - PARACHUTE_HOME=/parachute (the Render Blueprint pin) overrides
11
+ * `source` to "container".
12
+ * - PARACHUTE_BUILD_TIME passes through as `container_build_time`.
13
+ */
14
+ import { describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { type HubStatusResponse, handleApiHub } from "../api-hub.ts";
20
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
21
+ import { signAccessToken } from "../jwt-sign.ts";
22
+ import { mintOperatorToken } from "../operator-token.ts";
23
+ import { rotateSigningKey } from "../signing-keys.ts";
24
+ import { createUser } from "../users.ts";
25
+
26
+ interface Harness {
27
+ dir: string;
28
+ cleanup: () => void;
29
+ }
30
+
31
+ function makeHarness(): Harness {
32
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-hub-"));
33
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
34
+ }
35
+
36
+ const ISSUER = "http://127.0.0.1:1939";
37
+
38
+ // `hubSrcDir` defaults to `dirname(import.meta.url)` of api-hub.ts — but in
39
+ // tests we drive it explicitly so test-side test-double dirs don't accidentally
40
+ // pick up the real package.json. Point it at this file's dir (under __tests__/)
41
+ // so the climb-to-package.json loop walks up to the repo root and finds the
42
+ // real hub package.json.
43
+ const HUB_SRC_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
44
+
45
+ async function bootstrap(
46
+ dir: string,
47
+ ): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
48
+ const db = openHubDb(hubDbPath(dir));
49
+ rotateSigningKey(db);
50
+ const u = await createUser(db, "owner", "pw");
51
+ return { db, userId: u.id };
52
+ }
53
+
54
+ function getRequest(headers: Record<string, string> = {}): Request {
55
+ return new Request("http://localhost/api/hub", {
56
+ method: "GET",
57
+ headers,
58
+ });
59
+ }
60
+
61
+ describe("GET /api/hub (hub version + uptime badge surface)", () => {
62
+ test("405 on non-GET", async () => {
63
+ const h = makeHarness();
64
+ try {
65
+ const { db } = await bootstrap(h.dir);
66
+ try {
67
+ const req = new Request("http://localhost/api/hub", { method: "POST" });
68
+ const resp = await handleApiHub(req, { db, issuer: ISSUER });
69
+ expect(resp.status).toBe(405);
70
+ } finally {
71
+ db.close();
72
+ }
73
+ } finally {
74
+ h.cleanup();
75
+ }
76
+ });
77
+
78
+ test("401 when no Authorization header", async () => {
79
+ const h = makeHarness();
80
+ try {
81
+ const { db } = await bootstrap(h.dir);
82
+ try {
83
+ const resp = await handleApiHub(getRequest(), { db, issuer: ISSUER });
84
+ expect(resp.status).toBe(401);
85
+ } finally {
86
+ db.close();
87
+ }
88
+ } finally {
89
+ h.cleanup();
90
+ }
91
+ });
92
+
93
+ test("403 when bearer scope lacks parachute:host:admin", async () => {
94
+ const h = makeHarness();
95
+ try {
96
+ const { db, userId } = await bootstrap(h.dir);
97
+ try {
98
+ const narrow = await signAccessToken(db, {
99
+ sub: userId,
100
+ // Adjacent host scope but NOT host:admin — host:auth is the
101
+ // tokens-registry scope, not the admin one. Confirms the gate
102
+ // checks the exact scope, not any host:* membership.
103
+ scopes: ["parachute:host:auth"],
104
+ audience: "hub",
105
+ clientId: "parachute-hub",
106
+ issuer: ISSUER,
107
+ ttlSeconds: 3600,
108
+ });
109
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${narrow.token}` }), {
110
+ db,
111
+ issuer: ISSUER,
112
+ });
113
+ expect(resp.status).toBe(403);
114
+ } finally {
115
+ db.close();
116
+ }
117
+ } finally {
118
+ h.cleanup();
119
+ }
120
+ });
121
+
122
+ test("happy path: returns version + started_at + uptime_ms + source", async () => {
123
+ const h = makeHarness();
124
+ try {
125
+ const { db, userId } = await bootstrap(h.dir);
126
+ try {
127
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
128
+ const startedAt = new Date(Date.now() - 5000); // started 5s ago
129
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
130
+ db,
131
+ issuer: ISSUER,
132
+ hubSrcDir: HUB_SRC_DIR,
133
+ startedAt,
134
+ // Override env so we're not at the mercy of the host's
135
+ // PARACHUTE_HOME (or PARACHUTE_BUILD_TIME) when the test runs.
136
+ env: {},
137
+ });
138
+ expect(resp.status).toBe(200);
139
+ const body = (await resp.json()) as HubStatusResponse;
140
+ // Version pulled from the real hub package.json — assert SemVer
141
+ // shape rather than pinning a specific number (otherwise this test
142
+ // breaks on every rc bump).
143
+ expect(body.version).toMatch(/^\d+\.\d+\.\d+/);
144
+ expect(body.started_at).toBe(startedAt.toISOString());
145
+ expect(body.uptime_ms).toBeGreaterThanOrEqual(5000);
146
+ // hubSrcDir points at the real repo's src/, so install-source
147
+ // classification will report bun-linked OR npm OR unknown — never
148
+ // "container" because we cleared PARACHUTE_HOME.
149
+ expect(body.source).not.toBe("container");
150
+ expect(body.container_build_time).toBeUndefined();
151
+ } finally {
152
+ db.close();
153
+ }
154
+ } finally {
155
+ h.cleanup();
156
+ }
157
+ });
158
+
159
+ test("uptime_ms increments between calls", async () => {
160
+ const h = makeHarness();
161
+ try {
162
+ const { db, userId } = await bootstrap(h.dir);
163
+ try {
164
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
165
+ const startedAt = new Date("2026-05-23T14:00:00.000Z");
166
+ // Drive "now" so the assertion isn't a flaky timing test.
167
+ const first = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
168
+ db,
169
+ issuer: ISSUER,
170
+ hubSrcDir: HUB_SRC_DIR,
171
+ startedAt,
172
+ now: () => new Date("2026-05-23T14:00:05.000Z"),
173
+ env: {},
174
+ });
175
+ const second = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
176
+ db,
177
+ issuer: ISSUER,
178
+ hubSrcDir: HUB_SRC_DIR,
179
+ startedAt,
180
+ now: () => new Date("2026-05-23T14:00:08.000Z"),
181
+ env: {},
182
+ });
183
+ const firstBody = (await first.json()) as HubStatusResponse;
184
+ const secondBody = (await second.json()) as HubStatusResponse;
185
+ expect(firstBody.uptime_ms).toBe(5000);
186
+ expect(secondBody.uptime_ms).toBe(8000);
187
+ expect(secondBody.uptime_ms).toBeGreaterThan(firstBody.uptime_ms);
188
+ // started_at stays stable across calls (captured once at process
189
+ // start, not per-request).
190
+ expect(secondBody.started_at).toBe(firstBody.started_at);
191
+ } finally {
192
+ db.close();
193
+ }
194
+ } finally {
195
+ h.cleanup();
196
+ }
197
+ });
198
+
199
+ test("PARACHUTE_HOME=/parachute overrides source to 'container'", async () => {
200
+ const h = makeHarness();
201
+ try {
202
+ const { db, userId } = await bootstrap(h.dir);
203
+ try {
204
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
205
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
206
+ db,
207
+ issuer: ISSUER,
208
+ hubSrcDir: HUB_SRC_DIR,
209
+ env: { PARACHUTE_HOME: "/parachute" },
210
+ });
211
+ expect(resp.status).toBe(200);
212
+ const body = (await resp.json()) as HubStatusResponse;
213
+ expect(body.source).toBe("container");
214
+ // bun_linked_path is suppressed under container source — operators
215
+ // on Render don't have a meaningful "checkout path" to surface.
216
+ expect(body.bun_linked_path).toBeUndefined();
217
+ } finally {
218
+ db.close();
219
+ }
220
+ } finally {
221
+ h.cleanup();
222
+ }
223
+ });
224
+
225
+ test("PARACHUTE_BUILD_TIME passes through as container_build_time", async () => {
226
+ const h = makeHarness();
227
+ try {
228
+ const { db, userId } = await bootstrap(h.dir);
229
+ try {
230
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
231
+ const resp = await handleApiHub(getRequest({ authorization: `Bearer ${op.token}` }), {
232
+ db,
233
+ issuer: ISSUER,
234
+ hubSrcDir: HUB_SRC_DIR,
235
+ env: {
236
+ PARACHUTE_HOME: "/parachute",
237
+ PARACHUTE_BUILD_TIME: "2026-05-23T14:21:00.000Z",
238
+ },
239
+ });
240
+ expect(resp.status).toBe(200);
241
+ const body = (await resp.json()) as HubStatusResponse;
242
+ expect(body.container_build_time).toBe("2026-05-23T14:21:00.000Z");
243
+ expect(body.source).toBe("container");
244
+ } finally {
245
+ db.close();
246
+ }
247
+ } finally {
248
+ h.cleanup();
249
+ }
250
+ });
251
+ });
@@ -70,6 +70,14 @@ function postReq(path: string, headers: Record<string, string>): Request {
70
70
  return new Request(`http://localhost${path}`, { method: "POST", headers });
71
71
  }
72
72
 
73
+ function postReqJson(path: string, headers: Record<string, string>, body: unknown): Request {
74
+ return new Request(`http://localhost${path}`, {
75
+ method: "POST",
76
+ headers: { ...headers, "content-type": "application/json" },
77
+ body: JSON.stringify(body),
78
+ });
79
+ }
80
+
73
81
  function getReq(path: string, headers: Record<string, string>): Request {
74
82
  return new Request(`http://localhost${path}`, { method: "GET", headers });
75
83
  }
@@ -347,6 +355,222 @@ describe("POST /api/modules/:short/install", () => {
347
355
  expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
348
356
  });
349
357
 
358
+ // hub#337 — per-request channel in body + PARACHUTE_INSTALL_CHANNEL env var.
359
+ // Precedence: body.channel > PARACHUTE_INSTALL_CHANNEL env > hub_settings row > "latest".
360
+
361
+ test("body { channel: 'rc' } overrides the hub_settings row (hub#337)", async () => {
362
+ // SPA-driven "install X at rc" affordance: per-call override that
363
+ // doesn't flip the cluster-wide toggle.
364
+ setModuleInstallChannel(h.db, "latest");
365
+ const { supervisor } = makeIdleSupervisor();
366
+ const { run, calls } = alwaysOkRun();
367
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
368
+ const res = await handleInstall(
369
+ postReqJson(
370
+ "/api/modules/vault/install",
371
+ { authorization: `Bearer ${bearer}` },
372
+ { channel: "rc" },
373
+ ),
374
+ "vault",
375
+ {
376
+ db: h.db,
377
+ issuer: ISSUER,
378
+ manifestPath: h.manifestPath,
379
+ configDir: h.dir,
380
+ supervisor,
381
+ run,
382
+ },
383
+ );
384
+ expect(res.status).toBe(202);
385
+ await new Promise((r) => setTimeout(r, 10));
386
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
387
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
388
+ });
389
+
390
+ test("body { channel: 'latest' } overrides hub_settings.module_install_channel = rc (hub#337)", async () => {
391
+ setModuleInstallChannel(h.db, "rc");
392
+ const { supervisor } = makeIdleSupervisor();
393
+ const { run, calls } = alwaysOkRun();
394
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
395
+ await handleInstall(
396
+ postReqJson(
397
+ "/api/modules/vault/install",
398
+ { authorization: `Bearer ${bearer}` },
399
+ { channel: "latest" },
400
+ ),
401
+ "vault",
402
+ {
403
+ db: h.db,
404
+ issuer: ISSUER,
405
+ manifestPath: h.manifestPath,
406
+ configDir: h.dir,
407
+ supervisor,
408
+ run,
409
+ },
410
+ );
411
+ await new Promise((r) => setTimeout(r, 10));
412
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
413
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
414
+ });
415
+
416
+ test("body { channel: 'banana' } returns 400 invalid_channel (hub#337)", async () => {
417
+ // Operator-typed garbage in the SPA → don't silently fall through to
418
+ // the default; surface the typo immediately.
419
+ const { supervisor } = makeIdleSupervisor();
420
+ const { run } = alwaysOkRun();
421
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
422
+ const res = await handleInstall(
423
+ postReqJson(
424
+ "/api/modules/vault/install",
425
+ { authorization: `Bearer ${bearer}` },
426
+ { channel: "banana" },
427
+ ),
428
+ "vault",
429
+ {
430
+ db: h.db,
431
+ issuer: ISSUER,
432
+ manifestPath: h.manifestPath,
433
+ configDir: h.dir,
434
+ supervisor,
435
+ run,
436
+ },
437
+ );
438
+ expect(res.status).toBe(400);
439
+ const body = (await res.json()) as { error: string; error_description: string };
440
+ expect(body.error).toBe("invalid_channel");
441
+ expect(body.error_description).toMatch(/banana/);
442
+ });
443
+
444
+ test("missing body / empty body falls through to hub_settings channel (back-compat)", async () => {
445
+ // Pre-hub#337 callers don't send a JSON body. The existing SPA paths
446
+ // (and the first-boot wizard) keep working unchanged.
447
+ setModuleInstallChannel(h.db, "rc");
448
+ const { supervisor } = makeIdleSupervisor();
449
+ const { run, calls } = alwaysOkRun();
450
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
451
+ await handleInstall(
452
+ postReq("/api/modules/vault/install", { 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
+ await new Promise((r) => setTimeout(r, 10));
464
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
465
+ });
466
+
467
+ test("PARACHUTE_INSTALL_CHANNEL env overrides hub_settings.module_install_channel (hub#337)", async () => {
468
+ // The Render-deploy cascade shape: the platform sets the env var to
469
+ // `rc`, hub's API path picks it up over the DB-stored default. Lets
470
+ // an operator-toggle override that the platform-team hasn't pinned
471
+ // still work via the SPA toggle below it — but with the env in
472
+ // play, the env wins.
473
+ setModuleInstallChannel(h.db, "latest");
474
+ const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
475
+ process.env.PARACHUTE_INSTALL_CHANNEL = "rc";
476
+ try {
477
+ const { supervisor } = makeIdleSupervisor();
478
+ const { run, calls } = alwaysOkRun();
479
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
480
+ await handleInstall(
481
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
482
+ "vault",
483
+ {
484
+ db: h.db,
485
+ issuer: ISSUER,
486
+ manifestPath: h.manifestPath,
487
+ configDir: h.dir,
488
+ supervisor,
489
+ run,
490
+ },
491
+ );
492
+ await new Promise((r) => setTimeout(r, 10));
493
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
494
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
495
+ } finally {
496
+ // Bun's process.env supports the `[key]: undefined` shape
497
+ // (biome's noDelete rule preferred this over `delete`).
498
+ if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
499
+ else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
500
+ }
501
+ });
502
+
503
+ test("body channel wins over PARACHUTE_INSTALL_CHANNEL env (hub#337)", async () => {
504
+ // Per-request override beats the platform default — the SPA's
505
+ // "install this one at latest even though the cluster's on rc" path.
506
+ setModuleInstallChannel(h.db, "latest");
507
+ const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
508
+ process.env.PARACHUTE_INSTALL_CHANNEL = "rc";
509
+ try {
510
+ const { supervisor } = makeIdleSupervisor();
511
+ const { run, calls } = alwaysOkRun();
512
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
513
+ await handleInstall(
514
+ postReqJson(
515
+ "/api/modules/vault/install",
516
+ { authorization: `Bearer ${bearer}` },
517
+ { channel: "latest" },
518
+ ),
519
+ "vault",
520
+ {
521
+ db: h.db,
522
+ issuer: ISSUER,
523
+ manifestPath: h.manifestPath,
524
+ configDir: h.dir,
525
+ supervisor,
526
+ run,
527
+ },
528
+ );
529
+ await new Promise((r) => setTimeout(r, 10));
530
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
531
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
532
+ } finally {
533
+ // Bun's process.env supports the `[key]: undefined` shape
534
+ // (biome's noDelete rule preferred this over `delete`).
535
+ if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
536
+ else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
537
+ }
538
+ });
539
+
540
+ test("garbage PARACHUTE_INSTALL_CHANNEL env falls back to hub_settings (no crash)", async () => {
541
+ // Operator typo at the platform layer shouldn't crash installs.
542
+ // Warns + falls through to the DB-stored channel.
543
+ setModuleInstallChannel(h.db, "rc");
544
+ const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
545
+ process.env.PARACHUTE_INSTALL_CHANNEL = "banana";
546
+ try {
547
+ const { supervisor } = makeIdleSupervisor();
548
+ const { run, calls } = alwaysOkRun();
549
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
550
+ const res = await handleInstall(
551
+ postReq("/api/modules/vault/install", { authorization: `Bearer ${bearer}` }),
552
+ "vault",
553
+ {
554
+ db: h.db,
555
+ issuer: ISSUER,
556
+ manifestPath: h.manifestPath,
557
+ configDir: h.dir,
558
+ supervisor,
559
+ run,
560
+ },
561
+ );
562
+ expect(res.status).toBe(202);
563
+ await new Promise((r) => setTimeout(r, 10));
564
+ // Falls back to the DB-stored rc, not "@latest".
565
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
566
+ } finally {
567
+ // Bun's process.env supports the `[key]: undefined` shape
568
+ // (biome's noDelete rule preferred this over `delete`).
569
+ if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
570
+ else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
571
+ }
572
+ });
573
+
350
574
  test("failed bun-add surfaces failed status on the operation", async () => {
351
575
  const { supervisor } = makeIdleSupervisor();
352
576
  // Run returns 1 + findGlobalInstall returns null = real failure.
@@ -490,6 +714,39 @@ describe("POST /api/modules/:short/upgrade", () => {
490
714
  expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
491
715
  });
492
716
 
717
+ test("PARACHUTE_INSTALL_CHANNEL env cascades to upgrade too (hub#339 symmetry)", async () => {
718
+ // The Render-deploy operator sets PARACHUTE_INSTALL_CHANNEL=rc cluster-
719
+ // wide expecting BOTH install and upgrade through the admin SPA to
720
+ // honor it. Asymmetry between the two paths would surprise them.
721
+ setModuleInstallChannel(h.db, "latest"); // DB says latest
722
+ const prior = process.env.PARACHUTE_INSTALL_CHANNEL;
723
+ process.env.PARACHUTE_INSTALL_CHANNEL = "rc"; // env says rc — should win
724
+ try {
725
+ const { supervisor } = makeIdleSupervisor();
726
+ await supervisor.start({ short: "vault", cmd: ["parachute-vault", "serve"] });
727
+ const { run, calls } = alwaysOkRun();
728
+ const bearer = await mintBearer(h, [API_MODULES_OPS_REQUIRED_SCOPE]);
729
+ await handleUpgrade(
730
+ postReq("/api/modules/vault/upgrade", { authorization: `Bearer ${bearer}` }),
731
+ "vault",
732
+ {
733
+ db: h.db,
734
+ issuer: ISSUER,
735
+ manifestPath: h.manifestPath,
736
+ configDir: h.dir,
737
+ supervisor,
738
+ run,
739
+ },
740
+ );
741
+ await new Promise((r) => setTimeout(r, 10));
742
+ expect(calls).toContainEqual(["bun", "add", "-g", "@openparachute/vault@rc"]);
743
+ expect(calls).not.toContainEqual(["bun", "add", "-g", "@openparachute/vault@latest"]);
744
+ } finally {
745
+ if (prior === undefined) process.env.PARACHUTE_INSTALL_CHANNEL = undefined;
746
+ else process.env.PARACHUTE_INSTALL_CHANNEL = prior;
747
+ }
748
+ });
749
+
493
750
  test("fails with 'try install first' when module is installed but never supervised", async () => {
494
751
  // Module has a services.json row (e.g. seeded by `parachute install`
495
752
  // pre-supervisor era) but the supervisor never spawned it.
@@ -680,7 +937,6 @@ describe("well-known regen after module ops", () => {
680
937
  manifest: {
681
938
  name: string;
682
939
  manifestName: string;
683
- kind: "api" | "frontend" | "tool";
684
940
  port: number;
685
941
  paths: string[];
686
942
  health: string;
@@ -708,7 +964,6 @@ describe("well-known regen after module ops", () => {
708
964
  const install = fakeInstall("@openparachute/vault", {
709
965
  name: "vault",
710
966
  manifestName: "parachute-vault",
711
- kind: "api",
712
967
  port: 1940,
713
968
  paths: ["/vault/default"],
714
969
  health: "/vault/default/health",
@@ -812,7 +1067,6 @@ describe("well-known regen after module ops", () => {
812
1067
  const install = fakeInstall("@openparachute/vault", {
813
1068
  name: "vault",
814
1069
  manifestName: "parachute-vault",
815
- kind: "api",
816
1070
  port: 1940,
817
1071
  paths: ["/vault/default"],
818
1072
  health: "/vault/default/health",
@@ -902,7 +1156,6 @@ describe("well-known regen after module ops", () => {
902
1156
  const install = fakeInstall("@openparachute/vault", {
903
1157
  name: "vault",
904
1158
  manifestName: "parachute-vault",
905
- kind: "api",
906
1159
  port: 1940,
907
1160
  paths: ["/vault/default"],
908
1161
  health: "/vault/default/health",