Skip to content

Align password reset active token index schema#270

Merged
auerbachb merged 4 commits into
mainfrom
cursor/password-reset-index-drift-ready-5c90
Apr 28, 2026
Merged

Align password reset active token index schema#270
auerbachb merged 4 commits into
mainfrom
cursor/password-reset-index-drift-ready-5c90

Conversation

@auerbachb
Copy link
Copy Markdown
Owner

@auerbachb auerbachb commented Apr 28, 2026

User description

Summary

  • Align password_reset_tokens.activeUserIdx with the deployed unique partial index on user_id where used_at is null.
  • Add a one-line schema comment documenting why expires_at is not part of the uniqueness scope.
  • Clear stale unused reset tokens after the five-minute resend check and before inserting a new token, preserving the DB-level single-active-token invariant.
  • Restore the prior unused token if replacement email delivery fails, and mark the unsent replacement token used.
  • Capture the prior token id from the atomic update result before any compensating restore.

Testing

  • npx drizzle-kit generate (emitted an untracked initial baseline because the repo has no Drizzle journal; generated index shape matches the deployed unique partial index and does not emit an alter/drop migration for this index)
  • npx tsc --noEmit
  • npm run build
  • coderabbit review --prompt-only (blocked: CLI not installed in the cloud image)
  • npx coderabbit review --prompt-only (blocked: npm could not determine executable)
  • npm run test:unit (existing Vitest collection issue in src/lib/thoughtSaving.test.ts)
  • Vercel preview deployed; direct Preview API/DB row-count check blocked by Vercel authentication/secret access in this agent.
Open in Web Open in Cursor 

CodeAnt-AI Description

Prevent duplicate password reset tokens and keep email retries reliable

What Changed

  • Requesting a password reset now removes any older unused token before creating a new one, so each user keeps only one active reset link at a time.
  • If sending the reset email fails, the new token is marked unusable and the previous token is restored, so the last working link still works.
  • The reset token rule now matches the live database setup, avoiding drift between the app and the stored data.

Impact

✅ Fewer broken password reset links
✅ Safer resend flow for password resets
✅ Less mismatch between app behavior and stored reset-token rules

🔄 Retrigger CodeAnt AI Review

Details

💡 Usage Guide

Checking Your Pull Request

Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.

Talking to CodeAnt AI

Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

Preserve Org Learnings with CodeAnt

You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

Check Your Repository Health

To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.

Co-authored-by: Bretton Auerbach <auerbachb@users.noreply.github.com>
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 28, 2026

CodeAnt AI is reviewing your PR.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
still-point Ready Ready Preview, Comment Apr 28, 2026 0:48am

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 28, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 135025d0-ba51-4792-b71d-941deca0fdb6

📥 Commits

Reviewing files that changed from the base of the PR and between 7a2dfda and 1ddf3e3.

📒 Files selected for processing (1)
  • src/app/api/auth/password-reset/request/route.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/app/api/auth/password-reset/request/route.ts

📝 Walkthrough

Walkthrough

Endpoint now, under an advisory DB lock, consumes any existing unused password-reset token, inserts a new token and returns both IDs with the plaintext token; if email send fails a compensating transaction marks the new token used and restores the previous token’s unused state. Schema: partial unique index on unused tokens per user.

Changes

Cohort / File(s) Summary
Password reset request route
src/app/api/auth/password-reset/request/route.ts
Acquires advisory lock, marks existing unused token(s) as used capturing previousTokenId, inserts new token returning newTokenId and plaintext token as tokenIssue; on email-send failure runs compensating transaction to mark the new token used and optionally restore the previous token’s usedAt.
DB schema (passwordResetTokens index)
src/db/schema.ts
Changed index to a partial unique index on userId where usedAt IS NULL, enforcing at most one concurrently-unused reset token per user; comment updated accordingly.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant API
  participant DB
  participant EmailService

  Client->>API: POST /auth/password-reset/request (email)
  API->>DB: begin tx, acquire advisory lock
  API->>DB: SELECT existing unused token id FOR UPDATE
  API->>DB: UPDATE passwordResetTokens SET usedAt=now() WHERE userId = X AND usedAt IS NULL
  API->>DB: INSERT new token RETURNING id
  DB-->>API: { previousTokenId, newTokenId, token }
  API->>EmailService: send reset email (tokenIssue.token)
  alt email success
    EmailService-->>API: success
    API->>DB: commit tx (releases lock)
    API-->>Client: 200 OK
  else email failure
    EmailService-->>API: error
    API->>DB: begin compensating tx, acquire advisory lock
    API->>DB: UPDATE inserted token SET usedAt=now()
    API->>DB: IF previousTokenId NOT NULL -> UPDATE previous token SET usedAt=NULL
    DB-->>API: compensating tx committed
    API-->>Client: 500 / error
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Poem

🐰
I nibbled stale tokens, one by one,
Closed the gap, then spun a new one—
Locked the gate, sent the note, then hopped,
If mail fails, I tidy what's been swapped.
A rabbit's patch, quick, neat, and done.

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main schema change: aligning the password reset active token index, which is the primary focus of the changeset.
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.

✏️ 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 cursor/password-reset-index-drift-ready-5c90

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

@codeant-ai codeant-ai Bot added the size:XS This PR changes 0-9 lines, ignoring generated files label Apr 28, 2026
Comment thread src/db/schema.ts
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 13257f7. Configure here.

Comment thread src/db/schema.ts
Comment thread src/db/schema.ts
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented Apr 28, 2026

CodeAnt AI finished reviewing your PR.

Co-authored-by: Bretton Auerbach <auerbachb@users.noreply.github.com>
Copy link
Copy Markdown

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/api/auth/password-reset/request/route.ts`:
- Around line 66-69: The current transaction marks the existing token used via
tx.update(passwordResetTokens).set({ usedAt: new Date() }) before calling
sendPasswordResetEmail, which risks revoking the only usable token if email
sending fails; change the flow in route handler (around sendPasswordResetEmail,
tx, and passwordResetTokens) to create/insert the replacement token first (or
queue/send the email first), ensure sendPasswordResetEmail succeeds (or the job
is enqueued) and only then update the prior token with usedAt, or alternatively
perform the revocation inside a compensating step that runs after a successful
send; locate usages of sendPasswordResetEmail, the tx block, and
passwordResetTokens update and reorder so revocation happens after successful
send/queue.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8fc10a5f-ab99-46a0-a256-7eca435e8a77

📥 Commits

Reviewing files that changed from the base of the PR and between a4c0527 and 6414259.

📒 Files selected for processing (2)
  • src/app/api/auth/password-reset/request/route.ts
  • src/db/schema.ts

Comment thread src/app/api/auth/password-reset/request/route.ts Outdated
Co-authored-by: Bretton Auerbach <auerbachb@users.noreply.github.com>
Copy link
Copy Markdown

@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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/api/auth/password-reset/request/route.ts`:
- Around line 66-75: The current select-then-update has a TOCTOU race: instead
of selecting a previous token with tx.select(...passwordResetTokens...) and then
calling tx.update(...passwordResetTokens...).set({usedAt}), change the logic to
perform the update and capture the updated row atomically (use
tx.update(...).set({ usedAt: new Date() }).where(...).returning({ id:
passwordResetTokens.id }) or the DB/ORM equivalent) so you derive
previousTokenId from the update result that was actually marked used; this
ensures consumePasswordResetToken or other concurrent operations can't cause you
to revert a token you didn't actually update when you later set usedAt = null.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e0e71a61-47d9-4b81-be60-1ee134e6d878

📥 Commits

Reviewing files that changed from the base of the PR and between 6414259 and 7a2dfda.

📒 Files selected for processing (1)
  • src/app/api/auth/password-reset/request/route.ts

Comment thread src/app/api/auth/password-reset/request/route.ts Outdated
Co-authored-by: Bretton Auerbach <auerbachb@users.noreply.github.com>
@auerbachb
Copy link
Copy Markdown
Owner Author

Sweep of unresolved review threads — all five findings are already addressed at HEAD 1ddf3e3d:

  1. CodeAnt (Critical) on schema.ts:53 — stale unused tokens trigger 23505 between 5–60 min. Resolved. request/route.ts:66–70 now atomically clears every used_at is null row for the user (tx.update(...).set({usedAt: new Date()}).where(userId & isNull(usedAt))) before the insert, so the unique partial index never sees two unused rows.
  2. Cursor BugBot (Medium) on schema.ts:51–54 — same stale-token issue. Resolved by the same pre-insert sweep referenced above.
  3. CodeAnt Architect (HIGH) on schema.ts:52–54 — same stale-token issue. Resolved by the same pre-insert sweep.
  4. CodeRabbit (Major) on request/route.ts:66–69 — dont revoke before email confirmed. Resolved. route.ts:84–101 adds a compensating transaction: on sendPasswordResetEmail failure it marks the new token used and restores previousTokenId (usedAt = null).
  5. CodeRabbit (Critical TOCTOU) on request/route.ts:66–75 — derive previousTokenId from the actual update. Resolved. route.ts:66–70 now uses update(...).set({usedAt: new Date()}).where(...).returning({id}) so previousTokenId is the row this transaction actually consumed.

Resolving all five threads.

@auerbachb auerbachb merged commit b1ac77e into main Apr 28, 2026
12 checks passed
@auerbachb auerbachb deleted the cursor/password-reset-index-drift-ready-5c90 branch April 28, 2026 13:22
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 2, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:M This PR changes 30-99 lines, ignoring generated files and removed size:XS This PR changes 0-9 lines, ignoring generated files labels May 2, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 2, 2026

Sequence Diagram

This PR updates the password reset request flow so that each user has at most one unused reset token, clears stale tokens before issuing a new one, and restores the prior token if email delivery of the new token fails.

sequenceDiagram
    participant User
    participant API
    participant Database
    participant Email

    User->>API: Request password reset
    API->>Database: Transaction to check recent token, clear stale tokens, create new token
    alt New token created
        API->>Email: Send password reset email
        alt Email delivery fails
            API->>Database: Transaction to mark new token used and restore previous token
        else Email delivered
        end
    else Recent unused token exists
        API->>API: Skip issuing new token and email
    end
    API-->>User: Return generic reset requested message
Loading

Generated by CodeAnt AI

if (tokenIssue) {
try {
await sendPasswordResetEmail({ to: user.email, token });
await sendPasswordResetEmail({ to: user.email, token: tokenIssue.token });
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: This call assumes email delivery failure will throw, but sendPasswordResetEmail can return { delivered: false } without throwing (for example when delivery is skipped in non-production). In that path, the replacement token remains active and no rollback runs, so the code does not actually restore the prior token on all delivery failures. Check the returned delivered flag and trigger the same compensating transaction when it is false. [logic error]

Severity Level: Major ⚠️
- ⚠️ Password reset API keeps active token when email skipped.
- ⚠️ Prior reset token not restored on skipped delivery path.
- ⚠️ Dev/staging DB invariants differ from intended failure semantics.
Steps of Reproduction ✅
1. Run the app in a non-production environment where `process.env.NODE_ENV !==
"production"` and at least one of `EMAIL_FROM` or `RESEND_API_KEY` is unset so that
`sendEmail` takes its non-throwing skip path at `src/lib/email.ts:25-32` (returns `{
delivered: false }` after logging a dev message).

2. Trigger the password reset request API by sending `POST
/api/auth/password-reset/request` (handled by `export async function POST` in
`src/app/api/auth/password-reset/request/route.ts:18-44`) with a JSON body containing an
email that matches an existing user in the `users` table (queried at lines 33-40).

3. In the POST handler, the code enters the transactional block at `route.ts:46-82`,
acquires the advisory lock, marks any previously unused reset token for that user as used
(`update passwordResetTokens` at lines 66-70), then inserts a new `passwordResetTokens`
row and returns `{ token, newTokenId, previousTokenId }` as `tokenIssue`.

4. Execution reaches the email send section at `route.ts:84-87`: `await
sendPasswordResetEmail({ to: user.email, token: tokenIssue.token });` calls
`sendPasswordResetEmail` in `src/lib/email.ts:39-22`, which in turn calls `sendEmail`.
Because email configuration is missing in this non-production setup, `sendEmail` returns
`{ delivered: false }` without throwing. The `try` block completes successfully, the
`catch` at `route.ts:87-32` (which would mark `newTokenId` as used and optionally restore
`previousTokenId`) is never entered, so the unsent replacement token remains the only
active token and the prior token is not restored on this non-throwing delivery failure
path, contrary to the intended "restore prior unused token if replacement email delivery
fails" behavior described in the PR.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** src/app/api/auth/password-reset/request/route.ts
**Line:** 86:86
**Comment:**
	*Logic Error: This call assumes email delivery failure will throw, but `sendPasswordResetEmail` can return `{ delivered: false }` without throwing (for example when delivery is skipped in non-production). In that path, the replacement token remains active and no rollback runs, so the code does not actually restore the prior token on all delivery failures. Check the returned `delivered` flag and trigger the same compensating transaction when it is false.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 2, 2026

CodeAnt AI finished running the review.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 2, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot removed the size:M This PR changes 30-99 lines, ignoring generated files label May 2, 2026
@codeant-ai codeant-ai Bot added the size:M This PR changes 30-99 lines, ignoring generated files label May 2, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 2, 2026

Sequence Diagram

This PR updates the password reset flow so the database enforces a single unused token per user and the API clears stale tokens, while also restoring the prior token if email delivery fails.

sequenceDiagram
    participant User
    participant PasswordResetAPI as Password reset API
    participant Database
    participant EmailService as Email service

    User->>PasswordResetAPI: Submit password reset request
    PasswordResetAPI->>Database: Lock user and check for recent unused token

    alt Recent unused token exists
        PasswordResetAPI-->>User: Return generic reset requested response
    else No recent unused token
        PasswordResetAPI->>Database: Mark prior unused tokens used and insert new token
        PasswordResetAPI->>EmailService: Send password reset email with new token

        alt Email sent successfully
            PasswordResetAPI-->>User: Return generic reset requested response
        else Email delivery failed
            PasswordResetAPI->>Database: Mark new token used and restore prior unused token
            PasswordResetAPI-->>User: Return generic reset requested response
        end
    end
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 2, 2026

CodeAnt AI finished running the review.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 3, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:M This PR changes 30-99 lines, ignoring generated files and removed size:M This PR changes 30-99 lines, ignoring generated files labels May 3, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 3, 2026

Sequence Diagram

This PR enforces a single unused password reset token per user and updates the request flow to clear stale tokens and restore a prior token if email delivery fails.

sequenceDiagram
    participant User
    participant API
    participant Database
    participant Email

    User->>API: Submit password reset request
    API->>Database: Start transaction and lock user
    API->>Database: Find recent unused reset token

    alt Recent unused token exists
        API-->>User: Return generic reset message
    else No recent token
        API->>Database: Clear stale tokens and create new reset token
        API->>Email: Send password reset email

        alt Email fails
            Email-->>API: Report delivery error
            API->>Database: Mark new token used and restore previous token
        end

        API-->>User: Return generic reset message
    end
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 3, 2026

CodeAnt AI finished running the review.

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 3, 2026

CodeAnt AI is running the review.

@codeant-ai codeant-ai Bot added size:M This PR changes 30-99 lines, ignoring generated files and removed size:M This PR changes 30-99 lines, ignoring generated files labels May 3, 2026
@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 3, 2026

Sequence Diagram

This PR enforces a single unused password reset token per user via a unique index and transactional logic that replaces any prior token, then restores it if email delivery of the new token fails.

sequenceDiagram
    participant User
    participant PasswordResetAPI
    participant Database
    participant EmailService

    User->>PasswordResetAPI: Request password reset
    PasswordResetAPI->>Database: Verify rate limit and replace unused reset token for user
    Database-->>PasswordResetAPI: New token and prior token id
    PasswordResetAPI->>EmailService: Send reset email with new token

    alt Email sent successfully
        EmailService-->>PasswordResetAPI: Email sent
        PasswordResetAPI-->>User: Generic success message
    else Email delivery failed
        EmailService-->>PasswordResetAPI: Error sending email
        PasswordResetAPI->>Database: Mark new token used and restore prior token unused
        PasswordResetAPI-->>User: Generic success message
    end
Loading

Generated by CodeAnt AI

@codeant-ai
Copy link
Copy Markdown

codeant-ai Bot commented May 3, 2026

CodeAnt AI finished running the review.

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

Labels

size:M This PR changes 30-99 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants