From edced5f5c00471a6eaa6cb97e852bbf0f6f09fe0 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:55:22 -0400 Subject: [PATCH] Add Cloudflare API controls --- cloudflare/src/api_controls.ts | 73 ++++++++++++++++ cloudflare/src/bindings.ts | 16 ++++ cloudflare/src/index.ts | 67 +++++---------- cloudflare/src/responses.ts | 14 +++ cloudflare/tests/api_controls.test.ts | 119 ++++++++++++++++++++++++++ cloudflare/tests/index.test.ts | 8 ++ cloudflare/wrangler.toml | 16 ++++ 7 files changed, 268 insertions(+), 45 deletions(-) create mode 100644 cloudflare/src/api_controls.ts create mode 100644 cloudflare/src/responses.ts create mode 100644 cloudflare/tests/api_controls.test.ts diff --git a/cloudflare/src/api_controls.ts b/cloudflare/src/api_controls.ts new file mode 100644 index 0000000..9510527 --- /dev/null +++ b/cloudflare/src/api_controls.ts @@ -0,0 +1,73 @@ +import type { Env } from "./bindings.js"; +import { jsonResponse } from "./responses.js"; + +const RATE_LIMIT_WINDOW_SECONDS = 60; + +export type ApiHandler = () => Promise; + +export async function withPublicApiControls( + request: Request, + env: Env, + route: string, + allowedMethods: readonly string[], + handler: ApiHandler, +): Promise { + if (!allowedMethods.includes(request.method)) { + const response = jsonResponse({ error: "method_not_allowed" }, 405, { + Allow: allowedMethods.join(", "), + }); + recordApiAuditEvent(request, env, route, response, "method_not_allowed"); + return response; + } + + const limit = await env.ELY_RATE_LIMITER.limit({ + key: rateLimitKey(env.ELY_ENVIRONMENT, route), + }); + if (!limit.success) { + const response = jsonResponse({ error: "rate_limited" }, 429, { + "Cache-Control": "no-store", + "Retry-After": RATE_LIMIT_WINDOW_SECONDS.toString(), + }); + recordApiAuditEvent(request, env, route, response, "rate_limited"); + return response; + } + + try { + const response = await handler(); + recordApiAuditEvent(request, env, route, response, "handled"); + return response; + } catch (error) { + recordApiAuditEvent(request, env, route, internalErrorResponse(), "exception"); + throw error; + } +} + +function rateLimitKey(environment: string, route: string): string { + return `${environment}:${route}`; +} + +function recordApiAuditEvent( + request: Request, + env: Env, + route: string, + response: Response, + outcome: string, +): void { + const url = new URL(request.url); + env.ELY_API_AUDIT.writeDataPoint({ + indexes: [env.ELY_ENVIRONMENT], + blobs: [ + route, + request.method, + url.pathname, + outcome, + request.headers.get("cf-ray") ?? "", + request.headers.get("user-agent") ?? "", + ], + doubles: [response.status, Date.now()], + }); +} + +function internalErrorResponse(): Response { + return jsonResponse({ error: "internal_error" }, 500, { "Cache-Control": "no-store" }); +} diff --git a/cloudflare/src/bindings.ts b/cloudflare/src/bindings.ts index 34c692e..1143b1f 100644 --- a/cloudflare/src/bindings.ts +++ b/cloudflare/src/bindings.ts @@ -2,7 +2,23 @@ export interface ElyKvNamespace { get(key: string): Promise; } +export interface ElyRateLimit { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + +export interface ElyAnalyticsDataPoint { + indexes?: (ArrayBuffer | string | null)[]; + doubles?: number[]; + blobs?: (ArrayBuffer | string | null)[]; +} + +export interface ElyAnalyticsDataset { + writeDataPoint(event?: ElyAnalyticsDataPoint): void; +} + export interface Env { ELY_KV: ElyKvNamespace; + ELY_RATE_LIMITER: ElyRateLimit; + ELY_API_AUDIT: ElyAnalyticsDataset; ELY_ENVIRONMENT: string; } diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 52292e5..9db8a40 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -1,4 +1,5 @@ import type { Env } from "./bindings.js"; +import { withPublicApiControls } from "./api_controls.js"; import { PluginRegistrySchemaError, parsePluginRegistryDocument, @@ -21,6 +22,7 @@ import { parsePublicSigningKeysDocument, publicSigningKeysKvKey, } from "./signing_keys.js"; +import { jsonResponse } from "./responses.js"; export default { fetch(request: Request, env: Env): Promise { @@ -31,32 +33,36 @@ export default { export async function handleRequest(request: Request, env: Env): Promise { const url = new URL(request.url); if (url.pathname === "/api/plugins/signing-keys") { - return handlePublicSigningKeys(request, env); + return withPublicApiControls(request, env, "plugins.signing_keys", ["GET"], () => + handlePublicSigningKeys(env), + ); } const pluginRoute = parsePluginRoute(url.pathname); if (pluginRoute !== null) { - return handlePluginRoute(request, env, pluginRoute); + return withPublicApiControls(request, env, pluginRoute.auditRoute, ["GET"], () => + handlePluginRoute(env, pluginRoute), + ); } if (url.pathname === "/api/releases/manifest") { - return handleReleaseManifest(request, env); + return withPublicApiControls(request, env, "releases.manifest", ["GET"], () => + handleReleaseManifest(env), + ); } if (url.pathname === "/api/releases/signature") { - return handleReleaseSignature(request, env, url); + return withPublicApiControls(request, env, "releases.signature", ["GET"], () => + handleReleaseSignature(env, url), + ); } return jsonResponse({ error: "not_found" }, 404); } type PluginRoute = - | { kind: "catalog" } - | { kind: "details"; pluginId: string } - | { kind: "package"; pluginId: string }; - -async function handlePublicSigningKeys(request: Request, env: Env): Promise { - if (request.method !== "GET") { - return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); - } + | { kind: "catalog"; auditRoute: "plugins.catalog" } + | { kind: "details"; auditRoute: "plugins.details"; pluginId: string } + | { kind: "package"; auditRoute: "plugins.package"; pluginId: string }; +async function handlePublicSigningKeys(env: Env): Promise { const kvKey = publicSigningKeysKvKey(env.ELY_ENVIRONMENT); const value = await env.ELY_KV.get(kvKey); if (value === null) { @@ -77,14 +83,9 @@ async function handlePublicSigningKeys(request: Request, env: Env): Promise { - if (request.method !== "GET") { - return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); - } - const kvKey = pluginRegistryKvKey(env.ELY_ENVIRONMENT); const value = await env.ELY_KV.get(kvKey); if (value === null) { @@ -118,11 +119,7 @@ async function handlePluginRoute( } } -async function handleReleaseManifest(request: Request, env: Env): Promise { - if (request.method !== "GET") { - return jsonResponse({ error: "method_not_allowed" }, 405, { Allow: "GET" }); - } - +async function handleReleaseManifest(env: Env): Promise { const kvKey = releaseManifestKvKey(env.ELY_ENVIRONMENT); const value = await env.ELY_KV.get(kvKey); if (value === null) { @@ -143,14 +140,9 @@ async function handleReleaseManifest(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); @@ -186,7 +178,7 @@ async function handleReleaseSignature( function parsePluginRoute(pathname: string): PluginRoute | null { if (pathname === "/api/plugins") { - return { kind: "catalog" }; + return { kind: "catalog", auditRoute: "plugins.catalog" }; } if (!pathname.startsWith("/api/plugins/")) { @@ -199,14 +191,14 @@ function parsePluginRoute(pathname: string): PluginRoute | null { if (pluginId === null) { return null; } - return { kind: "details", pluginId }; + return { kind: "details", auditRoute: "plugins.details", pluginId }; } if (segments.length === 5 && segments[4] === "package") { const pluginId = pluginRouteId(segments[3]); if (pluginId === null) { return null; } - return { kind: "package", pluginId }; + return { kind: "package", auditRoute: "plugins.package", pluginId }; } return null; @@ -229,18 +221,3 @@ function pluginRouteId(segment: string | undefined): string | null { function publicPluginCacheHeaders(): Record { return { "Cache-Control": "public, max-age=300, stale-while-revalidate=60" }; } - -function jsonResponse( - body: unknown, - status: number, - headers: Record = {}, -): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - "Content-Type": "application/json; charset=utf-8", - "X-Content-Type-Options": "nosniff", - ...headers, - }, - }); -} diff --git a/cloudflare/src/responses.ts b/cloudflare/src/responses.ts new file mode 100644 index 0000000..5e7355a --- /dev/null +++ b/cloudflare/src/responses.ts @@ -0,0 +1,14 @@ +export function jsonResponse( + body: unknown, + status: number, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "Content-Type": "application/json; charset=utf-8", + "X-Content-Type-Options": "nosniff", + ...headers, + }, + }); +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts new file mode 100644 index 0000000..9a02366 --- /dev/null +++ b/cloudflare/tests/api_controls.test.ts @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { ElyAnalyticsDataPoint, Env } from "../src/bindings.js"; +import { handleRequest } from "../src/index.js"; +import { publicSigningKeysKvKey } from "../src/signing_keys.js"; + +const PUBLIC_KEY = "a".repeat(64); + +describe("api controls", () => { + it("rate limits public API routes before reading KV", async () => { + const auditEvents: ElyAnalyticsDataPoint[] = []; + const kvReads: string[] = []; + const response = await handleRequest( + new Request("https://elydora.test/api/plugins/signing-keys"), + testEnv({ auditEvents, kvReads, rateLimitSuccess: false }), + ); + + assert.equal(response.status, 429); + assert.equal(response.headers.get("retry-after"), "60"); + assert.deepEqual(await response.json(), { error: "rate_limited" }); + assert.deepEqual(kvReads, []); + assert.equal(auditEvents.length, 1); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 4), [ + "plugins.signing_keys", + "GET", + "/api/plugins/signing-keys", + "rate_limited", + ]); + assert.equal(auditEvents[0]?.doubles?.[0], 429); + }); + + it("records successful public API requests", async () => { + const auditEvents: ElyAnalyticsDataPoint[] = []; + const rateLimitKeys: string[] = []; + const response = await handleRequest( + new Request("https://elydora.test/api/plugins/signing-keys", { + headers: { "cf-ray": "ray-1", "user-agent": "ely-test" }, + }), + testEnv({ auditEvents, rateLimitKeys }), + ); + + assert.equal(response.status, 200); + assert.deepEqual(rateLimitKeys, ["local:plugins.signing_keys"]); + assert.equal(auditEvents.length, 1); + assert.deepEqual(auditEvents[0]?.indexes, ["local"]); + assert.deepEqual(auditEvents[0]?.blobs, [ + "plugins.signing_keys", + "GET", + "/api/plugins/signing-keys", + "handled", + "ray-1", + "ely-test", + ]); + assert.equal(auditEvents[0]?.doubles?.[0], 200); + }); + + it("records method rejections without consuming rate limit tokens", async () => { + const auditEvents: ElyAnalyticsDataPoint[] = []; + const rateLimitKeys: string[] = []; + const response = await handleRequest( + new Request("https://elydora.test/api/plugins/signing-keys", { method: "POST" }), + testEnv({ auditEvents, rateLimitKeys }), + ); + + assert.equal(response.status, 405); + assert.equal(response.headers.get("allow"), "GET"); + assert.deepEqual(rateLimitKeys, []); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 4), [ + "plugins.signing_keys", + "POST", + "/api/plugins/signing-keys", + "method_not_allowed", + ]); + assert.equal(auditEvents[0]?.doubles?.[0], 405); + }); +}); + +interface TestEnvOptions { + auditEvents?: ElyAnalyticsDataPoint[]; + kvReads?: string[]; + rateLimitKeys?: string[]; + rateLimitSuccess?: boolean; +} + +function testEnv(options: TestEnvOptions = {}): Env { + const values = new Map([ + [ + publicSigningKeysKvKey("local"), + JSON.stringify({ + version: 1, + keys: [{ key_id: "elydora-alpha-plugins", public_key: PUBLIC_KEY }], + }), + ], + ]); + + return { + ELY_ENVIRONMENT: "local", + ELY_KV: { + get(key: string): Promise { + options.kvReads?.push(key); + return Promise.resolve(values.get(key) ?? null); + }, + }, + ELY_RATE_LIMITER: { + limit(input: { key: string }): Promise<{ success: boolean }> { + options.rateLimitKeys?.push(input.key); + return Promise.resolve({ success: options.rateLimitSuccess ?? true }); + }, + }, + ELY_API_AUDIT: { + writeDataPoint(event?: ElyAnalyticsDataPoint): void { + if (event !== undefined) { + options.auditEvents?.push(event); + } + }, + }, + }; +} diff --git a/cloudflare/tests/index.test.ts b/cloudflare/tests/index.test.ts index 8545286..c69e770 100644 --- a/cloudflare/tests/index.test.ts +++ b/cloudflare/tests/index.test.ts @@ -346,6 +346,14 @@ function testEnv( return Promise.resolve(values.get(key) ?? null); }, }, + ELY_RATE_LIMITER: { + limit(): Promise<{ success: boolean }> { + return Promise.resolve({ success: true }); + }, + }, + ELY_API_AUDIT: { + writeDataPoint(): void {}, + }, }; } diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml index c1ad7a3..f2ac7e7 100644 --- a/cloudflare/wrangler.toml +++ b/cloudflare/wrangler.toml @@ -2,5 +2,21 @@ name = "ely-browser-cloud" main = "src/index.ts" compatibility_date = "2026-05-08" +[[kv_namespaces]] +binding = "ELY_KV" +id = "5eff92ba31c94fcf83e1b6d5e79ce070" + [vars] ELY_ENVIRONMENT = "local" + +[[analytics_engine_datasets]] +binding = "ELY_API_AUDIT" +dataset = "ely_api_audit" + +[[ratelimits]] +name = "ELY_RATE_LIMITER" +namespace_id = "1001" + + [ratelimits.simple] + limit = 600 + period = 60