Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/control-kody/references/features/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions .agents/skills/control-kody/references/features/signup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
48 changes: 27 additions & 21 deletions docs/contributing/architecture/data-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
17 changes: 10 additions & 7 deletions docs/contributing/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/worker/client/lazy-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
)
Expand Down
243 changes: 243 additions & 0 deletions packages/worker/client/routes/account-email-claims-client.ts
Original file line number Diff line number Diff line change
@@ -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<AccountFormerEmail> = []
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<AccountFormerEmail>) {
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,
}
},
}
}
Loading
Loading