# Mobile Verification & Password Reset Guide

OTP-backed flows for signup verification (onboarding) and password reset.
Both share the same `/api/verification` machinery — only the wrapping flow
and the endpoints around it differ.

Companion to [`mobile-auth-integration.md`](../auth/mobile-auth-integration.md) and
[`mobile-onboarding-guide.md`](../user-onboarding/mobile-onboarding-guide.md).

## What's new

<!-- Newest first, plain language, max 5 entries. Every session that changes this
     guide adds one line here and drops the oldest past 5 — see CLAUDE.md rule 8. -->

- **2026-09-01** — Forgot-password will never tell you whether an email is registered — this is deliberate. An unknown email still reaches the OTP screen and fails there with the same generic error a wrong code gives. Don't build a "no account found with this email" message: the accounts it would reveal belong to children.

---

## Shared Headers

```
X-Client-Type: mobile
Content-Type: application/json
```

All endpoints below are **unauthenticated** (no Bearer token) — the caller
has no account yet (signup) or is locked out (reset).

---

## 1. Onboarding Verification

Status: **Email verification is OPTIONAL.** Wireframe shows a popup OTP step
between the "enter email" screen and the "set password" screen, but the
server does NOT gate `/complete` on it. Mobile can:

- Run the OTP popup for UX (proves the user owns the inbox), then continue
  the flow without sending `code` to `/api/onboarding/session`.
- Skip the popup entirely. `/api/onboarding/session` accepts `email` +
  `password` alone; `/complete` succeeds.

Either path produces a working account. `emailVerifiedAt` will be `null`
when the OTP is not threaded through to the session — that's expected today.

Each session uses **exactly one** of email or phoneNumber (XOR enforced
server-side). Phone-channel onboarding is not yet live (no SMS adapter).

### Step 1 — Request a code (popup screen)

```
POST /api/verification/send
```

```json
{
  "channel": "email",
  "identifier": "user@example.com",
  "purpose": "email_verification"
}
```

Response 200:

```json
{ "success": true, "data": { "expiresIn": 300 } }
```

Code lifetime: **5 minutes.** Max 5 sends per identifier per 15 min.

### Step 2 — Confirm the code (popup verify button)

```
POST /api/verification/confirm
```

```json
{
  "identifier": "user@example.com",
  "code": "123456",
  "purpose": "email_verification"
}
```

Response 200:

```json
{ "success": true }
```

The OTP is consumed (deleted server-side). On success, server **also**:

- Stamps `User.emailVerifiedAt = now()` if a user with that email exists.
- Stamps `OnboardingSession.emailVerifiedAt = now()` if an ACTIVE session
  with that email exists.
- Writes a short-lived `verified-contact:email:<email>` flag in the
  key-value store (10-min TTL).

The flag bridges the popup-before-session case (Step 3 has not run yet).
`POST /api/onboarding/session` consumes the flag on create and stamps the
new session row. Result: `emailVerifiedAt` lands in the DB regardless of
which order the mobile screens fire.

### Step 3 — Start onboarding session

```
POST /api/onboarding/session
```

```json
{
  "email": "user@example.com",
  "password": "SecurePass1"
}
```

`code` may be included if Step 2 was skipped — the server verifies inline
and stamps `emailVerifiedAt`. If Step 2 already ran, omit `code`: the
server picks up the `verified-contact` flag automatically.

If neither Step 2 ran nor `code` is supplied, `emailVerifiedAt` stays
`null` on the session. `/complete` still succeeds (verification is
advisory today).

Response 201:

```json
{
  "success": true,
  "data": {
    "onboardingToken": "<jwt>",
    "session": { "id": "...", "expiresAt": "..." }
  }
}
```

Store `onboardingToken` in SecureStore. Use it as `Authorization: Bearer <token>`
for all subsequent `/api/onboarding/*` calls until `POST /api/onboarding/complete`
exchanges it for a real access token.

### Step 4 — Continue onboarding

Use `PATCH /api/onboarding/session` and `POST /api/onboarding/complete` as
documented in [`mobile-onboarding-guide.md`](../user-onboarding/mobile-onboarding-guide.md).
No verification gate at `/complete`.

### Error responses

| Code  | When                                            | UX                                              |
|-------|-------------------------------------------------|-------------------------------------------------|
| 429   | Too many `/send` calls in window                | "Try again in N minutes"                        |
| 400 `INVALID_OTP`         | Wrong / expired code                | "Code incorrect or expired. Request a new one." |
| 400 `MAX_ATTEMPTS_EXCEEDED` | 3 wrong codes                     | Force user to request a new code.               |
| 409 `ACCOUNT_EXISTS`      | Email already registered            | Route to login screen.                          |

---

## 2. Password Reset (three steps)

Three screens, three endpoints. The OTP is verified on screen 2 and
exchanged for a single-use `resetToken`; the new password is collected on
screen 3 and submitted with that token. This matches the wireframe and lets
mobile show clear per-screen errors.

### Step 1 — Request a reset code (email screen)

```
POST /api/auth/forgot-password
```

```json
{ "email": "user@example.com" }
```

Response 200 **always** (anti-enumeration):

```json
{
  "success": true,
  "message": "If an account exists for that email, a reset code has been sent."
}
```

UX: show the same confirmation regardless. Do not reveal whether the email
is registered.

> **An unregistered email still advances to the OTP screen. That is the
> design, not a defect.** The endpoint cannot say "no account found" without
> handing an attacker a registered/not-registered oracle for any address they
> type — on a platform whose accounts belong to children, that answer is a
> list of which kids are here. The dead end happens one screen later: step 2
> returns `400 INVALID_OTP` (`"Invalid or expired verification code."`),
> because no code was ever sent. Ticket QA-filed as a bug on 2026-09-01;
> closed by-design — see DECISIONS 2026-09-01.
>
> Make the screen-1 copy carry its own uncertainty so the dead end isn't a
> surprise. Say **"If an account exists for that email, we've sent a 6-digit
> code."** — not "We sent a code to <email>", which promises an email that may
> never arrive. Give screen 2 a visible **"Use a different email"** back
> affordance so a user who mistyped can correct it without restarting the
> flow; that is the recovery path the generic response takes away.

### Step 2 — Verify the code (code screen)

```
POST /api/auth/verify-reset-code
```

```json
{
  "email": "user@example.com",
  "code": "123456"
}
```

Response 200:

```json
{
  "success": true,
  "data": {
    "resetToken": "MFRkX...base64url",
    "expiresIn": 600
  }
}
```

- `resetToken` is **single-use, 10-minute TTL.** Stored in SecureStore (or
  in-memory if the new-password screen is the very next screen).
- The OTP is consumed by this call — re-submitting the same code returns
  `INVALID_OTP`.

### Step 3 — Submit new password (new-password screen)

```
POST /api/auth/reset-password
```

```json
{
  "resetToken": "MFRkX...base64url",
  "newPassword": "NewSecurePass1"
}
```

Response 200:

```json
{
  "success": true,
  "message": "Password reset. Please log in with your new password."
}
```

On success the server revokes **all** refresh tokens + sessions for the
user. The user must re-login on every device — including the one that just
reset.

### Error responses

| Code  | When                                       | UX                                |
|-------|--------------------------------------------|-----------------------------------|
| 400 `INVALID_OTP`         | Bad code, expired, or unknown email   | "Code incorrect or expired."        |
| 400 `MAX_ATTEMPTS_EXCEEDED` | 3 wrong attempts on the OTP         | "Request a new code."               |
| 400 `RESET_TOKEN_INVALID` | resetToken expired, used, or unknown  | "Reset session expired. Start over."|
| 400 (zod)                 | Weak new password                     | Inline field errors.                |
| 429                       | Per-IP rate limit hit                 | Back off — wait an hour.            |

Password rules (same as register): min 8 chars, ≥1 uppercase, ≥1 lowercase,
≥1 digit.

### Mobile flow after success

1. Clear SecureStore tokens.
2. Navigate to login screen.
3. Pre-fill email; user enters new password.
4. Normal `POST /api/auth/login`.

---

## 3. Reference Snippets

### Helpers

```typescript
async function sendCode(
  identifier: string,
  purpose: "email_verification" | "password_reset",
) {
  const { data } = await axios.post(
    `${API_URL}/api/verification/send`,
    { channel: "email", identifier, purpose },
    { headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
  );
  return data.data.expiresIn; // seconds
}

// Onboarding popup verify (purely client-side UX — server has no record).
async function confirmEmailCode(identifier: string, code: string) {
  await axios.post(
    `${API_URL}/api/verification/confirm`,
    { identifier, code, purpose: "email_verification" },
    { headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
  );
}

async function startOnboardingSession(input: {
  email: string;
  password: string;
  code?: string;
}) {
  const { data } = await axios.post(
    `${API_URL}/api/onboarding/session`,
    input,
    { headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
  );
  return data.data.onboardingToken;
}

// Password reset — 3-step.
async function forgotPassword(email: string) {
  await axios.post(
    `${API_URL}/api/auth/forgot-password`,
    { email },
    { headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
  );
}

async function verifyResetCode(email: string, code: string) {
  const { data } = await axios.post(
    `${API_URL}/api/auth/verify-reset-code`,
    { email, code },
    { headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
  );
  return data.data.resetToken as string; // ttl 600s
}

async function resetPassword(resetToken: string, newPassword: string) {
  await axios.post(
    `${API_URL}/api/auth/reset-password`,
    { resetToken, newPassword },
    { headers: { "X-Client-Type": "mobile", "Content-Type": "application/json" } },
  );
}
```

### curl

```bash
# 1. Trigger reset code
curl -X POST http://localhost:3001/api/auth/forgot-password \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"email":"user@example.com"}'

# 2. Verify code → resetToken
curl -X POST http://localhost:3001/api/auth/verify-reset-code \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"email":"user@example.com","code":"123456"}'

# 3. Submit new password
curl -X POST http://localhost:3001/api/auth/reset-password \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: mobile" \
  -d '{"resetToken":"<from step 2>","newPassword":"NewSecurePass1"}'
```

---

## 4. Gotchas

- **OTP storage**: codes are SHA-256 hashed at rest, 5-min TTL, 3 attempts.
  After 3 wrong attempts the code is wiped — user must request a new one.
- **Generation rate limit**: 5 codes per `(identifier, purpose)` per 15 min.
  6th `/send` (or `/forgot-password`) silently no-ops to prevent abuse.
- **Per-IP rate limits**:
  - `POST /api/auth/forgot-password` — 5 / hour / IP.
  - `POST /api/auth/verify-reset-code` — 10 / hour / IP.
  - `POST /api/auth/reset-password` — 10 / hour / IP.
- **resetToken is single-use.** It's deleted from the server's store the
  moment `/reset-password` reads it — even if the subsequent password
  update fails. On any error after step 2, re-run the full 3-step flow.
- **resetToken expiry = 10 minutes.** Storing it persistently is fine, but
  it'll fail with `RESET_TOKEN_INVALID` once expired.
- **Email delivery requires `RESEND_API_KEY` + `ADMIN_EMAIL`** in the API
  env. Without them codes log to API stdout (`ConsoleChannel` fallback).
  Useful for local dev — surface the code from container logs.
- **Resend requires a verified sender domain** before sending to arbitrary
  recipients. Until verified, `ADMIN_EMAIL` must equal your Resend signup
  email and you can only email yourself.
- **Onboarding verification is advisory at the gate** but **persisted** in
  the DB when it happens. Popup-verify (`/verification/confirm`) stamps
  `User.emailVerifiedAt` / `OnboardingSession.emailVerifiedAt` and writes
  a 10-minute store flag that `/onboarding/session` consumes on create.
  `/complete` does not check `emailVerifiedAt`, so an account still goes
  live if verification was skipped — but later trust features (feed
  weighting, parental flow) can read the column reliably.
- **Phone signup is not live.** `PHONE_VERIFICATION` purpose is not in
  the unauthenticated-allowed set on `/api/verification/send`, and no real
  SMS adapter is registered. Email is the only working channel.
- **Identifier must match byte-for-byte** between `/verification/send`,
  `/verification/confirm`, and any onboarding `/session` `code` payload.
  The OTP store key is `otp:email_verification:<identifier>` —
  `User@x.com` ≠ `user@x.com`. Normalize (lowercase + trim) on the client
  before every call.
