
The problem
Real time changes what a bug costs. A feed that renders a post to someone who blocked its author is a privacy failure, not a display glitch, and with more than a dozen read paths returning other people’s content, enforcing that rule at each call site means one new endpoint is all it takes to leak. Delivery has the same property: a message that reaches the wrong room is not visibly broken to the person who sent it.
Decisions
One HTTP server, one auth path
Express and the socket layer share a single server, and the socket handshake reuses the token verification the HTTP middleware already uses instead of adding a second one. A suspended account has to lose both at once, which is only guaranteed when one place decides. Rooms are joined server-side after membership is checked, and a client-supplied room id is never trusted.
One visibility gate, composed by every read path
Block, mute and private-account rules live in a single service that every endpoint returning someone else’s content composes. Enforced ad hoc at each call site, they are a privacy bug waiting for the next feature. Enforced in one place, an endpoint that forgets them shows up in review.
Every list pages by cursor
Skip/limit shifts under insertion, which on a live feed means a post arriving between two requests either duplicates an item or hides one. Explore is the documented exception, because its sort key is computed at read time.
Read receipts carry three states
Delivery and reading are separate fields: shape carries delivery (one tick against two), colour carries reading. With only a read timestamp, “they are ignoring me” and “it never arrived” render identically. Delivery is decided at write time from presence and backfilled on reconnect, so a message sent to a closed tab does not sit on one tick forever.
The hard part
The bugs that only exist because it is live
None of these reproduce against a mocked socket, which is why the project ends with a test that drives the running API using two real socket clients, in a suite covering handshake rejection, live delivery, read receipts, the edit and delete rules, typing, notifications and room isolation.
`.except()` on a broadcast returns a new operator and does not mutate the one it was called on. Called for its side effect the exclusion is discarded and the message goes to everyone in the room, which is how typing indicators once echoed back to the person typing.
A unique index on an array field is multikey. Indexing the participants array to mean “one conversation per pair” in fact means “each user appears in one conversation, ever”, which the seed found the moment it created a second chat for anybody. A derived pair key enforces the real constraint.
And marking a message read has to backfill its delivery time in the same write. `$min` does not work, because BSON sorts null below every date and it therefore keeps the null; a plain `$set` would overwrite delivery times that were already correct. It takes a pipeline update with `$ifNull`. Without it a message can render as read while delivery is still unset, which is a state that cannot physically have happened.
await Message.updateMany(
{ conversation: convo._id, sender: { $ne: req.user._id }, readAt: null },
[{ $set: { readAt: now, deliveredAt: { $ifNull: ['$deliveredAt', now] } } }]
);