Repository navigation
feat: CORS support for inbound A2A endpoint (origin allowlist) + fix SSE header ordering - #10
Merged
Merged
Conversation
…SSE headers Browser-based A2A clients (Flutter Web, plain fetch) could not call the inbound endpoint: no CORS headers were sent and OPTIONS preflight fell into the 405 branch, so the browser refused the real request. curl/native clients worked. Design: an explicit **origin allowlist**, not a wildcard default. - New `corsOrigins?: string[]` server config (persisted). EMPTY/ABSENT = NO CORS (safe default) — a cross-origin browser page is blocked, exactly as before this feature. Rationale: server-mode auth (authToken) is optional and off by default, so a wildcard default would let any site the user visits drive their local agent. Each request's Origin is matched against the list; a hit echoes that exact origin back with `Vary: Origin`, a miss sends no CORS headers. The literal `["*"]` opts into a wildcard (logged as a warning) for fully-trusted local setups. Credentials are never allowed (auth is a Bearer header, not a cookie). - Answer OPTIONS before routing; stamp resolved CORS on every response (AgentCard, JSON-RPC, SSE, 405, 401). Allowlist is read per-request from a mutable closure, so UI changes take effect live without a route rebuild. - New `set-server-cors` control action + `setServerCors` hook (persists to a2a.json), and a settings-panel editor: add/remove allowed origins as chips, with a warning when `*` is set. serverStatus reports the current allowlist. - Fix a latent SSE bug: the success path called res.write() (via onEvent) before res.writeHead(), so Node auto-flushed default headers and the stream never got content-type: text/event-stream or CORS. Auth is now checked up front (new public A2AServer.checkAuth), then stream headers are written before the first frame. Closes #8.
ryubyte
force-pushed
the
fix/inbound-cors
branch
from
September 28, 2026 04:24
ecc19a4 to
0754ad9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #8.
Problem
Browser-based A2A clients (Flutter Web, plain
fetch()) can't call the inbound endpoint: no CORS headers are sent, andOPTIONSpreflight falls into the405branch — so the browser refuses the real request even thoughcurl/native clients work. There's also a latent SSE bug (below).Approach: explicit origin allowlist, not a wildcard default
The original report (#8) suggested defaulting
corsOriginto'*', arguing it's safe because inbound is gated by the BearerauthToken. That reasoning only holds when a token is set — butauthTokenis optional and off by default. A'*'default would mean: on the common no-auth local setup, any website the user visits could drive their local agent cross-origin. Today the missing CORS is what blocks that. So this PR takes the safer shape:corsOrigins?: string[]server config (persisted toa2a.json).Originis matched against the list; a hit echoes that exact origin back withVary: Origin; a miss sends no CORS headers.["*"]opts into a wildcard (logged as a warning) for fully-trusted local setups.*.OPTIONSbefore routing and stamp resolved CORS on every response (AgentCard, JSON-RPC, SSE, 405, 401). The allowlist is read per-request from a mutable closure, so UI/config changes take effect live without a route rebuild.*is set. Newset-server-corscontrol action +setServerCorshook;serverStatusreports the current allowlist.res.write()(via theonEventcallback) beforeres.writeHead(), so Node auto-flushed default headers and the stream never gotcontent-type: text/event-streamor CORS. Auth is now checked up front (new publicA2AServer.checkAuth), then stream headers are written before the first frame.Testing
npm run typecheck+npm test(46) + client-bundle smoke — all green.Verified live on dsh
0.1.7-rc.2(server-mode instance):Access-Control-*on OPTIONS/GET/POST — browser blocks ✅[http://127.0.0.1:8080], matching OriginAccess-Control-Allow-Origin: http://127.0.0.1:8080+Vary: Origin✅evil.com)["*"], arbitrary OriginVary: Origin; warning logged ✅SendStreamingMessage)content-type: text/event-stream+cache-control: no-cache+ CORS, before first frame ✅a2a.json✅Notes
/a2a/api) stays loopback/same-origin only — no CORS there.*to an opt-in allowlist.