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

LayerImplementationNote
Edgenginx TLS vhost for chat.loca.zone behind Authelia forward-auth (one_factor, since 2026-10-05, D-323d), proxy to loopbackOwned with the app deployment; see private page (sign-in required)
Servernode build/index.js from @sveltejs/adapter-node, chat.serviceHardened system-scope unit, runs as the loca user
StorageDATA_DIR/rooms/<slug>/ contains events, metadata, participant/read state and uploadsNo database migration; back up the full data directory. Never replace live data with a stale snapshot
ClientSvelte 5 runes ($state, $derived), keyed block listComponent-local state; display name kept in localStorage

Routes

RouteMethodPurpose
/GET, form action POSTRoom list and create-room form; title Chat · loca.zone
/r/<room>GETChat page with server-rendered history; title <Room title> · Chat
/api/statusGET{ assistant: { configured, model }, rooms }
/api/rooms/<room>GETSame room/history snapshot as the messages GET route
/api/rooms/<room>/messagesGETRoom history from the JSONL log
/api/rooms/<room>/messagesPOSTAuthor, content, optional parent ID, attachment IDs and retry requestId; participant capability comes from an HTTP-only cookie
/api/rooms/<room>/eventsGET (SSE)Live changes; replay uses a durable event sequence in ?after= or Last-Event-ID, not a timestamp
/api/roomsGET, POSTList and create rooms
/api/rooms/<room>/messages/<id>PATCH, DELETEEdit or delete a message owned by the current participant
/api/rooms/<room>/messages/<id>/reactionsPOSTToggle the participant’s reaction
/api/rooms/<room>/participantsGETRoom participant directory
/api/rooms/<room>/presenceGET, POSTTransient presence and typing
/api/rooms/<room>/readPOSTAdvance the participant’s read cursor
/api/rooms/unreadGETPer-room unread and mention counts
/api/rooms/<room>/uploadsPOSTValidate and stage an upload
/api/rooms/<room>/uploads/<id>DELETEDiscard an owned pending upload
/api/rooms/<room>/files/<id>GET, HEADServe 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=false and model=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}
PieceWhat it guaranteesSource
blocks.ts splitBlocksClosed 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 CommonMarksrc/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 renderBlockmarked (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 codeBare dompurify throws during SSR; the isomorphic build is required. User HTML cannot borrow the app’s styles (test/render.test.ts)
SvelteKit CSPscript-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

CapabilityDeployedEvidence
Multi-user rooms, history, SSE fan-outyeslive routes above
Streaming-safe Markdown blocks, sanitizer, CSPyescarried over from the research proof
OpenAI-compatible assistantcode present, not configured/api/status
Mentions, slash commands, emoji, reactions, own-message edits/deletion, threadsyesFresh 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 notesyesFresh 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)noevaluated only
Speech transcription, synthesized speech, VAD (speaches, kokoro-js, vad-web)noevaluated only; distinct from implemented MediaRecorder voice-note attachments
Math (katex), rich highlighting (shiki), diagrams (mermaid)noevaluated only
Service-worker/PWA offline availabilitynodistinct 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, moderationnothe 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.

FeatureBehaviour
LayoutSidebar, 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 actionsToolbar on hover or keyboard focus; one 44 px More button per row on touch, opening an action sheet; long-press as a shortcut
DeleteUndo for 6 s instead of a confirm dialog; the server log keeps the original text
KeysCtrl/⌘K switcher, Alt+↑/↓ rooms, Alt+Shift+↑/↓ unread rooms, one Esc stack, ? for shortcuts
NotificationsAll, Mentions or Mute per room, saved in this browser
PresenceOnline dot, away crescent, offline ring
DensityGlanceable, comfortable, compact or x-ray, per surface (messages and threads); only spacing changes
MotionNamed view transitions for the side panel and room switches; none with reduced motion
Right-to-leftLogical properties throughout; the layout mirrors under dir=rtl
InstallSelf-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.

FeatureBehaviour
Message actionsCompact 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 pinsAn inline reply quotes its parent and jumps to it; the header pin list opens pinned messages
TimelineWelcome 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
RoomsWorkspace menu, categories that fold (kept per browser), a Starred group on top (per browser)
SearchHeader field (a button on phones) searches every room; from:, in:, has:link/file/image; results in the side panel, ?search= survives reload
Reaction tooltipsNames 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.