Skip to content

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/peer P2P 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 local id (a per-instance AUTOINCREMENT) each room carries a global_id UUID 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 the TuneCampChatClient class and the useTuneCampChat React 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_pub FID 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 to POST /api/auth/zen/keys as zen_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.encrypt alone 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 is tcv1:<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_pub but 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) nulls zen_priv for 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/:username returns source: "identity" when the key came from the account and source: "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, and onKeyChange fires. Only acceptPeerKeyChange(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 β€” sendMessage refuses 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 behind authMiddleware.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@instance resolves the target instance via federatedDiscoveryService.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 local id, which means a different room on every instance). A peer that does not know that global_id drops 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 an X-Chat-Signature header 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-controlled text cannot produce the same signing input as a different message. A sender signs with its own site actor key β€” RSA-SHA256 under site_private_key, the same keypair its ActivityPub actor publishes. The receiver resolves the claimed instance's public key through NodeInfo metadata.actorId β†’ the actor's publicKey.publicKeyPem, and caches it in remote_actors. The endpoint fails closed with 503 when no local site_public_key is configured, and returns 401 on 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 actorId on a different host β€” usually a publicUrl misconfiguration β€” 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 ts must be within 5 minutes in the past and 1 minute in the future (clock skew) or the message is refused with 401. Without it a captured message would stay replayable forever once it aged out of the dedup window.
  • Known-peer check: the claimed instance must resolve to a peer already in federated discovery, otherwise 403. The peer list is refreshed from federatedDiscoveryService on 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 at 401 (no origin to resolve a key from) rather than reaching the 403.
  • 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_SECRET is 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 publicUrl points at a host that does not serve its actor was previously masked by the shared-secret path; it is now visible as a 401, and its operator should fix publicUrl.
  • 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 id a 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 a 429 β€” after 2s, 8s and 30s. A 4xx other than 429 is 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 signed ts, 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. relayChat writes to peer_chat_messages only when the message is a lobby broadcast, and relayFederatedMessage only 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 by chat_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:

CommandPermissionDescription
/helpEveryoneLists all available chat commands.
/clearEveryoneClears the local chat viewport history.
/kick <user>Admin / OwnerDisconnects the specified user from the chat session.
/ban <user>Admin / OwnerBan a user from joining the chat lobby (persisted in DB).
/unban <user>Admin / OwnerRemoves a user ban.
/mute <user>Admin / OwnerMutes a user, preventing them from sending lobby messages.
/unmute <user>Admin / OwnerUnmutes 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 (/inbound returns 503) when there is no local site_public_key, and outbound fan-out is skipped with a logged error when there is no site_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. 404 when 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 its id and globalId.
  • 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 by GET /api/community/peers. Responses: 202 accepted, 409 duplicate, 400 missing fields, 401 missing/bad signature or a stale/future-dated ts, 403 unknown peer instance, 415 non-JSON body, 503 federation not configured (no site actor key). An instance outside the peer list is refused at 401, 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 local roomId. A room_chat that arrived from a federated peer also carries roomGlobalId.

6. Client Integration (@tunecamp/chat) ​

To integrate TuneCamp chat into custom frontends or React applications:

tsx
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>
  );
}

Released under the MIT License.