Haven
Private, self-hosted E2E encrypted messaging platform for family and friends. Implements the Signal Protocol (X3DH + Double Ratchet) for per-message forward secrecy. The server is a zero-knowledge relay — it stores and forwards encrypted blobs but never decrypts message content.
Zone: staging/ — under evaluation for graduation to apps/
Version: v1.2-dev
GitHub: irishrocker1125/haven
See also: Projects · AP Ecosystem
Architecture
Haven is a TypeScript monorepo with 7 packages:
packages/
types/ # Shared type definitions (no deps — built first)
crypto/ # Signal Protocol + TweetNaCl crypto primitives
server/ # Fastify WebSocket relay + REST API + SQLite
cli/ # Interactive terminal client (Commander + Inquirer)
web/ # React SPA (Vite + Tailwind + Zustand)
ios/ # SwiftUI native client (planned)
android/ # Jetpack Compose native client (planned)
Build order is dependency-sensitive: types → crypto → server/cli/web (server/cli/web can parallelize after crypto is built).
Server Design
- Zero-knowledge relay: Server never sees plaintext. All messages are encrypted blobs.
- Client-side fanout: Sender encrypts separately for each recipient device.
- WebSocket gateway: Real-time delivery, typing indicators, presence.
- REST API: Auth, key distribution, room management, polls, reactions, settings.
- Database: Better-SQLite3 + Drizzle ORM. Schema in
packages/server/src/db/schema.ts(15 tables). - Critical:
createTables()DDL must stay in sync with migrations — tests usecreateTables(), not migration files.
Crypto Layer
- Signal Protocol: X3DH key agreement + Double Ratchet for per-message forward secrecy.
- TweetNaCl: XSalsa20-Poly1305 for media, Ed25519 for signing.
- GC caveat: JS garbage collection prevents guaranteed secure memory wiping.
sodium.memzero()is best-effort. - Pre-1.0 dep:
@privacyresearch/libsignal-protocol-typescript@0.0.16is pre-release — evaluate alternatives before production graduation.
Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Language | TypeScript (strict) | 5.9 |
| Runtime | Node.js | 22+ |
| Server | Fastify + Better-SQLite3 + Drizzle ORM | 5.7 / 11.10 / 0.44 |
| Auth | @fastify/jwt + Argon2 | 9.1 / 0.44 |
| Realtime | @fastify/websocket | 11.2 |
| Crypto | libsignal-protocol-typescript + TweetNaCl | 0.0.16 / 1.0.3 |
| Web | React + Vite + Tailwind CSS | 18.3 / 6.4 / 4.1 |
| Client State | Zustand (persist middleware) | 5.0 |
| Data Fetching | TanStack Query | 5.90 |
| Offline Storage | Dexie (IndexedDB) | 4.2 |
| CLI | Commander + Inquirer | 12.1 / 9.3 |
| Push (iOS) | APNs (apns2) | 12.2 |
| Push (Android) | FCM (firebase-admin) | 13.6 |
Port: 3100 (registered in ports.yaml block 3100–3109). Web dev server: 5173 (Vite default).
Status
As of 2026-02-22 — 20 phases complete across 3 milestones (v1.0 MVP, v1.1 Polish, v1.2 Theming). Branch clean: 1 branch (main), 0 open PRs. 19 dependabot PRs resolved (7 merged, 12 closed).
| Metric | State |
|---|---|
| Test coverage | 687 tests: Server 541/548, Crypto 92/92, Web 146/146, CLI 0 |
| Compliance | 8/8 (100%) |
| CI/CD | Build + test CI pipeline active (Node 22, correct build order) |
| Dependencies | Current (7 patch/minor bumps merged; React 19 deferred) |
Next: Phase 21 (v1.2 continuation), React 19 migration, staging/ → apps/ graduation evaluation.
Key Decisions
| Decision | Rationale |
|---|---|
| Signal Protocol (X3DH + Double Ratchet) | Industry-standard forward secrecy; per-message key derivation |
Monorepo with @haven/crypto shared package | Single source of truth for crypto primitives across server, web, and mobile clients |
| Fastify + SQLite (Better-SQLite3) + Drizzle | Performance + low-ops self-hosted deployment; single-file database |
| Zustand + persist middleware | Lightweight, no boilerplate; offline-first for theme and settings |
CSS custom properties via Tailwind v4 @theme inline | Runtime-dynamic accent colors without build-time config changes |
| WCAG-shifted shade scales | Standard Tailwind green/orange/teal-600 fails 4.5:1 AA contrast; shifted one stop darker |
| Three-layer accessibility enforcement | Pre-validated presets + runtime rejection + UI disabling prevents WCAG violations at all levels |
Known Issues / Tech Debt
- 7 pre-existing test failures in
key-distribution.test.ts(rooms.created_byNOT NULL constraint) - CLI has 0 tests — all other packages covered
- React 18 → 19 migration needed (react + react-dom + @types coordinated upgrade)
- Image attachment support deferred — rich messaging phase scoped to text/reactions/voice only
- Pre-1.0 crypto dep —
@privacyresearch/libsignal-protocol-typescript@0.0.16pre-release - FOUC prevention scripts duplicate source data — accent preset hex values in both
accentColors.tsand the inline<script>inindex.html
Patterns Established
Reusable patterns documented from Haven development:
- Phase-based incremental delivery — 18 phases, 96 atomic plans; each independently verifiable
- VERIFICATION.md per phase — automated checks after each phase; audit trail for milestone reviews
- Zustand + persist for offline-first client state — proven in Phases 17–18
- WebSocket broadcast for cross-device sync — extensible to any real-time preference propagation
- CSS custom properties +
@theme inlinefor runtime theming — define--accent-Nvars, register with@theme inline, usebg-accent-Nin components - WCAG contrast validation engine — pure
contrast.tsutility (W3C algorithm); 17 edge-case tests - Sequential dependabot merge with rebase cycle — merge one PR,
@dependabot rebaseremaining, wait 30s, merge next
Lessons Learned
- Build order matters in monorepos — root
npm run build --workspacesbuilds alphabetically, not by dependency. Always build:types → crypto → server/cli/web. createTables()must mirror migrations — tests use DDL directly; schema drift causes mass failures.- Standard Tailwind shade 600 is WCAG danger zone for warm/green hues — verify contrast at exact shade used for buttons/text; shift darker if needed.
- Exponential backoff needs jitter — multiple clients reconnecting simultaneously overload the server; add ±25% random jitter to retry delays.
- FOUC requires pre-render theme detection — inject a blocking
<script>in<head>before React hydration. - Dependabot PRs cause cascading lockfile conflicts — merge sequentially with
@dependabot rebasebetween each. - React
getState()in render produces stale values — always use selector patternuseStore((s) => s.value).