feat(auth): unique, changeable, verifiable email addresses (engagement Phase 1b)
All checks were successful
PR Checks / bot-tests (pull_request) Successful in 29s
PR Checks / client-build (pull_request) Successful in 31s
PR Checks / server-tests (pull_request) Successful in 10m34s

Makes `users.email` unique, de-duplicates the addresses an upgrade will find,
and builds the self-service change-and-verify flow that did not exist.

The uniqueness index is on a generated `email_norm AS (LOWER(email)) STORED`
column under `utf8mb4_bin`, NOT on `email` under a `_ci` collation as the plan
specified. Every case-insensitive collation this server offers is also
accent-insensitive: `josé@x.com` and `jose@x.com` compare equal, and those are
two different mailboxes. The plan's index would have refused the second address
forever and the de-duplication would have nulled a legitimate account's.

A requested address is STAGED in `email_pending` and only a tokened link
installs it, so a typo cannot silently redirect account-recovery mail.

`isDuplicateUsername()` now distinguishes the two indexes. All five call sites
branch on it; each answers differently on purpose, because a public form, an
IdP callback, a half-completed invite and an admin screen do not owe the same
person the same amount of truth.

SSO reads the IdP's actual `email_verified`/`verified` claim instead of
inferring verification from an address merely being present.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-29 01:53:50 -05:00
parent c2e4df5b3d
commit fbb4b0bd91
44 changed files with 3024 additions and 59 deletions

View File

@@ -1166,6 +1166,16 @@
}
}
},
"409": {
"description": "An account already uses that email address. Addresses are unique, so such an invite could never be accepted; it is refused here rather than at accept time, after the invitee has clicked the link.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
@@ -5453,6 +5463,121 @@
}
}
},
"/api/v1/admin/users/email-dedupe-report": {
"get": {
"tags": [
"Admin · Users"
],
"summary": "Accounts whose email was cleared by de-duplication (admin only)",
"description": "When email addresses became unique, accounts sharing an address kept only the earliest-created one; the rest had their address cleared. These users can still sign in but cannot receive password-reset or notification email until they set a new address, so they are the ones to contact.",
"responses": {
"200": {
"description": "The affected accounts",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/EmailDedupeEntry"
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/users/email-dedupe-report/acknowledge": {
"post": {
"tags": [
"Admin · Users"
],
"summary": "Dismiss the de-duplication warning (admin only)",
"description": "Marks the report acknowledged so it stops appearing as a dashboard warning. The rows are kept as a record of what the upgrade did.",
"responses": {
"200": {
"description": "Acknowledged",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"acknowledged": {
"type": "integer"
}
}
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Admin role required",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/users/{id}": {
"put": {
"tags": [
@@ -6993,6 +7118,117 @@
]
}
},
"/api/v1/auth/email/verify/{token}": {
"get": {
"tags": [
"Auth"
],
"summary": "Validate an email-confirmation link",
"description": "Returns the target username and the address the link proves, so the confirmation page can render. 404 for anything not currently usable (never distinguishes expired from used from never-existed).",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Confirmation link is valid",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"username": {
"type": "string"
},
"email": {
"type": "string",
"format": "email"
}
}
}
}
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Invalid or expired confirmation link",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
},
"post": {
"tags": [
"Auth"
],
"summary": "Confirm an email address from its link",
"description": "Consumes the single-use link and installs the address on the account, marking it verified. Issues no session — it proves control of a mailbox, not of an account. Answers 404 for an unusable link AND for an address another account has since verified, deliberately: the two are indistinguishable to a caller so the endpoint cannot be used to test which addresses hold accounts.",
"parameters": [
{
"name": "token",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Address confirmed",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Message"
}
}
}
},
"400": {
"description": "Bad Request"
},
"404": {
"description": "Invalid, expired, superseded, or already-used confirmation link",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"429": {
"description": "Too many attempts",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
}
}
},
"/api/v1/auth/invite/{token}": {
"get": {
"tags": [
@@ -7385,6 +7621,191 @@
]
}
},
"/api/v1/auth/me/account/email": {
"patch": {
"tags": [
"Auth · Me"
],
"summary": "Request a new email address (self, any role)",
"description": "Stages the address and emails a confirmation link. The account keeps its current address until that link is used, so a mistyped address cannot redirect password-reset mail. Requires currentPassword when the account has a password; SSO-provisioned accounts with no password are exempt.",
"responses": {
"200": {
"description": "Address staged; a confirmation link was sent",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PendingEmail"
}
}
}
},
"400": {
"description": "Validation error, wrong current password, or already your address",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"429": {
"description": "Too many verification emails",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChangeEmailRequest"
}
}
}
}
}
},
"/api/v1/auth/me/account/email/pending": {
"delete": {
"tags": [
"Auth · Me"
],
"summary": "Abandon the pending email address",
"description": "Clears the staged address and retires its outstanding links, so a confirmation email already delivered can no longer install it.",
"responses": {
"200": {
"description": "Pending address cleared",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OkFlag"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/account/email/resend": {
"post": {
"tags": [
"Auth · Me"
],
"summary": "Re-send the confirmation link for the pending address",
"description": "",
"responses": {
"200": {
"description": "Confirmation link re-sent",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PendingEmail"
}
}
}
},
"400": {
"description": "No address is awaiting confirmation",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"401": {
"description": "Not authenticated",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"403": {
"description": "Forbidden"
},
"429": {
"description": "Too many verification emails",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/auth/me/account/identities": {
"get": {
"tags": [
@@ -9056,7 +9477,7 @@
"Auth"
],
"summary": "Request a password-reset link by email",
"description": "Emails a single-use, ~1h reset link to every active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Email is non-unique, so multiple accounts may each receive a link naming their username. Rate limited per IP.",
"description": "Emails a single-use, ~1h reset link to the active account on the address. Always returns the same generic 200 whether or not the email matches (no account enumeration). Rate limited per IP.",
"responses": {
"200": {
"description": "Generic acknowledgement (sent if the account exists)",
@@ -15229,6 +15650,45 @@
}
}
},
"email_verified": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "Whether the address above has been proved by opening a confirmation link."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"email_pending": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "email"
},
"nullable": {
"type": "boolean",
"example": true
},
"description": {
"type": "string",
"example": "An address the user has requested but not yet confirmed. It does NOT replace `email` until the confirmation link is used."
},
"example": {}
}
},
"status": {
"type": "object",
"properties": {
@@ -15288,6 +15748,250 @@
}
}
},
"ChangeEmailRequest": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"required": {
"type": "array",
"example": [
"email"
],
"items": {
"type": "string"
}
},
"properties": {
"type": "object",
"properties": {
"email": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "email"
},
"maxLength": {
"type": "number",
"example": 255
},
"example": {
"type": "string",
"example": "new@example.com"
}
}
},
"currentPassword": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "password"
},
"description": {
"type": "string",
"example": "Required when the account has a password. An address is where account recovery lands, so changing it is re-authenticated; an SSO-provisioned account with no password is exempt."
}
}
}
}
}
}
},
"PendingEmail": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "The address now awaiting confirmation. The account keeps its existing address until the emailed link is used."
},
"properties": {
"type": "object",
"properties": {
"email_pending": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "email"
},
"example": {
"type": "string",
"example": "new@example.com"
}
}
},
"emailed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "boolean"
},
"description": {
"type": "string",
"example": "False when outbound email is not configured or the send failed; the address stays staged so a resend can succeed later."
},
"example": {
"type": "boolean",
"example": true
}
}
},
"reason": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"nullable": {
"type": "boolean",
"example": true
},
"enum": {
"type": "array",
"example": [
"NOT_CONFIGURED",
"SEND_FAILED",
null
],
"items": {}
},
"description": {
"type": "string",
"example": "Why nothing was sent, when `emailed` is false."
},
"example": {}
}
}
}
}
}
},
"EmailDedupeEntry": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "object"
},
"description": {
"type": "string",
"example": "One account whose email address was cleared when addresses became unique, because an older account already held it."
},
"properties": {
"type": "object",
"properties": {
"id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 3
}
}
},
"user_id": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "integer"
},
"example": {
"type": "number",
"example": 42
}
}
},
"username": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"example": {
"type": "string",
"example": "someplayer"
}
}
},
"lost_address": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "email"
},
"example": {
"type": "string",
"example": "shared@example.com"
}
}
},
"cleared_at": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
}
}
},
"acknowledged_at": {
"type": "object",
"properties": {
"type": {
"type": "string",
"example": "string"
},
"format": {
"type": "string",
"example": "date-time"
},
"nullable": {
"type": "boolean",
"example": true
}
}
}
}
}
}
},
"OkFlag": {
"type": "object",
"properties": {