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