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 {
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<Respons
if (url.pathname === "/api/releases/manifest") {
return handleReleaseManifest(request, env);
}
if (url.pathname === "/api/releases/signature") {
return handleReleaseSignature(request, env, url);
}
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(
body: unknown,
status: number,
+92
View File
@@ -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<string, unknown> {
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();
}