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 use createTables(), 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.16 is pre-release — evaluate alternatives before production graduation.

Tech Stack

LayerTechnologyVersion
LanguageTypeScript (strict)5.9
RuntimeNode.js22+
ServerFastify + Better-SQLite3 + Drizzle ORM5.7 / 11.10 / 0.44
Auth@fastify/jwt + Argon29.1 / 0.44
Realtime@fastify/websocket11.2
Cryptolibsignal-protocol-typescript + TweetNaCl0.0.16 / 1.0.3
WebReact + Vite + Tailwind CSS18.3 / 6.4 / 4.1
Client StateZustand (persist middleware)5.0
Data FetchingTanStack Query5.90
Offline StorageDexie (IndexedDB)4.2
CLICommander + Inquirer12.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).

MetricState
Test coverage687 tests: Server 541/548, Crypto 92/92, Web 146/146, CLI 0
Compliance8/8 (100%)
CI/CDBuild + test CI pipeline active (Node 22, correct build order)
DependenciesCurrent (7 patch/minor bumps merged; React 19 deferred)

Next: Phase 21 (v1.2 continuation), React 19 migration, staging/ → apps/ graduation evaluation.


Key Decisions

DecisionRationale
Signal Protocol (X3DH + Double Ratchet)Industry-standard forward secrecy; per-message key derivation
Monorepo with @haven/crypto shared packageSingle source of truth for crypto primitives across server, web, and mobile clients
Fastify + SQLite (Better-SQLite3) + DrizzlePerformance + low-ops self-hosted deployment; single-file database
Zustand + persist middlewareLightweight, no boilerplate; offline-first for theme and settings
CSS custom properties via Tailwind v4 @theme inlineRuntime-dynamic accent colors without build-time config changes
WCAG-shifted shade scalesStandard Tailwind green/orange/teal-600 fails 4.5:1 AA contrast; shifted one stop darker
Three-layer accessibility enforcementPre-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_by NOT 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.16 pre-release
  • FOUC prevention scripts duplicate source data — accent preset hex values in both accentColors.ts and the inline <script> in index.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 inline for runtime theming — define --accent-N vars, register with @theme inline, use bg-accent-N in components
  • WCAG contrast validation engine — pure contrast.ts utility (W3C algorithm); 17 edge-case tests
  • Sequential dependabot merge with rebase cycle — merge one PR, @dependabot rebase remaining, wait 30s, merge next

Lessons Learned

  • Build order matters in monorepos — root npm run build --workspaces builds 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 rebase between each.
  • React getState() in render produces stale values — always use selector pattern useStore((s) => s.value).