Appearance
Avelia — Project Init Design
Date: 2026-02-28 Scope: Backend scaffold + auth, Flutter scaffold + auth, E2EE crypto foundation
What This Builds
The foundation layer both projects build on. Entry logging, partner feeds, and stage-aware UI come later. This init delivers:
- Both projects running locally end-to-end
- Auth (register / login / refresh / logout) — backend to Flutter screens
- MEK crypto layer — every subsequent feature encrypts/decrypts correctly from day one
- Device registration — WebSocket notification targeting is possible from day one
- Partnership table (many-to-many) — multi-partner is never a future migration
E2EE Architecture
Key Hierarchy
password + email + argon2Salt
──Argon2id(memory=64MB, iterations=3, parallelism=1)──▶ MEK (32 bytes, AES-256)
Per entry (built in future features):
random EK (32 bytes) ──AES-256-GCM──▶ ciphertext
recipientKeys: {
ownerId: AES-KW(EK, ownerMEK),
partnerId1: AES-KW(EK, partner1MEK), ← added on share
partnerId2: AES-KW(EK, partner2MEK), ← multi-partner supported
}Principles
- MEK never leaves the device. The server stores
argon2Saltonly — never the MEK or any MEK-derived value. - Per-entry keys. Each entry has a random AES-256-GCM key. Adding or revoking a recipient = updating
recipientKeysmap only; payload ciphertext unchanged. - Multi-device. Any device that knows the password can re-derive the same MEK (same
email + password + argon2Salt). No device-to-device key transfer required. - Multi-partner.
recipientKeysis a map keyed byuserId. Sharing with a new partner appends one entry; revocation removes it. - Server sees only:
{ ciphertext, recipientKeys, metadata }— zero plaintext.
Upgrade path
This init implements MEK derivation and storage. Argon2id parameters (memory, iterations) are configurable — can be tuned upward without breaking existing data. Option 3 (random MEK + device pairing QR) can be introduced later as "Trusted Device Setup" without a migration.
Recovery Strategy (Technical)
To address the "Lost Password" risk, we will support:
- Partner Recovery:
recipientKeysmap on entries already supports multiple users.- Mechanism: Partner's client re-encrypts the
entryKeywith the recovered user's new public key (derived from new password/MEK).
- Recovery Kit:
- Export
MEK(or a derivedrecoveryKey) as a QR code (Base45/Bech32 encoded) during onboarding. - Import: Scan QR -> decode -> restore
MEKto secure storage.
- Export
- Platform Backup:
- Use
flutter_secure_storagewithiCloud(iOS) andGoogle Block Store(Android) flags enabled for a specific "backup" slot. - This keeps the key within the OS's secure enclave ecosystem, never sending it to Avelia servers.
- Use
Backend
Tech Stack
| Layer | Choice |
|---|---|
| Runtime | Node.js 25 LTS |
| Language | TypeScript 5 (strict) |
| Framework | NestJS 11 |
| Package manager | pnpm |
| Database | PostgreSQL 16 |
| ORM | Prisma 5 |
| Auth | JWT (access 15m / refresh 7d) + Argon2 (passwords) |
| Validation | class-validator + class-transformer |
| Testing | Jest + Supertest |
Database Schema
prisma
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
argon2Salt String // hex-encoded; sent to client so Flutter can derive MEK
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
devices Device[]
refreshTokens RefreshToken[]
partnerships Partnership[] @relation("requester")
partnerOf Partnership[] @relation("target")
}
model Device {
id String @id @default(cuid())
userId String
deviceId String @unique // UUID generated by Flutter on first launch
pushToken String? // FCM / APNs token, optional
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
refreshTokens RefreshToken[]
}
model RefreshToken {
id String @id @default(cuid())
userId String
deviceId String
tokenHash String @unique // bcrypt hash of the token
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
}
model Partnership {
id String @id @default(cuid())
requesterId String
targetId String
status PartnershipStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
requester User @relation("requester", fields: [requesterId], references: [id])
target User @relation("target", fields: [targetId], references: [id])
@@unique([requesterId, targetId])
}
enum PartnershipStatus {
PENDING
ACTIVE
REVOKED
}Auth Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /auth/register | public | Create user + device, return tokens + argon2Salt |
| POST | /auth/login | public | Verify password, return tokens + argon2Salt |
| POST | /auth/refresh | public (refresh token in body) | Rotate refresh token, return new access token |
| POST | /auth/logout | JWT | Delete refresh token row |
| POST | /auth/devices | JWT | Register/update device (deviceId + pushToken) |
| GET | /health | public | { status: "ok", timestamp } |
Module Structure
src/
auth/
auth.module.ts
auth.service.ts
auth.controller.ts
strategies/
jwt.strategy.ts
jwt-refresh.strategy.ts
guards/
jwt-auth.guard.ts
dto/
register.dto.ts
login.dto.ts
refresh.dto.ts
decorators/
public.decorator.ts
current-user.decorator.ts
users/
users.module.ts
users.service.ts
devices/
devices.module.ts
devices.service.ts
devices.controller.ts
dto/
register-device.dto.ts
common/
filters/
global-exception.filter.ts
interceptors/
response-transform.interceptor.ts
pipes/ ← ValidationPipe configured globally
prisma/
prisma.module.ts
prisma.service.ts
health/
health.controller.ts
app.module.ts
main.tsE2E Tests (auth)
POST /auth/register— happy path, duplicate email 409, invalid body 400POST /auth/login— happy path, wrong password 401, unknown email 401POST /auth/refresh— happy path, expired token 401, reuse after rotation 401POST /auth/logout— happy path, already logged out 401POST /auth/devices— register device, update pushToken
Flutter
Tech Stack
| Layer | Package |
|---|---|
| SDK | Flutter 3.35.7 via FVM (Dart 3.9.2) |
| State | flutter_riverpod + riverpod_annotation + riverpod_generator |
| Code gen | build_runner + freezed + json_serializable + retrofit_generator |
| Navigation | go_router |
| HTTP | dio + retrofit |
| Local DB | drift (SQLite) |
| Crypto | cryptography (Argon2id, AES-256-GCM) |
| Secure storage | flutter_secure_storage |
| Testing | flutter_test + mocktail |
MEK Derivation Flow
Register:
1. App calls POST /auth/register → receives argon2Salt (hex)
2. Flutter derives: MEK = Argon2id(password, salt, memory=64MB, iter=3)
3. MEK stored: flutter_secure_storage["mek_<userId>"]
4. Tokens stored: flutter_secure_storage["access_<userId>"], ["refresh_<userId>"]
Login (existing device):
1. App calls POST /auth/login → receives argon2Salt + tokens
2. Flutter re-derives MEK same way
3. MEK + tokens stored as above
New device login:
Identical to login — same email+password+salt = same MEK.
No device-to-device coordination needed.
MEK usage (future features):
cryptoService.encryptEntry(plaintext) → { ciphertext, entryKey: AES-KW(EK, MEK) }
cryptoService.decryptEntry(ciphertext, wrappedEK) → plaintextFolder Structure
lib/
features/
auth/
data/
auth_api.dart # Retrofit interface
auth_repository_impl.dart # implements AuthRepository
dtos/
register_request.dto.dart
login_request.dto.dart
auth_response.dto.dart
domain/
auth_repository.dart # abstract interface
models/
auth_user.dart # @freezed
auth_tokens.dart # @freezed
presentation/
screens/
login_screen.dart
register_screen.dart
widgets/
auth_form_field.dart
providers/
auth_provider.dart # @riverpod authState
core/
crypto/
crypto_service.dart # MEK derivation + AES-256-GCM encrypt/decrypt
crypto_provider.dart # @riverpod cryptoService
router/
app_router.dart # go_router + auth redirect guard
network/
dio_client.dart # Dio setup, base URL from --dart-define
auth_interceptor.dart # attach Bearer, handle 401 → refresh
api_provider.dart # @riverpod dio + authApi
storage/
secure_storage.dart # flutter_secure_storage wrapper
secure_storage_provider.dart # @riverpod secureStorage
theme/
app_theme.dart
app_colors.dart
app_text_styles.dart
shared/
widgets/
primary_button.dart
loading_overlay.dart
extensions/
string_extensions.dart
main.dart # runApp entry
bootstrap.dart # ProviderScope + runAppAuth Guard (go_router)
dart
redirect: (context, state) {
final isAuthenticated = ref.read(authStateProvider).hasTokens;
final isOnAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isOnAuthRoute) return '/auth/login';
if (isAuthenticated && isOnAuthRoute) return '/home';
return null;
}Widget Tests
LoginScreen— renders correctly, shows error on failed login, navigates on successRegisterScreen— renders correctly, shows error on failed register, navigates on success
What Is NOT in This Init
Deliberately excluded — each gets its own plan:
- Entry logging (Drift schema for entries, sync engine, WebSocket gateway)
- Partner linking flow (invite codes, Partnership activation)
- Biometric lock (
local_auth) - Push notifications (FCM/APNs setup)
- Any of the 7 stage-specific UI
recipientKeysentry encryption (crypto primitives are built here; usage is in entry features)
Success Criteria
pnpm run start:devstarts the NestJS server with no errorspnpm run test:e2e— all auth E2E tests passfvm flutter runlaunches the app on a simulator/device- Register flow: user enters email + password → account created → MEK derived + stored → navigates to
/home - Login flow: existing user → MEK re-derived → navigates to
/home - All Riverpod providers resolve without error in widget tests