Skip to content

Add verified account email change flow - #645

Merged
kentcdodds merged 9 commits into
mainfrom
cursor/verified-email-change-4435
Jul 6, 2026
Merged

kentcdodds merged 9 commits into
mainfrom
cursor/verified-email-change-4435

Conversation

@kentcdodds

@kentcdodds kentcdodds commented Jul 6, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • Adds a verified email-change flow from /account, requiring the current password and a new-address verification link before updating users.email.
  • Persists users.stable_user_id and preserves existing user-owned namespaces when account emails change.
  • Covers pending email-change tokens in account export/deletion lifecycle with token hashes redacted.
  • Fixes OAuth approval for stale session-cookie emails and optimizes admin usage stable-id resolution.

Walkthrough

email-change-request-flow.mp4

Testing

  • npx vitest run --project node-unit packages/worker/src/app/handlers/account-email-change.node.test.ts packages/worker/src/entitlements/entitlements.node.test.ts packages/worker/src/app/admin-user-creation.node.test.ts
  • npx vitest run --project workers-unit packages/worker/src/oauth-handlers.workers.test.ts
  • npx vitest run --project node-unit packages/worker/src/app/admin-usage-data.node.test.ts packages/worker/src/mcp/capabilities/admin/admin-capabilities.node.test.ts
  • npm run typecheck
  • npm run test:e2e:run
  • git push -u origin cursor/verified-email-change-4435 (pre-push ran npm run test and npm run test:e2e:run; final run passed 581 unit/workers tests and 6 E2E tests)
  • npx wrangler d1 execute APP_DB --command "SELECT user_id, new_email, LENGTH(token_hash) AS token_hash_length FROM pending_email_changes WHERE new_email = 'email-change-demo-20260706-1624@example.com';" --local --env production --config packages/worker/wrangler.jsonc
  • Manual browser walkthrough: wrong password shows Password is incorrect., then correct password shows Verification email sent to your new address.
System recap — extends existing primitives (medium risk)

Mode: recap · Base: main @ 83f96229 · Head: 0036e4fd

Classification: extends — changes account auth/session behavior, D1 account schema, OAuth identity lookup, and user identity resolution while preserving existing primitives.

Primitives touched

Primitive Group Impact
app-ui surfaces extends — account UI adds password-confirmed email change request form and verification result copy
app-sessions auth extends — verified email-change route updates account email and refreshes session cookie when applicable
mcp-oauth auth extends — OAuth approval/reset identity lookup prefers stored/session IDs with stale-email fallback coverage
entitlements auth extends — plan lookup accepts stored stable IDs after email changes
account-export assistant extends — pending email-change tokens enter export/deletion coverage with hashes redacted
email assistant extends — platform sender account lookup accepts stored stable IDs
d1-app-db storage extends — adds users.stable_user_id and pending_email_changes

System map

flowchart LR
	appUi["app-ui"]:::extended --> appSessions["app-sessions"]:::extended --> d1AppDb["d1-app-db"]:::extended
	appSessions --> mcpOauth["mcp-oauth"]:::extended
	appSessions --> entitlements["entitlements"]:::extended
	appSessions --> email["email"]:::extended
	d1AppDb --> accountExport["account-export"]:::extended
	classDef touched fill:#1a7f37,color:#fff
	classDef extended fill:#9a6700,color:#fff
	classDef added fill:#cf222e,color:#fff
	classDef untouched fill:#57606a,color:#fff
Loading

Change flow

sequenceDiagram
	participant User
	participant Account as /account
	participant D1 as APP_DB
	participant Mail as Email sender
	participant Verify as /verify-email-change
	participant OAuth as /oauth/authorize
	User->>Account: new email + current password
	Account->>D1: rate-limit, verify password, store pending token hash
	Account->>Mail: send verification link to new email
	User->>Verify: open token link
	Verify->>D1: update users.email, preserve stable_user_id, clear pending tokens
	OAuth->>D1: resolve session by db user id if cookie email is stale
Loading

Before / after

Area Before After
Account email read-only after signup/admin creation password-confirmed request plus verified new-address link
User namespace derived from current email hash stored stable ID retained across email changes
Token storage account verification only separate hashed pending email-change tokens with resend replacement
OAuth session approval session cookie email was authoritative database user id refreshes stale session email
Admin usage per-row stable-id lookups stable IDs resolved from selected user rows

Invariants

  • per-user-isolation: account email changes preserve the stable user namespace so D1 rows, Durable Object names, KV-derived keys, and capability contexts continue to use the same user-owned identity.
Open in Web Open in Cursor 

Summary by CodeRabbit

  • New Features
    • Added an account “Change email” workflow with password confirmation and email confirmation.
    • Introduced a dedicated “Verify email change” page with tailored success/failure messaging.
  • Bug Fixes
    • Improved verification page messaging to correctly distinguish standard email verification vs email-change results.
    • Improved email-change outcomes for incorrect passwords and email conflicts with clearer status responses.
  • Security & Reliability
    • Added support for pending email-change requests with token verification and improved handling of expired/invalid tokens.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds stored stable user ID support across auth and account flows, then implements an email-change request and verification flow with route wiring, tests, and client updates.

Changes

Stable user ID and email-change feature

Layer / File(s) Summary
Stable user ID schema and resolver utilities
packages/worker/migrations/0052-*.sql, packages/worker/src/db.ts, packages/worker/src/user-id.ts, packages/worker/src/app/account-data-targets.ts
Adds stable_user_id, pending_email_changes, resolver helpers, and account D1 coverage updates.
Stable user ID consumers and persistence wiring
packages/worker/src/app/handlers/auth.ts, admin-user-creation.ts, admin-usage-data.ts, account-export.ts, email-verification.ts, request-auth-cache.ts, email/platform-address.ts, entitlements/service.ts, app/user-lookup.ts, oauth-handlers.ts
Updates signup, admin, export, auth, entitlements, OAuth, and lookup code to use stored or resolved stable user IDs.
Email-change backend and routing
packages/worker/src/app/email-change.ts, handlers/account-email-change.ts, handlers/verify-email-change.ts, app/routes.ts, app/router.ts, handlers/account-email-change.node.test.ts
Implements token creation and verification, the authenticated email-change request handler, verification-page handling, route registration, and integration tests.
Account page and verify-email client updates
packages/worker/client/routes/account.tsx, client/routes/index.tsx, client/routes/verify-email.tsx
Adds the Change email form, registers /verify-email-change, and updates verify-email messaging for email-change success.

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

Possibly related PRs

  • kentcdodds/kody#137: Implements the Cloudflare email-sending path used by the email-change verification flow.
  • kentcdodds/kody#615: Extends the same verification loader/data and client copy to distinguish email verification from email change.
  • kentcdodds/kody#636: Updates the same stable-user-id reverse-resolution logic in packages/worker/src/entitlements/service.ts.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding a verified account email change flow.
✨ 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/verified-email-change-4435

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.

@kentcdodds
kentcdodds marked this pull request as ready for review July 6, 2026 17:05
@github-actions

github-actions Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

🔎 Preview deployed: https://kody-pr-645.kentcdodds.workers.dev

Worker: kody-pr-645
D1: kody-pr-645-db
KV: kody-pr-645-oauth-kv

Mocks:

Comment thread packages/worker/src/oauth-handlers.ts

@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: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/worker/src/app/admin-usage-data.ts (1)

68-73: 🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

N+1 queries introduced by per-row resolveUserStableIdByEmail.

The bulk user query (lines 114-121) doesn't select stable_user_id, so addUsageIds now issues one extra DB round-trip per row via resolveUserStableIdByEmail. Selecting stable_user_id alongside the other columns and using the synchronous resolveUserStableId avoids the extra queries entirely.

⚡ Suggested fix
 type AdminUsageUserRow = {
 	id: number
 	username: string
 	email: string
 	plan: string | null
+	stable_user_id: string | null
 }
 		env.APP_DB.prepare(
-			`SELECT id, username, email, plan
+			`SELECT id, username, email, plan, stable_user_id
 			 FROM users
 			 ORDER BY id ASC
 			 LIMIT ? OFFSET ?`,
 		)
-	const users = await addUsageIds(env.APP_DB, userRows.results ?? [])
+	const users = await addUsageIds(userRows.results ?? [])
 async function addUsageIds(
-	db: D1Database,
 	rows: Array<AdminUsageUserRow>,
 ): Promise<Array<UserWithUsageId>> {
 	return await Promise.all(
 		rows.map(async (row) => ({
 			id: row.id,
 			username: row.username,
 			email: row.email,
 			plan: parsePlanName(row.plan),
-			usageUserId: await resolveUserStableIdByEmail({ db, email: row.email }),
+			usageUserId: await resolveUserStableId(row),
 		})),
 	)
 }

(loadSelectedUser's fallback query at line 196 would also need stable_user_id added, and its addUsageIds(input.db, [row]) call updated accordingly.)

Also applies to: 114-124, 168-181, 200-200

🤖 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 `@packages/worker/src/app/admin-usage-data.ts` around lines 68 - 73, The admin
usage data flow is doing per-row lookups because `loadSelectedUsers` and the
`loadSelectedUser` fallback path do not select `stable_user_id`, forcing
`addUsageIds` to call `resolveUserStableIdByEmail` for each row. Update the
`AdminUsageUserRow` shape and the queries in
`loadSelectedUsers`/`loadSelectedUser` to include `stable_user_id`, then switch
`addUsageIds` to use `resolveUserStableId` on the loaded rows so the IDs are
resolved without extra DB round-trips.
🧹 Nitpick comments (4)
packages/worker/client/routes/account.tsx (1)

531-544: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Error message uses role="status" instead of role="alert".

emailChangeMessage (including the "Password is incorrect." failure case) is always rendered with role="status", whereas the top-level message block above uses role="alert" unconditionally. Screen readers announce alert regions immediately/assertively but only poll status regions politely, so a password failure here may not be announced promptly.

♿ Suggested fix
 {emailChangeMessage ? (
   <p
-    role="status"
+    role={emailChangeTone === 'error' ? 'alert' : 'status'}
     mix={css({
       color:
         emailChangeTone === 'error'
           ? colors.error
           : colors.text,
       margin: 0,
     })}
   >
     {emailChangeMessage}
   </p>
 ) : null}
🤖 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 `@packages/worker/client/routes/account.tsx` around lines 531 - 544, The email
change feedback block in account.tsx is using role="status" for an error state,
which makes failures like “Password is incorrect.” announced too politely.
Update the emailChangeMessage rendering so the message uses role="alert" when
emailChangeTone indicates an error, while preserving the existing non-error
behavior for success/info states. Use the emailChangeMessage and emailChangeTone
conditional block to locate the change.
packages/worker/src/app/admin-user-creation.ts (1)

152-175: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Fold stable_user_id into the initial INSERT instead of a silently-swallowed follow-up UPDATE.

auth.ts sets stable_user_id directly in the INSERT/db.create call for new users; here it's set via a second UPDATE whose failure is discarded with no logging, inconsistent with how other best-effort steps in this file report failures (e.g., role assignment, email inbox provisioning both log on error).

♻️ Suggested fix
 	const stableUserId = await createStableUserIdFromEmail(email)
 	let userId: number | null = null

 	try {
 		const result = await input.db
 			.prepare(
-				`INSERT INTO users (username, email, password_hash, email_verified_at)
-				 VALUES (?, ?, ?, ?)`,
+				`INSERT INTO users (username, email, password_hash, email_verified_at, stable_user_id)
+				 VALUES (?, ?, ?, ?, ?)`,
 			)
-			.bind(username, email, adminCreatedNoUsablePasswordHash, nowIso)
+			.bind(username, email, adminCreatedNoUsablePasswordHash, nowIso, stableUserId)
 			.run()
 		const lastRowId = result.meta.last_row_id
 		if (!Number.isSafeInteger(lastRowId) || lastRowId < 1) {
 			throw new AdminCreateUserError(
 				'create_failed',
 				'Unable to create account.',
 			)
 		}
 		userId = lastRowId
-		await input.db
-			.prepare(`UPDATE users SET stable_user_id = ? WHERE id = ?`)
-			.bind(stableUserId, userId)
-			.run()
-			.catch(() => undefined)
 	} catch (error) {
🤖 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 `@packages/worker/src/app/admin-user-creation.ts` around lines 152 - 175,
`createAdminUser` is inserting a user and then quietly patching `stable_user_id`
in a separate UPDATE, which can fail without any visibility. Move the
`stable_user_id` assignment into the initial INSERT in this flow, using the same
`stableUserId` generated by `createStableUserIdFromEmail`, and remove the
swallowed follow-up UPDATE so the user record is created atomically and
consistently with `auth.ts` and `db.create`.
packages/worker/src/user-id.ts (1)

43-76: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Narrow the fallback scan to legacy rows The post-miss scan currently walks every users row, but the indexed lookup already covers rows with a stored stable_user_id; filtering the fallback to stable_user_id IS NULL avoids rehashing rows that can’t match.

🤖 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 `@packages/worker/src/user-id.ts` around lines 43 - 76, The fallback scan in
findUserRowByStableUserId is too broad because it rechecks every row after the
indexed lookup has already handled stored stable_user_id values. Update the
fallback query in this function to restrict the scan to legacy rows only, using
stable_user_id IS NULL (with the existing withoutStableUserIdColumn path as
needed), so resolveUserStableId is only applied to rows that could still match.
packages/worker/src/db.ts (1)

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

Consider indexing users.stable_user_id.

This PR routes several resolvers (findUserRowByStableUserId, entitlements lookups) through WHERE stable_user_id = ?. Without an index, those queries fall back to table scans, and the legacy scan-and-hash path already scans all rows. An index on stable_user_id keeps these lookups efficient as the table grows.

🤖 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 `@packages/worker/src/db.ts` at line 10, Add an index for users.stable_user_id
in the schema definition where the users table is declared in db.ts. The new
index should target the stable_user_id column so the resolver paths using
findUserRowByStableUserId and entitlement lookups can use indexed WHERE
stable_user_id = ? queries instead of table scans. Keep the change localized to
the users table schema alongside the existing column definitions.
🤖 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 `@packages/worker/client/routes/account.tsx`:
- Around line 214-221: The 401 handling in account.tsx is relying on the literal
error text from account-email-change.tsx, which is fragile. Update the response
handling to branch on a stable machine-readable discriminator instead of
comparing payload.error to “Password is incorrect.”, and use the existing inline
error path only for the invalid-password case. Add a backend code field in the
account-email-change handler so the client can distinguish invalid password from
session-expired/unauthorized responses without depending on human-readable
wording.

In `@packages/worker/client/routes/verify-email.tsx`:
- Around line 24-31: The title selection in verify-email.tsx is inferring
email-change state by substring-matching data.message, which tightly couples
EmailVerificationLoaderData to backend copy. Update the loader/data contract to
include an explicit discriminator such as kind on EmailVerificationLoaderData,
and use that in the verify-email route instead of checking message text when
computing isEmailChange and title.

In `@packages/worker/src/app/email-change.ts`:
- Around line 86-153: `sendEmailChange` currently blocks a new request when
`pending_email_changes.new_email` already has an active row, so a user cannot
get a fresh token for the same address. Update the flow in
`packages/worker/src/app/email-change.ts` to either replace any existing pending
row before `db.create(pendingEmailChangesTable, ...)` or add a resend path that
reuses the existing record, and make sure the cleanup logic in `discardNewToken`
and the final token cleanup still leave only the newest pending token per
user/email.

In `@packages/worker/src/app/handlers/account-email-change.node.test.ts`:
- Around line 293-304: The expiry setup in the verifyEmailChangeToken test is
using mismatched clocks, which can make the token look expired depending on when
the suite runs. Update the test in account-email-change.node.test.ts so the
seeded expires_at values and the now argument are derived from the same fixed
reference time, or make both relative to Date.now(). Use the existing
verifyEmailChangeToken test case and the email_verifications /
pending_email_changes inserts to keep the expiration assertion deterministic.

In `@packages/worker/src/app/handlers/account-email-change.ts`:
- Around line 86-145: The `/account-email-change` handler currently calls
`verifyPassword` before any user-specific throttling, which allows unlimited
password retries for a valid session. In `account-email-change.ts`, move the
existing `checkRateLimit` flow for the user (or add a dedicated failed-attempt
limiter) so it runs before `verifyPassword`, and ensure the same audit/logging
paths in this handler still record rate-limited failures and other rejection
reasons consistently.

In `@packages/worker/src/user-id.ts`:
- Around line 24-37: The resolveUserStableIdByEmail flow is swallowing database
errors by using .catch(() => null), which makes real query failures look like
missing rows and incorrectly falls back to createStableUserIdFromEmail. Update
resolveUserStableIdByEmail to let D1Database errors surface, and only use the
fallback path when the query explicitly returns no row; keep the existing
resolveUserStableId and createStableUserIdFromEmail branching, but distinguish
actual not-found from rejected queries.

---

Outside diff comments:
In `@packages/worker/src/app/admin-usage-data.ts`:
- Around line 68-73: The admin usage data flow is doing per-row lookups because
`loadSelectedUsers` and the `loadSelectedUser` fallback path do not select
`stable_user_id`, forcing `addUsageIds` to call `resolveUserStableIdByEmail` for
each row. Update the `AdminUsageUserRow` shape and the queries in
`loadSelectedUsers`/`loadSelectedUser` to include `stable_user_id`, then switch
`addUsageIds` to use `resolveUserStableId` on the loaded rows so the IDs are
resolved without extra DB round-trips.

---

Nitpick comments:
In `@packages/worker/client/routes/account.tsx`:
- Around line 531-544: The email change feedback block in account.tsx is using
role="status" for an error state, which makes failures like “Password is
incorrect.” announced too politely. Update the emailChangeMessage rendering so
the message uses role="alert" when emailChangeTone indicates an error, while
preserving the existing non-error behavior for success/info states. Use the
emailChangeMessage and emailChangeTone conditional block to locate the change.

In `@packages/worker/src/app/admin-user-creation.ts`:
- Around line 152-175: `createAdminUser` is inserting a user and then quietly
patching `stable_user_id` in a separate UPDATE, which can fail without any
visibility. Move the `stable_user_id` assignment into the initial INSERT in this
flow, using the same `stableUserId` generated by `createStableUserIdFromEmail`,
and remove the swallowed follow-up UPDATE so the user record is created
atomically and consistently with `auth.ts` and `db.create`.

In `@packages/worker/src/db.ts`:
- Line 10: Add an index for users.stable_user_id in the schema definition where
the users table is declared in db.ts. The new index should target the
stable_user_id column so the resolver paths using findUserRowByStableUserId and
entitlement lookups can use indexed WHERE stable_user_id = ? queries instead of
table scans. Keep the change localized to the users table schema alongside the
existing column definitions.

In `@packages/worker/src/user-id.ts`:
- Around line 43-76: The fallback scan in findUserRowByStableUserId is too broad
because it rechecks every row after the indexed lookup has already handled
stored stable_user_id values. Update the fallback query in this function to
restrict the scan to legacy rows only, using stable_user_id IS NULL (with the
existing withoutStableUserIdColumn path as needed), so resolveUserStableId is
only applied to rows that could still match.
🪄 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 Plus

Run ID: f8b3994c-5368-430c-9771-87b1a804367a

📥 Commits

Reviewing files that changed from the base of the PR and between 83f9622 and 5947ff6.

📒 Files selected for processing (23)
  • packages/worker/client/routes/account.tsx
  • packages/worker/client/routes/index.tsx
  • packages/worker/client/routes/verify-email.tsx
  • packages/worker/migrations/0052-stable-user-id-email-changes.sql
  • packages/worker/src/app/account-data-targets.ts
  • packages/worker/src/app/account-export.ts
  • packages/worker/src/app/admin-usage-data.ts
  • packages/worker/src/app/admin-user-creation.ts
  • packages/worker/src/app/email-change.ts
  • packages/worker/src/app/email-verification.ts
  • packages/worker/src/app/handlers/account-email-change.node.test.ts
  • packages/worker/src/app/handlers/account-email-change.ts
  • packages/worker/src/app/handlers/auth.ts
  • packages/worker/src/app/handlers/verify-email-change.ts
  • packages/worker/src/app/request-auth-cache.ts
  • packages/worker/src/app/router.ts
  • packages/worker/src/app/routes.ts
  • packages/worker/src/app/user-lookup.ts
  • packages/worker/src/db.ts
  • packages/worker/src/email/platform-address.ts
  • packages/worker/src/entitlements/service.ts
  • packages/worker/src/oauth-handlers.ts
  • packages/worker/src/user-id.ts

Comment thread packages/worker/client/routes/account.tsx
Comment thread packages/worker/client/routes/verify-email.tsx Outdated
Comment thread packages/worker/src/app/email-change.ts
Comment thread packages/worker/src/app/handlers/account-email-change.node.test.ts
Comment thread packages/worker/src/app/handlers/account-email-change.ts Outdated
Comment thread packages/worker/src/user-id.ts
.prepare(`UPDATE users SET stable_user_id = ? WHERE id = ?`)
.bind(stableUserId, userId)
.run()
.catch(() => undefined)

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.

Admin create duplicates stable identity

High Severity

After another account changes away from an address but keeps its stable_user_id, admin creation can insert a new row for that freed email, silently ignore a conflicting stable_user_id update, and leave two database users resolving to the same MCP stable id via resolveUserStableId.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit de3288f. Configure here.

{
username: normalizedUsername,
email: normalizedEmail,
stable_user_id: stableUserId,

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.

Signup ignores stable id conflict

Medium Severity

Signup now writes stable_user_id from the email hash, but unique failures on that column are not mapped to a client-facing conflict response, so registering a previously released address can surface as an unhandled error instead of a clear “email taken” outcome.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit de3288f. Configure here.

}
}
return false
const row = await findUserRowByStableUserId<{

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.

MCP blocked after email change

High Severity

After a verified email change, existing OAuth MCP tokens still carry the previous address in grant props. isAccountEmailVerified only looks up that stale email and never uses stableUserId, so it returns unverified and MCP requests get a 403 even though the account is verified under the new address.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 111dd40. Configure here.

.prepare(`SELECT plan FROM users WHERE email = ?`)
.bind(email)
.first<{ plan: string | null }>()
if (!row) return null

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.

Plan lookup breaks post change

Medium Severity

getUserPlan still treats a matching SHA-256 of the supplied email as sufficient proof of identity. After an email change, the stored stable_user_id no longer matches the new address hash, and lookups by the old address find no row, so plan enforcement is skipped or applied under the wrong user id namespace.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 111dd40. Configure here.

@cursor cursor Bot left a comment

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.

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

There are 5 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 0036e4f. Configure here.

)
}
const row = await input.env.APP_DB.prepare(
`SELECT id FROM users WHERE email = ?`,

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.

Export lookup breaks after change

Medium Severity

Account export resolves the database user only by the caller email. After an email change, MCP export can still pass the previous address, the row is not found, and export fails even when mcpUserId still matches the preserved stable id.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0036e4f. Configure here.

@kentcdodds
kentcdodds merged commit 17d545c into main Jul 6, 2026
5 checks passed
@kentcdodds
kentcdodds deleted the cursor/verified-email-change-4435 branch July 6, 2026 17:46
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.

2 participants