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>
47 KiB
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: noneon 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, othersaudio/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 notificationsandJingle 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'sbuild.targetnames 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:
- On login, run one global MAM query on the account archive starting
after the last stored archive id (
<after>/ RSM), page until done. - For each joined room, the same against the room's muc_mam.
- Live messages and carbons write to the store; the UI subscribes to the store.
- 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) hasmod_sasl2,mod_sasl2_bind2,mod_sasl2_fast,mod_sasl2_sm, all documented as working on 13. blunix already installs community modules viawithOnlyInstalledCommunityModules, 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_ldaphas noget_account_info, so an LDAP password change does not revoke existing tokens. They expire aftersasl2_fast_token_ttlwithout use (default 21 days). Mitigation: logout in the client invalidates its token; consider a shorter TTL. - xmpp.js ships
@xmpp/sasl2and@xmpp/sasl-ht-sha-256-none, and@xmpp/clientdepends on both.
- Prosody 13.0.6 core has no SASL2, but the community-modules revision
nixpkgs pins (
- 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(defaultupload.<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.spacetocross_domain_websocketfor the side-by-side period; otherwise Prosody refuses the websocket. - Add
sasl2,sasl2_bind2,sasl2_fast,sasl2_smtowithOnlyInstalledCommunityModulesand enable them on the main vhost.
Packaging and deploy
- Build from source in Nix with
buildNpmPackage(lockfile hash pinned). - Per-host
config.jsgenerated like today'slocal.js: websocket URL, default domain, MUC domain, upload host, palette, app name. - blunix: new nginx vhost + Traefik router
chat2.0ff.spacenext to the existing ones inlocalServices/xmpp-web.nix/localServices/traefik/prosody.nix; after sign-off, pointchat.at the new build and drop the xmpp-webchatbuild (keepplaza). - Record the decision in
blunix:docs/intent/when it goes live, following that repo's convention.
Milestones
- Skeleton — Vite + Svelte + TS project, config loading, login,
connect/disconnect over websocket against
chat.0ff.space; verify on the oldest target device. - Data layer — IndexedDB schema, roster, MAM catch-up, carbons, live messages into the store.
- Core UI — conversation list, 1:1 chat view, sending, receipts, markers, typing; responsive one/two-pane layout.
- Rooms — bookmarks, join, room history, send, mentions.
- Media — upload, inline images, voice messages.
- Polish — avatars, settings, theme, PWA, stay-logged-in.
- Nix + deploy —
buildNpmPackage,chat2.0ff.spaceside by side, Prosody changes, test on phone + desktop, then switchchat.. - 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_push2wants 5.3+). Checked on tnix: builds with a stub forluabitop(broken on 5.4, unused from 5.3 on) andbuildPackages.luaoverridden, native libs and P-256/HKDF load,mod_push2parses. - B:
mod_push2(Push 2.0, RFC 8291 encryption + VAPID JWT done by Prosody) plus a small local relay module onpush.0ff.spacethat 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.jsshows 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.
- A: Prosody on Lua 5.4 (nixpkgs' default is 5.2;
- Calls, server — coturn (
use-auth-secret, secret in agenix) and Prosody'sturn_external(XEP-0215); UDP 3478 + a small relay range (approved 2026-09-28). Also helps Monal-to-Monal calls. - 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.
- OMEMO encryption — 1:1 end-to-end encryption via
libomemo.js, phased rollout perOMEMO-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, passeses-check es2020. Logged in aslukas@0ff.spacevianpm 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 blunixe203240(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.
- Server bug found on the way: blunix's nginx forwarded
- 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 againstlukas@0ff.spacewith 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 noroster/), 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.spaceadded from the web client and a contact added in Monal both show up on the other side — Prosody now stores a roster forlukas@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 fortestom@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(noconfig/, emptypersistent.dat; onlymuc_logsurvived).lobby@muc(plaza's guest lobby) andupdate@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 withiqCaller.get, which only returns a child named like the request) — fixed in2f62166.- 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_afterdefault) — to decide with the other retention settings in milestone 7. - Not tested live: screenshot paste; drag and drop (no file manager at hand).
- Rule chosen: attachments render inline only from
- 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 leavetestom, avatar set in Monal,npm run build && npm run previewinstall).- 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 asdefaulton every page load until the page asks again (qutebrowser answers fromcontent.notifications.enabled); fixed in8cd46c3by asking at startup, plus an "Allow notifications" button. A directnew 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.jsactive and in control, and with the server stopped the login screen still loads from theshell-v1cache. Not tried: installing on a phone. - Follow-ups for milestone 7: generate the manifest name per host
(static now); decide how
sw.jscache names get bumped; before switchingchat., check whether the xmpp-web build there registered a service worker that would keep serving the old app. - Avatars follow PEP (
+notifyvia 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).
- 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
- 2026-09-28, milestone 7 done:
chat.0ff.spaceswitched, the user confirmed login, rooms, avatars and the iPhone app. blunix (capability-layout, deployed):capabilities/xmpp/chat-client.nixbuilds this repo'spackage.nixfrom the public flake inputchat-client(repo made public, 0BSD); strict CSP; xmpp-web builds only plaza; decision recordblunix:docs/intent/chat-web-client.md. Three deploys:69a5de2chat2 side by side +archive_expires_after = "1y"+ uploads 30 days;4bcdbdbSASL2/Bind2/FAST/SM;7af917a+7632459the 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-NONEonly when the stream header carriesfrom; 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_websocketis 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:
59db461Prosody on Lua 5.4,0ec011dmod_push2+mod_track_muc_joins+ localmod_webpush_relayonpush.0ff.space(senders limited to0ff.space, https to the Apple/FCM/Mozilla/WNS push services only),f58c28aclient_https_sslwith the CA bundle — the first live pushes failed "certificate verify failed" because Prosody's defaultcapath /etc/ssl/certshas no hashed links on NixOS. Clientcd29bb5: per-device VAPID key, subscribe +<enable>after each login,<disable>on logout,sw.jsshows 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_push2findings: itsarchivedmatch profiles never match (stana-idtypo), so the client usesimportant; it needs Lua 5.3+ andmod_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 onturn.0ff.space(public IPv4, UDP/TCP 3478, relay UDP 49160–49259, no TURNS, no TCP relay,use-auth-secretwith agenixturnSecret.age) and Prosody'sturn_externalon the0ff.spacevhost only (secret via systemdLoadCredential+Credential()). Verified from tnix withturnutils_uclientand credentials taken from Prosody'sexternal_services: UDP and TCP allocations, relay-to-relay 20/20 packets; wrong password refused; peers127.0.0.1,100.96.0.6(tailnet) and192.168.1.1refused with 403 (denied-peer-ip).anon.,bot.andmuc.have noturn_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, ascalltest@0ff.space) and Monal on the iPhone (aslukas@0ff.space). Client65f6bc7,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 needgeneration. 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 onchat.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 in5ac4d39(called-off ids never ring; the retract push shows "Missed call"; call pushes don't count on the badge), to deploy with blunix26664d9. 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.jsfetches the page network-first). Each reinstall leaves a stalepush2registration 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_push2pushes a JMI propose/retract from the archive hook and again from the hibernated session's queue. Patched in blunix878727d; the user confirmed one notification per call.sw.jsalso 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
efc5d93addsmod_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. Toesther@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;cfeb302keeps the audio element in the page (some old Android browsers stay silent with a detached one) and shows "Tap for sound" whenplay()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.
- Signaling taken from Monal's source (
- 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 listurn:xmpp:jingle:apps:rtp:video.- Monal writes H.264
profile_level_idtwice; 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 afterreplaceTrack(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.spacein a desktop browser) and the home-screen app on the iPhone, and between the web client and Monal on the iPhone.
- Monal writes H.264
- 2026-10-04, development moved from tnix to blunix: checkout
~/monal-rebuildon 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 legacyeu.siacs.conversations.axolotldevice lists/bundles only, noomemo:2node — legacy is the required first path.libomemo.js3.0.0 audited (delegated via herdr to OpenCode, in a temporary install fully reverted afterwards): license confirmedGPL-3.0(only transitive dependencyprotobufjs, BSD-3-Clause); ships typed ESM+CJS builds plus a real worker-offload./worker-clientexport. 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 foromemo-worker.tstime. Storage-interface shape read from the library's shipped.d.ts: sessions serialize to plain strings (not class instances) and itsaddresscarries no namespace — simpler to adapt than this plan first assumed; seeOMEMO-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 toLICENSEandREADME.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.tsimplement identity bootstrap only (registration id, identity keypair, 1 signed prekey, 100 one-time prekeys viaKeyHelper, idempotent across relogin) — no PEP, no send/receive, no UI yet.test/omemo.test.ts(4 tests) addedfake-indexeddbas a new devDependency, this project's first Node-side IndexedDB test coverage.npm run check0 errors/warnings,npm test32/32 (28 existing + 4 new, stable across repeated runs),npm run buildsucceeds at an unchanged 202.26 kB/65.51 kB gzip (nothing importssrc/crypto/into the app yet). Implementing against the library's realdist/index.d.tscorrected 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 fromChatDB's exact style) — seeOMEMO-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
runmode after the interactive TUI proved to crash reproducibly on this host — seedocs/agentic-opencode.mdin blunix), independently verified by the coordinator. Newsrc/crypto/omemo-wire.ts(legacy<bundle>/<list>build/parse, 6 unit tests incl. exact-wire-string assertions) pluspublishBundle/publishDeviceList/fetchDeviceList/fetchBundle/replenishPreKeys/syncOmemoinomemo.ts, hooked intosession.svelte.ts's login handler, fail-soft throughout.caps.ts/parse.tsgained the devicelist feature/namespace.npm run check0 errors/warnings,npm test38/38 (32 existing + 6 new, stable across repeated runs),npm run buildsucceeds 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) — seeOMEMO-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.jslocally first (no network): confirmedSessionCipher.encrypt()'s output is a binary string, not base64; confirmedregistrationId: 0is 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:1send()now encrypts for every capable contact, fail-closed on any error. NewencryptFor/decryptinomemo.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.tsgained 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 check0 errors/warnings,npm test44/44 (32 + 6 Phase-2 wire tests + 6 new),npm run buildsucceeds at 431.00 kB/155.77 kB gzip. SeeOMEMO-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, checksChatDB.hasMessage()before attempting decrypt (never after — a duplicate must never advance a sender device's ratchet twice), calls the new never-throwingdecryptIncomingand stores the real plaintext withencrypted: trueon success, or a distinct placeholder withundecryptable: trueon failure — a newfirstEverflag threaded through#query/#initialtells "sent before this device existed" (expected forward secrecy) apart from a generic decrypt failure, exactly as specified.npm run check0 errors/warnings,npm test44/44 (unchanged — see below),npm run buildsucceeds at 432.86 kB/156.37 kB gzip. Deliberately not unit-tested:Sync's constructor uses TS parameter properties, which Node's--testcan't load —Synchas no tests at all for this reason, matching the existing untested-network-code convention (muc.tsbookmarks, Phase 2's PEP calls); the crypto this new code calls is already thoroughly tested from Phase 3. Full detail inOMEMO-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()returnedfalseon a changed key, making the library throw and block further send/receive — contradicting this plan's own "warn, never block" trust model. Now always returnstrue;saveIdentity()alone detects a change and persists it as a status (tofu/changed/verified) on a richerTrustRecord. NewgetTrustStatus/markVerified/fingerprintFor/keyFingerprintHexexports inomemo.ts, plus five thin UI-facing methods onSessionso the UI never touches#xmpp/OmemoDBdirectly. The UI itself (lock icon inChat.svelte— none/encrypted/changed, newFingerprint.sveltesheet, 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 theopencode-goprovider's usage limit hit right after its checkpoint commit landed.npm run check0 errors/warnings,npm test47/47 unchanged (no Svelte-component test precedent in this project),npm run buildsucceeds at 440.55 kB/158.53 kB gzip; the dev server was started and curled (HTTP 200) as a minimal runtime check. SeeOMEMO-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 blockslibomemo.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) gatescachedDeviceList— 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'snpmDepsHashhas been stale (Nix build broken) since Phase 1 addedlibomemo.js/fake-indexeddbvia npm directly.npm run check0 errors/warnings,npm test48/48 (1 new test, run twice),npm run buildandnix-buildboth succeed. blunix'scapabilities/xmpp/chat-client.nixCSP now carries'wasm-unsafe-eval', itschat-clientflake input bumped to this repo'smain,docs/intent/omemo-encryption.mdwritten — all committed and pushed, butnixos-rebuild switchwas 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. SeeOMEMO-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 againstnpm run dev's proxy to productionchat.0ff.space(no separate staging server exists). Confirmed by screenshot: lock icon renders, a message round-trips encrypted and decrypts correctly, theFingerprint.sveltesheet 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). SeeOMEMO-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 agrepof all of/var/lib/prosody/finds no trace of either test message's plaintext. The two defects the run exposed:libomemo.jsfetches its WASM core from adata:URL, whichconnect-srcgoverns —'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'schat-client.nix(data:added toconnect-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 followsomemoCapable()(the peer publishes a device),changedstill 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.tsalready accepts both. Monal also sends aurn:xmpp:eme:0hint, 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
0x05key-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. SeeOMEMO-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 oncechat.0ff.spacehas 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'sdevelopsource: the only representation Monal recognises is XEP-0454'saesgcm://URL as the sole content of the decrypted body, with nojabber:x:oobchild and no thumbnail line; it has no XEP-0447 support, so SFS is rejected. Verified live on blunix thatupload.0ff.space's file route already answers withaccess-control-allow-origin: https://chat.0ff.space, and that the chat CSP already permits that fetch andblob: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. Newsrc/crypto/aesgcm.tsplus changes insendFile,mediaCapability,media.ts,db.tsandChat.svelte;npm run check, 86 tests andnpm run buildpass. 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 inOMEMO-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 blunixd23cb82. The deployed bundle is byte-identical to the local build, and the newux-media-runner.cjspasses all ten encrypted-attachment checks against it with zero page errors: nooobchild and no cleartextaesgcmon 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. SeeUX-MEDIA-RESULTS.md. What remains is device-bound: the real Monal round trip and iPhone recording/playback.