The local API
A loopback-only server, a per-launch token, and one event stream.
The web UI and the backend are one app on one machine: no remote server, no CORS, and no way to reach the API from another device.
Loopback only, token always
Kestrel binds to 127.0.0.1 exclusively: an ephemeral port in production, a fixed one (47210) in dev. Every API request except the health check must carry a bearer token minted fresh at each launch; a Host-header guard additionally rejects DNS-rebinding tricks.
How the SPA gets the token differs by mode:
- Production: the Host serves the built SPA itself and templates the token into
index.htmlas it goes out. - Dev: the Host writes
{ port, token }tomnemo-web/.dev/api.json, and the Vite proxy injects the Authorization header on every proxied request.
The content security policy
SpaHosting.LoadTemplatedIndex (Mnemo.Host/Web/SpaHosting.cs) mints one nonce per launch, not per response. The policy sent with each response:
default-src 'self'
script-src 'self' 'nonce-<value>'
style-src 'self' 'unsafe-inline'
img-src 'self' data: blob:
font-src 'self' data:
media-src 'self' blob:
worker-src 'self'
connect-src 'self'
object-src 'none'
base-uri 'self'
form-action 'none'
frame-ancestors 'none'
A script without the current nonce is refused, which stops chat-rendered model output from executing. connect-src 'self' blocks fetch and XHR to any origin but the Host's own. Styles keep unsafe-inline, since editor components inject styles at runtime.
The event stream
Server-to-client push is one SSE channel, GET /api/events. The Host publishes typed events (toasts, shutdown) into a bounded per-subscriber channel that drops its oldest entries rather than blocking the publisher, and never replays to a client that connects later, so nothing may treat the stream as a source of truth. On the SPA, an event whose payload alone decides the effect gets a case in one dispatch switch; an event whose meaning depends on what a page currently has open gets no case and reaches that page through a subscriber instead. Chat streaming is separate: each assistant turn streams its own SSE response with deltas, tool calls, and status events.
Shutdown is a handshake
Closing the window does not kill the process mid-save: the first close request is held, a shutdown event goes out over the stream, and the SPA gets a short grace period to flush pending saves and reply that it is ready. Pressing close a second time overrides the wait.
Adding an endpoint
Endpoints are minimal-API handlers mapped feature by feature over services resolved from DI, living with their feature (for example Mnemo.Host/Flashcards). Copy any existing feature folder: contracts in, service call, DTO out, nothing clever in the route handler.
Related
- Building from source for the dev-mode handshake in practice.
- The layers for where an endpoint's contract and service live.