From bba24d1b81521c1c7a7697b22ca6ab4027b1b00e Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 6 Sep 2026 20:54:33 +0000
Subject: [PATCH 1/4] Add self-serve former-email claims without reminting
identity.
Keep users.stable_user_id as the account identity and treat emails as
claims. Changing email warns and retains the previous verified address
so it cannot open a second account until the owner re-verifies and
releases it from Account settings. Signup and OAuth collisions now
explain that path without leaking the current login email.
Co-authored-by: Kent C. Dodds
---
.../references/features/account.md | 1 +
.../references/features/signup.md | 1 +
.../contributing/architecture/data-storage.md | 48 +--
docs/contributing/security.md | 17 +-
packages/worker/client/lazy-route.tsx | 1 +
.../routes/account-former-emails-panel.tsx | 180 +++++++++
.../client/routes/account-profile-panel.tsx | 8 +
packages/worker/client/routes/account.tsx | 129 +++++++
packages/worker/client/routes/index.tsx | 3 +
.../worker/client/routes/verify-email.tsx | 13 +-
.../migrations/0045-user-email-claims.sql | 40 ++
packages/worker/src/account/data-targets.ts | 3 +
.../worker/src/app/account-profile-data.ts | 7 +
packages/worker/src/app/email-change.ts | 20 +
.../worker/src/app/email-claim-release.ts | 251 +++++++++++++
packages/worker/src/app/email/messages.ts | 21 ++
.../app/handlers/account-avatar.node.test.ts | 1 +
.../account-email-change.node.test.ts | 14 +-
.../src/app/handlers/account-email-change.ts | 14 +-
.../account-email-claim-release.node.test.ts | 286 +++++++++++++++
.../handlers/account-email-claim-release.ts | 270 ++++++++++++++
.../app/handlers/account-profile.node.test.ts | 2 +
.../src/app/handlers/account.node.test.ts | 1 +
.../app/handlers/auth-provider.node.test.ts | 4 +-
.../worker/src/app/handlers/auth-provider.ts | 30 +-
.../auth-stable-user-id-conflict.node.test.ts | 19 +-
packages/worker/src/app/handlers/auth.ts | 105 +++++-
.../src/app/handlers/verify-email-change.ts | 3 +-
.../handlers/verify-email-claim-release.ts | 100 +++++
packages/worker/src/app/router.ts | 4 +
.../worker/src/app/ssr-render.node.test.ts | 1 +
packages/worker/src/database-errors.ts | 1 +
packages/worker/src/db.ts | 28 ++
.../src/identity/admin-user-creation.ts | 21 +-
.../src/identity/email-claims.node.test.ts | 156 ++++++++
packages/worker/src/identity/email-claims.ts | 343 ++++++++++++++++++
.../src/identity/platform-account-creation.ts | 19 +-
packages/worker/src/user-id.ts | 11 +
packages/worker/universal/document-head.ts | 4 +
.../worker/universal/email-claim-errors.ts | 9 +
packages/worker/universal/loader-data.ts | 8 +-
.../worker/universal/oauth-login-errors.ts | 3 +
packages/worker/universal/routes.ts | 2 +
tools/control-kody/feature-catalog.ts | 2 +
tools/migration-ledger.json | 4 +
45 files changed, 2143 insertions(+), 65 deletions(-)
create mode 100644 packages/worker/client/routes/account-former-emails-panel.tsx
create mode 100644 packages/worker/migrations/0045-user-email-claims.sql
create mode 100644 packages/worker/src/app/email-claim-release.ts
create mode 100644 packages/worker/src/app/handlers/account-email-claim-release.node.test.ts
create mode 100644 packages/worker/src/app/handlers/account-email-claim-release.ts
create mode 100644 packages/worker/src/app/handlers/verify-email-claim-release.ts
create mode 100644 packages/worker/src/identity/email-claims.node.test.ts
create mode 100644 packages/worker/src/identity/email-claims.ts
create mode 100644 packages/worker/universal/email-claim-errors.ts
diff --git a/.agents/skills/control-kody/references/features/account.md b/.agents/skills/control-kody/references/features/account.md
index b9a9417634..1420820d09 100644
--- a/.agents/skills/control-kody/references/features/account.md
+++ b/.agents/skills/control-kody/references/features/account.md
@@ -22,6 +22,7 @@ node tools/control-kody.ts request GET /account/profile.json
- `GET|POST /account/profile.json`
- `POST /account/profile/avatar.json`
- `POST /account/email-change.json`
+- `POST /account/email-claim-release.json`
- `GET /account/export.json`
- `POST /account/delete`
- `GET|POST /account/connections.json`
diff --git a/.agents/skills/control-kody/references/features/signup.md b/.agents/skills/control-kody/references/features/signup.md
index 0cae5ab278..90d3fb583d 100644
--- a/.agents/skills/control-kody/references/features/signup.md
+++ b/.agents/skills/control-kody/references/features/signup.md
@@ -6,6 +6,7 @@ Create an account, then confirm email.
`/signup` → verification email → `/verify-email` or `/pending-verification`.
Email-change confirm is `/verify-email-change` (token from the change email).
+Former-address release confirm is `/verify-email-claim-release`.
## Drive it
diff --git a/docs/contributing/architecture/data-storage.md b/docs/contributing/architecture/data-storage.md
index 3c8f209bc4..da1d7c3fa4 100644
--- a/docs/contributing/architecture/data-storage.md
+++ b/docs/contributing/architecture/data-storage.md
@@ -327,27 +327,33 @@ The schema is defined by migrations in `packages/worker/migrations/`:
- `users`: login identity and password hash, plus the persisted stable MCP
`userId` (`stable_user_id`, with a NOT NULL unique index in
`0001-squashed-init.sql`; initially SHA-256 of the normalized email at signup
- via `createStableUserIdFromEmail`, then preserved across email changes). Email
- change requires a verified current address (`users.email_verified_at` is
- non-null). A `stable_user_id` unique collision at signup is a controlled 409;
- operators inspect collisions with `adminUserStableIdConflict` (returns stable
- user id, username, `created_at`, and email-verified state — never content).
- Optional community profile fields are `display_name`, `bio`, and
- `profile_visibility` (default `public`). `account_type` (`'person'` default or
- `'platform'`) distinguishes normal signups from operator-provisioned platform
- accounts that own official package scopes (see
- [Platform accounts](./platform-accounts.md)). First-touch marketing columns
- (`utm_*`, `first_touch_landing_path`, `first_touch_referrer`) store signup
- attribution when present. Activation and return columns
- (`first_mcp_connected_at`, `first_execute_at`, `first_search_at`,
- `first_saved_package_at`, `mcp_client_name`, `last_active_at`) support product
- metrics; email verification delivery columns track the latest transactional
- verify-mail outcome. The `d1_storage_reconciliation` lane sweeps users by
- `stable_user_id` keyset from the platform-owned `d1_storage_reconcile_cursor`
- singleton. UserMeter `storage_bytes_state` (schema v4) drives storage-byte
- enforcement; see [Entitlements](./entitlements.md#usermeter). Inbound email
- routing does not reverse-resolve stable ids — it uses the indexed username
- lookup (`findPublicUserIdentityByUsername`) on the RFC 5233 base local
+ via `createStableUserIdFromEmail`, then preserved across email changes).
+ Emails are claims on that identity (`user_email_claims`): changing email keeps
+ the previous verified address claimed so it cannot open a second account until
+ the owner re-verifies and releases it. A released address can sign up as a new
+ account with a newly minted unique `stable_user_id`; the original account's id
+ is never reminted. Email change requires a verified current address
+ (`users.email_verified_at` is non-null). A former-email claim collision at
+ signup is a controlled 409 (`former_email_claimed`) that does not leak the
+ account's current email; operators inspect leftover implicit sha256 collisions
+ with `adminUserStableIdConflict` (returns stable user id, username,
+ `created_at`, and email-verified state — never content). Optional community
+ profile fields are `display_name`, `bio`, and `profile_visibility` (default
+ `public`). `account_type` (`'person'` default or `'platform'`) distinguishes
+ normal signups from operator-provisioned platform accounts that own official
+ package scopes (see [Platform accounts](./platform-accounts.md)). First-touch
+ marketing columns (`utm_*`, `first_touch_landing_path`,
+ `first_touch_referrer`) store signup attribution when present. Activation and
+ return columns (`first_mcp_connected_at`, `first_execute_at`,
+ `first_search_at`, `first_saved_package_at`, `mcp_client_name`,
+ `last_active_at`) support product metrics; email verification delivery columns
+ track the latest transactional verify-mail outcome. The
+ `d1_storage_reconciliation` lane sweeps users by `stable_user_id` keyset from
+ the platform-owned `d1_storage_reconcile_cursor` singleton. UserMeter
+ `storage_bytes_state` (schema v4) drives storage-byte enforcement; see
+ [Entitlements](./entitlements.md#usermeter). Inbound email routing does not
+ reverse-resolve stable ids — it uses the indexed username lookup
+ (`findPublicUserIdentityByUsername`) on the RFC 5233 base local
(`resolveInboundMailboxRoute`). Plus-tags on user inbox hosts are aliases for
that username, including tags that spell a reserved system local. Contextless
paths resolve stable ids with one indexed point read on `users.stable_user_id`
diff --git a/docs/contributing/security.md b/docs/contributing/security.md
index 0d13549605..f2cc99ef84 100644
--- a/docs/contributing/security.md
+++ b/docs/contributing/security.md
@@ -76,13 +76,16 @@ package-app surfaces:
11. **Email change requires a verified current address.**
`POST /account/email-change.json` refuses to start a change when
`users.email_verified_at` is null (403, audit reason `email_unverified`). A
- `stable_user_id` unique conflict at password or social-login signup is a
- controlled 409 with audit reason `stable_user_id_exists` (message directs
- the person to contact `support@kody.codes`) and releases a consumed invite.
- Operators inspect collisions with `adminUserStableIdConflict` (metadata
- only) and suspend or delete the squatting account with existing
- capabilities. `users.stable_user_id` is never recomputed for an existing
- account.
+ former-email claim collision at password or social-login signup is a
+ controlled 409 with audit reason `former_email_claimed` (copy tells the
+ person to sign in with the email that account uses now, or release the
+ address from Account settings — never leaking the current email) and
+ releases a consumed invite. The owner re-verifies the former address
+ (`POST /account/email-claim-release.json` plus
+ `/verify-email-claim-release`) to drop the claim; that path is rate limited.
+ Operators inspect leftover implicit sha256 collisions with
+ `adminUserStableIdConflict` (metadata only). `users.stable_user_id` is never
+ recomputed for an existing account.
12. **Unverified accounts are reclaimed on a provider-verified social match.**
When a social login profile presents a verified email that matches
`users.email` and `email_verified_at` is null, treat the row as a possible
diff --git a/packages/worker/client/lazy-route.tsx b/packages/worker/client/lazy-route.tsx
index 0b310243b3..97d7ed4e44 100644
--- a/packages/worker/client/lazy-route.tsx
+++ b/packages/worker/client/lazy-route.tsx
@@ -366,6 +366,7 @@ registerPreloadPatterns(
routePattern(routes.verify),
routePattern(routes.verifyEmail),
routePattern(routes.verifyEmailChange),
+ routePattern(routes.verifyEmailClaimRelease),
],
{ name: 'auth-area', load: authArea.load, getCached: authArea.getCached },
)
diff --git a/packages/worker/client/routes/account-former-emails-panel.tsx b/packages/worker/client/routes/account-former-emails-panel.tsx
new file mode 100644
index 0000000000..a770f2fd55
--- /dev/null
+++ b/packages/worker/client/routes/account-former-emails-panel.tsx
@@ -0,0 +1,180 @@
+import { css } from 'remix/ui'
+import { on } from '#client/event-mixin.ts'
+import { passwordManagerIgnoreProps } from '#client/password-manager-ignore.ts'
+import { type AccountFormerEmail } from '#universal/loader-data.ts'
+import { colors, spacing } from '#universal/styles/tokens.ts'
+import {
+ getGhostButtonCss,
+ getPillButtonCss,
+} from '#universal/styles/style-primitives.ts'
+import {
+ AccountManagementPanel,
+ accountFieldCss,
+ accountFieldLabelCss,
+ accountFieldNoteCss,
+ accountInputCss,
+} from '#client/routes/account-management-components.tsx'
+
+export type AccountFormerEmailsPanelProps = {
+ formerEmails: Array
+ releaseEmail: string
+ releasePassword: string
+ releaseStatus: 'idle' | 'sending'
+ releaseMessage: string | null
+ releaseTone: 'error' | 'info'
+ onReleaseEmailInput: (event: InputEvent) => void
+ onReleasePasswordInput: (event: InputEvent) => void
+ onReleaseSubmit: (event: SubmitEvent) => void
+ onUseAgainAsLogin: (email: string) => void
+ onReleaseListed: (email: string) => void
+}
+
+export function renderAccountFormerEmailsPanel(
+ props: AccountFormerEmailsPanelProps,
+) {
+ const {
+ formerEmails,
+ releaseEmail,
+ releasePassword,
+ releaseStatus,
+ releaseMessage,
+ releaseTone,
+ onReleaseEmailInput,
+ onReleasePasswordInput,
+ onReleaseSubmit,
+ onUseAgainAsLogin,
+ onReleaseListed,
+ } = props
+ const compactGhostButtonCss = getGhostButtonCss({ size: 'sm' })
+ const compactPillButtonCss = getPillButtonCss({ size: 'sm' })
+ const isSending = releaseStatus === 'sending'
+
+ return (
+
+ {formerEmails.length > 0 ? (
+
+ {formerEmails.map((claim) => (
+
+
{claim.email}
+
+
+
+
+
+ ))}
+
+ ) : (
+
+ No former addresses are listed yet. If you changed email before this
+ list existed, enter that old verified address below to release it.
+
+ After you switch, your current verified address stays tied to this
+ account and cannot open a second account unless you release it
+ from Former addresses.
+
diff --git a/packages/worker/migrations/0045-user-email-claims.sql b/packages/worker/migrations/0045-user-email-claims.sql
new file mode 100644
index 0000000000..2df6c54eab
--- /dev/null
+++ b/packages/worker/migrations/0045-user-email-claims.sql
@@ -0,0 +1,40 @@
+-- Emails are claims on an account. `users.stable_user_id` stays the identity
+-- minted at signup and is never reminted when the login email changes.
+-- Changing email keeps the previous verified address claimed so it cannot
+-- open a second account until the owner re-verifies and releases it.
+
+CREATE TABLE user_email_claims (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ user_id INTEGER NOT NULL,
+ email TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('claimed', 'released')),
+ claimed_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ released_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
+ UNIQUE (user_id, email)
+);
+
+CREATE UNIQUE INDEX idx_user_email_claims_active_email
+ ON user_email_claims(email)
+ WHERE status = 'claimed';
+
+CREATE INDEX idx_user_email_claims_user_id
+ ON user_email_claims(user_id);
+
+INSERT INTO user_email_claims (user_id, email, status)
+SELECT id, email, 'claimed' FROM users;
+
+CREATE TABLE pending_email_claim_releases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ user_id INTEGER NOT NULL,
+ email TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE UNIQUE INDEX idx_pending_email_claim_releases_user_email
+ ON pending_email_claim_releases(user_id, email);
diff --git a/packages/worker/src/account/data-targets.ts b/packages/worker/src/account/data-targets.ts
index 3f625760b2..45cc3d42d1 100644
--- a/packages/worker/src/account/data-targets.ts
+++ b/packages/worker/src/account/data-targets.ts
@@ -455,6 +455,8 @@ export const accountUserDataTargets: ReadonlyArray = [
// integer id rather than the mcp user id.
{ kind: 'db_user_id', table: 'email_verifications' },
{ kind: 'db_user_id', table: 'pending_email_changes' },
+ { kind: 'db_user_id', table: 'user_email_claims' },
+ { kind: 'db_user_id', table: 'pending_email_claim_releases' },
{ kind: 'db_user_id', table: 'password_resets' },
{ kind: 'db_user_id', table: 'user_roles' },
{ kind: 'db_user_id', table: 'passkeys' },
@@ -755,6 +757,7 @@ export const accountExportRedactedColumnsByTable: Readonly<
package_invocation_tokens: ['token_hash'],
password_resets: ['token_hash'],
pending_email_changes: ['token_hash'],
+ pending_email_claim_releases: ['token_hash'],
platform_feedback: ['reviewed_by_user_id', 'reviewed_at', 'admin_note'],
secret_entries: ['encrypted_value', 'lookup_hash'],
user_integrations: ['access_token_encrypted', 'refresh_token_encrypted'],
diff --git a/packages/worker/src/app/account-profile-data.ts b/packages/worker/src/app/account-profile-data.ts
index d1290b368a..8f9290334a 100644
--- a/packages/worker/src/app/account-profile-data.ts
+++ b/packages/worker/src/app/account-profile-data.ts
@@ -4,6 +4,7 @@ import {
type ProfileVisibility,
} from '#universal/loader-data.ts'
import { type readAuthenticatedAppUser } from '#app/authenticated-user.ts'
+import { listFormerEmailClaims } from '#worker/identity/email-claims.ts'
import { createDb, usersTable } from '#worker/db.ts'
type AuthenticatedUser = NonNullable<
@@ -23,6 +24,7 @@ export function buildAccountProfilePayload(
bio?: string | null
avatarKey?: string | null
profileVisibility?: ProfileVisibility
+ formerEmails?: AccountProfileLoaderData['formerEmails']
},
): AccountProfileLoaderData {
const rawDisplayName = profileFields?.displayName
@@ -45,6 +47,7 @@ export function buildAccountProfilePayload(
avatarKey: profileFields?.avatarKey ?? null,
}),
profileVisibility: profileFields?.profileVisibility ?? 'public',
+ formerEmails: profileFields?.formerEmails ?? [],
}
}
@@ -72,5 +75,9 @@ export async function loadAccountProfileData(
bio: row.bio,
avatarKey: row.avatar_key,
profileVisibility: asProfileVisibility(row.profile_visibility),
+ formerEmails: await listFormerEmailClaims(env.APP_DB, {
+ userId: user.userId,
+ currentEmail: user.email,
+ }),
})
}
diff --git a/packages/worker/src/app/email-change.ts b/packages/worker/src/app/email-change.ts
index b52cfee68e..6746eee4c9 100644
--- a/packages/worker/src/app/email-change.ts
+++ b/packages/worker/src/app/email-change.ts
@@ -6,6 +6,10 @@ import { normalizeEmail } from '#worker/identity/normalize-email.ts'
import { buildEmailChangeEmail } from '#app/email/messages.ts'
import { resolveTransactionalEmailConfig } from '#app/email/sender-config.ts'
import { createDb, pendingEmailChangesTable } from '#worker/db.ts'
+import {
+ claimAccountEmail,
+ isEmailReservedForOtherAccount,
+} from '#worker/identity/email-claims.ts'
import { resolveUserStableId } from '#worker/user-id.ts'
import { toHex } from '@kody-internal/shared/hex.ts'
@@ -181,6 +185,11 @@ export async function verifyEmailChangeToken(input: {
.bind(newEmail, record.user_id)
.first<{ id: number }>()
if (existing) return { ok: false, reason: 'email_conflict' }
+ if (
+ await isEmailReservedForOtherAccount(input.db, newEmail, record.user_id)
+ ) {
+ return { ok: false, reason: 'email_conflict' }
+ }
// Preserve the existing stable id across email changes so MCP identity,
// ownership rows, and grants stay bound to the same account.
@@ -215,6 +224,17 @@ export async function verifyEmailChangeToken(input: {
.bind(record.user_id)
.run()
+ await claimAccountEmail(input.db, {
+ userId: record.user_id,
+ email: record.email,
+ now,
+ })
+ await claimAccountEmail(input.db, {
+ userId: record.user_id,
+ email: newEmail,
+ now,
+ })
+
return {
ok: true,
userId: record.user_id,
diff --git a/packages/worker/src/app/email-claim-release.ts b/packages/worker/src/app/email-claim-release.ts
new file mode 100644
index 0000000000..5354067792
--- /dev/null
+++ b/packages/worker/src/app/email-claim-release.ts
@@ -0,0 +1,251 @@
+import { isNonProductionRuntime } from '#app/deployment-env.ts'
+import { sendCloudflareEmail } from '#app/email/cloudflare-email.ts'
+import { hashVerificationToken } from '#app/email-verification.ts'
+import { buildEmailClaimReleaseEmail } from '#app/email/messages.ts'
+import { resolveTransactionalEmailConfig } from '#app/email/sender-config.ts'
+import {
+ countRecentEmailClaimReleases,
+ releaseAccountEmailClaim,
+ resolveReleasableEmailClaim,
+} from '#worker/identity/email-claims.ts'
+import { normalizeEmail } from '#worker/identity/normalize-email.ts'
+import { toHex } from '@kody-internal/shared/hex.ts'
+
+export const emailClaimReleaseRequestRateLimitConfig = {
+ maxRequests: 3,
+ windowSeconds: 15 * 60,
+}
+
+export const emailClaimReleaseSuccessRateLimitConfig = {
+ maxRequests: 3,
+ windowSeconds: 24 * 60 * 60,
+}
+
+const emailClaimReleaseTokenBytes = 32
+const emailClaimReleaseTokenExpiryMs = 24 * 60 * 60 * 1000
+
+function generateEmailClaimReleaseToken() {
+ const bytes = new Uint8Array(emailClaimReleaseTokenBytes)
+ crypto.getRandomValues(bytes)
+ return toHex(bytes)
+}
+
+function getEmailClaimReleaseConfig(input: {
+ env: Pick & {
+ WRANGLER_IS_LOCAL_DEV?: string
+ }
+ requestUrl: string | URL
+}) {
+ return (
+ resolveTransactionalEmailConfig({
+ env: input.env,
+ requestUrl: input.requestUrl,
+ }) ?? {
+ appBaseUrl: new URL(input.requestUrl).origin,
+ fromEmail: `kody@${new URL(input.requestUrl).hostname}`,
+ }
+ )
+}
+
+export async function createEmailClaimReleaseVerification(input: {
+ env: Env
+ userId: number
+ stableUserId: string
+ currentEmail: string
+ email: string
+ requestUrl: string | URL
+}) {
+ const email = normalizeEmail(input.email)
+ const releasable = await resolveReleasableEmailClaim({
+ db: input.env.APP_DB,
+ userId: input.userId,
+ stableUserId: input.stableUserId,
+ currentEmail: input.currentEmail,
+ email,
+ })
+ if (!releasable.ok) {
+ throw new EmailClaimReleaseRequestError(releasable.reason)
+ }
+
+ const token = generateEmailClaimReleaseToken()
+ const tokenHash = await hashVerificationToken(token)
+ const expiresAt = Date.now() + emailClaimReleaseTokenExpiryMs
+
+ await input.env.APP_DB.prepare(
+ `DELETE FROM pending_email_claim_releases WHERE user_id = ? AND email = ?`,
+ )
+ .bind(input.userId, email)
+ .run()
+
+ await input.env.APP_DB.prepare(
+ `INSERT INTO pending_email_claim_releases (user_id, email, token_hash, expires_at)
+ VALUES (?, ?, ?, ?)`,
+ )
+ .bind(input.userId, email, tokenHash, expiresAt)
+ .run()
+
+ async function discardNewToken() {
+ await input.env.APP_DB.prepare(
+ `DELETE FROM pending_email_claim_releases WHERE token_hash = ?`,
+ )
+ .bind(tokenHash)
+ .run()
+ .catch(() => undefined)
+ }
+
+ const emailConfig = getEmailClaimReleaseConfig({
+ env: input.env,
+ requestUrl: input.requestUrl,
+ })
+ const verificationUrl = new URL(
+ '/verify-email-claim-release',
+ emailConfig.appBaseUrl,
+ )
+ verificationUrl.searchParams.set('token', token)
+ const message = buildEmailClaimReleaseEmail({
+ appBaseUrl: emailConfig.appBaseUrl,
+ email,
+ verificationUrl: verificationUrl.toString(),
+ })
+
+ let sendResult: Awaited>
+ try {
+ sendResult = await sendCloudflareEmail(
+ {
+ accountId: input.env.CLOUDFLARE_ACCOUNT_ID,
+ apiBaseUrl: input.env.CLOUDFLARE_API_BASE_URL,
+ apiToken: input.env.CLOUDFLARE_API_TOKEN,
+ },
+ {
+ to: email,
+ from: emailConfig.fromEmail,
+ subject: message.subject,
+ html: message.html,
+ text: message.text,
+ },
+ )
+ } catch (error) {
+ await discardNewToken()
+ throw error
+ }
+ if (!sendResult.ok) {
+ if (!(sendResult.skipped && isNonProductionRuntime(input.env))) {
+ await discardNewToken()
+ throw new Error(sendResult.error ?? 'Release email could not be sent.')
+ }
+ console.warn('email-claim-release-send-skipped', input.userId)
+ }
+}
+
+export type VerifyEmailClaimReleaseResult =
+ | {
+ ok: true
+ userId: number
+ email: string
+ }
+ | {
+ ok: false
+ reason:
+ | 'missing_token'
+ | 'invalid_token'
+ | 'expired_token'
+ | 'not_claimed'
+ | 'current_email'
+ | 'daily_cap'
+ }
+
+export async function verifyEmailClaimReleaseToken(input: {
+ db: D1Database
+ token: unknown
+ now?: Date
+}): Promise {
+ const token = typeof input.token === 'string' ? input.token.trim() : ''
+ if (!token) return { ok: false, reason: 'missing_token' }
+
+ const tokenHash = await hashVerificationToken(token)
+ const record = await input.db
+ .prepare(
+ `SELECT pec.id, pec.user_id, pec.email, pec.expires_at,
+ u.email AS current_email, u.stable_user_id
+ FROM pending_email_claim_releases pec
+ INNER JOIN users u ON u.id = pec.user_id
+ WHERE pec.token_hash = ?`,
+ )
+ .bind(tokenHash)
+ .first<{
+ id: number
+ user_id: number
+ email: string
+ expires_at: number
+ current_email: string
+ stable_user_id: string
+ }>()
+ const now = input.now ?? new Date()
+
+ if (!record) return { ok: false, reason: 'invalid_token' }
+ if (record.expires_at < now.getTime()) {
+ await input.db
+ .prepare(`DELETE FROM pending_email_claim_releases WHERE id = ?`)
+ .bind(record.id)
+ .run()
+ return { ok: false, reason: 'expired_token' }
+ }
+
+ const releasable = await resolveReleasableEmailClaim({
+ db: input.db,
+ userId: record.user_id,
+ stableUserId: record.stable_user_id,
+ currentEmail: record.current_email,
+ email: record.email,
+ })
+ if (!releasable.ok) {
+ await input.db
+ .prepare(`DELETE FROM pending_email_claim_releases WHERE id = ?`)
+ .bind(record.id)
+ .run()
+ return {
+ ok: false,
+ reason:
+ releasable.reason === 'already_released'
+ ? 'not_claimed'
+ : releasable.reason,
+ }
+ }
+
+ const recentReleases = await countRecentEmailClaimReleases(input.db, {
+ userId: record.user_id,
+ windowSeconds: emailClaimReleaseSuccessRateLimitConfig.windowSeconds,
+ now,
+ })
+ if (recentReleases >= emailClaimReleaseSuccessRateLimitConfig.maxRequests) {
+ return { ok: false, reason: 'daily_cap' }
+ }
+
+ await releaseAccountEmailClaim(input.db, {
+ userId: record.user_id,
+ email: releasable.email,
+ now,
+ })
+ await input.db
+ .prepare(
+ `DELETE FROM pending_email_claim_releases WHERE user_id = ? AND email = ?`,
+ )
+ .bind(record.user_id, releasable.email)
+ .run()
+
+ return {
+ ok: true,
+ userId: record.user_id,
+ email: releasable.email,
+ }
+}
+
+export class EmailClaimReleaseRequestError extends Error {
+ readonly reason: 'current_email' | 'not_claimed' | 'already_released'
+
+ constructor(reason: 'current_email' | 'not_claimed' | 'already_released') {
+ super(`Email claim cannot be released (${reason}).`)
+ this.name = 'EmailClaimReleaseRequestError'
+ this.reason = reason
+ }
+}
diff --git a/packages/worker/src/app/email/messages.ts b/packages/worker/src/app/email/messages.ts
index 1b59e06e22..adf9ef2216 100644
--- a/packages/worker/src/app/email/messages.ts
+++ b/packages/worker/src/app/email/messages.ts
@@ -56,6 +56,27 @@ export function buildEmailChangeEmail(input: {
})
}
+export function buildEmailClaimReleaseEmail(input: {
+ appBaseUrl: string
+ email: string
+ verificationUrl: string
+}) {
+ return renderTransactionalEmail({
+ appBaseUrl: input.appBaseUrl,
+ subject: 'Release this email from your Kody account',
+ preheader: `Confirm you want to release ${input.email} so it can open a new Kody account.`,
+ heading: 'Release this email address',
+ body: [
+ `${input.email} is still tied to your Kody account, so it cannot be used to create a second account.`,
+ 'Confirm this link to drop that claim. Your current login email and account identity stay the same.',
+ ],
+ action: { label: 'Release this email', url: input.verificationUrl },
+ afterAction: ['This link expires in 24 hours.'],
+ footnote:
+ 'If you did not ask to release this address, you can safely ignore this email — the claim stays in place.',
+ })
+}
+
export const userEntitlementWarningKinds = ['approaching', 'reached'] as const
export type UserEntitlementWarningKind =
diff --git a/packages/worker/src/app/handlers/account-avatar.node.test.ts b/packages/worker/src/app/handlers/account-avatar.node.test.ts
index 4eff714418..f25ff745d4 100644
--- a/packages/worker/src/app/handlers/account-avatar.node.test.ts
+++ b/packages/worker/src/app/handlers/account-avatar.node.test.ts
@@ -50,6 +50,7 @@ const profilePayload = {
bio: null,
avatarUrl: '/profiles/alice/avatar/hash.png',
profileVisibility: 'public' as const,
+ formerEmails: [],
}
function createEnv() {
diff --git a/packages/worker/src/app/handlers/account-email-change.node.test.ts b/packages/worker/src/app/handlers/account-email-change.node.test.ts
index b54f31b6f0..5d08dafe11 100644
--- a/packages/worker/src/app/handlers/account-email-change.node.test.ts
+++ b/packages/worker/src/app/handlers/account-email-change.node.test.ts
@@ -160,7 +160,9 @@ test('email change requests require the current password and create a pending ve
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
- message: 'Verification email sent to your new address.',
+ formerEmailRemainsClaimed: true,
+ message:
+ 'Verification email sent to your new address. After you confirm, your current address stays tied to this account until you release it from Former addresses.',
})
expect(
sqlite
@@ -328,4 +330,14 @@ test('email change verification updates email and preserves stable user id', asy
expect(
sqlite.prepare(`SELECT COUNT(*) AS count FROM email_verifications`).get(),
).toEqual({ count: 0 })
+ expect(
+ sqlite
+ .prepare(
+ `SELECT email, status FROM user_email_claims WHERE user_id = 1 ORDER BY email`,
+ )
+ .all() as Array<{ email: string; status: string }>,
+ ).toEqual([
+ { email: 'new@example.com', status: 'claimed' },
+ { email: 'old@example.com', status: 'claimed' },
+ ])
})
diff --git a/packages/worker/src/app/handlers/account-email-change.ts b/packages/worker/src/app/handlers/account-email-change.ts
index d2a283582d..944f6b6170 100644
--- a/packages/worker/src/app/handlers/account-email-change.ts
+++ b/packages/worker/src/app/handlers/account-email-change.ts
@@ -9,6 +9,7 @@ import {
import { readAuthenticatedAppUser } from '#app/authenticated-user.ts'
import { getUniqueConstraintField } from '#worker/database-errors.ts'
import { createEmailChangeVerification } from '#app/email-change.ts'
+import { isEmailReservedForOtherAccount } from '#worker/identity/email-claims.ts'
import { normalizeEmail } from '#worker/identity/normalize-email.ts'
import { checkRateLimit, releaseRateLimit } from '#app/rate-limit.ts'
import { type routes } from '#universal/routes.ts'
@@ -165,7 +166,14 @@ export function createAccountEmailChangeHandler(env: Env) {
const existingUser = await db.findOne(usersTable, {
where: { email: newEmail },
})
- if (existingUser) {
+ const reservedByOther = existingUser
+ ? existingUser.id !== user.userId
+ : await isEmailReservedForOtherAccount(
+ env.APP_DB,
+ newEmail,
+ user.userId,
+ )
+ if (reservedByOther) {
void logAuditEvent({
db: auditDatabaseFromEnv(env),
category: 'account',
@@ -232,7 +240,9 @@ export function createAccountEmailChangeHandler(env: Env) {
})
return jsonResponse({
ok: true,
- message: 'Verification email sent to your new address.',
+ formerEmailRemainsClaimed: true,
+ message:
+ 'Verification email sent to your new address. After you confirm, your current address stays tied to this account until you release it from Former addresses.',
})
},
} satisfies Action
diff --git a/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts b/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts
new file mode 100644
index 0000000000..4fdccd042d
--- /dev/null
+++ b/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts
@@ -0,0 +1,286 @@
+import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts'
+import { DatabaseSync } from 'node:sqlite'
+import { RequestContext } from 'remix/router'
+import { beforeAll, expect, test } from 'vitest'
+import {
+ createAuthCookie,
+ setAuthSessionSecret,
+ type AuthSession,
+} from '#app/auth-session.ts'
+import { verifyEmailClaimReleaseToken } from '#app/email-claim-release.ts'
+import { hashVerificationToken } from '#app/email-verification.ts'
+import { formerEmailClaimedSignupCode } from '#universal/email-claim-errors.ts'
+import { createPasswordHash } from '@kody-internal/shared/password-hash.ts'
+import { applyAllMigrations } from '#worker/test-support/apply-all-migrations.ts'
+import { consoleWarn } from '#worker/test-support/console-spies.ts'
+import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts'
+import { testStableUserIdFromEmail } from '#worker/test-support/stable-user-id.ts'
+import { createStableUserIdFromEmail } from '#worker/user-id.ts'
+import { createAccountEmailClaimReleaseHandler } from './account-email-claim-release.ts'
+import { createAuthHandler } from './auth.ts'
+
+const testCookieSecret = 'test-cookie-secret-0123456789abcdef0123456789'
+
+function createMigratedDb() {
+ const sqlite = new DatabaseSync(':memory:')
+ applyAllMigrations(sqlite, new URL('../../../migrations/', import.meta.url))
+ return { sqlite, db: createD1FromSqlite(sqlite) }
+}
+
+async function seedUser(
+ sqlite: DatabaseSync,
+ input: {
+ id: number
+ email: string
+ username: string
+ password: string
+ stableUserId?: string
+ },
+) {
+ const passwordHash = await createPasswordHash(input.password)
+ const stableUserId =
+ input.stableUserId ?? (await createStableUserIdFromEmail(input.email))
+ sqlite.exec(`
+ INSERT INTO users (
+ id, username, email, stable_user_id, password_hash, email_verified_at
+ ) VALUES (
+ ${input.id},
+ ${quoteSqlString(input.username)},
+ ${quoteSqlString(input.email)},
+ ${quoteSqlString(stableUserId)},
+ ${quoteSqlString(passwordHash)},
+ CURRENT_TIMESTAMP
+ );
+ `)
+ return stableUserId
+}
+
+function createAppEnv(db: D1Database) {
+ return {
+ APP_DB: db,
+ APP_BASE_URL: 'http://example.com',
+ COOKIE_SECRET: testCookieSecret,
+ SENTRY_ENVIRONMENT: 'test',
+ SIGNUP_MODE: 'open',
+ } as unknown as Env
+}
+
+async function createReleaseRequest(input: {
+ session: AuthSession
+ email: string
+ password: string
+}) {
+ const cookie = await createAuthCookie(input.session, false)
+ return new Request('http://example.com/account/email-claim-release.json', {
+ method: 'POST',
+ headers: {
+ Cookie: cookie,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ email: input.email,
+ password: input.password,
+ }),
+ })
+}
+
+beforeAll(() => {
+ setAuthSessionSecret(testCookieSecret)
+})
+
+test('release re-verifies a former address then allows a new account without reminting', async () => {
+ consoleWarn.mockImplementation(() => {})
+ const { sqlite, db } = createMigratedDb()
+ const formerEmail = 'personal@example.com'
+ const currentEmail = 'work@example.com'
+ const stableUserId = await seedUser(sqlite, {
+ id: 1,
+ email: currentEmail,
+ username: 'jamie',
+ password: 'correct-password',
+ stableUserId: await createStableUserIdFromEmail(formerEmail),
+ })
+ sqlite.exec(`
+ INSERT INTO user_email_claims (user_id, email, status)
+ VALUES (1, ${quoteSqlString(currentEmail)}, 'claimed');
+ INSERT INTO user_email_claims (user_id, email, status)
+ VALUES (1, ${quoteSqlString(formerEmail)}, 'claimed');
+ `)
+
+ const env = createAppEnv(db)
+ const handler = createAccountEmailClaimReleaseHandler(env)
+ const session = {
+ stableUserId: testStableUserIdFromEmail(formerEmail),
+ email: currentEmail,
+ rememberMe: false,
+ }
+
+ const currentEmailResponse = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: currentEmail,
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(currentEmailResponse.status).toBe(400)
+
+ const signupHandler = createAuthHandler(env)
+ const blockedSignup = await signupHandler.handler(
+ new RequestContext(
+ new Request('http://example.com/auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: formerEmail,
+ username: 'new-jamie',
+ password: 'password123',
+ mode: 'signup',
+ }),
+ }),
+ ),
+ )
+ expect(blockedSignup.status).toBe(409)
+ expect(await blockedSignup.json()).toMatchObject({
+ code: formerEmailClaimedSignupCode,
+ })
+
+ const requestResponse = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: formerEmail,
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(requestResponse.status).toBe(200)
+ expect(await requestResponse.json()).toMatchObject({ ok: true })
+
+ const pending = sqlite
+ .prepare(
+ `SELECT token_hash FROM pending_email_claim_releases WHERE user_id = 1`,
+ )
+ .get() as { token_hash: string }
+ expect(pending.token_hash).toEqual(expect.any(String))
+
+ const token = 'release-former-email-token'
+ const tokenHash = await hashVerificationToken(token)
+ sqlite.exec(`
+ UPDATE pending_email_claim_releases
+ SET token_hash = ${quoteSqlString(tokenHash)}
+ WHERE user_id = 1
+ `)
+
+ const verified = await verifyEmailClaimReleaseToken({
+ db,
+ token,
+ })
+ expect(verified).toEqual({
+ ok: true,
+ userId: 1,
+ email: formerEmail,
+ })
+ expect(
+ sqlite
+ .prepare(
+ `SELECT status FROM user_email_claims WHERE user_id = 1 AND email = ?`,
+ )
+ .get(formerEmail) as { status: string },
+ ).toEqual({ status: 'released' })
+ expect(
+ sqlite.prepare(`SELECT stable_user_id FROM users WHERE id = 1`).get() as {
+ stable_user_id: string
+ },
+ ).toEqual({ stable_user_id: stableUserId })
+
+ const allowedSignup = await signupHandler.handler(
+ new RequestContext(
+ new Request('http://example.com/auth', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email: formerEmail,
+ username: 'new-jamie',
+ password: 'password123',
+ mode: 'signup',
+ }),
+ }),
+ ),
+ )
+ expect(allowedSignup.status).toBe(200)
+ const created = sqlite
+ .prepare(`SELECT email, stable_user_id FROM users WHERE email = ?`)
+ .get(formerEmail) as { email: string; stable_user_id: string }
+ expect(created.email).toBe(formerEmail)
+ expect(created.stable_user_id).not.toBe(stableUserId)
+ expect(created.stable_user_id).toMatch(/^[a-f0-9]{64}$/)
+})
+
+test('release requests are rate limited and refuse another account email', async () => {
+ consoleWarn.mockImplementation(() => {})
+ const { sqlite, db } = createMigratedDb()
+ await seedUser(sqlite, {
+ id: 1,
+ email: 'owner@example.com',
+ username: 'owner',
+ password: 'correct-password',
+ })
+ await seedUser(sqlite, {
+ id: 2,
+ email: 'other@example.com',
+ username: 'other',
+ password: 'other-password',
+ })
+ const handler = createAccountEmailClaimReleaseHandler(createAppEnv(db))
+ const session = {
+ stableUserId: testStableUserIdFromEmail('owner@example.com'),
+ email: 'owner@example.com',
+ rememberMe: false,
+ }
+
+ const stranger = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: 'other@example.com',
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(stranger.status).toBe(404)
+
+ const first = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: 'old@example.com',
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(first.status).toBe(404)
+
+ const second = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: 'old@example.com',
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(second.status).toBe(404)
+
+ const limited = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: 'old@example.com',
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(limited.status).toBe(429)
+})
diff --git a/packages/worker/src/app/handlers/account-email-claim-release.ts b/packages/worker/src/app/handlers/account-email-claim-release.ts
new file mode 100644
index 0000000000..9180111c70
--- /dev/null
+++ b/packages/worker/src/app/handlers/account-email-claim-release.ts
@@ -0,0 +1,270 @@
+import { jsonResponse } from '#worker/json-response.ts'
+import { type Action } from 'remix/router'
+import { object, parseSafe, string } from 'remix/data-schema'
+import {
+ auditDatabaseFromEnv,
+ getRequestIp,
+ logAuditEvent,
+} from '#worker/audit-log.ts'
+import { readAuthenticatedAppUser } from '#app/authenticated-user.ts'
+import {
+ createEmailClaimReleaseVerification,
+ emailClaimReleaseRequestRateLimitConfig,
+ emailClaimReleaseSuccessRateLimitConfig,
+ EmailClaimReleaseRequestError,
+} from '#app/email-claim-release.ts'
+import { countRecentEmailClaimReleases } from '#worker/identity/email-claims.ts'
+import { normalizeEmail } from '#worker/identity/normalize-email.ts'
+import { checkRateLimit, releaseRateLimit } from '#app/rate-limit.ts'
+import { type routes } from '#universal/routes.ts'
+import { createDb, usersTable } from '#worker/db.ts'
+import { verifyPassword } from '@kody-internal/shared/password-hash.ts'
+
+const emailClaimReleaseRequestSchema = object({
+ email: string(),
+ password: string(),
+})
+
+function getEmailValidationError(email: string) {
+ if (!email) return 'Email is required.'
+ if (email.length > 254) return 'Email is too long.'
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ return 'Enter a valid email address.'
+ }
+ return null
+}
+
+function releaseRequestErrorMessage(
+ reason: 'current_email' | 'not_claimed' | 'already_released',
+) {
+ switch (reason) {
+ case 'current_email':
+ return 'You cannot release the email this account currently uses to sign in.'
+ case 'already_released':
+ return 'That address is already released from this account.'
+ case 'not_claimed':
+ return 'That address is not claimed by this account.'
+ default: {
+ const unreachable: never = reason
+ return unreachable
+ }
+ }
+}
+
+export function createAccountEmailClaimReleaseHandler(env: Env) {
+ const db = createDb(env.APP_DB)
+
+ return {
+ middleware: [],
+ async handler({ request, url }) {
+ const user = await readAuthenticatedAppUser(request, env)
+ if (!user) {
+ return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401)
+ }
+
+ let body: unknown
+ try {
+ body = await request.json()
+ } catch {
+ return jsonResponse({ ok: false, error: 'Invalid JSON payload.' }, 400)
+ }
+
+ const parsed = parseSafe(emailClaimReleaseRequestSchema, body)
+ const requestIp = getRequestIp(request) ?? undefined
+ const email = parsed.success ? normalizeEmail(parsed.value.email) : ''
+ const password = parsed.success ? parsed.value.password : ''
+ const validationError = parsed.success
+ ? getEmailValidationError(email)
+ : 'Invalid request body.'
+ if (validationError) {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'failure',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'invalid_payload',
+ })
+ return jsonResponse({ ok: false, error: validationError }, 400)
+ }
+
+ const userRecord = await db.findOne(usersTable, {
+ where: { id: user.userId },
+ })
+ if (!userRecord) {
+ return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401)
+ }
+
+ if (!userRecord.email_verified_at) {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'failure',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'email_unverified',
+ })
+ return jsonResponse(
+ {
+ ok: false,
+ error:
+ 'Verify your current email address before releasing a former address.',
+ },
+ 403,
+ )
+ }
+
+ const requestLimitKey = `email-claim-release-request:user:${user.userId}`
+ const requestLimit = await checkRateLimit(
+ env.APP_DB,
+ requestLimitKey,
+ emailClaimReleaseRequestRateLimitConfig,
+ )
+ if (!requestLimit.allowed) {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'rate_limited',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'request_window',
+ })
+ return jsonResponse(
+ {
+ ok: false,
+ error: 'Too many release requests. Please try again later.',
+ },
+ {
+ status: 429,
+ headers: {
+ 'Retry-After': String(requestLimit.retryAfterSeconds ?? 60),
+ },
+ },
+ )
+ }
+
+ const recentReleases = await countRecentEmailClaimReleases(env.APP_DB, {
+ userId: user.userId,
+ windowSeconds: emailClaimReleaseSuccessRateLimitConfig.windowSeconds,
+ })
+ if (
+ recentReleases >= emailClaimReleaseSuccessRateLimitConfig.maxRequests
+ ) {
+ await releaseRateLimit(env.APP_DB, requestLimitKey).catch(
+ () => undefined,
+ )
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'rate_limited',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'daily_cap',
+ })
+ return jsonResponse(
+ {
+ ok: false,
+ error:
+ 'You have released the maximum number of addresses for today. Try again tomorrow.',
+ },
+ 429,
+ )
+ }
+
+ const passwordValid = await verifyPassword(
+ password,
+ userRecord.password_hash,
+ )
+ if (!passwordValid) {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'failure',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'invalid_password',
+ })
+ return jsonResponse(
+ {
+ ok: false,
+ code: 'invalid_password',
+ error: 'Password is incorrect.',
+ },
+ 401,
+ )
+ }
+
+ try {
+ await createEmailClaimReleaseVerification({
+ env,
+ userId: user.userId,
+ stableUserId: user.mcpUser.userId,
+ currentEmail: user.email,
+ email,
+ requestUrl: url,
+ })
+ } catch (error) {
+ if (error instanceof EmailClaimReleaseRequestError) {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'failure',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: error.reason,
+ })
+ return jsonResponse(
+ { ok: false, error: releaseRequestErrorMessage(error.reason) },
+ error.reason === 'current_email' ? 400 : 404,
+ )
+ }
+ console.error('Failed to request email claim release:', error)
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'failure',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'send_failed',
+ })
+ return jsonResponse(
+ {
+ ok: false,
+ error:
+ 'Unable to send the release verification. Please try again later.',
+ },
+ 502,
+ )
+ }
+
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_request',
+ result: 'success',
+ email: user.email,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'release_requested',
+ })
+ return jsonResponse({
+ ok: true,
+ message: 'Verification email sent to that former address.',
+ })
+ },
+ } satisfies Action
+}
diff --git a/packages/worker/src/app/handlers/account-profile.node.test.ts b/packages/worker/src/app/handlers/account-profile.node.test.ts
index b98999ca2e..372e95b4fb 100644
--- a/packages/worker/src/app/handlers/account-profile.node.test.ts
+++ b/packages/worker/src/app/handlers/account-profile.node.test.ts
@@ -269,6 +269,7 @@ test('account profile API returns email and username for the signed-in user', as
bio: null,
avatarUrl: null,
profileVisibility: 'public',
+ formerEmails: [],
})
// Reads are not audited.
expect(logAuditEventSpy).not.toHaveBeenCalled()
@@ -540,6 +541,7 @@ test('account profile API rounds trip displayName, bio, and visibility', async (
bio: 'I build packages',
avatarUrl: null,
profileVisibility: 'private',
+ formerEmails: [],
})
expect(mockModule.updateCommunityProfile).toHaveBeenCalledWith({
env,
diff --git a/packages/worker/src/app/handlers/account.node.test.ts b/packages/worker/src/app/handlers/account.node.test.ts
index 6de4fea413..3fbc0e6d4d 100644
--- a/packages/worker/src/app/handlers/account.node.test.ts
+++ b/packages/worker/src/app/handlers/account.node.test.ts
@@ -23,6 +23,7 @@ vi.mock('#app/account-profile-data.ts', () => ({
bio: null,
avatarUrl: null,
profileVisibility: 'public',
+ formerEmails: [],
})),
}))
diff --git a/packages/worker/src/app/handlers/auth-provider.node.test.ts b/packages/worker/src/app/handlers/auth-provider.node.test.ts
index bb32ff8bab..5201396e65 100644
--- a/packages/worker/src/app/handlers/auth-provider.node.test.ts
+++ b/packages/worker/src/app/handlers/auth-provider.node.test.ts
@@ -1439,7 +1439,7 @@ test('OAuth signup returns a controlled error when stable_user_id already exists
)
expect(callback.status).toBe(302)
expect(callback.headers.get('Location')).toBe(
- '/login?oauthError=email-unavailable',
+ '/login?oauthError=email-claimed',
)
expect(sqlite.prepare(`SELECT COUNT(*) AS count FROM users`).get()).toEqual({
count: 1,
@@ -1454,7 +1454,7 @@ test('OAuth signup returns a controlled error when stable_user_id already exists
category: 'auth',
action: 'oauth_login',
result: 'failure',
- reason: 'stable_user_id_exists',
+ reason: 'former_email_claimed',
}),
)
})
diff --git a/packages/worker/src/app/handlers/auth-provider.ts b/packages/worker/src/app/handlers/auth-provider.ts
index e16a3f9fd8..ae220dd2f5 100644
--- a/packages/worker/src/app/handlers/auth-provider.ts
+++ b/packages/worker/src/app/handlers/auth-provider.ts
@@ -61,9 +61,10 @@ import {
type PlanName,
} from '#universal/plans.ts'
import {
- createStableUserIdFromEmail,
- resolveUserStableId,
-} from '#worker/user-id.ts'
+ allocateSignupIdentity,
+ claimAccountEmail,
+} from '#worker/identity/email-claims.ts'
+import { resolveUserStableId } from '#worker/user-id.ts'
import {
getTurnstileSiteKey,
verifyPublicFormProtection,
@@ -765,7 +766,15 @@ export function createAuthProviderCallbackHandler(env: Env) {
profile.username ?? usernameFromEmail(email),
env,
)
- stableUserId = await createStableUserIdFromEmail(email)
+ const allocated = await allocateSignupIdentity(env.APP_DB, email)
+ if (!allocated.ok) {
+ await releaseConsumedInvite()
+ if (allocated.reason === 'former_email_claimed') {
+ return fail('email-claimed', 'former_email_claimed')
+ }
+ return fail('account-error', 'user_create_conflict')
+ }
+ stableUserId = allocated.stableUserId
const createdAt = new Date().toISOString()
const signupAttribution = loginState.attribution
const createdUser = await db.create(
@@ -787,7 +796,7 @@ export function createAuthProviderCallbackHandler(env: Env) {
await releaseConsumedInvite()
const uniqueField = getUniqueConstraintField(error)
if (uniqueField === 'stable_user_id') {
- return fail('email-unavailable', 'stable_user_id_exists')
+ return fail('email-claimed', 'former_email_claimed')
}
if (uniqueField) {
return fail('account-error', 'user_create_conflict')
@@ -821,6 +830,17 @@ export function createAuthProviderCallbackHandler(env: Env) {
return fail('account-error', 'default_role_assignment_failed')
}
+ try {
+ await claimAccountEmail(env.APP_DB, {
+ userId: newUser.id,
+ email,
+ })
+ } catch (error) {
+ console.error('Failed to claim OAuth signup email:', error)
+ await rollbackNewUser(newUser.id)
+ return fail('account-error', 'email_claim_failed')
+ }
+
try {
await createConnection({ provider, profile, userId: newUser.id })
} catch (error) {
diff --git a/packages/worker/src/app/handlers/auth-stable-user-id-conflict.node.test.ts b/packages/worker/src/app/handlers/auth-stable-user-id-conflict.node.test.ts
index cbc2f1d331..25a32d803e 100644
--- a/packages/worker/src/app/handlers/auth-stable-user-id-conflict.node.test.ts
+++ b/packages/worker/src/app/handlers/auth-stable-user-id-conflict.node.test.ts
@@ -9,6 +9,10 @@ import {
auditEventSummaries,
logAuditEventSpy,
} from '#worker/test-support/audit-log-spy.ts'
+import {
+ formerEmailClaimedSignupCode,
+ formerEmailClaimedSignupMessage,
+} from '#universal/email-claim-errors.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
const lifecycleMocks = vi.hoisted(() => ({
@@ -24,8 +28,7 @@ vi.mock('#worker/identity/schedule-user-lifecycle-event.ts', () => ({
const { createAuthHandler } = await import('#app/handlers/auth.ts')
const testCookieSecret = 'test-cookie-secret-0123456789abcdef0123456789'
-const conflictMessage =
- 'This email address cannot be used for a new account. Contact support@kody.codes.'
+const conflictMessage = formerEmailClaimedSignupMessage
function applyMigrations(db: DatabaseSync) {
const migrationsDir = new URL('../../../migrations/', import.meta.url)
@@ -103,7 +106,10 @@ test('signup returns 409 when sha256(email) collides with an existing stable_use
mode: 'signup',
})
expect(openResponse.status).toBe(409)
- expect(await openResponse.json()).toEqual({ error: conflictMessage })
+ expect(await openResponse.json()).toEqual({
+ error: conflictMessage,
+ code: formerEmailClaimedSignupCode,
+ })
expect(sqlite.prepare(`SELECT COUNT(*) AS count FROM users`).get()).toEqual({
count: 1,
})
@@ -113,7 +119,7 @@ test('signup returns 409 when sha256(email) collides with an existing stable_use
category: 'auth',
action: 'signup',
result: 'failure',
- reason: 'stable_user_id_exists',
+ reason: 'former_email_claimed',
}),
)
@@ -127,7 +133,10 @@ test('signup returns 409 when sha256(email) collides with an existing stable_use
inviteCode: 'stable-id-invite',
})
expect(invitedResponse.status).toBe(409)
- expect(await invitedResponse.json()).toEqual({ error: conflictMessage })
+ expect(await invitedResponse.json()).toEqual({
+ error: conflictMessage,
+ code: formerEmailClaimedSignupCode,
+ })
expect(
sqlite
.prepare(`SELECT use_count FROM invites WHERE code = ?`)
diff --git a/packages/worker/src/app/handlers/auth.ts b/packages/worker/src/app/handlers/auth.ts
index 7cdbf1001a..1154c927e3 100644
--- a/packages/worker/src/app/handlers/auth.ts
+++ b/packages/worker/src/app/handlers/auth.ts
@@ -41,9 +41,14 @@ import {
import { ensureDefaultEmailInbox } from '#worker/email/default-inbox.ts'
import { getPlatformEmailDomain } from '#worker/email/platform-address.ts'
import {
- createStableUserIdFromEmail,
- resolveUserStableId,
-} from '#worker/user-id.ts'
+ formerEmailClaimedSignupCode,
+ formerEmailClaimedSignupMessage,
+} from '#universal/email-claim-errors.ts'
+import {
+ allocateSignupIdentity,
+ claimAccountEmail,
+} from '#worker/identity/email-claims.ts'
+import { resolveUserStableId } from '#worker/user-id.ts'
import {
createPasswordHash,
verifyPassword,
@@ -72,9 +77,6 @@ const authRequestSchema = object({
const dummyPasswordHash =
'pbkdf2_sha256$100000$00000000000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000'
-const stableUserIdConflictSignupMessage =
- 'This email address cannot be used for a new account. Contact support@kody.codes.'
-
function signupUniqueConflict(
uniqueField: 'email' | 'username' | 'stable_user_id',
) {
@@ -91,8 +93,8 @@ function signupUniqueConflict(
}
case 'stable_user_id':
return {
- reason: 'stable_user_id_exists',
- error: stableUserIdConflictSignupMessage,
+ reason: 'former_email_claimed',
+ error: formerEmailClaimedSignupMessage,
}
default: {
const exhaustive: never = uniqueField
@@ -334,10 +336,47 @@ export function createAuthHandler(env: Env) {
return Response.json(signupAcceptedBody(normalizedMode))
}
+ const allocated = await allocateSignupIdentity(
+ env.APP_DB,
+ normalizedEmail,
+ )
+ if (!allocated.ok) {
+ await releaseConsumedInvite()
+ if (allocated.reason === 'current_email') {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'auth',
+ action: 'signup',
+ result: 'failure',
+ email: normalizedEmail,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'email_exists',
+ })
+ return Response.json(signupAcceptedBody(normalizedMode))
+ }
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'auth',
+ action: 'signup',
+ result: 'failure',
+ email: normalizedEmail,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'former_email_claimed',
+ })
+ return Response.json(
+ {
+ error: formerEmailClaimedSignupMessage,
+ code: formerEmailClaimedSignupCode,
+ },
+ { status: 409 },
+ )
+ }
+
let record: { id: number; stableUserId: string } | null = null
try {
- const stableUserId =
- await createStableUserIdFromEmail(normalizedEmail)
+ const stableUserId = allocated.stableUserId
const createdAt = new Date().toISOString()
const createdUser = await db.create(
usersTable,
@@ -379,7 +418,15 @@ export function createAuthHandler(env: Env) {
if (uniqueField === 'email') {
return Response.json(signupAcceptedBody(normalizedMode))
}
- return Response.json({ error: conflict.error }, { status: 409 })
+ return Response.json(
+ uniqueField === 'stable_user_id'
+ ? {
+ error: conflict.error,
+ code: formerEmailClaimedSignupCode,
+ }
+ : { error: conflict.error },
+ { status: 409 },
+ )
}
await releaseConsumedInvite()
throw error
@@ -446,6 +493,40 @@ export function createAuthHandler(env: Env) {
)
}
+ try {
+ await claimAccountEmail(env.APP_DB, {
+ userId: record.id,
+ email: normalizedEmail,
+ })
+ } catch (error) {
+ console.error('Failed to claim signup email:', error)
+ try {
+ await env.APP_DB.prepare(`DELETE FROM users WHERE id = ?`)
+ .bind(record.id)
+ .run()
+ } catch (deleteError) {
+ console.error(
+ 'Failed to remove user row after email claim failure:',
+ deleteError,
+ )
+ }
+ await releaseConsumedInvite()
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'auth',
+ action: 'signup',
+ result: 'failure',
+ email: normalizedEmail,
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'email_claim_failed',
+ })
+ return Response.json(
+ { error: 'Unable to create account.' },
+ { status: 500 },
+ )
+ }
+
try {
await createEmailVerification({
env,
@@ -494,7 +575,7 @@ export function createAuthHandler(env: Env) {
try {
await ensureDefaultEmailInbox({
db: env.APP_DB,
- userId: await createStableUserIdFromEmail(normalizedEmail),
+ userId: record.stableUserId,
username: normalizedUsername,
domain: platformEmailDomain,
})
diff --git a/packages/worker/src/app/handlers/verify-email-change.ts b/packages/worker/src/app/handlers/verify-email-change.ts
index b9a4fcb44c..640ac55e5f 100644
--- a/packages/worker/src/app/handlers/verify-email-change.ts
+++ b/packages/worker/src/app/handlers/verify-email-change.ts
@@ -104,7 +104,8 @@ export function createVerifyEmailChangeHandler(env: Env) {
emailVerification: {
ok: true,
kind: 'email_change',
- message: 'Your account email has been changed and verified.',
+ message:
+ 'Your account email has been changed and verified. The previous address stays tied to this account until you release it from Account settings → Former addresses.',
},
},
})
diff --git a/packages/worker/src/app/handlers/verify-email-claim-release.ts b/packages/worker/src/app/handlers/verify-email-claim-release.ts
new file mode 100644
index 0000000000..a75bea276a
--- /dev/null
+++ b/packages/worker/src/app/handlers/verify-email-claim-release.ts
@@ -0,0 +1,100 @@
+import { type Action } from 'remix/router'
+import {
+ auditDatabaseFromEnv,
+ getRequestIp,
+ logAuditEvent,
+} from '#worker/audit-log.ts'
+import { verifyEmailClaimReleaseToken } from '#app/email-claim-release.ts'
+import { renderAppPage } from '#app/ssr-render.tsx'
+import { type routes } from '#universal/routes.ts'
+
+function getVerifyEmailClaimReleaseError(
+ reason:
+ | 'missing_token'
+ | 'invalid_token'
+ | 'expired_token'
+ | 'not_claimed'
+ | 'current_email'
+ | 'daily_cap',
+) {
+ switch (reason) {
+ case 'missing_token':
+ return 'Verification token is required.'
+ case 'invalid_token':
+ return 'Release link is invalid.'
+ case 'expired_token':
+ return 'Release link has expired.'
+ case 'not_claimed':
+ return 'That address is not claimed by this account.'
+ case 'current_email':
+ return 'You cannot release the email this account currently uses to sign in.'
+ case 'daily_cap':
+ return 'You have released the maximum number of addresses for today. Try again tomorrow.'
+ default: {
+ const unreachable: never = reason
+ return unreachable
+ }
+ }
+}
+
+export function createVerifyEmailClaimReleaseHandler(env: Env) {
+ return {
+ middleware: [],
+ async handler({ request, url }) {
+ const result = await verifyEmailClaimReleaseToken({
+ db: env.APP_DB,
+ token: url.searchParams.get('token'),
+ })
+ const requestIp = getRequestIp(request) ?? undefined
+
+ if (!result.ok) {
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_verify',
+ result: result.reason === 'daily_cap' ? 'rate_limited' : 'failure',
+ ip: requestIp,
+ path: url.pathname,
+ reason: result.reason,
+ })
+ return renderAppPage({
+ request,
+ env,
+ title: 'Release email',
+ status: result.reason === 'daily_cap' ? 429 : 400,
+ loaderData: {
+ emailVerification: {
+ ok: false,
+ error: getVerifyEmailClaimReleaseError(result.reason),
+ },
+ },
+ })
+ }
+
+ void logAuditEvent({
+ db: auditDatabaseFromEnv(env),
+ category: 'account',
+ action: 'email_claim_release_verify',
+ result: 'success',
+ ip: requestIp,
+ path: url.pathname,
+ reason: 'released',
+ })
+ return renderAppPage({
+ request,
+ env,
+ title: 'Email released',
+ loaderData: {
+ emailVerification: {
+ ok: true,
+ kind: 'email_claim_release',
+ message:
+ 'That former address is no longer tied to this account. It can be used to create a new Kody account.',
+ ctaHref: '/account',
+ ctaLabel: 'Go to account',
+ },
+ },
+ })
+ },
+ } satisfies Action
+}
diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts
index af9f83c213..3a7f7876a0 100644
--- a/packages/worker/src/app/router.ts
+++ b/packages/worker/src/app/router.ts
@@ -58,6 +58,7 @@ import {
createAccountEmailHandler,
} from '#app/handlers/account-email.ts'
import { createAccountEmailChangeHandler } from '#app/handlers/account-email-change.ts'
+import { createAccountEmailClaimReleaseHandler } from '#app/handlers/account-email-claim-release.ts'
import { createAccountPasswordHandler } from '#app/handlers/account-password.ts'
import { createAccountExportHandler } from '#app/handlers/account-export.ts'
import {
@@ -239,6 +240,7 @@ import {
createVerifyHandler,
} from '#app/handlers/verify.ts'
import { createVerifyEmailChangeHandler } from '#app/handlers/verify-email-change.ts'
+import { createVerifyEmailClaimReleaseHandler } from '#app/handlers/verify-email-claim-release.ts'
import { createVerifyEmailHandler } from '#app/handlers/verify-email.ts'
import {
createWebauthnAuthenticationHandler,
@@ -328,6 +330,7 @@ export function createAppRouter(env: Env) {
resetPassword: createResetPasswordHandler(env),
verifyEmail: createVerifyEmailHandler(env),
verifyEmailChange: createVerifyEmailChangeHandler(env),
+ verifyEmailClaimRelease: createVerifyEmailClaimReleaseHandler(env),
pendingVerification: createPendingVerificationHandler(env),
signup: createSignupHandler(env),
waitingList: createWaitingListHandler(env),
@@ -389,6 +392,7 @@ export function createAppRouter(env: Env) {
accountWaiting: createAccountWaitingHandler(env),
accountWaitingApi: createAccountWaitingApiHandler(env),
accountEmailChange: createAccountEmailChangeHandler(env),
+ accountEmailClaimRelease: createAccountEmailClaimReleaseHandler(env),
accountPassword: createAccountPasswordHandler(env),
accountResendVerification: createAccountResendVerificationHandler(env),
accountSecrets: createAccountSecretsHandler(env),
diff --git a/packages/worker/src/app/ssr-render.node.test.ts b/packages/worker/src/app/ssr-render.node.test.ts
index 0396a62f75..d7917cfc6c 100644
--- a/packages/worker/src/app/ssr-render.node.test.ts
+++ b/packages/worker/src/app/ssr-render.node.test.ts
@@ -417,6 +417,7 @@ test('SSR HTML routes render page content and embedded loader data', async () =>
bio: null,
avatarUrl: null,
profileVisibility: 'public',
+ formerEmails: [],
})
expect(accountProps.loaderData?.accountConnections).toEqual({
ok: true,
diff --git a/packages/worker/src/database-errors.ts b/packages/worker/src/database-errors.ts
index 8a881fad9f..132270919f 100644
--- a/packages/worker/src/database-errors.ts
+++ b/packages/worker/src/database-errors.ts
@@ -1,5 +1,6 @@
const uniqueIndexFieldNames: Record = {
idx_users_stable_user_id: 'stable_user_id',
+ idx_user_email_claims_active_email: 'email',
}
export function getUniqueConstraintField(error: unknown) {
diff --git a/packages/worker/src/db.ts b/packages/worker/src/db.ts
index 450d039826..7f6ce04724 100644
--- a/packages/worker/src/db.ts
+++ b/packages/worker/src/db.ts
@@ -87,6 +87,34 @@ export const pendingEmailChangesTable = table({
primaryKey: 'id',
})
+export const userEmailClaimsTable = table({
+ name: 'user_email_claims',
+ columns: {
+ id: c.integer(),
+ user_id: c.integer(),
+ email: c.text(),
+ status: c.text(),
+ claimed_at: c.text(),
+ released_at: c.text(),
+ created_at: c.text(),
+ updated_at: c.text(),
+ },
+ primaryKey: 'id',
+})
+
+export const pendingEmailClaimReleasesTable = table({
+ name: 'pending_email_claim_releases',
+ columns: {
+ id: c.integer(),
+ user_id: c.integer(),
+ email: c.text(),
+ token_hash: c.text(),
+ expires_at: c.integer(),
+ created_at: c.text(),
+ },
+ primaryKey: 'id',
+})
+
export const invitesTable = table({
name: 'invites',
columns: {
diff --git a/packages/worker/src/identity/admin-user-creation.ts b/packages/worker/src/identity/admin-user-creation.ts
index ca562c91f4..e59b8a3632 100644
--- a/packages/worker/src/identity/admin-user-creation.ts
+++ b/packages/worker/src/identity/admin-user-creation.ts
@@ -13,7 +13,10 @@ import {
getEffectiveUsernameValidationError,
normalizeUsername,
} from '#worker/identity/username.ts'
-import { createStableUserIdFromEmail } from '#worker/user-id.ts'
+import {
+ allocateSignupIdentity,
+ claimAccountEmail,
+} from '#worker/identity/email-claims.ts'
import { unusablePasswordHash } from '#worker/identity/usable-password.ts'
export type AdminCreateUserErrorCode =
@@ -114,7 +117,11 @@ export async function adminCreateUserWithPasswordSetup(input: {
})
const now = input.now ?? new Date()
const nowIso = now.toISOString()
- const stableUserId = await createStableUserIdFromEmail(email)
+ const allocated = await allocateSignupIdentity(input.db, email)
+ if (!allocated.ok) {
+ throw new AdminCreateUserError('email_exists', 'Email already registered.')
+ }
+ const stableUserId = allocated.stableUserId
let userId: number | null = null
try {
@@ -169,6 +176,16 @@ export async function adminCreateUserWithPasswordSetup(input: {
)
}
+ try {
+ await claimAccountEmail(input.db, { userId, email, now })
+ } catch (error) {
+ await deleteUserBestEffort(input.db, userId)
+ throw new AdminCreateUserError(
+ 'create_failed',
+ error instanceof Error ? error.message : 'Unable to create account.',
+ )
+ }
+
const setupTokenExpiresAt = now.getTime() + adminPasswordSetupTokenExpiryMs
let resetToken: Awaited>
try {
diff --git a/packages/worker/src/identity/email-claims.node.test.ts b/packages/worker/src/identity/email-claims.node.test.ts
new file mode 100644
index 0000000000..172d09c5c1
--- /dev/null
+++ b/packages/worker/src/identity/email-claims.node.test.ts
@@ -0,0 +1,156 @@
+import { DatabaseSync } from 'node:sqlite'
+import { expect, test } from 'vitest'
+import { quoteSqlString } from '@kody-internal/shared/sql-literals.ts'
+import { applyAllMigrations } from '#worker/test-support/apply-all-migrations.ts'
+import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts'
+import { createStableUserIdFromEmail } from '#worker/user-id.ts'
+import {
+ allocateSignupIdentity,
+ claimAccountEmail,
+ isEmailReservedForOtherAccount,
+ listFormerEmailClaims,
+ releaseAccountEmailClaim,
+ resolveReleasableEmailClaim,
+} from './email-claims.ts'
+
+function createMigratedDb() {
+ const sqlite = new DatabaseSync(':memory:')
+ applyAllMigrations(sqlite, new URL('../../migrations/', import.meta.url))
+ return { sqlite, db: createD1FromSqlite(sqlite) }
+}
+
+async function insertUser(
+ sqlite: DatabaseSync,
+ input: {
+ id: number
+ email: string
+ username: string
+ stableUserId?: string
+ },
+) {
+ const stableUserId =
+ input.stableUserId ?? (await createStableUserIdFromEmail(input.email))
+ sqlite.exec(`
+ INSERT INTO users (id, username, email, stable_user_id, password_hash)
+ VALUES (
+ ${input.id},
+ ${quoteSqlString(input.username)},
+ ${quoteSqlString(input.email)},
+ ${quoteSqlString(stableUserId)},
+ 'hash'
+ );
+ `)
+ return stableUserId
+}
+
+test('email claims reserve former addresses without reminting identity', async () => {
+ const { sqlite, db } = createMigratedDb()
+ const originalStableUserId = await insertUser(sqlite, {
+ id: 1,
+ email: 'first@example.com',
+ username: 'jamie',
+ })
+ await claimAccountEmail(db, { userId: 1, email: 'first@example.com' })
+
+ sqlite.exec(`UPDATE users SET email = 'work@example.com' WHERE id = 1`)
+ await claimAccountEmail(db, { userId: 1, email: 'work@example.com' })
+
+ expect(
+ await listFormerEmailClaims(db, {
+ userId: 1,
+ currentEmail: 'work@example.com',
+ }),
+ ).toEqual([
+ {
+ email: 'first@example.com',
+ claimedAt: expect.any(String),
+ },
+ ])
+ expect(await isEmailReservedForOtherAccount(db, 'first@example.com')).toBe(
+ true,
+ )
+ expect(await allocateSignupIdentity(db, 'first@example.com')).toEqual({
+ ok: false,
+ reason: 'former_email_claimed',
+ })
+
+ const implicit = await resolveReleasableEmailClaim({
+ db,
+ userId: 1,
+ stableUserId: originalStableUserId,
+ currentEmail: 'work@example.com',
+ email: 'first@example.com',
+ })
+ expect(implicit).toEqual({ ok: true, email: 'first@example.com' })
+
+ await releaseAccountEmailClaim(db, {
+ userId: 1,
+ email: 'first@example.com',
+ })
+ expect(
+ await listFormerEmailClaims(db, {
+ userId: 1,
+ currentEmail: 'work@example.com',
+ }),
+ ).toEqual([])
+ expect(await isEmailReservedForOtherAccount(db, 'first@example.com')).toBe(
+ false,
+ )
+
+ const allocated = await allocateSignupIdentity(db, 'first@example.com')
+ expect(allocated.ok).toBe(true)
+ if (!allocated.ok) throw new Error('expected allocation')
+ expect(allocated.stableUserId).not.toBe(originalStableUserId)
+ expect(allocated.stableUserId).toMatch(/^[a-f0-9]{64}$/)
+
+ expect(
+ sqlite.prepare(`SELECT stable_user_id FROM users WHERE id = 1`).get() as {
+ stable_user_id: string
+ },
+ ).toEqual({ stable_user_id: originalStableUserId })
+
+ expect(
+ await resolveReleasableEmailClaim({
+ db,
+ userId: 1,
+ stableUserId: originalStableUserId,
+ currentEmail: 'work@example.com',
+ email: 'work@example.com',
+ }),
+ ).toEqual({ ok: false, reason: 'current_email' })
+ expect(
+ await resolveReleasableEmailClaim({
+ db,
+ userId: 1,
+ stableUserId: originalStableUserId,
+ currentEmail: 'work@example.com',
+ email: 'stranger@example.com',
+ }),
+ ).toEqual({ ok: false, reason: 'not_claimed' })
+})
+
+test('implicit sha256 reservation is releasable before a claim row exists', async () => {
+ const { sqlite, db } = createMigratedDb()
+ const originalEmail = 'legacy@example.com'
+ const stableUserId = await insertUser(sqlite, {
+ id: 2,
+ email: 'now@example.com',
+ username: 'legacy',
+ stableUserId: await createStableUserIdFromEmail(originalEmail),
+ })
+
+ expect(await isEmailReservedForOtherAccount(db, originalEmail)).toBe(true)
+ expect(await allocateSignupIdentity(db, originalEmail)).toEqual({
+ ok: false,
+ reason: 'former_email_claimed',
+ })
+ expect(
+ await resolveReleasableEmailClaim({
+ db,
+ userId: 2,
+ stableUserId,
+ currentEmail: 'now@example.com',
+ email: originalEmail,
+ }),
+ ).toEqual({ ok: true, email: originalEmail })
+})
diff --git a/packages/worker/src/identity/email-claims.ts b/packages/worker/src/identity/email-claims.ts
new file mode 100644
index 0000000000..6f2b9750b4
--- /dev/null
+++ b/packages/worker/src/identity/email-claims.ts
@@ -0,0 +1,343 @@
+import { normalizeEmail } from '#worker/identity/normalize-email.ts'
+import { getUniqueConstraintField } from '#worker/database-errors.ts'
+import {
+ createRandomStableUserId,
+ createStableUserIdFromEmail,
+} from '#worker/user-id.ts'
+
+export type EmailClaimStatus = 'claimed' | 'released'
+
+export type ActiveEmailClaim = {
+ userId: number
+ email: string
+ status: EmailClaimStatus
+ claimedAt: string
+ releasedAt: string | null
+}
+
+export type FormerEmailClaim = {
+ email: string
+ claimedAt: string
+}
+
+export type AllocateSignupIdentityResult =
+ | { ok: true; stableUserId: string }
+ | { ok: false; reason: 'current_email' | 'former_email_claimed' }
+
+export type ReleasableEmailClaimResult =
+ | { ok: true; email: string }
+ | {
+ ok: false
+ reason: 'current_email' | 'not_claimed' | 'already_released'
+ }
+
+const randomStableUserIdAttempts = 8
+
+export async function findActiveEmailClaim(
+ db: D1Database,
+ email: string,
+): Promise {
+ const normalized = normalizeEmail(email)
+ if (!normalized) return null
+ const row = await db
+ .prepare(
+ `SELECT user_id, email, status, claimed_at, released_at
+ FROM user_email_claims
+ WHERE email = ? AND status = 'claimed'`,
+ )
+ .bind(normalized)
+ .first<{
+ user_id: number
+ email: string
+ status: EmailClaimStatus
+ claimed_at: string
+ released_at: string | null
+ }>()
+ if (!row) return null
+ return {
+ userId: row.user_id,
+ email: row.email,
+ status: row.status,
+ claimedAt: row.claimed_at,
+ releasedAt: row.released_at,
+ }
+}
+
+export async function listFormerEmailClaims(
+ db: D1Database,
+ input: { userId: number; currentEmail: string },
+): Promise> {
+ const currentEmail = normalizeEmail(input.currentEmail)
+ const rows = await db
+ .prepare(
+ `SELECT email, claimed_at
+ FROM user_email_claims
+ WHERE user_id = ? AND status = 'claimed' AND email != ?
+ ORDER BY claimed_at ASC, email ASC`,
+ )
+ .bind(input.userId, currentEmail)
+ .all<{ email: string; claimed_at: string }>()
+ return (rows.results ?? []).map((row) => ({
+ email: row.email,
+ claimedAt: row.claimed_at,
+ }))
+}
+
+export async function isEmailClaimReleased(db: D1Database, email: string) {
+ const normalized = normalizeEmail(email)
+ if (!normalized) return false
+ const active = await findActiveEmailClaim(db, normalized)
+ if (active) return false
+ const released = await db
+ .prepare(
+ `SELECT 1 AS present
+ FROM user_email_claims
+ WHERE email = ? AND status = 'released'
+ LIMIT 1`,
+ )
+ .bind(normalized)
+ .first<{ present: number }>()
+ return Boolean(released)
+}
+
+/**
+ * True when another account currently uses this address as login, holds an
+ * active former-email claim, or still implicitly reserves sha256(email) as
+ * `stable_user_id` and has not released it.
+ */
+export async function isEmailReservedForOtherAccount(
+ db: D1Database,
+ email: string,
+ exceptUserId?: number,
+) {
+ const normalized = normalizeEmail(email)
+ if (!normalized) return false
+
+ const currentOwner = await db
+ .prepare(`SELECT id FROM users WHERE email = ?`)
+ .bind(normalized)
+ .first<{ id: number }>()
+ if (currentOwner && currentOwner.id !== exceptUserId) return true
+
+ const active = await findActiveEmailClaim(db, normalized)
+ if (active && active.userId !== exceptUserId) return true
+
+ if (await isEmailClaimReleased(db, normalized)) return false
+
+ const hashedId = await createStableUserIdFromEmail(normalized)
+ const implicitHolder = await db
+ .prepare(`SELECT id, email FROM users WHERE stable_user_id = ?`)
+ .bind(hashedId)
+ .first<{ id: number; email: string }>()
+ if (!implicitHolder) return false
+ if (implicitHolder.id === exceptUserId) return false
+ return normalizeEmail(implicitHolder.email) !== normalized
+}
+
+export async function resolveReleasableEmailClaim(input: {
+ db: D1Database
+ userId: number
+ stableUserId: string
+ currentEmail: string
+ email: string
+}): Promise {
+ const email = normalizeEmail(input.email)
+ if (!email) return { ok: false, reason: 'not_claimed' }
+ if (email === normalizeEmail(input.currentEmail)) {
+ return { ok: false, reason: 'current_email' }
+ }
+
+ const active = await findActiveEmailClaim(input.db, email)
+ if (active) {
+ if (active.userId !== input.userId) {
+ return { ok: false, reason: 'not_claimed' }
+ }
+ return { ok: true, email }
+ }
+
+ const ownReleased = await input.db
+ .prepare(
+ `SELECT 1 AS present
+ FROM user_email_claims
+ WHERE user_id = ? AND email = ? AND status = 'released'`,
+ )
+ .bind(input.userId, email)
+ .first<{ present: number }>()
+ if (ownReleased) return { ok: false, reason: 'already_released' }
+
+ const implicitId = await createStableUserIdFromEmail(email)
+ if (implicitId === input.stableUserId) {
+ return { ok: true, email }
+ }
+ return { ok: false, reason: 'not_claimed' }
+}
+
+export async function claimAccountEmail(
+ db: D1Database,
+ input: { userId: number; email: string; now?: Date },
+) {
+ const email = normalizeEmail(input.email)
+ if (!email) {
+ throw new Error('Email is required to claim.')
+ }
+ const now = (input.now ?? new Date()).toISOString()
+ const existing = await db
+ .prepare(
+ `SELECT id, user_id, status
+ FROM user_email_claims
+ WHERE user_id = ? AND email = ?`,
+ )
+ .bind(input.userId, email)
+ .first<{ id: number; user_id: number; status: EmailClaimStatus }>()
+
+ if (existing) {
+ if (existing.status === 'claimed') return
+ await db
+ .prepare(
+ `UPDATE user_email_claims
+ SET status = 'claimed',
+ claimed_at = ?,
+ released_at = NULL,
+ updated_at = ?
+ WHERE id = ?`,
+ )
+ .bind(now, now, existing.id)
+ .run()
+ return
+ }
+
+ try {
+ await db
+ .prepare(
+ `INSERT INTO user_email_claims (user_id, email, status, claimed_at, updated_at)
+ VALUES (?, ?, 'claimed', ?, ?)`,
+ )
+ .bind(input.userId, email, now, now)
+ .run()
+ } catch (error) {
+ if (getUniqueConstraintField(error) === 'email') {
+ throw new EmailClaimConflictError(email)
+ }
+ throw error
+ }
+}
+
+export async function releaseAccountEmailClaim(
+ db: D1Database,
+ input: { userId: number; email: string; now?: Date },
+) {
+ const email = normalizeEmail(input.email)
+ if (!email) {
+ throw new Error('Email is required to release.')
+ }
+ const now = (input.now ?? new Date()).toISOString()
+ const existing = await db
+ .prepare(
+ `SELECT id, status
+ FROM user_email_claims
+ WHERE user_id = ? AND email = ?`,
+ )
+ .bind(input.userId, email)
+ .first<{ id: number; status: EmailClaimStatus }>()
+
+ if (existing) {
+ if (existing.status === 'released') return
+ await db
+ .prepare(
+ `UPDATE user_email_claims
+ SET status = 'released',
+ released_at = ?,
+ updated_at = ?
+ WHERE id = ?`,
+ )
+ .bind(now, now, existing.id)
+ .run()
+ return
+ }
+
+ await db
+ .prepare(
+ `INSERT INTO user_email_claims (
+ user_id, email, status, claimed_at, released_at, updated_at
+ ) VALUES (?, ?, 'released', ?, ?, ?)`,
+ )
+ .bind(input.userId, email, now, now, now)
+ .run()
+}
+
+export async function countRecentEmailClaimReleases(
+ db: D1Database,
+ input: { userId: number; windowSeconds: number; now?: Date },
+) {
+ const now = input.now ?? new Date()
+ const since = new Date(
+ now.getTime() - input.windowSeconds * 1000,
+ ).toISOString()
+ const row = await db
+ .prepare(
+ `SELECT COUNT(*) AS count
+ FROM user_email_claims
+ WHERE user_id = ? AND status = 'released' AND released_at >= ?`,
+ )
+ .bind(input.userId, since)
+ .first<{ count: number }>()
+ return row?.count ?? 0
+}
+
+export async function allocateSignupIdentity(
+ db: D1Database,
+ email: string,
+): Promise {
+ const normalized = normalizeEmail(email)
+ if (!normalized) return { ok: false, reason: 'current_email' }
+
+ const currentOwner = await db
+ .prepare(`SELECT id FROM users WHERE email = ?`)
+ .bind(normalized)
+ .first<{ id: number }>()
+ if (currentOwner) return { ok: false, reason: 'current_email' }
+
+ const active = await findActiveEmailClaim(db, normalized)
+ if (active) return { ok: false, reason: 'former_email_claimed' }
+
+ const preferredId = await createStableUserIdFromEmail(normalized)
+ const existing = await db
+ .prepare(`SELECT id, email FROM users WHERE stable_user_id = ?`)
+ .bind(preferredId)
+ .first<{ id: number; email: string }>()
+ if (!existing) {
+ return { ok: true, stableUserId: preferredId }
+ }
+ if (normalizeEmail(existing.email) === normalized) {
+ return { ok: false, reason: 'current_email' }
+ }
+ if (await isEmailClaimReleased(db, normalized)) {
+ return {
+ ok: true,
+ stableUserId: await createUnusedRandomStableUserId(db),
+ }
+ }
+ return { ok: false, reason: 'former_email_claimed' }
+}
+
+export class EmailClaimConflictError extends Error {
+ readonly email: string
+
+ constructor(email: string) {
+ super('Email claim is already held by another account.')
+ this.name = 'EmailClaimConflictError'
+ this.email = email
+ }
+}
+
+async function createUnusedRandomStableUserId(db: D1Database) {
+ for (let attempt = 0; attempt < randomStableUserIdAttempts; attempt++) {
+ const stableUserId = createRandomStableUserId()
+ const existing = await db
+ .prepare(`SELECT id FROM users WHERE stable_user_id = ?`)
+ .bind(stableUserId)
+ .first<{ id: number }>()
+ if (!existing) return stableUserId
+ }
+ throw new Error('Unable to allocate a unique stable_user_id')
+}
diff --git a/packages/worker/src/identity/platform-account-creation.ts b/packages/worker/src/identity/platform-account-creation.ts
index bcd3dbe16f..2a77d8f9c2 100644
--- a/packages/worker/src/identity/platform-account-creation.ts
+++ b/packages/worker/src/identity/platform-account-creation.ts
@@ -7,7 +7,10 @@ import {
getUsernameFormatValidationError,
normalizeUsername,
} from '#worker/identity/username.ts'
-import { createStableUserIdFromEmail } from '#worker/user-id.ts'
+import {
+ allocateSignupIdentity,
+ claimAccountEmail,
+} from '#worker/identity/email-claims.ts'
import { unusablePasswordHash } from '#worker/identity/usable-password.ts'
export type PlatformAccountCreateErrorCode =
@@ -87,7 +90,14 @@ export async function createPlatformAccount(input: {
const now = input.now ?? new Date()
const nowIso = now.toISOString()
- const stableUserId = await createStableUserIdFromEmail(email)
+ const allocated = await allocateSignupIdentity(input.db, email)
+ if (!allocated.ok) {
+ throw new PlatformAccountCreateError(
+ 'email_exists',
+ 'Email already registered.',
+ )
+ }
+ const stableUserId = allocated.stableUserId
try {
const result = await input.db
@@ -110,6 +120,11 @@ export async function createPlatformAccount(input: {
'Unable to create platform account.',
)
}
+ await claimAccountEmail(input.db, {
+ userId: lastRowId,
+ email,
+ now,
+ })
return {
userId: lastRowId,
email,
diff --git a/packages/worker/src/user-id.ts b/packages/worker/src/user-id.ts
index fcf7875ac9..2e3c19e62b 100644
--- a/packages/worker/src/user-id.ts
+++ b/packages/worker/src/user-id.ts
@@ -14,6 +14,17 @@ export async function createStableUserIdFromEmail(email: string) {
return toHex(new Uint8Array(hash))
}
+/**
+ * Random 64-hex id in the same shape as `createStableUserIdFromEmail`.
+ * Used only for a *new* account when the email-hash id is still held by the
+ * original account after that email claim was released.
+ */
+export function createRandomStableUserId() {
+ const bytes = new Uint8Array(32)
+ crypto.getRandomValues(bytes)
+ return toHex(bytes)
+}
+
export function normalizeStableUserId(value: string | null | undefined) {
return value?.trim() ?? ''
}
diff --git a/packages/worker/universal/document-head.ts b/packages/worker/universal/document-head.ts
index 10a6125c43..62f1a839ca 100644
--- a/packages/worker/universal/document-head.ts
+++ b/packages/worker/universal/document-head.ts
@@ -384,6 +384,10 @@ const routeDocumentHeads = {
const verification = loaderData?.emailVerification
return titleOnly(verification?.ok ? 'Email changed' : 'Verify email change')
},
+ [routePattern(routes.verifyEmailClaimRelease)]: ({ loaderData }) => {
+ const verification = loaderData?.emailVerification
+ return titleOnly(verification?.ok ? 'Email released' : 'Release email')
+ },
[routePattern(routes.connectOauth)]: ({ loaderData }) => {
const provider = loaderData?.connectOauth?.provider?.trim()
return titleOnly(provider ? `Connect ${provider}` : 'Connect an account')
diff --git a/packages/worker/universal/email-claim-errors.ts b/packages/worker/universal/email-claim-errors.ts
new file mode 100644
index 0000000000..98c94032fe
--- /dev/null
+++ b/packages/worker/universal/email-claim-errors.ts
@@ -0,0 +1,9 @@
+/**
+ * Structured signup/OAuth contract when the address is still claimed by an
+ * existing account whose *current* login email is different. Copy must not
+ * leak that account's current email.
+ */
+export const formerEmailClaimedSignupCode = 'former_email_claimed'
+
+export const formerEmailClaimedSignupMessage =
+ 'This email is linked to an existing Kody account. Sign in with the email that account uses now — including a different Google mailbox — or release this address from Account settings → Former addresses on that account.'
diff --git a/packages/worker/universal/loader-data.ts b/packages/worker/universal/loader-data.ts
index 45222bcc80..e298a90885 100644
--- a/packages/worker/universal/loader-data.ts
+++ b/packages/worker/universal/loader-data.ts
@@ -909,6 +909,11 @@ export type AdminCreatedUserSetup = {
setupTokenExpiresAt: number
}
+export type AccountFormerEmail = {
+ email: string
+ claimedAt: string
+}
+
export type AccountProfileLoaderData = {
ok: true
email: string
@@ -919,6 +924,7 @@ export type AccountProfileLoaderData = {
bio: string | null
avatarUrl: string | null
profileVisibility: ProfileVisibility
+ formerEmails: Array
}
export type AccountConnectionListItem = {
@@ -1031,7 +1037,7 @@ export type PendingVerificationLoaderData = {
export type EmailVerificationLoaderData =
| {
ok: true
- kind: 'email_verify' | 'email_change'
+ kind: 'email_verify' | 'email_change' | 'email_claim_release'
message: string
ctaHref?: string
ctaLabel?: string
diff --git a/packages/worker/universal/oauth-login-errors.ts b/packages/worker/universal/oauth-login-errors.ts
index 03584df31a..6d868f04af 100644
--- a/packages/worker/universal/oauth-login-errors.ts
+++ b/packages/worker/universal/oauth-login-errors.ts
@@ -1,3 +1,5 @@
+import { formerEmailClaimedSignupMessage } from '#universal/email-claim-errors.ts'
+
/**
* Social-login failure codes carried back to `/login?oauthError=`.
* Shared with the client login route, which maps codes to friendly copy, so
@@ -24,6 +26,7 @@ export const oauthLoginErrorMessages = {
'email-unverified': 'Verify your email before connecting a sign-in provider.',
'email-unavailable':
'This email address cannot be used for a new account. Contact support@kody.codes.',
+ 'email-claimed': formerEmailClaimedSignupMessage,
'account-error': 'We could not create your account. Please try again.',
'rate-limited': 'Too many sign-in attempts. Please try again later.',
} as const
diff --git a/packages/worker/universal/routes.ts b/packages/worker/universal/routes.ts
index 8d30c36277..0f862b9cef 100644
--- a/packages/worker/universal/routes.ts
+++ b/packages/worker/universal/routes.ts
@@ -107,6 +107,7 @@ export const routes = route({
accountUsage: '/account/usage',
accountUsageApi: '/account/usage.json',
accountEmailChange: post('/account/email-change.json'),
+ accountEmailClaimRelease: post('/account/email-claim-release.json'),
accountPassword: post('/account/password.json'),
accountResendVerification: post('/account/resend-verification.json'),
accountExport: '/account/export.json',
@@ -231,6 +232,7 @@ export const routes = route({
verifyTwoFactorApi: post('/verify/2fa.json'),
verifyEmail: '/verify-email',
verifyEmailChange: '/verify-email-change',
+ verifyEmailClaimRelease: '/verify-email-claim-release',
pendingVerification: '/pending-verification',
signup: '/signup',
waitingList: post('/waiting-list'),
diff --git a/tools/control-kody/feature-catalog.ts b/tools/control-kody/feature-catalog.ts
index fc742d8d4b..1716694ff2 100644
--- a/tools/control-kody/feature-catalog.ts
+++ b/tools/control-kody/feature-catalog.ts
@@ -34,6 +34,7 @@ export const featureCatalog: ReadonlyArray = [
'/verify-email',
'/pending-verification',
'/verify-email-change',
+ '/verify-email-claim-release',
],
apis: ['/account/resend-verification.json'],
},
@@ -94,6 +95,7 @@ export const featureCatalog: ReadonlyArray = [
'/account/profile.json',
'/account/profile/avatar.json',
'/account/email-change.json',
+ '/account/email-claim-release.json',
'/account/export.json',
'/account/delete',
'/account/connections.json',
diff --git a/tools/migration-ledger.json b/tools/migration-ledger.json
index da7f766d95..32941cf54e 100644
--- a/tools/migration-ledger.json
+++ b/tools/migration-ledger.json
@@ -179,6 +179,10 @@
{
"filename": "0044-users-stripe-price-id.sql",
"sha256": "2626ab6b7bba5c6f30181de4e22f5833a988444756b1b3e7d223e927fd1228b4"
+ },
+ {
+ "filename": "0045-user-email-claims.sql",
+ "sha256": "9f7f3b177513e2e2ff4bf48d54c9cdbcbf8ddfa7c9772ace61c6cbabae04e1b8"
}
]
}
From 9c0807b87862fed61edd6515da3a78d958e2c849 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 6 Sep 2026 21:01:23 +0000
Subject: [PATCH 2/4] Provision email-claim tables in workers-unit platform
account schema.
Local D1 does not apply migrations, so createPlatformAccount now needs
the 0045 claims tables before allocateSignupIdentity can run.
Co-authored-by: Kent C. Dodds
---
.../src/identity/email-claims-test-schema.ts | 53 +++++++++++++++++++
.../src/package-registry/test-schema.ts | 2 +
2 files changed, 55 insertions(+)
create mode 100644 packages/worker/src/identity/email-claims-test-schema.ts
diff --git a/packages/worker/src/identity/email-claims-test-schema.ts b/packages/worker/src/identity/email-claims-test-schema.ts
new file mode 100644
index 0000000000..6bc9248cd8
--- /dev/null
+++ b/packages/worker/src/identity/email-claims-test-schema.ts
@@ -0,0 +1,53 @@
+/**
+ * Local D1 workers-unit suites do not apply migrations. Suites that create
+ * accounts through `allocateSignupIdentity` / `claimAccountEmail` need these
+ * tables (migration 0045).
+ */
+export async function ensureEmailClaimsTestSchema(db: D1Database) {
+ await db
+ .prepare(
+ `CREATE TABLE IF NOT EXISTS user_email_claims (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ user_id INTEGER NOT NULL,
+ email TEXT NOT NULL,
+ status TEXT NOT NULL CHECK (status IN ('claimed', 'released')),
+ claimed_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ released_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP),
+ UNIQUE (user_id, email)
+ )`,
+ )
+ .run()
+ await db
+ .prepare(
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_user_email_claims_active_email
+ ON user_email_claims(email)
+ WHERE status = 'claimed'`,
+ )
+ .run()
+ await db
+ .prepare(
+ `CREATE INDEX IF NOT EXISTS idx_user_email_claims_user_id
+ ON user_email_claims(user_id)`,
+ )
+ .run()
+ await db
+ .prepare(
+ `CREATE TABLE IF NOT EXISTS pending_email_claim_releases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
+ user_id INTEGER NOT NULL,
+ email TEXT NOT NULL,
+ token_hash TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
+ )`,
+ )
+ .run()
+ await db
+ .prepare(
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_pending_email_claim_releases_user_email
+ ON pending_email_claim_releases(user_id, email)`,
+ )
+ .run()
+}
diff --git a/packages/worker/src/package-registry/test-schema.ts b/packages/worker/src/package-registry/test-schema.ts
index 96334acdb7..36d4c524d1 100644
--- a/packages/worker/src/package-registry/test-schema.ts
+++ b/packages/worker/src/package-registry/test-schema.ts
@@ -1,3 +1,4 @@
+import { ensureEmailClaimsTestSchema } from '#worker/identity/email-claims-test-schema.ts'
import { ensureUsersTestSchema } from '#worker/users-test-schema.ts'
/**
@@ -11,6 +12,7 @@ export async function ensurePackageScopeGrantsTestSchema(db: D1Database) {
db,
columns: ['email_verified_at', 'account_type'],
})
+ await ensureEmailClaimsTestSchema(db)
await db.prepare(`DROP TABLE IF EXISTS package_scope_grants`).run()
await db
.prepare(
From 35ea2a43c2c0da779a35d376e5b99cc6c0dbda2b Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 6 Sep 2026 21:12:59 +0000
Subject: [PATCH 3/4] Keep the account route under the client file-size
ratchet.
Move email-change and former-address client state into a factory so
account.tsx stays under the 800-line budget after the claims UI.
Co-authored-by: Kent C. Dodds
---
.../routes/account-email-claims-client.ts | 243 +++++++++++++++++
packages/worker/client/routes/account.tsx | 255 +++---------------
2 files changed, 277 insertions(+), 221 deletions(-)
create mode 100644 packages/worker/client/routes/account-email-claims-client.ts
diff --git a/packages/worker/client/routes/account-email-claims-client.ts b/packages/worker/client/routes/account-email-claims-client.ts
new file mode 100644
index 0000000000..bbf49ecaaa
--- /dev/null
+++ b/packages/worker/client/routes/account-email-claims-client.ts
@@ -0,0 +1,243 @@
+import { type Handle } from 'remix/ui'
+import { type AccountFormerEmail } from '#universal/loader-data.ts'
+import { readJson } from '#client/routes/account-approval-shared.ts'
+
+const emailChangeApiPath = '/account/email-change.json'
+const emailClaimReleaseApiPath = '/account/email-claim-release.json'
+
+export function createAccountEmailClaims(handle: Handle) {
+ let emailChangeStatus: 'idle' | 'sending' = 'idle'
+ let emailChangeOpen = false
+ let formerEmails: Array = []
+ let releaseEmail = ''
+ let releasePassword = ''
+ let releaseStatus: 'idle' | 'sending' = 'idle'
+ let releaseMessage: string | null = null
+ let releaseTone: 'error' | 'info' = 'info'
+ let draftEmail = ''
+ let emailChangePassword = ''
+ let emailChangeMessage: string | null = null
+ let emailChangeTone: 'error' | 'info' = 'info'
+
+ function applyFormerEmails(nextFormerEmails: Array) {
+ formerEmails = nextFormerEmails
+ }
+
+ function applyCurrentEmail(email: string) {
+ draftEmail = email
+ }
+
+ function updateDraftEmail(event: InputEvent) {
+ if (!(event.currentTarget instanceof HTMLInputElement)) return
+ draftEmail = event.currentTarget.value
+ handle.update()
+ }
+
+ function updateEmailChangePassword(event: InputEvent) {
+ if (!(event.currentTarget instanceof HTMLInputElement)) return
+ emailChangePassword = event.currentTarget.value
+ handle.update()
+ }
+
+ function updateReleaseEmail(event: InputEvent) {
+ if (!(event.currentTarget instanceof HTMLInputElement)) return
+ releaseEmail = event.currentTarget.value
+ handle.update()
+ }
+
+ function updateReleasePassword(event: InputEvent) {
+ if (!(event.currentTarget instanceof HTMLInputElement)) return
+ releasePassword = event.currentTarget.value
+ handle.update()
+ }
+
+ function useFormerEmailAsLogin(nextEmail: string) {
+ draftEmail = nextEmail
+ emailChangeOpen = true
+ emailChangeMessage = null
+ handle.update()
+ }
+
+ async function requestFormerEmailRelease(nextEmail: string) {
+ if (!releasePassword) {
+ releaseEmail = nextEmail
+ releaseMessage = 'Current password is required to send a release link.'
+ releaseTone = 'error'
+ handle.update()
+ return
+ }
+ releaseEmail = nextEmail
+ handle.update()
+ await submitFormerEmailRelease()
+ }
+
+ async function handleFormerEmailReleaseSubmit(event: SubmitEvent) {
+ event.preventDefault()
+ await submitFormerEmailRelease()
+ }
+
+ async function submitFormerEmailRelease() {
+ const nextEmail = releaseEmail.trim().toLowerCase()
+ if (!nextEmail || !releasePassword) {
+ releaseMessage = 'Former email and current password are required.'
+ releaseTone = 'error'
+ handle.update()
+ return
+ }
+
+ releaseStatus = 'sending'
+ releaseMessage = null
+ releaseTone = 'info'
+ handle.update()
+
+ try {
+ const response = await fetch(emailClaimReleaseApiPath, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ email: nextEmail,
+ password: releasePassword,
+ }),
+ })
+ if (response.status === 401) {
+ const payload = await readJson<{ code?: string; error?: string }>(
+ response,
+ )
+ if (payload?.code === 'invalid_password') {
+ throw new Error(payload.error)
+ }
+ window.location.assign('/login')
+ return
+ }
+ const payload = await readJson<{
+ ok?: boolean
+ message?: string
+ error?: string
+ }>(response)
+ if (!response.ok || !payload?.ok) {
+ throw new Error(
+ payload?.error || 'Unable to send the release verification.',
+ )
+ }
+ releasePassword = ''
+ releaseMessage =
+ payload.message ?? 'Verification email sent to that former address.'
+ releaseTone = 'info'
+ } catch (error) {
+ releaseMessage =
+ error instanceof Error
+ ? error.message
+ : 'Unable to send the release verification.'
+ releaseTone = 'error'
+ } finally {
+ releaseStatus = 'idle'
+ handle.update()
+ }
+ }
+
+ async function handleEmailChangeSubmit(
+ event: SubmitEvent,
+ currentEmail: string,
+ ) {
+ event.preventDefault()
+ const nextEmail = draftEmail.trim().toLowerCase()
+ if (!nextEmail || !emailChangePassword) {
+ emailChangeMessage = 'New email and current password are required.'
+ emailChangeTone = 'error'
+ handle.update()
+ return
+ }
+ if (nextEmail === currentEmail.trim().toLowerCase()) {
+ emailChangeMessage = 'Enter a different email address.'
+ emailChangeTone = 'error'
+ handle.update()
+ return
+ }
+
+ emailChangeStatus = 'sending'
+ emailChangeMessage = null
+ emailChangeTone = 'info'
+ handle.update()
+
+ try {
+ const response = await fetch(emailChangeApiPath, {
+ method: 'POST',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ },
+ credentials: 'include',
+ body: JSON.stringify({
+ email: nextEmail,
+ password: emailChangePassword,
+ }),
+ })
+ if (response.status === 401) {
+ const payload = await readJson<{ code?: string; error?: string }>(
+ response,
+ )
+ if (payload?.code === 'invalid_password') {
+ throw new Error(payload.error)
+ }
+ window.location.assign('/login')
+ return
+ }
+ const payload = await readJson<{
+ ok?: boolean
+ message?: string
+ error?: string
+ }>(response)
+ if (!response.ok || !payload?.ok) {
+ throw new Error(
+ payload?.error || 'Unable to send the email change verification.',
+ )
+ }
+ emailChangePassword = ''
+ emailChangeMessage =
+ payload.message ?? 'Verification email sent to your new address.'
+ emailChangeTone = 'info'
+ } catch (error) {
+ emailChangeMessage =
+ error instanceof Error
+ ? error.message
+ : 'Unable to send the email change verification.'
+ emailChangeTone = 'error'
+ } finally {
+ emailChangeStatus = 'idle'
+ handle.update()
+ }
+ }
+
+ return {
+ applyFormerEmails,
+ applyCurrentEmail,
+ updateDraftEmail,
+ updateEmailChangePassword,
+ updateReleaseEmail,
+ updateReleasePassword,
+ useFormerEmailAsLogin,
+ requestFormerEmailRelease,
+ handleFormerEmailReleaseSubmit,
+ handleEmailChangeSubmit,
+ get snapshot() {
+ return {
+ emailChangeStatus,
+ emailChangeOpen,
+ formerEmails,
+ releaseEmail,
+ releasePassword,
+ releaseStatus,
+ releaseMessage,
+ releaseTone,
+ draftEmail,
+ emailChangePassword,
+ emailChangeMessage,
+ emailChangeTone,
+ }
+ },
+ }
+}
diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx
index c19951e502..4c833975c8 100644
--- a/packages/worker/client/routes/account.tsx
+++ b/packages/worker/client/routes/account.tsx
@@ -8,7 +8,6 @@ import { consumeStaleNavigationData } from '#client/navigation-data.ts'
import {
type OnboardingChecklistLoaderData,
type AccountConnectionsLoaderData,
- type AccountFormerEmail,
type AccountProfileLoaderData,
type ProfileVisibility,
} from '#universal/loader-data.ts'
@@ -39,6 +38,7 @@ import {
AccountPageHeader,
accountActionsCss,
} from '#client/routes/account-management-components.tsx'
+import { createAccountEmailClaims } from '#client/routes/account-email-claims-client.ts'
import { renderAccountFormerEmailsPanel } from '#client/routes/account-former-emails-panel.tsx'
import { renderAccountProfilePanel } from '#client/routes/account-profile-panel.tsx'
import { AccountPasswordPanel } from '#client/routes/account-password-panel.tsx'
@@ -61,8 +61,6 @@ import {
type RouteLoaderResult,
} from '#client/route-loader.ts'
-const emailChangeApiPath = '/account/email-change.json'
-const emailClaimReleaseApiPath = '/account/email-claim-release.json'
const connectionsApiPath = '/account/connections.json'
const accountAvatarApiPath = '/account/profile/avatar.json'
@@ -111,14 +109,6 @@ export function AccountRoute(handle: Handle) {
let status: AccountStatus = 'loading'
let saveStatus: 'idle' | 'saving' = 'idle'
let resendStatus: 'idle' | 'sending' = 'idle'
- let emailChangeStatus: 'idle' | 'sending' = 'idle'
- let emailChangeOpen = false
- let formerEmails: Array = []
- let releaseEmail = ''
- let releasePassword = ''
- let releaseStatus: 'idle' | 'sending' = 'idle'
- let releaseMessage: string | null = null
- let releaseTone: 'error' | 'info' = 'info'
let resendMessage: string | null = null
let resendTone: 'error' | 'info' = 'info'
let email = ''
@@ -138,13 +128,10 @@ export function AccountRoute(handle: Handle) {
let avatarStatus: 'idle' | 'editing' | 'uploading' | 'removing' = 'idle'
let editorFile: File | null = null
let avatarDropActive = false
- let draftEmail = ''
- let emailChangePassword = ''
let message: string | null = null
let messageTone: 'error' | 'info' = 'info'
- let emailChangeMessage: string | null = null
- let emailChangeTone: 'error' | 'info' = 'info'
const accountConnections = createAccountConnections(handle)
+ const accountEmailClaims = createAccountEmailClaims(handle)
let consumedCallbackMessage = false
let needsOnboarding = false
let onboardingChecklist: OnboardingChecklistLoaderData | null = null
@@ -195,7 +182,7 @@ export function AccountRoute(handle: Handle) {
username = payload.username
draftUsername = payload.username
applyProfileFields(payload)
- draftEmail = payload.email
+ accountEmailClaims.applyCurrentEmail(payload.email)
status = 'ready'
message = null
messageTone = 'info'
@@ -220,7 +207,7 @@ export function AccountRoute(handle: Handle) {
draftBio = payload.bio ?? ''
draftProfileVisibility = payload.profileVisibility
if (!optimisticAvatarObjectUrl) avatarUrl = payload.avatarUrl
- formerEmails = payload.formerEmails ?? []
+ accountEmailClaims.applyFormerEmails(payload.formerEmails ?? [])
}
function releaseOptimisticAvatar() {
@@ -399,188 +386,6 @@ export function AccountRoute(handle: Handle) {
handle.update()
}
- function updateDraftEmail(event: InputEvent) {
- if (!(event.currentTarget instanceof HTMLInputElement)) return
- draftEmail = event.currentTarget.value
- handle.update()
- }
-
- function updateEmailChangePassword(event: InputEvent) {
- if (!(event.currentTarget instanceof HTMLInputElement)) return
- emailChangePassword = event.currentTarget.value
- handle.update()
- }
-
- function updateReleaseEmail(event: InputEvent) {
- if (!(event.currentTarget instanceof HTMLInputElement)) return
- releaseEmail = event.currentTarget.value
- handle.update()
- }
-
- function updateReleasePassword(event: InputEvent) {
- if (!(event.currentTarget instanceof HTMLInputElement)) return
- releasePassword = event.currentTarget.value
- handle.update()
- }
-
- function useFormerEmailAsLogin(nextEmail: string) {
- draftEmail = nextEmail
- emailChangeOpen = true
- emailChangeMessage = null
- handle.update()
- }
-
- async function requestFormerEmailRelease(nextEmail: string) {
- if (!releasePassword) {
- releaseEmail = nextEmail
- releaseMessage = 'Current password is required to send a release link.'
- releaseTone = 'error'
- handle.update()
- return
- }
- releaseEmail = nextEmail
- handle.update()
- await submitFormerEmailRelease()
- }
-
- async function handleFormerEmailReleaseSubmit(event: SubmitEvent) {
- event.preventDefault()
- await submitFormerEmailRelease()
- }
-
- async function submitFormerEmailRelease() {
- const nextEmail = releaseEmail.trim().toLowerCase()
- if (!nextEmail || !releasePassword) {
- releaseMessage = 'Former email and current password are required.'
- releaseTone = 'error'
- handle.update()
- return
- }
-
- releaseStatus = 'sending'
- releaseMessage = null
- releaseTone = 'info'
- handle.update()
-
- try {
- const response = await fetch(emailClaimReleaseApiPath, {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- credentials: 'include',
- body: JSON.stringify({
- email: nextEmail,
- password: releasePassword,
- }),
- })
- if (response.status === 401) {
- const payload = await readJson<{ code?: string; error?: string }>(
- response,
- )
- if (payload?.code === 'invalid_password') {
- throw new Error(payload.error)
- }
- window.location.assign('/login')
- return
- }
- const payload = await readJson<{
- ok?: boolean
- message?: string
- error?: string
- }>(response)
- if (!response.ok || !payload?.ok) {
- throw new Error(
- payload?.error || 'Unable to send the release verification.',
- )
- }
- releasePassword = ''
- releaseMessage =
- payload.message ?? 'Verification email sent to that former address.'
- releaseTone = 'info'
- } catch (error) {
- releaseMessage =
- error instanceof Error
- ? error.message
- : 'Unable to send the release verification.'
- releaseTone = 'error'
- } finally {
- releaseStatus = 'idle'
- handle.update()
- }
- }
-
- async function handleEmailChangeSubmit(event: SubmitEvent) {
- event.preventDefault()
- const nextEmail = draftEmail.trim().toLowerCase()
- if (!nextEmail || !emailChangePassword) {
- emailChangeMessage = 'New email and current password are required.'
- emailChangeTone = 'error'
- handle.update()
- return
- }
- if (nextEmail === email.trim().toLowerCase()) {
- emailChangeMessage = 'Enter a different email address.'
- emailChangeTone = 'error'
- handle.update()
- return
- }
-
- emailChangeStatus = 'sending'
- emailChangeMessage = null
- emailChangeTone = 'info'
- handle.update()
-
- try {
- const response = await fetch(emailChangeApiPath, {
- method: 'POST',
- headers: {
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- credentials: 'include',
- body: JSON.stringify({
- email: nextEmail,
- password: emailChangePassword,
- }),
- })
- if (response.status === 401) {
- const payload = await readJson<{ code?: string; error?: string }>(
- response,
- )
- if (payload?.code === 'invalid_password') {
- throw new Error(payload.error)
- }
- window.location.assign('/login')
- return
- }
- const payload = await readJson<{
- ok?: boolean
- message?: string
- error?: string
- }>(response)
- if (!response.ok || !payload?.ok) {
- throw new Error(
- payload?.error || 'Unable to send the email change verification.',
- )
- }
- emailChangePassword = ''
- emailChangeMessage =
- payload.message ?? 'Verification email sent to your new address.'
- emailChangeTone = 'info'
- } catch (error) {
- emailChangeMessage =
- error instanceof Error
- ? error.message
- : 'Unable to send the email change verification.'
- emailChangeTone = 'error'
- } finally {
- emailChangeStatus = 'idle'
- handle.update()
- }
- }
-
async function handleProfileSubmit(event: SubmitEvent) {
event.preventDefault()
const nextUsername = draftUsername.trim()
@@ -672,7 +477,7 @@ export function AccountRoute(handle: Handle) {
username = routeData.username
draftUsername = routeData.username
applyProfileFields(routeData)
- draftEmail = routeData.email
+ accountEmailClaims.applyCurrentEmail(routeData.email)
accountConnections.applyPayload(connectionsData)
const onboardingData = tryConsumeRouteLoaderData(handle, 'onboarding', href)
if (onboardingData) {
@@ -708,9 +513,10 @@ export function AccountRoute(handle: Handle) {
accountConnections.setMessage(readConnectionCallbackMessage(currentHref))
}
const isSaving = saveStatus === 'saving'
- const isSendingEmailChange = emailChangeStatus === 'sending'
+ const emailClaims = accountEmailClaims.snapshot
+ const isSendingEmailChange = emailClaims.emailChangeStatus === 'sending'
const normalizedDraftUsername = draftUsername.trim().toLowerCase()
- const normalizedDraftEmail = draftEmail.trim().toLowerCase()
+ const normalizedDraftEmail = emailClaims.draftEmail.trim().toLowerCase()
const profileUnchanged =
normalizedDraftUsername === username &&
draftDisplayName === savedDisplayName &&
@@ -766,8 +572,8 @@ export function AccountRoute(handle: Handle) {
draftDisplayName,
draftBio,
draftProfileVisibility,
- draftEmail,
- emailChangePassword,
+ draftEmail: emailClaims.draftEmail,
+ emailChangePassword: emailClaims.emailChangePassword,
avatarUrl,
avatarStatus,
isSaving,
@@ -775,11 +581,13 @@ export function AccountRoute(handle: Handle) {
profileUnchanged,
normalizedDraftUsername,
normalizedDraftEmail,
- emailChangeMessage,
- emailChangeTone,
- emailChangeOpen,
+ emailChangeMessage: emailClaims.emailChangeMessage,
+ emailChangeTone: emailClaims.emailChangeTone,
+ emailChangeOpen: emailClaims.emailChangeOpen,
onProfileSubmit: handleProfileSubmit,
- onEmailChangeSubmit: handleEmailChangeSubmit,
+ onEmailChangeSubmit: (event) => {
+ void accountEmailClaims.handleEmailChangeSubmit(event, email)
+ },
onAvatarSelected: handleAvatarSelected,
onRemoveAvatar: () => void handleRemoveAvatar(),
onDraftUsernameInput: updateDraftUsername,
@@ -795,23 +603,28 @@ export function AccountRoute(handle: Handle) {
draftProfileVisibility = value
handle.update()
},
- onDraftEmailInput: updateDraftEmail,
- onEmailChangePasswordInput: updateEmailChangePassword,
+ onDraftEmailInput: accountEmailClaims.updateDraftEmail,
+ onEmailChangePasswordInput:
+ accountEmailClaims.updateEmailChangePassword,
})}
{emailVerified
? renderAccountFormerEmailsPanel({
- formerEmails,
- releaseEmail,
- releasePassword,
- releaseStatus,
- releaseMessage,
- releaseTone,
- onReleaseEmailInput: updateReleaseEmail,
- onReleasePasswordInput: updateReleasePassword,
- onReleaseSubmit: handleFormerEmailReleaseSubmit,
- onUseAgainAsLogin: useFormerEmailAsLogin,
+ formerEmails: emailClaims.formerEmails,
+ releaseEmail: emailClaims.releaseEmail,
+ releasePassword: emailClaims.releasePassword,
+ releaseStatus: emailClaims.releaseStatus,
+ releaseMessage: emailClaims.releaseMessage,
+ releaseTone: emailClaims.releaseTone,
+ onReleaseEmailInput: accountEmailClaims.updateReleaseEmail,
+ onReleasePasswordInput:
+ accountEmailClaims.updateReleasePassword,
+ onReleaseSubmit:
+ accountEmailClaims.handleFormerEmailReleaseSubmit,
+ onUseAgainAsLogin: accountEmailClaims.useFormerEmailAsLogin,
onReleaseListed: (listedEmail) => {
- void requestFormerEmailRelease(listedEmail)
+ void accountEmailClaims.requestFormerEmailRelease(
+ listedEmail,
+ )
},
})
: null}
From c25102e2ff257c480303dbceedacac270d2adaeb Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 6 Sep 2026 21:26:42 +0000
Subject: [PATCH 4/4] Address review feedback on claim rollback and release
limits.
Roll back a platform user when claiming its email fails so retries are not
stuck, and refund the release-request limiter when the confirmation email
cannot be sent.
Co-authored-by: Kent C. Dodds
---
.../account-email-claim-release.node.test.ts | 46 +++++++++++++++++-
.../handlers/account-email-claim-release.ts | 3 ++
.../platform-account-creation.node.test.ts | 48 +++++++++++++++++++
.../src/identity/platform-account-creation.ts | 22 ++++++++-
4 files changed, 116 insertions(+), 3 deletions(-)
create mode 100644 packages/worker/src/identity/platform-account-creation.node.test.ts
diff --git a/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts b/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts
index 4fdccd042d..8220d1e2b3 100644
--- a/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts
+++ b/packages/worker/src/app/handlers/account-email-claim-release.node.test.ts
@@ -12,7 +12,10 @@ import { hashVerificationToken } from '#app/email-verification.ts'
import { formerEmailClaimedSignupCode } from '#universal/email-claim-errors.ts'
import { createPasswordHash } from '@kody-internal/shared/password-hash.ts'
import { applyAllMigrations } from '#worker/test-support/apply-all-migrations.ts'
-import { consoleWarn } from '#worker/test-support/console-spies.ts'
+import {
+ consoleError,
+ consoleWarn,
+} from '#worker/test-support/console-spies.ts'
import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts'
import { testStableUserIdFromEmail } from '#worker/test-support/stable-user-id.ts'
import { createStableUserIdFromEmail } from '#worker/user-id.ts'
@@ -284,3 +287,44 @@ test('release requests are rate limited and refuse another account email', async
} as never)
expect(limited.status).toBe(429)
})
+
+test('refunds the request limiter when the release email cannot be sent', async () => {
+ consoleError.mockImplementation(() => {})
+ const { sqlite, db } = createMigratedDb()
+ await seedUser(sqlite, {
+ id: 1,
+ email: 'owner@example.com',
+ username: 'owner',
+ password: 'correct-password',
+ })
+ sqlite.exec(`
+ INSERT INTO user_email_claims (user_id, email, status)
+ VALUES (1, 'owner@example.com', 'claimed');
+ INSERT INTO user_email_claims (user_id, email, status)
+ VALUES (1, 'old@example.com', 'claimed');
+ `)
+ const env = {
+ ...createAppEnv(db),
+ SENTRY_ENVIRONMENT: 'production',
+ } as Env
+ const handler = createAccountEmailClaimReleaseHandler(env)
+ const session = {
+ stableUserId: testStableUserIdFromEmail('owner@example.com'),
+ email: 'owner@example.com',
+ rememberMe: false,
+ }
+
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ const response = await handler.handler({
+ request: await createReleaseRequest({
+ session,
+ email: 'old@example.com',
+ password: 'correct-password',
+ }),
+ url: new URL('http://example.com/account/email-claim-release.json'),
+ params: {},
+ } as never)
+ expect(response.status).toBe(502)
+ }
+ expect(consoleError).toHaveBeenCalled()
+})
diff --git a/packages/worker/src/app/handlers/account-email-claim-release.ts b/packages/worker/src/app/handlers/account-email-claim-release.ts
index 9180111c70..204cafaa8b 100644
--- a/packages/worker/src/app/handlers/account-email-claim-release.ts
+++ b/packages/worker/src/app/handlers/account-email-claim-release.ts
@@ -231,6 +231,9 @@ export function createAccountEmailClaimReleaseHandler(env: Env) {
)
}
console.error('Failed to request email claim release:', error)
+ await releaseRateLimit(env.APP_DB, requestLimitKey).catch(
+ () => undefined,
+ )
void logAuditEvent({
db: auditDatabaseFromEnv(env),
category: 'account',
diff --git a/packages/worker/src/identity/platform-account-creation.node.test.ts b/packages/worker/src/identity/platform-account-creation.node.test.ts
new file mode 100644
index 0000000000..46bbeb9c5a
--- /dev/null
+++ b/packages/worker/src/identity/platform-account-creation.node.test.ts
@@ -0,0 +1,48 @@
+import { DatabaseSync } from 'node:sqlite'
+import { expect, test } from 'vitest'
+import { applyAllMigrations } from '#worker/test-support/apply-all-migrations.ts'
+import { createD1FromSqlite } from '#worker/test-support/create-d1-from-sqlite.ts'
+import {
+ createPlatformAccount,
+ type PlatformAccountCreateError,
+} from './platform-account-creation.ts'
+
+function createMigratedDb() {
+ const sqlite = new DatabaseSync(':memory:')
+ applyAllMigrations(sqlite, new URL('../../migrations/', import.meta.url))
+ return { sqlite, db: createD1FromSqlite(sqlite) }
+}
+
+test('rolls back the inserted platform user when claiming the email fails', async () => {
+ const { sqlite, db } = createMigratedDb()
+ const failingDb = new Proxy(db, {
+ get(target, property, receiver) {
+ if (property === 'prepare') {
+ return (sql: string) => {
+ if (sql.includes('INSERT INTO user_email_claims')) {
+ throw new Error('forced claim failure')
+ }
+ return target.prepare(sql)
+ }
+ }
+ return Reflect.get(target, property, receiver)
+ },
+ })
+ const email = 'platform@example.com'
+
+ await expect(
+ createPlatformAccount({
+ db: failingDb,
+ email,
+ username: 'kody',
+ }),
+ ).rejects.toMatchObject({
+ code: 'create_failed',
+ } satisfies Partial)
+
+ expect(
+ sqlite
+ .prepare(`SELECT COUNT(*) AS count FROM users WHERE email = ?`)
+ .get(email) as { count: number },
+ ).toEqual({ count: 0 })
+})
diff --git a/packages/worker/src/identity/platform-account-creation.ts b/packages/worker/src/identity/platform-account-creation.ts
index 2a77d8f9c2..ada46c044a 100644
--- a/packages/worker/src/identity/platform-account-creation.ts
+++ b/packages/worker/src/identity/platform-account-creation.ts
@@ -98,6 +98,7 @@ export async function createPlatformAccount(input: {
)
}
const stableUserId = allocated.stableUserId
+ let userId: number | null = null
try {
const result = await input.db
@@ -120,18 +121,27 @@ export async function createPlatformAccount(input: {
'Unable to create platform account.',
)
}
+ userId = lastRowId
await claimAccountEmail(input.db, {
- userId: lastRowId,
+ userId,
email,
now,
})
return {
- userId: lastRowId,
+ userId,
email,
username,
stableUserId,
} satisfies CreatedPlatformAccount
} catch (error) {
+ if (userId != null) {
+ await deleteUserBestEffort(input.db, userId)
+ if (error instanceof PlatformAccountCreateError) throw error
+ throw new PlatformAccountCreateError(
+ 'create_failed',
+ 'Unable to create platform account.',
+ )
+ }
if (error instanceof PlatformAccountCreateError) throw error
const uniqueField = getUniqueConstraintField(error)
if (uniqueField === 'email') {
@@ -149,3 +159,11 @@ export async function createPlatformAccount(input: {
throw error
}
}
+
+async function deleteUserBestEffort(db: D1Database, userId: number) {
+ try {
+ await db.prepare(`DELETE FROM users WHERE id = ?`).bind(userId).run()
+ } catch (error) {
+ console.error('Failed to roll back platform account user:', error)
+ }
+}