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. +

+ )} +
+

+ We send a confirmation link to the former address. Releasing it does + not change this account's identity. +

+ + +
+ +
+ {releaseMessage ? ( +

+ {releaseMessage} +

+ ) : null} +
+
+ ) +} diff --git a/packages/worker/client/routes/account-profile-panel.tsx b/packages/worker/client/routes/account-profile-panel.tsx index 03a5cbc2af..a8a6045b16 100644 --- a/packages/worker/client/routes/account-profile-panel.tsx +++ b/packages/worker/client/routes/account-profile-panel.tsx @@ -42,6 +42,7 @@ export type AccountProfilePanelProps = { normalizedDraftEmail: string emailChangeMessage: string | null emailChangeTone: 'error' | 'info' + emailChangeOpen: boolean onProfileSubmit: (event: SubmitEvent) => void onEmailChangeSubmit: (event: SubmitEvent) => void onAvatarSelected: (event: Event) => void @@ -74,6 +75,7 @@ export function renderAccountProfilePanel(props: AccountProfilePanelProps) { normalizedDraftEmail, emailChangeMessage, emailChangeTone, + emailChangeOpen, onProfileSubmit, onEmailChangeSubmit, onAvatarSelected, @@ -280,6 +282,7 @@ export function renderAccountProfilePanel(props: AccountProfilePanelProps) { {emailVerified ? (
+

+ 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. +