Skip to main content

Access control

Two controls decide who may reach the assistant, and they do different jobs. Both are in api/src/access.ts.

ALLOWED_ORIGINSEMBED_KEY
What it isThe origins whose pages may call us from a browserA shared key sent on the model-facing endpoints
What it stopsAny other page on the internet embedding the assistantA stranger who finds the endpoint spending the model budget
What it does not stopAnything that is not a browser (curl does not ask)Anyone who can read the host page's markup
Identifies a userNoNo
Protects claim dataNo: the caller supplies the claimNo
Unset meansNo cross-origin embedding at allOpen

ALLOWED_ORIGINS​

A comma-separated list in api/wrangler.jsonc:

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:

  1. A cross-origin POST with JSON is preflighted. For an allowed origin the Worker answers the OPTIONS with 204 and access-control-allow-origin, allow-methods: GET, POST, OPTIONS, allow-headers: content-type, x-claimpilot-key, and a one-day max age.
  2. Actual responses to an allowed origin carry access-control-allow-origin and Vary: Origin.
  3. A refused origin gets 403 with {"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.
  4. The voice WebSocket handshake is checked against the same list. CORS never applied to WebSockets, so the Origin header 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-key header, which the element sends from its api-key attribute;
  • 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.