Access control
Two controls decide who may reach the assistant, and they do different jobs. Both are in api/src/access.ts.
ALLOWED_ORIGINS | EMBED_KEY | |
|---|---|---|
| What it is | The origins whose pages may call us from a browser | A shared key sent on the model-facing endpoints |
| What it stops | Any other page on the internet embedding the assistant | A stranger who finds the endpoint spending the model budget |
| What it does not stop | Anything that is not a browser (curl does not ask) | Anyone who can read the host page's markup |
| Identifies a user | No | No |
| Protects claim data | No: the caller supplies the claim | No |
| Unset means | No cross-origin embedding at all | Open |
ALLOWED_ORIGINS
A comma-separated list in api/wrangler.jsonc:
"ALLOWED_ORIGINS": "https://cpc.davies-lab.com,http://localhost:*,http://127.0.0.1:*"
- An entry is an exact origin: scheme, host and port, no path, no trailing slash.
- An entry ending
:*matches any numeric port on that host, so a host developer's dev server can move between 3000, 4200 and 5173 without a change on our side. - Same-origin callers (the demo and Claimpanion) never need an entry.
How it is enforced:
- A cross-origin
POSTwith JSON is preflighted. For an allowed origin the Worker answers theOPTIONSwith204andaccess-control-allow-origin,allow-methods: GET, POST, OPTIONS,allow-headers: content-type, x-claimpilot-key, and a one-day max age. - Actual responses to an allowed origin carry
access-control-allow-originandVary: Origin. - A refused origin gets
403with{"error": "Origin … may not embed this assistant. Add it to ALLOWED_ORIGINS."}, before any lookup or model call, and a warning in the Worker log. - The voice WebSocket handshake is checked against the same list. CORS never applied to WebSockets, so the
Originheader is all there is, and refusing at the handshake is the only opportunity.
A browser only ever shows its own CORS message, never our 403 body. That body is for whoever curls the preflight and for the Worker log, because "not in ALLOWED_ORIGINS" and "the endpoint is down" look identical from the page.
EMBED_KEY
Set as a secret. When set, /api/chat, /api/voice and /api/feedback require it:
- in the
x-claimpilot-keyheader, which the element sends from itsapi-keyattribute; - or as
?key=on the voice socket, because a browser cannot put headers on a WebSocket.
The comparison is constant-time. A missing or wrong key is a 401. /api/claims, /api/prompts and /api/health are never gated.
The browser has to send the key, so the host page has to contain it, so anyone who can see the assistant can read it. It does not identify anyone and cannot be scoped to one host. It earns its place as a budget guard, because origin checks mean nothing to a caller that is not a browser.
Who decides who sees a claim
The host. The assistant answers about whatever claim the page hands it. If a user should not see a claim, the host should not put it on their screen. Neither control above changes that, and neither should be described to a host as access control.
Rate limiting
AI Gateway provides rate limiting on model traffic. There is no per-user limit, because there are no users.