configure hosted api endpoint and refresh local mock wiring #23
5 changed files with 213 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
107
services/api/src/cors.ts
Normal file
107
services/api/src/cors.ts
Normal file
|
|
@ -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<string> => {
|
||||
const origins = new Set<string>();
|
||||
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>): 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<string>
|
||||
): 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<string>
|
||||
): 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
|
||||
});
|
||||
};
|
||||
|
|
@ -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,8 +1371,13 @@ const run = async () => {
|
|||
hostname: env.API_HOST,
|
||||
port: env.API_PORT,
|
||||
fetch: async (req: Request, serverRef: any) => {
|
||||
const handleApiRequest = async (): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
return createCorsPreflightResponse(req, corsAllowedOrigins);
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/health") {
|
||||
return jsonResponse({ status: "ok" });
|
||||
}
|
||||
|
|
@ -1952,6 +1965,10 @@ const run = async () => {
|
|||
}
|
||||
|
||||
return jsonResponse({ error: "not found" }, 404);
|
||||
};
|
||||
|
||||
const response = await handleApiRequest();
|
||||
return withCorsHeaders(req, response, corsAllowedOrigins);
|
||||
},
|
||||
websocket: {
|
||||
open: (socket: any) => {
|
||||
|
|
|
|||
81
services/api/tests/cors.test.ts
Normal file
81
services/api/tests/cors.test.ts
Normal file
|
|
@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue