Skip to content

feat(auth): add email verification code backend services#3051

Open
baktun14 wants to merge 4 commits intomainfrom
feat/auth0-verification-backend
Open

feat(auth): add email verification code backend services#3051
baktun14 wants to merge 4 commits intomainfrom
feat/auth0-verification-backend

Conversation

@baktun14
Copy link
Copy Markdown
Contributor

@baktun14 baktun14 commented Apr 9, 2026

Why

Part of CON-197 — Replace Auth0's default email verification link with a 6-digit code flow.

This is PR 1 of 2 — backend only. Split from #2824 to make review manageable.
PR 2 (frontend): #3052 (targets this branch).

What

  • DB migration: email_verification_codes table with expiry and attempt limits
  • EmailVerificationCodeRepository with Drizzle ORM and SELECT ... FOR UPDATE
  • EmailVerificationCodeService with cooldown enforcement, code generation, and verification
  • Three new routes: POST /v1/send-verification-code, POST /v1/verify-email-code, POST /v1/signup
  • Auth0 service: createUser method for signup flow (409 sanitized to generic 422)
  • UserService: sends verification code on registration
  • Notification template for verification code emails
  • Auth0 markEmailVerified call moved outside transaction
  • HTTP SDK: sendVerificationCode() and verifyEmailCode() methods
  • Unit, integration, and functional tests

26 files changed (~2,350 additions)

Summary by CodeRabbit

  • New Features

    • Email verification with 6‑digit codes: send and verify endpoints, signup endpoint for account creation, and automatic initial code on new registrations.
    • Verification flow includes rate limits, attempt limits, and email notification template with emphasized code text.
    • Client SDK methods to send and verify codes added.
  • Tests

    • New unit/integration tests covering signup and the full verification send/verify flows.

Implement server-side email verification using 6-digit codes with
cooldown enforcement, expiry, and attempt limits. Includes DB migration,
repository with advisory locks, Auth0 signup integration, notification
template, and HTTP SDK methods.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 04585a3e-29d2-43dd-bcb0-1539f9d33bc6

📥 Commits

Reviewing files that changed from the base of the PR and between 3cd8f4a and 248c38a.

📒 Files selected for processing (2)
  • apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts
  • apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts

📝 Walkthrough

Walkthrough

Adds email verification: DB migration and Drizzle schema, repository and service for issuing/validating 6-digit codes, AuthController signup and verification endpoints, notification template and sender adjustments, Auth0 helpers, client SDK methods, routes/OpenAPI, and accompanying tests and snapshots.

Changes

Cohort / File(s) Summary
Database Schema & Migrations
apps/api/drizzle/0029_fluffy_drax.sql, apps/api/drizzle/meta/0029_snapshot.json, apps/api/drizzle/meta/_journal.json
New email_verification_codes table (UUID id, user FK ON DELETE cascade, email, hashed code, expires_at, attempts, created_at); idempotent FK addition and indexes; snapshot and journal updated.
Drizzle Schema & Repository
apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts, apps/api/src/auth/model-schemas/index.ts, apps/api/src/auth/repositories/email-verification-code/...
Added EmailVerificationCodes pgTable, re-exported schema, and new EmailVerificationCodeRepository with types and methods (accessibleBy, findActiveByUserIdForUpdate, countRecentByUserId, incrementAttempts) and custom output mapping.
Email Verification Service & Tests
apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts, .../email-verification-code.service.spec.ts
New singleton service implementing sendCode and verifyCode with 6-digit plaintext + SHA-256 storage, 10min expiry, rate limiting, max attempts, transactional verification; comprehensive unit tests.
Auth Controller, Auth0 Service & Tests
apps/api/src/auth/controllers/auth/auth.controller.ts, ...auth.controller.spec.ts, apps/api/src/auth/services/auth0/auth0.service.ts, ...auth0.service.spec.ts
AuthController: added signup() with Auth0 createUser handling and protected sendVerificationCode/verifyEmailCode methods; Auth0Service: createUser, markEmailVerified, and AUTH0_DB_CONNECTION constant; tests added/updated.
Routes & OpenAPI
apps/api/src/auth/routes/signup/signup.router.ts, .../send-verification-code.router.ts, .../verify-email-code.router.ts, apps/api/src/auth/routes/index.ts, apps/api/src/rest-app.ts
Added POST routes /v1/auth/signup, /v1/send-verification-code, /v1/verify-email-code with OpenAPI metadata; routers exported and registered in REST app.
User Service Integration & Tests
apps/api/src/user/services/user/user.service.ts, ...user.service.spec.ts, ...user.service.integration.ts
UserService now depends on EmailVerificationCodeService and conditionally sends initial verification codes for unverified emails; tests updated to assert send behavior and error logging.
Notification Template & Tests
apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts, ...notification.spec.ts
Added emailVerificationCodeNotification producing HTML payload (includes <strong> with code and 10-minute expiry) and tests validating payload and generated notificationId.
Email Sender Sanitization
apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts
HTML sanitizer now allows <strong> tag in email content.
HTTP SDK
packages/http-sdk/src/auth/auth-http.service.ts, packages/http-sdk/src/auth/auth-http.types.ts
Added client methods sendVerificationCode() and verifyEmailCode(code) and SendVerificationCodeResponse schema; marked legacy link-based sendVerificationEmail() deprecated.
Tests & Snapshots
various spec files, apps/api/drizzle/meta/0029_snapshot.json
Multiple new/updated tests for service, controller, notification template, and Auth0 interactions; updated Drizzle snapshot reflects schema changes.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant AuthController
    participant Auth0Service
    participant EmailCodeService
    participant Repo
    participant NotificationService
    participant Database

    Client->>AuthController: POST /v1/auth/signup (email,password)
    AuthController->>Auth0Service: createUser(email,password,connection)
    Auth0Service->>Auth0Service: call Auth0 Management API
    AuthController-->>Client: 204

    Client->>AuthController: POST /v1/send-verification-code (auth)
    AuthController->>EmailCodeService: sendCode(userId)
    EmailCodeService->>Repo: countRecentByUserId(userId,since)
    Repo->>Database: SELECT count(...)
    EmailCodeService->>Repo: create(hashedCode,expiresAt,createdAt)
    Repo->>Database: INSERT verification code
    EmailCodeService->>NotificationService: create/send email (plaintext code)
    EmailCodeService-->>AuthController: { codeSentAt }
    AuthController-->>Client: 200 { data: { codeSentAt } }

    Client->>AuthController: POST /v1/verify-email-code (code)
    AuthController->>EmailCodeService: verifyCode(userId,code)
    EmailCodeService->>Repo: findActiveByUserIdForUpdate(userId)
    Repo->>Database: SELECT ... FOR UPDATE
    alt code matches
        EmailCodeService->>Repo: update user (emailVerified=true)
        Repo->>Database: UPDATE users
        EmailCodeService->>Auth0Service: markEmailVerified(auth0Id)
        Auth0Service->>Auth0Service: call Auth0 Management API
    else mismatch
        EmailCodeService->>Repo: incrementAttempts(codeId)
        Repo->>Database: UPDATE attempts
    end
    EmailCodeService-->>AuthController: success or error
    AuthController-->>Client: 204 or error
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped a six-digit tune and hid it in the ground,

Hashed and tucked in rows where safe codes are found,
I nudged a notification, bold in strong delight,
Locks and transactions kept the burrow snug that night,
Now verified users hop home—nibble on, all right! 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The pull request title accurately describes the main change: adding email verification code backend services, which aligns with the primary objective of implementing a 6-digit code-based email verification flow.
Description check ✅ Passed The pull request description is comprehensive, covering the rationale (CON-197), clearly stating it is PR 1 of 2, and detailing all major changes including database migration, services, routes, and tests. It follows the template structure with 'Why' and 'What' sections properly filled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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 feat/auth0-verification-backend

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

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 9, 2026

Codecov Report

❌ Patch coverage is 85.41667% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.83%. Comparing base (9ccf71a) to head (248c38a).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
...ication-code/email-verification-code.repository.ts 14.28% 10 Missing and 2 partials ⚠️
...s/api/src/auth/controllers/auth/auth.controller.ts 95.45% 1 Missing ⚠️
...erification-code/email-verification-code.schema.ts 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3051      +/-   ##
==========================================
+ Coverage   59.71%   59.83%   +0.12%     
==========================================
  Files        1034     1038       +4     
  Lines       24273    24367      +94     
  Branches     6003     6011       +8     
==========================================
+ Hits        14494    14580      +86     
- Misses       8528     8534       +6     
- Partials     1251     1253       +2     
Flag Coverage Δ *Carryforward flag
api 81.45% <85.41%> (+0.15%) ⬆️
deploy-web 43.13% <ø> (-0.02%) ⬇️
log-collector 85.17% <ø> (ø)
notifications 86.06% <ø> (ø)
provider-console 81.48% <ø> (ø) Carriedforward from 3cd8f4a
provider-proxy 85.21% <ø> (ø) Carriedforward from 3cd8f4a
tx-signer 76.26% <ø> (ø)

*This pull request uses carry forward flags. Click here to find out more.

Files with missing lines Coverage Δ
apps/api/src/auth/services/auth0/auth0.service.ts 100.00% <100.00%> (+25.00%) ⬆️
...rification-code/email-verification-code.service.ts 100.00% <100.00%> (ø)
...-templates/email-verification-code-notification.ts 100.00% <100.00%> (ø)
apps/api/src/rest-app.ts 82.35% <ø> (ø)
apps/api/src/user/services/user/user.service.ts 94.44% <100.00%> (+0.44%) ⬆️
...ions/services/email-sender/email-sender.service.ts 100.00% <ø> (ø)
...s/api/src/auth/controllers/auth/auth.controller.ts 72.72% <95.45%> (+72.72%) ⬆️
...erification-code/email-verification-code.schema.ts 66.66% <66.66%> (ø)
...ication-code/email-verification-code.repository.ts 14.28% <14.28%> (ø)

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

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 (10)
apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts (1)

10-12: Consider extracting the expiry duration to a shared constant.

The "expires in 10 minutes" text is hardcoded here, while the actual expiry (CODE_EXPIRY_MS = 10 * 60 * 1000) is defined in email-verification-code.service.ts. If the expiry changes, these could drift out of sync.

♻️ Optional: Share expiry constant

You could export CODE_EXPIRY_MINUTES from the service or a shared constants file and interpolate it here:

`This code expires in ${CODE_EXPIRY_MINUTES} minutes.`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts`
around lines 10 - 12, The template in email-verification-code-notification.ts
hardcodes "expires in 10 minutes" while the real expiry is defined as
CODE_EXPIRY_MS in email-verification-code.service.ts; to prevent drift, export a
shared expiry constant (e.g., CODE_EXPIRY_MINUTES) from the service or a common
constants module, import that constant into the notification template, and
replace the hardcoded "10" with an interpolated ${CODE_EXPIRY_MINUTES} in the
description string used by the EmailVerificationCodeNotification template.
apps/api/src/auth/routes/signup/signup.router.ts (1)

9-12: Consider enhancing password validation or deferring to Auth0.

The password schema only enforces min(8). Auth0 typically has its own password policy (complexity, special characters, etc.) that will reject weak passwords anyway. However, for better UX, you might want to validate against Auth0's policy client-side or document that Auth0 rules apply.

If you want to match common Auth0 default password policies:

password: z.string()
  .min(8)
  .regex(/[a-z]/, "Must contain lowercase")
  .regex(/[A-Z]/, "Must contain uppercase")
  .regex(/[0-9]/, "Must contain a number")

Alternatively, keep it minimal and let Auth0 return validation errors (current approach is acceptable).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/routes/signup/signup.router.ts` around lines 9 - 12, The
signup password validation currently only enforces min(8) on the
signupInputSchema; either strengthen it to mirror Auth0's common policy by
adding regex checks for lowercase, uppercase, and digit (e.g., add
.regex(/[a-z]/), .regex(/[A-Z]/), .regex(/[0-9]/) to the password z.string()) or
explicitly document in the file that Auth0 enforces stronger password rules and
defer full validation to Auth0; update signupInputSchema accordingly (or add the
explanatory comment next to signupInputSchema) so the intent is clear to future
readers.
apps/api/src/auth/services/auth0/auth0.service.spec.ts (1)

113-127: Consider using vitest-mock-extended for mocking consistency.

The setup function constructs mocks using jest.fn() and casts to unknown as ManagementClient. Per coding guidelines, unit tests should use vitest-mock-extended (mock(), MockProxy<T>) for mocking.

♻️ Suggested refactor using vitest-mock-extended
+import { mock, type MockProxy } from "vitest-mock-extended";
+import type { ManagementClient } from "auth0";

-  function setup(
-    input: {
-      jobs?: { verifyEmail: jest.Mock };
-      users?: { update?: jest.Mock; create?: jest.Mock };
-      usersByEmail?: { getByEmail: jest.Mock };
-    } = {}
-  ) {
-    const mockManagementClient = {
-      ...input
-    } as unknown as ManagementClient;
+  function setup() {
+    const mockManagementClient = mock<ManagementClient>();

     const auth0Service = new Auth0Service(mockManagementClient);

-    return { auth0Service, mockManagementClient };
+    return { auth0Service, managementClient: mockManagementClient };
   }

Then configure mock behavior in individual tests using managementClient.users.create.mockResolvedValue(...).

As per coding guidelines: "Use vitest-mock-extended (mock(), MockProxy<T>) for mocking in unit tests."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/services/auth0/auth0.service.spec.ts` around lines 113 -
127, The setup helper currently builds ad-hoc jest mocks and casts to
ManagementClient; replace this with vitest-mock-extended to create a properly
typed mock: import and use mock<ManagementClient>() (typed as
MockProxy<ManagementClient>) to create mockManagementClient inside setup, remove
the manual jest.Mock shapes, and return { auth0Service, mockManagementClient }
where tests can call typed methods like
mockManagementClient.users.create.mockResolvedValue(...) or
mockManagementClient.jobs.verifyEmail.mockResolvedValue(...); ensure
Auth0Service is instantiated with the MockProxy so TypeScript-aware mocks are
used throughout.
apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts (1)

11-18: Minor redundancy in validation schema.

The .length(6) check is redundant since the regex /^\d{6}$/ already enforces exactly 6 digits. However, this redundancy is harmless and provides defense-in-depth.

♻️ Optional simplification
 const VerifyEmailCodeRequestSchema = z.object({
   data: z.object({
     code: z
       .string()
-      .length(6)
       .regex(/^\d{6}$/, "Code must be exactly 6 digits")
   })
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts`
around lines 11 - 18, The schema VerifyEmailCodeRequestSchema redundantly
applies .length(6) on the code field while the regex /^\d{6}$/ already enforces
exactly six digits; remove the .length(6) call from the code validator so the
field becomes z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits") to
simplify the validation while preserving the same constraint.
apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts (2)

43-51: Consider asserting the specific error message or status code.

The test only asserts that an error is thrown but doesn't verify it's specifically a 429 rate limit error. This could mask regressions if the error type changes.

💚 More specific assertion
     it("throws 429 when rate limit exceeded", async () => {
       const user = UserSeeder.create({ email: "test@example.com" });
       const { service, emailVerificationCodeRepository, userRepository } = setup();
 
       userRepository.findById.mockResolvedValue(user);
       emailVerificationCodeRepository.countRecentByUserId.mockResolvedValue(5);
 
-      await expect(service.sendCode(user.id)).rejects.toThrow();
+      await expect(service.sendCode(user.id)).rejects.toMatchObject({
+        status: 429,
+        message: "Too many verification code requests. Please try again later."
+      });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts`
around lines 43 - 51, The test currently just checks that
service.sendCode(user.id) throws, which is too broad; update the assertion to
verify it's specifically a 429 rate-limit error by asserting the thrown error's
type/message/status. Locate the test and replace the generic rejects.toThrow()
with an assertion that inspects the rejected error from service.sendCode (e.g.,
expect(...).rejects.toMatchObject({ status: 429 }) or
expect(...).rejects.toThrowError(/429|rate limit/i) or assert instance of your
RateLimitError class), keeping use of
emailVerificationCodeRepository.countRecentByUserId and the same arrange/act
setup.

53-68: Consider asserting specific error status codes for error cases.

The 404 and 400 error tests only verify that an error is thrown, not the specific status. Verifying status codes ensures the correct HTTP responses reach clients.

💚 More specific assertions
     it("throws 404 when user not found", async () => {
       const { service, userRepository } = setup();
 
       userRepository.findById.mockResolvedValue(undefined);
 
-      await expect(service.sendCode("nonexistent")).rejects.toThrow();
+      await expect(service.sendCode("nonexistent")).rejects.toMatchObject({ status: 404 });
     });
 
     it("throws 400 when user has no email", async () => {
       const user = UserSeeder.create({ email: null });
       const { service, userRepository } = setup();
 
       userRepository.findById.mockResolvedValue(user);
 
-      await expect(service.sendCode(user.id)).rejects.toThrow();
+      await expect(service.sendCode(user.id)).rejects.toMatchObject({ status: 400 });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts`
around lines 53 - 68, Update the two tests to assert the specific HTTP error
status instead of a generic throw: when userRepository.findById returns
undefined, call service.sendCode("nonexistent") and assert the rejected error
has status 404 (e.g., check error.status or error.code); when
UserSeeder.create({ email: null }) is returned, call service.sendCode(user.id)
and assert the rejected error has status 400. Use the same service.sendCode and
userRepository.findById symbols to locate the tests and replace await
expect(...).rejects.toThrow() with assertions that inspect the thrown error
object for the expected status.
apps/api/src/auth/services/auth0/auth0.service.ts (2)

10-17: Consider returning the created user for downstream use.

The createUser method discards the Auth0 response. If the caller needs the created user's Auth0 ID (e.g., for immediate verification), returning it avoids an extra lookup.

♻️ Optional: return created user data
-  async createUser(input: { email: string; password: string; connection: string }): Promise<void> {
-    await this.managementClient.users.create({
+  async createUser(input: { email: string; password: string; connection: string }): Promise<{ user_id: string }> {
+    const { data } = await this.managementClient.users.create({
       email: input.email,
       password: input.password,
       connection: input.connection,
       verify_email: false
     });
+    return { user_id: data.user_id! };
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/services/auth0/auth0.service.ts` around lines 10 - 17, The
createUser method currently discards the Auth0 response; change it to capture
and return the created user so callers can access the Auth0 ID or other fields.
Update the signature of createUser to return Promise<any> (or a proper Auth0
user type), assign the result of await this.managementClient.users.create({ ...
}) to a const (e.g., createdUser) and return it; ensure callers are updated if
they expect the previous void return. This references createUser and
managementClient.users.create in the existing code.

23-25: Add explicit return type annotation for consistency.

Other methods in this class have explicit return types. Adding Promise<void> maintains consistency.

♻️ Add return type
-  async markEmailVerified(userId: string) {
+  async markEmailVerified(userId: string): Promise<void> {
     await this.managementClient.users.update({ id: userId }, { email_verified: true });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/services/auth0/auth0.service.ts` around lines 23 - 25, The
method markEmailVerified lacks an explicit return type; update its signature to
include Promise<void> (i.e., async markEmailVerified(userId: string):
Promise<void>) to match other methods in the class and maintain consistency,
leaving the body that calls this.managementClient.users.update({ id: userId }, {
email_verified: true }) unchanged.
apps/api/src/auth/controllers/auth/auth.controller.spec.ts (1)

30-38: Test doesn't verify the HTTP status code is preserved.

The test only checks the message but doesn't verify that the 400 status code from Auth0 is preserved in the thrown error.

💚 Enhanced assertion
     it("throws http error when auth0 returns a non-409 error", async () => {
       const { controller, auth0Service } = setup();
 
       auth0Service.createUser.mockRejectedValue(
         new ResponseError(400, JSON.stringify({ message: "PasswordStrengthError: Password is too weak" }), new Headers())
       );
 
-      await expect(controller.signup({ email: "user@example.com", password: "weak" })).rejects.toThrow("PasswordStrengthError: Password is too weak");
+      await expect(controller.signup({ email: "user@example.com", password: "weak" })).rejects.toMatchObject({
+        status: 400,
+        message: "PasswordStrengthError: Password is too weak"
+      });
     });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/api/src/auth/controllers/auth/auth.controller.spec.ts` around lines 30 -
38, The test for controller.signup currently only checks the error message but
not that the original HTTP status code from auth0Service.createUser (a
ResponseError) is preserved; update the test to await the rejected promise and
capture the thrown error from controller.signup, then assert the error is the
expected ResponseError (or has the same shape) and that its status (e.g.,
error.status or error.statusCode) equals 400 while still asserting the message
contains "PasswordStrengthError: Password is too weak"; reference the mocked
auth0Service.createUser, the controller.signup call, and the ResponseError type
when making these assertions.
apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts (1)

9-10: Narrow the repository input type.

Partial<Table["$inferInsert"]> exposes a much wider write surface than this repository actually needs, including DB-managed fields. An explicit input DTO or Pick/Omit-based type will keep inserts harder to misuse and the contract clearer.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts`
around lines 9 - 10, The current EmailVerificationCodeInput type uses
Partial<Table["$inferInsert"]>, which exposes DB-managed fields; replace it with
a narrowed DTO using Pick/Omit or an explicit interface that only includes the
fields required for repository inserts (e.g., omit id, created_at, updated_at,
and other DB-managed columns). Update the EmailVerificationCodeInput type
declaration (and any insert/create methods that accept it) so it only accepts
the writable properties the repository actually sets, while keeping
EmailVerificationCodeDbOutput as the selected DB output type.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/api/src/auth/controllers/auth/auth.controller.ts`:
- Around line 30-40: In the catch block that handles ResponseError in
auth.controller.ts (the code that inspects error instanceof ResponseError and
calls assert), guard the JSON.parse(error.body) call so a non-JSON body cannot
throw: attempt to parse error.body in a try/catch and if parsing fails fall back
to a safe message (e.g., use the raw error.body or a generic message like
"Unknown error from Auth0"); preserve the existing special-case mapping that
turns statusCode 409 into a 422 assertion message, and then call assert(false,
statusCode, message) using the safe message variable so the original parse
failure cannot crash the handler.

In `@apps/api/src/user/services/user/user.service.integration.ts`:
- Around line 347-350: In the test setup where EmailVerificationCodeService is
mocked (the mock<EmailVerificationCodeService> with sendCode), replace the Jest
compatibility global by using Vitest's native API: change
jest.fn().mockResolvedValue(...) to vi.fn().mockResolvedValue(...); ensure any
other occurrences in this test file use vi.fn() instead of jest.fn() so the mock
for sendCode and similar stubs use Vitest directly.

---

Nitpick comments:
In `@apps/api/src/auth/controllers/auth/auth.controller.spec.ts`:
- Around line 30-38: The test for controller.signup currently only checks the
error message but not that the original HTTP status code from
auth0Service.createUser (a ResponseError) is preserved; update the test to await
the rejected promise and capture the thrown error from controller.signup, then
assert the error is the expected ResponseError (or has the same shape) and that
its status (e.g., error.status or error.statusCode) equals 400 while still
asserting the message contains "PasswordStrengthError: Password is too weak";
reference the mocked auth0Service.createUser, the controller.signup call, and
the ResponseError type when making these assertions.

In
`@apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts`:
- Around line 9-10: The current EmailVerificationCodeInput type uses
Partial<Table["$inferInsert"]>, which exposes DB-managed fields; replace it with
a narrowed DTO using Pick/Omit or an explicit interface that only includes the
fields required for repository inserts (e.g., omit id, created_at, updated_at,
and other DB-managed columns). Update the EmailVerificationCodeInput type
declaration (and any insert/create methods that accept it) so it only accepts
the writable properties the repository actually sets, while keeping
EmailVerificationCodeDbOutput as the selected DB output type.

In `@apps/api/src/auth/routes/signup/signup.router.ts`:
- Around line 9-12: The signup password validation currently only enforces
min(8) on the signupInputSchema; either strengthen it to mirror Auth0's common
policy by adding regex checks for lowercase, uppercase, and digit (e.g., add
.regex(/[a-z]/), .regex(/[A-Z]/), .regex(/[0-9]/) to the password z.string()) or
explicitly document in the file that Auth0 enforces stronger password rules and
defer full validation to Auth0; update signupInputSchema accordingly (or add the
explanatory comment next to signupInputSchema) so the intent is clear to future
readers.

In `@apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts`:
- Around line 11-18: The schema VerifyEmailCodeRequestSchema redundantly applies
.length(6) on the code field while the regex /^\d{6}$/ already enforces exactly
six digits; remove the .length(6) call from the code validator so the field
becomes z.string().regex(/^\d{6}$/, "Code must be exactly 6 digits") to simplify
the validation while preserving the same constraint.

In `@apps/api/src/auth/services/auth0/auth0.service.spec.ts`:
- Around line 113-127: The setup helper currently builds ad-hoc jest mocks and
casts to ManagementClient; replace this with vitest-mock-extended to create a
properly typed mock: import and use mock<ManagementClient>() (typed as
MockProxy<ManagementClient>) to create mockManagementClient inside setup, remove
the manual jest.Mock shapes, and return { auth0Service, mockManagementClient }
where tests can call typed methods like
mockManagementClient.users.create.mockResolvedValue(...) or
mockManagementClient.jobs.verifyEmail.mockResolvedValue(...); ensure
Auth0Service is instantiated with the MockProxy so TypeScript-aware mocks are
used throughout.

In `@apps/api/src/auth/services/auth0/auth0.service.ts`:
- Around line 10-17: The createUser method currently discards the Auth0
response; change it to capture and return the created user so callers can access
the Auth0 ID or other fields. Update the signature of createUser to return
Promise<any> (or a proper Auth0 user type), assign the result of await
this.managementClient.users.create({ ... }) to a const (e.g., createdUser) and
return it; ensure callers are updated if they expect the previous void return.
This references createUser and managementClient.users.create in the existing
code.
- Around line 23-25: The method markEmailVerified lacks an explicit return type;
update its signature to include Promise<void> (i.e., async
markEmailVerified(userId: string): Promise<void>) to match other methods in the
class and maintain consistency, leaving the body that calls
this.managementClient.users.update({ id: userId }, { email_verified: true })
unchanged.

In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts`:
- Around line 43-51: The test currently just checks that
service.sendCode(user.id) throws, which is too broad; update the assertion to
verify it's specifically a 429 rate-limit error by asserting the thrown error's
type/message/status. Locate the test and replace the generic rejects.toThrow()
with an assertion that inspects the rejected error from service.sendCode (e.g.,
expect(...).rejects.toMatchObject({ status: 429 }) or
expect(...).rejects.toThrowError(/429|rate limit/i) or assert instance of your
RateLimitError class), keeping use of
emailVerificationCodeRepository.countRecentByUserId and the same arrange/act
setup.
- Around line 53-68: Update the two tests to assert the specific HTTP error
status instead of a generic throw: when userRepository.findById returns
undefined, call service.sendCode("nonexistent") and assert the rejected error
has status 404 (e.g., check error.status or error.code); when
UserSeeder.create({ email: null }) is returned, call service.sendCode(user.id)
and assert the rejected error has status 400. Use the same service.sendCode and
userRepository.findById symbols to locate the tests and replace await
expect(...).rejects.toThrow() with assertions that inspect the thrown error
object for the expected status.

In
`@apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts`:
- Around line 10-12: The template in email-verification-code-notification.ts
hardcodes "expires in 10 minutes" while the real expiry is defined as
CODE_EXPIRY_MS in email-verification-code.service.ts; to prevent drift, export a
shared expiry constant (e.g., CODE_EXPIRY_MINUTES) from the service or a common
constants module, import that constant into the notification template, and
replace the hardcoded "10" with an interpolated ${CODE_EXPIRY_MINUTES} in the
description string used by the EmailVerificationCodeNotification template.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 102172ac-ceac-4fbf-a247-c6f8aa6c9946

📥 Commits

Reviewing files that changed from the base of the PR and between 9ccf71a and a699f54.

⛔ Files ignored due to path filters (1)
  • apps/api/test/functional/__snapshots__/docs.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (25)
  • apps/api/drizzle/0029_fluffy_drax.sql
  • apps/api/drizzle/meta/0029_snapshot.json
  • apps/api/drizzle/meta/_journal.json
  • apps/api/src/auth/controllers/auth/auth.controller.spec.ts
  • apps/api/src/auth/controllers/auth/auth.controller.ts
  • apps/api/src/auth/model-schemas/email-verification-code/email-verification-code.schema.ts
  • apps/api/src/auth/model-schemas/index.ts
  • apps/api/src/auth/repositories/email-verification-code/email-verification-code.repository.ts
  • apps/api/src/auth/routes/index.ts
  • apps/api/src/auth/routes/send-verification-code/send-verification-code.router.ts
  • apps/api/src/auth/routes/signup/signup.router.ts
  • apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts
  • apps/api/src/auth/services/auth0/auth0.service.spec.ts
  • apps/api/src/auth/services/auth0/auth0.service.ts
  • apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts
  • apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts
  • apps/api/src/notifications/services/notification-templates/email-verification-code-notification.spec.ts
  • apps/api/src/notifications/services/notification-templates/email-verification-code-notification.ts
  • apps/api/src/rest-app.ts
  • apps/api/src/user/services/user/user.service.integration.ts
  • apps/api/src/user/services/user/user.service.spec.ts
  • apps/api/src/user/services/user/user.service.ts
  • apps/notifications/src/modules/notifications/services/email-sender/email-sender.service.ts
  • packages/http-sdk/src/auth/auth-http.service.ts
  • packages/http-sdk/src/auth/auth-http.types.ts

Replace http-assert with http-errors for error handling consistency.
Add safe JSON parsing for Auth0 error bodies. Fix UserSeeder import
to use existing createUser function and replace jest.fn with vi.fn.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts (1)

43-68: Tighten negative-path assertions to check expected error shape.

A few tests only use rejects.toThrow(), which can pass on unrelated failures. Since the test names specify 429/404/400 behavior, assert status/message explicitly to prevent false positives.

Example pattern
- await expect(service.sendCode(user.id)).rejects.toThrow();
+ await expect(service.sendCode(user.id)).rejects.toMatchObject({
+   status: 429
+ });

Also applies to: 99-109, 138-146

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts`
around lines 43 - 68, The tests for email-verification-code.service are using
generic rejects.toThrow() and should assert the specific error shape and status
to avoid false positives; update the negative tests around service.sendCode to
capture the rejected error from sendCode (when userRepository.findById returns
undefined, when user.email is null, and when
emailVerificationCodeRepository.countRecentByUserId returns 5) and assert the
thrown error contains the expected HTTP status codes (429, 404, 400) and/or
message fields rather than a generic toThrow; locate these assertions near
service.sendCode, userRepository.findById and
emailVerificationCodeRepository.countRecentByUserId in the spec and replace the
loose rejects.toThrow() with explicit checks on error.status or error.message
(or toMatchObject) matching the test names.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts`:
- Line 15: The test file imports EmailVerificationCodeService via a relative
path; update the import to use the TypeScript path alias for backend sources
(use the `@src/`* alias instead of "./email-verification-code.service") so the
spec imports EmailVerificationCodeService from the aliased module location
matching the project's backend alias rules; ensure the import string uses
`@src/`.../email-verification-code.service and keep the imported symbol
EmailVerificationCodeService unchanged.

---

Nitpick comments:
In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts`:
- Around line 43-68: The tests for email-verification-code.service are using
generic rejects.toThrow() and should assert the specific error shape and status
to avoid false positives; update the negative tests around service.sendCode to
capture the rejected error from sendCode (when userRepository.findById returns
undefined, when user.email is null, and when
emailVerificationCodeRepository.countRecentByUserId returns 5) and assert the
thrown error contains the expected HTTP status codes (429, 404, 400) and/or
message fields rather than a generic toThrow; locate these assertions near
service.sendCode, userRepository.findById and
emailVerificationCodeRepository.countRecentByUserId in the spec and replace the
loose rejects.toThrow() with explicit checks on error.status or error.message
(or toMatchObject) matching the test names.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6455759f-cda3-47af-979b-c7d41a25085f

📥 Commits

Reviewing files that changed from the base of the PR and between a699f54 and f4a31ed.

📒 Files selected for processing (5)
  • apps/api/src/auth/controllers/auth/auth.controller.spec.ts
  • apps/api/src/auth/controllers/auth/auth.controller.ts
  • apps/api/src/auth/services/email-verification-code/email-verification-code.service.spec.ts
  • apps/api/src/user/services/user/user.service.integration.ts
  • apps/api/src/user/services/user/user.service.spec.ts
✅ Files skipped from review due to trivial changes (1)
  • apps/api/src/auth/controllers/auth/auth.controller.spec.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • apps/api/src/user/services/user/user.service.integration.ts
  • apps/api/src/user/services/user/user.service.spec.ts
  • apps/api/src/auth/controllers/auth/auth.controller.ts

- Replace jest.fn/jest.Mock with vi.fn/Mock from vitest in auth0.service.spec
- Remove redundant .length(6) validation (regex already enforces 6 digits)
- Parallelize independent DB reads in sendCode via Promise.all
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts (1)

67-90: Consider invalidating the verification code after successful verification.

After the code is successfully verified (line 87), the code record remains active in the database until it expires. While not a security issue (the user is already verified), this means:

  1. Unnecessary DB lookups on subsequent calls until expiration.
  2. The code could theoretically be "verified" multiple times (idempotent but wasteful).

Consider deleting or marking the code as used after successful verification.

♻️ Suggested fix
     await this.userRepository.updateById(userInternalId, { emailVerified: true });
+    await this.emailVerificationCodeRepository.deleteById(record.id);

     return user.userId;

This would require adding a deleteById method to the repository if it doesn't exist.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts`
around lines 67 - 90, The verifyCodeInTransaction method leaves the verification
record active after successful verification; update it to invalidate the record
(e.g., call emailVerificationCodeRepository.deleteById(record.id) or an
equivalent mark-as-used method) immediately after setting user.emailVerified (or
before returning) so the code cannot be reused and DB lookups stop; add or use a
deleteById/markUsed method on emailVerificationCodeRepository if missing and
ensure this delete runs inside the same transaction/WithTransaction context.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts`:
- Around line 59-65: The verifyCode flow can leave local DB and Auth0
inconsistent because auth0Service.markEmailVerified(auth0UserId) is called
outside the transaction; wrap the call in a try-catch inside verifyCode
(referencing verifyCode and verifyCodeInTransaction), on failure log a distinct
event (e.g., "EMAIL_VERIFIED_MARK_AUTH0_FAILED") with auth0UserId,
userInternalId and the error via this.logger.error, and implement retry
semantics (e.g., limited immediate retries with backoff) or enqueue a background
reconciliation job/message for later retry; ensure the controller still surfaces
a clear error when retries/external reconciliation are scheduled and document
that downstream flows rely on local emailVerified state.

---

Nitpick comments:
In
`@apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts`:
- Around line 67-90: The verifyCodeInTransaction method leaves the verification
record active after successful verification; update it to invalidate the record
(e.g., call emailVerificationCodeRepository.deleteById(record.id) or an
equivalent mark-as-used method) immediately after setting user.emailVerified (or
before returning) so the code cannot be reused and DB lookups stop; add or use a
deleteById/markUsed method on emailVerificationCodeRepository if missing and
ensure this delete runs inside the same transaction/WithTransaction context.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9a529fde-fcf6-4214-9b76-f11dc46ea15c

📥 Commits

Reviewing files that changed from the base of the PR and between f4a31ed and 3cd8f4a.

⛔ Files ignored due to path filters (1)
  • apps/api/test/functional/__snapshots__/docs.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (3)
  • apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts
  • apps/api/src/auth/services/auth0/auth0.service.spec.ts
  • apps/api/src/auth/services/email-verification-code/email-verification-code.service.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/api/src/auth/services/auth0/auth0.service.spec.ts
  • apps/api/src/auth/routes/verify-email-code/verify-email-code.router.ts

- Use @src/* path alias instead of relative import in spec file
- Add try-catch with structured error logging around Auth0 markEmailVerified
userIdIdx: index("email_verification_codes_user_id_idx").on(table.userId),
expiresAtIdx: index("email_verification_codes_expires_at_idx").on(table.expiresAt)
})
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Consider using a unique index on userId instead of a regular index. This would:

  1. Structurally enforce one code per user at the DB level — no need for SELECT ... FOR UPDATE with ORDER BY + LIMIT 1 in the repository
  2. Simplify sendCode — use upsert (INSERT ... ON CONFLICT DO UPDATE) which is atomic, eliminating race conditions between concurrent sendCode calls
  3. Simplify verifyCodeFOR UPDATE query becomes just WHERE userId = ? with no ordering

Rate limiting can be derived from the existing expiresAt column: if expiresAt > now() + (EXPIRY - COOLDOWN), the code was created within the cooldown window. No need for countRecentByUserId or multiple rows.


const codeBuffer = Buffer.from(hashCode(code));
const recordBuffer = Buffer.from(record.code);
const isCodeValid = codeBuffer.length === recordBuffer.length && timingSafeEqual(recordBuffer, codeBuffer);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since the code is already hashed with SHA-256 before storage, timingSafeEqual is unnecessary here. Timing attacks exploit character-by-character comparison of secrets — but the attacker would be trying to guess a SHA-256 hash, which is meaningless for timing side-channels.

You could instead query the DB directly:

WHERE userId = ? AND code = hash(input) AND expiresAt > now()

This eliminates the need to fetch the row and compare in application code.

]);
assert(user, 404, "User not found");
assert(user.email, 400, "User has no email address");
assert(recentCount < MAX_CODES_PER_WINDOW, 429, "Too many verification code requests. Please try again later.");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Race condition: Two concurrent sendCode requests can both pass this rate-limit check simultaneously (both read the same countRecentByUserId before either inserts), allowing both to insert — effectively bypassing the rate limit.

With a unique index on userId + upsert, this goes away structurally since the upsert is atomic.

private async verifyCodeInTransaction(userInternalId: string, code: string): Promise<string> {
const [user, record] = await Promise.all([
this.userRepository.findById(userInternalId),
this.emailVerificationCodeRepository.findActiveByUserIdForUpdate(userInternalId)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Race condition between verify and send: verifyCodeInTransaction locks the row with FOR UPDATE, but sendCode does a plain INSERT (different row, no lock). A new code can be inserted while verification of the old one is in progress — the transaction isolation doesn't help because it's a different row.

With a unique index, there's only ever one row per user, so FOR UPDATE during verify also blocks concurrent upserts on the same row.

@stalniy
Copy link
Copy Markdown
Contributor

stalniy commented Apr 13, 2026

@baktun14 could you please explain why you decided to use SELECT FOR UPDATE instead of unique or compound unique index?

I also don’t understand why there are multiple rows per user in the table. How do you plan to use it?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants