CORS — an explicit list, never a wildcard
cwkPippa's CORS is an explicit allowlist, and it is longer than you would guess: local dev ports, the tailnet address of each Mac that runs a client, and the served origin of every sibling app whose browser mints its own PIN session — about thirty entries — plus one pattern for browser-extension and desktop-webview origins. New client? Add its origin, on purpose. The pattern needs the same care as the list, because it is where an allowlist quietly widens: Starlette full-matches it against the origin, so a trailing .* after a host also admits any domain that merely starts with that host. And the shortcut people reach for when CORS errors pile up, allow_origins=['*'], is a security hole even on a private box. CORSMiddleware itself starts closed: with no origins configured it allows none.
Where CORS sits matters as much as what it allows. It is the outermost middleware on purpose: when the PIN check refuses a request, the 401 must still carry CORS headers, or the browser hides the response body behind a CORS error and the client cannot tell 'wrong PIN' from 'server down'.
Middleware — a stack whose order is the design
Every ordinary request passes through seven layers, outermost first: CORS; PIN auth; a user resolver that binds who is asking into request scope; an audit layer that records admin overrides; gzip for /api JSON, built so it never touches an SSE stream; conditional GET, inside gzip so its ETag is taken over the uncompressed body and a phone that already holds a reply gets an empty 304 (added 2026-10-10; mobile track, lesson 8); and innermost of all, private doors, so a path that names a private conversation reaches only the routes listed for it (2026-09-26). One more layer was registered after CORS, so strictly it wraps the rest: the family gateway (2026-09-22), which acts on a single gateway path and hands every other request straight through. The chat-versus-cron mutex is not middleware — routes call it explicitly, per conversation (Heart track, lesson 4). Middleware stays light and generic; anything expensive or specific belongs in a route or a background task.
Errors — fail loud, fail honest
No silent catches. If the adapter raises, the route surfaces the error event over SSE so the frontend can show 'I got stuck — try again.' Never swallow exceptions to make the chat 'look fine.' That's the broken-chat sin from a different angle.
Logs — for the operator, not the record
The server's log is plain timestamped lines, tagged with the vessel or account they concern — easy to grep, not JSON. That is a deliberate division of labor: the durable record of a conversation is not the log at all, it is the JSONL (Truth track). The log exists so an operator can reconstruct a failure; the JSONL exists so nothing Dad saw is ever lost.
Production starts after 'it works on my Mac'. Check that every allowed origin matches a URL that is actually served, and that an SSE error turns into a visible UI state instead of hiding inside an HTTP 200. A config that looks right and a request that actually travels that path are different claims.
Logs have a boundary too. Dumping raw prompts or credentials because it is convenient buys observability and creates a different incident. Keep the shape a diagnosis needs — event kind, duration, provider, outcome — and summarize or drop sensitive payloads. A good log is not a ledger of everything; it is honest enough to reconstruct the failure.
And resist gathering policy into middleware. Because it touches every route, it is tempting to put business rules there, and then it becomes a hidden ancestor. Keep middleware to what is truly common to every request, and let domain judgment show up in named services and routes.