docs: Phase 9 documentation roundup — close encryption-shaped doc gaps

Five documentation surfaces gained encryption awareness in this
sweep. Before this commit, the only place anyone could learn about
the at-rest encryption layer or the zero-knowledge opt-in was the
internal DATA_LAYER_AUDIT.md. New contributors and self-hosters
would never discover one of the most important features of the
product just by reading the standard onboarding docs.

apps/docs/src/content/docs/architecture/security.mdx (NEW)
----------------------------------------------------------
First-class user-facing security page in the Starlight site,
slotted into the Architecture sidebar between Authentication and
Backend.

Sections:
  - What's encrypted (overview table of 27 modules + the
    intentional plaintext carve-outs)
  - Standard mode flow with ASCII diagram
  - "What Mana CAN see" trust statements per mode
  - Zero-knowledge mode setup walkthrough (Steps component)
  - Unlock flow on a new device
  - Recovery code rotation
  - Deployment requirements (the loud MANA_AUTH_KEK warning)
  - Audit trail action vocabulary
  - Threat model summary table
  - Implementation file references with paths

services/mana-auth/CLAUDE.md
----------------------------
New "Encryption Vault" section under Key Endpoints, listing all 7
routes (status, init, key, rotate, recovery-wrap GET+DELETE,
zero-knowledge) with their HTTP method, path, error codes, and a
description. Mentions the three CHECK constraints + RLS + audit
table. Points readers at DATA_LAYER_AUDIT.md and the new
security.mdx for the deep dive.

Environment Variables block gains MANA_AUTH_KEK with a multi-line
comment explaining the openssl rand command + dev fallback warning.

apps/mana/CLAUDE.md
-------------------
Full rewrite. The existing file was from the Supabase era and
described things like @supabase/ssr, safeGetSession(), and a
five-table schema with users + organizations + teams that doesn't
exist any more. Replaced with the unified-app architecture:

  - Module system layout (collections.ts / queries.ts / stores/)
  - Mana Auth (Better Auth + EdDSA JWT) instead of Supabase
  - Local-first data layer with the full pipeline diagram
  - At-rest encryption section with the "when writing module code
    that touches sensitive fields" 4-step guide
  - Updated routing structure (no more separate /organizations,
    /teams routes)
  - Module store pattern code example
  - Reference document table at the bottom pointing at the audit,
    the new security.mdx, and the auth doc

Root CLAUDE.md
--------------
New "At-Rest Encryption (Phase 1–9)" subsection under the
Local-First Architecture section. Two-mode trust summary table,
production requirement for MANA_AUTH_KEK with the openssl command,
the "when writing module code" 4-step guide, and a reference
table. New contributors reading the root CLAUDE.md from top to
bottom now hit encryption naturally as part of the data layer
discussion.

.env.macmini.example
--------------------
MANA_AUTH_KEK was missing from the production env example
entirely — the macmini deployment would silently boot on the
32-zero-byte dev fallback if you copied this file. Added with a
multi-paragraph comment covering: how to generate, why it's
required, how to store securely (Docker secrets / KMS / Vault),
and the rotation caveat.

apps/docs/src/content/docs/deployment/self-hosting.mdx
------------------------------------------------------
Two changes:

  1. Added MANA_AUTH_KEK to the mana-auth service block in the
     Compose example with an inline comment pointing at the new
     section below.

  2. New "Encryption Vault Setup" H2 section with subsections:
     - Generating a KEK (with a fake example value labelled DO NOT
       USE — generate your own)
     - Securing the KEK (Docker secrets, KMS, systemd
       LoadCredential, anti-patterns)
     - "What if I lose the KEK?" — explains the data is
       unrecoverable by design and mitigation via zero-knowledge
       mode opt-in
     - KEK rotation — calls out the missing background re-wrap
       job as a known limitation

apps/docs/astro.config.mjs
--------------------------
Added "Security & Encryption" entry to the Architecture sidebar
between Authentication and Backend so the new page is reachable
from the docs nav.

Astro check: 0 errors, 0 warnings, 0 hints across 4 .astro files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-08 11:47:59 +02:00
parent b961453244
commit 142a65a22f
7 changed files with 483 additions and 132 deletions

View file

@ -32,6 +32,22 @@ JWT_SECRET=your-jwt-secret-here
JWT_PUBLIC_KEY= JWT_PUBLIC_KEY=
JWT_PRIVATE_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) # Supabase (optional, for legacy features)
# ============================================ # ============================================

View file

@ -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. 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 19)
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) ### Dev Commands (Local-First Stack)
```bash ```bash

View file

@ -46,6 +46,7 @@ export default defineConfig({
items: [ items: [
{ label: 'Overview', slug: 'architecture/overview' }, { label: 'Overview', slug: 'architecture/overview' },
{ label: 'Authentication', slug: 'architecture/authentication' }, { label: 'Authentication', slug: 'architecture/authentication' },
{ label: 'Security & Encryption', slug: 'architecture/security' },
{ label: 'Backend (NestJS)', slug: 'architecture/backend' }, { label: 'Backend (NestJS)', slug: 'architecture/backend' },
{ label: 'Web (SvelteKit)', slug: 'architecture/web' }, { label: 'Web (SvelteKit)', slug: 'architecture/web' },
{ label: 'Mobile (Expo)', slug: 'architecture/mobile' }, { label: 'Mobile (Expo)', slug: 'architecture/mobile' },

View file

@ -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**.
<Steps>
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**.
</Steps>
### 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
<Aside type="caution" title="MANA_AUTH_KEK is required in production">
mana-auth refuses to start without `MANA_AUTH_KEK` set to a base64-encoded 32-byte value. The dev fallback is 32 zero bytes, which prints a loud warning. Generate a real key with:
```bash
openssl rand -base64 32
```
Store it as a Docker secret, KMS-injected env var, or Vault-served value. The KEK never touches the database — it lives only in process memory.
</Aside>
### 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 19 + 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 |

View file

@ -120,6 +120,10 @@ services:
REDIS_PASSWORD: ${REDIS_PASSWORD} REDIS_PASSWORD: ${REDIS_PASSWORD}
JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY} JWT_PRIVATE_KEY: ${JWT_PRIVATE_KEY}
JWT_PUBLIC_KEY: ${JWT_PUBLIC_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: depends_on:
postgres: postgres:
condition: service_healthy 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)" 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 ## Reverse Proxy Setup
### Nginx ### Nginx

View file

@ -4,82 +4,118 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## 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 - **Web App** (`apps/web`): SvelteKit 2 + Svelte 5 unified app — the main surface
- **Mobile App** (`apps/mobile`): React Native (Expo) application for iOS, Android, and web - **Mobile App** (`apps/mobile`): Expo / React Native (work-in-progress, separate codebase)
- **Landing** (`apps/landing`): Landing page directory (currently minimal/empty) - **Landing** (`apps/landing`): Astro static landing page deployed to Cloudflare Pages
## Architecture ## 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` - **Token format**: EdDSA JWT with minimal claims (`sub`, `email`, `role`, `sid`, `tier`)
- Two-hook middleware system in `apps/web/src/hooks.server.ts`: - **Session storage**: Cookies (`*.mana.how` domain) + JWT in memory
- `supabase` hook: Creates Supabase client per request with cookie management - **Route protection**: `(app)` group is auth-gated via the `AuthGate` component in the layout
- `authGuard` hook: Validates JWT, protects `(app)` routes, redirects based on auth state - **Cross-app SSO**: Same Mana Auth session works across all `*.mana.how` apps
- Safe session validation: Uses `safeGetSession()` which validates JWT via `getUser()` instead of just reading from cookies - **Access tiers**: `guest` < `public` < `beta` < `alpha` < `founder` — apps can require a minimum tier via `mana-apps.ts`
- Route groups: `(auth)` for login/register, `(app)` for protected dashboard pages
**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` ### Data Layer (Local-First)
- 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)
### 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) User action (e.g. tasksStore.createTask)
- `organizations`: Organization entities
- `teams`: Team entities within organizations
- `team_members`: Junction table linking users to teams Module store builds the LocalRecord
encryptRecord(tableName, record) ← Phase 49 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 ### Routing Structure
**Web App** (SvelteKit file-based routing):
``` ```
routes/ apps/web/src/routes/
├── (auth)/ # Public authentication pages ├── (auth)/ # Public auth pages (login, register, recovery)
│ ├── login/ ├── (app)/ # Auth-gated app surface — 27+ module routes
│ └── register/ │ ├── dashboard/ # Customizable widget grid
├── (app)/ # Protected application pages
│ ├── dashboard/
│ ├── organizations/
│ ├── settings/ │ ├── settings/
│ └── teams/ │ │ └── security/ # Vault status + recovery code + ZK opt-in
└── api/ # API endpoints │ ├── 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): ### Path Aliases
```
app/
├── (drawer)/ # Main drawer navigation
│ ├── (tabs)/ # Nested tab navigation
│ ├── organizations/
│ ├── teams/
│ ├── settings/
│ └── apps/
├── auth/ # Auth-related screens
└── login # Login screen
```
### Path Aliases (Web App)
Defined in `apps/web/svelte.config.js`: Defined in `apps/web/svelte.config.js`:
@ -102,13 +138,12 @@ pnpm dev # Start dev server on port 5173
# Building # Building
pnpm build # Build for production pnpm build # Build for production
pnpm preview # Preview production build on port 4173 pnpm preview # Preview production build
# Code Quality # Code Quality
pnpm check # Type-check with svelte-check pnpm check # Type-check with svelte-check
pnpm check:watch # Type-check in watch mode pnpm lint # Format check + ESLint
pnpm lint # Check formatting and lint pnpm format # Prettier write
pnpm format # Format code with Prettier
# Testing # Testing
pnpm test # Run Vitest unit tests pnpm test # Run Vitest unit tests
@ -116,113 +151,108 @@ pnpm test:ui # Run Vitest with UI
pnpm test:e2e # Run Playwright E2E tests 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) ### Mobile App (apps/mobile)
```bash 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).
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
```
## Environment Configuration ## 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`): ```env
PUBLIC_MANA_AUTH_URL=http://localhost:3001
``` PUBLIC_MANA_SYNC_URL=http://localhost:3050
PUBLIC_SUPABASE_URL=your_supabase_url
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
MIDDLEWARE_URL=https://mana-middleware-111768794939.europe-west3.run.app
``` ```
**Mobile App** (`apps/mobile/.env`): 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.
```
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
```
## Technology Stack ## Technology Stack
### Web App ### Web App
- **Framework**: SvelteKit 2 with Svelte 5 - **Framework**: SvelteKit 2 + Svelte 5 (runes mode throughout)
- **Styling**: TailwindCSS with PostCSS - **Styling**: TailwindCSS
- **Database**: Supabase (PostgreSQL) - **Auth**: Mana Auth (Better Auth + EdDSA JWT) via `@mana/shared-auth`
- **Auth**: Supabase Auth with SSR - **Data**: Local-first with Dexie.js + mana-sync (Go) backend
- **Testing**: Vitest (unit), Playwright (E2E) - **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 - **Build**: Vite
### Mobile App ### Mobile App
- **Framework**: Expo 52 with React Native 0.76 - **Framework**: Expo + React Native
- **Routing**: Expo Router 4 (file-based) - **Routing**: Expo Router (file-based)
- **Styling**: NativeWind (TailwindCSS for React Native) - **Styling**: NativeWind
- **Navigation**: React Navigation (drawer, tabs)
- **Database**: Supabase
- **Build**: EAS Build - **Build**: EAS Build
- **Platforms**: iOS, Android, Web
## Important Patterns ## 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 ```typescript
export const load: PageServerLoad = async ({ locals: { supabase, session } }) => { // modules/todo/stores/tasks.svelte.ts
if (!session) { import { taskTable } from '../collections';
throw redirect(307, '/login'); import { encryptRecord } from '$lib/data/crypto';
} import { toTask } from '../queries';
const { data } = await supabase.from('table_name').select('*').eq('user_id', session.user.id); export const tasksStore = {
async createTask(input: {...}) {
return { data }; 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<LocalTask>('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 All routes and components use Svelte 5 runes:
- `getSession(event)`: Get current session
- `requireAuth(event)`: Require auth or throw error
- `getSupabaseServerClient(event)`: Get the Supabase client
**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 ### 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 | Path | Purpose |
|------|---------|
When adding new apps to the ecosystem, update `apps/web/src/lib/config/apps.ts` with: | `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 |
- App name and display name | `apps/docs/src/content/docs/architecture/authentication.mdx` | Auth flow + JWT structure |
- Tagline and description | Root `CLAUDE.md` | Monorepo overview, dev commands, sibling services |
- Logo emoji and colors
- Feature list for marketing
- Dashboard route
The welcome page will automatically render the appropriate branding based on app context.

View file

@ -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). 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 19 + 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/*`) ### Admin (`/api/v1/admin/*`)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
@ -84,6 +104,13 @@ SMTP_PORT=587
SMTP_USER=... SMTP_USER=...
SMTP_PASS=... SMTP_PASS=...
SYNAPSE_OIDC_CLIENT_SECRET=... 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 ## Critical Rules