configure hosted api endpoint and refresh local mock wiring #23
15 changed files with 1776 additions and 601 deletions
|
|
@ -1,3 +1,5 @@
|
||||||
|
{"_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-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-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}
|
{"_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 +32,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-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-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-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-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-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}
|
{"_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}
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,14 @@ COMPUTE_DELIVER_POLICY=new
|
||||||
COMPUTE_CONSUMER_RESET=false
|
COMPUTE_CONSUMER_RESET=false
|
||||||
API_DELIVER_POLICY=new
|
API_DELIVER_POLICY=new
|
||||||
API_CONSUMER_RESET=false
|
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
|
NBBO_MAX_AGE_MS=1000
|
||||||
NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000
|
NEXT_PUBLIC_NBBO_MAX_AGE_MS=1000
|
||||||
NEXT_PUBLIC_LIVE_HOT_WINDOW=600
|
NEXT_PUBLIC_LIVE_HOT_WINDOW=600
|
||||||
NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=1200
|
NEXT_PUBLIC_LIVE_HOT_WINDOW_OPTIONS=1200
|
||||||
NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS=1200000
|
NEXT_PUBLIC_PINNED_EVIDENCE_TTL_MS=1200000
|
||||||
NEXT_PUBLIC_PINNED_EVIDENCE_MAX_ITEMS=4000
|
NEXT_PUBLIC_PINNED_EVIDENCE_MAX_ITEMS=4000
|
||||||
|
NEXT_ALLOWED_DEV_ORIGINS=
|
||||||
ROLLING_WINDOW_SIZE=50
|
ROLLING_WINDOW_SIZE=50
|
||||||
ROLLING_TTL_SEC=86400
|
ROLLING_TTL_SEC=86400
|
||||||
CLASSIFIER_SWEEP_MIN_PREMIUM=40000
|
CLASSIFIER_SWEEP_MIN_PREMIUM=40000
|
||||||
|
|
|
||||||
|
|
@ -280,7 +280,7 @@ bun run make:desktop
|
||||||
Desktop-specific environment:
|
Desktop-specific environment:
|
||||||
|
|
||||||
- `ISLANDFLOW_DESKTOP_START_URL` is only used by the Electron shell and is restricted to trusted Islandflow app origins.
|
- `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
|
## Environment Configuration
|
||||||
|
|
||||||
|
|
@ -400,17 +400,19 @@ Default `smart-money` policy rejects lower-information prints and keeps higher-c
|
||||||
| `REST_DEFAULT_LIMIT` | `200` | Default REST record count. |
|
| `REST_DEFAULT_LIMIT` | `200` | Default REST record count. |
|
||||||
| `API_DELIVER_POLICY` | `new` | JetStream consumer start policy used by API live subscribers. |
|
| `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_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_DEFAULT` | `1000` | Optional generic live cache depth default. |
|
||||||
| `LIVE_LIMIT_FLOW` | `500` | Live cache depth for flow packet events unless overridden. |
|
| `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. |
|
| `LIVE_LIMIT_SMART_MONEY` | `300` | Live cache depth for smart-money events unless overridden. |
|
||||||
| `LIVE_LIMIT_OPTIONS` | `1000` | Live cache depth for options channel unless overridden. |
|
| `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_ALERTS` | `300` | Live cache depth for alerts channel unless overridden. |
|
||||||
| `LIVE_LIMIT_NEWS` | `100` | Live cache depth for news 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` | `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_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_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_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
|
### Replay and testing controls
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,5 +25,5 @@ This workspace packages a thin Electron shell around the hosted Islandflow app.
|
||||||
## Development Notes
|
## 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.
|
- `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.
|
- `assets/` currently contains placeholders only; a real `.icns` icon is deferred.
|
||||||
|
|
|
||||||
|
|
@ -542,6 +542,87 @@ const readErrorDetail = async (response: Response): Promise<string> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = <T,>(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<FlowPacket[]> => {
|
||||||
|
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<OptionPrint[]> => {
|
||||||
|
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 WsStatus = "connecting" | "connected" | "disconnected" | "stale";
|
||||||
|
|
||||||
type TapeMode = "live" | "replay";
|
type TapeMode = "live" | "replay";
|
||||||
|
|
@ -4515,7 +4596,7 @@ const CandleChart = ({
|
||||||
url.searchParams.set("underlying_id", ticker);
|
url.searchParams.set("underlying_id", ticker);
|
||||||
url.searchParams.set("start_ts", Math.floor(startTs).toString());
|
url.searchParams.set("start_ts", Math.floor(startTs).toString());
|
||||||
url.searchParams.set("end_ts", Math.floor(endTs).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 });
|
const response = await fetch(url.toString(), { signal: abort.signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -6350,8 +6431,10 @@ const useTerminalState = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const abort = new AbortController();
|
||||||
void fetch(buildApiUrl("/lookup/options-support"), {
|
void fetch(buildApiUrl("/lookup/options-support"), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
signal: abort.signal,
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
trace_ids: uniqueTraceIds,
|
trace_ids: uniqueTraceIds,
|
||||||
|
|
@ -6417,11 +6500,15 @@ const useTerminalState = () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (cancelled || abort.signal.aborted || isAbortLikeError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.warn("Failed to hydrate option row support", error);
|
console.warn("Failed to hydrate option row support", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
abort.abort();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
mode,
|
mode,
|
||||||
|
|
@ -6526,35 +6613,26 @@ const useTerminalState = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
const missingPacketIds = selectedSmartMoneyEvent.packet_ids.filter(
|
const missingPacketIds = selectedSmartMoneyEvent.packet_ids.filter(
|
||||||
(id) => !resolvedFlowPacketMap.has(id)
|
(id) => !resolvedFlowPacketMap.has(id)
|
||||||
);
|
);
|
||||||
if (missingPacketIds.length > 0) {
|
if (missingPacketIds.length > 0) {
|
||||||
incrementRetentionMetric("pinnedFetchMisses", missingPacketIds.length);
|
incrementRetentionMetric("pinnedFetchMisses", missingPacketIds.length);
|
||||||
void Promise.all(
|
void fetchFlowPacketsByIds(missingPacketIds, abort.signal)
|
||||||
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;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then((packets) => {
|
.then((packets) => {
|
||||||
const next = new Map<string, FlowPacket>();
|
const next = new Map<string, FlowPacket>();
|
||||||
for (const packet of packets) {
|
for (const packet of packets) {
|
||||||
if (packet) {
|
|
||||||
next.set(packet.id, packet);
|
next.set(packet.id, packet);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, next, Date.now()));
|
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, next, Date.now()));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (abort.signal.aborted || isAbortLikeError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
incrementRetentionMetric("pinnedFetchFailures", 1);
|
||||||
console.warn("Failed to fetch smart-money flow packets", error);
|
console.warn("Failed to fetch smart-money flow packets", error);
|
||||||
});
|
});
|
||||||
|
|
@ -6563,27 +6641,12 @@ const useTerminalState = () => {
|
||||||
const missingPrintIds = selectedSmartMoneyEvent.member_print_ids.filter(
|
const missingPrintIds = selectedSmartMoneyEvent.member_print_ids.filter(
|
||||||
(id) => !resolvedOptionPrintMap.has(id)
|
(id) => !resolvedOptionPrintMap.has(id)
|
||||||
);
|
);
|
||||||
if (missingPrintIds.length === 0) {
|
if (missingPrintIds.length > 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length);
|
incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length);
|
||||||
const url = new URL(buildApiUrl("/option-prints/by-trace"));
|
void fetchOptionPrintsByTraceIds(missingPrintIds, abort.signal)
|
||||||
for (const traceId of missingPrintIds) {
|
.then((prints) => {
|
||||||
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<string, OptionPrint>();
|
const next = new Map<string, OptionPrint>();
|
||||||
for (const item of payload.data ?? []) {
|
for (const item of prints) {
|
||||||
if (!item || !item.trace_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.set(item.trace_id, item);
|
next.set(item.trace_id, item);
|
||||||
}
|
}
|
||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
|
|
@ -6591,9 +6654,15 @@ const useTerminalState = () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (abort.signal.aborted || isAbortLikeError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
incrementRetentionMetric("pinnedFetchFailures", 1);
|
||||||
console.warn("Failed to fetch smart-money option prints", error);
|
console.warn("Failed to fetch smart-money option prints", error);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => abort.abort();
|
||||||
}, [mode, resolvedFlowPacketMap, resolvedOptionPrintMap, selectedSmartMoneyEvent]);
|
}, [mode, resolvedFlowPacketMap, resolvedOptionPrintMap, selectedSmartMoneyEvent]);
|
||||||
|
|
||||||
const inferAlertUnderlying = useCallback(
|
const inferAlertUnderlying = useCallback(
|
||||||
|
|
@ -6902,6 +6971,7 @@ const useTerminalState = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abort = new AbortController();
|
||||||
const visiblePacketIds = visibleAlerts.flatMap((alert) => getAlertFlowPacketRefs(alert));
|
const visiblePacketIds = visibleAlerts.flatMap((alert) => getAlertFlowPacketRefs(alert));
|
||||||
const missingPacketIds = Array.from(new Set(visiblePacketIds)).filter(
|
const missingPacketIds = Array.from(new Set(visiblePacketIds)).filter(
|
||||||
(id) => !resolvedFlowPacketMap.has(id)
|
(id) => !resolvedFlowPacketMap.has(id)
|
||||||
|
|
@ -6909,31 +6979,21 @@ const useTerminalState = () => {
|
||||||
|
|
||||||
if (missingPacketIds.length > 0) {
|
if (missingPacketIds.length > 0) {
|
||||||
incrementRetentionMetric("pinnedFetchMisses", missingPacketIds.length);
|
incrementRetentionMetric("pinnedFetchMisses", missingPacketIds.length);
|
||||||
void Promise.all(
|
void fetchFlowPacketsByIds(missingPacketIds, abort.signal)
|
||||||
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;
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.then((packets) => {
|
.then((packets) => {
|
||||||
const next = new Map<string, FlowPacket>();
|
const next = new Map<string, FlowPacket>();
|
||||||
for (const packet of packets) {
|
for (const packet of packets) {
|
||||||
if (packet) {
|
|
||||||
next.set(packet.id, packet);
|
next.set(packet.id, packet);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, next, now));
|
setPinnedFlowPacketMap((prev) => upsertPinnedEntries(prev, next, now));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (abort.signal.aborted || isAbortLikeError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
incrementRetentionMetric("pinnedFetchFailures", 1);
|
||||||
console.warn("Failed to prefetch visible alert packets", error);
|
console.warn("Failed to prefetch visible alert packets", error);
|
||||||
});
|
});
|
||||||
|
|
@ -6942,28 +7002,12 @@ const useTerminalState = () => {
|
||||||
const missingPrintIds = Array.from(visibleAlertEvidenceRefs).filter(
|
const missingPrintIds = Array.from(visibleAlertEvidenceRefs).filter(
|
||||||
(id) => !resolvedFlowPacketMap.has(id) && !resolvedOptionPrintMap.has(id)
|
(id) => !resolvedFlowPacketMap.has(id) && !resolvedOptionPrintMap.has(id)
|
||||||
);
|
);
|
||||||
if (missingPrintIds.length === 0) {
|
if (missingPrintIds.length > 0) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length);
|
incrementRetentionMetric("pinnedFetchMisses", missingPrintIds.length);
|
||||||
const url = new URL(buildApiUrl("/option-prints/by-trace"));
|
void fetchOptionPrintsByTraceIds(missingPrintIds, abort.signal)
|
||||||
for (const traceId of missingPrintIds) {
|
.then((prints) => {
|
||||||
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<string, OptionPrint>();
|
const next = new Map<string, OptionPrint>();
|
||||||
for (const item of payload.data ?? []) {
|
for (const item of prints) {
|
||||||
if (!item || !item.trace_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.set(item.trace_id, item);
|
next.set(item.trace_id, item);
|
||||||
}
|
}
|
||||||
if (next.size > 0) {
|
if (next.size > 0) {
|
||||||
|
|
@ -6972,9 +7016,15 @@ const useTerminalState = () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
if (abort.signal.aborted || isAbortLikeError(error)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
incrementRetentionMetric("pinnedFetchFailures", 1);
|
incrementRetentionMetric("pinnedFetchFailures", 1);
|
||||||
console.warn("Failed to prefetch visible alert evidence", error);
|
console.warn("Failed to prefetch visible alert evidence", error);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => abort.abort();
|
||||||
}, [
|
}, [
|
||||||
mode,
|
mode,
|
||||||
visibleAlerts,
|
visibleAlerts,
|
||||||
|
|
@ -7866,10 +7916,11 @@ const OptionsPane = memo(({ state, limit, title = "Options", className }: Option
|
||||||
);
|
);
|
||||||
|
|
||||||
return decor ? (
|
return decor ? (
|
||||||
<button
|
<div
|
||||||
type="button"
|
|
||||||
{...commonProps}
|
{...commonProps}
|
||||||
key={key}
|
key={key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
decor.smartMoney
|
decor.smartMoney
|
||||||
? state.openFromSmartMoneyEvent(decor.smartMoney)
|
? state.openFromSmartMoneyEvent(decor.smartMoney)
|
||||||
|
|
@ -7889,7 +7940,7 @@ const OptionsPane = memo(({ state, limit, title = "Options", className }: Option
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{cells}
|
{cells}
|
||||||
</button>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div {...commonProps} key={key}>
|
<div {...commonProps} key={key}>
|
||||||
{cells}
|
{cells}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
|
import { networkInterfaces } from "node:os";
|
||||||
import { PHASE_DEVELOPMENT_SERVER } from "next/constants.js";
|
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
|
* Keep dev and production build artifacts separate to avoid chunk/runtime
|
||||||
* mismatches when `next dev` and `next build` are run in overlapping sessions.
|
* 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;
|
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
allowedDevOrigins: isDev ? allowedDevOrigins() : undefined,
|
||||||
distDir: isDev ? ".next-dev" : ".next"
|
distDir: isDev ? ".next-dev" : ".next"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
|
|
||||||
|
const DEFAULT_REMOTE_API_URL = "https://api.flow.deltaisland.io";
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const port = 3000;
|
const port = 3000;
|
||||||
const distDir = ".next-dev";
|
const distDir = ".next-dev";
|
||||||
console.log(`[web] starting Next.js dev server on port ${port}`);
|
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 path = Bun.env.PATH ?? "";
|
||||||
const cwd = `${import.meta.dir}/..`;
|
const cwd = `${import.meta.dir}/..`;
|
||||||
|
|
@ -21,6 +28,7 @@ const run = async () => {
|
||||||
env: {
|
env: {
|
||||||
...Bun.env,
|
...Bun.env,
|
||||||
PATH: `${cwd}/node_modules/.bin:${path}`,
|
PATH: `${cwd}/node_modules/.bin:${path}`,
|
||||||
|
NEXT_PUBLIC_API_URL: Bun.env.NEXT_PUBLIC_API_URL ?? DEFAULT_REMOTE_API_URL,
|
||||||
PORT: String(port)
|
PORT: String(port)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@
|
||||||
"@electron/node-gyp": "^10.2.0-electron.2",
|
"@electron/node-gyp": "^10.2.0-electron.2",
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tar": "^7.5.15",
|
"tar": "^7.5.15",
|
||||||
"tmp": "^0.2.5",
|
"tmp": "^0.2.6",
|
||||||
},
|
},
|
||||||
"packages": {
|
"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=="],
|
"@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=="],
|
"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=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"postcss": "^8.5.15",
|
"postcss": "^8.5.15",
|
||||||
"tar": "^7.5.15",
|
"tar": "^7.5.15",
|
||||||
"tmp": "^0.2.5",
|
"tmp": "^0.2.6",
|
||||||
"@electron/node-gyp": "^10.2.0-electron.2"
|
"@electron/node-gyp": "^10.2.0-electron.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
493
docs/turns/2026-06-13-0338-configure-hosted-api-endpoint.html
Normal file
493
docs/turns/2026-06-13-0338-configure-hosted-api-endpoint.html
Normal file
File diff suppressed because one or more lines are too long
380
docs/turns/2026-06-13-1130-fix-local-backend-connectivity.html
Normal file
380
docs/turns/2026-06-13-1130-fix-local-backend-connectivity.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -3,6 +3,7 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
const DESKTOP_REMOTE_URL = "https://flow.deltaisland.io";
|
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 DESKTOP_LOCAL_URL = "http://127.0.0.1:3000";
|
||||||
const WEB_PORT = 3000;
|
const WEB_PORT = 3000;
|
||||||
|
|
||||||
|
|
@ -268,7 +269,7 @@ if (!remoteMode) {
|
||||||
cmd: ["bun", "run", "dev"],
|
cmd: ["bun", "run", "dev"],
|
||||||
cwd: "apps/web",
|
cwd: "apps/web",
|
||||||
env: {
|
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();
|
await waitForWebPort();
|
||||||
|
|
|
||||||
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,
|
recordSyntheticProfileHit,
|
||||||
resolveSyntheticBackendMode
|
resolveSyntheticBackendMode
|
||||||
} from "./synthetic-control";
|
} from "./synthetic-control";
|
||||||
|
import {
|
||||||
|
DEFAULT_API_CORS_ORIGINS,
|
||||||
|
createCorsPreflightResponse,
|
||||||
|
parseCorsAllowedOrigins,
|
||||||
|
withCorsHeaders
|
||||||
|
} from "./cors";
|
||||||
|
|
||||||
const service = "api";
|
const service = "api";
|
||||||
const logger = createLogger({ service });
|
const logger = createLogger({ service });
|
||||||
|
|
@ -172,10 +178,12 @@ const envSchema = z.object({
|
||||||
return value;
|
return value;
|
||||||
}, z.boolean())
|
}, z.boolean())
|
||||||
.default(false),
|
.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 env = readEnv(envSchema);
|
||||||
|
const corsAllowedOrigins = parseCorsAllowedOrigins(env.API_CORS_ORIGINS);
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
shuttingDown: false,
|
shuttingDown: false,
|
||||||
|
|
@ -1363,8 +1371,13 @@ const run = async () => {
|
||||||
hostname: env.API_HOST,
|
hostname: env.API_HOST,
|
||||||
port: env.API_PORT,
|
port: env.API_PORT,
|
||||||
fetch: async (req: Request, serverRef: any) => {
|
fetch: async (req: Request, serverRef: any) => {
|
||||||
|
const handleApiRequest = async (): Promise<Response> => {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return createCorsPreflightResponse(req, corsAllowedOrigins);
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/health") {
|
if (req.method === "GET" && url.pathname === "/health") {
|
||||||
return jsonResponse({ status: "ok" });
|
return jsonResponse({ status: "ok" });
|
||||||
}
|
}
|
||||||
|
|
@ -1395,7 +1408,11 @@ const run = async () => {
|
||||||
syntheticControl = await writeSyntheticControlState(syntheticControlKv, payload);
|
syntheticControl = await writeSyntheticControlState(syntheticControlKv, payload);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
control: syntheticControl,
|
control: syntheticControl,
|
||||||
derived: buildSyntheticDerivedStatus(Date.now(), syntheticControl, syntheticProfileHits)
|
derived: buildSyntheticDerivedStatus(
|
||||||
|
Date.now(),
|
||||||
|
syntheticControl,
|
||||||
|
syntheticProfileHits
|
||||||
|
)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
|
|
@ -1596,7 +1613,9 @@ const run = async () => {
|
||||||
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
||||||
const source = parseReplaySource(url) ?? undefined;
|
const source = parseReplaySource(url) ?? undefined;
|
||||||
const data = await fetchOptionNBBOBefore(clickhouse, beforeTs, beforeSeq, limit, source);
|
const data = await fetchOptionNBBOBefore(clickhouse, beforeTs, beforeSeq, limit, source);
|
||||||
return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })));
|
return jsonResponse(
|
||||||
|
buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/history/equities") {
|
if (req.method === "GET" && url.pathname === "/history/equities") {
|
||||||
|
|
@ -1608,13 +1627,17 @@ const run = async () => {
|
||||||
limit,
|
limit,
|
||||||
parseLiveEquityPrintFilters(url)
|
parseLiveEquityPrintFilters(url)
|
||||||
);
|
);
|
||||||
return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })));
|
return jsonResponse(
|
||||||
|
buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/history/equity-quotes") {
|
if (req.method === "GET" && url.pathname === "/history/equity-quotes") {
|
||||||
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
const { beforeTs, beforeSeq, limit } = parseBeforeParams(url);
|
||||||
const data = await fetchEquityQuotesBefore(clickhouse, beforeTs, beforeSeq, limit);
|
const data = await fetchEquityQuotesBefore(clickhouse, beforeTs, beforeSeq, limit);
|
||||||
return jsonResponse(buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq })));
|
return jsonResponse(
|
||||||
|
buildHistoryResponse(data, (item) => ({ ts: item.ts, seq: item.seq }))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "GET" && url.pathname === "/history/equity-joins") {
|
if (req.method === "GET" && url.pathname === "/history/equity-joins") {
|
||||||
|
|
@ -1952,6 +1975,10 @@ const run = async () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return jsonResponse({ error: "not found" }, 404);
|
return jsonResponse({ error: "not found" }, 404);
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await handleApiRequest();
|
||||||
|
return withCorsHeaders(req, response, corsAllowedOrigins);
|
||||||
},
|
},
|
||||||
websocket: {
|
websocket: {
|
||||||
open: (socket: any) => {
|
open: (socket: any) => {
|
||||||
|
|
|
||||||
79
services/api/tests/cors.test.ts
Normal file
79
services/api/tests/cors.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
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