Chat & Real-Time Messaging β
TuneCamp includes an integrated real-time community chat lobby and end-to-end encrypted (E2EE) direct messaging (DM) system. It enables fans, artists, and instance members to communicate directly within the web app or via desktop/daemon clients like Sidecamp.
1. Core Architecture β
The chat system is built around a lightweight WebSocket transport and local SQLite persistence, with optional client-side end-to-end encryption for 1-on-1 private conversations.
- WebSocket Transport: Connections are handled via
/ws/chat(separate from the/ws/peerP2P sharing transport). - Backend Service: Managed by
ChatService(src/server/modules/chat/chat.service.ts). - Database Storage:
chat_messages: Stores public lobby messages with persistent history.chat_rooms: Named multi-user rooms. Besides the localid(a per-instanceAUTOINCREMENT) each room carries aglobal_idUUID minted once by the instance that created it β that is the only identifier valid across instances.chat_room_members: Room membership, keyed by(room_id, username).chat_room_messages: Room backlog (plaintext; rooms are not E2EE, by decision β see Rooms).peer_chat_bans: Persistent IP / user bans for chat moderation.peer_chat_mutes: Persistent user mute list.
- Client Library: Packaged separately as
@tunecamp/chat(tunecamp-chat), which provides theTuneCampChatClientclass and theuseTuneCampChatReact hook.
2. Modes of Operation β
Community Lobby Chat β
- Public & Persistent: Broadcast to all connected users in the instance lobby.
- History Backlog: Automatically hydrated on connect via
GET /api/chat/history. - Domain Labels: Automatically appends instance origin labels to user nicknames across federated/multi-instance environments (e.g.
artist (sudorecords)).
End-to-End Encrypted (E2EE) Direct Messages β
- 1-on-1 Private DMs: Direct messages between two users are encrypted on the client side using Zen SEA β an elliptic-curve identity (secp256k1). Messages are encrypted with an ECDH-derived shared secret (
Zen.secret+Zen.encrypt/Zen.decrypt). - The DM key is the account's Zen identity, the same
zen_pubFID uses for cross-instance SSO β not a chat-only keypair. That is what makes a fetched public key checkable: it belongs to the account, not to whichever socket happens to be connected. - Random pair, password-sealed vault: the pair is generated randomly and then encrypted client-side under the user's password (
encryptPairVault) and uploaded toPOST /api/auth/zen/keysaszen_priv. It is not derived from the password β a derived pair would silently become a different identity on every password change. The server stores the vault opaquely and cannot open it. - The vault password is stretched before it reaches the cipher: PBKDF2-HMAC-SHA256, 600 000 iterations, 16-byte random salt.
Zen.encryptalone derives its AES key with a single SHA-256, which puts an offline attacker holding a database dump within billions of guesses per second of every user's identity. Format istcv1:<iterations>:<saltHex>:<zenBlob>, so the cost can be raised later without invalidating existing vaults; a blob declaring fewer than 100 000 iterations is refused, since otherwise the server could pick a cost it can brute-force. Vaults predating the envelope still open (isLegacyPairVault) and are re-sealed at the next login, which is the only moment the client holds the password. - Provisioning: on register, and on password login for an account that has no identity yet, the webapp mints a pair and uploads the vault. If the account already has a
zen_pubbut no vault (identity bound from the FID portal, private half never uploaded), the client does not mint a second pair β it degrades to no E2EE rather than forking the account into two identities. - Password changes must re-seal: the vault is encrypted with the old password until re-wrapped, so every password-change path calls
resealChatIdentity(newPassword)(useAuthStore.ts). Skipping it locks the user out of their own identity and of every DM addressed to it.POST /api/auth/zen/set(rebinding to a different identity) nullszen_privfor the same reason: a stale vault would pair a new public key with a non-matching private key. - Zero-Trust Server Relay: The TuneCamp server only acts as a public key and opaque ciphertext relay. It never sees plaintext DM content.
- Key source is reported and downgrade-resistant:
GET /api/chat/pubkey/:usernamereturnssource: "identity"when the key came from the account andsource: "session"when it came only from a live socket announcement. The client (@tunecamp/chat) remembers which it got and will not let a later WebSocket-announced session key overwrite an already-resolved identity key. - Fingerprint pinning (TOFU): the server chooses which key it hands out, so possessing a key proves nothing about whose it is. The client pins
SHA-256(pub)truncated to 128 bits the first time it sees a peer (keyFingerprint, persisted per peer id) and refuses any later key that hashes differently β the old key stays in force, the new one is quarantined, andonKeyChangefires. OnlyacceptPeerKeyChange(peerId), driven by an explicit user action after comparing fingerprints out of band, re-pins. A peer whose key legitimately rotated is indistinguishable from a wiretap without that comparison. - A DM is never sent in the clear: if there is no usable key for the recipient β none published yet, or one quarantined by the pin check β
sendMessagerefuses and says so, instead of falling back to plaintext the sender has no way to notice. Withholding a key is something the server can do at will, so plaintext fallback would be a downgrade it fully controls. - Keypair persistence: The opened pair is cached per-username in
localStorage(useAuthStore.ts) so it survives page reloads without the password, which is not kept in memory.
Rooms β
Named multi-user conversations, separate from the single global lobby. Membership is keyed by username, not by socket: a user who joins from the webapp is still a member from their Sidecamp daemon and across reconnects.
Managed over REST (
/api/chat/rooms*, all behindauthMiddleware.requireUser) and used over WebSocket (room_join,room_leave,room_chat). The acting user is always taken from the authenticated session β never from a query parameter.Deletion is creator-only; private rooms (
is_private) are visible to members only.Not E2EE, by decision rather than by omission. Room messages are stored and relayed in plaintext, unlike DMs. Do not use a room for anything that needs the DM threat model.
A room is a moderated space: an instance admin can clear its backlog, a moderator acts on what was said, and the backlog is served to whoever joins later β including members who were not present. All of that requires the server to read the messages. Group E2EE would also have to answer who holds the key, how it reaches a member who joins a year late, and what happens to the backlog when someone is removed, which is a key-management problem rather than a cipher problem. The two are in genuine tension, and resolving it is not release-sized work. Rooms are therefore plaintext on purpose, and documented as such, so nobody mistakes them for private. DMs remain end-to-end encrypted and unmoderated; that is the trade, and it is deliberate on both sides.
Federated Chat (Cross-Instance) β
- Lobby relay: Public lobby messages are broadcast to every known federated peer instance and injected into their local lobby, tagged with the sender's origin instance.
- Cross-instance DMs: Sending to
username@instanceresolves the target instance viafederatedDiscoveryService.resolvePeerByInstance()and delivers the message to that single peer only (not broadcast). - Federated room messages: A public room's messages fan out to every peer, addressed by the room's
global_id(never by the localid, which means a different room on every instance). A peer that does not know thatglobal_iddrops the message instead of guessing. Private rooms are never federated: membership is not federated yet, so no peer could enforce who may read them. - Transport & auth: Federated instances relay over
POST /api/chat/federated/inbound, authenticated with anX-Chat-Signatureheader over the JSON encoding of[username, instance, text, ts, lobby, toUsername, roomGlobalId, roomName]. The fields are JSON-encoded rather than joined on a separator so that a separator character inside the attacker-controlledtextcannot produce the same signing input as a different message. A sender signs with its own site actor key β RSA-SHA256 undersite_private_key, the same keypair its ActivityPub actor publishes. The receiver resolves the claimed instance's public key through NodeInfometadata.actorIdβ the actor'spublicKey.publicKeyPem, and caches it inremote_actors. The endpoint fails closed with503when no localsite_public_keyis configured, and returns401on a bad signature. - Signatures are asymmetric only: verification uses the claimed peer's published key and nothing else. There is no shared-secret fallback β a message whose sender cannot be pinned to one host is refused rather than half-trusted. One operational consequence: if a peer's key cannot be fetched (its NodeInfo or actor endpoint is briefly unreachable) its first message is refused with
401; once fetched the key is cached and reused, so this does not repeat. - Key resolution prefers the peer's own origin: when a peer's NodeInfo advertises an
actorIdon a different host β usually apublicUrlmisconfiguration β the same path is tried on the peer's own origin first, and only then the advertised URI. This keeps the key trusted for an instance coming from that instance, and stops a misconfigured peer from looking keyless. - Freshness window: a signature never expires on its own, so
tsmust be within 5 minutes in the past and 1 minute in the future (clock skew) or the message is refused with401. Without it a captured message would stay replayable forever once it aged out of the dedup window. - Known-peer check: the claimed
instancemust resolve to a peer already in federated discovery, otherwise403. The peer list is refreshed fromfederatedDiscoveryServiceon every inbound request, so a receiver that has never sent anything still knows who it federates with β but an instance that has not yet discovered the sender will reject it. Note this check runs after the signature check, so an instance outside the peer list is stopped at401(no origin to resolve a key from) rather than reaching the403. - Trust model β read this before deploying: a signature pins a message to one host, always. Every instance generates a site keypair at boot and publishes it on its site actor, so there is no keyless case left to accommodate.
TUNECAMP_CHAT_FEDERATION_SECRETis gone: it stopped authenticating anything on receipt in 5.2.0, and the code that still read it has been removed. Setting it now does nothing. - Cross-instance chat requires peers on 5.2.0 or later: an instance on an older release still accepts shared-secret signatures, and one on a release before 5.1.0 signs with the secret while already publishing a site actor key, so its messages are refused by an updated receiver. Upgrade both sides before relying on cross-instance chat. A peer whose
publicUrlpoints at a host that does not serve its actor was previously masked by the shared-secret path; it is now visible as a401, and its operator should fixpublicUrl. - Dedup: Inbound messages are deduplicated by a content hash of the signed fields, held in process for 6 minutes (the freshness window plus the skew allowance, so an entry can never expire while the message is still fresh enough to re-enter). The
ida sender puts in the body is ignored and recomputed locally β it is not covered by the MAC, so honouring it would let a peer choose the dedup key and pre-seed it to suppress a later message. No durable replay store: the map is lost on restart. - Delivery & retry: outbound fan-out attempts each peer once, then retries a transient failure β a network error, a
5xx, or a429β after 2s, 8s and 30s. A4xxother than429is not retried: the peer refused the message on its merits and resending the same bytes cannot change the answer. Every retry is additionally bounded by the receiver's freshness window: a retry carries the original signedts, so once the message is older than 5 minutes no delay could still get it accepted, and it is abandoned with a logged warning. This is also why there is no durable queue for chat, unlike ActivityPub delivery (ap_delivery_queue): anything that survived a restart would already be too stale to deliver. A peer that is down for more than ~40 seconds loses the message, by design. - DM ciphertext stays E2EE end-to-end: federation only relays the already-encrypted DM payload between servers β plaintext still never touches any instance.
What the server stores β
The relevant privacy question for a server-routed protocol is what survives on disk, so, explicitly:
- Direct messages are never persisted β not locally, not on receipt from a peer.
relayChatwrites topeer_chat_messagesonly when the message is a lobby broadcast, andrelayFederatedMessageonly for lobby or room traffic. A DM exists as ciphertext in flight and in the recipient's client, nowhere else. There is no table recording who messaged whom, and no DM metadata is logged. - Lobby history lives in
peer_chat_messages, trimmed to the most recent 500 rows on every insert. Lobby traffic is public by definition. - Room messages live in
chat_room_messages, addressed across instances bychat_rooms.global_id. Private rooms are never federated. - In flight, a peer necessarily sees the routing envelope β
username,instance,toUsername,tsβ because that is what tells it where to deliver. It is not stored. Removing it would mean changing the routing model, not the storage model.
This is the honest limit of the design: message content is end-to-end encrypted and unlogged, while routing metadata is visible to the two servers on the path for as long as it takes to route.
Why federated, not peer-to-peer β
The chat protocol is server-to-server, like Matrix or email β not peer-to-peer like Soulseek or eMule. Clients hold a WebSocket to their own instance; instances POST to each other. This is a deliberate choice, and it is settled:
- Offline delivery needs store-and-forward, which reintroduces a server. A P2P design would need relays to hold messages, at which point the relay is the server.
- Browser and mobile clients cannot hold long-lived peer connections β NAT, background execution limits, battery. WebRTC would need signaling plus TURN, and TURN relays the traffic anyway.
- Content confidentiality is already handled by end-to-end encryption, so P2P would buy metadata privacy only.
- Moderation depends on instance operators being able to block a peer. A P2P mesh removes that lever entirely.
Note that "move chat onto a P2P graph" is not an option here either: the old ZEN P2P graph was removed and must not be reintroduced (see the ZEN notes in the repo's CLAUDE.md).
Known limits β
Everything above describes what the design does. This is what it does not do, collected in one place so nobody has to infer it from an absence:
- No forward secrecy. A DM is encrypted under a secret derived from the two long-term identity keys, and there is no ratchet: the same secret protects the first message and the thousandth. Whoever obtains one private key can read every DM to or from that identity that anyone archived β past messages included. This is the most consequential limit listed here, and it is the one a user is least likely to guess.
- A private key is only as strong as the password behind it. The identity is derived from, or sealed under, the user's password, and the sealed vault lives on the server. Whoever holds the vault β the instance does β can attack it offline, with no rate limit and no account to lock. PBKDF2 at 600 000 iterations raises the cost per guess; it does not save a weak password.
- Rooms and the lobby are plaintext. Not an oversight: see Rooms for why moderation and late-joiner history require it. Do not put anything in a room that needs the DM threat model.
- Routing metadata is visible to the servers on the path. Who messaged whom, and when, is what tells an instance where to deliver. It is not stored, but it is seen. See What the server stores.
- Key pinning is trust-on-first-use. The first key seen for a peer is pinned and a later substitution is refused, which catches a server that changes its answer. It cannot catch one that lied the first time, before the user had a genuine key to compare against. Fingerprints are meant to be checked out of band.
- The client is served by the instance it talks to. The webapp is a bundle the instance hands you, so whoever controls the instance controls the code that handles the keys. End-to-end encryption bounds what a passive server learns; it does not bind a server that chooses to ship different JavaScript. A daemon client such as Sidecamp, installed once from its own release, narrows this β it does not eliminate it.
- Federated messages have no offline delivery. A peer unreachable for more than ~40 seconds loses the message; there is no durable queue, for the reason given under Federated Chat.
3. IRC-Style Commands & Moderation β
TuneCamp chat supports native slash commands for user interaction and administration:
| Command | Permission | Description |
|---|---|---|
/help | Everyone | Lists all available chat commands. |
/clear | Everyone | Clears the local chat viewport history. |
/kick <user> | Admin / Owner | Disconnects the specified user from the chat session. |
/ban <user> | Admin / Owner | Ban a user from joining the chat lobby (persisted in DB). |
/unban <user> | Admin / Owner | Removes a user ban. |
/mute <user> | Admin / Owner | Mutes a user, preventing them from sending lobby messages. |
/unmute <user> | Admin / Owner | Unmutes a user. |
4. Administration & Configuration β
Instance administrators can control chat behavior from the Admin Dashboard or environment configuration:
peerChatEnabled(boolean): Master toggle to enable or disable the chat service across the instance.peerChatGuestEnabled(boolean): Allows unauthenticated guests to view and participate in the public lobby with generated guest handles.TUNECAMP_CHAT_FEDERATION_SECRET(env var): Removed. Signing and verification both use the instance's own RSA site key. The variable is no longer read; you can delete it from your environment. Federated relay is disabled (/inboundreturns503) when there is no localsite_public_key, and outbound fan-out is skipped with a logged error when there is nosite_private_keyβ see Federated Chat.
5. API Reference β
REST Endpoints β
GET /api/chat/history: Retrieves recent lobby message history.GET /api/chat/peers: Returns the roster of currently active chat participants.GET /api/chat/pubkey/:username?instance=: Returns{ pubkey, source }for a user's Zen SEA public key. Prefers the account's stored identity (source: "identity", answers even while the user is offline), falls back to a live session's announced key (source: "session"), then to resolving a remote instance's peer and proxying the request.404when the user has neither.
Room Endpoints β
All of them require a session (/api/chat is mounted behind authMiddleware.requireUser) and act as the authenticated user.
GET /api/chat/rooms: Lists rooms, each with itsidandglobalId.POST /api/chat/rooms: Creates a room (name,description,is_private); returns{ id, globalId, name }.DELETE /api/chat/rooms/:id: Deletes a room. Creator only.POST /api/chat/rooms/:id/join//leave: Adds or removes the caller's membership.GET /api/chat/rooms/:id/messages?limit=: Room backlog (capped at 500).GET /api/chat/rooms/:id/members: Room member list.
Federation Endpoints β
POST /api/chat/federated/inbound: Accepts a signed message relay from a federated peer (see Federated Chat above). Known peers are listed byGET /api/community/peers. Responses:202accepted,409duplicate,400missing fields,401missing/bad signature or a stale/future-datedts,403unknown peer instance,415non-JSON body,503federation not configured (no site actor key). An instance outside the peer list is refused at401, since no key can be resolved for it.
WebSocket /ws/chat Events β
chat:message: Outgoing/incoming lobby or DM payloads.chat:peers: Roster update events on peer join/leave.chat:ban/chat:mute: Moderation signal events dispatched by admins.room_join/room_leave/room_chat: Room subscription and room messages, addressed by the localroomId. Aroom_chatthat arrived from a federated peer also carriesroomGlobalId.
6. Client Integration (@tunecamp/chat) β
To integrate TuneCamp chat into custom frontends or React applications:
import { useTuneCampChat } from '@tunecamp/chat';
function ChatComponent() {
const { messages, peers, sendMessage, formatUser } = useTuneCampChat({
serverUrl: 'https://sudorecords.scobrudot.dev',
token: 'USER_JWT_TOKEN'
});
return (
<div>
{messages.map((msg, i) => (
<div key={i}>
<strong>{formatUser(msg.from, msg.instance)}</strong>: {msg.text}
</div>
))}
<button onClick={() => sendMessage('', 'Hello Lobby!')}>Send</button>
</div>
);
}