This file is the single source of truth for safety-critical behaviour. If you touch any file referenced here, re-run the named tests.
- Where:
lib/auth/age.ts(checkAge),lib/auth/signup.ts(completeSignup — the only user INSERT path),app/api/auth/request-otp/route.ts(gate runs before any OTP is sent). - Behaviour: full DOB validated server-side, then discarded — only
birth_yearis stored. Under-16 → HTTP 403under_16→ client routes to/{locale}/under-16(helplines 1484 + 1677, tap-to-call). No OTP is sent and no row is written for a rejected attempt. - Test:
tests/age.test.ts,tests/signup.test.ts(npm test).
- Where:
lib/auth/crypto.ts(hashPhone — HMAC, plaintext never stored),lib/auth/names.ts(auto display names), schema comments inlib/db/schema.ts. - Behaviour: members are
AdjectiveNounNN. Phone exists only as HMAC hash; email only for account recovery; neither is ever rendered to listeners/members. No photos or file sharing exist anywhere in the product. - Test:
tests/signup.test.tsasserts no plaintext phone on the user row.
- Where:
components/HelplineCorner.tsx, rendered byapp/[locale]/member/layout.tsx(wraps every member screen). - Test: open any member page — 1677/119 corner with
tel:links.
- Where:
lib/auth/session.ts. Random 256-bit token in httpOnly cookie; DB stores sha256(token) only; banned/suspended users are cut off at lookup.
- Where:
lib/auth/otp.ts. Codes hashed (sha256) at rest, 10-min TTL, 5 attempts max, 60s resend cooldown.
- Where:
messages/en.json/messages/dv.json—common.listenerNoteand all copy say "listeners", never counsellor/therapist/advisor. Keep it that way in every new string.
- Where:
lib/chat/encryption.ts, used bylib/chat/service.ts. - Behaviour: AES-256-GCM; per-conversation random key, stored only wrapped
by
MESSAGE_MASTER_KEY. Plaintext never touches the DB. - Test:
tests/chat.test.ts(ciphertext-only rows, round-trip).
- Where:
lib/chat/service.tshardDeleteConversation, routeapp/api/chat/[id]/delete. - Behaviour: member-only. Deletes all message rows + keyword flags AND destroys the wrapped conversation key, so stray ciphertext is undecryptable.
- Test:
tests/chat.test.ts+ live drillscripts/e2e-chat.ts.
- Where:
lib/chat/matching.tsfindEligibleListener. - Behaviour: only
verified_at IS NOT NULL+training_completed_at IS NOT NULL+ level ≠ applicant listeners are ever matchable, regardless of availability. Concurrency caps: probation 1, full/mentor 2. - Test:
tests/chat.test.ts("matching only ever selects verified…").
- Where:
components/PanicButton.tsx, decoy pageapp/notes/page.tsx. - Behaviour: button or 3×Escape →
location.replace("/notes")(unbranded notes page, no history entry back to Hiyvaru). - Test: manual — click Quick exit in a chat; back button must not return.
- Where:
app/api/report/route.ts,components/ReportButton.tsx; never-again prefs inapp/api/chat/[id]/rate.
- Where:
lib/safety/escalate.ts, routeapp/api/chat/[id]/escalate, UIcomponents/EscalateButton.tsx+ crisis card incomponents/ChatWindow.tsx. - Behaviour: member instantly sees the calm full-width card (tap-to-call 119 / 1677 / 332 2111; chat stays open); listener sees the crisis script; conversation unlocks for moderators; duty moderators get socket ping + SMS (Twilio or mock outbox); escalations + audit_log rows written.
- Test:
tests/safety.test.ts+ live drillscripts/e2e-crisis.ts.
- Where:
lib/safety/escalate.tsdispatchPoliceReferral,lib/safety/contact.ts(recoverable phone),users.phone_enc/phone_iv. - Model: HUMAN-CONFIRMED, then immediate (not keyword-automatic). An
escalation = a trained listener or moderator confirming danger. On that
confirmation the member's recoverable contact is dispatched to
POLICE_ALERT_PHONE(welfare-check request; no chat content), the listener keeps talking, andescalations.police_notified_atis set. - Recoverable contact: phone is stored a second time, AES-256-GCM
encrypted under
CONTACT_MASTER_KEY, decrypted ONLY ingetEmergencyContactfor a referral or a moderator on an escalated chat. The login hash stays the anonymity default; listeners never see it. - Transparency: privacy policy + terms state this exception plainly (hidden police contact is the harmful pattern; disclosed is not).
- Audit:
police_referral_dispatchedrecords that police were notified and whether a contact existed — never the plaintext number. - Test:
tests/safety.test.ts(recoverable-contact + referral + no plaintext in audit) and live drillscripts/e2e-crisis.ts.
- Where:
lib/safety/lexicons.ts(editable viaconfigtable),lib/safety/scan.ts, wired inlib/socket/server.tsafter delivery. - Behaviour: dv+en lexicons; match → message flagged + keyword_flags row + soft banner to listener + audit entry. Never blocks or auto-calls anyone — a human decides. Dhivehi list needs psychologist review before pilot.
- Where:
lib/safety/scan.tscheckOutgoingMessage (the send gate), enforced inlib/socket/server.tsBEFORE delivery. - Behaviour: messages containing contact information (phone numbers, @handles, platform names) are never delivered in either direction — the sender sees why, the other side sees nothing. No photo/file sharing exists anywhere. Off-platform solicitation = instant-ban offence. 3 blocked messages in one conversation auto-files a moderator report.
- Test:
tests/gate.test.ts.
- Where: same gate; explicit lexicon in
lib/safety/lexicons.ts(en + dv, editable viaconfigtable keyexplicit_lexicon). - Behaviour: sexual/explicit content is never delivered, either direction; flagged + audited; 3 strikes auto-reports to moderators. IMPORTANT invariant: risk-of-harm disclosures ("I want to die") are NEVER blocked — they must always reach the listener; they trigger the crisis-hint path.
- Test:
tests/gate.test.ts(including the never-block-risk case).
- Where:
lib/listener/application.ts, admin routesapp/api/admin/applications, UIapp/[locale]/admin/page.tsx. - Behaviour: ID + selfie stored AES-256-GCM encrypted (separate master key), decryptable only by admin for side-by-side review; hard-purged the moment a decision is made (approve or reject); only verified ✓/doc type/ expiry retained. Approval sets role=listener, level=probation.
- Test:
tests/safety.test.ts(purge + activation assertions).
- Where:
lib/training/*, probation logic inlib/chat/service.tsendConversation. - Behaviour: 5 modules, quiz pass = 100% only; training_completed_at set only when all pass (matcher requires it). Probation: first 10 chats audited for mentor review, then auto-promote to full.
- Test:
tests/safety.test.ts.
- Where:
app/api/moderator/*. - Behaviour: chat content visible only when moderator_unlocked (crisis or report review); every transcript view is audited; suspend/ban kills the target's sessions immediately; admins cannot be actioned.
- Where:
lib/ratelimit.ts; applied in request-otp (10/IP/hr), chat request (20/member/hr + 5/day for accounts younger than 24h), report (10/hr), apply (3/day). - Note: in-memory store (single-process deploy). Move to Redis if the app ever runs multi-instance.
- Where: Listener Lounge (
app/api/lounge, listener/mentor/moderator only), debrief nudge after escalated chats in the listener dashboard, daily-cap column on profiles.
- Where:
--crisistoken inapp/globals.css, per theme. - Behaviour: crisis/helpline text must clear WCAG AA (4.5:1) in BOTH
themes. Two real failures were found and fixed: dark mode inherited the
light red (4.28:1) and light mode measured 4.43:1. Now 5.45 dark / 5.61
light. If you change
--crisis, re-measure both themes — this is the most safety-critical text in the product and it is read at 1am. - Also: visible
:focus-visiblerings everywhere, skip-link to main content, 44px minimum touch targets,role="log"/aria-liveon the chat transcript so screen readers announce incoming messages, forced-colors support, andprefers-reduced-motionhonoured globally. - Test:
tests/i18n.test.tsguards translation completeness (a missing Dhivehi key renders a raw key path to someone in distress).
- Where:
lib/push.ts,public/sw.js. - Behaviour: notification bodies are always generic ("Someone would like to talk") — chat content and names never appear on lock screens. The service worker never caches /api or /socket.io responses.
npm test # all safety tests
npm run dev # local dev (PGlite + mock SMS — no Docker needed)
docker compose up # full stack with real Postgres/RedisMock SMS outbox: .data/outbox.jsonl.