cleanup phases 6

This commit is contained in:
hamid
2026-08-02 18:48:32 +03:30
parent e2db97392a
commit 51e86a1e5f
239 changed files with 118 additions and 70 deletions
@@ -0,0 +1,141 @@
# Contract — Identity & Auth (backend phase b2)
> One-line: phone-OTP login, revocable refresh-token sessions with rotation + reuse detection, the
> current-user profile (`/me`) and public role selection. Assumes
> [`../conventions/api-conventions.md`](../conventions/api-conventions.md) +
> [`../conventions/money-and-types.md`](../conventions/money-and-types.md). Source of truth for the
> machine schema: [`../openapi/`](../openapi/README.md) (`swagger.v1.json`, refreshed for b2).
**Status:** live as of backend-phase-2 · **Frontend consumer:** frontend-phase-f1-b2
> **Exact paths** (snake_case transformer output — differs from early sketches that showed
> `otp/request` / `me/role`): `auth/request_otp`, `auth/verify_otp`, `auth/refresh`, `auth/logout`,
> `me`, `me/select_role`. JSON bodies are **camelCase** (confirmed against the live envelope).
## Enums used
- `role` (self-selectable): `customer` | `nurse` — a user may hold **both**. Admin sub-roles
(`admin`, `support`, `finance`, `moderation`, `super_admin`) exist but are **internal-only**;
sending one to `me/select_role` returns `403`.
- `gender`: `male` | `female` — load-bearing for same-gender matching; **null until the profile flow
(b3) sets it**; never defaulted.
- `nurseVerificationStatus`: `not_started` until the b6 verification pipeline exists.
## Endpoints
### `POST api/v1/auth/request_otp`
- **Purpose:** send a one-time login code to an Iranian mobile; silently creates an
inactive-until-verified account for a new phone.
- **Auth:** none · **Rate-limited:** yes (`otp` per-IP policy → `429`) · **Idempotency key:** no
- **Request body:**
```json
{ "phone": "09121112233" }
```
Accepts `+98…`/`0098…`/Persian digits; normalized server-side to `09xxxxxxxxx`.
- **Success `200` payload (`data`):**
```json
{ "otpSent": true, "resendAvailableInSeconds": 120 }
```
Inside the per-phone resend window the same shape returns with `otpSent: false` and the remaining
seconds. **The shape never reveals whether the phone already had an account** (no enumeration).
- **Failure cases:** `400` invalid phone; `429` over the per-IP OTP limit.
- **Notes:** in the mock environment the code is written to the server log (`ISmsSender` mock); the
OTP itself expires on the TOTP provider's window (~3 min).
### `POST api/v1/auth/verify_otp`
- **Purpose:** verify the code, activate the account, mint the token pair + a revocable session.
- **Auth:** none · **Rate-limited:** yes (`otp` policy) · **Idempotency key:** no
- **Request body:**
```json
{ "phone": "09121112233", "code": "466036", "deviceInfo": "iPhone 15 / app 1.0 (optional)" }
```
- **Success `200` payload (`data`):** `AuthTokensResult` (below). `isNewUser: true` on the first
successful verify; `roles` is empty for a fresh user — **route them to role selection**.
- **Failure cases:** `400` wrong/expired code (same safe message whether the phone exists or the
code is wrong — no enumeration); `400` "too many failed attempts" after `auth_otp_max_attempts`
wrong codes (request a new OTP to reset); `429` over limit.
### `POST api/v1/auth/refresh`
- **Purpose:** rotate the refresh token: the presented session is revoked, a new pair is issued.
- **Auth:** none required (`RequireTokenWithoutAuthorization` — the refresh token is the
credential) · **Rate-limited:** yes (`auth` policy) · **Idempotency key:** no
- **Request body:**
```json
{ "refreshToken": "<64-hex refresh token>", "deviceInfo": "optional" }
```
- **Success `200` payload (`data`):** `AuthTokensResult` (`isNewUser` always `false`).
- **Failure cases:** `401` unknown token; `401` expired session; **`401` reuse-detection** — a token
that hashes to an *already-revoked* session is treated as stolen: **all** of that user's sessions
are revoked (logout-everywhere) and the client must sign in again.
### `POST api/v1/auth/logout`
- **Purpose:** revoke the session server-side and kill outstanding access tokens.
- **Auth:** authenticated (Bearer) · **Rate-limited:** no
- **Request body:** (send `{}` at minimum)
```json
{ "refreshToken": "optional — revoke just this session", "everywhere": false }
```
With `everywhere: true` **or no `refreshToken`**, every active session is revoked.
- **Success `200`:** empty envelope (no `data`).
- **Failure cases:** `401` unauthenticated.
- **Notes:** the security stamp rotates on every logout, so **all** of the user's outstanding access
tokens fail immediately (other devices recover by refreshing their still-valid refresh tokens).
### `GET api/v1/me`
- **Purpose:** the signed-in user's identity, roles and onboarding state (drives the role router).
- **Auth:** authenticated · **Rate-limited:** no
- **Success `200` payload (`data`):** `MeResult` (below).
- **Failure cases:** `401` missing/expired/stamp-invalidated token.
### `POST api/v1/me/select_role`
- **Purpose:** self-assign a public actor role. Idempotent; `customer` and `nurse` can coexist.
- **Auth:** authenticated · **Rate-limited:** no
- **Request body:**
```json
{ "role": "customer" }
```
- **Success `200` payload (`data`):** the updated `MeResult`.
- **Failure cases:** **`403` for any non-public role** (`super_admin`, `support`, …); `400` empty
role; `401` unauthenticated.
- **Notes:** role claims live inside the (JWE) access token — after selecting a role, **refresh the
token pair** so subsequent role-gated calls carry the new claim. `/me` reads roles from the DB and
reflects the change immediately.
## Shared shapes
- `AuthTokensResult`:
| field | type | notes |
|---|---|---|
| `accessToken` | string | JWE bearer token (send as `Authorization: Bearer …`) |
| `refreshToken` | string | 64-hex opaque token; **store securely**, only its hash exists server-side |
| `accessExpiresAt` | ISO-8601 | absolute access-token expiry |
| `refreshExpiresAt` | ISO-8601 | session expiry (`auth_session_ttl_days`, default 30d) |
| `isNewUser` | bool | `true` only on the first successful verify for the phone |
| `roles` | string[] | active roles; empty ⇒ send the user to role selection |
- `MeResult`:
| field | type | notes |
|---|---|---|
| `id` | int | user id |
| `phone` | string | **always masked** (`0912*****33`) — full phone is never returned |
| `firstName` / `lastName` | string \| null | null until profile (b3) |
| `gender` | `male`/`female` \| null | null until profile (b3); never defaulted |
| `isActive` | bool | phone-verified account |
| `roles` | string[] | active roles (revoked grants excluded) |
| `hasCustomerProfile` / `hasNurseProfile` | bool | `false` until b3 populates the profile tables |
| `nurseVerificationStatus` | string | `not_started` until b6 |
- `RequestOtpResult`: `otpSent` (bool) + `resendAvailableInSeconds` (int).
## Changelog
- b2 — initial contract (phone-OTP auth, sessions, `/me`, role selection).
---
## Refinement phase 3 additions (REQ-002/003)
- **`RequestOtpResult`** gains `codeLength` (6) and `expiresInSeconds` (60) so the OTP box count + expiry
hint are contract-driven.
- **`verify_otp` failures** now carry a stable machine `code` on the envelope: `otp_invalid` (wrong **or**
expired — collapsed for anti-enumeration) and `otp_locked` with `data: { retryAfterSeconds }` on lockout.
The coded-error envelope is `{ isSuccess: false, statusCode: 400, message, code, data? }` (the optional
`code` is omitted from every other response).