From c8a7a326733e1297889cbb1c83e34cd7c1b276b9 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:18:02 -0400 Subject: [PATCH] Expose release manifest API --- cloudflare/src/index.ts | 32 ++++ cloudflare/src/kv_keys.ts | 32 ++++ cloudflare/src/release_manifests.ts | 200 +++++++++++++++++++++ cloudflare/src/signing_keys.ts | 14 +- cloudflare/tests/index.test.ts | 89 ++++++++- cloudflare/tests/release_manifests.test.ts | 97 ++++++++++ 6 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 cloudflare/src/kv_keys.ts create mode 100644 cloudflare/src/release_manifests.ts create mode 100644 cloudflare/tests/release_manifests.test.ts diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 249d3b5..e1563e8 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,4 +1,9 @@ import type { Env } from "./bindings.js"; +import { + ReleaseManifestSchemaError, + parseReleaseManifestDocument, + releaseManifestKvKey, +} from "./release_manifests.js"; import { SigningKeysSchemaError, parsePublicSigningKeysDocument, @@ -16,6 +21,9 @@ export async function handleRequest(request: Request, env: Env): Promise { + if (request.method !== "GET") { + return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); + } + + 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 document = parseReleaseManifestDocument(value); + 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/kv_keys.ts b/cloudflare/src/kv_keys.ts new file mode 100644 index 0000000..66da635 --- /dev/null +++ b/cloudflare/src/kv_keys.ts @@ -0,0 +1,32 @@ +const KV_NAMESPACE_PREFIX = "ely"; +const ENVIRONMENT_NAME_PATTERN = /^[a-z0-9._-]{3,64}$/; +const KV_KEY_PART_PATTERN = /^[a-z0-9._-]{3,128}$/; + +export class KvKeySchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "KvKeySchemaError"; + } +} + +export function prefixedKvKey(environment: string, namespace: string): string { + const normalizedEnvironment = normalizedEnvironmentName(environment); + const normalizedNamespace = normalizedKvKeyPart("KV namespace", namespace); + return `${KV_NAMESPACE_PREFIX}:${normalizedEnvironment}:${normalizedNamespace}`; +} + +function normalizedEnvironmentName(value: string): string { + const environment = value.trim(); + if (!ENVIRONMENT_NAME_PATTERN.test(environment)) { + throw new KvKeySchemaError(`invalid environment name: ${value}`); + } + return environment; +} + +function normalizedKvKeyPart(label: string, value: string): string { + const keyPart = value.trim(); + if (!KV_KEY_PART_PATTERN.test(keyPart)) { + throw new KvKeySchemaError(`invalid ${label}: ${value}`); + } + return keyPart; +} diff --git a/cloudflare/src/release_manifests.ts b/cloudflare/src/release_manifests.ts new file mode 100644 index 0000000..0ebaa7a --- /dev/null +++ b/cloudflare/src/release_manifests.ts @@ -0,0 +1,200 @@ +import { prefixedKvKey } from "./kv_keys.js"; + +const RELEASE_MANIFEST_NAMESPACE = "release_manifest_cache"; +const RELEASE_CHANNEL_PATTERN = /^(stable|beta|nightly)$/; +const RELEASE_PLATFORM_PATTERN = /^(macos|windows|linux)$/; +const RELEASE_ARCHITECTURE_PATTERN = /^[a-z0-9._-]{2,32}$/; +const RELEASE_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; +const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/; +const ED25519_SIGNATURE_HEX_PATTERN = /^[a-f0-9]{128}$/; + +export interface ReleaseArtifact { + platform: string; + architecture: string; + version: string; + url: string; + sha256: string; + signature: string; + size_bytes: number; +} + +export interface ReleaseManifestDocument { + version: 1; + channel: string; + generated_at: string; + artifacts: ReleaseArtifact[]; +} + +export class ReleaseManifestSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "ReleaseManifestSchemaError"; + } +} + +export function releaseManifestKvKey(environment: string): string { + return prefixedKvKey(environment, RELEASE_MANIFEST_NAMESPACE); +} + +export function parseReleaseManifestDocument(value: string): ReleaseManifestDocument { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new ReleaseManifestSchemaError("release manifest must be valid JSON"); + } + + if (!isRecord(parsed)) { + throw new ReleaseManifestSchemaError("release manifest must be an object"); + } + assertOnlyFields(parsed, ["version", "channel", "generated_at", "artifacts"], "release manifest"); + + if (parsed.version !== 1) { + throw new ReleaseManifestSchemaError("release manifest version must be 1"); + } + + return { + version: 1, + channel: releaseChannel(stringField(parsed, "channel")), + generated_at: isoTimestamp(stringField(parsed, "generated_at"), "generated_at"), + artifacts: parseArtifacts(parsed.artifacts), + }; +} + +function parseArtifacts(value: unknown): ReleaseArtifact[] { + if (!Array.isArray(value) || value.length === 0) { + throw new ReleaseManifestSchemaError("release manifest must contain artifacts"); + } + + const artifacts: ReleaseArtifact[] = []; + const seenTargets = new Set(); + for (const artifactValue of value) { + const artifact = parseArtifact(artifactValue); + const target = `${artifact.platform}:${artifact.architecture}`; + if (seenTargets.has(target)) { + throw new ReleaseManifestSchemaError(`duplicate release artifact target: ${target}`); + } + seenTargets.add(target); + artifacts.push(artifact); + } + return artifacts; +} + +function parseArtifact(value: unknown): ReleaseArtifact { + if (!isRecord(value)) { + throw new ReleaseManifestSchemaError("release artifact must be an object"); + } + assertOnlyFields( + value, + ["platform", "architecture", "version", "url", "sha256", "signature", "size_bytes"], + "release artifact", + ); + + return { + platform: releasePlatform(stringField(value, "platform")), + architecture: releaseArchitecture(stringField(value, "architecture")), + version: releaseVersion(stringField(value, "version")), + url: httpsUrl(stringField(value, "url")), + sha256: sha256Hex(stringField(value, "sha256")), + signature: ed25519SignatureHex(stringField(value, "signature")), + size_bytes: positiveIntegerField(value, "size_bytes"), + }; +} + +function releaseChannel(value: string): string { + if (!RELEASE_CHANNEL_PATTERN.test(value)) { + throw new ReleaseManifestSchemaError(`invalid release channel: ${value}`); + } + return value; +} + +function releasePlatform(value: string): string { + if (!RELEASE_PLATFORM_PATTERN.test(value)) { + throw new ReleaseManifestSchemaError(`invalid release platform: ${value}`); + } + return value; +} + +function releaseArchitecture(value: string): string { + if (!RELEASE_ARCHITECTURE_PATTERN.test(value)) { + throw new ReleaseManifestSchemaError(`invalid release architecture: ${value}`); + } + return value; +} + +function releaseVersion(value: string): string { + if (!RELEASE_VERSION_PATTERN.test(value)) { + throw new ReleaseManifestSchemaError(`invalid release version: ${value}`); + } + return value; +} + +function httpsUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new ReleaseManifestSchemaError(`invalid release artifact URL: ${value}`); + } + if (url.protocol !== "https:") { + throw new ReleaseManifestSchemaError(`release artifact URL must use https: ${value}`); + } + return url.toString(); +} + +function sha256Hex(value: string): string { + const normalized = value.toLowerCase(); + if (!SHA256_HEX_PATTERN.test(normalized)) { + throw new ReleaseManifestSchemaError(`invalid release artifact sha256: ${value}`); + } + return normalized; +} + +function ed25519SignatureHex(value: string): string { + const normalized = value.toLowerCase(); + if (!ED25519_SIGNATURE_HEX_PATTERN.test(normalized)) { + throw new ReleaseManifestSchemaError("invalid release artifact signature"); + } + return normalized; +} + +function isoTimestamp(value: string, field: string): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + throw new ReleaseManifestSchemaError(`${field} must be an ISO timestamp`); + } + return new Date(timestamp).toISOString(); +} + +function positiveIntegerField(value: Record, field: string): number { + const fieldValue = value[field]; + if (typeof fieldValue !== "number" || !Number.isSafeInteger(fieldValue) || fieldValue <= 0) { + throw new ReleaseManifestSchemaError(`${field} must be a positive integer`); + } + return fieldValue; +} + +function stringField(value: Record, field: string): string { + const fieldValue = value[field]; + if (typeof fieldValue !== "string" || fieldValue.trim() === "") { + throw new ReleaseManifestSchemaError(`${field} must be a non-empty string`); + } + return fieldValue.trim(); +} + +function assertOnlyFields( + value: Record, + allowedFields: string[], + label: string, +): void { + const allowed = new Set(allowedFields); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new ReleaseManifestSchemaError(`${label} has unknown field: ${field}`); + } + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/cloudflare/src/signing_keys.ts b/cloudflare/src/signing_keys.ts index e777c46..8bdb1db 100644 --- a/cloudflare/src/signing_keys.ts +++ b/cloudflare/src/signing_keys.ts @@ -1,6 +1,7 @@ +import { prefixedKvKey } from "./kv_keys.js"; + const KEY_ID_PATTERN = /^[a-z0-9._-]{3,128}$/; const PUBLIC_KEY_PATTERN = /^[a-f0-9]{64}$/; -const KV_NAMESPACE_PREFIX = "ely"; const PUBLIC_SIGNING_KEYS_NAMESPACE = "public_signing_keys"; export interface PublicSigningKey { @@ -21,8 +22,7 @@ export class SigningKeysSchemaError extends Error { } export function publicSigningKeysKvKey(environment: string): string { - const normalizedEnvironment = normalizedEnvironmentName(environment); - return `${KV_NAMESPACE_PREFIX}:${normalizedEnvironment}:${PUBLIC_SIGNING_KEYS_NAMESPACE}`; + return prefixedKvKey(environment, PUBLIC_SIGNING_KEYS_NAMESPACE); } export function parsePublicSigningKeysDocument(value: string): PublicSigningKeysDocument { @@ -85,14 +85,6 @@ function parsePublicSigningKey(value: unknown): PublicSigningKey { return { key_id: keyId, public_key: publicKey }; } -function normalizedEnvironmentName(value: string): string { - const environment = value.trim(); - if (!/^[a-z0-9._-]{3,64}$/.test(environment)) { - throw new SigningKeysSchemaError(`invalid environment name: ${value}`); - } - return environment; -} - function stringField(value: Record, field: string): string { const fieldValue = value[field]; if (typeof fieldValue !== "string" || fieldValue.trim() === "") { diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index f8330a3..337b561 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -3,9 +3,12 @@ import { describe, it } from "node:test"; import type { Env } from "../src/bindings.js"; import { handleRequest } from "../src/index.js"; +import { releaseManifestKvKey } from "../src/release_manifests.js"; import { publicSigningKeysKvKey } from "../src/signing_keys.js"; const PUBLIC_KEY = "a".repeat(64); +const RELEASE_SIGNATURE = "b".repeat(128); +const RELEASE_SHA256 = "c".repeat(64); describe("worker routes", () => { it("returns public plugin signing keys from KV", async () => { @@ -74,13 +77,78 @@ describe("worker routes", () => { assert.equal(response.status, 404); assert.deepEqual(await response.json(), { error: "not_found" }); }); + + it("returns release manifest from KV", async () => { + const env = testEnv(null, releaseManifestDocument()); + + const response = await handleRequest( + new Request("https://elydora.test/api/releases/manifest"), + env, + ); + + 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", + artifacts: [ + { + platform: "macos", + architecture: "aarch64", + version: "0.1.0", + url: "https://downloads.elydora.com/ely-browser/0.1.0/macos-aarch64.zip", + sha256: RELEASE_SHA256, + signature: RELEASE_SIGNATURE, + size_bytes: 1048576, + }, + ], + }); + }); + + it("rejects unsupported methods on release manifest", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/releases/manifest", { 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" }); + }); + + it("returns service unavailable when release manifest is missing", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/releases/manifest"), + testEnv(null), + ); + + assert.equal(response.status, 503); + assert.deepEqual(await response.json(), { error: "release_manifest_unavailable" }); + }); + + it("returns a generic server error for malformed release manifest", async () => { + const response = await handleRequest( + new Request("https://elydora.test/api/releases/manifest"), + testEnv(null, "{"), + ); + + assert.equal(response.status, 500); + assert.deepEqual(await response.json(), { error: "release_manifest_invalid" }); + }); }); -function testEnv(publicSigningKeys: string | null): Env { +function testEnv(publicSigningKeys: string | null, releaseManifest?: string | null): Env { const values = new Map(); if (publicSigningKeys !== null) { values.set(publicSigningKeysKvKey("local"), publicSigningKeys); } + if (releaseManifest !== undefined && releaseManifest !== null) { + values.set(releaseManifestKvKey("local"), releaseManifest); + } return { ELY_ENVIRONMENT: "local", @@ -91,3 +159,22 @@ function testEnv(publicSigningKeys: string | null): Env { }, }; } + +function releaseManifestDocument(): string { + return JSON.stringify({ + version: 1, + channel: "stable", + generated_at: "2026-05-08T00:00:00.000Z", + artifacts: [ + { + platform: "macos", + architecture: "aarch64", + version: "0.1.0", + url: "https://downloads.elydora.com/ely-browser/0.1.0/macos-aarch64.zip", + sha256: RELEASE_SHA256, + signature: RELEASE_SIGNATURE, + size_bytes: 1048576, + }, + ], + }); +} diff --git a/cloudflare/tests/release_manifests.test.ts b/cloudflare/tests/release_manifests.test.ts new file mode 100644 index 0000000..9f1f0b5 --- /dev/null +++ b/cloudflare/tests/release_manifests.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + ReleaseManifestSchemaError, + parseReleaseManifestDocument, + releaseManifestKvKey, +} from "../src/release_manifests.js"; + +const SHA256 = "a".repeat(64); +const SIGNATURE = "b".repeat(128); + +describe("release manifests", () => { + it("builds an environment-prefixed KV key", () => { + assert.equal(releaseManifestKvKey("production"), "ely:production:release_manifest_cache"); + }); + + it("parses a valid release manifest", () => { + const document = parseReleaseManifestDocument(validManifest()); + + assert.deepEqual(document, { + version: 1, + channel: "stable", + generated_at: "2026-05-08T00:00:00.000Z", + artifacts: [ + { + platform: "macos", + architecture: "aarch64", + version: "0.1.0", + url: "https://downloads.elydora.com/ely-browser/0.1.0/macos-aarch64.zip", + sha256: SHA256, + signature: SIGNATURE, + size_bytes: 1048576, + }, + ], + }); + }); + + it("rejects duplicate platform architecture artifacts", () => { + assert.throws( + () => + parseReleaseManifestDocument( + validManifest({ + artifacts: [ + validArtifact(), + { ...validArtifact(), version: "0.1.1", sha256: "c".repeat(64) }, + ], + }), + ), + ReleaseManifestSchemaError, + ); + }); + + it("rejects non-https artifact URLs", () => { + assert.throws( + () => + parseReleaseManifestDocument( + validManifest({ + artifacts: [{ ...validArtifact(), url: "http://downloads.elydora.com/app.zip" }], + }), + ), + ReleaseManifestSchemaError, + ); + }); + + it("rejects malformed artifact signatures", () => { + assert.throws( + () => + parseReleaseManifestDocument( + validManifest({ artifacts: [{ ...validArtifact(), signature: "abcd" }] }), + ), + ReleaseManifestSchemaError, + ); + }); +}); + +function validManifest(overrides: Record = {}): string { + return JSON.stringify({ + version: 1, + channel: "stable", + generated_at: "2026-05-08T00:00:00.000Z", + artifacts: [validArtifact()], + ...overrides, + }); +} + +function validArtifact(): Record { + return { + platform: "macos", + architecture: "aarch64", + version: "0.1.0", + url: "https://downloads.elydora.com/ely-browser/0.1.0/macos-aarch64.zip", + sha256: SHA256, + signature: SIGNATURE, + size_bytes: 1048576, + }; +}