From 81c1fe24f6b5150f8e6d0448ddb7986ef4cba8ae Mon Sep 17 00:00:00 2001 From: Ronald Tse Date: Tue, 1 Sep 2026 15:17:41 +0200 Subject: [PATCH] fix: browser downloads via the CORS asset front door (5.2.0) GH Releases redirect lacks ACAO: rewrite interscript-ml release URLs to api.interscript.org/v1/assets (sha256 verification unchanged - the channel is never trusted); fetch loop fails loudly on zero bytes instead of returning an empty artifact. Completes TODO.client-work 13 (proxy live as api-worker 1.3.1). --- src/ml/imf/registry.ts | 24 ++++++++++++++++++++---- test/client-work.test.ts | 16 ++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/ml/imf/registry.ts b/src/ml/imf/registry.ts index 33c86c3..d703c4a 100644 --- a/src/ml/imf/registry.ts +++ b/src/ml/imf/registry.ts @@ -62,7 +62,7 @@ async function fetchHttpBytes(url: string): Promise { async function fetchIndex(source: string): Promise> { if (source.startsWith("http://") || source.startsWith("https://")) { const bytes = await fetchHttpBytes(source) - const sidecarRes = await fetch(`${source}.sha256`) + const sidecarRes = await fetch(`${corsAssetUrl(source)}.sha256`) if (!sidecarRes.ok) { throw new RegistryError( `index sha256 sidecar missing: ${source}.sha256 -> ${sidecarRes.status}`, @@ -144,7 +144,7 @@ function indexCacheKey(source: string): string { async function cacheIndexForOffline(source: string): Promise { if (typeof caches === "undefined") return try { - const res = await fetch(source) + const res = await fetch(corsAssetUrl(source)) if (res.ok) await (await caches.open(CACHE_NAME)).put(indexCacheKey(source), res.clone()) } catch { /* best-effort */ @@ -204,7 +204,8 @@ async function fetchWithProgress( } catch { continue } - if (!res.ok && res.status !== 206) throw new RegistryError(`fetch failed: ${url} -> ${res.status}`) + if (!res.ok && res.status !== 206) + throw new RegistryError(`fetch failed: ${url} -> ${res.status}`) if (received === 0) total = Number(res.headers.get("content-length") ?? 0) else if (res.status !== 206) { // server ignored Range: restart cleanly rather than corrupt @@ -229,6 +230,9 @@ async function fetchWithProgress( } if (!dropped) break } + if (received === 0) { + throw new RegistryError(`fetch produced zero bytes (5 attempts): ${url}`) + } const out = new Uint8Array(received) let offset = 0 for (const chunk of chunks) { @@ -247,6 +251,18 @@ export interface ResolvedZip { * reloads, so a model downloads once per browser. Node hosts persist * to the filesystem instead; both paths re-verify against the index * sha256 on every use — the cache is never trusted blindly. */ + +const RELEASE_ORIGIN = "https://github.com/interscript/interscript-ml/releases/download/" +const CORS_FRONT_DOOR = "https://api.interscript.org/v1/assets/" + +/** In browsers, GH Releases are not CORS-fetchable (the github.com + * redirect hop lacks ACAO); rewrite release URLs to the API's + * streaming front door. Content authenticity is unaffected: the + * sha256 verification proves whatever channel delivered the bytes. */ +export function corsAssetUrl(url: string): string { + return url.startsWith(RELEASE_ORIGIN) ? CORS_FRONT_DOOR + url.slice(RELEASE_ORIGIN.length) : url +} + const CACHE_NAME = "interscript-imf-models-v1" // Minimal structural types — the DOM lib isn't in this package's tsconfig @@ -359,7 +375,7 @@ export async function resolve( const bytes = entry.url.startsWith("file://") ? fs!.readFileSync(entry.url.replace(/^file:\/\//, "")) - : await fetchWithProgress(entry.url, opts.onProgress) + : await fetchWithProgress(corsAssetUrl(entry.url), opts.onProgress) const actual = await sha256Hex(bytes) if (actual !== entry.sha256) { throw new RegistryError( diff --git a/test/client-work.test.ts b/test/client-work.test.ts index 1dd8e14..7eea6f4 100644 --- a/test/client-work.test.ts +++ b/test/client-work.test.ts @@ -131,3 +131,19 @@ describe("download progress (03)", () => { } }) }) + +import { corsAssetUrl } from "../src/ml/imf/registry.js" + +describe("CORS asset rewrite (13)", () => { + it("rewrites interscript-ml release URLs to the API front door", () => { + expect( + corsAssetUrl( + "https://github.com/interscript/interscript-ml/releases/download/index-v2/models-index.yaml", + ), + ).toBe("https://api.interscript.org/v1/assets/index-v2/models-index.yaml") + }) + it("leaves other hosts and schemes untouched", () => { + expect(corsAssetUrl("https://example.com/x.zip")).toBe("https://example.com/x.zip") + expect(corsAssetUrl("file:///tmp/x.zip")).toBe("file:///tmp/x.zip") + }) +})