diff --git a/src/rest.ts b/src/rest.ts index 945a6b1..90993fe 100644 --- a/src/rest.ts +++ b/src/rest.ts @@ -137,6 +137,31 @@ rest.post("/v1/detect", async (c) => { return c.json({ count: results.length, results }) }) +const RELEASE_BASE = "https://github.com/interscript/interscript-ml/releases/download" + +/** + * CORS-enabled streaming proxy for release assets (TODO.client-work 13): + * browsers cannot fetch GH Releases directly — the github.com redirect + * hop carries no Access-Control-Allow-Origin. Releases stay the origin + * of truth; this is the edge front door on the API's own domain. + */ +rest.get("/v1/assets/*", async (c) => { + const path = c.req.path.replace(/^\/v1\/assets\//, "") + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._.-]*$/.test(path)) { + return errorResponse(400, "bad_request", "path must be {tag}/{file}") + } + const upstream = await fetch(`${RELEASE_BASE}/${path}`).catch(() => null) + if (!upstream || !upstream.ok) { + return errorResponse(404, "asset_not_found", `Couldn't locate ${path}`) + } + const headers = new Headers() + headers.set("access-control-allow-origin", "*") + headers.set("content-type", upstream.headers.get("content-type") ?? "application/octet-stream") + const length = upstream.headers.get("content-length") + if (length) headers.set("content-length", length) + return new Response(upstream.body, { status: upstream.status, headers }) +}) + rest.get("/v1/models", (c) => c.json({ count: listModels().length, models: listModels() })) rest.get("/v1/models/:id", (c) => { diff --git a/test/rest.test.ts b/test/rest.test.ts index 50a8aa0..5afef2d 100644 --- a/test/rest.test.ts +++ b/test/rest.test.ts @@ -222,3 +222,31 @@ describe("POST /v1/infer/batch", () => { expect(many.status).toBe(400) }) }) + +describe("GET /v1/assets/* (CORS release proxy)", () => { + it("streams release assets with permissive CORS and passthrough status", async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async (url: string | URL | Request) => { + if (String(url).includes("releases/download")) { + return new Response("asset-bytes", { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }) + } + return originalFetch(url as RequestInfo) + }) as typeof fetch + try { + const res = await get("/v1/assets/index-v2/models-index.yaml") + expect(res.status).toBe(200) + expect(res.headers.get("access-control-allow-origin")).toBe("*") + expect(await res.text()).toBe("asset-bytes") + } finally { + globalThis.fetch = originalFetch + } + }) + + it("rejects encoded traversal within the assets path", async () => { + const res = await get("/v1/assets/index-v2/..%2fsecret.yaml") + expect([400, 404]).toContain(res.status) + }) +})