@openparachute/hub 0.5.13-rc.13 → 0.5.13-rc.21

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.5.13-rc.13",
3
+ "version": "0.5.13-rc.21",
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": {
@@ -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",
@@ -315,6 +315,96 @@ describe("GET /api/modules", () => {
315
315
  expect(body.supervisor_available).toBe(true);
316
316
  });
317
317
 
318
+ test("populates management_url from a relative managementUrl + module mount (hub#342)", async () => {
319
+ // Vault declares `managementUrl: "/admin"` in its module.json — hub
320
+ // resolves that against the entry's mount path (`/vault/default`)
321
+ // to produce the absolute admin URL the SPA's "Open" button targets.
322
+ writeManifest(h.manifestPath, [
323
+ {
324
+ name: "parachute-vault",
325
+ port: 1940,
326
+ paths: ["/vault/default"],
327
+ health: "/vault/default/health",
328
+ version: "0.4.5",
329
+ installDir: "/install/dir/vault",
330
+ },
331
+ ]);
332
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
333
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
334
+ db: h.db,
335
+ issuer: ISSUER,
336
+ manifestPath: h.manifestPath,
337
+ fetchLatestVersion: async () => null,
338
+ readModuleManifest: async (installDir) => {
339
+ // Return a minimal module.json with managementUrl set. Cast the
340
+ // shape via `as unknown as ...` because the test only exercises
341
+ // the consumer-side resolver, not the validator (which lives in
342
+ // module-manifest.ts and has its own test suite).
343
+ if (installDir === "/install/dir/vault") {
344
+ return {
345
+ name: "parachute-vault",
346
+ manifestName: "parachute-vault",
347
+ displayName: "Vault",
348
+ tagline: "",
349
+ port: 1940,
350
+ paths: ["/vault/default"],
351
+ health: "/health",
352
+ managementUrl: "/admin",
353
+ } as unknown as Awaited<
354
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
355
+ >;
356
+ }
357
+ return null;
358
+ },
359
+ });
360
+ expect(res.status).toBe(200);
361
+ const body = (await res.json()) as {
362
+ modules: Array<{ short: string; management_url: string | null }>;
363
+ };
364
+ const vault = body.modules.find((m) => m.short === "vault");
365
+ expect(vault?.management_url).toBe("/vault/default/admin");
366
+ });
367
+
368
+ test("management_url is null when the module declares neither managementUrl nor uiUrl (hub#342)", async () => {
369
+ // Scribe + runner today: no managementUrl declared yet. The SPA's
370
+ // "Open" button renders disabled with a follow-up tooltip in that
371
+ // case — null on the wire is the canonical signal.
372
+ writeManifest(h.manifestPath, [
373
+ {
374
+ name: "parachute-scribe",
375
+ port: 1942,
376
+ paths: ["/scribe"],
377
+ health: "/scribe/health",
378
+ version: "0.1.0",
379
+ installDir: "/install/dir/scribe",
380
+ },
381
+ ]);
382
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
383
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
384
+ db: h.db,
385
+ issuer: ISSUER,
386
+ manifestPath: h.manifestPath,
387
+ fetchLatestVersion: async () => null,
388
+ readModuleManifest: async () =>
389
+ ({
390
+ name: "parachute-scribe",
391
+ manifestName: "parachute-scribe",
392
+ displayName: "Scribe",
393
+ tagline: "",
394
+ port: 1942,
395
+ paths: ["/scribe"],
396
+ health: "/health",
397
+ }) as unknown as Awaited<
398
+ ReturnType<typeof import("../module-manifest.ts").readModuleManifest>
399
+ >,
400
+ });
401
+ const body = (await res.json()) as {
402
+ modules: Array<{ short: string; management_url: string | null }>;
403
+ };
404
+ const scribe = body.modules.find((m) => m.short === "scribe");
405
+ expect(scribe?.management_url).toBeNull();
406
+ });
407
+
318
408
  test("npm probe failure → latest_version is null but response still 200", async () => {
319
409
  // The whole point of the probe-is-opportunistic posture: a flaky
320
410
  // npm registry must not break the page render. The UI handles
@@ -64,6 +64,19 @@ describe("cli", () => {
64
64
  expect(stderr).toMatch(/usage: parachute install/);
65
65
  });
66
66
 
67
+ test("install --channel without a value exits 1 (hub#337)", async () => {
68
+ const { code, stderr } = await runCli(["install", "vault", "--channel"]);
69
+ expect(code).toBe(1);
70
+ expect(stderr).toMatch(/--channel requires a value/);
71
+ });
72
+
73
+ test("install --channel with an invalid value exits 1 (hub#337)", async () => {
74
+ const { code, stderr } = await runCli(["install", "vault", "--channel", "banana"]);
75
+ expect(code).toBe(1);
76
+ expect(stderr).toMatch(/--channel must be "rc" or "latest"/);
77
+ expect(stderr).toMatch(/banana/);
78
+ });
79
+
67
80
  test("unknown command exits 1", async () => {
68
81
  const { code, stderr } = await runCli(["wat"]);
69
82
  expect(code).toBe(1);
@@ -243,7 +243,6 @@ describe("hubFetch routing", () => {
243
243
  readModuleManifest: async () => ({
244
244
  name: "vault",
245
245
  manifestName: "parachute-vault",
246
- kind: "api",
247
246
  port: 1940,
248
247
  paths: ["/vault/default"],
249
248
  health: "/health",
@@ -298,7 +297,6 @@ describe("hubFetch routing", () => {
298
297
  readModuleManifest: async () => ({
299
298
  name: "notes",
300
299
  manifestName: "parachute-notes",
301
- kind: "frontend",
302
300
  port: 5173,
303
301
  paths: ["/notes"],
304
302
  health: "/health",
@@ -335,7 +333,6 @@ describe("hubFetch routing", () => {
335
333
  readModuleManifest: async () => ({
336
334
  name: "notes",
337
335
  manifestName: "parachute-notes",
338
- kind: "frontend",
339
336
  port: 5173,
340
337
  paths: ["/notes"],
341
338
  health: "/health",
@@ -362,7 +359,6 @@ describe("hubFetch routing", () => {
362
359
  readModuleManifest: async () => ({
363
360
  name: "vault",
364
361
  manifestName: "parachute-vault",
365
- kind: "api",
366
362
  port: 1940,
367
363
  paths: ["/vault/default"],
368
364
  health: "/health",
@@ -1940,19 +1936,20 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
1940
1936
  }
1941
1937
  });
1942
1938
 
1943
- test("routes a deep /agent/api/health to the matching upstream", async () => {
1944
- // Agent registers `/agent`; deeper paths route by prefix.
1939
+ test("routes a deep /someapp/api/health to the matching upstream", async () => {
1940
+ // A generic third-party module registers `/someapp`; deeper paths
1941
+ // route by prefix.
1945
1942
  const h = makeHarness();
1946
- const upstream = startUpstream("agent");
1943
+ const upstream = startUpstream("someapp");
1947
1944
  try {
1948
1945
  writeManifest(
1949
1946
  {
1950
1947
  services: [
1951
1948
  {
1952
- name: "agent",
1949
+ name: "someapp",
1953
1950
  port: upstream.port,
1954
- paths: ["/agent"],
1955
- health: "/agent/api/health",
1951
+ paths: ["/someapp"],
1952
+ health: "/someapp/api/health",
1956
1953
  version: "0.1.0",
1957
1954
  },
1958
1955
  ],
@@ -1960,11 +1957,11 @@ describe("hubFetch /<svc>/* generic proxy dispatch (#182)", () => {
1960
1957
  h.manifestPath,
1961
1958
  );
1962
1959
  const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
1963
- const res = await fetcher(req("/agent/api/health?probe=1"));
1960
+ const res = await fetcher(req("/someapp/api/health?probe=1"));
1964
1961
  expect(res.status).toBe(200);
1965
1962
  const body = (await res.json()) as { tag: string; pathname: string; search: string };
1966
- expect(body.tag).toBe("agent");
1967
- expect(body.pathname).toBe("/agent/api/health");
1963
+ expect(body.tag).toBe("someapp");
1964
+ expect(body.pathname).toBe("/someapp/api/health");
1968
1965
  expect(body.search).toBe("?probe=1");
1969
1966
  } finally {
1970
1967
  upstream.stop();