@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 +1 -1
- package/src/__tests__/api-modules.test.ts +66 -1
- package/src/api-modules.ts +37 -12
package/package.json
CHANGED
|
@@ -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 (
|
|
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]);
|
package/src/api-modules.ts
CHANGED
|
@@ -97,11 +97,14 @@ export interface ApiModulesDeps {
|
|
|
97
97
|
manifestPath: string;
|
|
98
98
|
supervisor?: Supervisor;
|
|
99
99
|
/**
|
|
100
|
-
* NPM
|
|
101
|
-
* timeout. Default is the real npm
|
|
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(
|
|
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
|
-
|
|
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
|
|
240
|
-
|
|
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
|
|
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
|
|
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(
|
|
426
|
+
const value = await fetchLatest(pkg, channel);
|
|
427
|
+
latestVersionCache.set(cacheKey, { value, fetchedAt: now() });
|
|
403
428
|
latestByShort.set(short, value);
|
|
404
429
|
}),
|
|
405
430
|
);
|