# Emberline — Project Instructions > Anonymous keyword-based chat · Node.js + WebSocket · E2EE (NaCl) · Swiss jurisdiction > > **Design priorities, in order: User Experience → Privacy → Security → Simplicity** > > **Profiles beta:** keyword matching, the waiting screen and "next" were replaced > by temporary profiles, requests and chats. [PROFILES.md](./PROFILES.md) is the > reference for the protocol, limits and client behaviour; sections below that > describe keyword pools, matching or the waiting screen are out of date. --- ## 1. Project Identity Emberline is an ephemeral, anonymous chat platform that matches strangers on shared keywords. No accounts, no message storage, no identity. Every session is disposable. **Aesthetic:** "Ice floe" — a calm `#060d17` polar night where every segment is its own slab of ice with a lit top edge and 14px corners; `#e87834` ember accent, Manrope throughout, Playfair Display italic for the quiet voice, a skewed uppercase headline on the entry page. See §9. **The rule above all rules:** prefer the lean solution. If a problem can be solved with one line, do not write ten. Every layer of complexity is a future bug. --- ## 2. Architecture ``` User → emberline.ch → Swiss VPS (Caddy TLS, logs disabled) → WireGuard tunnel → Raspberry Pi 4 (Docker) Browser │ WebSocket (wss://) HTTP REST │ E2EE via NaCl box /challenge /count ▼ server.js (Node.js · Express + ws, inside Docker container) ├── waitingPool Map> ├── rooms Map ├── challenges Map └── rate maps (all ephemeral, never written to disk) app.js (browser — single JS file, no framework) Persistent files (append-only, no message content): reports.log reason + optional reporter-written details + server timestamp (no IPs) abuse.log event type + timestamp + IP (audit trail of abuse events and bans) ``` **No database. No sessions. No persistence beyond the two log files.** **Infrastructure:** - **VPS:** Plan-les-Ouates CH. Runs Caddy (TLS reverse proxy, access logs discarded) and WireGuard. - **Pi:** Raspberry Pi 4, 4GB RAM, passive heatsink, USB boot (SanDisk), wired Ethernet. Runs OpenMediaVault 7 + Docker. - **Domain:** `emberline.ch` Domain Privacy enabled. - **Email:** `contactall@emberline.ch` --- ## 3. File Structure ``` / ├── server.js — Node.js WebSocket + HTTP server ├── package.json — Dependencies: express ^4, ws ^8 ├── setup-assets.js — One-time asset downloader (fonts + NaCl) ├── reports.log — Abuse reports (auto-created in LOG_DIR) ├── abuse.log — Abuse events + bans (auto-created in LOG_DIR) └── public/ — The ONLY directory served over HTTP ├── index.html — Single-page frontend (CSS inline, JS via src) ├── app.js — All client-side logic ├── manifest.json — PWA manifest ├── sitemap.xml ├── icons/ │ ├── icon-192.png — PWA icon (ember flame) │ └── icon-512.png — PWA icon large ├── fonts/ │ ├── fonts.css │ └── *.woff2 — Manrope + Playfair Display italic (self-hosted) └── vendor/ ├── nacl-fast.min.js └── nacl-util.min.js ``` `express.static` is rooted at `public/` with dotfiles denied. Server source, `node_modules/`, `.git/`, `BUILD_VERSION` and the log files are never reachable by URL. Logs default to the project root and can be moved with `LOG_DIR`; the server refuses to start if `LOG_DIR` points inside `public/`. **Client IP.** `getIP()` does not trust the leftmost `X-Forwarded-For` entry — clients can set that header themselves. It walks back `TRUST_PROXY` hops (default `1`) from the socket address, matching the number of reverse proxies that append to the header. The canonical deployment has exactly one (Caddy on the VPS). --- ## 4. Matching — How It Works The matching algorithm is O(keywords × pool_size) and runs synchronously in the `join` handler. It is fast enough for single-instance deploys. Do not over-engineer it. ### Pre-warming Pre-warming is **deferred until the first user interaction** with the keyword input (first `focus` or first `keydown`, whichever comes first). On that first interaction: 1. A keypair is generated: `myKeyPair = nacl.box.keyPair()` 2. A PoW challenge is fetched and solved in the background via `prewarmChallenge()` Crawlers, link-preview unfurls, and quick-bounce visitors never consume a `/challenge` slot or waste server CPU. By the time the user has typed a tag and clicked "Find my match", both the keypair and a solved PoW are cached. If somehow the user clicks Find without any prior interaction, `enterKeyword()` generates the keypair on demand and `getPow()` falls through to a fresh solve — the critical path still works, just slightly slower. ### First match (`enterKeyword`) 1. User clicks → `connect()` opens the WebSocket 2. `join` is sent with keywords + pre-solved PoW token + pre-generated public key 3. Server matches immediately if a partner is waiting, otherwise registers client in pool 4. After 10s with no keyword match, client sends a second `join` for `__random__` ### Next → (`nextConversation`) - The existing WebSocket is **kept open** — no disconnect, no reconnect - `leave` is sent to clean up the current room server-side - A fresh E2EE keypair is generated client-side - `join` is sent immediately on the same verified connection - No new PoW solve needed — `ws._verified` persists for the connection lifetime - This is why Next → is instant: zero reconnect overhead, zero re-verification - Because one PoW covers every later join, joins are paced per socket and per IP (§5.1). Over the limit the server replies `slow_down` with `retryMs` and the client resends the same join after that delay - If the connection dropped during the chat ("Connection lost"), Next → starts over with a new connection and PoW. A drop while waiting reconnects automatically, once per 30s ### The lesson When Next → broke, successive fixes (Worker threads, pre-warming, parallel solvers) added complexity and introduced new bugs each time. The right answer was one architectural insight — reuse the connection — not more code. When something breaks, question the assumption before adding a layer. --- ## 5. Security ### 5.1 Bot & Abuse Defences | Layer | Mechanism | Config constant | |---|---|---| | Proof-of-Work | SHA-256 hash prefix, 4 leading zeros | `POW_DIFFICULTY = 4` | | Timing | Reject `join` < 200ms after connect | `MIN_JOIN_DELAY_MS = 200` | | Honeypot keyword | `__honeypot__` → ban IP for 24h | `HONEYPOT_KEYWORD` | | Auto-ban | 20 strikes in 10 min → ban IP for 24h (in memory); rate-limit hits count at most once per IP per minute (§5.7) | `STRIKE_LIMIT`, `BAN_DURATION_MS` | | WS connection rate | Max 20 new connections/min/IP | `MAX_WS_CONNECTS_PER_MIN` | | Concurrent WS cap | Max 20 open sockets/IP | `MAX_CONNS_PER_IP` | | WS frame rate | Any frame type: burst 30, then 10/s per socket → close + strike | `FRAME_BURST`, `FRAME_REFILL_MS` | | Join pacing | `join`/`join_ai`: burst 5, then 1 per 3s per socket; 120/min/IP → `slow_down` | `JOIN_BURST`, `JOIN_REFILL_MS`, `MAX_JOINS_PER_IP_PER_MIN` | | Chat messages | Burst 5, then 1 per 300ms per socket → `rate_limited` | `MSG_BURST`, `MSG_REFILL_MS` | | HTTP API rate | Max 60 req/min/IP (API routes except `/count`) | `MAX_HTTP_API_RPM` | | HTTP static rate | Max 300 req/min/IP (assets and `/count`) | `MAX_HTTP_STATIC_RPM` | | Challenge rate | Max 60 tokens/hour/IP | `MAX_CHALLENGES_PER_IP` | | Report rate | Max 10 reports/hour/IP | `MAX_REPORTS_PER_IP` | | Memory ceilings | Hard caps on all Maps | `MAX_WAITING_POOL_KEYS` etc. | **Connection cap and VPN users:** `MAX_CONNS_PER_IP` was raised from 5 to 20 to accommodate VPN users who share exit IPs. When the cap is hit, the server closes the connection with custom WebSocket code `4429`. The client detects this and shows an alert suggesting to wait a minute or switch VPN servers. It must not claim that IPs are never logged: `abuse.log` records them (§6). **Rate limiting uses two separate budgets** — static assets (fonts, JS) plus `/count`, and API routes — so neither a page load nor the waiting screen's `/count` polling consumes the user's matching budget. **PoW difficulty:** each +1 roughly doubles client solve time. At difficulty 4, expect 50–200ms. At 5, ~300ms. Do not exceed 6 without measuring UX impact. PoW is the primary bot defence; timing check is supplementary. ### 5.2 Input Validation All WebSocket frames are hard-capped at **4096 bytes** at the `ws` library level (`maxPayload: 4096`) before Node.js sees them. Then: - Keywords: `toLowerCase().replace(/[^a-z0-9_]/g, '')`, max 10 keywords, 20 chars each, sliced to `MAX_KEYWORD_POOLS` (11, including `__random__`) - `pubKey`: string, max 64 chars - `ciphertext`: max 800 chars - `nonce`: max 50 chars - Report `reason`: allowlist — `csam`, `harassment`, `illegal`, `spam` - Report `details`: `replace(/[<>]/g, '').trim().slice(0, 500)` Never relax these caps without a documented reason. ### 5.3 Security Headers Every HTTP response carries: ``` Content-Security-Policy: default-src 'none' script-src 'self' style-src 'self' 'sha256-…' ← one hash per inline