# Emberline profiles — server and client spec (beta) Status: implemented on the `profiles-beta` branch, 27 September 2026. This file describes what `server.js`, `public/app.js` and `public/index.html` do; where the code and this file disagree, fix one of them. Everything here is in-memory only, like today: nothing is written to disk except the existing abuse and report logs. --- ## 0. Open, and settled design calls Nothing open for the beta. Settled on 27 September 2026: - **Profiles replace Just Chat completely.** Random keyword matching, the waiting screen and the "Just Chat" entry page are removed; the only way to chat is profile → request → accept. - **The AI chat becomes a premium feature** for paying subscribers later (§11). **For the beta it is free for everyone**: any profile can start one AI chat at a time from Discover, clearly labeled as AI. Also settled on 27 September 2026: 1. **Newest tab wins, without storage.** A new tab asks over a `BroadcastChannel` (in-memory messaging between tabs of the same browser, no storage) whether a session is open; the old tab hands over its resume token and key pair and shows "continued in another tab". Works in the same browser while the old tab is still open; another browser or device is a new profile. 2. **Grace only for unclean drops.** A clean close (page unload, tab closed, log off) deletes the profile immediately, so a reload never leaves a ghost profile or blocks the name. The 300 s grace applies only when the connection drops without a clean close (network loss, phone asleep). 3. **Reports on encrypted requests.** The reporter may attach the decrypted request message (opt-in checkbox); it's logged as "provided by reporter, unverified", because NaCl box is deniable and the server can't check it. 4. **Profiles per IP.** 5 concurrent, as the env var `MAX_PROFILES_PER_IP`, so it can be raised without a release if shared networks (CGNAT, universities) run into it. 5. **Interest spelling.** Letters of any language and digits, plus `-`; lowercased, NFC-normalised, 2–24 chars. Entry works as on the live site: Enter, Space or comma turns the typed word into a pill, so an interest has no spaces ("board-games"); Backspace on an empty field removes the last one; phone keyboards and pasted lists are split on separators. 7. **Gender.** woman, man, trans woman (MtF), trans man (FtM), non-binary, genderfluid, don't show. Optional; the profile notice and privacy policy say it's visible to everyone online and may be sensitive personal data. --- ## 1. Decided values All limits are constants at the top of `server.js`; those marked *env* can be overridden like `MAX_CONNS_PER_IP` today. | Name | Value | Notes | |---|---|---| | `MAX_OUTGOING_REQUESTS` | 20 | open (unanswered) requests a profile has sent | | `MAX_INCOMING_REQUESTS` | 30 | open requests a profile can receive; the 31st sender gets `busy` | | `REQUEST_TTL_MS` | 600 000 | 10 min; decline is silent and looks identical to expiry | | `REQUEST_RATE` | 5 / min | per profile, token bucket | | `REQUEST_MSG_MIN` / `MAX` | 10 / 200 chars | plaintext length, checked in the client; server checks ciphertext size | | `MAX_ACTIVE_CHATS` | 20 | per profile; ended or closed chats don't count | | `SEARCH_RESULTS` | 20 | random sample of matches; refresh draws again | | `IDLE_LOGOFF_MS` | 1 800 000 | 30 min without user activity; warning at 28 min | | `RECONNECT_GRACE_MS` | 300 000 | 5 min, unclean disconnects only (see §0, 2) | | `MAX_PROFILES_PER_IP` *env* | 5 | concurrent; no limit on how many are created over time | | `COUNTS_TICK_MS` | 5 000 | push interval for interest counts | | Count buckets | 1–4, 5–9, 10+, 25+, 50+ | nothing hidden, small interests included | | Interests | 1–10 per profile | `[\p{L}\p{N}-]`, 2–24 chars; treated literally: "football" ≠ "soccer" ≠ "footballs" | | Username | 3–20 chars `[A-Za-z0-9_.-]` | unique among online profiles, case-insensitive | | Reserved names | file, see §6 | any name *containing* a reserved word | | Word filter | file, see §6 | empty at launch | Also decided: requests are end-to-end encrypted; links and phone numbers are allowed in them; requests between people with no shared interest are allowed; a profile can't be edited while online; someone who let your request expire can't be asked again this session; after a chat ends a new request is allowed unless either side blocked; the only alert is a count in the page title (no sound); page reload logs off; no automatic action on reports. --- ## 2. Data model (server memory) ```js profiles: Map byName: Map byInterest: Map> // also the source of the counts perIp: Map // concurrent profiles requests: Map chats: Map Profile = { id, name, gender, interests[], // public pubKey, // NaCl box public key, public ws, ip, resumeToken, // private; token rotates on each resume createdAt, lastActiveAt, accepting, // accepting = "take new requests" switch outgoing: Set, incoming: Set, chats: Set, blocked: Set, // either direction hides both ways noAnswer: Set, // they let my request expire: don't ask again graceTimer, idleTimer, } Request = { id, from, to, ciphertext, nonce, createdAt, timer } Chat = { id, a, b, open: Set } // a side leaves `open` when it ends or closes the chat ``` A chat is removed from memory once `open` is empty. Ending a chat only removes *your* side; the partner keeps theirs, read-only, until they close it (§4). --- ## 3. Protocol Removed: `join` / `join_ai` (keyword matching and the old AI entry), the waiting pool, `waiting`/`matched` for human pairs, `leave`/`partner_left` for humans and the `__random__` pool. Bots keep exactly the frames they had (`bot_ready`, `matched`, `message`, `typing`, `leave`, `partner_left`), so the `bots/` code is unchanged; a person starts an AI chat with `ai_start`. Same transport as today: JSON frames over the existing WebSocket, 4 KB max frame, proof of work on the first frame, and the existing flood, frame and message buckets. All `ciphertext`/`nonce` fields use the existing `CIPHERTEXT_RE` and `NONCE_RE`. `id`s are random 16-byte base64url strings. Unknown types are ignored, as today. ### Client → server | type | fields | effect / errors | |---|---|---| | `profile_create` | `name, gender, interests[], pubKey, token, nonce` | PoW on the socket's first profile. → `profile_ok {id, name, gender, interests, pubKey, accepting, resumeToken}`. Errors: `name_taken`, `name_reserved`, `name_invalid`, `interests_invalid`, `gender_invalid`, `invalid_key`, `too_many_profiles`, `server_busy`, `slow_down {retryMs}` (paced after validation, 5 then 1 per 3 s) | | `resume` | `resumeToken` | within the grace period, or a tab handover. → `profile_ok` + `state` snapshot; the previous socket gets `replaced` and is closed | | `counts_watch` | `interests[]` (≤ 40), `on` | subscribe to buckets for these interests plus the top 50; `on:false` pauses while the tab is hidden | | `search` | `interest` | → `results {interest, bucket, people[≤20]}`; each person: `id, name, gender, interests, pubKey, accepting, askedBefore`. Random sample; excludes self and blocks both ways. Rate: 10, then 1 per 3 s (shared with `interests_all`). With `auto: true` (the page refreshing its open results by itself) it does not count as user action for the idle timer | | `browse` | `exclude[]` (≤ 40 ids) | → `browse_results {people[≤20]}`: a random sample of everyone online (not you, not the excluded, no blocks either way), shown below the interest results. Shares the search rate limit | | `interests_all` | – | → `interests_all {list: [[interest, bucket]] ≤ 200}` for "show all" | | `request_send` | `to, ciphertext, nonce` | → `request_sent {requestId, to, expiresIn}`. Errors (each with `to`): `offline` (also when blocked, deliberately), `message_rejected` (over 200 chars), `already_chatting`, `already_pending`, `asked_before`, `not_accepting`, `outgoing_full`, `chats_full`, `busy` (recipient has 30), `rate_limited {retryMs}`, `server_busy` | | `request_withdraw` | `requestId` | recipient gets `request_gone` | | `request_answer` | `requestId, accept` | accept → `chat_open` to both. Decline → recipient's copy removed; **sender hears nothing** until expiry | | `set_accepting` | `on` | toggles the switch; → `accepting {on}`; existing incoming requests stay | | `chat_message` | `chatId, ref?, ciphertext, nonce` | → `chat_ack {chatId, ref, queued}`. If the partner is reconnecting, the message waits (encrypted, ≤ 50 per chat) and is delivered on their `resume`, else dropped with their profile. Errors carry `ref`: `chat_closed`, `partner_away` (queue full), `rate_limited`, `message_rejected` | | `chat_typing` | `chatId` | relayed | | `chat_end` | `chatId` | your side closes; partner gets `chat_ended {chatId, reason:'ended'}` and keeps a read-only copy | | `block` | `profileId` | ends shared chats (partner sees `chat_ended {reason:'ended'}`, not "blocked"), drops requests both ways, hides both from each other's search | | `report` | `profileId, name, reason, details?, requestText?` | → `report_ok`. Appended to `reports.log` as `{ts, reason, details, reported, requestTextUnverified?}`; no reporter data. `name` is used if the person already logged off | | `ai_start` | – | one AI chat at a time → `chat_open {chatId, ai:true, partner:{name, pubKey}}`; the bot gets `matched {ai, keywords: interests, name, gender, partnerPubKey}` (`gender` is `''` when not shown). Errors: `ai_unavailable`, `ai_already_open`, `chats_full`, `slow_down` | | `still_here` | – | answers `idle_warning` (any other user action also counts) | | `logoff` | – | deletes everything now (§5); socket may stay open for a new profile | ### Server → client `profile_ok`, `state`, `counts {buckets:{interest:bucket}, top[]}` (sent only when something changed; on your own interests the bucket counts the others, not you), `results`, `interests_all`, `request_in {requestId, from:{id,name,gender,interests,pubKey,accepting}, ciphertext, nonce, expiresIn}`, `request_sent`, `request_gone {requestId}`, `request_expired {requestId}` (sender, at 10 min, identical for decline and no answer), `chat_open {chatId, partner}`, `chat_message`, `chat_typing`, `chat_ended {chatId, reason:'ended'|'logged_off'}`, `partner_reconnecting {chatId}` / `partner_back {chatId}`, `idle_warning {secondsLeft}`, `logged_off {reason:'logoff'|'idle'|'timeout'}`, `replaced`, `accepting`, `report_ok`, `error {code, re, to?|chatId?}` (`re` is the type of the frame the error answers). Times are relative (`expiresIn`, `secondsLeft`), so the browser's clock doesn't matter. ### Encryption Each profile has one NaCl box key pair, created in the browser and kept only in memory (and handed to a new tab per §0, 1). Requests and chat messages are `nacl.box(plaintext, nonce, recipientPubKey, senderSecretKey)`, the same as today's chat. The server sees only ciphertext length and routing. Public profile fields (name, gender, interests) are plaintext by nature. --- ## 4. Lifecycles **Request:** `request_send` → pending (10 min timer) → one of: accepted → `chat_open`; declined → recipient side gone, sender still "waiting" → `request_expired` at 10 min; withdrawn → `request_gone`; either side logs off or blocks → gone. On expiry (or decline + expiry) the recipient is added to the sender's `noAnswer` set: no new request to them this session. **Chat:** `chat_open` → active → one side `chat_end`, blocks, or logs off → the other side gets `chat_ended` and a read-only copy it can close. A new request between the two is allowed afterwards unless a block exists. **Connection:** - `logoff` (the page sends it on `pagehide`, so closing or reloading counts) → delete immediately - any socket close without a `logoff` (§0, 2) → `partner_reconnecting` to chat partners, profile stays listed and holds its name for 300 s → `resume` restores it, or it's deleted - 28 min without user action → `idle_warning`; 30 min → `logged_off {reason:'idle'}` and deleted - "user action" = any frame except typing and heartbeats: search, request, message, answer, `still_here`. The client sends `still_here` when the tab becomes visible again. --- ## 5. What "log off" deletes Immediately, from memory: the profile and its name, interest memberships (counts update on the next tick), all its outgoing and incoming requests (other side gets `request_gone`), its side of every chat (partners get `chat_ended {reason:'logged_off'}` and keep their read-only copy), its blocks and `noAnswer` sets, the per-IP slot. The IP stays in the rate-limit maps for up to 2 h, exactly as the privacy policy already says. --- ## 6. Filters, easy to extend Two plain-text files in `LOG_DIR` (next to the logs), one entry per line, `#` for comments, loaded at start and re-read within 10 s when they change (`fs.watchFile`, which works with bind mounts). On the Pi, put them in `~/docker/appdata/emberline/data/` and bind-mount them like the logs: - `reserved-names.txt`: default `admin, emberline, moderator, support, system, official`. A name is refused if its lowercase form *contains* an entry. - `blocked-words.txt`: empty at launch. When filled, applied to usernames and interests (lowercase, contains). Not applied to messages, which the server can't read. Error codes stay generic (`name_reserved`, `interests_invalid`) so the lists can't be probed word by word. --- ## 7. Client (public/app.js + index.html) - Removed: the Just Chat entry page, keyword matching, waiting screen with sparks, "next", and the AI offer. - New views, as in the `beta.html` prototype: profile entry, Discover / Requests / Messages tabs, chat, deleted. - Discover shows a labeled AI card while a bot is free (`/count` → `ai`). - Requests tab: the "accepting new requests" switch, requests for you, and the requests you sent (with withdraw). - Messages tab: chats only. A newly accepted chat is marked "new" and counts in the tab badge. - No pop-ups or toasts: feedback goes into a status line in the page flow below the tabs (and one in the chat view), so nothing covers the tabs. - Page title: `(N) Emberline` with N = open incoming requests. No sound, no notifications API. - Request dialog states "at least 10 characters" before typing and shows a live counter ("7 more characters needed" → "ready to send"). - Interest input: the live `addTag` / `splitTypedTags` logic, with the profile character rules (§0, 5). - `beforeunload` while online → the browser's "Leave site?" dialog (its wording can't be set; many phone browsers skip it). - `pagehide` → send `logoff` (a clean close deletes immediately). - The entry page explains in a "good to know · everything here is temporary" list what gets deleted and when. - `visibilitychange` → `counts_watch {on:false}` when hidden; `still_here` when visible. - `BroadcastChannel('emberline')` tab handover per §0, 1. - Request message box: required, 10–200 chars, counter. - Still no cookies, localStorage, sessionStorage or IndexedDB. --- ## 8. Load on the Pi At 2 000 profiles online: profiles ~2 MB, requests (≤ 20 out each, ~1 KB) worst case ~40 MB, chats a few MB. Counts: one diff per tick, sent only to visible tabs, ≤ 400 small frames/s. Search: a set intersection plus a random sample of 20, cheap. Comfortably inside the Pi 4's RAM and CPU; the tunnel and VPS bandwidth remain the limit, as today. --- ## 9. Privacy policy and terms Must change before launch: - Remove the Just Chat wording (random matching, keywords discarded after a match) and the "Optional AI chat" section; the AI returns with premium (§11). - Profiles: what's visible (name, gender, interests), to whom (anyone online), for how long (until log off, idle 30 min, or 5 min after a lost connection); gender as optional, possibly sensitive data. - Requests: end-to-end encrypted, stored in memory up to 10 min. - "Nothing stored" now reads "nothing stored on disk; your profile is held in memory while you're online". - Blocks, reports, and that a reporter may attach a request message (§0, 3). - Terms: rules for usernames and interests, and an 18+ rule that applies to profiles too. - Re-check the VÜPF revision status (derived communication services) before launch. --- ## 10. Tests (test/server.test.js) Name uniqueness (case), reserved names, per-IP cap, request limits (20 out, 30 in, 5/min), decline indistinguishable from expiry for the sender (same frames, same timing), `asked_before`, block hides both ways and is reported as `unavailable`, chat end leaves the partner's copy, 20-chat cap, idle warning and log off, clean close deletes immediately, grace + resume, `replaced` on handover, and a memory check that every map empties after all profiles log off. --- ## 11. Later: premium AI chat (not in the beta) Decided: chatting with a bot is for paying subscribers only. How is open. Questions to settle before building it: 1. **Fit with the association.** `verein/Statuten.md` Art. 2.3: the Verein is non-profit and has no commercial purpose. A subscription that only covers running costs (hardware, power, hosting) is likely compatible; profit is not, and could affect a tax exemption. Options: a paid subscription run by the Verein at cost, or premium as a supporter/membership contribution (Art. 6 lets the general assembly set a membership fee). Check with a fiduciary before taking money. 2. **Paying without an account.** Emberline promises no accounts and no stored identities. Workable patterns, from most to least private: - *Anonymous access code* (as Mullvad does): payment gives a random code; the user types it in to unlock the AI for the paid period. The server stores only code → expiry date, never who paid. The payment provider knows the payer, but not which code they got if codes are issued after payment without linking them. - *Blind-signed tokens* (Privacy Pass style): the server signs tokens it can't later link to the payment. Strongest privacy, most work. - *Account with email*: simplest, but breaks the "no accounts" promise. 3. **Storage.** Any of these needs a small persistent store (code → expiry), the first thing Emberline keeps on disk besides logs; the privacy policy has to say so. 4. **Payment provider.** TWINT, card via Stripe or a Swiss provider (e.g. Datatrans, Payrexx), or crypto. Each has fees and its own data processing to disclose. Recurring billing is harder with anonymous codes; prepaid periods (1, 3, 12 months) fit better. 5. **Consumer and tax rules.** Terms for a paid service, what happens on downtime, refunds, and VAT (registration is only needed above CHF 100 000 yearly turnover). 6. **Capacity.** The bot runs on Emberline's own hardware; decide how many concurrent AI chats it can serve and what premium users see when it's full. Protocol when it's built: `premium_unlock {code}` → `premium {until}` for the session only (the code is not stored in the browser, per the no-storage rule), then the existing `join_ai` flow, presented as a clearly labelled AI entry in Discover.