monal-rebuild/PLAN.md
pijukka 1802100aa0 test(chat): accept encrypted attachments live, and record the release
New ux-media-runner.cjs exercises package 9 against the deployed bundle
with two Linux browser profiles and the dedicated audit accounts. All ten
checks pass with zero page errors: every outgoing attachment stanza is
OMEMO-encrypted with no jabber❌oob child and no cleartext aesgcm string,
the second profile decrypts image, file and voice independently (the 1x1
PNG decodes 1x1, the generic file downloads as the exact plaintext bytes),
upload sizes are plaintext + 16 to the byte, the only plaintext PUT in the
run is the group upload that is plaintext by design, cancellation sends
nothing, a 404 and an integrity failure read differently with a working
retry, a foreign host is explained without a request, and the group path
keeps its oob, name and media type.

Three checks first failed on the runner's own assumptions rather than the
app's behaviour, recorded in UX-MEDIA-RESULTS.md so the next run is not
misled: Prosody serves uploads immutable so a re-read never reaches the
network, persistent profiles make a bare waitFor match an older
attachment, and connect-src has no blob: so an in-page fetch of our own
object URL is CSP-blocked — the app only assigns it to src/href.

Release: client 031766c pinned by blunix d23cb82, live as generation 557,
Nix build and HTTPS-served bundle byte-identical, sw.js and CSP unchanged,
ten services active, zero failed units, rollback 556. The group fixture was
destroyed and the seeded row removed. Still device-bound: the real Monal
round trip and iPhone recording/playback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 16:53:28 +02:00

47 KiB
Raw Permalink Blame History

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 (<after> / 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.<domain>); 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, <enable> at every login, <disable> 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.
  11. 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 <slot> 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 + <enable> after each login, <disable> 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 <bundle>/<list> 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 <encrypted> 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 <encrypted> stanzas (per-device <key rid>, <iv>, <payload>, <store> hint) whose <body> 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.