# monal-rebuild — plan A small, clean XMPP web client in the spirit of Monal, replacing the stock `nioc/xmpp-web` 0.12.0 build on blunix's `chat.0ff.space`. Planned 2026-09-28. ## Why `xmpp-web` works, but: - it looks like Bulma, not like a messenger — blunix patches its CSS with hex-string substitutions and `display: none` on upstream markup (`blunix:localServices/xmpp-web.nix`), which breaks on every upgrade; - it has no "stay logged in" (noted in `pinix:xmpp/web.nix`); - there is no conversation list with previews/unread counts, which is what makes Monal feel like Monal. ## Target and scope **v1 targets blunix, `chat.0ff.space` only.** `plaza.0ff.space` (anonymous guest lobby) stays on xmpp-web until this client is solid. pinix is out of scope for this project (2026-10-07): if `chat.0ff.space` proves itself in daily use, replacing `pinix:xmpp/web.nix`'s xmpp-web happens in a separate project, not here. What blunix already provides (checked 2026-09-28 via `nix eval`): | Item | State | | --- | --- | | Prosody | 13.0.6 | | Modules | mam, carbons, smacks, csi, bookmarks, pep, blocklist, cloud_notify, vcard_legacy, http file share | | Auth | LDAP (LLDAP), main vhost `0ff.space` | | WebSocket | `wss://chat.0ff.space/xmpp-websocket` → Prosody :5280 via nginx :8080 | | Upload | `upload.0ff.space` (XEP-0363) | | MUC | `muc.0ff.space`, members-only, muc_mam (30 d), muc_moderation | | Exposure | **public** (Traefik `websecure`) — login faces the internet | | MAM retention (1:1) | not set → Prosody default, **1 week** | ## v1 features - Login with JID + password; opt-in "stay logged in" (see Auth below) - Stream management (XEP-0198) resume and automatic reconnect - Conversation list: avatar, name, last-message preview, time, unread badge, sorted by activity — 1:1 chats and rooms in one list - 1:1 chat: bubbles grouped by sender/time, delivery receipts (XEP-0184), chat markers (XEP-0333), typing notifications (XEP-0085), message correction display (XEP-0308) - Rooms (MUC): join from bookmarks (XEP-0402), read/send, nicknames, room history via muc_mam, mention highlighting - Carbons (XEP-0280) so messages sent from Monal show up here - Media: upload images/files (XEP-0363), inline image preview, voice message recording (MediaRecorder; Safari `audio/mp4`, others `audio/webm` — same caveats as pinix's jev-panel) - Contacts: roster, avatars (XEP-0084 / vCard fallback), add/remove contact - Settings: account, notifications (in-page only), theme (system/light/dark), logout - PWA: manifest + service worker for the app shell (installable on home screen); no offline sending - Layout: single pane on phones, two panes (list | chat) on desktop — both first-class ## Not in v1 - OMEMO (end-to-end encryption) — deferred past v1 for exactly this reason; approved as milestone 12, see `OMEMO-PLAN.md` - ~~Web push notifications~~ and ~~Jingle audio/video calls~~: moved into milestones 8–10 (decided 2026-09-28) - Guest/anonymous access (plaza keeps xmpp-web) - Room administration beyond what moderation needs; creating rooms ## Stack - **Svelte 5 + TypeScript**, built with **Vite** — compiles to plain DOM code, tiny runtime, no CSS framework. Budget: ~100–150 KB gzipped total. - **xmpp.js** (`@xmpp/client`, websocket transport only) for the protocol. Note: current releases are ESM-only and declare Node ≥ 22 — a build-time constraint; verify the output transpiles to the browser target. - **IndexedDB** (via a thin wrapper such as `idb`) for the local store. - Plain CSS with custom properties; the 0ff.space launcher palette and dotted background come in as variables from the host config, not as overrides. - **Browser target (decided): `es2020`** — Chrome/WebView 80+, Safari 14+, Firefox 78+ (Vite's `build.target` names es2020 plus these browser versions). That covers phones from roughly 2017 on with an updated Chrome. No legacy plugin unless a real device fails. ## Architecture ``` src/ xmpp/ connection, auth, reconnect, stanza handlers per XEP store/ IndexedDB schema, message/conversation/roster stores sync/ MAM catch-up, carbons, receipts/markers bookkeeping ui/ Svelte components: ChatList, Chat, Room, Contact, Settings config.ts reads window.config (generated per host by Nix) ``` **Data layer first.** The conversation list is driven by the local store, not by live queries: 1. On login, run one global MAM query on the account archive starting after the last stored archive id (`` / RSM), page until done. 2. For each joined room, the same against the room's muc_mam. 3. Live messages and carbons write to the store; the UI subscribes to the store. 4. Conversations table keeps last message, unread count and last-read marker, so the list renders instantly from IndexedDB before the network is up. Because 1:1 retention is only one week on the server, the local store is what gives long history on a device. Additionally raise retention on the server (see Server changes). ## Auth / staying logged in `chat.0ff.space` is public, so: - **Decided: SASL2 + FAST** (XEP-0388 / XEP-0484). With "stay logged in" ticked, the browser keeps a revocable token, never the password; without it, nothing is persisted. Verified 2026-09-28: - Prosody 13.0.6 core has no SASL2, but the community-modules revision nixpkgs pins (`15a7749c7acb`) has `mod_sasl2`, `mod_sasl2_bind2`, `mod_sasl2_fast`, `mod_sasl2_sm`, all documented as working on 13. blunix already installs community modules via `withOnlyInstalledCommunityModules`, so it's a list addition. - Works with LDAP: tokens live in Prosody's own store and are checked by HMAC, not against LDAP. Caveat: `mod_auth_ldap` has no `get_account_info`, so **an LDAP password change does not revoke existing tokens**. They expire after `sasl2_fast_token_ttl` without use (default 21 days). Mitigation: logout in the client invalidates its token; consider a shorter TTL. - xmpp.js ships `@xmpp/sasl2` and `@xmpp/sasl-ht-sha-256-none`, and `@xmpp/client` depends on both. - Password is never written to IndexedDB/localStorage. - Strict CSP (`default-src 'self'; connect-src 'self' wss://chat.0ff.space https://upload.0ff.space; img-src 'self' https://upload.0ff.space blob: data:; media-src 'self' https://upload.0ff.space blob:`), no third-party requests, no inline scripts. - Matching rule in the client (milestone 5): attachments are shown inline only from `config.mediaHosts` (default `upload.`); media from any other host stays a link, so a stranger's message can't make the browser fetch from their server (IP leak) and nothing breaks under the CSP. ## Server changes (blunix) Small, in `localServices/prosody/setup-prosody.nix`: - `archive_expires_after = "1y"` on the main vhost (decided: one year; the local store keeps longer history per device anyway). - Add `https://chat2.0ff.space` to `cross_domain_websocket` for the side-by-side period; otherwise Prosody refuses the websocket. - Add `sasl2`, `sasl2_bind2`, `sasl2_fast`, `sasl2_sm` to `withOnlyInstalledCommunityModules` and enable them on the main vhost. ## Packaging and deploy - Build from source in Nix with `buildNpmPackage` (lockfile hash pinned). - Per-host `config.js` generated like today's `local.js`: websocket URL, default domain, MUC domain, upload host, palette, app name. - blunix: new nginx vhost + Traefik router `chat2.0ff.space` next to the existing ones in `localServices/xmpp-web.nix` / `localServices/traefik/prosody.nix`; after sign-off, point `chat.` at the new build and drop the xmpp-web `chat` build (keep `plaza`). - Record the decision in `blunix:docs/intent/` when it goes live, following that repo's convention. ## Milestones 1. **Skeleton** — Vite + Svelte + TS project, config loading, login, connect/disconnect over websocket against `chat.0ff.space`; verify on the oldest target device. 2. **Data layer** — IndexedDB schema, roster, MAM catch-up, carbons, live messages into the store. 3. **Core UI** — conversation list, 1:1 chat view, sending, receipts, markers, typing; responsive one/two-pane layout. 4. **Rooms** — bookmarks, join, room history, send, mentions. 5. **Media** — upload, inline images, voice messages. 6. **Polish** — avatars, settings, theme, PWA, stay-logged-in. 7. **Nix + deploy** — `buildNpmPackage`, `chat2.0ff.space` side by side, Prosody changes, test on phone + desktop, then switch `chat.`. 8. **Push** — notifications and the icon badge with the app closed; iPhone (home-screen app, iOS 16.4+) and Android first, qutebrowser left out (QtWebEngine has no Web Push). Two deploys: - A: Prosody on Lua 5.4 (nixpkgs' default is 5.2; `mod_push2` wants 5.3+). Checked on tnix: builds with a stub for `luabitop` (broken on 5.4, unused from 5.3 on) and `buildPackages.lua` overridden, native libs and P-256/HKDF load, `mod_push2` parses. - B: `mod_push2` (Push 2.0, RFC 8291 encryption + VAPID JWT done by Prosody) plus a small local relay module on `push.0ff.space` that POSTs the ready payload to the browser's push service (allowlist: Apple, FCM, Mozilla). Client: per-device VAPID key in WebCrypto, `pushManager.subscribe`, `` at every login, `` at logout; `sw.js` shows the notification (always — iOS revokes silent pushes) and sets the badge from its own pending counter. - Coexists with Monal's `mod_cloud_notify`: push2's hooks only act on sessions registered with push2 (read in the source). - Pushes for new archived messages need a session on the server; a closed tab's hibernated session counts, extended by push2 (72 h default) — to verify live. 9. **Calls, server** — coturn (`use-auth-secret`, secret in agenix) and Prosody's `turn_external` (XEP-0215); UDP 3478 + a small relay range (approved 2026-09-28). Also helps Monal-to-Monal calls. 10. **Calls, client** — Jingle Message Initiation, Jingle RTP/ICE/DTLS over WebRTC; audio first (acceptance: a call with Monal on the iPhone), then video. A closed web app can only show a notification for an incoming call, no CallKit-style ringing. 12. **OMEMO encryption** — 1:1 end-to-end encryption via `libomemo.js`, phased rollout per `OMEMO-PLAN.md` (approved 2026-10-04). Milestone 11 was *pinix* (a second config replacing that host's xmpp-web). It was removed on 2026-10-07 and moved out of this project; see Target and scope. The number is left vacant so the status entries below, which all say "milestone 12" for OMEMO, keep referring to the same thing. ## Decisions (2026-09-28) | Question | Decision | | --- | --- | | Browser target | `es2020` (see Stack) | | Server MAM retention | `1y` | | App name | working name `0ff chat`; config-driven per host, like today | | Side-by-side hostname | `chat2.0ff.space` | | Staying logged in | SASL2 + FAST tokens (verified, see Auth) | | OMEMO library (2026-10-04) | `libomemo.js` (GPLv3) — accepted over hand-rolled crypto; see `OMEMO-PLAN.md` | | OMEMO namespace (2026-10-04) | legacy `eu.siacs.conversations.axolotl`, verified against production PEP data; `urn:xmpp:omemo:2` deferred | | OMEMO trust model (2026-10-04) | silent TOFU (pin on first use); manual fingerprint verification stays opt-in | ## Status - **2026-09-28, milestone 1 done:** project skeleton, config loading, login form, FAST token handling (`src/xmpp/session.svelte.ts`), English UI. Bundle 32 KB gzipped, passes `es-check es2020`. Logged in as `lukas@0ff.space` via `npm run dev`: online. - Server bug found on the way: blunix's nginx forwarded `X-Forwarded-Proto: http`, so Prosody offered no SASL on the chat. and plaza. websockets (both web clients were unusable). Fixed in blunix `e203240` (main) / `078b959` (capability-layout, deployed); afterwards chat offers PLAIN, plaza ANONYMOUS. - Not yet exercised: FAST tokens ("stay logged in") — the server modules come with milestone 7; testing on an old device. - **2026-09-28, milestone 2 done:** IndexedDB store per account (`src/store/`), sync engine (`src/sync/sync.ts`): roster + pushes, carbons, live messages, receipts/markers, MAM catch-up from the last archive id. 8 parser tests (`npm test`); store logic checked in a real browser. Tested against `lukas@0ff.space` with messages from plaza guests: live delivery with unread badge; a message sent while the tab was closed arrives via catch-up with badge 1; nothing doubles across logins; reading in Monal clears the badge here (own displayed marker via MAM). - Moved: room MAM catch-up to milestone 4 (was listed under the data layer), together with joining rooms. - Receipts/markers are matched on the message's `origin-id ?? id`. - Known gap for milestone 4: MUC private messages would share the room JID as conversation key with the room itself. - Finding, not a client bug: the server has **no roster stored for any account** (`/var/lib/prosody/0ff%2espace/` has no `roster/`), so the client correctly shows 0 contacts while Monal shows a locally cached list. Probably lost in the 2026-09-06 pinix → blunix move (unverified). Re-adding contacts from Monal stores them server-side. - **2026-09-28, milestone 3 done:** chat list, chat view, sending, receipts, displayed markers, typing states, one/two-pane layout, plus contacts pulled forward from milestone 6: add to the server roster and answer contact requests. Tested live against a plaza guest: read marker on opening a chat, ✓ → ✓✓ → cyan ✓✓, composing/paused sent, contact request banner and accept. `agent@0ff.space` added from the web client and a contact added in Monal both show up on the other side — Prosody now stores a roster for `lukas@0ff.space`. Fixed before the live test: loading older messages jumped back to the bottom (checked with 150 messages); logout now asks first. - Not tested live: an incoming "typing…" (the guest typed before login). - No UI to remove a contact yet (milestone 6). - **2026-09-28, milestone 4 done:** bookmarks (XEP-0402), autojoin, room MAM catch-up, instant-room creation (status 201), nick handling, mentions, room PMs as their own conversations, and — found on the way — handling of resumed streams (xmpp.js emits no "online" on an XEP-0198 resume, so the UI stayed on "Reconnecting…" after a blip; fixed in `9946cb8`). Verified on the server for `testom@muc.0ff.space`: bookmark stored with autojoin; a message from the web client; a second one after closing the tab and logging in again (auto-rejoin); "Text from Monal" from the same bookmark in Monal. - Not tested live (covered by parser tests and a sample-data render): messages from other occupants, mentions, room PMs — new rooms are members-only, so an anonymous test guest can't join. - Known gaps: a room PM's typing state is keyed by the room; ticks on room PMs stop at ✓ (receipts looked up under the room JID); system messages from the bare room JID show the JID as nick; no leave-room or remove-bookmark action; bookmarks without autojoin aren't listed. - Finding, not a client bug: **no persistent rooms were left on `muc.0ff.space`** (no `config/`, empty `persistent.dat`; only `muc_log` survived). `lobby@muc` (plaza's guest lobby) and `update@muc` (Forgejo notifications) don't exist, and no bookmarks were stored server-side. Same probable cause as the roster. Decided 2026-09-28: leave the lost rooms for now. - **2026-09-28, milestone 5 done:** HTTP upload (XEP-0363) with disco, progress and cancel; attach, paste, drag and drop; voice messages; inline images/audio/video/file links. Verified on the server (metadata only): a PNG, a 4.1 MB PDF, a 5.1 MB JPEG and a voice message recorded by the desktop browser as **AAC in MP4** (`audio/mp4`, 54 KB) uploaded from the web client; a photo from Monal shows inline in the web client; the voice message plays in Monal on iOS. First try failed ("no slot": the reply's `` was read with `iqCaller.get`, which only returns a child named like the request) — fixed in `2f62166`. - Rule chosen: attachments render inline only from `config.mediaHosts` (see Auth); expired files fall back to a link. - Server limits: 10 MB per file, 100 MB per user per day, and **uploads are deleted after 1 week** (`http_file_share_expires_after` default) — to decide with the other retention settings in milestone 7. - Not tested live: screenshot paste; drag and drop (no file manager at hand). - **2026-09-28, milestone 6 done:** settings page (theme, notifications, login-token status, log out), notifications + unread count in the tab title, remove contact, leave room, avatars (PEP → vCard, cached; DB schema v2, upgrade checked), PWA (manifest, icons, service worker; offline shell checked in a production preview). Pending: the user's run of the M6 test table (settings, theme, notification via a plaza guest, remove the leftover guest contact `kg4i…@anon.0ff.space`, optionally leave `testom`, avatar set in Monal, `npm run build && npm run preview` install). - Live run (qutebrowser 3.7 on tnix): settings, theme, remove contact and leave room work. Avatar set in Monal showed only after adding entity caps with PEP `+notify` (`e6118ed`). Unread count in the tab title works. Pop-ups didn't show at first: QtWebEngine reports the permission as `default` on every page load until the page asks again (qutebrowser answers from `content.notifications.enabled`); fixed in `8cd46c3` by asking at startup, plus an "Allow notifications" button. A direct `new Notification()` reached the desktop daemon; after the fix a message from a plaza guest popped up too. - PWA checked in headless Chromium 154 against `vite preview`: no installability errors, manifest parsed, `sw.js` active and in control, and with the server stopped the login screen still loads from the `shell-v1` cache. Not tried: installing on a phone. - Follow-ups for milestone 7: generate the manifest name per host (static now); decide how `sw.js` cache names get bumped; before switching `chat.`, check whether the xmpp-web build there registered a service worker that would keep serving the old app. - Avatars follow PEP (`+notify` via entity caps), so changes made in Monal arrive live and at every login. Option for milestone 7: a server-side job copying the LLDAP photo to the account's XMPP avatar (decided 2026-09-28: XMPP stays the source clients read). - **2026-09-28, milestone 7 done: `chat.0ff.space` switched**, the user confirmed login, rooms, avatars and the iPhone app. blunix (`capability-layout`, deployed): `capabilities/xmpp/chat-client.nix` builds this repo's `package.nix` from the public flake input `chat-client` (repo made public, 0BSD); strict CSP; xmpp-web builds only plaza; decision record `blunix:docs/intent/chat-web-client.md`. Three deploys: `69a5de2` chat2 side by side + `archive_expires_after = "1y"` + uploads 30 days; `4bcdbdb` SASL2/Bind2/FAST/SM; `7af917a` + `7632459` the switch (chat2 removed) and the app badge (`3fbd363`). - Verified on chat2 by the user: login, messages, photo, room, avatars, voice message, iPhone, and "stay logged in" across closing the tab. Monal logs in over SASL2 and sends/receives (reply to a guest seen). - Verified from tnix: headers/CSP, websocket features (FAST offers `HT-SHA-256-NONE` only when the stream header carries `from`; xmpp.js sends it), PWA installable and offline shell in headless Chromium. - Corrections to the plan: Prosody accepts the websocket from any Origin (`cross_domain_websocket` is CORS only), and nixpkgs' nginx puts the store hash into ETags, so no cache-busting is needed. - The old xmpp-web on `chat.` had a Workbox service worker at `/sw.js`; ours replaces it at the same path, so browsers show xmpp-web at most once more. - Badge (Badging API) only updates while the app runs; live counts need web push (not in v1). - **2026-09-29, milestone 8 (push) done on the iPhone:** with the home-screen app closed, two guest messages arrived as notifications, the icon counted 1 → 2 and went back to the real unread count after opening; Monal's own push kept working. blunix: `59db461` Prosody on Lua 5.4, `0ec011d` `mod_push2` + `mod_track_muc_joins` + local `mod_webpush_relay` on `push.0ff.space` (senders limited to `0ff.space`, https to the Apple/FCM/Mozilla/WNS push services only), `f58c28a` `client_https_ssl` with the CA bundle — the first live pushes failed "certificate verify failed" because Prosody's default `capath /etc/ssl/certs` has no hashed links on NixOS. Client `cd29bb5`: per-device VAPID key, subscribe + `` after each login, `` on logout, `sw.js` shows the push and counts the badge. - Tested before deploying against a local Prosody from the same package: decrypted payload, valid VAPID JWT, hibernated session, forged notification refused. - `mod_push2` findings: its `archived` match profiles never match (`stana-id` typo), so the client uses `important`; it needs Lua 5.3+ and `mod_track_muc_joins`. - Not tested: Android. Known limits: titles are the JID's local part (no roster names in the service worker); with Monal installed too, the iPhone shows both apps' notifications. - **2026-09-29, milestone 9 (TURN) done:** blunix `4e78cef`: coturn on `turn.0ff.space` (public IPv4, UDP/TCP 3478, relay UDP 49160–49259, no TURNS, no TCP relay, `use-auth-secret` with agenix `turnSecret.age`) and Prosody's `turn_external` on the `0ff.space` vhost only (secret via systemd `LoadCredential` + `Credential()`). Verified from tnix with `turnutils_uclient` and credentials taken from Prosody's `external_services`: UDP and TCP allocations, relay-to-relay 20/20 packets; wrong password refused; peers `127.0.0.1`, `100.96.0.6` (tailnet) and `192.168.1.1` refused with 403 (`denied-peer-ip`). `anon.`, `bot.` and `muc.` have no `turn_external`. - Not tested: the XEP-0215 IQ from a real client (Monal or 0ff chat) — comes with milestone 10; a Monal-to-Monal call over the relay. - Accepted: relaying to blunix's own public IP stays allowed (needed for relay-to-relay), which exposes its UDP listeners (AdGuard 53, tailscaled) to account holders. - **2026-09-29, milestone 10 (calls), audio done:** confirmed by the user: audio calls work both ways between the web client (dev server on tnix against `chat.0ff.space`, as `calltest@0ff.space`) and Monal on the iPhone (as `lukas@0ff.space`). Client `65f6bc7`, `9c70628`: `src/call/jingle.ts` (SDP ↔ Jingle), `src/call/call.svelte.ts` (JMI, Jingle, WebRTC, ICE servers via XEP-0215), `src/ui/CallView.svelte`. - Signaling taken from Monal's source (`MLCall.m`, `rust/sdp-to-jingle`): propose to the bare JID, UUID id = Jingle sid, the rest to the full JID with a store hint; proposer sends session-initiate; transport-info repeats ufrag/pwd (else Monal answers not-authorized); candidates need `generation`. Monal accepts calls without OMEMO unless "allow unverified calls" is off, and rings only for contacts with a presence subscription — the client does the same. - Checked before the live test: Monal's own converter reads our session-initiate, session-accept and transport-info; Chromium answers SDP built from Monal's XML (Opus moved first: older Monal builds list payload types in hash order). Local E2E with two headless Chromium pages and a local Prosody: calls both ways with audio, hang-up, decline; two tabs of one account: answer/decline on one stops the other, a cancelled call is missed on both. - Not tested yet: web → Monal with the iPhone locked; incoming call push to the closed web app; the deployed build (still on the dev server). - Open: video (the client answers a video call with audio and shows the peer's video if sent, but offers none) — implemented, see below. - Deployed 2026-09-29 (blunix `c8583d3`); the user confirmed calls on `chat.0ff.space`. Locked-iPhone test (Monal switched off, only the home-screen app): Prosody delivered two web pushes to Apple (201). Opening the app later resumed its session, which replayed the propose and the retract; the app rang for a call already ended. Fixed in `5ac4d39` (called-off ids never ring; the retract push shows "Missed call"; call pushes don't count on the badge), to deploy with blunix `26664d9`. A closed web app can only show a notification, not ring. - Web push for calls on the iPhone, 2026-09-29: the notifications were missing because deleting the home-screen app deletes its notification permission and push subscription, and iOS ignores the permission request at startup (it needs the tap on "Allow notifications" in Settings). Updating needs no reinstall: close and reopen the app (`sw.js` fetches the page network-first). Each reinstall leaves a stale `push2` registration whose endpoint iOS still delivers; two were removed by hand (`prosodyctl shell`, store edit + session destroy + `module:reload("push2")`). Then every call showed twice: `mod_push2` pushes a JMI propose/retract from the archive hook and again from the hibernated session's queue. Patched in blunix `878727d`; the user confirmed one notification per call. `sw.js` also closes an older notification of the same conversation first (`9341752`). - Stale registrations: decided 2026-09-29 against expiry (it would silence an app that just wasn't opened); blunix `efc5d93` adds `mod_push2_same_device`, which drops older registrations with the same SASL2 user-agent device string when a client enables push. Tested locally; live check pending. Next: video. - Live test with other people, 2026-09-29: web app on the user's iPhone to `fanny@0ff.space` (iPhone) worked both ways. To `esther@0ff.space` (old Android, web app then home-screen app) the user heard her, she never heard the user, from the start — the connection works, so her phone didn't play the audio (possibly her phone's settings). Not reproduced; `cfeb302` keeps the audio element in the page (some old Android browsers stay silent with a detached one) and shows "Tap for sound" when `play()` is refused. Retest with Esther when possible. - Same test: calls played on the loudspeaker and the app rotated. `ed73cb8`: on iOS 17+ `navigator.audioSession.type = "play-and-record"` during a call (meant for the earpiece, unverified) with a Speaker button (`"auto"`); no button elsewhere (a web page can't pick the earpiece on Android). Screen Wake Lock during calls (auto-lock cuts a web app's microphone on iOS). Manifest `"orientation": "portrait"`, honoured by installed Android apps (after a reinstall), ignored on iOS. Awaiting the user's test on the iPhone. - **2026-09-29, milestone 10, video implemented (not yet confirmed):** `e9adbf9`, `15ea77b`. Video-call button next to the phone button; the propose announces video only when a camera opened (Monal aborts a call whose Jingle has video the propose didn't announce), else the call stays audio. Incoming video calls are answered with the camera (audio only without one). Camera on/off (`track.enabled`), front/back switch (`replaceTrack`, button only with more than one camera), self-view top right, mirrored for the front camera; video calls start on the loudspeaker like Monal. Caps now list `urn:xmpp:jingle:apps:rtp:video`. - Monal writes H.264 `profile_level_id` twice; the SDP gets it once. - Checked locally: Monal's own converter reads our audio+video offer and answer (both sendrecv); Chromium answers Monal-style video Jingle (from Monal's `parse_sdp`) with audio+video sendrecv. E2E with two headless Chromium pages and a local Prosody: video both ways (~100 frames in 5 s each), camera off, no live tracks after hang-up, audio calls still have no video section. Camera switch picks the other device and the sender carries it, but headless Chromium's fake cameras stop feeding the encoder after `replaceTrack` (a canvas track works), so media after a switch is unverified. - Not tested: Safari's and real Monal video SDP, switching with real cameras, a video call to a device without a camera. Awaiting the live test with Monal on the iPhone. - Deployed 2026-09-29 (blunix `dccc145`). Confirmed by the user: video calls work both ways between the web client (`calltest@0ff.space` in a desktop browser) and the home-screen app on the iPhone, and between the web client and Monal on the iPhone. - **2026-10-04, development moved from tnix to blunix:** checkout `~/monal-rebuild` on blunix (`blunix:capabilities/dev/` installs nodejs), so the operator can work from the iPhone over the tailnet. The tnix clone stays as fallback. - **2026-10-04, room invites and conversation deletion implemented:** room occupants can ask the room to mediate an invitation; the service handles membership for members-only rooms and reports an immediate policy refusal in the form. 1:1 conversations can be removed from the list without deleting their stored messages or the contact, including while offline. A later incoming message recreates the conversation entry. Static checks and the production build pass. **Live-confirmed by the operator on `chat.0ff.space`:** deleting a 1:1 chat, leaving a room, and inviting a second account (`calltest`, entered without `@0ff.space` — the invite still worked, so a bare local part resolves fine, worth noting but not acted on since nothing broke) into a room all work. This milestone's live checks are complete. - **2026-10-04, milestone 12 (OMEMO), Phase 0 verify done:** plan approved and saved to `OMEMO-PLAN.md`. Namespace confirmed from production Prosody's PEP store: Monal publishes legacy `eu.siacs.conversations.axolotl` device lists/bundles only, no `omemo:2` node — legacy is the required first path. `libomemo.js` 3.0.0 audited (delegated via herdr to OpenCode, in a temporary install fully reverted afterwards): license confirmed `GPL-3.0` (only transitive dependency `protobufjs`, BSD-3-Clause); ships typed ESM+CJS builds plus a real worker-offload `./worker-client` export. Bundle cost measured: default import +225.9 kB raw/+89.2 kB gzip, worker-client +99.4 kB raw/+25.1 kB gzip in the main chunk plus a 165 kB worker asset fetched on demand — a genuine trade-off left open for `omemo-worker.ts` time. Storage-interface shape read from the library's shipped `.d.ts`: sessions serialize to plain strings (not class instances) and its `address` carries no namespace — simpler to adapt than this plan first assumed; see `OMEMO-PLAN.md`'s "Audit findings" for the corrections. `es-check es2020 --module` (not plain script mode) passes with the library present. Licensing-text reviewed and applied to `LICENSE` and `README.md`'s license section (combined-work GPLv3 note). Phase 0 is fully done. - **2026-10-04, milestone 12 (OMEMO), Phase 1 (skeleton) done:** delegated via herdr to OpenCode, independently verified by the coordinator. New `src/crypto/omemo-db.ts`/`omemo-store.ts`/`omemo.ts` implement identity bootstrap only (registration id, identity keypair, 1 signed prekey, 100 one-time prekeys via `KeyHelper`, idempotent across relogin) — no PEP, no send/receive, no UI yet. `test/omemo.test.ts` (4 tests) added `fake-indexeddb` as a new devDependency, this project's first Node-side IndexedDB test coverage. `npm run check` 0 errors/warnings, `npm test` 32/32 (28 existing + 4 new, stable across repeated runs), `npm run build` succeeds at an unchanged 202.26 kB/65.51 kB gzip (nothing imports `src/crypto/` into the app yet). Implementing against the library's real `dist/index.d.ts` corrected several of Phase 0's audit assumptions (`saveIdentity()`'s return direction, the generic store methods being sync-only/unused by the library, two Node-compat deviations from `ChatDB`'s exact style) — see `OMEMO-PLAN.md`'s "Phase 1 skeleton findings" for the full list. - **2026-10-04, milestone 12 (OMEMO), Phase 2 (device lists & bundles) done:** delegated via herdr to OpenCode (using its non-interactive `run` mode after the interactive TUI proved to crash reproducibly on this host — see `docs/agentic-opencode.md` in blunix), independently verified by the coordinator. New `src/crypto/omemo-wire.ts` (legacy ``/`` build/parse, 6 unit tests incl. exact-wire-string assertions) plus `publishBundle`/`publishDeviceList`/`fetchDeviceList`/ `fetchBundle`/`replenishPreKeys`/`syncOmemo` in `omemo.ts`, hooked into `session.svelte.ts`'s login handler, fail-soft throughout. `caps.ts`/ `parse.ts` gained the devicelist feature/namespace. `npm run check` 0 errors/warnings, `npm test` 38/38 (32 existing + 6 new, stable across repeated runs), `npm run build` succeeds at 412.21 kB/151.07 kB gzip (libomemo.js's cost now actually paid, since this is the first phase that wires it into the app). Noted deviations from the plan (publish- options minimalism vs. the bookmark precedent, two simplified function signatures, a documented safe import cycle) — see `OMEMO-PLAN.md`'s "Phase 2 findings". Still outstanding, unchanged: the live two-scratch- account/real-Monal interop test, which needs the operator's credentials. - **2026-10-04, milestone 12 (OMEMO), Phase 3 (send-path encryption) done:** implemented directly (not delegated — real encryption, not scaffolding). Probed the real `libomemo.js` locally first (no network): confirmed `SessionCipher.encrypt()`'s output is a binary string, not base64; confirmed `registrationId: 0` is accepted for legacy bundles (which never publish one); confirmed legacy OMEMO uses **AES-128-GCM** with the tag concatenated into the per-device wrapped key, not the plan's originally- written AES-256. `session.svelte.ts`'s 1:1 `send()` now encrypts for every capable contact, fail-closed on any error. New `encryptFor`/ `decrypt` in `omemo.ts` (the latter written for test coverage now, not yet wired into the receive path — Phase 4's job) round-tripped real plaintext through the actual Double Ratchet + AES-GCM locally, two simulated devices including a third "own other device," no network (`test/omemo.test.ts`). `omemo-wire.ts` gained the `` envelope (10 new tests). Found and closed two gaps the plan never addressed: private messages to room occupants (a full JID; OMEMO needs a bare one) now explicitly skip OMEMO rather than mis-querying or hard-failing; an encrypted-capable contact's file attachment now fails closed instead of leaking the URL in the clear. `npm run check` 0 errors/warnings, `npm test` 44/44 (32 + 6 Phase-2 wire tests + 6 new), `npm run build` succeeds at 431.00 kB/155.77 kB gzip. See `OMEMO-PLAN.md`'s "Phase 3 findings" for the full detail. Still outstanding, unchanged from the plan's own wording: the live two-scratch-account Prosody-MAM-ciphertext acceptance test, which needs the operator's server access. - **2026-10-04, milestone 12 (OMEMO), Phase 4 (receive-path decryption) done:** implemented directly, same reasoning as Phase 3. `Sync.#store()` now skips OMEMO for groupchat, checks `ChatDB.hasMessage()` before attempting decrypt (never after — a duplicate must never advance a sender device's ratchet twice), calls the new never-throwing `decryptIncoming` and stores the real plaintext with `encrypted: true` on success, or a distinct placeholder with `undecryptable: true` on failure — a new `firstEver` flag threaded through `#query`/`#initial` tells "sent before this device existed" (expected forward secrecy) apart from a generic decrypt failure, exactly as specified. `npm run check` 0 errors/warnings, `npm test` 44/44 (unchanged — see below), `npm run build` succeeds at 432.86 kB/156.37 kB gzip. **Deliberately not unit-tested**: `Sync`'s constructor uses TS parameter properties, which Node's `--test` can't load — `Sync` has no tests at all for this reason, matching the existing untested-network-code convention (`muc.ts` bookmarks, Phase 2's PEP calls); the crypto this new code calls is already thoroughly tested from Phase 3. Full detail in `OMEMO-PLAN.md`'s "Phase 4 findings". Still outstanding: the plan's own two live tests (new-device backlog placeholder; same-profile relogin still decrypts) plus the Phase 2/3 live tests already pending — all need the operator. - **2026-10-04, milestone 12 (OMEMO), Phase 5 done:** found and fixed a real bug while designing the lock icon's "key changed" state, before any UI was built on it: `isTrustedIdentity()` returned `false` on a changed key, making the library throw and *block* further send/receive — contradicting this plan's own "warn, never block" trust model. Now always returns `true`; `saveIdentity()` alone detects a change and persists it as a status (`tofu`/`changed`/`verified`) on a richer `TrustRecord`. New `getTrustStatus`/`markVerified`/`fingerprintFor`/ `keyFingerprintHex` exports in `omemo.ts`, plus five thin UI-facing methods on `Session` so the UI never touches `#xmpp`/`OmemoDB` directly. The UI itself (lock icon in `Chat.svelte` — none/encrypted/changed, new `Fingerprint.svelte` sheet, Settings' Encryption section, device-list prefetch on conversation open) was delegated to OpenCode via herdr (`opencode run`) and reviewed cold — its own self-report couldn't finish because the `opencode-go` provider's usage limit hit right after its checkpoint commit landed. `npm run check` 0 errors/warnings, `npm test` 47/47 unchanged (no Svelte-component test precedent in this project), `npm run build` succeeds at 440.55 kB/158.53 kB gzip; the dev server was started and curled (HTTP 200) as a minimal runtime check. See `OMEMO-PLAN.md`'s "Phase 5 findings" for the full detail. **Not verified**: real-browser rendering and the live Monal fingerprint comparison — both the operator's job, alongside the Phase 2-4 live tests already pending. - **2026-10-04, milestone 12 (OMEMO), Phase 6 (deploy) prepared, cutover pending:** found a real, previously-unaddressed gap while preparing this phase: `chat.0ff.space`'s *current* CSP already blocks `libomemo.js`'s WASM entirely, and nothing degraded gracefully from that — `send()` would have thrown an uncaught, cryptic error on the first message to a capable contact instead of falling back to plaintext. Fixed first: `omemoSupported()` (feature-detects WASM via the smallest valid module, memoized) gates `cachedDeviceList` — the one choke point every OMEMO entry point already goes through — so a CSP-blocked browser now behaves exactly as if no contact were ever OMEMO-capable; Settings shows a one-line notice. Also found and fixed: `package.nix`'s `npmDepsHash` has been stale (Nix build broken) since Phase 1 added `libomemo.js`/`fake-indexeddb` via npm directly. `npm run check` 0 errors/warnings, `npm test` 48/48 (1 new test, run twice), `npm run build` and `nix-build` both succeed. blunix's `capabilities/xmpp/chat-client.nix` CSP now carries `'wasm-unsafe-eval'`, its `chat-client` flake input bumped to this repo's `main`, `docs/intent/omemo-encryption.md` written — all committed and pushed, but **`nixos-rebuild switch` was deliberately not run**: no cutover until the operator's live interop tests (still pending from Phases 2-5) confirm this actually works against real Monal. See `OMEMO-PLAN.md`'s updated Phase 6 rollout entry. - **2026-10-05, milestone 12 (OMEMO), Phase 5's real-browser gap closed:** two disposable LLDAP test accounts (`testcal-a`, `testcal-b`) in two Playwright-driven Chromium contexts against `npm run dev`'s proxy to production `chat.0ff.space` (no separate staging server exists). Confirmed by screenshot: lock icon renders, a message round-trips encrypted and decrypts correctly, the `Fingerprint.svelte` sheet lists devices with matching fingerprints and a working "Mark verified", and Settings' Encryption section shows the own fingerprint. Script at `.playwright-cli/omemo-e2e.mjs` (gitignored, not committed). See `OMEMO-PLAN.md`'s "Phase 5 findings" for the full write-up and the NixOS/Playwright browser-binary setup note. **Still outstanding, unchanged**: the live Monal fingerprint comparison and the Phase 2-4 live interop tests — all the operator's job, not scriptable. - **2026-10-07, milestone 12 (OMEMO) live on production, plus two defects found by testing it:** the CSP/chat-client deploy was switched (blunix generation 542), and the whole send/receive path was then exercised by Playwright against the *deployed* build (not the dev proxy), two accounts, both directions. Both messages round-tripped and decrypted. **Phase 3's acceptance test is now passed**: Prosody's MAM archive on disk holds `` stanzas (per-device ``, ``, ``, `` hint) whose `` carries only the fixed fallback string, and a `grep` of all of `/var/lib/prosody/` finds no trace of either test message's plaintext. The two defects the run exposed: - `libomemo.js` fetches its WASM core from a `data:` URL, which `connect-src` governs — `'wasm-unsafe-eval'` only allows compiling the module. The fetch was blocked and the library fell back to an undocumented inline-compile path, so encryption worked but rested on that fallback. Fixed in blunix's `chat-client.nix` (`data:` added to `connect-src`); re-tested against production, console now clean. - The lock icon was driven by `omemoDevices`, which lists only devices a session was already built with — so the *first* message to a peer was encrypted while showing no lock, and the fingerprint sheet behind the lock was unreachable. Fixed: presence now follows `omemoCapable()` (the peer publishes a device), `changed` still comes from trust rows. Verified in a fresh browser context: lock and sheet both present before any session exists. - **2026-10-07, group invitations: the receiving half was missing and is now built.** An invitation arrived as a conversation whose subtitle read "Not joined" with no way to act on it — the composer is disabled when a room isn't joined, so the only route in was typing the room address into "New chat" by hand. The handoff's long-pending "confirm the invitee can join" check was therefore never passable through the UI as built. `Chat.svelte`'s header now shows a "Join" button while a room is not joined (disabled while a join is under way, left enabled on error as a retry). End-to-end verified with Playwright: a creates a room and invites b, b's list shows the invitation, b joins from the header (subtitle goes "Not joined" → "2 participants", button disappears, composer enables) and b's message into the room arrives at a. - **2026-10-07, OMEMO interop confirmed against real Monal, both directions.** Monal fetched the web client's PEP device list and encrypted to every device on it (plus its own, for self-sync), with a payload-less KeyTransportElement first for session setup — so our publish path is validated by a foreign implementation. In the other direction, a message typed in Monal ("Hallo wie geht's") decrypted correctly in the web client, read from a persistent browser profile whose OMEMO identity survives across runs. Monal writes `prekey="1"` where this client writes `"true"`; `omemo-wire.ts` already accepts both. Monal also sends a `urn:xmpp:eme:0` hint, which this client does not — no interop effect observed. Phase 4's new-device backlog placeholder was confirmed on the way: a real Monal message from before the profile's device existed rendered as "Encrypted message sent before this device was added." Still open, and only the operator can do it: the out-of-band fingerprint comparison against their Monal device. - **2026-10-07, dead OMEMO devices can now be retracted.** See `OMEMO-PLAN.md`'s "Dead-device cleanup" section for the full write-up. Settings' Encryption section lists the account's own published devices and removes the stale ones; verified live, the test account went from 9 devices to 1 with the orphaned bundle nodes deleted. - **2026-10-07, fingerprint display corrected and confirmed against Monal.** Two defects, both of which defeated out-of-band comparison: the hex renderer included the `0x05` key-type byte (66 characters, 8 groups plus a stray 2, every later character a byte out of step), and the per-device rows showed a libsignal *safety number* — order-independent by design, hence identical under "You" and "Them", and absent from Monal and Conversations entirely. Both fixed; the sheet now shows each device's identity-key fingerprint in the shared convention. Verified in a browser: the value shown for the operator's Monal device matches what Monal displays character for character. See `OMEMO-PLAN.md`'s "Fingerprint display corrected". **The OMEMO plan is now fully verified end to end, including against a foreign client; nothing on it is outstanding.** - **2026-10-07, milestone 11 (pinix) removed from this project.** The operator's decision: replacing `pinix:xmpp/web.nix`'s xmpp-web happens in a separate project, and only once `chat.0ff.space` has proven itself in daily use. The milestone number stays vacant so every "milestone 12" above still means OMEMO. With it gone, no milestone of this plan is outstanding. - **2026-10-08, encrypted attachments: the compatibility format is decided, nothing is implemented yet.** `ux-implementation-plan.md`'s package 9 blocked on this. Read from Monal's `develop` source: the only representation Monal recognises is XEP-0454's `aesgcm://` URL as the sole content of the decrypted body, with no `jabber:x:oob` child and no thumbnail line; it has no XEP-0447 support, so SFS is rejected. Verified live on blunix that `upload.0ff.space`'s file route already answers with `access-control-allow-origin: https://chat.0ff.space`, and that the chat CSP already permits that fetch and `blob:` media — so no server change is needed. Full write-up, including the three receive fragment layouts, the 10 MiB limit's 16-byte tag overhead, the federated-host limitation and the failure matrix: `OMEMO-PLAN.md`, "Encrypted attachments: format decided". - **2026-10-08, encrypted attachments are implemented; acceptance is the remaining half.** Image, file and voice messages to an OMEMO-capable contact are now encrypted with AES-256-GCM before the upload and shared as an `aesgcm://` URL inside the envelope, so the composer no longer refuses them. New `src/crypto/aesgcm.ts` plus changes in `sendFile`, `mediaCapability`, `media.ts`, `db.ts` and `Chat.svelte`; `npm run check`, 86 tests and `npm run build` pass. Two defects were found on the way — the WHATWG URL scheme swap silently failing, and a cleartext key leak if a peer's capability changed mid-upload — both closed and written up in `OMEMO-PLAN.md`, "Encrypted attachments implemented". Still open: a live browser run, the round trip against real Monal, iPhone recording and playback, and the release itself. - **2026-10-08, encrypted attachments are live as generation 557.** Client `031766c`, pinned by blunix `d23cb82`. The deployed bundle is byte-identical to the local build, and the new `ux-media-runner.cjs` passes all ten encrypted-attachment checks against it with zero page errors: no `oob` child and no cleartext `aesgcm` on the wire, independent decryption in a second profile, uploads exactly plaintext + 16 bytes, the group path untouched, and distinct messages for a 404 and an integrity failure. See `UX-MEDIA-RESULTS.md`. What remains is device-bound: the real Monal round trip and iPhone recording/playback.