The app is a single SvelteKit 2 / Svelte 5 process on adapter-node. Durable room events are append-only JSONL; room metadata, participants, read cursors and attachment metadata live alongside them. HTTP routes provide history and mutations; SSE carries live changes and sequence-based replay.
Runtime layers
| Layer | Implementation | Note |
|---|---|---|
| Edge | nginx TLS vhost for chat.loca.zone behind Authelia forward-auth (one_factor, since 2026-10-05, D-323d), proxy to loopback | Owned with the app deployment; see private page (sign-in required) |
| Server | node build/index.js from @sveltejs/adapter-node, chat.service | Hardened system-scope unit, runs as the loca user |
| Storage | DATA_DIR/rooms/<slug>/ contains events, metadata, participant/read state and uploads | No database migration; back up the full data directory. Never replace live data with a stale snapshot |
| Client | Svelte 5 runes ($state, $derived), keyed block list | Component-local state; display name kept in localStorage |
Routes
| Route | Method | Purpose |
|---|---|---|
/ | GET, form action POST | Room list and create-room form; title Chat · loca.zone |
/r/<room> | GET | Chat page with server-rendered history; title <Room title> · Chat |
/api/status | GET | { assistant: { configured, model }, rooms } |
/api/rooms/<room> | GET | Same room/history snapshot as the messages GET route |
/api/rooms/<room>/messages | GET | Room history from the JSONL log |
/api/rooms/<room>/messages | POST | Author, content, optional parent ID, attachment IDs and retry requestId; participant capability comes from an HTTP-only cookie |
/api/rooms/<room>/events | GET (SSE) | Live changes; replay uses a durable event sequence in ?after= or Last-Event-ID, not a timestamp |
/api/rooms | GET, POST | List and create rooms |
/api/rooms/<room>/messages/<id> | PATCH, DELETE | Edit or delete a message owned by the current participant |
/api/rooms/<room>/messages/<id>/reactions | POST | Toggle the participant’s reaction |
/api/rooms/<room>/participants | GET | Room participant directory |
/api/rooms/<room>/presence | GET, POST | Transient presence and typing |
/api/rooms/<room>/read | POST | Advance the participant’s read cursor |
/api/rooms/unread | GET | Per-room unread and mention counts |
/api/rooms/<room>/uploads | POST | Validate and stage an upload |
/api/rooms/<room>/uploads/<id> | DELETE | Discard an owned pending upload |
/api/rooms/<room>/files/<id> | GET, HEAD | Serve a validated attachment, with download and byte-range support |
Room slugs match ^[a-z0-9][a-z0-9-]{0,31}$. Every room is open to everyone who passes the site sign-in. Opaque participant cookies protect message ownership; display names are not authentication.
Backend boundary
- Rooms are real and multi-user: every browser on the same room sees appended messages through SSE. The site sits behind the loca Authelia sign-in, but the app has no accounts and no identity guarantee of its own: it does not read who signed in, and display names are self-declared.
- The assistant is optional, never mocked. It is driven by an OpenAI-compatible upstream selected through environment variables (see Assistant environment). This deployment currently reports
assistant.configured=falseandmodel=null; the UI exposes the unconfigured state instead of fabricating a reply. That observation does not establish whether unrelated host providers exist. Room-message SSE and real upstream assistant-token streaming are separate verification claims. - No third-party services are called unless the upstream is configured.
Durable message retries
Composer retries reuse the same requestId. Within a room, the store deduplicates that key for the same participant against durable message records, including after log replay; another participant does not share the key’s identity. A duplicate returns the existing message rather than appending another event. Optional assistant work is scheduled only for a newly created message, not for the duplicate retry.
The native D-181 voice retry receipt retains the same attachment and request ID after a deliberately lost successful message response, with exactly one durable voice message. Interrupted upload recovery is a separate observation: the captured audio remains available for retry. These claims do not imply that every network failure is a server-side rejection or that an optional model provider was invoked.
Render pipeline
message text → splitBlocks(src, streaming) → renderBlock() per block → keyed {#each} → sanitized {@html}| Piece | What it guarantees | Source |
|---|---|---|
blocks.ts splitBlocks | Closed blocks get content-hash ids; the growing tail uses tail / tail-code. While streaming, the last line counts as unfinished, so neither a fence closer nor an indentation-only line there ends a block. Fence closing requires a run of the opener’s own marker character at least as long as the opener; indentation and info-string rules are not full CommonMark | src/lib/blocks.ts; test/blocks.test.ts replays its fixtures one character at a time, and the dated seven-case research proof found zero closed-id churn for its fixtures, not every possible Markdown input |
render.ts renderBlock | marked (GFM, breaks: true so single newlines become line breaks, a chat-specific deviation from the proof) then isomorphic-dompurify with a forbidden-tag list, no data attributes and no class attributes; mention chips are app markup inserted after sanitizing, only into text outside code | Bare dompurify throws during SSR; the isomorphic build is required. User HTML cannot borrow the app’s styles (test/render.test.ts) |
| SvelteKit CSP | script-src 'self' with Kit nonces, img-src 'self' data: blob: | Sanitizing alone does not stop image-load exfiltration; the CSP does |
Stable keys are not render caching: a block still re-renders when its own content changes. The 124.6× figure in Research counts block renders on a 590-character document, not wall-clock time.
Wired versus evaluated
| Capability | Deployed | Evidence |
|---|---|---|
| Multi-user rooms, history, SSE fan-out | yes | live routes above |
| Streaming-safe Markdown blocks, sanitizer, CSP | yes | carried over from the research proof |
| OpenAI-compatible assistant | code present, not configured | /api/status |
| Mentions, slash commands, emoji, reactions, own-message edits/deletion, threads | yes | Fresh D-181 two-context browser receipts cover these journeys; D-191 accepts the inherited headless bounded-layout proof for the mobile-keyboard criterion, with device-only proof pending and nonblocking |
| Files and recorded voice notes | yes | Fresh native picker receipts preserve 100,000-, 5,000,000- and 5,242,880-byte attachments; voice recording, cleanup, retry and playback are exercised. UI oversized-file preflight is distinct from supplemental public-HTTPS 413 probes. Transport limits remain 16M and the application file limit is 10 MiB |
Virtualized message list (virtua) | no | evaluated only |
Speech transcription, synthesized speech, VAD (speaches, kokoro-js, vad-web) | no | evaluated only; distinct from implemented MediaRecorder voice-note attachments |
Math (katex), rich highlighting (shiki), diagrams (mermaid) | no | evaluated only |
| Service-worker/PWA offline availability | no | distinct from implemented local draft persistence and sequence-based SSE replay after reconnect |
Edge sign-in (Authelia one_factor) | yes, since 2026-10-05 (D-323d) | an anonymous request gets 302 to auth.loca.zone |
| App accounts, roles, moderation | no | the app does not map the signed-in user; treat in-app identity as unverified |
Runtime dependencies are limited to marked and isomorphic-dompurify; the rest of the research stack stays a recommendation in Research.
D-181 verification boundary
The 2026-09-27 continuation preserves the original D-056/D-064 requirements. Historical D-064 browser results and D-071 upload results are not new acceptance. The earlier nginx 1m blocker is resolved; it must not be cited as a current failure. Deployed repairs and fresh browser receipts cover duplicate-name-safe mentions, retries, reactor identities, presence, file/voice journeys, keyboard controls, both themes, narrow layouts and true native 400% zoom.
Verification limits remain explicit. Actual ordinary and thread notification popups were rendered in an isolated desktop, and genuine desktop body clicks focused the notified message or thread and its composer. This used a fresh regular Chrome profile, a native CDP permission grant plus the visible app opt-in, and Chrome’s browser-owned MessageCenter fallback selected with --disable-features=NativeNotifications. It is not proof of a browser permission-prompt interaction or host-default operating-system delivery.
Historical D-181 assessment, retained without reclassifying its original receipt:
The native visual-only mobile-keyboard obligation remains blocked by the available proof runtime. Reducing the layout viewport was exercised, but that is not Chrome’s default on-screen-keyboard behavior. The permitted Chrome 142 CDP interface does not expose an independent visual-viewport keyboard inset; no mocked viewport or unsupported geometry probe is presented as a substitute. This missing proof does not establish a product defect.
Voice capture used a deterministic audio device rather than a physical microphone. The optional assistant remains unconfigured and was not changed; verified human-room SSE is distinct from unobserved upstream model-token streaming. Source assertions, historical receipts and green checks are not substituted for unobserved behavior.
D-191 headless acceptance — 2026-09-27
The revised user criterion accepts the preserved headless check for D056:1.4.2.7. At 2026-09-27T08:35:47.758Z, Chrome 142 on build-20260927T082956Z kept the focused composer between 283px and 363px inside a 420px visual viewport. Physical-device keyboard and independent visual-only OSK resize proof remain pending and nonblocking under this criterion.
This is an acceptance reclassification, not a new keyboard test on the later deployed build-20260927T104015Z. The dated D-181 blocked receipts and their narrower proof limits remain intact. Operations (sign-in required) records the inherited and acceptance receipts separately.
Single sign-on: no native method (gap)
Assessed 2026-09-27 (D-190 SSO sync); recorded 2026-09-29.
Chat has no supported single sign-on, and none is configured. This is a deliberate part of its anonymous design, not a missing configuration step:
- Each participant receives a random, HttpOnly capability cookie that protects ownership of their own messages. It is not an account, and display names are self-declared.
- The app has no native accounts, no OIDC client support and no code that reads a trusted identity header. The running build behaves the same way as the source.
- An edge sign-in or an injected header therefore cannot create a native, per-user identity in Chat. A display name must never be treated as a login.
The D-190 assessment classified Chat as BLOCKED_UNSUPPORTED_NATIVE_METHOD. No OIDC client was registered, no custom identity or session bridge is permitted, none was built, and the Chat app and its configuration were not changed. Chat is therefore a recorded gap in the host-wide SSO rollout. It is not an exclusion by design.
Closing the gap requires svelte-chat itself to gain native account support, such as a built-in OIDC login that maps to real user identities. Until that exists, every room is open to every signed-in visitor and in-app identity stays unverified, as the wired-versus-evaluated table states.
Update 2026-10-05 (D-323d): the whole app now sits behind the loca Authelia sign-in at the edge (one_factor, like the other loca sites), so only signed-in people reach it. This closes anonymous access, not the native gap above: the app keeps its per-browser cookie identity and self-declared names and does not read Remote-User. Mapping the signed-in user to one stable in-app identity is phase P3 of the chat vision.
Room screen (P1, 2026-10-05)
The room page is built from components in src/lib/components/room/: sidebar, header, message list and rows, composer, side panel, action sheet, quick switcher, lightbox, and the shortcuts and density menus. Pure helpers in src/lib/client/ are unit-tested: the Esc layer stack, switcher matching, undo queue, reaction labels, notification levels, deep links, presence, density and view transitions.
| Feature | Behaviour |
|---|---|
| Layout | Sidebar, conversation and side panel from 1024 px; one column below, with the thread as a full-screen sheet |
| Threads | ?thread=<id> and ?panel=people deep links survive a reload |
| Message actions | Toolbar on hover or keyboard focus; one 44 px More button per row on touch, opening an action sheet; long-press as a shortcut |
| Delete | Undo for 6 s instead of a confirm dialog; the server log keeps the original text |
| Keys | Ctrl/⌘K switcher, Alt+↑/↓ rooms, Alt+Shift+↑/↓ unread rooms, one Esc stack, ? for shortcuts |
| Notifications | All, Mentions or Mute per room, saved in this browser |
| Presence | Online dot, away crescent, offline ring |
| Density | Glanceable, comfortable, compact or x-ray, per surface (messages and threads); only spacing changes |
| Motion | Named view transitions for the side panel and room switches; none with reduced motion |
| Right-to-left | Logical properties throughout; the layout mirrors under dir=rtl |
| Install | Self-hosted web manifest and icons; no service worker |
A mention counts only outside code. A <@id> inside inline code or a fenced block is shown literally and notifies no one.
Room screen P2 (2026-10-06)
P2 adds Fluxer-inspired behaviour on our own stack (Bits UI for behaviour, daisyUI for look, Lucide icons); no Fluxer code. Every increment shipped alone, green on its deployed bytes.
| Feature | Behaviour |
|---|---|
| Message actions | Compact hover bar with three frequent reactions, a full More menu and a right-click menu; Reply, Forward, Pin, Mark unread and Copy link; Delete last, in red, with undo |
| Replies and pins | An inline reply quotes its parent and jumps to it; the header pin list opens pinned messages |
| Timeline | Welcome block at the top of every room, centred date dividers, “Today at” stamps, the gutter time on one line, large emoji-only messages |
| Composer | ”+” menu (file, voice with Ctrl/Cmd+Alt+V), customisable buttons, Send stays the one primary (44 px), : emoji completion and an emoji picker with recents |
| Rooms | Workspace menu, categories that fold (kept per browser), a Starred group on top (per browser) |
| Search | Header field (a button on phones) searches every room; from:, in:, has:link/file/image; results in the side panel, ?search= survives reload |
| Reaction tooltips | Names on hover at desktop width only; the button’s accessible name always lists the people |
Search keeps each room’s messages in memory until its log changes, so a repeated search does not re-read the log (30k messages: p95 105-198 ms on the lab host at load 27-35). Under heavy CPU contention on that shared host the p95 crossed 300 ms in 5 of 24 runs (D-388b): two searches per run are cold by design (the first reads the logs, the first full walk folds every message), and the third slow one was a warm search with no GC pause and no other request, whose usual 9-18 ms of server work was stretched by the host.
Ctrl/Cmd+K speed (D-388, 2026-10-06): a keystroke re-styles the switcher’s list, not the page. Two things had met since P2a: daisyUI rules of the form :root:has(...) (the modal’s page scroll lock, the theme-controller themes) re-styled <html> on every list change, and the first ch length in the page (the welcome block’s reading measure) made each such restyle re-style every element. The two themes are now plain [data-theme] rules, daisyUI’s unused modal and drawer are excluded, the reading measure is max-w-measure (39em), and the switcher updates its rows in place. Since D-388b the list is no longer rebuilt on the first letter typed or the last one deleted (Bits’ Command.List keys its content on an empty search, so the switcher hands it a never-empty one). With 205 rooms on the loaded lab host (load 27-32), on the deployed bytes: median 7.0-8.3 ms per keystroke (P1 10.0-11.2 ms in the same window, the build before D-388 64-67 ms); the worst per-query median is 17.7-44.9 ms, under the 50 ms gate in 3 of 3 runs.