From 7c392295bf398d7c8e7978973be56991e48ff6ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 16:23:20 -0400 Subject: [PATCH] Expose release signature API --- cloudflare/src/index.ts | 48 +++++++++++ cloudflare/src/release_manifests.ts | 92 ++++++++++++++++++++++ cloudflare/tests/index.test.ts | 61 ++++++++++++++ cloudflare/tests/release_manifests.test.ts | 28 +++++++ 4 files changed, 229 insertions(+) diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index e1563e8..d48bff5 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,8 +1,11 @@ import type { Env } from "./bindings.js"; import { ReleaseManifestSchemaError, + ReleaseSignatureQueryError, parseReleaseManifestDocument, + parseReleaseSignatureQuery, releaseManifestKvKey, + releaseSignatureDocument, } from "./release_manifests.js"; import { SigningKeysSchemaError, @@ -24,6 +27,9 @@ export async function handleRequest(request: Request, env: Env): Promise { + if (request.method !== "GET") { + return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); + } + + let query; + try { + query = parseReleaseSignatureQuery(url.searchParams); + } catch (error) { + if (error instanceof ReleaseSignatureQueryError) { + return jsonResponse({ error: "invalid_release_signature_query" }, 400); + } + throw error; + } + + const kvKey = releaseManifestKvKey(env.ELY_ENVIRONMENT); + const value = await env.ELY_KV.get(kvKey); + if (value === null) { + return jsonResponse({ error: "release_manifest_unavailable" }, 503); + } + + try { + const manifest = parseReleaseManifestDocument(value); + const document = releaseSignatureDocument(manifest, query); + if (document === null) { + return jsonResponse({ error: "release_signature_not_found" }, 404); + } + return jsonResponse(document, 200, { + "Cache-Control": "public, max-age=120, stale-while-revalidate=60", + }); + } catch (error) { + if (error instanceof ReleaseManifestSchemaError) { + return jsonResponse({ error: "release_manifest_invalid" }, 500); + } + throw error; + } +} + function jsonResponse( body: unknown, status: number, diff --git a/cloudflare/src/release_manifests.ts b/cloudflare/src/release_manifests.ts index 0ebaa7a..40b1351 100644 --- a/cloudflare/src/release_manifests.ts +++ b/cloudflare/src/release_manifests.ts @@ -25,6 +25,23 @@ export interface ReleaseManifestDocument { artifacts: ReleaseArtifact[]; } +export interface ReleaseSignatureQuery { + platform: string; + architecture: string; + version?: string; +} + +export interface ReleaseSignatureDocument { + version: 1; + channel: string; + generated_at: string; + platform: string; + architecture: string; + release_version: string; + sha256: string; + signature: string; +} + export class ReleaseManifestSchemaError extends Error { constructor(message: string) { super(message); @@ -32,6 +49,13 @@ export class ReleaseManifestSchemaError extends Error { } } +export class ReleaseSignatureQueryError extends Error { + constructor(message: string) { + super(message); + this.name = "ReleaseSignatureQueryError"; + } +} + export function releaseManifestKvKey(environment: string): string { return prefixedKvKey(environment, RELEASE_MANIFEST_NAMESPACE); } @@ -61,6 +85,53 @@ export function parseReleaseManifestDocument(value: string): ReleaseManifestDocu }; } +export function parseReleaseSignatureQuery( + searchParams: URLSearchParams, +): ReleaseSignatureQuery { + const allowedFields = new Set(["platform", "architecture", "version"]); + for (const field of searchParams.keys()) { + if (!allowedFields.has(field)) { + throw new ReleaseSignatureQueryError(`unknown release signature query field: ${field}`); + } + } + + const platform = requiredQueryField(searchParams, "platform"); + const architecture = requiredQueryField(searchParams, "architecture"); + const version = optionalQueryField(searchParams, "version"); + return { + platform: releasePlatform(platform), + architecture: releaseArchitecture(architecture), + ...(version === undefined ? {} : { version: releaseVersion(version) }), + }; +} + +export function releaseSignatureDocument( + manifest: ReleaseManifestDocument, + query: ReleaseSignatureQuery, +): ReleaseSignatureDocument | null { + const artifact = manifest.artifacts.find((value) => { + const targetMatches = + value.platform === query.platform && value.architecture === query.architecture; + const versionMatches = query.version === undefined || value.version === query.version; + return targetMatches && versionMatches; + }); + + if (artifact === undefined) { + return null; + } + + return { + version: 1, + channel: manifest.channel, + generated_at: manifest.generated_at, + platform: artifact.platform, + architecture: artifact.architecture, + release_version: artifact.version, + sha256: artifact.sha256, + signature: artifact.signature, + }; +} + function parseArtifacts(value: unknown): ReleaseArtifact[] { if (!Array.isArray(value) || value.length === 0) { throw new ReleaseManifestSchemaError("release manifest must contain artifacts"); @@ -198,3 +269,24 @@ function assertOnlyFields( function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function requiredQueryField(searchParams: URLSearchParams, field: string): string { + const values = searchParams.getAll(field); + const [value] = values; + if (values.length !== 1 || value === undefined || value.trim() === "") { + throw new ReleaseSignatureQueryError(`${field} query parameter is required`); + } + return value.trim(); +} + +function optionalQueryField(searchParams: URLSearchParams, field: string): string | undefined { + const values = searchParams.getAll(field); + if (values.length === 0) { + return undefined; + } + const [value] = values; + if (values.length !== 1 || value === undefined || value.trim() === "") { + throw new ReleaseSignatureQueryError(`${field} query parameter is invalid`); + } + return value.trim(); +} diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index 337b561..dadb686 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -139,6 +139,67 @@ describe("worker routes", () => { assert.equal(response.status, 500); assert.deepEqual(await response.json(), { error: "release_manifest_invalid" }); }); + + it("returns release signature from the release manifest cache", async () => { + const response = await handleRequest( + new Request( + "https://elydora.test/api/releases/signature?platform=macos&architecture=aarch64", + ), + testEnv(null, releaseManifestDocument()), + ); + + assert.equal(response.status, 200); + assert.equal( + response.headers.get("cache-control"), + "public, max-age=120, stale-while-revalidate=60", + ); + assert.deepEqual(await response.json(), { + version: 1, + channel: "stable", + generated_at: "2026-05-08T00:00:00.000Z", + platform: "macos", + architecture: "aarch64", + release_version: "0.1.0", + sha256: RELEASE_SHA256, + signature: RELEASE_SIGNATURE, + }); + }); + + it("rejects invalid release signature query parameters", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/releases/signature?platform=macos"), + testEnv(null, releaseManifestDocument()), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_release_signature_query" }); + }); + + it("returns not found for unmatched release signature targets", async () => { + const response = await handleRequest( + new Request( + "https://elydora.test/api/releases/signature?platform=macos&architecture=x86_64", + ), + testEnv(null, releaseManifestDocument()), + ); + + assert.equal(response.status, 404); + assert.deepEqual(await response.json(), { error: "release_signature_not_found" }); + }); + + it("rejects unsupported methods on release signature", async () => { + const response = await handleRequest( + new Request( + "https://elydora.test/api/releases/signature?platform=macos&architecture=aarch64", + { method: "POST" }, + ), + testEnv(null, releaseManifestDocument()), + ); + + assert.equal(response.status, 405); + assert.equal(response.headers.get("allow"), "GET"); + assert.deepEqual(await response.json(), { error: "method_not_allowed" }); + }); }); function testEnv(publicSigningKeys: string | null, releaseManifest?: string | null): Env { diff --git a/cloudflare/tests/release_manifests.test.ts b/cloudflare/tests/release_manifests.test.ts index 9f1f0b5..f1bef42 100644 --- a/cloudflare/tests/release_manifests.test.ts +++ b/cloudflare/tests/release_manifests.test.ts @@ -3,8 +3,11 @@ import { describe, it } from "node:test"; import { ReleaseManifestSchemaError, + ReleaseSignatureQueryError, parseReleaseManifestDocument, + parseReleaseSignatureQuery, releaseManifestKvKey, + releaseSignatureDocument, } from "../src/release_manifests.js"; const SHA256 = "a".repeat(64); @@ -72,6 +75,31 @@ describe("release manifests", () => { ReleaseManifestSchemaError, ); }); + + it("extracts signature details for a release target", () => { + const manifest = parseReleaseManifestDocument(validManifest()); + const query = parseReleaseSignatureQuery( + new URLSearchParams("platform=macos&architecture=aarch64&version=0.1.0"), + ); + + assert.deepEqual(releaseSignatureDocument(manifest, query), { + version: 1, + channel: "stable", + generated_at: "2026-05-08T00:00:00.000Z", + platform: "macos", + architecture: "aarch64", + release_version: "0.1.0", + sha256: SHA256, + signature: SIGNATURE, + }); + }); + + it("rejects malformed release signature queries", () => { + assert.throws( + () => parseReleaseSignatureQuery(new URLSearchParams("platform=macos")), + ReleaseSignatureQueryError, + ); + }); }); function validManifest(overrides: Record = {}): string {