diff --git a/.env.macmini.example b/.env.macmini.example
index 3c15c2013..69a075427 100644
--- a/.env.macmini.example
+++ b/.env.macmini.example
@@ -32,6 +32,22 @@ JWT_SECRET=your-jwt-secret-here
JWT_PUBLIC_KEY=
JWT_PRIVATE_KEY=
+# ============================================
+# Encryption Vault Key Encryption Key (KEK) — REQUIRED
+# ============================================
+# Wraps every user's master key in auth.encryption_vaults.
+# Generate with: openssl rand -base64 32
+#
+# Without a real value, mana-auth boots with a 32-zero-byte fallback
+# and prints a loud warning every startup. Production must set this.
+# Treat it like a database root password — store as a Docker secret,
+# KMS-injected env var, or Vault-served value.
+#
+# Rotation requires planned downtime today (no background re-wrap job
+# yet). The kek_id column on encryption_vaults is reserved for the
+# future migration path.
+MANA_AUTH_KEK=
+
# ============================================
# Supabase (optional, for legacy features)
# ============================================
diff --git a/CLAUDE.md b/CLAUDE.md
index d8575c217..d49d8da8a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -655,6 +655,31 @@ The Mana unified app uses a **single IndexedDB** (`mana`) containing all 120+ co
Individual apps in `apps/*/apps/web/` still use `@mana/local-store` with per-app IndexedDB databases (`mana-{appId}`). When users first open the unified Mana app, `legacy-migration.ts` migrates data from these old DBs into the unified DB.
+### At-Rest Encryption (Phase 1–9)
+
+User-typed content in **27 tables** is encrypted with **AES-GCM-256** before it touches IndexedDB. The master key lives in mana-auth (KEK-wrapped) and is fetched on login. Two trust modes:
+
+- **Standard mode** (default): server holds the user's master key wrapped with `MANA_AUTH_KEK` (env-provided). Mana operators with KEK access could theoretically decrypt; protected against all realistic threats except court-ordered disclosure.
+- **Zero-Knowledge mode** (opt-in via Settings → Sicherheit): user generates a 32-byte recovery code locally, the server NULLs out the KEK wrap. Mana is then **computationally incapable** of decrypting user data — losing the recovery code means losing the data, by design.
+
+**Production requirement:** `MANA_AUTH_KEK` must be set on the mana-auth service to a base64-encoded 32-byte value (`openssl rand -base64 32`). The dev fallback is 32 zero bytes and prints a loud warning at startup.
+
+**When writing module code that touches sensitive fields:**
+
+1. Add the table to `apps/mana/apps/web/src/lib/data/crypto/registry.ts` with the field allowlist
+2. Wrap writes with `await encryptRecord(tableName, record)` before `table.add()` / `table.update()`
+3. Wrap reads with `decryptRecords(tableName, visible)` after the Dexie query, before the type converter
+4. Default to **encrypt** for user-typed text; default to **plaintext** for IDs / timestamps / sort keys / enum discriminators
+
+**Reference docs:**
+
+| File | Purpose |
+|------|---------|
+| `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md` | Complete sprint history, rollout phases, threat model, backlog |
+| `apps/docs/src/content/docs/architecture/security.mdx` | User-facing walkthrough + threat model table |
+| `services/mana-auth/sql/002_encryption_vaults.sql` | Vault schema + RLS policies |
+| `services/mana-auth/sql/003_recovery_wrap.sql` | Phase 9 recovery columns + CHECK constraints |
+
### Dev Commands (Local-First Stack)
```bash
diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs
index e0da9e726..d8d4ab5d6 100644
--- a/apps/docs/astro.config.mjs
+++ b/apps/docs/astro.config.mjs
@@ -46,6 +46,7 @@ export default defineConfig({
items: [
{ label: 'Overview', slug: 'architecture/overview' },
{ label: 'Authentication', slug: 'architecture/authentication' },
+ { label: 'Security & Encryption', slug: 'architecture/security' },
{ label: 'Backend (NestJS)', slug: 'architecture/backend' },
{ label: 'Web (SvelteKit)', slug: 'architecture/web' },
{ label: 'Mobile (Expo)', slug: 'architecture/mobile' },
diff --git a/apps/docs/src/content/docs/architecture/security.mdx b/apps/docs/src/content/docs/architecture/security.mdx
new file mode 100644
index 000000000..31c056a93
--- /dev/null
+++ b/apps/docs/src/content/docs/architecture/security.mdx
@@ -0,0 +1,204 @@
+---
+title: Security & Encryption
+description: Trust model, at-rest encryption, and the optional zero-knowledge mode for Mana user data.
+---
+
+import { Aside, Tabs, TabItem, Steps } from '@astrojs/starlight/components';
+
+# Security & Encryption
+
+Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedDB or the sync server. The user has two trust modes to choose from:
+
+| Mode | Default | What the server can decrypt |
+|------|---------|----------------------------|
+| **Standard** | ✅ Yes | The user's master key, via the server-side KEK |
+| **Zero-Knowledge** | Opt-in | Nothing — the recovery code lives only with the user |
+
+## What's encrypted
+
+**27 tables** ship with at-rest encryption enabled. The full list is in [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md), but the highlights:
+
+| Module | Fields |
+|--------|--------|
+| Chat | `messages.messageText`, `conversations.title`, chat templates |
+| Notes | `title`, `content` |
+| Dreams | `title`, `content`, `transcript`, `interpretation`, `location` |
+| Memoro | `title`, `intro`, `transcript` (the largest plaintext blobs in the app) |
+| Contacts | 16 PII fields (firstName, lastName, email, phone, mobile, birthday, address, social) |
+| Cycles | `notes`, `mood` (GDPR Art. 9 sensitive personal data) |
+| Finance | `transactions.description`, `transactions.note` |
+| Cards | `front`, `back`, deck name + description |
+| Todo | `tasks.title`, `description`, `subtasks`, `metadata` |
+| Calendar | `events.title`, `description`, `location` + the cross-module `timeBlocks` hub |
+| Picture | `images.prompt`, `negativePrompt`, board name + description |
+| Storage | `files.name`, `originalName` |
+| Music | `songs.title`, playlist name + description |
+| Events | `socialEvents.title/description/location`, guest contact details |
+
+What's intentionally **plaintext** for structural reasons:
+
+- IDs, foreign keys, timestamps
+- Sort/filter keys (e.g. `tasks.dueDate`, `songs.artist`)
+- Public-redirect lookup keys (`uload.links.shortCode` and `originalUrl`)
+- Published social-event content (decrypted by design when the user shares an RSVP link)
+
+## Standard Mode (default)
+
+```
+┌─────────────┐ Login ┌──────────────┐
+│ Browser │────────────>│ mana-auth │
+│ │ │ :3001 │
+└─────────────┘ └──────────────┘
+ │ │
+ │ GET /encryption-vault/key│
+ │ ──────────────────────────>│
+ │ │ 1. Load wrapped MK from
+ │ │ auth.encryption_vaults
+ │ │ 2. Unwrap with KEK from
+ │ │ MANA_AUTH_KEK env var
+ │ │ 3. Return raw 32-byte MK
+ │ <──────────────────────────│
+ │
+ ▼
+ importMasterKey() → CryptoKey (non-extractable)
+ │
+ ▼
+ MemoryKeyProvider holds the key for the session
+ │
+ ▼
+ encryptRecord() / decryptRecord() per Dexie write/read
+```
+
+The master key never crosses the browser process boundary except as base64 over HTTPS during the initial fetch. Once imported, it's a **non-extractable CryptoKey** — even malicious JavaScript with a reference to it cannot read its raw bytes.
+
+### What Mana CAN see in Standard Mode
+
+- ❌ Never the contents of encrypted fields without actively unwrapping the KEK
+- ⚠️ Theoretically: a Mana operator with KEK access could unwrap the master key and decrypt user data. Protected against all realistic threats except a court-ordered disclosure.
+
+This is the same trust model as 1Password's "I trust the company" tier or Signal's local-only key.
+
+## Zero-Knowledge Mode (opt-in)
+
+Users who want **provable** confidentiality (not just "we promise") can opt into zero-knowledge mode in **Settings → Sicherheit**.
+
+
+
+1. **Generate a recovery code.** The browser generates 32 random bytes via Web Crypto, derives an AES wrap key via HKDF-SHA256, and seals the user's master key locally. The wrapped blob is sent to the server. The 32-byte secret itself **never leaves the browser**.
+
+2. **Back up the recovery code.** The UI displays the formatted code (`1A2B-3C4D-...`, 79 characters). The user copies it into their password manager. They have to type it back to confirm — we don't move forward until the confirmation matches.
+
+3. **Activate.** The server NULLs out the KEK-wrapped master key and sets `zero_knowledge=true` on the vault row. From this moment on, **the server is computationally incapable of decrypting the user's data**.
+
+
+
+### Unlock flow on a new device
+
+When a zero-knowledge user signs in on a new device:
+
+```
+1. Login → JWT
+ ↓
+2. Browser fetches /encryption-vault/key
+ ↓
+3. Server returns { requiresRecoveryCode: true, recoveryWrappedMk, recoveryIv }
+ ↓
+4. Browser shows the RecoveryCodeUnlockModal
+ ↓
+5. User pastes the code from their password manager
+ ↓
+6. Browser unwraps the master key locally with HKDF + AES-GCM
+ ↓
+7. App boots with decrypted data
+```
+
+If the user loses the recovery code, **the data is unrecoverable**. Mana cannot help — that's the design. The trade-off is the entire point of zero-knowledge.
+
+### What Mana CAN see in Zero-Knowledge Mode
+
+- ❌ Never the contents of encrypted fields
+- ❌ Never the master key (the server has no usable copy)
+- ❌ Never the recovery code (it lives only with the user)
+- ✅ Structural metadata: number of records, timestamps, foreign keys, which modules are active
+
+A database CHECK constraint (`encryption_vaults_zk_consistency`) enforces the "ZK active ⇒ recovery wrap exists" invariant at the schema level, so the server cannot accidentally lock a user out.
+
+## Recovery code rotation
+
+Users can rotate their recovery code without disabling zero-knowledge mode. The settings page shows a "🔁 Recovery-Code rotieren" button when ZK is active. The flow:
+
+1. Browser uses the cached master key bytes (stashed during the recovery-code unlock earlier in the session)
+2. Generates a fresh 32-byte secret + new HKDF wrap key
+3. Seals the same master key with the new wrap key
+4. Posts the new wrap to `/recovery-wrap` (replaces the previous row)
+5. Displays the new code; the old one is now permanently invalid
+
+If the user is in standard mode, rotation re-fetches the master key from the server (same path as the initial setup).
+
+## Deployment requirements
+
+
+
+### Key rotation
+
+The KEK and the per-user master keys can rotate independently:
+
+- **MK rotation** (`POST /encryption-vault/rotate`): mints a fresh master key, wipes the existing wrap. The old MK is lost — caller is responsible for re-encrypting any data sealed with it. Use case: suspected device compromise.
+- **KEK rotation**: handled by deploying a new `MANA_AUTH_KEK` value with a new `kek_id`. Old vault rows keep their original `kek_id` until a background re-wrap job migrates them. (The migration job is a future addition — for now KEK rotation requires planned downtime.)
+
+## Audit trail
+
+Every vault access is recorded in `auth.encryption_vault_audit`:
+
+| Action | When |
+|--------|------|
+| `init` | First-time vault creation |
+| `fetch` | Each `GET /key` call (the hot path) |
+| `failed_fetch` | Any 4xx/5xx on the fetch path |
+| `rotate` | Master key rotation |
+| `recovery_set` | New recovery wrap stored |
+| `recovery_clear` | Recovery wrap removed |
+| `zk_enable` | Zero-knowledge mode activated |
+| `zk_disable` | Zero-knowledge mode deactivated |
+
+Each row carries the IP address, user-agent, HTTP status code, and a free-form context string. Used for security investigations and compliance reporting.
+
+## Threat model summary
+
+| Threat | Standard | Zero-Knowledge |
+|--------|----------|----------------|
+| Browser-local malware reading IndexedDB | ✅ Protected (encrypted blobs) | ✅ Protected |
+| Stolen device with no screen lock | ✅ Protected (key not persisted) | ✅ Protected |
+| Database leak (encrypted_vaults dump) | ✅ Protected (KEK is in env, not DB) | ✅ Protected |
+| Mana operator with full DB access | ⚠️ Could decrypt with KEK | ✅ Cannot decrypt |
+| Mana operator with full DB + KEK access | ⚠️ Could decrypt | ✅ Cannot decrypt (no usable wrap) |
+| Court order against Mana | ⚠️ Could be compelled to decrypt | ✅ Mana physically cannot comply |
+| User loses recovery code | n/a | ❌ Data lost |
+| User loses password but vault is in ZK mode | Recovery via password reset | ❌ Data lost (vault is keyed to recovery code) |
+
+## Implementation references
+
+For the architectural deep dive, code locations, and the complete rollout history (Phases 1–9 + the backlog sweep), see [`DATA_LAYER_AUDIT.md`](https://github.com/mana-how/mana-monorepo/blob/main/apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md).
+
+Key files:
+
+| File | Role |
+|------|------|
+| `apps/mana/apps/web/src/lib/data/crypto/aes.ts` | AES-GCM-256 wrap/unwrap primitives |
+| `apps/mana/apps/web/src/lib/data/crypto/registry.ts` | Allowlist of which fields on which tables get encrypted |
+| `apps/mana/apps/web/src/lib/data/crypto/recovery.ts` | Recovery code generation, format/parse, HKDF wrap |
+| `apps/mana/apps/web/src/lib/data/crypto/vault-client.ts` | Browser-side vault client + zero-knowledge state machine |
+| `apps/mana/apps/web/src/lib/components/RecoveryCodeUnlockModal.svelte` | Lock-screen modal for the ZK unlock flow |
+| `apps/mana/apps/web/src/routes/(app)/settings/security/+page.svelte` | Settings UI for setup, rotation, disable |
+| `services/mana-auth/src/services/encryption-vault/index.ts` | Server-side vault service |
+| `services/mana-auth/src/services/encryption-vault/kek.ts` | KEK loader + master-key wrap helpers |
+| `services/mana-auth/sql/002_encryption_vaults.sql` | Vault table + RLS policies |
+| `services/mana-auth/sql/003_recovery_wrap.sql` | Recovery wrap columns + CHECK constraints |
diff --git a/apps/docs/src/content/docs/deployment/self-hosting.mdx b/apps/docs/src/content/docs/deployment/self-hosting.mdx
index 3bc6e0e3f..43bfe7f3b 100644
--- a/apps/docs/src/content/docs/deployment/self-hosting.mdx
+++ b/apps/docs/src/content/docs/deployment/self-hosting.mdx
@@ -120,6 +120,10 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_KEY}
+ # REQUIRED: encryption-vault Key Encryption Key.
+ # Generate with: openssl rand -base64 32
+ # See "Encryption Vault Setup" below.
+ MANA_AUTH_KEK: ${MANA_AUTH_KEK}
depends_on:
postgres:
condition: service_healthy
@@ -160,6 +164,50 @@ echo "JWT_PRIVATE_KEY=$(cat private.pem | base64 -w 0)"
echo "JWT_PUBLIC_KEY=$(cat public.pem | base64 -w 0)"
```
+## Encryption Vault Setup
+
+mana-auth ships with a per-user encryption vault that wraps each user's master key with a service-wide **Key Encryption Key (KEK)**. The KEK is loaded from the `MANA_AUTH_KEK` environment variable on boot — **it is required in production** and refusing to set it leaves you running on a 32-zero-byte dev fallback that prints a loud warning every startup.
+
+### Generating a KEK
+
+```bash
+# 32 random bytes, base64-encoded
+openssl rand -base64 32
+
+# Example output (DO NOT use this — generate your own!)
+# 4n8jzXq2K9pL5mR7tY1wE3uI6oP0sD8fG2hJ4kL6nM8=
+```
+
+Add the result to your `.env`:
+
+```env
+MANA_AUTH_KEK=4n8jzXq2K9pL5mR7tY1wE3uI6oP0sD8fG2hJ4kL6nM8=
+```
+
+### Securing the KEK
+
+Treat the KEK like a database root password — anyone with both the KEK and the database can decrypt every user's master key. Do not commit it to git, do not log it, do not paste it into chat.
+
+Recommended storage:
+
+- **Docker Secrets**: `secrets:` block in compose, mounted into the container at `/run/secrets/mana_auth_kek`
+- **HashiCorp Vault / AWS Secrets Manager / Google Secret Manager**: inject at boot via init container or sidecar
+- **systemd `LoadCredential=`**: when running mana-auth as a systemd service
+
+Avoid plaintext `.env` files on production hosts where possible.
+
+### What if I lose the KEK?
+
+Every user's master key in `auth.encryption_vaults` becomes unrecoverable. The wrapped data on disk is mathematically opaque without the KEK. There is no backup path on the server side — by design.
+
+The mitigation: users who care about that risk should opt into **Zero-Knowledge Mode** in **Settings → Sicherheit**, which moves the wrap from the server-side KEK to a client-held recovery code. After that, the KEK loss only affects new user sign-ups, not existing accounts.
+
+### KEK rotation
+
+KEK rotation today requires planned downtime — there is no background re-wrap job yet. The `kek_id` column on `auth.encryption_vaults` is reserved for the future migration path. Track issue [#TODO] for when this lands.
+
+For now, treat the KEK as long-lived and rotate JWT signing keys (which are independent) on the regular schedule instead.
+
## Reverse Proxy Setup
### Nginx
diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md
index 6d719f5fc..73257f69e 100644
--- a/apps/mana/CLAUDE.md
+++ b/apps/mana/CLAUDE.md
@@ -4,82 +4,118 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-Mana Apps is a monorepo containing multiple applications that share a unified authentication system powered by Supabase. The repository includes:
+**Mana** is the unified web application serving 27+ product modules (todo, calendar, contacts, chat, notes, dreams, memoro, cards, picture, presi, music, storage, …) under one SvelteKit app, one IndexedDB, one auth session, and one deployment at **mana.how**.
-- **Web App** (`apps/web`): SvelteKit-based web application
-- **Mobile App** (`apps/mobile`): React Native (Expo) application for iOS, Android, and web
-- **Landing** (`apps/landing`): Landing page directory (currently minimal/empty)
+- **Web App** (`apps/web`): SvelteKit 2 + Svelte 5 unified app — the main surface
+- **Mobile App** (`apps/mobile`): Expo / React Native (work-in-progress, separate codebase)
+- **Landing** (`apps/landing`): Astro static landing page deployed to Cloudflare Pages
## Architecture
-### Multi-App Ecosystem
+### Unified Module System
-This is a multi-tenant system where a single authentication backend supports multiple branded applications (Memoro, Cards, Storyteller, Mana). Each app shares the same user database but can present different branding and features.
+All modules share one SvelteKit build. Each module lives in `apps/web/src/lib/modules/{name}/` and registers itself via a `module.config.ts` file. The data layer uses a single Dexie IndexedDB (`mana`) containing all 120+ collections from every module — table names that collide across modules are prefixed (e.g. `todoProjects`, `cardDecks`, `presiDecks`).
-**Key concept**: App configuration is centralized in `apps/web/src/lib/config/apps.ts` and defines branding, features, and routing for each application in the ecosystem.
+Module state lives in three files per module:
-### Authentication & Session Management
+| File | Role |
+|------|------|
+| `collections.ts` | Dexie table references + (sometimes) seed data |
+| `queries.ts` | Read-side: Dexie liveQuery hooks, type converters, pure helpers for `$derived` |
+| `stores/*.svelte.ts` | Write-side: mutation methods, no reads (those go through queries.ts) |
-Both web and mobile apps use Supabase for authentication with different approaches:
+### Authentication
-**Web App (SvelteKit)**:
+**Mana Auth** is the central authentication service (`services/mana-auth/`, port 3001), built on Better Auth + Hono + Bun. The web app talks to it via the shared `@mana/shared-auth` client.
-- Server-side authentication using `@supabase/ssr`
-- Two-hook middleware system in `apps/web/src/hooks.server.ts`:
- - `supabase` hook: Creates Supabase client per request with cookie management
- - `authGuard` hook: Validates JWT, protects `(app)` routes, redirects based on auth state
-- Safe session validation: Uses `safeGetSession()` which validates JWT via `getUser()` instead of just reading from cookies
-- Route groups: `(auth)` for login/register, `(app)` for protected dashboard pages
+- **Token format**: EdDSA JWT with minimal claims (`sub`, `email`, `role`, `sid`, `tier`)
+- **Session storage**: Cookies (`*.mana.how` domain) + JWT in memory
+- **Route protection**: `(app)` group is auth-gated via the `AuthGate` component in the layout
+- **Cross-app SSO**: Same Mana Auth session works across all `*.mana.how` apps
+- **Access tiers**: `guest` < `public` < `beta` < `alpha` < `founder` — apps can require a minimum tier via `mana-apps.ts`
-**Mobile App (Expo)**:
+The legacy Supabase integration was removed. Anything mentioning `@supabase/ssr`, `safeGetSession()`, or `event.locals.supabase` is a leftover from a much earlier iteration and should be deleted on sight.
-- Client-side authentication using `@supabase/supabase-js`
-- Custom memory storage implementation (`apps/mobile/utils/memoryStorage.ts`) for session persistence
-- `AuthProvider` component in `apps/mobile/app/_layout.tsx` handles auth state and navigation
-- Platform-specific configuration (web build disables realtime to avoid import issues)
+### Data Layer (Local-First)
-### Database Schema
+The app reads and writes to IndexedDB **first**, then syncs to the server in the background via the **mana-sync** Go service (port 3050).
-Key tables (inferred from queries):
+Architecture diagram:
-- `users`: User profiles linked via `auth_id` to Supabase Auth users
-- `user_roles`: Junction table linking users to organizations (with role information)
-- `organizations`: Organization entities
-- `teams`: Team entities within organizations
-- `team_members`: Junction table linking users to teams
+```
+User action (e.g. tasksStore.createTask)
+ │
+ ▼
+Module store builds the LocalRecord
+ │
+ ▼
+encryptRecord(tableName, record) ← Phase 4–9 encryption layer
+ │
+ ▼
+table.add(encryptedRecord) ← Dexie write
+ │
+ ▼
+Dexie hooks (database.ts):
+ - stamp userId
+ - stamp __fieldTimestamps per field
+ - record into _pendingChanges
+ - record into _activity
+ │
+ ▼
+Sync engine (sync.ts) — debounced 1s
+ - groups changes by appId
+ - POSTs to mana-sync
+ │
+ ▼
+mana-sync → PostgreSQL with field-level LWW + RLS
+ │
+ ▼
+Other clients pull via SSE / polling
+ │
+ ▼
+applyServerChanges → Dexie hooks (suppressed) → liveQuery → decryptRecord → UI
+```
+
+For the deep dive — sync engine, retry/backoff, quota recovery, telemetry, RLS, encryption rollout — read **`apps/web/src/lib/data/DATA_LAYER_AUDIT.md`**. This is the single most important file for understanding how the app works under the hood.
+
+### At-Rest Encryption
+
+User-typed content in **27 tables** is encrypted with **AES-GCM-256** before it touches IndexedDB. The master key lives in `mana-auth` (KEK-wrapped) and is fetched on login.
+
+Two trust modes:
+
+| Mode | Default | What Mana can decrypt |
+|------|---------|----------------------|
+| Standard | ✅ Yes | The user's master key, via the server-side KEK |
+| Zero-Knowledge | Opt-in (Settings → Sicherheit) | Nothing — recovery code lives only with the user |
+
+**When writing module code that touches sensitive fields:**
+
+1. Add the table to `apps/web/src/lib/data/crypto/registry.ts` with the field allowlist
+2. Wrap writes: `await encryptRecord(tableName, record)` before `table.add()` / `table.update()`
+3. Wrap reads: `decryptRecords(tableName, visible)` after the Dexie query, before the type converter
+4. The Dexie hook in `database.ts` does NOT auto-encrypt — every store does it explicitly. This is by design (Web Crypto is async, hooks are sync).
+
+For new sensitive fields, default to **encrypt**. For new structural fields (IDs, timestamps, enums, sort/filter keys), default to **plaintext**.
+
+User-facing docs at `apps/docs/src/content/docs/architecture/security.mdx`.
### Routing Structure
-**Web App** (SvelteKit file-based routing):
-
```
-routes/
-├── (auth)/ # Public authentication pages
-│ ├── login/
-│ └── register/
-├── (app)/ # Protected application pages
-│ ├── dashboard/
-│ ├── organizations/
+apps/web/src/routes/
+├── (auth)/ # Public auth pages (login, register, recovery)
+├── (app)/ # Auth-gated app surface — 27+ module routes
+│ ├── dashboard/ # Customizable widget grid
│ ├── settings/
-│ └── teams/
-└── api/ # API endpoints
+│ │ └── security/ # Vault status + recovery code + ZK opt-in
+│ ├── todo/ # …and many more module routes
+│ └── …
+├── workbench/ # Multi-pane interface (ListView + DetailView per module)
+└── api/ # SvelteKit API endpoints (rare; most data is local-first)
```
-**Mobile App** (Expo Router):
-
-```
-app/
-├── (drawer)/ # Main drawer navigation
-│ ├── (tabs)/ # Nested tab navigation
-│ ├── organizations/
-│ ├── teams/
-│ ├── settings/
-│ └── apps/
-├── auth/ # Auth-related screens
-└── login # Login screen
-```
-
-### Path Aliases (Web App)
+### Path Aliases
Defined in `apps/web/svelte.config.js`:
@@ -102,13 +138,12 @@ pnpm dev # Start dev server on port 5173
# Building
pnpm build # Build for production
-pnpm preview # Preview production build on port 4173
+pnpm preview # Preview production build
# Code Quality
pnpm check # Type-check with svelte-check
-pnpm check:watch # Type-check in watch mode
-pnpm lint # Check formatting and lint
-pnpm format # Format code with Prettier
+pnpm lint # Format check + ESLint
+pnpm format # Prettier write
# Testing
pnpm test # Run Vitest unit tests
@@ -116,113 +151,108 @@ pnpm test:ui # Run Vitest with UI
pnpm test:e2e # Run Playwright E2E tests
```
+For full local-dev setup (Mana Auth + mana-sync + web together), use the root-level `dev:*:full` commands. See `docs/LOCAL_DEVELOPMENT.md` and the root `CLAUDE.md`.
+
### Mobile App (apps/mobile)
-```bash
-cd apps/mobile
-
-# Development
-npm start # Start Expo dev server with dev client
-npm run ios # Run on iOS simulator
-npm run android # Run on Android emulator
-npm run web # Run web version on port 19006
-
-# Building (EAS)
-npm run build:dev # Build development client
-npm run build:preview # Build preview/internal distribution
-npm run build:prod # Build production (auto-increment version)
-
-# Code Quality
-npm run lint # Lint and check formatting
-npm run format # Fix linting and format code
-
-# Setup
-npm run prebuild # Generate native projects
-```
+The mobile app is currently lower-priority than the web app and may lag behind in features. Standard Expo commands apply (`pnpm start`, `pnpm ios`, `pnpm android`, EAS builds for production).
## Environment Configuration
-Both apps require Supabase credentials. Copy the `.env.example` files and configure:
+Generated automatically from the root `.env.development` via `pnpm setup:env`. The relevant variables for the web app:
-**Web App** (`apps/web/.env`):
-
-```
-PUBLIC_SUPABASE_URL=your_supabase_url
-PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
-MIDDLEWARE_URL=https://mana-middleware-111768794939.europe-west3.run.app
+```env
+PUBLIC_MANA_AUTH_URL=http://localhost:3001
+PUBLIC_MANA_SYNC_URL=http://localhost:3050
```
-**Mobile App** (`apps/mobile/.env`):
-
-```
-EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
-EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
-```
+The web app does NOT read or store any database credentials directly — all server-side data goes through Mana Auth + mana-sync. See the root `CLAUDE.md` for the full env-var rundown.
## Technology Stack
### Web App
-- **Framework**: SvelteKit 2 with Svelte 5
-- **Styling**: TailwindCSS with PostCSS
-- **Database**: Supabase (PostgreSQL)
-- **Auth**: Supabase Auth with SSR
-- **Testing**: Vitest (unit), Playwright (E2E)
+- **Framework**: SvelteKit 2 + Svelte 5 (runes mode throughout)
+- **Styling**: TailwindCSS
+- **Auth**: Mana Auth (Better Auth + EdDSA JWT) via `@mana/shared-auth`
+- **Data**: Local-first with Dexie.js + mana-sync (Go) backend
+- **Encryption**: AES-GCM-256 via Web Crypto, server-wrapped MK with optional zero-knowledge mode
+- **Testing**: Vitest (unit + integration with fake-indexeddb), Playwright (E2E)
- **Build**: Vite
### Mobile App
-- **Framework**: Expo 52 with React Native 0.76
-- **Routing**: Expo Router 4 (file-based)
-- **Styling**: NativeWind (TailwindCSS for React Native)
-- **Navigation**: React Navigation (drawer, tabs)
-- **Database**: Supabase
+- **Framework**: Expo + React Native
+- **Routing**: Expo Router (file-based)
+- **Styling**: NativeWind
- **Build**: EAS Build
-- **Platforms**: iOS, Android, Web
## Important Patterns
-### Server-Side Data Loading (Web)
+### Module Store Pattern
-Use `+page.server.ts` files for server-side data fetching with automatic auth context:
+Every module has a mutation-only store that handles writes + a queries file that handles reads. The store NEVER reads from Dexie via `await table.toArray()` for UI rendering — that's the queries file's job (via liveQuery hooks). The store only reads when it needs to mutate based on existing state (e.g. toggle, increment).
```typescript
-export const load: PageServerLoad = async ({ locals: { supabase, session } }) => {
- if (!session) {
- throw redirect(307, '/login');
- }
+// modules/todo/stores/tasks.svelte.ts
+import { taskTable } from '../collections';
+import { encryptRecord } from '$lib/data/crypto';
+import { toTask } from '../queries';
- const { data } = await supabase.from('table_name').select('*').eq('user_id', session.user.id);
-
- return { data };
+export const tasksStore = {
+ async createTask(input: {...}) {
+ const newLocal: LocalTask = { ...input, id: crypto.randomUUID() };
+ const plaintextSnapshot = toTask({ ...newLocal });
+ await encryptRecord('tasks', newLocal);
+ await taskTable.add(newLocal);
+ return plaintextSnapshot;
+ },
+ // ...
};
```
-### Supabase Client Access
+```typescript
+// modules/todo/queries.ts
+export function useAllTasks() {
+ return useLiveQueryWithDefault(async () => {
+ const locals = await db.table('tasks').orderBy('order').toArray();
+ const visible = locals.filter((t) => !t.deletedAt);
+ const decrypted = await decryptRecords('tasks', visible);
+ return decrypted.map(toTask);
+ }, [] as Task[]);
+}
+```
-**Web**: Access via `event.locals.supabase` in server code, or use helper functions in `$server/supabase.ts`:
+### Svelte 5 Runes Mode
-- `getUser(event)`: Get current user
-- `getSession(event)`: Get current session
-- `requireAuth(event)`: Require auth or throw error
-- `getSupabaseServerClient(event)`: Get the Supabase client
+All routes and components use Svelte 5 runes:
-**Mobile**: Import directly from `~/utils/supabase.ts`
+```typescript
+// CORRECT
+let count = $state(0);
+let doubled = $derived(count * 2);
+$effect(() => {
+ console.log(count);
+});
+
+// WRONG (legacy Svelte 3/4)
+let count = 0;
+$: doubled = count * 2;
+```
+
+### Auth Access
+
+Auth state lives in `$lib/stores/auth.svelte.ts`. The current user id is also pushed into the data layer's `current-user.ts` so the Dexie creating-hook can auto-stamp `userId` on every record. Module stores never need to know who the current user is.
### Route Protection
-**Web**: Automatic via `authGuard` hook in `hooks.server.ts`. Routes in `(app)` group are protected.
+The `(app)` group is wrapped by an `AuthGate` component that redirects unauthenticated users to `/login` and reads the access tier from the JWT to gate beta/alpha/founder-only modules.
-**Mobile**: Handled by `AuthProvider` in `_layout.tsx` which redirects unauthenticated users to `/login`.
+## Reference Documents
-## Multi-App Branding
-
-When adding new apps to the ecosystem, update `apps/web/src/lib/config/apps.ts` with:
-
-- App name and display name
-- Tagline and description
-- Logo emoji and colors
-- Feature list for marketing
-- Dashboard route
-
-The welcome page will automatically render the appropriate branding based on app context.
+| Path | Purpose |
+|------|---------|
+| `apps/web/src/lib/data/DATA_LAYER_AUDIT.md` | Complete data-layer architecture, sync engine, encryption rollout, threat model, backlog |
+| `apps/docs/src/content/docs/architecture/security.mdx` | User-facing security & encryption walkthrough |
+| `apps/docs/src/content/docs/architecture/authentication.mdx` | Auth flow + JWT structure |
+| Root `CLAUDE.md` | Monorepo overview, dev commands, sibling services |
diff --git a/services/mana-auth/CLAUDE.md b/services/mana-auth/CLAUDE.md
index 3912d693c..924474f8c 100644
--- a/services/mana-auth/CLAUDE.md
+++ b/services/mana-auth/CLAUDE.md
@@ -49,6 +49,26 @@ OpenID Connect provider for Matrix/Synapse SSO.
Aggregates data from 3 sources: auth DB (sessions, accounts, 2FA, passkeys), mana-credits (balance, transactions), mana-sync DB (entity counts per app).
+### Encryption Vault (`/api/v1/me/encryption-vault/*`)
+
+Per-user master-key custody for the Mana data-layer encryption. The browser fetches its master key here on first login and re-fetches on each session start. The key itself never lives in the database — it's wrapped with the service-wide KEK (loaded from `MANA_AUTH_KEK`).
+
+| Method | Path | Description |
+|--------|------|-------------|
+| GET | `/status` | Cheap metadata read: `{ vaultExists, hasRecoveryWrap, zeroKnowledge, recoverySetAt }`. No decryption, no audit row. Used by the settings page on mount. |
+| POST | `/init` | Idempotent vault initialisation. Mints + KEK-wraps a fresh master key on first call, returns the existing one on subsequent calls. |
+| GET | `/key` | Hot path. Returns either `{ masterKey, formatVersion, kekId }` (standard mode) or `{ requiresRecoveryCode: true, recoveryWrappedMk, recoveryIv }` (zero-knowledge mode). |
+| POST | `/rotate` | Mints a fresh master key. Old MK is gone — caller must re-encrypt or accept loss. **Forbidden in zero-knowledge mode** (`409 ZK_ROTATE_FORBIDDEN`). |
+| POST | `/recovery-wrap` | Stores a client-built recovery wrap: `{ recoveryWrappedMk, recoveryIv }`. The recovery secret itself NEVER touches the wire. Idempotent — replaces existing wrap. |
+| DELETE | `/recovery-wrap` | Removes the recovery wrap. **Forbidden in zero-knowledge mode** (`409 ZK_ACTIVE`) — would lock the user out. |
+| POST | `/zero-knowledge` | Toggles ZK mode. `{ enable: true }` requires a recovery wrap to be set first (else `400 RECOVERY_WRAP_MISSING`). `{ enable: false, masterKey: base64 }` requires the freshly-unwrapped MK from the client so the server can KEK-re-wrap it. |
+
+All routes write to `auth.encryption_vault_audit` for security investigations. Three database CHECK constraints enforce vault consistency at the schema level (`encryption_vaults_has_wrap`, `encryption_vaults_wrap_iv_pair`, `encryption_vaults_zk_consistency`) so a code-level bug can't accidentally lock a user out.
+
+Schema lives in `src/db/schema/encryption-vaults.ts`, service in `src/services/encryption-vault/`. Migration files: `sql/002_encryption_vaults.sql` (Phase 2: tables + RLS) and `sql/003_recovery_wrap.sql` (Phase 9: recovery columns + ZK constraints).
+
+For the full architectural deep-dive, threat model, and rollout history (Phases 1–9 + backlog sweep), see `apps/mana/apps/web/src/lib/data/DATA_LAYER_AUDIT.md`. User-facing docs at `apps/docs/src/content/docs/architecture/security.mdx`.
+
### Admin (`/api/v1/admin/*`)
| Method | Path | Description |
|--------|------|-------------|
@@ -84,6 +104,13 @@ SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
SYNAPSE_OIDC_CLIENT_SECRET=...
+
+# Encryption Vault — REQUIRED IN PRODUCTION
+# Base64-encoded 32-byte AES-256 key. Generate with `openssl rand -base64 32`.
+# The dev fallback is 32 zero bytes (prints a loud warning at startup).
+# This key wraps every user's master key in auth.encryption_vaults — guard
+# it like a database password. Provision via Docker secret / KMS / Vault.
+MANA_AUTH_KEK=
```
## Critical Rules