Skip to content

fix(api): resolve Clean Architecture Dependency Rule violations - #81

Merged
mankatcheung merged 1 commit into
mainfrom
fix/clean-architecture-violations
Jul 24, 2026
Merged

mankatcheung merged 1 commit into
mainfrom
fix/clean-architecture-violations

Conversation

@mankatcheung

@mankatcheung mankatcheung commented Jul 24, 2026 •

Copy link
Copy Markdown
Owner

Summary

Audited apps/api against Clean Architecture's Dependency Rule (inner layers must not depend on outer layers). Found and fixed the real violations; confirmed the rest of the codebase clean.

1. TOTP crypto bypassed DI entirely (the one exception in the whole app)

  • LoginWithTotpUseCase, GenerateTotpSecretUseCase, and ConfirmTotpSetupUseCase imported infrastructure/auth/totp.js / totpSecretCrypto.js directly — the only infrastructure concern in this codebase with no port interface (every repository, storage provider, LLM provider, and rate limiter has one).
  • Added ITotpProvider (generateSecret / getOtpauthUrl / verifyCode / encryptSecret / decryptSecret) and a TotpProvider implementation, wired through the DI container like everything else. The three use cases now depend on the port via their Deps interface.
  • Verified end-to-end against a live server: setup → confirm → login-requires-TOTP → login-with-valid-code → disable, all still work correctly.

2. apiToken, session, coverLetter, jobDescription skipped the resolver layer

  • Every other domain (application, note, document, user, contact, activity log, login event...) routes GraphQL → interface-adapters/resolvers/XResolver → use case → mapper. These four called use-cases/mappers directly from the schema layer instead.
  • Added ApiTokenResolver and SessionResolver, matching the existing pattern exactly, wired into the DI container.
  • Left coverLetter and jobDescription as direct schema-layer calls — each is a single trivial pass-through with no mapper and no logic to protect, so a resolver there would be pure ceremony for no benefit.

3. Business logic embedded in a GraphQL resolve() function

  • apiTokenMutations.ts had args.scope === 'read' ? 'read' : 'full' — silently coercing any invalid string (including typos) into full-scope access. Root cause: scope was a raw string arg instead of a GraphQL enum.
  • Added ApiTokenScopeEnum. Invalid values are now rejected by GraphQL's own argument validation instead of being silently coerced — a real correctness fix, not just a refactor (a typo like "raed" used to silently grant full scope).

4. A backward import from earlier this session

  • The pagination PR (feat: cursor-paginated applications with infinite scroll #80) had interface-adapters/resolvers/ApplicationResolver.ts importing a type from http/schema/types/ApplicationConnectionType.ts — outer importing from... itself, backwards. Moved ApplicationConnectionDTO to live next to ApplicationDTO in the mapper; http/ now imports from interface-adapters/ as it should.

Confirmed clean, left alone

  • Domain layer: zero imports outside domain/ anywhere.
  • All port interfaces: no Prisma types leak through any of them.
  • Mappers: pure field-by-field DTO translation, no logic.
  • McpController: correctly goes through use cases only.
  • DI container: purely declarative registration, no embedded logic.
  • DocumentResolver calling storageProvider.getSignedUrl directly to enrich a DTO — goes through the injected port interface (not a concrete class), judged a reasonable, low-risk resolver responsibility rather than a violation worth the churn of extracting a use case for it.
  • bcrypt/crypto/qrcode/nanoid used directly in ~15 use cases without a port — a deliberate, consistent convention for stateless utilities with no hidden config dependency, distinct from the TOTP case (which did read an env var internally). Left as-is.

Test plan

  • pnpm typecheck, pnpm lint, pnpm build all pass
  • 634 tests pass (was 623 before this branch; net +11 from the new TotpProvider, ApiTokenResolver, and SessionResolver test files, minus the retired totpSecretCrypto.test.ts)
  • Manually verified end-to-end against a live server: createApiToken with the new enum-typed scope, listApiTokens, deleteApiToken, sessions query with correct current flagging, revokeOtherSessions, and the full TOTP lifecycle (setup/confirm/login/disable) — all correct
  • Confirmed the "internal server error" on an invalid enum value is pre-existing GraphQL-validation-error handling behavior (reproduced identically on the existing ApplicationStatus enum), not something this PR introduced

Summary by CodeRabbit

  • New Features

    • API token creation now supports selectable full-access or read-only scopes.
    • API token and session operations provide consistent listing, creation, revocation and deletion responses.
    • Two-factor authentication setup and verification now support secure secret generation, encrypted storage and time-based code validation.
  • Bug Fixes

    • Improved schema validation for API token scopes.
    • Enhanced handling of session and API token data returned through the API.

Audited apps/api against the Dependency Rule (inner layers must not
depend on outer layers) and fixed the real violations found:

- TOTP crypto bypassed DI entirely: three use cases (LoginWithTotpUseCase,
  GenerateTotpSecretUseCase, ConfirmTotpSetupUseCase) imported
  infrastructure/auth/totp.js and totpSecretCrypto.js directly, the one
  infra concern in the app with no port interface. Introduces
  ITotpProvider (generateSecret/getOtpauthUrl/verifyCode/encryptSecret/
  decryptSecret) with a TotpProvider implementation, wired through DI
  like every other repository/provider in the app. Verified end-to-end
  against a live server: setup, confirm, login-with-TOTP, and disable
  all still work correctly.

- apiToken, session, coverLetter, and jobDescription mutations/queries
  were the only ones in the schema calling use-cases and mappers
  directly instead of going through an interface-adapters resolver
  (every other domain — application, note, document, user, etc. — has
  one). Added ApiTokenResolver and SessionResolver, matching the
  existing pattern, and wired them into the DI container. Left
  coverLetter/jobDescription as direct schema-layer calls — each is a
  single trivial pass-through with no mapper and no business logic, so
  a resolver there would be pure ceremony.

- apiTokenMutations.ts embedded a business rule in the GraphQL resolve()
  function (defaulting/coercing an invalid scope string to 'full'
  instead of validating it). Root-caused: the `scope` arg was a raw
  string instead of a GraphQL enum. Added ApiTokenScopeEnum so invalid
  values are now rejected by GraphQL argument validation instead of
  being silently coerced — a real (if narrow) correctness improvement,
  not just a refactor.

- Fixed a backward import introduced in the pagination PR this session:
  interface-adapters/resolvers/ApplicationResolver.ts was importing a
  type from http/schema/. Moved ApplicationConnectionDTO to live next
  to ApplicationDTO in the mapper (interface-adapters), with http/
  importing it from there instead.

Confirmed clean and left alone: the domain layer (zero external
imports anywhere), all port interfaces (no Prisma types leak through),
mappers (pure field-by-field translation, no logic), McpController,
and the DI container itself. bcrypt/crypto/qrcode/nanoid are used
directly in several use cases without a port — a deliberate, consistent
choice throughout the codebase for stateless utilities with no hidden
config dependency, left as-is.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026 •

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The change centralises TOTP operations behind an injected provider, adds provider and resolver tests, registers new dependencies, and routes API token and session GraphQL operations through dedicated interface adapters. API token scope is now represented by a GraphQL enum.

Changes

Authentication and API integration

Layer / File(s) Summary
TOTP provider contract and cryptography
apps/api/src/use-cases/ports/ITotpProvider.ts, apps/api/src/infrastructure/auth/TotpProvider.ts, apps/api/src/__tests__/infrastructure/auth/TotpProvider.test.ts
TOTP generation, URI creation, verification, encryption, and decryption are provided through TotpProvider and covered by infrastructure tests.
Injected TOTP use-case flow
apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts, apps/api/src/use-cases/user/*TotpUseCase.ts, apps/api/src/__tests__/application/{auth,user}/*TotpUseCase.test.ts, apps/api/src/__tests__/helpers/mocks.ts
TOTP use cases consume ITotpProvider; tests inject provider fixtures and generate valid codes with otplib.
API token and session resolver adapters
apps/api/src/interface-adapters/resolvers/{ApiTokenResolver,SessionResolver}.ts, apps/api/src/__tests__/interface-adapters/resolvers/*
New resolvers coordinate token and session use cases and map domain results to API DTOs.
GraphQL schema and dependency wiring
apps/api/src/http/container.ts, apps/api/src/http/schema/**/*, apps/api/src/interface-adapters/mappers/ApplicationMapper.ts, apps/api/src/interface-adapters/resolvers/ApplicationResolver.ts
GraphQL operations delegate to resolvers, token scope uses a registered enum, dependencies are registered in Awilix, and ApplicationConnectionDTO is sourced from the mapper.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant GraphQL
  participant Resolver
  participant UseCase
  participant Provider
  Client->>GraphQL: Submit TOTP or API request
  GraphQL->>Resolver: Delegate operation
  Resolver->>UseCase: Execute application operation
  UseCase->>Provider: Generate, verify, encrypt, or decrypt TOTP data
  Provider-->>UseCase: Return TOTP result
  UseCase-->>Resolver: Return application result
  Resolver-->>GraphQL: Return mapped API response
Loading

Poem

A bunny hops where codes now flow,
Through guarded gates with crypto glow.
Resolvers neatly pass the way,
Providers keep the secrets safe each day.
“Hop hooray!” the rabbit sings,
For tested, tidy TOTP things.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarises the main change: fixing Clean Architecture dependency rule violations in the API layer.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/clean-architecture-violations

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts (1)

51-56: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid decrypting the TOTP secret for backup-code attempts.

decryptSecret runs unconditionally even when isTotpFormat is false, so every backup-code login attempt pays for a secret decryption it never uses. Gate the decrypt behind the format check.

⚡ Proposed fix
     // TOTP codes are always 6 digits — anything else can only be a backup code.
     const isTotpFormat = /^\d{6}$/.test(input.code);
-    const secret = this.deps.totpProvider.decryptSecret(user.totpSecret);
-    const validTotp = isTotpFormat
-      ? await this.deps.totpProvider.verifyCode(secret, input.code)
-      : false;
+    const validTotp = isTotpFormat
+      ? await this.deps.totpProvider.verifyCode(
+          this.deps.totpProvider.decryptSecret(user.totpSecret),
+          input.code,
+        )
+      : false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts` around lines 51 - 56,
Update the TOTP validation flow in LoginWithTotpUseCase so
totpProvider.decryptSecret is only called when isTotpFormat is true. Preserve
validTotp as false for backup-code attempts without decrypting the user's TOTP
secret.
apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts (1)

22-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated generateValidCode test fixture across two files. Both test files define an identical fixtureCrypto/fixtureBase32/generateValidCode(secret) helper (same code, same comment) purely to build valid TOTP codes for fixtures.

  • apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts#L22-L29: remove the local fixture and import a shared generateValidCode helper from apps/api/src/__tests__/helpers/mocks.ts instead.
  • apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts#L11-L18: remove the local fixture and import the same shared helper.

Move fixtureCrypto, fixtureBase32, and generateValidCode into mocks.ts (exported alongside makeTotpProvider) so both suites share one implementation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts` around
lines 22 - 29, Move fixtureCrypto, fixtureBase32, and the exported
generateValidCode helper into apps/api/src/__tests__/helpers/mocks.ts alongside
makeTotpProvider. Remove the duplicate local fixtures from
apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts (anchor,
lines 22-29) and
apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts
(sibling, lines 11-18), and import generateValidCode from mocks.ts in both
suites.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/http/schema/mutations/sessionMutations.ts`:
- Around line 13-14: Update the session revocation mutation around
sessionResolver.revokeSession to preserve the existing fromCodedError conversion
for use-case failures. Either retain the conversion in the mutation or implement
equivalent conversion within SessionResolver.revokeSession, ensuring failed
revocations continue exposing their established GraphQL error codes.

In `@apps/api/src/infrastructure/auth/TotpProvider.ts`:
- Line 13: Cache the scrypt-derived key in the singleton TotpProvider instance
instead of recomputing it in getKey() for every encryptSecret, decryptSecret, or
secret-generation call. Add instance-level lazy initialization so scryptSync
runs only once, then reuse the cached key for all subsequent operations while
preserving the existing key derivation inputs and behavior.

---

Nitpick comments:
In `@apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts`:
- Around line 22-29: Move fixtureCrypto, fixtureBase32, and the exported
generateValidCode helper into apps/api/src/__tests__/helpers/mocks.ts alongside
makeTotpProvider. Remove the duplicate local fixtures from
apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts (anchor,
lines 22-29) and
apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts
(sibling, lines 11-18), and import generateValidCode from mocks.ts in both
suites.

In `@apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts`:
- Around line 51-56: Update the TOTP validation flow in LoginWithTotpUseCase so
totpProvider.decryptSecret is only called when isTotpFormat is true. Preserve
validTotp as false for backup-code attempts without decrypting the user's TOTP
secret.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 472c8fae-3ae6-4178-b511-ee58a2cbfe0a

📥 Commits

Reviewing files that changed from the base of the PR and between 70a4ddb and fe8b1a8.

📒 Files selected for processing (27)
  • apps/api/src/__tests__/application/auth/LoginWithTotpUseCase.test.ts
  • apps/api/src/__tests__/application/user/ConfirmTotpSetupUseCase.test.ts
  • apps/api/src/__tests__/application/user/GenerateTotpSecretUseCase.test.ts
  • apps/api/src/__tests__/helpers/mocks.ts
  • apps/api/src/__tests__/infrastructure/auth/TotpProvider.test.ts
  • apps/api/src/__tests__/infrastructure/auth/totpSecretCrypto.test.ts
  • apps/api/src/__tests__/interface-adapters/resolvers/ApiTokenResolver.test.ts
  • apps/api/src/__tests__/interface-adapters/resolvers/SessionResolver.test.ts
  • apps/api/src/http/container.ts
  • apps/api/src/http/schema/index.ts
  • apps/api/src/http/schema/mutations/apiTokenMutations.ts
  • apps/api/src/http/schema/mutations/sessionMutations.ts
  • apps/api/src/http/schema/queries/apiTokenQueries.ts
  • apps/api/src/http/schema/queries/sessionQueries.ts
  • apps/api/src/http/schema/types/ApplicationConnectionType.ts
  • apps/api/src/http/schema/types/enums/ApiTokenScopeEnum.ts
  • apps/api/src/infrastructure/auth/TotpProvider.ts
  • apps/api/src/infrastructure/auth/totp.ts
  • apps/api/src/infrastructure/auth/totpSecretCrypto.ts
  • apps/api/src/interface-adapters/mappers/ApplicationMapper.ts
  • apps/api/src/interface-adapters/resolvers/ApiTokenResolver.ts
  • apps/api/src/interface-adapters/resolvers/ApplicationResolver.ts
  • apps/api/src/interface-adapters/resolvers/SessionResolver.ts
  • apps/api/src/use-cases/auth/LoginWithTotpUseCase.ts
  • apps/api/src/use-cases/ports/ITotpProvider.ts
  • apps/api/src/use-cases/user/ConfirmTotpSetupUseCase.ts
  • apps/api/src/use-cases/user/GenerateTotpSecretUseCase.ts
💤 Files with no reviewable changes (3)
  • apps/api/src/infrastructure/auth/totp.ts
  • apps/api/src/tests/infrastructure/auth/totpSecretCrypto.test.ts
  • apps/api/src/infrastructure/auth/totpSecretCrypto.ts

Comment on lines +13 to +14
const { sessionResolver } = ctx.diScope.cradle;
return sessionResolver.revokeSession(ctx.user.sub, String(args.id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Preserve coded-error conversion for session revocation.

The previous mutation converted use-case failures with fromCodedError, while SessionResolver.revokeSession only propagates them. Failed revocations will therefore lose their established GraphQL error code unless conversion is retained here or moved into the resolver.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/http/schema/mutations/sessionMutations.ts` around lines 13 - 14,
Update the session revocation mutation around sessionResolver.revokeSession to
preserve the existing fromCodedError conversion for use-case failures. Either
retain the conversion in the mutation or implement equivalent conversion within
SessionResolver.revokeSession, ensuring failed revocations continue exposing
their established GraphQL error codes.

const IV_BYTES = 12;
const AUTH_TAG_BYTES = 16;
const KEY_LENGTH = 32;
const SCRYPT_SALT = 'job-finder-totp-secret';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache the derived key — scryptSync re-runs on every encrypt/decrypt call.

getKey() re-derives the key via scryptSync every time encryptSecret/decryptSecret is called, and TotpProvider is registered as a singleton. This means every TOTP login attempt (decryptSecret in LoginWithTotpUseCase), setup confirmation, and secret generation synchronously blocks the event loop for the full cost of a scrypt derivation, throttling all concurrent requests on the server.

⚡ Proposed fix: cache the derived key on the singleton instance
 export class TotpProvider implements ITotpProvider {
+  private cachedKey?: Buffer;
+
   private makeTotp(options: { secret?: string; label?: string } = {}): TOTP {
     return new TOTP({ crypto, base32, issuer: TOTP_CONFIG.ISSUER, ...options });
   }

   private getKey(): Buffer {
+    if (this.cachedKey) return this.cachedKey;
     const passphrase = process.env[ENV.TOTP_ENCRYPTION_KEY];
     if (!passphrase) {
       throw new Error(
         `${ENV.TOTP_ENCRYPTION_KEY} is not configured — required to encrypt/decrypt TOTP secrets`,
       );
     }
-    return scryptSync(passphrase, SCRYPT_SALT, KEY_LENGTH);
+    this.cachedKey = scryptSync(passphrase, SCRYPT_SALT, KEY_LENGTH);
+    return this.cachedKey;
   }

Also applies to: 20-28, 45-63

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/infrastructure/auth/TotpProvider.ts` at line 13, Cache the
scrypt-derived key in the singleton TotpProvider instance instead of recomputing
it in getKey() for every encryptSecret, decryptSecret, or secret-generation
call. Add instance-level lazy initialization so scryptSync runs only once, then
reuse the cached key for all subsequent operations while preserving the existing
key derivation inputs and behavior.

@mankatcheung
mankatcheung merged commit 1e9e9c0 into main Jul 24, 2026
5 checks passed
@mankatcheung
mankatcheung deleted the fix/clean-architecture-violations branch July 24, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant