Expose release signature API

This commit is contained in:
2026-05-08 16:23:20 -04:00
parent c8a7a32673
commit 7c392295bf
4 changed files with 229 additions and 0 deletions
+48
View File
@@ -1,8 +1,11 @@
import type { Env } from "./bindings.js"; import type { Env } from "./bindings.js";
import { import {
ReleaseManifestSchemaError, ReleaseManifestSchemaError,
ReleaseSignatureQueryError,
parseReleaseManifestDocument, parseReleaseManifestDocument,
parseReleaseSignatureQuery,
releaseManifestKvKey, releaseManifestKvKey,
releaseSignatureDocument,
} from "./release_manifests.js"; } from "./release_manifests.js";
import { import {
SigningKeysSchemaError, SigningKeysSchemaError,
@@ -24,6 +27,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (url.pathname === "/api/releases/manifest") { if (url.pathname === "/api/releases/manifest") {
return handleReleaseManifest(request, env); return handleReleaseManifest(request, env);
} }
if (url.pathname === "/api/releases/signature") {
return handleReleaseSignature(request, env, url);
}
return jsonResponse({ error: "not_found" }, 404); return jsonResponse({ error: "not_found" }, 404);
} }
@@ -76,6 +82,48 @@ async function handleReleaseManifest(request: Request, env: Env): Promise<Respon
} }
} }
async function handleReleaseSignature(
request: Request,
env: Env,
url: URL,
): Promise<Response> {
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( function jsonResponse(
body: unknown, body: unknown,
status: number, status: number,
+92
View File
@@ -25,6 +25,23 @@ export interface ReleaseManifestDocument {
artifacts: ReleaseArtifact[]; 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 { export class ReleaseManifestSchemaError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); 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 { export function releaseManifestKvKey(environment: string): string {
return prefixedKvKey(environment, RELEASE_MANIFEST_NAMESPACE); 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[] { function parseArtifacts(value: unknown): ReleaseArtifact[] {
if (!Array.isArray(value) || value.length === 0) { if (!Array.isArray(value) || value.length === 0) {
throw new ReleaseManifestSchemaError("release manifest must contain artifacts"); throw new ReleaseManifestSchemaError("release manifest must contain artifacts");
@@ -198,3 +269,24 @@ function assertOnlyFields(
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value); 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();
}
+61
View File
@@ -139,6 +139,67 @@ describe("worker routes", () => {
assert.equal(response.status, 500); assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "release_manifest_invalid" }); 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 { function testEnv(publicSigningKeys: string | null, releaseManifest?: string | null): Env {
@@ -3,8 +3,11 @@ import { describe, it } from "node:test";
import { import {
ReleaseManifestSchemaError, ReleaseManifestSchemaError,
ReleaseSignatureQueryError,
parseReleaseManifestDocument, parseReleaseManifestDocument,
parseReleaseSignatureQuery,
releaseManifestKvKey, releaseManifestKvKey,
releaseSignatureDocument,
} from "../src/release_manifests.js"; } from "../src/release_manifests.js";
const SHA256 = "a".repeat(64); const SHA256 = "a".repeat(64);
@@ -72,6 +75,31 @@ describe("release manifests", () => {
ReleaseManifestSchemaError, 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, unknown> = {}): string { function validManifest(overrides: Record<string, unknown> = {}): string {