Skip to main content

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.

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.

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):
StateMeaning
idleNot in the queue, no active direct chats
searchingIn one or more interest queues; waiting for a peer
matchedHas at least one active direct chat
Transitions:
idle ──enqueue──▶ searching ──peer found──▶ matched
 ▲                    │                        │
 │                    └──dequeue/timeout──┐    │
 │                                        ▼    │
 │◀─── all slots empty ──── leave / grace expire ──▶ archived (24h)

Slot limits

Each user has a maximum number of concurrent active DIRECT chats:
PlanConcurrent DIRECT slots
Free1
Premium3
Attempting to enqueue when all slots are occupied returns 409 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):
  1. Rejects if the user is already searching or at the slot limit (409).
  2. Writes the user to all listed interest queues with a TTL (10 minutes, kept alive by socket presence).
  3. Returns searching so the frontend starts polling.
The 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:
  1. A direct chat is created (owner NULL, chat_type=DIRECT)
  2. Both users are socket-joined to the chat
  3. Both users’ active ZSET entries are updated to reflect the new chat
  4. An E2EE chat key is generated and fanned out (see E2EE)
  5. 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
A 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:
  1. Look up the reported chat to retrieve the stranger’s signature.
  2. Add the signature to the blocklist.
  3. The stranger’s next enqueue attempt returns 403 ERR_STRANGER_BLOCKED.
Strangers can rotate their identity by clearing the anonymous session cookie — each new session gets a fresh signature. The blocklist applies to the current session only.

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_left with grace_until_ms=0
  • Archives the direct chat immediately (24h read-only window opens)
  • Peer routes straight to the “match ended” screen — no countdown
Socket disconnect (window close, network drop):
  • Arms a 60s grace timer via the disconnect handler
  • Peer sees room:peer_left with grace_until_ms set 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/chats includes archived chats with archived: true
  • The socket chat:list event 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’s matched 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.