All documentation

Architecture Decision Records

ADR 0001 — Auth module (Module 1) architecture and implementation decisions

Status: accepted, implemented. Context: PROJECT_SPEC.md, docs/modules/01-auth.md, plan at implementation time (/home/kali/.claude/plans/lucky-spinning-tarjan.md).

This records the decisions made building the auth backend — what was decided up front, what changed during implementation, and why. Grouped by when the decision was made: planned (agreed before writing code) vs. discovered (surfaced by actually running the code against a real database and real HTTP requests).


Planned decisions

1. Redis deferred

The spec listed Redis as "(if needed)". Rate limiting uses @nestjs/throttler's in-memory storage (correct for a single instance); MFA challenge / OAuth exchange tokens use short-lived hashed DB rows following the same pattern as email-verification/password-reset tokens, rather than a cache. ThrottlerStorage is swappable via its own interface, so a Redis-backed adapter for multi-instance deployment is a config change later, not a rewrite.

2. Ports-and-adapters repository layer

Every aggregate has a domain/repositories/*.interface.ts (interface + Symbol DI token) and a infrastructure/persistence/prisma/prisma-*.ts adapter. Use-cases depend only on the interface; PrismaService is injected nowhere outside the adapter classes. This was a explicit correction mid-planning — the original proposal was "no repository layer, inject PrismaService directly" (Prisma already being a clean, typed data layer) — overruled in favor of full port/adapter separation so persistence can be swapped later without touching business logic, and so unit tests mock narrow, purpose-built interfaces instead of a generic ORM client.

Repository interfaces expose the specific operations each use-case needs (recordFailedLogin, findValidByHash, ...), not generic CRUD — Interface Segregation over a passthrough surface.

Entity types: repositories return the Prisma-generated model types (User, Session, ...) rather than hand-duplicated domain classes. These are plain data shapes with no query methods attached, so importing them is a type-only dependency — the actual coupling this decision guards against (use-cases calling prisma.user.findUnique(...) directly) is fully eliminated either way.

Cross-repository transactions (register writes Users + Rbac + Organizations + UserPreferences atomically) use @nestjs-cls/transactional's @Transactional() decorator rather than threading a Prisma transaction client through every repository method's signature (which would leak a Prisma-specific type into otherwise persistence-agnostic interfaces).

3. Schema additions beyond the originally-committed model

MfaBackupCode replaces a backupCodes String[] field (a plain array can't be hashed-and-individually-consumed); AuditEvent (userId nullable + onDelete: SetNull so history survives account deletion); MfaChallengeToken and OAuthExchangeCode (same shape as the existing verification/reset tokens).

4. OAuth callback → frontend handoff via one-time exchange code

GET /auth/oauth/:provider/callback creates an OAuthExchangeCode and redirects to ${FRONTEND_URL}/auth/oauth/callback?code=...; the frontend POSTs that code to /auth/oauth/exchange for real tokens. Keeps access/refresh tokens out of the redirect URL entirely (browser history, referrer headers, server logs).

5. No real email provider

MailService is an interface (common/providers/mail/); the only implementation is DevMailService, which logs and writes to apps/api/.mail-outbox/*.txt. Swapping in a real provider later is a new class + one DI binding in providers.module.ts.

6. Response envelope

A global interceptor wraps success bodies as { success: true, data }; a global exception filter returns errors as { statusCode, code, message } — unwrapped (no redundant success: false, the non-2xx status already signals that). Required one additive type in packages/shared (ApiSuccessResponse<T>/ApiErrorResponse).

7. Access token claims and refresh token shape

Access tokens carry roles: string[] and sessionId, 15m TTL. JwtStrategy re-checks user.status on every request (one indexed PK lookup) so a suspension/lock takes effect within that window rather than waiting for the token to expire. Refresh tokens are opaque random strings (not JWTs), 7d TTL (30d with rememberMe), stored only as a sha256 hash — the standard shape for revocable refresh tokens; a signed JWT refresh token would still need a DB check to be revocable, so opaque is strictly simpler.

8. Platform-foundation schema (RBAC, Organizations, ApiKey, UserPreferences, provider interfaces)

Added per explicit direction beyond the original auth-only scope — schema and minimal wiring only, no business features:

AdditionWired this sprintExplicitly not this sprint
Full RBAC (Role/Permission/RolePermission/UserRole)Register assigns USER; JWT roles claim resolved from these tables; RolesGuard/@Roles() mechanism existsNo role/permission management endpoints; nothing is actually gated by role yet
Organization + OrganizationMemberRegister creates a personal Organization + OWNER membershipInvites, switching, multi-member workflows
ApiKeySchema + repository onlyNo create/list/revoke endpoints, no API-key auth strategy
UserPreferencesRegister creates a default rowNo settings-update endpoint
Session device infobrowser/os parsed from User-Agent at session creationlocation column exists, left unpopulated — no geo-IP provider configured
Mail/Storage/Queue provider interfacesInterfaces + dev-local implementations (console+file mail, local-disk storage, in-memory queue), DI-bound and readyNothing consumes Storage/Queue yet — no upload endpoints, no job processors

Discovered during implementation

These weren't anticipated in planning — they surfaced by actually building, booting, and exercising the system against a real database and real HTTP requests, per this project's verification standard of testing runtime behavior rather than trusting a design on paper.

D1. nest build's compiler ignores moduleResolution: nodenext against the generated Prisma client

Prisma 7's prisma-client generator outputs ESM-only TypeScript. apps/api uses moduleResolution: nodenext, which should emit ESM when the nearest package.json has "type": "module" — but nest build's internal compiler emitted CommonJS regardless, crashing at boot (ReferenceError: exports is not defined in ES module scope). Plain tsc -p tsconfig.build.json emits correctly. Switched build to tsc directly and start:dev/start:debug to tsx watch (both handle NodeNext ESM correctly); nest build/nest start are no longer used.

D2. TransactionalAdapterPrisma's default type parameter is @prisma/client's standard client, not ours

Left unparameterized, TransactionHost#tx's type couldn't be resolved from $transaction's signature and silently collapsed to any — caught by eslint's no-unsafe-* rules (not by tsc, since noImplicitAny is off). Added prisma-transaction.types.ts exporting AppTransactionalAdapter = TransactionalAdapterPrisma<PrismaClient> (our generated client), imported by every repository and app.module.ts instead of the bare adapter type.

D3. MFA backup codes: argon2 was the wrong hash, not just a style choice

Originally planned to hash backup codes with argon2 like passwords (both "human-facing secrets"). But the repository does a direct hash lookup (findUnusedBackupCodeByHash), which requires a deterministic hash — argon2 is salted and non-deterministic by design. Backup codes are actually high-entropy crypto-random values (5 bytes via randomBytes(5).toString("hex")), not human-chosen secrets, so sha256 (via TokenService.hashOpaqueToken, the same path used for refresh/ verification/reset tokens) is both correct and simpler.

D4. otplib's verify() throws on malformed input instead of returning {valid:false}

A 10-character backup code reaching TOTP verification (the fallback path in mfa-challenge/mfa-disable) threw TokenLengthError, producing a 500 before the backup-code check could ever run — found by actually attempting a backup-code login live, not by reading the code. Fixed by wrapping TotpService.verifyCode in try/catch, treating any verification failure (wrong code or malformed input) as false.

D5. reset-password session-revocation ordering bug

Original implementation called sessionsRepository.revokeAllForUser() then sessionsRepository.findActiveByUser() to find refresh tokens to revoke — but the first call already marks sessions revoked, so the second query (which filters revokedAt: null) returns nothing, leaving refresh tokens live after a password reset. Caught in code review before it ever ran; fixed by querying active sessions first, then revoking their refresh tokens, then revoking the sessions. Now guarded by an explicit regression test asserting call order.

D6. pnpm's strict node_modules surfaced four transitive-dependency gaps

dotenv, express, @jest/globals, and (early on) sharp/prisma native builds were all used directly (as type imports or runtime imports) but only present transitively through other packages. Under pnpm's default strict linking, TypeScript/Node couldn't resolve them — symptoms ranged from a silent any-typed import (express's Request) to a hard crash (dotenv in the seed script) to a tsc failure only (@jest/globals, since Jest itself resolves fine at runtime through its own module resolution). All four added as direct dependencies.

D7. Express 5 / @types/node header-array typing gap

req.headers['user-agent'], once narrowed via Array.isArray(), typed its array branch as any[] instead of string[] — an upstream typing quirk, not a real type-safety hole (HTTP headers are always strings by spec). Fixed with a documented, narrow cast in extractRequestContext.

D8. pglite (the local dev Postgres) doesn't support migrate dev's shadow-database flow

npx prisma migrate dev failed with P1017: Server has closed the connection against the bundled local dev server; npx prisma db push works fine. Documented as an environment-specific workflow difference — a real deployment target (managed Postgres) should use normal migrate dev/ migrate deploy with full migration history.

D9. pglite corrupts prepared-statement state under concurrent connections

Running the repository integration test suite in Jest's default parallel workers (each opening its own Prisma/pg connection to the same pglite instance) produced DriverAdapterError: bind message supplies N parameters, but prepared statement "" requires 0 on unrelated queries — a backend concurrency limitation, not a product bug (confirmed by running the identical suite serially via --runInBand, which passes reliably). pnpm test:integration runs with --runInBand for this reason.

D10. e2e test suite: shared throttler state across test blocks

The first version of the e2e suite shared one app instance (and therefore one in-memory ThrottlerStorage) across every describe block. Since /auth/login is rate-limited at 5 req/60s and the suite's later blocks (login lockout, MFA lifecycle) each need more than 5 login calls to prove their actual business logic, they collided with earlier blocks' quota usage and with each other. Fixed two ways: (a) each top-level describe block now bootstraps its own app instance, so each starts with fresh throttler storage; (b) the login-lockout test specifically arranges its "4 prior failures" state directly via Prisma rather than driving 5 real HTTP calls, since throttle-limit (5/60s) and lockout-threshold (5 attempts) coincidentally share the same number and would otherwise collide even within one block. A dedicated describe block with its own app instance verifies the 429 path itself.


Explicitly out of scope this sprint

Carried over from the plan and unchanged by implementation: Redis-backed rate limiting, RBAC management endpoints, permission-gated routes, Organization invites/switching, API key issuance, user-preferences settings endpoint, geo-IP session location, file upload endpoints, queue job processors, and a formal Prisma migration history (schema sync is via db push in this environment — see D8).