diff --git a/.changeset/sdk-download-presigned-1011.md b/.changeset/sdk-download-presigned-1011.md new file mode 100644 index 00000000..ecef51fe --- /dev/null +++ b/.changeset/sdk-download-presigned-1011.md @@ -0,0 +1,5 @@ +--- +"@chronoai/ornn-sdk": patch +--- + +Fix package download in both SDKs. `downloadPackage` / `download_package` (and the `pullClosure` / `pull_closure` that build on them) targeted a `GET /skills/:id/versions/:version/download` endpoint that does not exist server-side, so every package pull 404'd and both README quickstarts were broken. They now resolve the package via the skill-detail `presignedPackageUrl` (honoring an optional `version`), fetch the bytes directly from object storage, and verify them against the skill's `skillHash` (SRI) when present. (#1011) diff --git a/sdk/python/README.md b/sdk/python/README.md index f5d50907..8130023d 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -45,7 +45,10 @@ with OrnnClient( # Pull pkg = ornn.download_package(skill.id, skill.latest_version) - # pkg is raw bytes — write to disk, pass to zipfile, etc. + # pkg is raw bytes — write to disk, pass to zipfile, etc. Resolves the + # version's presigned package URL from the skill detail and fetches it + # directly; verifies the bytes against `skill_hash` (SRI) when present. + # version is optional — omit it to pull the latest. # Publish new_skill = ornn.publish(pkg) @@ -88,7 +91,7 @@ Error codes follow [`docs/CONVENTIONS.md` §1.4](../../docs/CONVENTIONS.md) (low | `search(...)` | `GET /skill-search` — returns `SkillSearchResult` | | `get(guid_or_name, version=None)` | `GET /skills/:id` — returns `SkillDetail` | | `list_versions(guid_or_name)` | `GET /skills/:id/versions` — returns `list[SkillVersionEntry]` | -| `download_package(guid, version)` | `GET /skills/:id/versions/:v/download` — returns `bytes` | +| `download_package(guid, version=None)` | `GET /skills/:id` → fetch `presigned_package_url` (SRI-verified) — returns `bytes` | | `publish(zip_bytes, skip_validation=False)` | `POST /skills` — returns `SkillDetail` | | `update(id, metadata=..., zip_bytes=..., skip_validation=False)` | `PUT /skills/:id` — returns `SkillDetail` | | `delete(id)` | `DELETE /skills/:id` | diff --git a/sdk/python/src/ornn_sdk/client.py b/sdk/python/src/ornn_sdk/client.py index 55b6f649..253aed80 100644 --- a/sdk/python/src/ornn_sdk/client.py +++ b/sdk/python/src/ornn_sdk/client.py @@ -14,6 +14,7 @@ from __future__ import annotations +import hashlib from collections.abc import Callable from typing import Any from urllib.parse import urlencode @@ -75,6 +76,10 @@ def __init__( self._base_url = base_url.rstrip("/") self._static_token = token self._token_resolver = token_resolver + # Retained for the one-shot presigned-URL fetch in + # download_package, which bypasses self._http (object storage is + # outside /api/v1 and unauthenticated). + self._timeout = timeout self._http = httpx.Client( base_url=f"{self._base_url}/api/v1", transport=transport, @@ -143,16 +148,59 @@ def list_versions(self, guid_or_name: str) -> list[SkillVersionEntry]: data = self.request("GET", f"/skills/{_quote(guid_or_name)}/versions") return [SkillVersionEntry.from_dict(v) for v in data.get("items") or []] - def download_package(self, guid: str, version: str) -> bytes: - """Download a skill package ZIP. Returns raw bytes.""" - path = f"/skills/{_quote(guid)}/versions/{_quote(version)}/download" - res = self._raw_request("GET", path) + def download_package(self, guid: str, version: str | None = None) -> bytes: + """Download a skill package ZIP. Returns raw bytes. + + There is no ``/api/v1`` download endpoint: the bytes live in + object storage behind a time-limited presigned URL. This resolves + that URL via the skill-detail read (:meth:`get`), then fetches the + absolute URL directly with a one-shot ``httpx.get`` — NOT through + :meth:`_raw_request`, which would wrongly prefix ``/api/v1`` and + attach the NyxID bearer to an object-storage request. + + When the detail carries ``skill_hash`` (hex SHA-256), the + downloaded bytes are re-hashed and compared (SRI-style); a + mismatch raises :class:`OrnnError` with code ``integrity_mismatch``. + + ``version`` is optional — defaults to the skill's latest version + (resolved server-side) when omitted. + """ + detail = self.get(guid, version=version) + url = detail.presigned_package_url + if not url: + ref = f"{guid}@{version}" if version else guid + raise OrnnError( + status=404, + code="package_not_found", + message=f"Ornn: no downloadable package for skill {ref}", + ) + # Bare httpx.get — the presigned URL is absolute object storage, + # outside the /api/v1 surface and not authenticated with the + # NyxID bearer. + res = httpx.get(url, timeout=self._timeout, follow_redirects=True) if res.status_code >= 400: - raise _build_error(res) + raise OrnnError( + status=res.status_code, + code="package_download_failed", + message=f"Ornn: package download failed with HTTP {res.status_code}", + ) # httpx exposes res.content as bytes at runtime; the typeshed # stub annotates it as Any. Cast explicitly so strict mypy is # happy without disabling the rule. - return bytes(res.content) + data = bytes(res.content) + if detail.skill_hash: + actual = hashlib.sha256(data).hexdigest() + if actual != detail.skill_hash: + ref = f"{guid}@{version}" if version else guid + raise OrnnError( + status=0, + code="integrity_mismatch", + message=( + f"Ornn: package integrity check failed for {ref} " + f"(expected {detail.skill_hash}, got {actual})" + ), + ) + return data def resolve_closure(self, guid_or_name: str, *, version: str | None = None) -> ClosureResult: """Resolve the full transitive dependency closure of a skill (#968). diff --git a/sdk/python/src/ornn_sdk/types.py b/sdk/python/src/ornn_sdk/types.py index ad5e2ac5..be49bb55 100644 --- a/sdk/python/src/ornn_sdk/types.py +++ b/sdk/python/src/ornn_sdk/types.py @@ -70,6 +70,13 @@ class SkillDetail(SkillSummary): storage_key: str | None = None shared_with_users: list[str] = field(default_factory=list) shared_with_orgs: list[str] = field(default_factory=list) + # Absolute, time-limited object-storage URL for the version's package + # ZIP (resolved server-side per ?version=). `download_package` fetches + # it directly (not via /api/v1). Absent when unreadable / no package. + presigned_package_url: str | None = None + # Hex SHA-256 of the package bytes — `download_package` verifies the + # downloaded bytes against it (SRI) when present. + skill_hash: str | None = None @classmethod def from_dict(cls, raw: dict[str, Any]) -> SkillDetail: @@ -77,7 +84,13 @@ def from_dict(cls, raw: dict[str, Any]) -> SkillDetail: base.pop("_extra") # `ownerId` was removed from the wire in #581 — tolerate it # showing up on old API responses (treat as unknown extra). - known_extra = {"storageKey", "sharedWithUsers", "sharedWithOrgs"} + known_extra = { + "storageKey", + "sharedWithUsers", + "sharedWithOrgs", + "presignedPackageUrl", + "skillHash", + } summary_known = { "id", "name", @@ -100,6 +113,8 @@ def from_dict(cls, raw: dict[str, Any]) -> SkillDetail: storage_key=raw.get("storageKey"), shared_with_users=list(raw.get("sharedWithUsers") or []), shared_with_orgs=list(raw.get("sharedWithOrgs") or []), + presigned_package_url=raw.get("presignedPackageUrl"), + skill_hash=raw.get("skillHash"), _extra=extra, ) diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index df33d47e..0c2c1590 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -253,30 +253,79 @@ def test_list_versions_unwraps_items(self) -> None: assert versions[0].is_latest is True +def _detail_data(**overrides: object) -> dict[str, object]: + base: dict[str, object] = { + "id": "abc", + "name": "abc", + "description": "", + "isPrivate": False, + "createdBy": "u1", + "createdOn": "2026-01-01T00:00:00Z", + } + base.update(overrides) + return base + + +PRESIGNED = "https://obj.example.com/bucket/abc-1.0.zip?sig=xyz" + + class TestDownload: @respx.mock - def test_download_returns_raw_bytes(self) -> None: + def test_download_resolves_presigned_url_and_fetches_bytes(self) -> None: + # download_package now reads the skill detail for the presigned + # URL (#1011), then fetches that absolute object-storage URL. zip_bytes = b"PK\x03\x04\x01\x02\x03" - respx.get(f"{BASE}/api/v1/skills/abc/versions/1.0/download").respond( + detail = respx.get(f"{BASE}/api/v1/skills/abc").respond( 200, - content=zip_bytes, - headers={"Content-Type": "application/zip"}, + json={ + "data": _detail_data(presignedPackageUrl=PRESIGNED), + "error": None, + }, + ) + obj = respx.get(PRESIGNED).respond( + 200, content=zip_bytes, headers={"Content-Type": "application/zip"} ) with make_client() as ornn: result = ornn.download_package("abc", "1.0") assert result == zip_bytes + # Detail read threads ?version= ; the object fetch is the bare URL. + assert detail.calls.last.request.url.params["version"] == "1.0" + assert obj.called @respx.mock - def test_download_raises_on_error(self) -> None: - respx.get(f"{BASE}/api/v1/skills/abc/versions/9.9/download").respond( + def test_download_version_optional_omits_version_param(self) -> None: + detail = respx.get(f"{BASE}/api/v1/skills/abc").respond( + 200, + json={"data": _detail_data(presignedPackageUrl=PRESIGNED), "error": None}, + ) + respx.get(PRESIGNED).respond(200, content=b"PK") + with make_client() as ornn: + ornn.download_package("abc") + assert "version" not in detail.calls.last.request.url.params + + @respx.mock + def test_download_raises_package_not_found_without_presigned_url(self) -> None: + respx.get(f"{BASE}/api/v1/skills/abc").respond( + 200, json={"data": _detail_data(), "error": None} + ) + with make_client() as ornn: + with pytest.raises(OrnnError) as excinfo: + ornn.download_package("abc", "1.0") + assert excinfo.value.status == 404 + assert excinfo.value.code == "package_not_found" + + @respx.mock + def test_download_raises_when_skill_read_404s(self) -> None: + # The detail read is the first hop; its 404 (RFC 7807) propagates. + respx.get(f"{BASE}/api/v1/skills/abc").respond( 404, json={ "type": "https://github.com/.../ERRORS.md#resource_not_found", "title": "Resource not found", "status": 404, "code": "resource_not_found", - "detail": "no such version", - "instance": "/v1/skills/abc/versions/9.9/download", + "detail": "no such skill", + "instance": "/v1/skills/abc", }, ) with make_client() as ornn: @@ -285,6 +334,53 @@ def test_download_raises_on_error(self) -> None: assert excinfo.value.status == 404 assert excinfo.value.code == "resource_not_found" + @respx.mock + def test_download_raises_package_download_failed_on_non_2xx(self) -> None: + respx.get(f"{BASE}/api/v1/skills/abc").respond( + 200, + json={"data": _detail_data(presignedPackageUrl=PRESIGNED), "error": None}, + ) + # Object storage rejects the (now-expired) presigned URL. + respx.get(PRESIGNED).respond(403, text="expired") + with make_client() as ornn: + with pytest.raises(OrnnError) as excinfo: + ornn.download_package("abc", "1.0") + assert excinfo.value.status == 403 + assert excinfo.value.code == "package_download_failed" + + @respx.mock + def test_download_verifies_skill_hash_and_returns_bytes(self) -> None: + import hashlib + + zip_bytes = b"PK\x03\x04" + expected = hashlib.sha256(zip_bytes).hexdigest() + respx.get(f"{BASE}/api/v1/skills/abc").respond( + 200, + json={ + "data": _detail_data(presignedPackageUrl=PRESIGNED, skillHash=expected), + "error": None, + }, + ) + respx.get(PRESIGNED).respond(200, content=zip_bytes) + with make_client() as ornn: + result = ornn.download_package("abc", "1.0") + assert result == zip_bytes + + @respx.mock + def test_download_raises_integrity_mismatch_on_bad_hash(self) -> None: + respx.get(f"{BASE}/api/v1/skills/abc").respond( + 200, + json={ + "data": _detail_data(presignedPackageUrl=PRESIGNED, skillHash="deadbeef"), + "error": None, + }, + ) + respx.get(PRESIGNED).respond(200, content=b"PK\x03\x04") + with make_client() as ornn: + with pytest.raises(OrnnError) as excinfo: + ornn.download_package("abc", "1.0") + assert excinfo.value.code == "integrity_mismatch" + class TestClosure: @respx.mock @@ -374,10 +470,28 @@ def test_pull_closure_downloads_in_topo_order(self) -> None: "error": None, }, ) - dl_c = respx.get(f"{BASE}/api/v1/skills/g-c/versions/1.0/download").respond( + # Per-node skill-detail reads (#1011) resolve each node's + # presigned URL; the bytes then come from object storage. + url_c = "https://obj.example.com/g-c.zip" + url_b = "https://obj.example.com/g-b.zip" + detail_c = respx.get(f"{BASE}/api/v1/skills/g-c").respond( + 200, + json={ + "data": _detail_data(id="g-c", name="g-c", presignedPackageUrl=url_c), + "error": None, + }, + ) + detail_b = respx.get(f"{BASE}/api/v1/skills/g-b").respond( + 200, + json={ + "data": _detail_data(id="g-b", name="g-b", presignedPackageUrl=url_b), + "error": None, + }, + ) + dl_c = respx.get(url_c).respond( 200, content=b"PKc", headers={"Content-Type": "application/zip"} ) - dl_b = respx.get(f"{BASE}/api/v1/skills/g-b/versions/1.0/download").respond( + dl_b = respx.get(url_b).respond( 200, content=b"PKb", headers={"Content-Type": "application/zip"} ) with make_client() as ornn: @@ -386,6 +500,7 @@ def test_pull_closure_downloads_in_topo_order(self) -> None: # Downloads follow the closure order — c (deps-first) before b. assert [node.name for node, _ in packages] == ["c", "b"] assert packages[0][1] == b"PKc" + assert detail_c.called and detail_b.called assert dl_c.called and dl_b.called diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 552cab4a..1fb6fc0e 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -46,8 +46,11 @@ const { items } = await ornn.search({ q: "pdf", scope: "public" }); const skill = await ornn.get(items[0]!.id); // Pull -const pkg = await ornn.downloadPackage(skill.id, skill.latestVersion!); -// pkg is an ArrayBuffer — write to disk, unzip, etc. +const pkg = await ornn.downloadPackage(skill.id, skill.latestVersion); +// pkg is an ArrayBuffer — write to disk, unzip, etc. Resolves the +// version's presigned package URL from the skill detail and fetches it +// directly; verifies the bytes against `skillHash` (SRI) when present. +// version is optional — omit it to pull the latest. // Publish const newSkill = await ornn.publish(pkg); // or a Blob / Uint8Array @@ -93,7 +96,7 @@ Error codes follow [`docs/CONVENTIONS.md` §1.4](../../docs/CONVENTIONS.md) (low | `search(params)` | `GET /skill-search` | | `get(guidOrName, version?)` | `GET /skills/:id` | | `listVersions(guidOrName)` | `GET /skills/:id/versions` | -| `downloadPackage(guid, version)` | `GET /skills/:id/versions/:version/download` (returns `ArrayBuffer`) | +| `downloadPackage(guid, version?)` | `GET /skills/:id` → fetch `presignedPackageUrl` (SRI-verified; returns `ArrayBuffer`) | | `publish(zip, options?)` | `POST /skills` (`application/zip` body) | | `update(id, { metadata? | zip? }, options?)` | `PUT /skills/:id` | | `delete(id)` | `DELETE /skills/:id` | diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index fc2588ba..91d524e4 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -16,6 +16,7 @@ */ import { OrnnError, type OrnnErrorPayload } from "./errors"; +import { sha256Hex } from "./integrity"; import type { ClosureNode, ClosureResult, @@ -177,16 +178,57 @@ export class OrnnClient { * * Returns the raw package bytes. Callers typically pipe this into * JSZip, write it to disk, or upload it elsewhere. + * + * There is no `/api/v1` download endpoint: the bytes live in object + * storage behind a time-limited presigned URL. This resolves that URL + * via the skill-detail read (`get(guid, version)`), then fetches the + * absolute URL directly with the global/injected fetch — NOT through + * {@link rawRequest}, which would wrongly prefix `/api/v1` and attach + * the NyxID bearer to an object-storage request. + * + * When the detail carries `skillHash` (hex SHA-256), the downloaded + * bytes are re-hashed and compared (SRI-style); a mismatch throws + * `OrnnError` with code `integrity_mismatch`. + * + * @param version Optional. Defaults to the skill's latest version + * (resolved server-side) when omitted. */ - async downloadPackage(guid: string, version: string): Promise { - const res = await this.rawRequest( - "GET", - `/skills/${encodeURIComponent(guid)}/versions/${encodeURIComponent(version)}/download`, - ); + async downloadPackage(guid: string, version?: string): Promise { + const detail = await this.get(guid, version); + const url = detail.presignedPackageUrl; + if (!url) { + throw new OrnnError({ + status: 404, + code: "package_not_found", + message: `Ornn: no downloadable package for skill ${guid}${ + version ? `@${version}` : "" + }`, + }); + } + // Bare fetch — the presigned URL is absolute object storage, outside + // the `/api/v1` surface and not authenticated with the NyxID bearer. + const res = await this.fetchImpl(url); if (!res.ok) { - throw await parseError(res); + throw new OrnnError({ + status: res.status, + code: "package_download_failed", + message: `Ornn: package download failed with HTTP ${res.status}`, + }); } - return res.arrayBuffer(); + const bytes = await res.arrayBuffer(); + if (detail.skillHash) { + const actual = await sha256Hex(bytes); + if (actual !== detail.skillHash) { + throw new OrnnError({ + status: 0, + code: "integrity_mismatch", + message: `Ornn: package integrity check failed for ${guid}${ + version ? `@${version}` : "" + } (expected ${detail.skillHash}, got ${actual})`, + }); + } + } + return bytes; } /** @@ -433,11 +475,6 @@ function zipToBlob(zip: Blob | ArrayBuffer | Uint8Array): Blob { return new Blob([zip as BlobPart], { type: "application/zip" }); } -async function parseError(res: Response): Promise { - const body = (await res.json().catch(() => null)) as ProblemJson | null; - return buildError(res.status, body); -} - function buildError(status: number, body: ProblemJson | null): OrnnError { if (body && (body.code || body.detail || body.title)) { // exactOptionalPropertyTypes (#450): only stamp `requestId` / diff --git a/sdk/typescript/src/integrity.ts b/sdk/typescript/src/integrity.ts new file mode 100644 index 00000000..f1a70307 --- /dev/null +++ b/sdk/typescript/src/integrity.ts @@ -0,0 +1,40 @@ +/** + * Package-integrity helpers. + * + * The skill-detail response carries `skillHash` (hex SHA-256 of the + * package bytes). After downloading a package from its presigned + * object-storage URL, the SDK re-hashes the bytes and compares — an + * SRI-style guard against a tampered or corrupted artifact. Extracted + * from `client.ts` to keep that file under the 500-line cap and to keep + * the WebCrypto/hex plumbing isolated from the HTTP machinery. + * + * @module integrity + */ + +/** + * Compute the lowercase hex SHA-256 of `bytes` via WebCrypto. + * + * Uses `crypto.subtle` from the global scope (available in browsers, + * Node 20+, Bun, Deno, modern Workers). Throws a clear error if the + * runtime exposes no WebCrypto rather than silently skipping the check. + */ +export async function sha256Hex(bytes: ArrayBuffer): Promise { + const subtle = globalThis.crypto?.subtle; + if (!subtle) { + throw new Error( + "OrnnClient: WebCrypto (crypto.subtle) is unavailable; cannot verify package integrity", + ); + } + const digest = await subtle.digest("SHA-256", bytes); + return bufferToHex(digest); +} + +/** Lowercase hex-encode an ArrayBuffer without intermediate string churn. */ +function bufferToHex(buffer: ArrayBuffer): string { + const view = new Uint8Array(buffer); + let hex = ""; + for (let i = 0; i < view.length; i++) { + hex += view[i]!.toString(16).padStart(2, "0"); + } + return hex; +} diff --git a/sdk/typescript/src/tests/client.test.ts b/sdk/typescript/src/tests/client.test.ts index 1f57f416..f1adf90d 100644 --- a/sdk/typescript/src/tests/client.test.ts +++ b/sdk/typescript/src/tests/client.test.ts @@ -226,26 +226,73 @@ describe("OrnnClient", () => { expect(capturedUrl).toContain("/skills?skip_validation=true"); }); - test("downloadPackage(): returns the raw bytes, not JSON", async () => { + test("downloadPackage(): resolves the presigned URL via detail, fetches the bytes (#1011)", async () => { const zipBytes = new Uint8Array([80, 75, 3, 4, 1, 2, 3]); - const fetchMock = mockFetch( - () => new Response(zipBytes, { status: 200, headers: { "Content-Type": "application/zip" } }), - ); + const presignedUrl = "https://obj.example.com/bucket/abc-1.0.zip?sig=xyz"; + let detailUrl = ""; + let fetchedPresigned = ""; + const fetchMock = mockFetch((url) => { + if (url.includes("/api/v1/skills/")) { + detailUrl = url; + // Skill-detail read — carries the presigned URL, no skillHash. + return jsonResponse(200, { + data: { id: "abc", name: "abc", presignedPackageUrl: presignedUrl }, + error: null, + }); + } + // The absolute object-storage URL — returns the raw package bytes. + fetchedPresigned = url; + return new Response(zipBytes, { + status: 200, + headers: { "Content-Type": "application/zip" }, + }); + }); const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); const buf = await client.downloadPackage("abc", "1.0"); + // Detail read threads ?version= ; the presigned fetch is the bare URL. + expect(detailUrl).toBe("https://x/api/v1/skills/abc?version=1.0"); + expect(fetchedPresigned).toBe(presignedUrl); expect(buf.byteLength).toBe(zipBytes.byteLength); expect(new Uint8Array(buf)[0]).toBe(80); }); - test("downloadPackage(): throws OrnnError on 404", async () => { - // 404 body is RFC 7807 problem+json (#456) — fields at the root. + test("downloadPackage(): version is optional — omits ?version= (#1011)", async () => { + let detailUrl = ""; + const fetchMock = mockFetch((url) => { + if (url.includes("/api/v1/skills/")) { + detailUrl = url; + return jsonResponse(200, { + data: { id: "abc", name: "abc", presignedPackageUrl: "https://obj.example.com/z.zip" }, + error: null, + }); + } + return new Response(new Uint8Array([80, 75]), { status: 200 }); + }); + const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); + await client.downloadPackage("abc"); + expect(detailUrl).toBe("https://x/api/v1/skills/abc"); + }); + + test("downloadPackage(): throws package_not_found when detail has no presigned URL (#1011)", async () => { + const fetchMock = mockFetch(() => + jsonResponse(200, { data: { id: "abc", name: "abc" }, error: null }), + ); + const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); + const err = (await client.downloadPackage("abc", "1.0").catch((e) => e)) as OrnnError; + expect(err).toBeInstanceOf(OrnnError); + expect(err.status).toBe(404); + expect(err.code).toBe("package_not_found"); + }); + + test("downloadPackage(): throws OrnnError when the underlying skill read 404s (#1011)", async () => { + // The detail read is the first hop; its 404 (RFC 7807) propagates. const fetchMock = mockFetch(() => jsonResponse(404, { type: "https://github.com/.../ERRORS.md#resource_not_found", title: "Resource not found", status: 404, code: "resource_not_found", - detail: "no such version", + detail: "no such skill", }), ); const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); @@ -255,6 +302,72 @@ describe("OrnnClient", () => { expect(err.code).toBe("resource_not_found"); }); + test("downloadPackage(): throws package_download_failed on a non-2xx presigned fetch (#1011)", async () => { + const fetchMock = mockFetch((url) => { + if (url.includes("/api/v1/skills/")) { + return jsonResponse(200, { + data: { id: "abc", name: "abc", presignedPackageUrl: "https://obj.example.com/z.zip" }, + error: null, + }); + } + // Object storage rejects the (now-expired) presigned URL. + return new Response("expired", { status: 403 }); + }); + const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); + const err = (await client.downloadPackage("abc", "1.0").catch((e) => e)) as OrnnError; + expect(err).toBeInstanceOf(OrnnError); + expect(err.status).toBe(403); + expect(err.code).toBe("package_download_failed"); + }); + + test("downloadPackage(): verifies skillHash and returns the bytes on match (#1011)", async () => { + const zipBytes = new Uint8Array([80, 75, 3, 4]); + // Pre-computed SHA-256 of the four bytes below, asserted at runtime + // against the SDK's own WebCrypto digest so the test is self-checking. + const digest = await crypto.subtle.digest("SHA-256", zipBytes); + const expectedHash = Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + const fetchMock = mockFetch((url) => { + if (url.includes("/api/v1/skills/")) { + return jsonResponse(200, { + data: { + id: "abc", + name: "abc", + presignedPackageUrl: "https://obj.example.com/z.zip", + skillHash: expectedHash, + }, + error: null, + }); + } + return new Response(zipBytes, { status: 200 }); + }); + const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); + const buf = await client.downloadPackage("abc", "1.0"); + expect(new Uint8Array(buf)).toEqual(zipBytes); + }); + + test("downloadPackage(): throws integrity_mismatch when skillHash disagrees (#1011)", async () => { + const fetchMock = mockFetch((url) => { + if (url.includes("/api/v1/skills/")) { + return jsonResponse(200, { + data: { + id: "abc", + name: "abc", + presignedPackageUrl: "https://obj.example.com/z.zip", + skillHash: "deadbeef", // deliberately wrong + }, + error: null, + }); + } + return new Response(new Uint8Array([80, 75, 3, 4]), { status: 200 }); + }); + const client = new OrnnClient({ baseUrl: "https://x", fetch: fetchMock }); + const err = (await client.downloadPackage("abc", "1.0").catch((e) => e)) as OrnnError; + expect(err).toBeInstanceOf(OrnnError); + expect(err.code).toBe("integrity_mismatch"); + }); + test("resolveClosure(): parses the topo-ordered items envelope (#968)", async () => { let capturedUrl = ""; const fetchMock = mockFetch((url) => { @@ -318,9 +431,22 @@ describe("OrnnClient", () => { error: null, }); } - // download path: /skills/:guid/versions/:version/download - const match = url.match(/\/skills\/([^/]+)\/versions\//); - downloadOrder.push(match?.[1] ?? "?"); + // Per-node skill-detail read (#1011) — record order, hand back a + // presigned URL pointing at that node's object-storage artifact. + const detailMatch = url.match(/\/api\/v1\/skills\/([^/?]+)/); + if (detailMatch) { + const guid = detailMatch[1]!; + downloadOrder.push(guid); + return jsonResponse(200, { + data: { + id: guid, + name: guid, + presignedPackageUrl: `https://obj.example.com/${guid}.zip`, + }, + error: null, + }); + } + // The absolute presigned-URL fetch — raw bytes. return new Response(new Uint8Array([80, 75, 3, 4]), { status: 200, headers: { "Content-Type": "application/zip" }, diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 242f7f6a..5fe7dfd9 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -30,6 +30,19 @@ export interface SkillDetail extends SkillSummary { readonly storageKey?: string; readonly sharedWithUsers?: readonly string[]; readonly sharedWithOrgs?: readonly string[]; + /** + * Absolute, time-limited object-storage URL for the version's package + * ZIP. Resolved by the server per `?version=`; download the bytes by + * fetching this URL directly (NOT through `/api/v1`). Absent when the + * caller can't read the package or no package exists. + */ + readonly presignedPackageUrl?: string; + /** + * Hex SHA-256 of the package bytes for this version. When present, + * {@link OrnnClient.downloadPackage} verifies the downloaded bytes + * against it (SRI-style) and throws on mismatch. + */ + readonly skillHash?: string; } export interface SkillVersionEntry {