Portal’s matchmaker is an anonymous 1-on-1 pairing service: users enqueue on one or more interest tags, the system finds a peer who shares at least one tag, and a short-lived direct chat is created for the pair.Documentation Index
Fetch the complete documentation index at: https://docs.7331.org/llms.txt
Use this file to discover all available pages before exploring further.
State machine
A user’s matchmaking state is tracked server-side via a per-user Redis sorted set (score = expiry timestamp). A user can hold multiple concurrent active DIRECT chats (up to the slot limit):| State | Meaning |
|---|---|
idle | Not in the queue, no active direct chats |
searching | In one or more interest queues; waiting for a peer |
matched | Has at least one active direct chat |
Slot limits
Each user has a maximum number of concurrent active DIRECT chats:| Plan | Concurrent DIRECT slots |
|---|---|
| Free | 1 |
| Premium | 3 |
ERR_MATCH_SLOT_LIMIT. The slot count is derived from the per-user active ZSET so expired chats never inflate the count.
Queue lifecycle
Enqueue (POST /v1/portal/match):
- Rejects if the user is already
searchingor at the slot limit (409). - Writes the user to all listed interest queues with a TTL (10 minutes, kept alive by socket presence).
- Returns
searchingso the frontend starts polling.
interests body field accepts 1–3 tags. Each tag must be 3–12 lowercase alphanumeric characters (^[a-z0-9]{3,12}$).
Status polling (GET /v1/portal/match/status):
Pure read — no side effects on the queue TTL. The queue entry TTL (10 minutes) is kept alive by socket presence events. Dead clients are reaped when their socket disconnects and the short grace window (MATCH_DISCONNECT_GRACE) elapses.
Dequeue (POST /v1/portal/match/dequeue):
Idempotent. Removes the user from every interest queue. Does not affect active direct chats.
Pairing
When two compatible users share at least one interest queue:- A direct chat is created (owner
NULL,chat_type=DIRECT) - Both users are socket-joined to the chat
- Both users’ active ZSET entries are updated to reflect the new chat
- An E2EE chat key is generated and fanned out (see E2EE)
- A system message is persisted:
"Chat started — say hi to {peer}! Matched on: {tag1}, {tag2}"— the shared tags are listed so both parties know the common ground
match:found socket event is emitted to both parties with peer info. For stranger matches, the peer object includes a country_code field (ISO 3166-1 alpha-2) when geo data is available — e.g. "NL". It is null when the IP could not be resolved.
Stranger moderation
Strangers are identified by a signature derived from their anonymous session token. When a match is created the server writes this signature, along with the stranger’s IP and geo data, into the chat’s internal record. Moderators can use this to act on reports:- Look up the reported chat to retrieve the stranger’s signature.
- Add the signature to the blocklist.
- The stranger’s next enqueue attempt returns 403
ERR_STRANGER_BLOCKED.
Leaving
Two leave paths, and the difference matters for UX: Explicit leave (POST /v1/portal/match/leave):
- Persists a
"{username} left the chat"system message - Emits
room:peer_leftwithgrace_until_ms=0 - Archives the direct chat immediately (24h read-only window opens)
- Peer routes straight to the “match ended” screen — no countdown
- Arms a 60s grace timer via the
disconnecthandler - Peer sees
room:peer_leftwithgrace_until_msset to now+60s and a countdown - If the user reconnects within 60s, the timer clears
- If it expires, the chat is archived (not destroyed) and the peer flips back to
idle
Archived chats
After the grace timer expires (or on explicit leave), the direct chat enters a 24-hour read-only archive rather than being deleted outright:- The chat hash and message stream are preserved with a 24h TTL
- The chat is moved from the user’s active ZSET to their ended ZSET
GET /v1/portal/chatsincludes archived chats witharchived: true- The socket
chat:listevent includes them on reconnect - The sidebar renders archived chats in a dimmed read-only state — messages are visible but no new messages can be sent
- After 24h the TTLs expire and the entries are pruned automatically
Frontend drift recovery
If a client thinks it’smatched but the server has already archived the chat (peer bailed, grace expired while the tab was backgrounded), calling match/leave returns 404 ERR_MATCH_NO_ACTIVE_DIRECT. The frontend useMatch.leave hook treats 404 as a force-reset: dismiss the UI, toast “Not in an active chat”, and re-render as idle.
This is intentional — the server is authoritative and the client syncs to it, never the other way around.