This policy describes processing that exists in the current server and web app. Signed-in users can download a JSON export and schedule account deletion from /settings/data. It does not claim counsel review, GDPR certification, or a staffed mailbox.
Draft placeholder. Entity: Operating entity not yet incorporated
Draft placeholder. Grievance email: grievance@example.invalid
Draft placeholder. First-reply SLA: 72 hours
Who is responsible
Draft controller: Operating entity not yet incorporated. There is no registered company, privacy officer, or real contact mailbox in this repository.
Until that is decided, use the draft grievance address grievance@example.invalid (see Contact). That address is a placeholder, not a working inbox.
What we do not log
Structured application logs are JSON. They are built to carry identifiers, not chat text. Message bodies, access tokens, and refresh tokens are not supposed to appear in logs. Protocol log helpers record text length, not the string.
What logs do contain
When a request has context attached, logs copy request_id, user_id, and session_id. HTTP requests also use an X-Request-Id header (generated or propagated; invalid values are rejected, not coerced).
Moderation verdict logs include user_id, session_id, action (allow, redact, block, flag, terminate), rule names, severity, and whether the message was redacted or should penalise trust. They do not include the message body.
Other logs record ids and actions such as session created or ended, message relayed (session_id, msg_id, client_msg_id, user_id), report ingested (ids and reason enum), block recorded, trust changes, rate-limit breaches, geo-block denials (country code), data export (user_id), deletion scheduled (user_id), account restricted (user_id), and retention purge counts (row counts, not bodies).
JWT cookies and tokens
Access tokens are JWTs with a 15-minute lifetime. Claims are sub (user id), exp, iat, anon, ver (token_version), and admin (server-set when sub equals ADMIN_USER_ID; never taken from the client). The client sends them as Authorization: Bearer. Bumping token_version (ban, logout, deletion request, or age-assurance restriction) makes old tokens fail verification.
On anonymous, register, and login, the API also sets a cookie named refresh: httpOnly, SameSite=Lax, Path=/, Max-Age 7 days, Secure when SECURE_COOKIES is on. There is no REST refresh endpoint in this version, so the cookie is issued but not consumed yet.
Redis session metadata
Redis holds short-lived operational state, not a chat archive. Keys and typical contents:
- Match queues: user ids waiting, scored by enqueue time
- u:conn:{userId}: which server instance holds the socket (TTL 60 seconds, heartbeat-refreshed)
- u:lock:{userId}: match lock (TTL 10 seconds)
- u:recent:{userId}: recent peer ids so you are not rematched for 30 minutes
- sess:{sessionId}: a best-effort cache of the history flag and last-activity timestamp (TTL 24 hours). It is not the source of truth after a restart
- Rate-limit buckets keyed by action and user id or IP
- ban:{userId}: cached ban verdict (TTL 60 seconds)
- Pub/sub inboxes for routing a frame to the instance that holds a peer
Postgres tables
Durable rows live in Postgres. The current schema includes: users (alias, optional email and password hash, anonymous flag, optional gender, optional country code, language, trust_score, token_version, deletion_requested_at, restricted_at, timestamps), user_interests, interests, chat_sessions (participant ids, start/end, end reason, message_count, history_enabled, last_activity — not bodies), chat_client_msgs (idempotency ids, not bodies), messages (random-chat bodies only when history is on), dm_threads, dm_messages (direct-message bodies are stored until delete-for-me or account purge), reports (reason enum plus evidence jsonb), blocks, bans, enforcement_events, moderation_flags (rule name and severity, not the text), coin_balances, coin_ledger, subscriptions, payments, profiles, friendships, friend_requests, notifications, and consents.
users.country exists on the table. The geo-block path reads CF-IPCountry on the request; it does not write that header into users.country in the current code.
The web app stores a theme preference in the browser (localStorage). That does not go to the API.
Messages — random chat vs direct messages
Random chat: by default, text is relayed in memory and is not written to the messages table. If either participant has the history entitlement, HistoryEnabled is true for the session, bodies are persisted, match.found tells both clients, and the UI shows that the chat may be saved. Anyone uncomfortable can skip. This is disclosed here and in the terms because one person’s premium feature stores the other person’s messages.
GET /api/v1/history lists persisted random-chat sessions for the authenticated user and requires the history entitlement (403 entitlement_required without it). DELETE /api/v1/history/:sessionId is mounted: the caller must be an owner of that session and have the entitlement; non-owners get 404. It removes messages for that session, not the other participant’s account.
Direct messages: every delivered DM body is written to dm_messages. Storage is the default for that surface, not an opt-in premium flag. A message stays until the viewer hides it for themselves (delete-for-me: hidden_for_a / hidden_for_b) or the account is hard-deleted after the 30-day deletion hold. There is no delete-for-everyone. We do not claim that we do not store DMs. This page is a draft pending counsel.
Reports and evidence
A report stores reporter id, reported id, session id, a reason from the fixed enum, status, and evidence jsonb. Evidence is message ids and a count — not message bodies. Bodies are only in messages when history entitlement caused a write. A retention job nulls evidence jsonb on reports older than 90 days; the report row remains.
IP addresses and geo-block
Anonymous-account and WebSocket connection limits key Redis buckets by client IP (X-Forwarded-For first hop, or the socket address). Those keys expire with the rate-limit TTL. IP is not a column on users.
When BLOCKED_COUNTRIES is set, the server reads CF-IPCountry and may log a deny with the country code. If that list is non-empty and the header is missing, the check fails closed (HTTP 403). Empty BLOCKED_COUNTRIES means this middleware is off. GET /api/v1/public/geo returns {blockedCountries} from the same config, with no auth. When the list is non-empty, the middleware wraps the API, including that listing.
Age-assurance restriction
Confirming you are 18 is a declaration. When the chat filter Flags a message under the minor rule, the server sets users.restricted_at (idempotent) and bumps token_version. That is not a row in bans. WebSocket authenticate then refuses the account (same error as a ban). Further chat sends from a restricted account end the session without delivering. Existing sockets are not disconnected on the restrict write itself.
Retention — what the code actually does
Redis keys expire (seconds to 24 hours depending on the key).
A background retention job on the API process runs on an hourly ticker until the process shuts down. Each run: (1) nulls report evidence jsonb older than 90 days (the report row stays); (2) deletes persisted random-chat history messages (and matching chat_client_msgs ids) older than 90 days — this 90-day purge does not delete direct messages; (3) hard-deletes users whose deletion_requested_at is at least 30 days old, with dependent rows in one transaction (sessions they were in, messages in those sessions, dm_messages then dm_threads they participate in, reports involving them, blocks, bans, coins, interests, flags, enforcement events). Direct messages are kept until delete-for-me or that account purge. The 30-day deletion hold is unchanged.
Session rows, report rows after evidence is nulled, flags, bans, and ledger rows are not on a 90-day delete unless they belong to a user whose 30-day deletion hold has elapsed. Do not read a promise that we wipe the whole database after 90 days.
Export — JSON, authenticated, owner-scoped
GET /api/v1/me/export requires a valid Bearer access token and always dumps the authenticated subject. It is JSON (Content-Disposition attachment; filename omeclone-export.json). Signed-in users can start that download from /settings/data. Another account’s export does not include your identity fields (tested: a peer dump does not contain your email).
The dump (MeExport) contains: exportedAt; user identity (id, alias, email if any, isAnonymous, lang, optional country, optional gender, createdAt, lastSeenAt, optional deletionRequestedAt, optional restrictedAt); reports filed about you (ids, reporter id, session id, reason, status, createdAt, evidence message ids when still present — not message bodies); session metadata for chats you were in; random-chat message bodies only when they were persisted under history for those sessions (including the peer’s stored text in that session); blocks you created (blocked user id, alias, createdAt); DMThreads (thread ids and participant ids for conversations you are in); DMMessages (direct-message bodies visible to you — not messages you hid for yourself).
The export query does not select password_hash, token_version, or trust_score. It does not include reports you filed about others, coin balances or ledger, interests, subscriptions, or payments. If a peer later exports their own account, sessions you shared that were history-enabled can appear in their dump with stored bodies — that is the history-consent model. Direct messages they can still see (not hidden-for-them) also appear in their dump. That is not a way for them to hit /me/export as you.
Deletion — scheduled, not an instant row drop
POST /api/v1/me/delete requires a valid Bearer token. It sets users.deletion_requested_at (the first timestamp is kept if you POST again) and bumps token_version so current access tokens fail verification. The users row is not deleted on that request. The response includes requestedAt and purgeAfter (requestedAt plus 30 days).
The same action is available in the app at /settings/data. After the 30-day hold, the retention job hard-deletes the user and dependent rows as described under Retention, including dm_messages then dm_threads. There is no cancel-deletion endpoint in this tree. This contact mailbox cannot run the job for you; use the signed-in settings page.
Processors and payments
The running stack uses Postgres and Redis. Hosting and any CDN in front of CF-IPCountry are operator choices, not hardcoded vendors in application code.
There are no live payment-gateway credentials in the tree. Billing webhooks are verified when a secret is configured; the current provider path is a stub. We cannot process a card refund against a live processor from this product yet.