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

771 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
12. **OMEMO encryption** — 1:1 end-to-end encryption via `libomemo.js`,
phased rollout per `OMEMO-PLAN.md` (approved 2026-10-04).
Milestone 11 was *pinix* (a second config replacing that host's xmpp-web).
It was removed on 2026-10-07 and moved out of this project; see Target and
scope. The number is left vacant so the status entries below, which all say
"milestone 12" for OMEMO, keep referring to the same thing.
## Decisions (2026-09-28)
| Question | Decision |
| --- | --- |
| Browser target | `es2020` (see Stack) |
| Server MAM retention | `1y` |
| App name | working name `0ff chat`; config-driven per host, like today |
| Side-by-side hostname | `chat2.0ff.space` |
| Staying logged in | SASL2 + FAST tokens (verified, see Auth) |
| OMEMO library (2026-10-04) | `libomemo.js` (GPLv3) — accepted over hand-rolled crypto; see `OMEMO-PLAN.md` |
| OMEMO namespace (2026-10-04) | legacy `eu.siacs.conversations.axolotl`, verified against production PEP data; `urn:xmpp:omemo:2` deferred |
| OMEMO trust model (2026-10-04) | silent TOFU (pin on first use); manual fingerprint verification stays opt-in |
## Status
- **2026-09-28, milestone 1 done:** project skeleton, config loading,
login form, FAST token handling (`src/xmpp/session.svelte.ts`), English
UI. Bundle 32 KB gzipped, passes `es-check es2020`. Logged in as
`lukas@0ff.space` via `npm run dev`: online.
- Server bug found on the way: blunix's nginx forwarded
`X-Forwarded-Proto: http`, so Prosody offered no SASL on the chat.
and plaza. websockets (both web clients were unusable). Fixed in
blunix `e203240` (main) / `078b959` (capability-layout, deployed);
afterwards chat offers PLAIN, plaza ANONYMOUS.
- Not yet exercised: FAST tokens ("stay logged in") — the server
modules come with milestone 7; testing on an old device.
- **2026-09-28, milestone 2 done:** IndexedDB store per account
(`src/store/`), sync engine (`src/sync/sync.ts`): roster + pushes,
carbons, live messages, receipts/markers, MAM catch-up from the last
archive id. 8 parser tests (`npm test`); store logic checked in a real
browser. Tested against `lukas@0ff.space` with messages from plaza
guests: live delivery with unread badge; a message sent while the tab
was closed arrives via catch-up with badge 1; nothing doubles across
logins; reading in Monal clears the badge here (own displayed marker
via MAM).
- Moved: room MAM catch-up to milestone 4 (was listed under the data
layer), together with joining rooms.
- Receipts/markers are matched on the message's `origin-id ?? id`.
- Known gap for milestone 4: MUC private messages would share the room
JID as conversation key with the room itself.
- Finding, not a client bug: the server has **no roster stored for any
account** (`/var/lib/prosody/0ff%2espace/` has no `roster/`), so the
client correctly shows 0 contacts while Monal shows a locally cached
list. Probably lost in the 2026-09-06 pinix → blunix move
(unverified). Re-adding contacts from Monal stores them server-side.
- **2026-09-28, milestone 3 done:** chat list, chat view, sending,
receipts, displayed markers, typing states, one/two-pane layout, plus
contacts pulled forward from milestone 6: add to the server roster and
answer contact requests. Tested live against a plaza guest: read marker
on opening a chat, ✓ → ✓✓ → cyan ✓✓, composing/paused sent, contact
request banner and accept. `agent@0ff.space` added from the web client
and a contact added in Monal both show up on the other side — Prosody
now stores a roster for `lukas@0ff.space`. Fixed before the live test:
loading older messages jumped back to the bottom (checked with 150
messages); logout now asks first.
- Not tested live: an incoming "typing…" (the guest typed before login).
- No UI to remove a contact yet (milestone 6).
- **2026-09-28, milestone 4 done:** bookmarks (XEP-0402), autojoin,
room MAM catch-up, instant-room creation (status 201), nick handling,
mentions, room PMs as their own conversations, and — found on the way
— handling of resumed streams (xmpp.js emits no "online" on an
XEP-0198 resume, so the UI stayed on "Reconnecting…" after a blip;
fixed in `9946cb8`). Verified on the server for `testom@muc.0ff.space`:
bookmark stored with autojoin; a message from the web client; a second
one after closing the tab and logging in again (auto-rejoin); "Text
from Monal" from the same bookmark in Monal.
- Not tested live (covered by parser tests and a sample-data render):
messages from other occupants, mentions, room PMs — new rooms are
members-only, so an anonymous test guest can't join.
- Known gaps: a room PM's typing state is keyed by the room; ticks on
room PMs stop at ✓ (receipts looked up under the room JID); system
messages from the bare room JID show the JID as nick; no leave-room
or remove-bookmark action; bookmarks without autojoin aren't listed.
- Finding, not a client bug: **no persistent rooms were left on
`muc.0ff.space`** (no `config/`, empty `persistent.dat`; only
`muc_log` survived). `lobby@muc` (plaza's guest lobby) and
`update@muc` (Forgejo notifications) don't exist, and no bookmarks
were stored server-side. Same probable cause as the roster. Decided
2026-09-28: leave the lost rooms for now.
- **2026-09-28, milestone 5 done:** HTTP upload (XEP-0363) with disco,
progress and cancel; attach, paste, drag and drop; voice messages;
inline images/audio/video/file links. Verified on the server
(metadata only): a PNG, a 4.1 MB PDF, a 5.1 MB JPEG and a voice message
recorded by the desktop browser as **AAC in MP4** (`audio/mp4`, 54 KB)
uploaded from the web client; a photo from Monal shows inline in the
web client; the voice message plays in Monal on iOS. First try failed
("no slot": the reply's `<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.