From 4446b228d7546b446caa009df8820625f8239f68 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sat, 13 Jun 2026 03:39:37 -0400 Subject: [PATCH 1/4] configure local dev api endpoint --- README.md | 4 +- apps/desktop/README.md | 2 +- apps/web/scripts/dev.ts | 8 + ...13-0338-configure-hosted-api-endpoint.html | 493 ++++++++++++++++++ scripts/dev-desktop.ts | 3 +- 5 files changed, 506 insertions(+), 4 deletions(-) create mode 100644 docs/turns/2026-06-13-0338-configure-hosted-api-endpoint.html diff --git a/README.md b/README.md index 27dc940..583041c 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ bun run make:desktop Desktop-specific environment: - `ISLANDFLOW_DESKTOP_START_URL` is only used by the Electron shell and is restricted to trusted Islandflow app origins. -- `NEXT_PUBLIC_API_URL` remains the web app API/WebSocket origin control and usually points at `https://flow.deltaisland.io` when developing local UI inside Electron. +- `NEXT_PUBLIC_API_URL` remains the web app API/WebSocket origin control and usually points at `https://api.flow.deltaisland.io` when developing local UI inside Electron. ## Environment Configuration @@ -406,7 +406,7 @@ Default `smart-money` policy rejects lower-information prints and keeps higher-c | `LIVE_LIMIT_OPTIONS` | `1000` | Live cache depth for options channel unless overridden. | | `LIVE_LIMIT_ALERTS` | `300` | Live cache depth for alerts channel unless overridden. | | `LIVE_LIMIT_NEWS` | `100` | Live cache depth for news channel unless overridden. | -| `NEXT_PUBLIC_API_URL` | auto-detected in browser, `http://127.0.0.1:4000` fallback | Explicit base URL for API/WS calls from the web app. | +| `NEXT_PUBLIC_API_URL` | `https://api.flow.deltaisland.io` for local web dev, auto-detected in browser when unset by other runners | Explicit base URL for API/WS calls from the web app. | | `NEXT_PUBLIC_LIVE_HOT_WINDOW` | `600` | Max hot-window items retained for non-options live streams in UI state. | | `NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS` | `1200` | Dedicated max hot-window items retained for options prints. | | `NEXT_PUBLIC_NBBO_MAX_AGE_MS` | `1000` | Frontend NBBO staleness threshold. | diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d8166b8..70ba392 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -25,5 +25,5 @@ This workspace packages a thin Electron shell around the hosted Islandflow app. ## Development Notes - `ISLANDFLOW_DESKTOP_START_URL` controls which trusted app URL Electron loads. Prefer `/options` for deep links; `/tape` remains supported and redirects in the web app for compatibility. -- `NEXT_PUBLIC_API_URL` remains a web-app setting and should typically be `https://flow.deltaisland.io` when developing the local UI inside Electron. +- `NEXT_PUBLIC_API_URL` remains a web-app setting and should typically be `https://api.flow.deltaisland.io` when developing the local UI inside Electron. - `assets/` currently contains placeholders only; a real `.icns` icon is deferred. diff --git a/apps/web/scripts/dev.ts b/apps/web/scripts/dev.ts index 985f6e6..c9754d5 100644 --- a/apps/web/scripts/dev.ts +++ b/apps/web/scripts/dev.ts @@ -1,9 +1,16 @@ import { rm } from "node:fs/promises"; +const DEFAULT_REMOTE_API_URL = "https://api.flow.deltaisland.io"; + const run = async () => { const port = 3000; const distDir = ".next-dev"; console.log(`[web] starting Next.js dev server on port ${port}`); + console.log( + `[web] API origin: ${Bun.env.NEXT_PUBLIC_API_URL ?? DEFAULT_REMOTE_API_URL}${ + Bun.env.NEXT_PUBLIC_API_URL ? " (from NEXT_PUBLIC_API_URL)" : " (default)" + }` + ); const path = Bun.env.PATH ?? ""; const cwd = `${import.meta.dir}/..`; @@ -21,6 +28,7 @@ const run = async () => { env: { ...Bun.env, PATH: `${cwd}/node_modules/.bin:${path}`, + NEXT_PUBLIC_API_URL: Bun.env.NEXT_PUBLIC_API_URL ?? DEFAULT_REMOTE_API_URL, PORT: String(port) } }); diff --git a/docs/turns/2026-06-13-0338-configure-hosted-api-endpoint.html b/docs/turns/2026-06-13-0338-configure-hosted-api-endpoint.html new file mode 100644 index 0000000..2af3bc2 --- /dev/null +++ b/docs/turns/2026-06-13-0338-configure-hosted-api-endpoint.html @@ -0,0 +1,493 @@ + + + + + + Configure Hosted API Endpoint + + + +
+
+

Turn Document ยท June 13, 2026

+

Configure Local Web and Desktop Development for the Hosted API

+
+
Branchlavender/configure-hosted-api-endpoint
+
Issueislandflow-7l2
+
API Hosthttps://api.flow.deltaisland.io
+
App Hosthttps://flow.deltaisland.io
+
+
+ +
+

Summary

+

Local web development and the desktop local-UI workflow now default API and WebSocket traffic to https://api.flow.deltaisland.io, while the hosted desktop window still opens the app at https://flow.deltaisland.io.

+
+ +
+

Changes Made

+
    +
  • Added a local web development default API origin in apps/web/scripts/dev.ts.
  • +
  • Changed scripts/dev-desktop.ts so its spawned local web UI uses the API subdomain by default.
  • +
  • Updated README guidance for web and desktop development to distinguish the hosted app origin from the hosted API origin.
  • +
  • Updated the ignored local file apps/web/.env.local on this machine to point at the API subdomain. That local file is not committed.
  • +
+
+ +
+

Context

+

The VPS check over ssh di confirmed https://api.flow.deltaisland.io/health returns 200, while https://flow.deltaisland.io/health returns 404. The app origin remains the hosted UI, and the API subdomain is the correct base for local dev API and WebSocket calls.

+
+ +
+

Important Implementation Details

+
    +
  • bun run dev:web now passes NEXT_PUBLIC_API_URL into Next.js when the variable is not already set.
  • +
  • bun run dev:desktop still launches Electron at http://127.0.0.1:3000, but the local web child receives the hosted API origin.
  • +
  • bun run dev:desktop:remote still loads https://flow.deltaisland.io directly and does not start the local web child.
  • +
+
+ +
+

Relevant Diff Snippets

+

Rendered with @pierre/diffs/ssr from the focused endpoint patch and contained in an offline iframe.

+ +
+ +
+

Expected Impact for End-Users

+

Running local web or desktop development should reach the live Delta Island API without manually remembering the current API hostname. Hosted desktop behavior stays pointed at the public app.

+
+ +
+

Validation

+
    +
  • ssh di plus curl confirmed api.flow.deltaisland.io/health responds with 200.
  • +
  • bun run scripts/check-public-api-routes.ts https://api.flow.deltaisland.io passed for REST and WebSocket probes.
  • +
  • bun test apps/web/app/terminal.test.ts apps/web/app/api/admin/synthetic/routes.test.ts apps/desktop/src/security.test.ts passed.
  • +
  • bun --cwd=apps/web run build passed.
  • +
  • A brief bun run dev:web smoke confirmed the API origin is https://api.flow.deltaisland.io, but port 3000 was already occupied by an existing node listener.
  • +
+
+ +
+

Issues, Limitations, and Mitigations

+
    +
  • The smoke run could not bind port 3000 because another local process was already listening there. The startup log still confirmed the corrected API origin.
  • +
  • The local ignored apps/web/.env.local change fixes this machine only. Tracked script defaults cover missing local env files for future worktrees.
  • +
  • Unrelated dashboard route edits were present in the worktree before this endpoint fix and were not included in this task's intended patch.
  • +
+
+ +
+

Follow-up Work

+

No endpoint follow-up is required. The existing port 3000 listener can be stopped separately if the user wants a clean local dev server restart.

+
+
+ + diff --git a/scripts/dev-desktop.ts b/scripts/dev-desktop.ts index fbf5a66..062e932 100644 --- a/scripts/dev-desktop.ts +++ b/scripts/dev-desktop.ts @@ -3,6 +3,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; const DESKTOP_REMOTE_URL = "https://flow.deltaisland.io"; +const DESKTOP_REMOTE_API_URL = "https://api.flow.deltaisland.io"; const DESKTOP_LOCAL_URL = "http://127.0.0.1:3000"; const WEB_PORT = 3000; @@ -268,7 +269,7 @@ if (!remoteMode) { cmd: ["bun", "run", "dev"], cwd: "apps/web", env: { - NEXT_PUBLIC_API_URL: Bun.env.NEXT_PUBLIC_API_URL ?? DESKTOP_REMOTE_URL + NEXT_PUBLIC_API_URL: Bun.env.NEXT_PUBLIC_API_URL ?? DESKTOP_REMOTE_API_URL } }); await waitForWebPort(); From 7e095b51f60f7dc3f9e68cb7467780555cb299f3 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sat, 13 Jun 2026 11:07:53 -0400 Subject: [PATCH 2/4] 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" + ); + }); +}); From f716b8556f6252d8a46fd75a25149647d6722d85 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sat, 13 Jun 2026 11:28:24 -0400 Subject: [PATCH 3/4] consolidate dev origin and terminal fetch handling --- .beads/issues.jsonl | 2 + .env.example | 1 + README.md | 1 + apps/web/app/terminal.tsx | 241 ++++++----- apps/web/next.config.mjs | 22 + ...3-1130-fix-local-backend-connectivity.html | 380 ++++++++++++++++++ 6 files changed, 552 insertions(+), 95 deletions(-) create mode 100644 docs/turns/2026-06-13-1130-fix-local-backend-connectivity.html diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index bb482ea..3362806 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"islandflow-9w7","title":"Allow local dev origins on hosted API","description":"Local bun run dev:web and desktop-local point at the hosted API, but browser requests from http://127.0.0.1:3000 are blocked because the API omits CORS headers and returns 404 for OPTIONS preflight. Add API-side CORS handling, validate local web/desktop browser access, and deploy the API fix.","acceptance_criteria":"API responses include Access-Control-Allow-Origin for allowed local/dev origins; OPTIONS preflight succeeds; bun run dev:web reaches hosted REST/WS endpoints from a browser; bun run dev:desktop local mode reaches the backend through the local web UI; tests/build pass; fix is deployed to api.flow.deltaisland.io.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T15:04:19Z","created_by":"dirtydishes","updated_at":"2026-06-13T15:29:42Z","started_at":"2026-06-13T15:04:26Z","closed_at":"2026-06-13T15:29:42Z","close_reason":"Hosted API now reflects allowed local dev origins and handles OPTIONS preflight; local web and desktop dev runners both reach https://api.flow.deltaisland.io; API tests, typecheck, and web build passed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-xkq","title":"Rebuild production dashboard options news around mock9 aesthetic","description":"Reconstruct the production web UI for Dashboard, Options, and News around the mock9 through mock12 dense terminal aesthetic while preserving production data subscriptions, drawers, virtualization, route helpers, redirects, and validation.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T14:07:34Z","created_by":"dirtydishes","updated_at":"2026-06-13T14:26:46Z","started_at":"2026-06-13T14:07:53Z","closed_at":"2026-06-13T14:26:46Z","close_reason":"Rebuilt Dashboard, Options, and News around the dense mock9 to mock12 production aesthetic; tests and build passed, and Browser visual inspection was documented as blocked by the unavailable in-app browser backend.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-u45","title":"Patch CVE-related dependency and Docker image findings","description":"Address Forgejo issues #15, #18, and #19 by upgrading the vulnerable tmp dependency resolution and moving Bun Docker images off the vulnerable oven/bun:1.3.11 base image with patched OpenSSL packages during image build.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-12T23:21:29Z","created_by":"dirtydishes","updated_at":"2026-06-12T23:23:27Z","started_at":"2026-06-12T23:22:16Z","closed_at":"2026-06-12T23:23:27Z","close_reason":"Patched Forgejo #15/#18 tmp CVE by resolving tmp@0.2.7, updated Bun Docker images and OpenSSL package upgrade layers for #19, and validated with bun audit, tests, web build, docker workspace check, and replacement image manifest inspection.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-hut","title":"Fix tmp path traversal audit finding","description":"bun audit reports GHSA-ph9p-34f9-6g65 through workspace:@islandflow/desktop via @electron-forge/cli. Update dependency resolution so tmp is at a non-vulnerable version and verify bun audit passes.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-12T22:50:18Z","created_by":"dirtydishes","updated_at":"2026-06-12T22:58:59Z","started_at":"2026-06-12T22:58:33Z","closed_at":"2026-06-12T22:58:59Z","close_reason":"Fixed by bumping the root tmp override to ^0.2.6, refreshing bun.lock to tmp@0.2.7, and validating with bun audit plus bun test. Forgejo issue listing was inaccessible from this environment, so the branch targets the active audit finding visible on current main.","dependency_count":0,"dependent_count":0,"comment_count":0} @@ -30,6 +31,7 @@ {"_type":"issue","id":"islandflow-ayo","title":"Drop stale backlog events from live fanout","description":"Follow-up to live freshness rollout: /ws/live was still fanning out stale backlog events for freshness-gated channels, which kept tape panes in Live feed behind despite active synthetic ingest. Gate fanout and cache ingest by freshness for options/nbbo/equities/flow.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:26:39Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:26:44Z","started_at":"2026-04-28T21:26:44Z","closed_at":"2026-04-28T21:26:44Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-0v6","title":"Fix tape freshness, NBBO coverage, pause controls, and filter popup","description":"Implement the tape fixes requested for synthetic options notional sizing, strict live freshness, live-mode pause/resume behavior, stronger NBBO snapshot coverage, and moving flow filters behind a popup. Includes server-side live cache changes, web terminal state/UI changes, and tests for synthetic pricing, live snapshot freshness/NBBO retention, and live pause/filter interactions.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T21:02:52Z","created_by":"dirtydishes","updated_at":"2026-04-28T21:13:38Z","started_at":"2026-04-28T21:02:57Z","closed_at":"2026-04-28T21:13:38Z","close_reason":"Completed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-e4r","title":"Implement smart-money flow filtering and synthetic firehose modes","description":"Implement the approved multi-surface plan for named synthetic market profiles, options raw-vs-signal filtering, live/API filter contracts, Tape page client-side flow filters, firehose-readiness improvements, tests, and README updates.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-04-28T20:10:49Z","created_by":"dirtydishes","updated_at":"2026-04-28T20:29:29Z","started_at":"2026-04-28T20:10:53Z","closed_at":"2026-04-28T20:29:29Z","close_reason":"Implemented synthetic market profiles, options signal-path filtering, signal-aware API/replay contracts, Tape page filters, tests, and README updates. Follow-up tracked in islandflow-biq.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"islandflow-cq6","title":"consolidate deploy script prompts","description":"Add a more robust consolidated deploy script that can prompt for runtime, branch/ref, and deploy pieces while preserving non-interactive CLI usage.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T15:12:51Z","created_by":"dirtydishes","updated_at":"2026-06-13T15:28:45Z","started_at":"2026-06-13T15:28:18Z","closed_at":"2026-06-13T15:28:45Z","close_reason":"Implemented guided deploy prompts, named branch deploys, explicit piece selection, docs, validation, and turn documentation.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-9gb","title":"Rename news route to Newswire","description":"Follow-up to the mock9 production terminal rebuild: rename the /news route title from Wire Control to Newswire and keep the visual verification/docs aligned with the latest user-facing label.","status":"closed","priority":2,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T14:33:30Z","created_by":"dirtydishes","updated_at":"2026-06-13T14:37:01Z","started_at":"2026-06-13T14:33:42Z","closed_at":"2026-06-13T14:37:01Z","close_reason":"Renamed the /news route to Newswire, updated the design record and turn document, decoded common provider HTML entities in news text, and validated with focused web tests, production build, and Helium fitted/narrow inspection.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-iil","title":"Replace overview with dashboard command page","description":"Turn the mock9 Market Command concept into the production root dashboard, rename the visible route from Home to Dashboard, and keep the layout dense with a chart-first command surface.","acceptance_criteria":"Root page displays Dashboard instead of Home; dashboard includes command metrics, chart area, decision levels, priority board, live context, feed health, dark context, and replay context; web tests and production build pass.","notes":"Implemented from the mock9 direction while preserving the existing / URL and using the existing ChartPane until proper chart implementation lands.","status":"closed","priority":2,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T07:37:56Z","created_by":"dirtydishes","updated_at":"2026-06-13T07:43:44Z","started_at":"2026-06-13T07:38:02Z","closed_at":"2026-06-13T07:43:44Z","close_reason":"dashboard replacement implemented, validated, and documented","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-7l2","title":"Configure local web and desktop to use hosted Islandflow API","description":"Local web development and the Electron desktop shell are not connecting to the VPS-hosted API reliably after a recent endpoint change. Verify the active Delta Island API hostname, update local/default configuration so bun run dev:web and desktop development target it correctly, and validate the relevant web/desktop paths.","status":"closed","priority":2,"issue_type":"bug","owner":"dishes@dpdrm.com","created_at":"2026-06-13T07:32:28Z","created_by":"dirtydishes","updated_at":"2026-06-13T07:38:19Z","closed_at":"2026-06-13T07:38:19Z","close_reason":"Configured local web and desktop development to use https://api.flow.deltaisland.io as the hosted API origin, updated docs and local ignored env, verified the API host from the VPS, passed focused tests, public API route checks, and web build. Dev-web smoke confirmed the corrected API origin but port 3000 was already occupied.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.env.example b/.env.example index 0d59497..da449ef 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,7 @@ NEXT_PUBLIC_LIVE_HOT_WINDOW=600 NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=1200 NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS=1200000 NEXT_PUBLIC_PINNED_EVIDENCE_MAX_ITEMS=4000 +NEXT_ALLOWED_DEV_ORIGINS= ROLLING_WINDOW_SIZE=50 ROLLING_TTL_SEC=86400 CLASSIFIER_SWEEP_MIN_PREMIUM=40000 diff --git a/README.md b/README.md index 227fbbc..969ece4 100644 --- a/README.md +++ b/README.md @@ -412,6 +412,7 @@ Default `smart-money` policy rejects lower-information prints and keeps higher-c | `NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS` | `1200` | Dedicated max hot-window items retained for options prints. | | `NEXT_PUBLIC_NBBO_MAX_AGE_MS` | `1000` | Frontend NBBO staleness threshold. | | `NEXT_PUBLIC_FLOW_FILTER_PRESET` | `smart-money` | Default flow filter preset: `smart-money`, `balanced`, or `all`. | +| `NEXT_ALLOWED_DEV_ORIGINS` | empty, plus auto-detected local IPv4 addresses | Optional comma-separated extra hostnames/IPs allowed to load Next.js dev resources when local browser tooling reaches the dev server through a nonstandard local interface. | ### Replay and testing controls diff --git a/apps/web/app/terminal.tsx b/apps/web/app/terminal.tsx index a61bd29..ad8d046 100644 --- a/apps/web/app/terminal.tsx +++ b/apps/web/app/terminal.tsx @@ -542,6 +542,87 @@ const readErrorDetail = async (response: Response): Promise => { } }; +const OPTION_PRINT_LOOKUP_BATCH_SIZE = 100; +const FLOW_PACKET_LOOKUP_BATCH_SIZE = 12; + +const isAbortLikeError = (error: unknown): boolean => { + return ( + typeof error === "object" && + error !== null && + "name" in error && + (error as { name?: unknown }).name === "AbortError" + ); +}; + +const uniqueNonEmpty = (items: string[]): string[] => { + return Array.from(new Set(items.map((item) => item.trim()).filter(Boolean))); +}; + +const chunkItems = (items: T[], size: number): T[][] => { + const chunks: T[][] = []; + for (let index = 0; index < items.length; index += size) { + chunks.push(items.slice(index, index + size)); + } + return chunks; +}; + +const fetchFlowPacketsByIds = async ( + packetIds: string[], + signal?: AbortSignal +): Promise => { + const packets: FlowPacket[] = []; + for (const batch of chunkItems(uniqueNonEmpty(packetIds), FLOW_PACKET_LOOKUP_BATCH_SIZE)) { + if (signal?.aborted) { + break; + } + const batchPackets = await Promise.all( + batch.map(async (packetId) => { + const response = await fetch(buildApiUrl(`/flow/packets/${encodeURIComponent(packetId)}`), { + signal + }); + if (!response.ok) { + throw new Error(await readErrorDetail(response)); + } + const payload = (await response.json()) as { data?: FlowPacket | null }; + return payload.data ?? null; + }) + ); + for (const packet of batchPackets) { + if (packet) { + packets.push(packet); + } + } + } + return packets; +}; + +const fetchOptionPrintsByTraceIds = async ( + traceIds: string[], + signal?: AbortSignal +): Promise => { + const prints: OptionPrint[] = []; + for (const batch of chunkItems(uniqueNonEmpty(traceIds), OPTION_PRINT_LOOKUP_BATCH_SIZE)) { + if (signal?.aborted) { + break; + } + const url = new URL(buildApiUrl("/option-prints/by-trace")); + for (const traceId of batch) { + url.searchParams.append("trace_id", traceId); + } + const response = await fetch(url.toString(), { signal }); + if (!response.ok) { + throw new Error(await readErrorDetail(response)); + } + const payload = (await response.json()) as { data?: OptionPrint[] }; + for (const item of payload.data ?? []) { + if (item?.trace_id) { + prints.push(item); + } + } + } + return prints; +}; + type WsStatus = "connecting" | "connected" | "disconnected" | "stale"; type TapeMode = "live" | "replay"; @@ -4515,7 +4596,7 @@ const CandleChart = ({ url.searchParams.set("underlying_id", ticker); url.searchParams.set("start_ts", Math.floor(startTs).toString()); url.searchParams.set("end_ts", Math.floor(endTs).toString()); - url.searchParams.set("limit", "2500"); + url.searchParams.set("limit", "1000"); const response = await fetch(url.toString(), { signal: abort.signal }); if (!response.ok) { @@ -6350,8 +6431,10 @@ const useTerminalState = () => { } let cancelled = false; + const abort = new AbortController(); void fetch(buildApiUrl("/lookup/options-support"), { method: "POST", + signal: abort.signal, headers: { "content-type": "application/json" }, body: JSON.stringify({ trace_ids: uniqueTraceIds, @@ -6417,11 +6500,15 @@ const useTerminalState = () => { } }) .catch((error) => { + if (cancelled || abort.signal.aborted || isAbortLikeError(error)) { + return; + } console.warn("Failed to hydrate option row support", error); }); return () => { cancelled = true; + abort.abort(); }; }, [ mode, @@ -6526,35 +6613,26 @@ const useTerminalState = () => { return; } + const abort = new AbortController(); const missingPacketIds = selectedSmartMoneyEvent.packet_ids.filter( (id) => !resolvedFlowPacketMap.has(id) ); if (missingPacketIds.length > 0) { incrementRetentionMetric("pinnedFetchMisses", missingPacketIds.length); - void Promise.all( - missingPacketIds.map(async (packetId) => { - const response = await fetch( - buildApiUrl(`/flow/packets/${encodeURIComponent(packetId)}`) - ); - if (!response.ok) { - throw new Error(await readErrorDetail(response)); - } - const payload = (await response.json()) as { data?: FlowPacket | null }; - return payload.data ?? null; - }) - ) + void fetchFlowPacketsByIds(missingPacketIds, abort.signal) .then((packets) => { const next = new Map(); for (const packet of packets) { - if (packet) { - next.set(packet.id, packet); - } + next.set(packet.id, packet); } if (next.size > 0) { setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, next, Date.now())); } }) .catch((error) => { + if (abort.signal.aborted || isAbortLikeError(error)) { + return; + } incrementRetentionMetric("pinnedFetchFailures", 1); console.warn("Failed to fetch smart-money flow packets", error); }); @@ -6563,37 +6641,28 @@ const useTerminalState = () => { const missingPrintIds = selectedSmartMoneyEvent.member_print_ids.filter( (id) => !resolvedOptionPrintMap.has(id) ); - if (missingPrintIds.length === 0) { - return; - } - incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length); - const url = new URL(buildApiUrl("/option-prints/by-trace")); - for (const traceId of missingPrintIds) { - url.searchParams.append("trace_id", traceId); - } - void fetch(url.toString()) - .then(async (response) => { - if (!response.ok) { - throw new Error(await readErrorDetail(response)); - } - return response.json(); - }) - .then((payload: { data?: OptionPrint[] }) => { - const next = new Map(); - for (const item of payload.data ?? []) { - if (!item || !item.trace_id) { - continue; + if (missingPrintIds.length > 0) { + incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length); + void fetchOptionPrintsByTraceIds(missingPrintIds, abort.signal) + .then((prints) => { + const next = new Map(); + for (const item of prints) { + next.set(item.trace_id, item); } - next.set(item.trace_id, item); - } - if (next.size > 0) { - setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, next, Date.now())); - } - }) - .catch((error) => { - incrementRetentionMetric("pinnedFetchFailures", 1); - console.warn("Failed to fetch smart-money option prints", error); - }); + if (next.size > 0) { + setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, next, Date.now())); + } + }) + .catch((error) => { + if (abort.signal.aborted || isAbortLikeError(error)) { + return; + } + incrementRetentionMetric("pinnedFetchFailures", 1); + console.warn("Failed to fetch smart-money option prints", error); + }); + } + + return () => abort.abort(); }, [mode, resolvedFlowPacketMap, resolvedOptionPrintMap, selectedSmartMoneyEvent]); const inferAlertUnderlying = useCallback( @@ -6902,6 +6971,7 @@ const useTerminalState = () => { return; } + const abort = new AbortController(); const visiblePacketIds = visibleAlerts.flatMap((alert) => getAlertFlowPacketRefs(alert)); const missingPacketIds = Array.from(new Set(visiblePacketIds)).filter( (id) => !resolvedFlowPacketMap.has(id) @@ -6909,24 +6979,11 @@ const useTerminalState = () => { if (missingPacketIds.length > 0) { incrementRetentionMetric("pinnedFetchMisses", missingPacketIds.length); - void Promise.all( - missingPacketIds.map(async (packetId) => { - const response = await fetch( - buildApiUrl(`/flow/packets/${encodeURIComponent(packetId)}`) - ); - if (!response.ok) { - throw new Error(await readErrorDetail(response)); - } - const payload = (await response.json()) as { data?: FlowPacket | null }; - return payload.data ?? null; - }) - ) + void fetchFlowPacketsByIds(missingPacketIds, abort.signal) .then((packets) => { const next = new Map(); for (const packet of packets) { - if (packet) { - next.set(packet.id, packet); - } + next.set(packet.id, packet); } if (next.size > 0) { const now = Date.now(); @@ -6934,6 +6991,9 @@ const useTerminalState = () => { } }) .catch((error) => { + if (abort.signal.aborted || isAbortLikeError(error)) { + return; + } incrementRetentionMetric("pinnedFetchFailures", 1); console.warn("Failed to prefetch visible alert packets", error); }); @@ -6942,39 +7002,29 @@ const useTerminalState = () => { const missingPrintIds = Array.from(visibleAlertEvidenceRefs).filter( (id) => !resolvedFlowPacketMap.has(id) && !resolvedOptionPrintMap.has(id) ); - if (missingPrintIds.length === 0) { - return; + if (missingPrintIds.length > 0) { + incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length); + void fetchOptionPrintsByTraceIds(missingPrintIds, abort.signal) + .then((prints) => { + const next = new Map(); + for (const item of prints) { + next.set(item.trace_id, item); + } + if (next.size > 0) { + const now = Date.now(); + setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, next, now)); + } + }) + .catch((error) => { + if (abort.signal.aborted || isAbortLikeError(error)) { + return; + } + incrementRetentionMetric("pinnedFetchFailures", 1); + console.warn("Failed to prefetch visible alert evidence", error); + }); } - incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length); - const url = new URL(buildApiUrl("/option-prints/by-trace")); - for (const traceId of missingPrintIds) { - url.searchParams.append("trace_id", traceId); - } - void fetch(url.toString()) - .then(async (response) => { - if (!response.ok) { - throw new Error(await readErrorDetail(response)); - } - return response.json(); - }) - .then((payload: { data?: OptionPrint[] }) => { - const next = new Map(); - for (const item of payload.data ?? []) { - if (!item || !item.trace_id) { - continue; - } - next.set(item.trace_id, item); - } - if (next.size > 0) { - const now = Date.now(); - setPinnedOptionPrintMap((prev) => upsertPinnedEntries(prev, next, now)); - } - }) - .catch((error) => { - incrementRetentionMetric("pinnedFetchFailures", 1); - console.warn("Failed to prefetch visible alert evidence", error); - }); + return () => abort.abort(); }, [ mode, visibleAlerts, @@ -7866,10 +7916,11 @@ const OptionsPane = memo(({ state, limit, title = "Options", className }: Option ); return decor ? ( - + ) : (
{cells} diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index ae6d971..a723042 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,5 +1,26 @@ +import { networkInterfaces } from "node:os"; import { PHASE_DEVELOPMENT_SERVER } from "next/constants.js"; +const configuredAllowedDevOrigins = () => { + return (process.env.NEXT_ALLOWED_DEV_ORIGINS ?? "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean); +}; + +const localIpv4DevOrigins = () => { + return Object.values(networkInterfaces()) + .flat() + .filter((address) => address?.family === "IPv4") + .map((address) => address.address); +}; + +const allowedDevOrigins = () => { + return Array.from( + new Set(["localhost", "127.0.0.1", ...localIpv4DevOrigins(), ...configuredAllowedDevOrigins()]) + ); +}; + /** * Keep dev and production build artifacts separate to avoid chunk/runtime * mismatches when `next dev` and `next build` are run in overlapping sessions. @@ -11,6 +32,7 @@ export default function nextConfig(phase) { const isDev = phase === PHASE_DEVELOPMENT_SERVER; return { + allowedDevOrigins: isDev ? allowedDevOrigins() : undefined, distDir: isDev ? ".next-dev" : ".next" }; } diff --git a/docs/turns/2026-06-13-1130-fix-local-backend-connectivity.html b/docs/turns/2026-06-13-1130-fix-local-backend-connectivity.html new file mode 100644 index 0000000..becf4db --- /dev/null +++ b/docs/turns/2026-06-13-1130-fix-local-backend-connectivity.html @@ -0,0 +1,380 @@ + + + + + + Fix local backend connectivity + + + +
+
+

Islandflow turn record

+

Fix local backend connectivity

+
+ api cors deployed + dev:web verified + dev:desktop verified + native deployment path +
+
+

Summary

Local web and desktop development were failing to reach the hosted Islandflow backend because browser CORS preflight requests were blocked by the native API edge. The API now reflects allowed local origins, answers OPTIONS preflight, and the local web surface connects cleanly to https://api.flow.deltaisland.io.

The terminal UI also now avoids oversized evidence URLs and stale request floods, which were showing up as noisy browser network warnings after the CORS fix landed.

+

Changes Made

API CORS layerAdded reusable CORS helpers, configured allowed origins, wrapped API responses, and handled OPTIONS globally.
Local dev originsNext dev now allows localhost, 127.0.0.1, detected local IPv4 addresses, and optional NEXT_ALLOWED_DEV_ORIGINS.
Terminal fetch stabilityChunked option evidence lookups, bounded flow packet fetch concurrency, and abort stale hydration requests.
Chart overlay capChanged the equity overlay range request from 2500 rows to the API-supported 1000-row maximum.
+

Context

The repo is using native deployment for the hosted API, not Docker compose. I deployed the API CORS fix through the native deploy path and validated the running islandflow-api.service directly after the deploy wrapper returned a nonzero verification-tail exit.

After CORS was fixed, the local browser could connect, but terminal helper fetches still produced warnings from oversized /option-prints/by-trace query strings and fast-changing live windows. Those were separate frontend request-shaping issues, not the main websocket/backend connection.

+

Important Implementation Details

  • API_CORS_ORIGINS defaults include the hosted web origin and local dev origins for ports 3000 and 3100.
  • Preflight responses reflect requested headers and allow GET, POST, PUT, and OPTIONS.
  • Terminal evidence lookups now chunk trace-id batches to avoid edge 414 Request-URI Too Large responses.
  • High-churn live hydration effects now use AbortController cleanup so stale requests do not masquerade as backend failures.
  • Classified option rows now use a focusable row container instead of nesting instrument buttons inside another button.
+

Relevant Diff Snippets

Rendered with @pierre/diffs/ssr from a representative diff covering the API CORS helper, API wiring, Next dev-origin config, and terminal fetch handling.

+

Expected Impact for End-Users

Developers can run bun run dev:web or bun run dev:desktop and see the local terminal connect to the hosted native backend without CORS failures. The live terminal should also stay calmer under evidence-heavy alert windows because it no longer emits oversized by-trace URLs or piles up stale support requests.

+

Validation

  • Ran bun test services/api/tests: 38 tests passed.
  • Ran bun run typecheck: passed across apps, packages, and services.
  • Ran bun --cwd=apps/web run build: passed Next production build.
  • Verified hosted API CORS with curl health, OPTIONS preflight, options REST, and websocket checks from local origins.
  • Verified bun run dev:web in the in-app browser at http://127.0.0.1:3000/: page showed LIVE: CONNECTED and fresh logs stayed clear of backend network warnings.
  • Verified bun run dev:desktop: Electron launched, the runner served the local web UI, and browser verification against its 127.0.0.1:3000 endpoint showed LIVE: CONNECTED.
  • Confirmed no dev server was left listening on port 3000 after validation.
+

Issues, Limitations, and Mitigations

  • The native deploy command returned a nonzero status during its verification tail, but the native user service was active and direct live API checks passed. I did not leave Docker deployment state running.
  • The web build temporarily flipped apps/web/next-env.d.ts from the dev routes file to the production routes file. That generated change was restored and excluded from the final commit.
  • The frontend request chunking fixes are validated locally. I did not deploy the hosted web frontend in this pass because the user-facing breakage was local dev access and the hosted API CORS fix is the deployed native change.
+

Follow-up Work

  • Add a POST batch endpoint for evidence lookups so the terminal never has to encode many trace IDs into a query string.
  • Add a scripted browser smoke test for local dev against https://api.flow.deltaisland.io.
  • Improve the native deploy script verification path so a successful service restart is reported cleanly.
+
+ + From ce65e7b45f13bc69e0a9d102e650b6af009d40b5 Mon Sep 17 00:00:00 2001 From: dirtydishes Date: Sun, 14 Jun 2026 15:37:14 -0400 Subject: [PATCH 4/4] fix pr ci validation --- .beads/issues.jsonl | 1 + deployment/docker/workspace-root/bun.lock | 4 +- deployment/docker/workspace-root/package.json | 2 +- services/api/src/index.ts | 994 +++++++++--------- services/api/tests/cors.test.ts | 4 +- 5 files changed, 507 insertions(+), 498 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3362806..195a952 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,3 +1,4 @@ +{"_type":"issue","id":"islandflow-0e3","title":"Fix PR 23 CI failures","description":"PR 23 is failing the Forgejo CI Validate workflow. Reproduce the failing gates locally, fix the underlying formatting/lint/typecheck/test/build issues, update the PR branch, and confirm the remote check passes.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-14T19:35:07Z","created_by":"dirtydishes","updated_at":"2026-06-14T19:37:01Z","started_at":"2026-06-14T19:35:12Z","closed_at":"2026-06-14T19:37:01Z","close_reason":"Local Validate workflow passes after applying formatter output and syncing the Docker workspace snapshot.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-9w7","title":"Allow local dev origins on hosted API","description":"Local bun run dev:web and desktop-local point at the hosted API, but browser requests from http://127.0.0.1:3000 are blocked because the API omits CORS headers and returns 404 for OPTIONS preflight. Add API-side CORS handling, validate local web/desktop browser access, and deploy the API fix.","acceptance_criteria":"API responses include Access-Control-Allow-Origin for allowed local/dev origins; OPTIONS preflight succeeds; bun run dev:web reaches hosted REST/WS endpoints from a browser; bun run dev:desktop local mode reaches the backend through the local web UI; tests/build pass; fix is deployed to api.flow.deltaisland.io.","status":"closed","priority":1,"issue_type":"bug","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T15:04:19Z","created_by":"dirtydishes","updated_at":"2026-06-13T15:29:42Z","started_at":"2026-06-13T15:04:26Z","closed_at":"2026-06-13T15:29:42Z","close_reason":"Hosted API now reflects allowed local dev origins and handles OPTIONS preflight; local web and desktop dev runners both reach https://api.flow.deltaisland.io; API tests, typecheck, and web build passed.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-xkq","title":"Rebuild production dashboard options news around mock9 aesthetic","description":"Reconstruct the production web UI for Dashboard, Options, and News around the mock9 through mock12 dense terminal aesthetic while preserving production data subscriptions, drawers, virtualization, route helpers, redirects, and validation.","status":"closed","priority":1,"issue_type":"feature","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-13T14:07:34Z","created_by":"dirtydishes","updated_at":"2026-06-13T14:26:46Z","started_at":"2026-06-13T14:07:53Z","closed_at":"2026-06-13T14:26:46Z","close_reason":"Rebuilt Dashboard, Options, and News around the dense mock9 to mock12 production aesthetic; tests and build passed, and Browser visual inspection was documented as blocked by the unavailable in-app browser backend.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"islandflow-u45","title":"Patch CVE-related dependency and Docker image findings","description":"Address Forgejo issues #15, #18, and #19 by upgrading the vulnerable tmp dependency resolution and moving Bun Docker images off the vulnerable oven/bun:1.3.11 base image with patched OpenSSL packages during image build.","status":"closed","priority":1,"issue_type":"task","assignee":"dirtydishes","owner":"dishes@dpdrm.com","created_at":"2026-06-12T23:21:29Z","created_by":"dirtydishes","updated_at":"2026-06-12T23:23:27Z","started_at":"2026-06-12T23:22:16Z","closed_at":"2026-06-12T23:23:27Z","close_reason":"Patched Forgejo #15/#18 tmp CVE by resolving tmp@0.2.7, updated Bun Docker images and OpenSSL package upgrade layers for #19, and validated with bun audit, tests, web build, docker workspace check, and replacement image manifest inspection.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/deployment/docker/workspace-root/bun.lock b/deployment/docker/workspace-root/bun.lock index 9b60caa..0b7d3ab 100644 --- a/deployment/docker/workspace-root/bun.lock +++ b/deployment/docker/workspace-root/bun.lock @@ -176,7 +176,7 @@ "@electron/node-gyp": "^10.2.0-electron.2", "postcss": "^8.5.15", "tar": "^7.5.15", - "tmp": "^0.2.5", + "tmp": "^0.2.6", }, "packages": { "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], @@ -1175,7 +1175,7 @@ "terser-webpack-plugin": ["terser-webpack-plugin@5.6.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", "schema-utils": "^4.3.0", "terser": "^5.31.1" }, "peerDependencies": { "webpack": "^5.1.0" } }, "sha512-Eum+5ajkaOhf5KbM26osvv21kLD7BaGqQ1UA4Ami4arYwylmGUQTgHFpHDdmJod1q4QXa66p0to/FBKID+J1vA=="], - "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + "tmp": ["tmp@0.2.7", "", {}, "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], diff --git a/deployment/docker/workspace-root/package.json b/deployment/docker/workspace-root/package.json index 7dc2533..a7789a7 100644 --- a/deployment/docker/workspace-root/package.json +++ b/deployment/docker/workspace-root/package.json @@ -39,7 +39,7 @@ "overrides": { "postcss": "^8.5.15", "tar": "^7.5.15", - "tmp": "^0.2.5", + "tmp": "^0.2.6", "@electron/node-gyp": "^10.2.0-electron.2" }, "dependencies": { diff --git a/services/api/src/index.ts b/services/api/src/index.ts index cdfad6e..e450e19 100644 --- a/services/api/src/index.ts +++ b/services/api/src/index.ts @@ -1382,587 +1382,597 @@ const run = async () => { return jsonResponse({ status: "ok" }); } - if (req.method === "GET" && url.pathname === "/admin/synthetic/status") { - const authError = authenticateSyntheticAdminRequest(req); - if (authError) { - return authError; + if (req.method === "GET" && url.pathname === "/admin/synthetic/status") { + const authError = authenticateSyntheticAdminRequest(req); + if (authError) { + return authError; + } + return jsonResponse(buildSyntheticStatusBody()); } - return jsonResponse(buildSyntheticStatusBody()); - } - if (req.method === "GET" && url.pathname === "/admin/synthetic/control") { - const authError = authenticateSyntheticAdminRequest(req); - if (authError) { - return authError; + if (req.method === "GET" && url.pathname === "/admin/synthetic/control") { + const authError = authenticateSyntheticAdminRequest(req); + if (authError) { + return authError; + } + return jsonResponse({ control: syntheticControl }); } - return jsonResponse({ control: syntheticControl }); - } - if (req.method === "PUT" && url.pathname === "/admin/synthetic/control") { - const authError = authenticateSyntheticAdminRequest(req); - if (authError) { - return authError; + if (req.method === "PUT" && url.pathname === "/admin/synthetic/control") { + const authError = authenticateSyntheticAdminRequest(req); + if (authError) { + return authError; + } + try { + const payload = SyntheticControlStateSchema.parse(await readJsonBody(req)); + syntheticControl = await writeSyntheticControlState(syntheticControlKv, payload); + return jsonResponse({ + control: syntheticControl, + derived: buildSyntheticDerivedStatus( + Date.now(), + syntheticControl, + syntheticProfileHits + ) + }); + } catch (error) { + return jsonResponse( + { + error: "invalid synthetic control payload", + detail: getErrorMessage(error) + }, + 400 + ); + } } - try { - const payload = SyntheticControlStateSchema.parse(await readJsonBody(req)); - syntheticControl = await writeSyntheticControlState(syntheticControlKv, payload); - return jsonResponse({ - control: syntheticControl, - derived: buildSyntheticDerivedStatus(Date.now(), syntheticControl, syntheticProfileHits) - }); - } catch (error) { - return jsonResponse( - { - error: "invalid synthetic control payload", - detail: getErrorMessage(error) - }, - 400 - ); - } - } - if (req.method === "GET" && url.pathname === "/prints/options") { - try { + if (req.method === "GET" && url.pathname === "/prints/options") { + try { + const limit = parseLimit(url.searchParams.get("limit")); + const source = parseReplaySource(url) ?? undefined; + const { storageFilters } = parseOptionPrintQuery(url); + const data = await fetchRecentOptionPrints(clickhouse, limit, source, storageFilters); + return jsonResponse({ data }); + } catch (error) { + return jsonResponse( + { + error: "invalid options query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); + } + } + + if (req.method === "GET" && url.pathname === "/nbbo/options") { const limit = parseLimit(url.searchParams.get("limit")); const source = parseReplaySource(url) ?? undefined; - const { storageFilters } = parseOptionPrintQuery(url); - const data = await fetchRecentOptionPrints(clickhouse, limit, source, storageFilters); + const data = await fetchRecentOptionNBBO(clickhouse, limit, source); return jsonResponse({ data }); - } catch (error) { - return jsonResponse( - { - error: "invalid options query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 - ); } - } - if (req.method === "GET" && url.pathname === "/nbbo/options") { - const limit = parseLimit(url.searchParams.get("limit")); - const source = parseReplaySource(url) ?? undefined; - const data = await fetchRecentOptionNBBO(clickhouse, limit, source); - return jsonResponse({ data }); - } - - if (req.method === "GET" && url.pathname === "/prints/equities") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentEquityPrints(clickhouse, limit); - return jsonResponse({ data }); - } - - if (req.method === "GET" && url.pathname === "/prints/equities/range") { - try { - const { underlyingId, startTs, endTs, limit } = parseEquityPrintRangeParams(url); - const data = await fetchEquityPrintsRange( - clickhouse, - underlyingId, - startTs, - endTs, - limit - ); + if (req.method === "GET" && url.pathname === "/prints/equities") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentEquityPrints(clickhouse, limit); return jsonResponse({ data }); - } catch (error) { - return jsonResponse( - { - error: "invalid equity range query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 - ); } - } - if (req.method === "GET" && url.pathname === "/quotes/equities") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentEquityQuotes(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/prints/equities/range") { + try { + const { underlyingId, startTs, endTs, limit } = parseEquityPrintRangeParams(url); + const data = await fetchEquityPrintsRange( + clickhouse, + underlyingId, + startTs, + endTs, + limit + ); + return jsonResponse({ data }); + } catch (error) { + return jsonResponse( + { + error: "invalid equity range query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); + } + } - if (req.method === "GET" && url.pathname === "/candles/equities") { - try { - const { underlyingId, intervalMs, startTs, endTs, limit, useCache } = - parseCandleParams(url); - if (useCache && redis && redis.isOpen) { - const cached = await fetchEquityCandlesFromCache( - redis, + if (req.method === "GET" && url.pathname === "/quotes/equities") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentEquityQuotes(clickhouse, limit); + return jsonResponse({ data }); + } + + if (req.method === "GET" && url.pathname === "/candles/equities") { + try { + const { underlyingId, intervalMs, startTs, endTs, limit, useCache } = + parseCandleParams(url); + if (useCache && redis && redis.isOpen) { + const cached = await fetchEquityCandlesFromCache( + redis, + underlyingId, + intervalMs, + startTs, + endTs + ); + if (cached.length > 0) { + return jsonResponse({ data: cached }); + } + } + + const data = await fetchEquityCandlesRange( + clickhouse, underlyingId, intervalMs, startTs, - endTs + endTs, + limit + ); + return jsonResponse({ data }); + } catch (error) { + return jsonResponse( + { + error: "invalid candle query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 ); - if (cached.length > 0) { - return jsonResponse({ data: cached }); - } } + } - const data = await fetchEquityCandlesRange( - clickhouse, - underlyingId, - intervalMs, - startTs, - endTs, - limit - ); + if (req.method === "GET" && url.pathname === "/joins/equities") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentEquityPrintJoins(clickhouse, limit); return jsonResponse({ data }); - } catch (error) { - return jsonResponse( - { - error: "invalid candle query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 - ); } - } - if (req.method === "GET" && url.pathname === "/joins/equities") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentEquityPrintJoins(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/dark/inferred") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentInferredDark(clickhouse, limit); + return jsonResponse({ data }); + } - if (req.method === "GET" && url.pathname === "/dark/inferred") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentInferredDark(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/flow/packets") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentFlowPackets(clickhouse, limit); + return jsonResponse({ data }); + } - if (req.method === "GET" && url.pathname === "/flow/packets") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentFlowPackets(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/flow/smart-money") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentSmartMoneyEvents(clickhouse, limit); + return jsonResponse({ data }); + } - if (req.method === "GET" && url.pathname === "/flow/smart-money") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentSmartMoneyEvents(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/flow/classifier-hits") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentClassifierHits(clickhouse, limit); + return jsonResponse({ data }); + } - if (req.method === "GET" && url.pathname === "/flow/classifier-hits") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentClassifierHits(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/flow/alerts") { + const limit = parseLimit(url.searchParams.get("limit")); + const data = await fetchRecentAlerts(clickhouse, limit); + return jsonResponse({ data }); + } - if (req.method === "GET" && url.pathname === "/flow/alerts") { - const limit = parseLimit(url.searchParams.get("limit")); - const data = await fetchRecentAlerts(clickhouse, limit); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/news") { + const limit = parseLimit(url.searchParams.get("limit") ?? "100"); + const data = await fetchRecentNews(clickhouse, limit); + return jsonResponse({ data }); + } - if (req.method === "GET" && url.pathname === "/news") { - const limit = parseLimit(url.searchParams.get("limit") ?? "100"); - const data = await fetchRecentNews(clickhouse, limit); - return jsonResponse({ data }); - } - - if (req.method === "GET" && isAlertContextPath(url.pathname)) { - try { - const traceId = parseAlertContextTraceIdPath(url.pathname); - if (traceId === null) { - return jsonResponse({ error: "not found" }, 404); + if (req.method === "GET" && isAlertContextPath(url.pathname)) { + try { + const traceId = parseAlertContextTraceIdPath(url.pathname); + if (traceId === null) { + return jsonResponse({ error: "not found" }, 404); + } + const data = await fetchAlertContextByTraceId(clickhouse, traceId); + return jsonResponse(data); + } catch (error) { + return jsonResponse( + { + error: "invalid alert context query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); } - const data = await fetchAlertContextByTraceId(clickhouse, traceId); - return jsonResponse(data); - } catch (error) { - return jsonResponse( - { - error: "invalid alert context query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 - ); } - } - if (req.method === "GET" && url.pathname === "/history/options") { - try { + if (req.method === "GET" && url.pathname === "/history/options") { + try { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const source = parseReplaySource(url) ?? undefined; + const { storageFilters } = parseOptionPrintQuery(url); + const data = await fetchOptionPrintsBefore( + clickhouse, + beforeTs, + beforeSeq, + limit, + source, + storageFilters + ); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })) + ); + } catch (error) { + return jsonResponse( + { + error: "invalid options history query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); + } + } + + if (req.method === "GET" && url.pathname === "/history/nbbo") { const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); const source = parseReplaySource(url) ?? undefined; - const { storageFilters } = parseOptionPrintQuery(url); - const data = await fetchOptionPrintsBefore( + const data = await fetchOptionNBBOBefore(clickhouse, beforeTs, beforeSeq, limit, source); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })) + ); + } + + if (req.method === "GET" && url.pathname === "/history/equities") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchEquityPrintsBefore( clickhouse, beforeTs, beforeSeq, limit, - source, - storageFilters + parseLiveEquityPrintFilters(url) ); return jsonResponse( buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })) ); - } catch (error) { + } + + if (req.method === "GET" && url.pathname === "/history/equity-quotes") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchEquityQuotesBefore(clickhouse, beforeTs, beforeSeq, limit); return jsonResponse( - { - error: "invalid options history query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 + buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })) ); } - } - if (req.method === "GET" && url.pathname === "/history/nbbo") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const source = parseReplaySource(url) ?? undefined; - const data = await fetchOptionNBBOBefore(clickhouse, beforeTs, beforeSeq, limit, source); - return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))); - } - - if (req.method === "GET" && url.pathname === "/history/equities") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchEquityPrintsBefore( - clickhouse, - beforeTs, - beforeSeq, - limit, - parseLiveEquityPrintFilters(url) - ); - return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))); - } - - if (req.method === "GET" && url.pathname === "/history/equity-quotes") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchEquityQuotesBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))); - } - - if (req.method === "GET" && url.pathname === "/history/equity-joins") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchEquityPrintJoinsBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && url.pathname === "/history/flow") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchFlowPacketsBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && url.pathname === "/history/smart-money") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchSmartMoneyEventsBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && url.pathname === "/history/classifier-hits") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchClassifierHitsBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && url.pathname === "/history/alerts") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchAlertsBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && url.pathname === "/history/inferred-dark") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchInferredDarkBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && url.pathname === "/history/news") { - const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); - const data = await fetchNewsBefore(clickhouse, beforeTs, beforeSeq, limit); - return jsonResponse( - buildHistoryResponse(data, (item) => ({ ts: item.published_ts, seq: item.seq })) - ); - } - - if (req.method === "GET" && /^\/flow\/packets\/[^/]+$/.test(url.pathname)) { - const id = decodeURIComponent(url.pathname.slice("/flow/packets/".length)); - const data = await fetchFlowPacketById(clickhouse, id); - return jsonResponse({ data }); - } - - if (req.method === "GET" && /^\/flow\/alerts\/[^/]+\/context$/.test(url.pathname)) { - const traceId = decodeURIComponent( - url.pathname.slice("/flow/alerts/".length, -"/context".length) - ).trim(); - if (!traceId || traceId.length > 512) { - return jsonResponse({ error: "invalid alert trace id" }, 400); - } - const data = await fetchAlertContextByTraceId(clickhouse, traceId); - return jsonResponse(data); - } - - if (req.method === "GET" && url.pathname === "/option-prints/by-trace") { - const traceIds = url.searchParams.getAll("trace_id"); - const data = await fetchOptionPrintsByTraceIds(clickhouse, traceIds); - return jsonResponse({ data }); - } - - if (req.method === "POST" && url.pathname === "/lookup/options-support") { - try { - const body = optionsSupportLookupSchema.parse(await readJsonBody(req)); - const packets = await fetchFlowPacketsByMemberTraceIds(clickhouse, body.trace_ids); - const packetIds = packets.map((packet) => packet.id); - const [smartMoney, classifierHits, nbboByTraceId] = await Promise.all([ - fetchSmartMoneyEventsByPacketIds(clickhouse, packetIds), - fetchClassifierHitsByPacketIds(clickhouse, packetIds), - fetchNearestOptionNBBOForPrints(clickhouse, body.nbbo_context) - ]); - return jsonResponse({ - packets, - smart_money: smartMoney, - classifier_hits: classifierHits, - nbbo_by_trace_id: nbboByTraceId - }); - } catch (error) { + if (req.method === "GET" && url.pathname === "/history/equity-joins") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchEquityPrintJoinsBefore(clickhouse, beforeTs, beforeSeq, limit); return jsonResponse( - { - error: "invalid options support lookup", - detail: error instanceof Error ? error.message : String(error) - }, - 400 + buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) ); } - } - if (req.method === "GET" && url.pathname === "/equity-joins/by-id") { - const ids = url.searchParams.getAll("id"); - const data = await fetchEquityPrintJoinsByIds(clickhouse, ids); - return jsonResponse({ data }); - } + if (req.method === "GET" && url.pathname === "/history/flow") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchFlowPacketsBefore(clickhouse, beforeTs, beforeSeq, limit); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) + ); + } - if (req.method === "GET" && url.pathname === "/replay/options") { - try { + if (req.method === "GET" && url.pathname === "/history/smart-money") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchSmartMoneyEventsBefore(clickhouse, beforeTs, beforeSeq, limit); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) + ); + } + + if (req.method === "GET" && url.pathname === "/history/classifier-hits") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchClassifierHitsBefore(clickhouse, beforeTs, beforeSeq, limit); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) + ); + } + + if (req.method === "GET" && url.pathname === "/history/alerts") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchAlertsBefore(clickhouse, beforeTs, beforeSeq, limit); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) + ); + } + + if (req.method === "GET" && url.pathname === "/history/inferred-dark") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchInferredDarkBefore(clickhouse, beforeTs, beforeSeq, limit); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.source_ts, seq: item.seq })) + ); + } + + if (req.method === "GET" && url.pathname === "/history/news") { + const { beforeTs, beforeSeq, limit } = parseBeforeParams(url); + const data = await fetchNewsBefore(clickhouse, beforeTs, beforeSeq, limit); + return jsonResponse( + buildHistoryResponse(data, (item) => ({ ts: item.published_ts, seq: item.seq })) + ); + } + + if (req.method === "GET" && /^\/flow\/packets\/[^/]+$/.test(url.pathname)) { + const id = decodeURIComponent(url.pathname.slice("/flow/packets/".length)); + const data = await fetchFlowPacketById(clickhouse, id); + return jsonResponse({ data }); + } + + if (req.method === "GET" && /^\/flow\/alerts\/[^/]+\/context$/.test(url.pathname)) { + const traceId = decodeURIComponent( + url.pathname.slice("/flow/alerts/".length, -"/context".length) + ).trim(); + if (!traceId || traceId.length > 512) { + return jsonResponse({ error: "invalid alert trace id" }, 400); + } + const data = await fetchAlertContextByTraceId(clickhouse, traceId); + return jsonResponse(data); + } + + if (req.method === "GET" && url.pathname === "/option-prints/by-trace") { + const traceIds = url.searchParams.getAll("trace_id"); + const data = await fetchOptionPrintsByTraceIds(clickhouse, traceIds); + return jsonResponse({ data }); + } + + if (req.method === "POST" && url.pathname === "/lookup/options-support") { + try { + const body = optionsSupportLookupSchema.parse(await readJsonBody(req)); + const packets = await fetchFlowPacketsByMemberTraceIds(clickhouse, body.trace_ids); + const packetIds = packets.map((packet) => packet.id); + const [smartMoney, classifierHits, nbboByTraceId] = await Promise.all([ + fetchSmartMoneyEventsByPacketIds(clickhouse, packetIds), + fetchClassifierHitsByPacketIds(clickhouse, packetIds), + fetchNearestOptionNBBOForPrints(clickhouse, body.nbbo_context) + ]); + return jsonResponse({ + packets, + smart_money: smartMoney, + classifier_hits: classifierHits, + nbbo_by_trace_id: nbboByTraceId + }); + } catch (error) { + return jsonResponse( + { + error: "invalid options support lookup", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); + } + } + + if (req.method === "GET" && url.pathname === "/equity-joins/by-id") { + const ids = url.searchParams.getAll("id"); + const data = await fetchEquityPrintJoinsByIds(clickhouse, ids); + return jsonResponse({ data }); + } + + if (req.method === "GET" && url.pathname === "/replay/options") { + try { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const source = parseReplaySource(url) ?? undefined; + const { storageFilters } = parseOptionPrintQuery(url); + const data = await fetchOptionPrintsAfter( + clickhouse, + afterTs, + afterSeq, + limit, + source, + storageFilters + ); + const last = data.at(-1); + const next = last ? { ts: last.ts, seq: last.seq } : null; + return jsonResponse({ data, next }); + } catch (error) { + return jsonResponse( + { + error: "invalid options replay query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); + } + } + + if (req.method === "GET" && url.pathname === "/replay/nbbo") { const { afterTs, afterSeq, limit } = parseReplayParams(url); const source = parseReplaySource(url) ?? undefined; - const { storageFilters } = parseOptionPrintQuery(url); - const data = await fetchOptionPrintsAfter( - clickhouse, - afterTs, - afterSeq, - limit, - source, - storageFilters - ); + const data = await fetchOptionNBBOAfter(clickhouse, afterTs, afterSeq, limit, source); const last = data.at(-1); const next = last ? { ts: last.ts, seq: last.seq } : null; return jsonResponse({ data, next }); - } catch (error) { - return jsonResponse( - { - error: "invalid options replay query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 - ); } - } - if (req.method === "GET" && url.pathname === "/replay/nbbo") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const source = parseReplaySource(url) ?? undefined; - const data = await fetchOptionNBBOAfter(clickhouse, afterTs, afterSeq, limit, source); - const last = data.at(-1); - const next = last ? { ts: last.ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/equities") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchEquityPrintsAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/equity-quotes") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchEquityQuotesAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/equity-candles") { - try { - const { underlyingId, intervalMs, afterTs, afterSeq, limit } = - parseCandleReplayParams(url); - const data = await fetchEquityCandlesAfter( - clickhouse, - underlyingId, - intervalMs, - afterTs, - afterSeq, - limit - ); + if (req.method === "GET" && url.pathname === "/replay/equities") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchEquityPrintsAfter(clickhouse, afterTs, afterSeq, limit); const last = data.at(-1); const next = last ? { ts: last.ts, seq: last.seq } : null; return jsonResponse({ data, next }); - } catch (error) { - return jsonResponse( - { - error: "invalid candle replay query", - detail: error instanceof Error ? error.message : String(error) - }, - 400 - ); - } - } - - if (req.method === "GET" && url.pathname === "/replay/equity-joins") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchEquityPrintJoinsAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.source_ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/inferred-dark") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchInferredDarkAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.source_ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/flow") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchFlowPacketsAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.source_ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/smart-money") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchSmartMoneyEventsAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.source_ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/classifier-hits") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchClassifierHitsAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.source_ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/replay/alerts") { - const { afterTs, afterSeq, limit } = parseReplayParams(url); - const data = await fetchAlertsAfter(clickhouse, afterTs, afterSeq, limit); - const last = data.at(-1); - const next = last ? { ts: last.source_ts, seq: last.seq } : null; - return jsonResponse({ data, next }); - } - - if (req.method === "GET" && url.pathname === "/ws/options") { - if (serverRef.upgrade(req, { data: { channel: "options" } })) { - return new Response(null, { status: 101 }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/options-nbbo") { - if (serverRef.upgrade(req, { data: { channel: "options-nbbo" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/equity-quotes") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchEquityQuotesAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/equities") { - if (serverRef.upgrade(req, { data: { channel: "equities" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/equity-candles") { + try { + const { underlyingId, intervalMs, afterTs, afterSeq, limit } = + parseCandleReplayParams(url); + const data = await fetchEquityCandlesAfter( + clickhouse, + underlyingId, + intervalMs, + afterTs, + afterSeq, + limit + ); + const last = data.at(-1); + const next = last ? { ts: last.ts, seq: last.seq } : null; + return jsonResponse({ data, next }); + } catch (error) { + return jsonResponse( + { + error: "invalid candle replay query", + detail: error instanceof Error ? error.message : String(error) + }, + 400 + ); + } } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/equity-candles") { - if (serverRef.upgrade(req, { data: { channel: "equity-candles" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/equity-joins") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchEquityPrintJoinsAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.source_ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/equity-quotes") { - if (serverRef.upgrade(req, { data: { channel: "equity-quotes" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/inferred-dark") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchInferredDarkAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.source_ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/equity-joins") { - if (serverRef.upgrade(req, { data: { channel: "equity-joins" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/flow") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchFlowPacketsAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.source_ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/inferred-dark") { - if (serverRef.upgrade(req, { data: { channel: "inferred-dark" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/smart-money") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchSmartMoneyEventsAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.source_ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/flow") { - if (serverRef.upgrade(req, { data: { channel: "flow" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/classifier-hits") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchClassifierHitsAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.source_ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } - - if (req.method === "GET" && url.pathname === "/ws/classifier-hits") { - if (serverRef.upgrade(req, { data: { channel: "classifier-hits" } })) { - return new Response(null, { status: 101 }); + if (req.method === "GET" && url.pathname === "/replay/alerts") { + const { afterTs, afterSeq, limit } = parseReplayParams(url); + const data = await fetchAlertsAfter(clickhouse, afterTs, afterSeq, limit); + const last = data.at(-1); + const next = last ? { ts: last.source_ts, seq: last.seq } : null; + return jsonResponse({ data, next }); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } + if (req.method === "GET" && url.pathname === "/ws/options") { + if (serverRef.upgrade(req, { data: { channel: "options" } })) { + return new Response(null, { status: 101 }); + } - if (req.method === "GET" && url.pathname === "/ws/smart-money") { - if (serverRef.upgrade(req, { data: { channel: "smart-money" } })) { - return new Response(null, { status: 101 }); + return jsonResponse({ error: "websocket upgrade failed" }, 400); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } + if (req.method === "GET" && url.pathname === "/ws/options-nbbo") { + if (serverRef.upgrade(req, { data: { channel: "options-nbbo" } })) { + return new Response(null, { status: 101 }); + } - if (req.method === "GET" && url.pathname === "/ws/alerts") { - if (serverRef.upgrade(req, { data: { channel: "alerts" } })) { - return new Response(null, { status: 101 }); + return jsonResponse({ error: "websocket upgrade failed" }, 400); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } + if (req.method === "GET" && url.pathname === "/ws/equities") { + if (serverRef.upgrade(req, { data: { channel: "equities" } })) { + return new Response(null, { status: 101 }); + } - if (req.method === "GET" && url.pathname === "/ws/live") { - if (serverRef.upgrade(req, { data: { channel: "live" } })) { - return new Response(null, { status: 101 }); + return jsonResponse({ error: "websocket upgrade failed" }, 400); } - return jsonResponse({ error: "websocket upgrade failed" }, 400); - } + if (req.method === "GET" && url.pathname === "/ws/equity-candles") { + if (serverRef.upgrade(req, { data: { channel: "equity-candles" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/equity-quotes") { + if (serverRef.upgrade(req, { data: { channel: "equity-quotes" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/equity-joins") { + if (serverRef.upgrade(req, { data: { channel: "equity-joins" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/inferred-dark") { + if (serverRef.upgrade(req, { data: { channel: "inferred-dark" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/flow") { + if (serverRef.upgrade(req, { data: { channel: "flow" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/classifier-hits") { + if (serverRef.upgrade(req, { data: { channel: "classifier-hits" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/smart-money") { + if (serverRef.upgrade(req, { data: { channel: "smart-money" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/alerts") { + if (serverRef.upgrade(req, { data: { channel: "alerts" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } + + if (req.method === "GET" && url.pathname === "/ws/live") { + if (serverRef.upgrade(req, { data: { channel: "live" } })) { + return new Response(null, { status: 101 }); + } + + return jsonResponse({ error: "websocket upgrade failed" }, 400); + } return jsonResponse({ error: "not found" }, 404); }; diff --git a/services/api/tests/cors.test.ts b/services/api/tests/cors.test.ts index e10d64d..f730e88 100644 --- a/services/api/tests/cors.test.ts +++ b/services/api/tests/cors.test.ts @@ -74,8 +74,6 @@ describe("api cors helpers", () => { 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" - ); + expect(response.headers.get("access-control-allow-headers")).toBe("content-type,authorization"); }); });