Files
Toju/agents-docs/features/messaging.md
brogeby 47beed01ca docs: add cross-context feature docs for auth, presence, access-control, messaging, attachments
Fills the five highest-value gaps under agents-docs/features/ so the index covers
the system's main cross-context contracts. Each doc follows the feature-template
structure and the AGENTS_FEATURES.md contract, with honest TODOs where coverage
or behavior couldn't be confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 22:33:41 +02:00

196 lines
12 KiB
Markdown

# Messaging
> **Area:** messaging
> **Status:** Active
> **Last updated:** 2026-05-25
## Overview
Messaging in Toju covers two transports that share a single domain model. Server-channel chat is broadcast by the signaling server over WebSocket — fire-and-forget, no server-side persistence today. Direct messages (1:1 and group DMs) are peer-to-peer over the WebRTC chat data channel, with a signaling-server fallback when no data channel is open and an offline queue for when neither path is available. On both transports the client maintains a **monotonic delivery state machine** per message and a **chunked inventory-sync protocol** that lets two peers reconcile missing history without flooding the link.
This document is the cross-context contract: envelope names, sync protocol, delivery states, edit/delete rules, and storage decisions. The product-client domain READMEs at `toju-app/src/app/domains/chat/README.md` and `toju-app/src/app/domains/direct-message/README.md` cover internal NgRx state and effects; the wire shapes live in [websocket-envelopes](./websocket-envelopes.md).
## Responsibilities
- Send, edit, and delete server-channel chat messages over WebSocket (`chat_message`, `edit_message`, `delete_message`).
- Send, edit, and delete direct messages over the WebRTC data channel with signaling fallback.
- Carry typing indicators on server-channel chat (`user_typing`).
- Reconcile peer history via the inventory-sync protocol (chunked, capped backfill).
- Drive a monotonic delivery state machine: `QUEUED → SENT → DELIVERED → ACKNOWLEDGED`.
- Persist direct messages locally; the server keeps no message store.
This area does **not** own:
- Attachment payloads or the chunked file-transfer protocol → [attachments](./attachments.md).
- RTC negotiation that brings the data channel up → [voice-signaling](./voice-signaling.md).
- Permission to send a message (`writeMessages`, `manageMessages`, channel overrides, slowmode hint) → [access-control](./access-control.md).
- The wire shape of every envelope used here → [websocket-envelopes](./websocket-envelopes.md).
## Key concepts
- **Server-channel message** — broadcast through the signaling server to every connected peer of a server. No server-side persistence in the current build.
- **Direct message** — point-to-point or group P2P message, persisted locally per-user via Electron CQRS (and via `localStorage` on browser fallback today — TODO confirm).
- **Conversation** — 1:1 or group DM thread. Group DMs can be created by adding a third participant to a 1:1, which spawns a new conversation while preserving the original.
- **Inventory event** — peer-to-peer announcement of "here is what I have for this conversation/channel"; the receiver replies with a request for missing pieces.
- **Sync batch** — chunked response carrying up to 200 messages per envelope, capped at 1000 messages of backfill per inventory exchange.
- **Delivery state** — monotonic enum on a direct message: `QUEUED (0) → SENT (1) → DELIVERED (2) → ACKNOWLEDGED (3)`. Defined in the chat-events shared kernel; advanced via `advanceDirectMessageStatus`.
- **Peer-delivery service** — `PeerDeliveryService` (`toju-app/src/app/domains/direct-message/application/services/peer-delivery.service.ts`) — the dispatcher that tries the data channel first, then the signaling forward, then the offline queue.
---
## Transports
### Server-channel chat (WebSocket)
- Client sends `chat_message` to the signaling server; `handleChatMessage` (`server/src/websocket/handler.ts:274`) validates the user is in the target server and broadcasts to every other connection.
- `edit_message` and `delete_message` follow the same fan-out path.
- `user_typing` (`handleTyping`, `handler.ts:309`) is broadcast as a transient signal — no persistence, no sync, no delivery state.
- The server **does not persist** these envelopes. Late joiners do not see chat history older than their join — they can request it from peers via the inventory protocol if any peer present has it stored locally.
### Direct messages (WebRTC data channel)
- DMs ride the `chat`-labelled data channel established alongside each voice peer connection (see [voice-signaling](./voice-signaling.md)).
- `PeerDeliveryService` is the dispatcher. For each outgoing event it:
1. Tries every open data channel to a `recipients`-listed peer.
2. If no data channel is available to a recipient, falls back to a signaling-server `forwardPeerMessage` envelope so the server forwards it to that peer's connection.
3. If neither path is open, enqueues the event in `OfflineMessageQueueService` and replays on `peerConnected$` / `networkRestored$`.
- The server **forwards** DM envelopes opaquely — no inspection, no persistence.
### Storage
- **Direct messages** persist via Electron CQRS — see [ipc-bridge](./ipc-bridge.md). Each user has their own local TypeORM database; messages are written via `save-direct-message` / equivalent commands.
- **Server-channel chat** persists via `DatabaseService` (Electron CQRS) when running on desktop, or in IndexedDB when running purely in browser. TODO: confirm the IndexedDB code path.
- The signaling server holds **zero** message bytes at rest today. Re-deploys lose nothing because there is nothing to lose.
---
## Inventory / sync protocol
Both transports share the same inventory shape, defined in `toju-app/src/app/shared-kernel/chat-events.ts`:
- `ChatInventoryEvent` — sender broadcasts "for conversation X I have messages with these ids and last-modified timestamps" (capped at 1000 entries).
- `ChatSyncBatchEvent` — receiver replies with a chunked batch of full message payloads, **up to 200 per envelope**, repeated until the requested set is satisfied or 1000 messages have been returned.
Rules:
- Inventory is **additive**`mergeIncomingMessage` / `upsertDirectMessage` only insert or update; a sparser peer never wipes a richer peer's history.
- Reactions and attachment-link changes are reconciled by comparing per-message `lastModifiedAt`; the higher wins.
- The 1000-message ceiling is per inventory exchange, not per conversation lifetime; an older history can be filled in piecewise across multiple inventory cycles.
The same protocol is reused for the chat domain (server channels) and the direct-message domain. The implementation lives in `toju-app/src/app/domains/chat/domain/rules/message-sync.rules.ts` and is invoked from the direct-message effects via `DirectMessageService.requestSync()`.
---
## Delivery state machine
For DMs, every outgoing message has a `status`:
| Value | Numeric | Meaning |
|-------|---------|---------|
| `QUEUED` | 0 | Composed locally; no transport attempt has succeeded yet. |
| `SENT` | 1 | At least one transport (data channel or signaling forward) has accepted the payload. |
| `DELIVERED` | 2 | At least one recipient has acknowledged receipt at the application layer. |
| `ACKNOWLEDGED` | 3 | The full recipient set has acknowledged (1:1: the one recipient; group: every participant). |
Transitions are advanced via `advanceDirectMessageStatus`, which **only advances** — a higher value is never replaced by a lower one. A retried message that succeeds after a queue replay can therefore move `QUEUED → SENT` but never `DELIVERED → SENT`.
Server-channel messages do not carry an application-level delivery state today (the server broadcast is fire-and-forget); the UI treats them as `SENT` once the WebSocket accepts the frame.
---
## Edit and delete
- `edit-message` / `delete-message` events carry the original `messageId`. On both transports, the receiver locates the existing row (by id) and applies the mutation via `applyMutation`.
- DMs use the same envelope types but ride the data channel / signaling-forward fabric.
- Edits are last-writer-wins by `editedAt`. A delete removes the message body but keeps a tombstone with `deletedAt` so peers that haven't yet seen the delete can converge on the next inventory sync.
TODO: `applyMutation` does not currently verify the mutation originated from the original author. A non-cooperating client could send `edit-message` for someone else's message and a receiver would accept it. Confirm and either harden client-side or document the trust model.
---
## Typing indicators
- Server-channel only. DMs do not have a typing indicator today.
- Sent as `user_typing`; broadcast to a server scope.
- Transient: no persistence, no sync replay, no delivery state.
---
## Business rules and invariants
- The signaling server is **not authoritative** for messaging. `handleChatMessage` only broadcasts; there is no server-side message log.
- The delivery state machine is **monotonic**`advanceDirectMessageStatus` never moves status backwards.
- DM envelopes are **ignored** unless the local user appears in `participants` / `recipients` (or has an existing local conversation matching the id).
- Inventory merges are **additive**`mergeIncomingMessage` / `upsertDirectMessage` never delete or downgrade a richer local row.
- A 1:1 → group upgrade **preserves** the original 1:1 history; the group is a new conversation.
- Edits / deletes are reconciled by `lastModifiedAt` / `editedAt` / `deletedAt`.
---
## Technical implementation
### Server
- WS handlers — `server/src/websocket/handler.ts`: `handleChatMessage` (line 274), `handleTyping` (309); DM forwarding via `forwardPeerMessage` / `forwardRtcMessage`.
- No CQRS, no entities, no migrations: server messaging is broadcast-only.
### Product client
- Chat domain — `toju-app/src/app/domains/chat/`: services, effects, sync rules at `domain/rules/message-sync.rules.ts`.
- Direct-message domain — `toju-app/src/app/domains/direct-message/`: `DirectMessageService`, `PeerDeliveryService` (`application/services/peer-delivery.service.ts`), offline queue.
- Shared kernel — `toju-app/src/app/shared-kernel/chat-events.ts` (`ChatInventoryEvent`, `ChatSyncBatchEvent`, `chat_message`, `edit-message`, `delete-message`, `direct-message-sync`, `direct-message-sync-request`).
- Persistence — Electron CQRS commands for DMs (see [ipc-bridge](./ipc-bridge.md)); `DatabaseService` for server-channel chat.
### Electron
- DM persistence — TypeORM entity (likely `MessageEntity` and a DM-specific row) + CQRS handlers. Backup / restore is part of the Electron data-management surface.
---
## Testing
- TODO: chat domain has zero `*.spec.ts` files at time of writing.
- TODO: no dedicated server-side spec for `handleChatMessage`, `handleTyping`, or `forwardRtcMessage`.
- TODO: confirm specs for `PeerDeliveryService` and `OfflineMessageQueueService`.
- E2E: `e2e/tests/chat/chat-message-features.spec.ts` covers happy-path chat and attachment sync between users.
---
## Performance considerations
- Inventory batch cap: **200 messages per envelope**.
- Inventory backfill cap: **1000 messages per inventory exchange**.
- `chat_message` broadcast is O(N) over connected peers of the server; no fan-out batching.
- DMs incur O(recipients) data-channel writes (or signaling forwards) per send; large group DMs amplify per-message cost linearly.
---
## Security considerations
- **No end-to-end encryption.** P2P traffic over the data channel is DTLS-encrypted by WebRTC; signaling-forwarded fallback is plain WebSocket; either way the local TypeORM database stores plaintext.
- **`applyMutation` does not verify authorship** on incoming `edit-message` / `delete-message` events. TODO above.
- **No server-side rate limiting** on `chat_message`. A non-cooperating client can flood a server's broadcast.
---
## Known issues and limitations
- **No server-side chat history.** Late joiners depend on peers having local history to replay via inventory sync.
- **No spec coverage** for the chat domain.
- **DM authorship is not verified** by `applyMutation`.
- **No DM typing indicator.**
- **`OfflineMessageQueueService` retry policy** is currently driven by `peerConnected$` / `networkRestored$` events only — there is no scheduled retry; a stuck queue requires one of those events to fire. TODO: confirm behavior across reconnects.
---
## Related features
- **[websocket-envelopes](./websocket-envelopes.md)** — owns the wire shape of every envelope here.
- **[attachments](./attachments.md)** — file payloads ride alongside chat events on the data channel.
- **[voice-signaling](./voice-signaling.md)** — establishes the data channel DMs ride on.
- **[ipc-bridge](./ipc-bridge.md)** — exposes the CQRS persistence DMs and server chat use.
- **[access-control](./access-control.md)** — gates write permissions and slowmode.
## Changelog
| Date | Change |
|------|--------|
| 2026-05-25 | Initial documentation |