Skip to content

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:

  1. Both projects running locally end-to-end
  2. Auth (register / login / refresh / logout) — backend to Flutter screens
  3. MEK crypto layer — every subsequent feature encrypts/decrypts correctly from day one
  4. Device registration — WebSocket notification targeting is possible from day one
  5. 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 argon2Salt only — 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 recipientKeys map 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. recipientKeys is a map keyed by userId. 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:

  1. Partner Recovery:
    • recipientKeys map on entries already supports multiple users.
    • Mechanism: Partner's client re-encrypts the entryKey with the recovered user's new public key (derived from new password/MEK).
  2. Recovery Kit:
    • Export MEK (or a derived recoveryKey) as a QR code (Base45/Bech32 encoded) during onboarding.
    • Import: Scan QR -> decode -> restore MEK to secure storage.
  3. Platform Backup:
    • Use flutter_secure_storage with iCloud (iOS) and Google 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.

Backend

Tech Stack

LayerChoice
RuntimeNode.js 25 LTS
LanguageTypeScript 5 (strict)
FrameworkNestJS 11
Package managerpnpm
DatabasePostgreSQL 16
ORMPrisma 5
AuthJWT (access 15m / refresh 7d) + Argon2 (passwords)
Validationclass-validator + class-transformer
TestingJest + 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

MethodPathAuthDescription
POST/auth/registerpublicCreate user + device, return tokens + argon2Salt
POST/auth/loginpublicVerify password, return tokens + argon2Salt
POST/auth/refreshpublic (refresh token in body)Rotate refresh token, return new access token
POST/auth/logoutJWTDelete refresh token row
POST/auth/devicesJWTRegister/update device (deviceId + pushToken)
GET/healthpublic{ 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.ts

E2E Tests (auth)

  • POST /auth/register — happy path, duplicate email 409, invalid body 400
  • POST /auth/login — happy path, wrong password 401, unknown email 401
  • POST /auth/refresh — happy path, expired token 401, reuse after rotation 401
  • POST /auth/logout — happy path, already logged out 401
  • POST /auth/devices — register device, update pushToken

Flutter

Tech Stack

LayerPackage
SDKFlutter 3.35.7 via FVM (Dart 3.9.2)
Stateflutter_riverpod + riverpod_annotation + riverpod_generator
Code genbuild_runner + freezed + json_serializable + retrofit_generator
Navigationgo_router
HTTPdio + retrofit
Local DBdrift (SQLite)
Cryptocryptography (Argon2id, AES-256-GCM)
Secure storageflutter_secure_storage
Testingflutter_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) → plaintext

Folder 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 + runApp

Auth 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 success
  • RegisterScreen — 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
  • recipientKeys entry encryption (crypto primitives are built here; usage is in entry features)

Success Criteria

  • pnpm run start:dev starts the NestJS server with no errors
  • pnpm run test:e2e — all auth E2E tests pass
  • fvm flutter run launches 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

Private by design.