@openparachute/hub 0.5.13-rc.40 → 0.5.13-rc.41

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.40",
3
+ "version": "0.5.13-rc.41",
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": {
@@ -427,7 +427,10 @@ describe("GET /api/modules", () => {
427
427
  // Second back-to-back request must not re-hit the registry. The
428
428
  // UI may poll this endpoint; we don't want it to slam npm.
429
429
  let calls = 0;
430
- const probe = async (_pkg: string): Promise<string | null> => {
430
+ const probe = async (
431
+ _pkg: string,
432
+ _channel: "latest" | "rc",
433
+ ): Promise<string | null> => {
431
434
  calls++;
432
435
  return "0.5.0";
433
436
  };
@@ -446,6 +449,68 @@ describe("GET /api/modules", () => {
446
449
  expect(calls).toBe(callsAfterFirst);
447
450
  });
448
451
 
452
+ test("fetchLatestVersion receives the configured install channel (hub#377 dist-tag fix)", async () => {
453
+ // The audit caught the bug 2026-05-25 on Aaron's deploy: operators
454
+ // on the `rc` channel saw the @latest dist-tag value as their upgrade
455
+ // target (e.g. app showed "rc.4 available" while the rc channel was
456
+ // actually at rc.13). The fix threads the configured channel into
457
+ // fetchLatestVersion so the probe targets the right dist-tag.
458
+ setModuleInstallChannel(h.db, "rc");
459
+ const callsByChannel: string[] = [];
460
+ const probe = async (_pkg: string, channel: "latest" | "rc"): Promise<string | null> => {
461
+ callsByChannel.push(channel);
462
+ return channel === "rc" ? "0.5.0-rc.13" : "0.5.0-rc.4";
463
+ };
464
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
465
+ const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
466
+ db: h.db,
467
+ issuer: ISSUER,
468
+ manifestPath: h.manifestPath,
469
+ fetchLatestVersion: probe,
470
+ cacheTtlMs: 0, // disable cache for this test
471
+ });
472
+ expect(res.status).toBe(200);
473
+ const body = (await res.json()) as {
474
+ modules: Array<{ short: string; latest_version: string | null }>;
475
+ };
476
+ // Every probe call received the configured channel.
477
+ expect(callsByChannel.every((c) => c === "rc")).toBe(true);
478
+ expect(callsByChannel.length).toBeGreaterThan(0);
479
+ // The latest_version reflects the rc dist-tag.
480
+ for (const m of body.modules) {
481
+ expect(m.latest_version).toBe("0.5.0-rc.13");
482
+ }
483
+ });
484
+
485
+ test("cache key includes channel — toggling channel returns fresh value, not stale (hub#377)", async () => {
486
+ // The cache key includes channel so a runtime toggle between latest
487
+ // and rc surfaces the right version immediately, not after TTL expiry.
488
+ let callCount = 0;
489
+ const probe = async (_pkg: string, channel: "latest" | "rc"): Promise<string | null> => {
490
+ callCount++;
491
+ return channel === "rc" ? "1.0.0-rc.5" : "1.0.0";
492
+ };
493
+ const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
494
+ const deps = {
495
+ db: h.db,
496
+ issuer: ISSUER,
497
+ manifestPath: h.manifestPath,
498
+ fetchLatestVersion: probe,
499
+ cacheTtlMs: 60_000,
500
+ };
501
+ setModuleInstallChannel(h.db, "latest");
502
+ const r1 = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), deps);
503
+ const b1 = (await r1.json()) as { modules: Array<{ latest_version: string | null }> };
504
+ expect(b1.modules[0]?.latest_version).toBe("1.0.0");
505
+ const callsAfterLatest = callCount;
506
+ setModuleInstallChannel(h.db, "rc");
507
+ const r2 = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), deps);
508
+ const b2 = (await r2.json()) as { modules: Array<{ latest_version: string | null }> };
509
+ expect(b2.modules[0]?.latest_version).toBe("1.0.0-rc.5");
510
+ // Per-channel cache miss → fresh probe calls fired for the rc lookup.
511
+ expect(callCount).toBeGreaterThan(callsAfterLatest);
512
+ });
513
+
449
514
  test("surfaces module_install_channel in the response (hub#275)", async () => {
450
515
  // Default — first read seeds with `latest`.
451
516
  const bearer = await mintBearer(h, [API_MODULES_REQUIRED_SCOPE]);
@@ -97,11 +97,14 @@ export interface ApiModulesDeps {
97
97
  manifestPath: string;
98
98
  supervisor?: Supervisor;
99
99
  /**
100
- * NPM @latest probe. Returns the version string or null on failure /
101
- * timeout. Default is the real npm registry; tests inject a fake so
102
- * they don't hit the network.
100
+ * NPM dist-tag probe. Returns the version string at the given dist-tag,
101
+ * or null on failure / timeout / unknown tag. Default is the real npm
102
+ * registry; tests inject a fake so they don't hit the network. Channel
103
+ * arg lets the probe respect the operator's configured install channel
104
+ * (`rc` operators see the rc version as the upgrade target, not the
105
+ * stable `latest`).
103
106
  */
104
- fetchLatestVersion?: (pkg: string) => Promise<string | null>;
107
+ fetchLatestVersion?: (pkg: string, channel: ModuleInstallChannel) => Promise<string | null>;
105
108
  /**
106
109
  * Module-level cache TTL for `latest_version` probes, in ms. Default
107
110
  * 5 minutes — long enough that a tab refresh doesn't slam npm,
@@ -229,15 +232,32 @@ const latestVersionCache = new Map<string, CachedVersion>();
229
232
  * tolerates a missing latest_version, so we keep the response shape
230
233
  * stable even when the registry is flaky.
231
234
  */
232
- export async function defaultFetchLatestVersion(pkg: string): Promise<string | null> {
235
+ export async function defaultFetchLatestVersion(
236
+ pkg: string,
237
+ channel: ModuleInstallChannel,
238
+ ): Promise<string | null> {
233
239
  const controller = new AbortController();
234
240
  const timer = setTimeout(() => controller.abort(), 3_000);
235
241
  try {
236
- const url = `https://registry.npmjs.org/${encodeURIComponent(pkg)}/latest`;
242
+ // npm exposes per-package dist-tags at /-/package/<pkg>/dist-tags as a
243
+ // simple map (e.g. `{"latest": "0.2.0-rc.4", "rc": "0.2.0-rc.13"}`).
244
+ // Look up the configured channel; fall back to `latest` if the channel
245
+ // is missing (e.g. a package that hasn't been published with @rc yet).
246
+ // Previously hit `/${pkg}/latest` directly — that endpoint always
247
+ // returns the `latest` dist-tag's package doc regardless of channel,
248
+ // so an operator on @rc saw the @latest version as the "available"
249
+ // upgrade target (audit caught on Aaron's deploy 2026-05-25: app
250
+ // showed "rc.4 available" while @rc was actually rc.13).
251
+ // encodeURIComponent handles scoped packages: @openparachute/vault →
252
+ // %40openparachute%2Fvault. npm resolves the encoded form correctly.
253
+ const url = `https://registry.npmjs.org/-/package/${encodeURIComponent(pkg)}/dist-tags`;
237
254
  const res = await fetch(url, { signal: controller.signal });
238
255
  if (!res.ok) return null;
239
- const body = (await res.json()) as { version?: unknown };
240
- return typeof body.version === "string" ? body.version : null;
256
+ const tags = (await res.json()) as Record<string, unknown>;
257
+ const fromChannel = tags[channel];
258
+ if (typeof fromChannel === "string") return fromChannel;
259
+ const fromLatest = tags.latest;
260
+ return typeof fromLatest === "string" ? fromLatest : null;
241
261
  } catch {
242
262
  return null;
243
263
  } finally {
@@ -378,11 +398,15 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
378
398
  }
379
399
  }
380
400
 
381
- // Resolve npm @latest in parallel — short timeout per request, cache
401
+ // Resolve npm dist-tag in parallel — short timeout per request, cache
382
402
  // shared across requests so a fast UI poll doesn't slam the registry.
403
+ // Channel-aware: an operator on @rc sees the rc-tagged version as the
404
+ // upgrade target (not the stable @latest which may be older). Cache key
405
+ // includes channel so a channel-toggle doesn't return a stale value.
383
406
  const fetchLatest = deps.fetchLatestVersion ?? defaultFetchLatestVersion;
384
407
  const cacheTtl = deps.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
385
408
  const now = deps.now ?? Date.now;
409
+ const channel = getModuleInstallChannel(deps.db);
386
410
 
387
411
  const latestByShort = new Map<string, string | null>();
388
412
  await Promise.all(
@@ -393,13 +417,14 @@ export async function handleApiModules(req: Request, deps: ApiModulesDeps): Prom
393
417
  return;
394
418
  }
395
419
  const pkg = m.package;
396
- const cached = latestVersionCache.get(pkg);
420
+ const cacheKey = `${pkg}@${channel}`;
421
+ const cached = latestVersionCache.get(cacheKey);
397
422
  if (cached && cacheTtl > 0 && now() - cached.fetchedAt < cacheTtl) {
398
423
  latestByShort.set(short, cached.value);
399
424
  return;
400
425
  }
401
- const value = await fetchLatest(pkg);
402
- latestVersionCache.set(pkg, { value, fetchedAt: now() });
426
+ const value = await fetchLatest(pkg, channel);
427
+ latestVersionCache.set(cacheKey, { value, fetchedAt: now() });
403
428
  latestByShort.set(short, value);
404
429
  }),
405
430
  );