From 7e095b51f60f7dc3f9e68cb7467780555cb299f3 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sat, 13 Jun 2026 11:07:53 -0400 Subject: [PATCH] allow local dev origins on api --- .env.example | 1 + README.md | 1 + services/api/src/cors.ts | 107 ++++++++++++++++++++++++++++++++ services/api/src/index.ts | 29 +++++++-- services/api/tests/cors.test.ts | 81 ++++++++++++++++++++++++ 5 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 services/api/src/cors.ts create mode 100644 services/api/tests/cors.test.ts diff --git a/.env.example b/.env.example index be20b62..0d59497 100644 --- a/.env.example +++ b/.env.example @@ -60,6 +60,7 @@ COMPUTE_DELIVER_POLICY=new COMPUTE_CONSUMER_RESET=false API_DELIVER_POLICY=new API_CONSUMER_RESET=false +API_CORS_ORIGINS=https://flow.deltaisland.io,http://127.0.0.1:3000,http://localhost:3000,http://127.0.0.1:3100,http://localhost:3100 NBBO_MAX_AGE_MS=1000 NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000 NEXT_PUBLIC_LIVE_HOT_WINDOW=600 diff --git a/README.md b/README.md index 583041c..227fbbc 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,7 @@ Default `smart-money` policy rejects lower-information prints and keeps higher-c | `REST_DEFAULT_LIMIT` | `200` | Default REST record count. | | `API_DELIVER_POLICY` | `new` | JetStream consumer start policy used by API live subscribers. | | `API_CONSUMER_RESET` | `false` | Resets/recreates API live durable consumers on startup when true. | +| `API_CORS_ORIGINS` | `https://flow.deltaisland.io,http://127.0.0.1:3000,http://localhost:3000,http://127.0.0.1:3100,http://localhost:3100` | Comma-separated browser origins allowed to call the API directly; local web and desktop-local dev rely on these headers. | | `LIVE_LIMIT_DEFAULT` | `1000` | Optional generic live cache depth default. | | `LIVE_LIMIT_FLOW` | `500` | Live cache depth for flow packet events unless overridden. | | `LIVE_LIMIT_SMART_MONEY` | `300` | Live cache depth for smart-money events unless overridden. | diff --git a/services/api/src/cors.ts b/services/api/src/cors.ts new file mode 100644 index 0000000..fbb183f --- /dev/null +++ b/services/api/src/cors.ts @@ -0,0 +1,107 @@ +export const DEFAULT_API_CORS_ORIGINS = [ + "https://flow.deltaisland.io", + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://127.0.0.1:3100", + "http://localhost:3100" +].join(","); + +const DEFAULT_ALLOWED_HEADERS = "authorization,content-type,x-synthetic-admin-token"; +const DEFAULT_ALLOWED_METHODS = "GET,POST,PUT,OPTIONS"; + +const normalizeOrigin = (origin: string): string | null => { + const trimmed = origin.trim(); + if (!trimmed) { + return null; + } + if (trimmed === "*") { + return trimmed; + } + + try { + return new URL(trimmed).origin; + } catch { + return null; + } +}; + +export const parseCorsAllowedOrigins = (value: string): Set => { + const origins = new Set(); + for (const entry of value.split(",")) { + const origin = normalizeOrigin(entry); + if (origin) { + origins.add(origin); + } + } + return origins; +}; + +export const resolveCorsOrigin = (req: Request, allowedOrigins: Set): string | null => { + const origin = normalizeOrigin(req.headers.get("origin") ?? ""); + if (!origin) { + return null; + } + if (allowedOrigins.has("*")) { + return "*"; + } + return allowedOrigins.has(origin) ? origin : null; +}; + +const appendVaryOrigin = (headers: Headers): void => { + const vary = headers.get("vary"); + if (!vary) { + headers.set("vary", "Origin"); + return; + } + if (!vary.split(",").some((value) => value.trim().toLowerCase() === "origin")) { + headers.set("vary", `${vary}, Origin`); + } +}; + +export const withCorsHeaders = ( + req: Request, + response: Response, + allowedOrigins: Set +): Response => { + if (response.status === 101) { + return response; + } + + const allowedOrigin = resolveCorsOrigin(req, allowedOrigins); + if (!allowedOrigin) { + return response; + } + + const headers = new Headers(response.headers); + headers.set("access-control-allow-origin", allowedOrigin); + appendVaryOrigin(headers); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers + }); +}; + +export const createCorsPreflightResponse = ( + req: Request, + allowedOrigins: Set +): Response => { + const headers = new Headers(); + const allowedOrigin = resolveCorsOrigin(req, allowedOrigins); + if (allowedOrigin) { + headers.set("access-control-allow-origin", allowedOrigin); + headers.set("access-control-allow-methods", DEFAULT_ALLOWED_METHODS); + headers.set( + "access-control-allow-headers", + req.headers.get("access-control-request-headers") ?? DEFAULT_ALLOWED_HEADERS + ); + headers.set("access-control-max-age", "86400"); + appendVaryOrigin(headers); + } + + return new Response(null, { + status: 204, + headers + }); +}; diff --git a/services/api/src/index.ts b/services/api/src/index.ts index 88ba825..cdfad6e 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -138,6 +138,12 @@ import { recordSyntheticProfileHit, resolveSyntheticBackendMode } from "./synthetic-control"; +import { + DEFAULT_API_CORS_ORIGINS, + createCorsPreflightResponse, + parseCorsAllowedOrigins, + withCorsHeaders +} from "./cors"; const service = "api"; const logger = createLogger({ service }); @@ -172,10 +178,12 @@ const envSchema = z.object({ return value; }, z.boolean()) .default(false), - SYNTHETIC_ADMIN_TOKEN: z.string().default("") + SYNTHETIC_ADMIN_TOKEN: z.string().default(""), + API_CORS_ORIGINS: z.string().default(DEFAULT_API_CORS_ORIGINS) }); const env = readEnv(envSchema); +const corsAllowedOrigins = parseCorsAllowedOrigins(env.API_CORS_ORIGINS); const state = { shuttingDown: false, @@ -1363,11 +1371,16 @@ const run = async () => { hostname: env.API_HOST, port: env.API_PORT, fetch: async (req: Request, serverRef: any) => { - const url = new URL(req.url); + const handleApiRequest = async (): Promise => { + const url = new URL(req.url); - if (req.method === "GET" && url.pathname === "/health") { - return jsonResponse({ status: "ok" }); - } + if (req.method === "OPTIONS") { + return createCorsPreflightResponse(req, corsAllowedOrigins); + } + + if (req.method === "GET" && url.pathname === "/health") { + return jsonResponse({ status: "ok" }); + } if (req.method === "GET" && url.pathname === "/admin/synthetic/status") { const authError = authenticateSyntheticAdminRequest(req); @@ -1951,7 +1964,11 @@ const run = async () => { return jsonResponse({ error: "websocket upgrade failed" }, 400); } - return jsonResponse({ error: "not found" }, 404); + return jsonResponse({ error: "not found" }, 404); + }; + + const response = await handleApiRequest(); + return withCorsHeaders(req, response, corsAllowedOrigins); }, websocket: { open: (socket: any) => { diff --git a/services/api/tests/cors.test.ts b/services/api/tests/cors.test.ts new file mode 100644 index 0000000..e10d64d --- /dev/null +++ b/services/api/tests/cors.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "bun:test"; +import { + createCorsPreflightResponse, + parseCorsAllowedOrigins, + resolveCorsOrigin, + withCorsHeaders +} from "../src/cors"; + +describe("api cors helpers", () => { + const allowedOrigins = parseCorsAllowedOrigins( + "https://flow.deltaisland.io, http://127.0.0.1:3000/, http://localhost:3100" + ); + + it("normalizes configured origins", () => { + expect(allowedOrigins.has("https://flow.deltaisland.io")).toBe(true); + expect(allowedOrigins.has("http://127.0.0.1:3000")).toBe(true); + expect(allowedOrigins.has("http://localhost:3100")).toBe(true); + expect(allowedOrigins.has("http://127.0.0.1:3000/")).toBe(false); + }); + + it("reflects allowed browser origins", () => { + const req = new Request("https://api.flow.deltaisland.io/prints/options", { + headers: { + origin: "http://127.0.0.1:3000" + } + }); + + expect(resolveCorsOrigin(req, allowedOrigins)).toBe("http://127.0.0.1:3000"); + }); + + it("does not reflect unknown origins", () => { + const req = new Request("https://api.flow.deltaisland.io/prints/options", { + headers: { + origin: "http://evil.example" + } + }); + + expect(resolveCorsOrigin(req, allowedOrigins)).toBeNull(); + }); + + it("adds cors headers to normal responses for allowed origins", async () => { + const req = new Request("https://api.flow.deltaisland.io/health", { + headers: { + origin: "https://flow.deltaisland.io" + } + }); + const response = withCorsHeaders( + req, + new Response(JSON.stringify({ status: "ok" }), { + headers: { + "content-type": "application/json" + } + }), + allowedOrigins + ); + + expect(response.headers.get("access-control-allow-origin")).toBe("https://flow.deltaisland.io"); + expect(response.headers.get("vary")).toBe("Origin"); + expect(response.headers.get("content-type")).toBe("application/json"); + expect(await response.json()).toEqual({ status: "ok" }); + }); + + it("answers preflight requests for allowed origins", () => { + const req = new Request("https://api.flow.deltaisland.io/lookup/options-support", { + method: "OPTIONS", + headers: { + origin: "http://localhost:3100", + "access-control-request-method": "POST", + "access-control-request-headers": "content-type,authorization" + } + }); + const response = createCorsPreflightResponse(req, allowedOrigins); + + expect(response.status).toBe(204); + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3100"); + expect(response.headers.get("access-control-allow-methods")).toContain("POST"); + expect(response.headers.get("access-control-allow-headers")).toBe( + "content-type,authorization" + ); + }); +});