# Platform auth integration

**Scope:** PDF2HTML composition of `@platform-modules/auth` and `@platform-modules/auth-react`.
**Evidence date:** 2026-08-23. **Measured** means read from the live checkouts; this document is not implementation or deployment evidence.

**Contract status: SPECIFIED / BLOCKED.** It stays so until the routes, DTOs, adapter guarantees, and production-equivalent browser evidence defined here exist. Requirements are not evidence.

## Measured package boundary and blockers

| Package | Measured version | Usable public surface | Status |
|---|---:|---|---|
| `@platform-modules/auth` | `0.2.2` | core plus `/engine-custom`, `/engine-better-auth`, `/otp-email`, `/api-keys`, `/oauth-provider` | Built, not version-matched verified. The existing test log is for 0.2.0 and includes four timeouts. |
| `@platform-modules/auth-react` | `0.0.2` | package root | Built and locally tested; no real PDF2HTML split-origin evidence. |

`createAuth(engine)` exposes only `getSession`, `signIn`, `signOut`, and `refresh`. Registration and password-reset HTTP behavior is app-owned. The custom engine implements `createUser` and `setPassword`; Better Auth does not provide an equivalent password/refresh contract. Choose and review one engine; never mix tokens, hashes, rows, or migrations between engines.

### Exact API-contract implementation blockers

The MVP base URL is `https://api.press.zone/v1`; paths below are route paths relative to that base. `/v1` is the base prefix, **not** part of the auth namespace. Route constants and cookie paths must use the `/auth` namespace exactly and must not duplicate the base prefix.

The live `packages/api-contracts/src/index.ts` does **not** represent this wire contract:

* its `SessionResponse` has the wrong envelope/version shape, numeric balances, and no refresh-CSRF generation;
* it has no route schemas or path constants;
* `RegisterRequest` omits `terms_version` and `privacy_version`;
* `LoginRequest` is merely aliased to `RegisterRequest`;
* it has no refresh, CSRF-bootstrap, refresh-CSRF-bootstrap, or password-reset DTOs;
* its error code/status set lacks `CSRF_REJECTED`, `STATE_CONFLICT`, and `DEPENDENCY_UNAVAILABLE` required here.

These are exact **BLOCKED implementation targets**, not descriptions of current schemas:

```ts
type ApiVersion = "2026-08-23"
type RequestId = string // UUID

type Success<T> = { version: ApiVersion; request_id: RequestId; data: T }
type Failure = { version: ApiVersion; request_id: RequestId; error: {
  code: "INVALID_REQUEST" | "INVALID_CREDENTIALS" | "AUTH_REQUIRED" |
        "CSRF_REJECTED" | "FORBIDDEN" | "STATE_CONFLICT" | "IDEMPOTENCY_CONFLICT" |
        "RATE_LIMITED" | "DEPENDENCY_UNAVAILABLE"
  message: string // exact fixed public message for code
  retryable: boolean
  field_errors: Record<string, string>
}}

type RegisterRequest = { email: string; password: string; terms_version: string; privacy_version: string }
type LoginRequest = { email: string; password: string }
type EmptyRequest = Record<string, never>
type PasswordResetRequestRequest = { email: string }
type PasswordResetCompleteRequest = { token: string; new_password: string }
type RefreshRequest = { refresh_csrf_generation: number } // safe integer >= 1
type LogoutRequest = { refresh_csrf_generation: number } // safe integer >= 1

type SessionSummary = {
  user: { id: string; email: string; role: "customer" | "admin" }
  csrf_token: string
  spendable_credits: string // canonical non-negative decimal
  credit_deficit: string    // canonical non-negative decimal
}
type LoginResponse = SessionSummary & { refresh_csrf_token: string; refresh_csrf_generation: number }
type RegisterResponse = LoginResponse
type RefreshResponse = LoginResponse
type SessionResponse = SessionSummary
type CsrfBootstrapResponse = { csrf_token: string }
type RefreshCsrfBootstrapResponse = { refresh_csrf_token: string; refresh_csrf_generation: number }
type LogoutResponse = { logged_out: true }
type PasswordResetRequestResponse = { accepted: true }
type PasswordResetCompleteResponse = { completed: true }
```

Add exact path constants and request/response schemas for all nine routes:

| MVP identity | Method and route path | Request DTO | Success DTO |
|---|---|---|---|
| `API-001` | `POST /auth/register` | `RegisterRequest` | `RegisterResponse` |
| `API-002` | `POST /auth/login` | `LoginRequest` | `LoginResponse` |
| `API-003` | `POST /auth/logout` | `LogoutRequest` | `LogoutResponse` |
| `API-004` amendment | `GET /auth/session` | none | `SessionResponse` |
| MVP auth amendment | `POST /auth/csrf` | `EmptyRequest` | `CsrfBootstrapResponse` |
| MVP auth amendment | `POST /auth/refresh-csrf` | `EmptyRequest` | `RefreshCsrfBootstrapResponse` |
| MVP auth amendment | `POST /auth/refresh` | `RefreshRequest` | `RefreshResponse` |
| MVP auth amendment | `POST /auth/password-reset/request` | `PasswordResetRequestRequest` | `PasswordResetRequestResponse` |
| MVP auth amendment | `POST /auth/password-reset/complete` | `PasswordResetCompleteRequest` | `PasswordResetCompleteResponse` |

This nine-route table is the authoritative MVP amendment target and every row remains **BLOCKED** until its contract and schema land. Exact old → new changes are: API-001 keeps `POST /auth/register` but replaces the old `{email,password}` request and implementation-defined response with `RegisterRequest` and `Success<RegisterResponse>`, requires exact Origin plus `Idempotency-Key`, and bootstraps three cookies plus raw refresh-CSRF body/header proof; API-002 keeps `POST /auth/login` but replaces the old `RegisterRequest` alias/implementation-defined response with distinct `LoginRequest` and `Success<LoginResponse>`, the same Origin/idempotency and three-cookie plus body/header proof semantics; API-003 keeps `POST /auth/logout` but replaces implicit cookie-only auth/empty response with `LogoutRequest`, HttpOnly refresh-cookie authorization, refresh-CSRF header+generation proof, `Idempotency-Key`, and `Success<LogoutResponse>`; API-004 moves `GET /session` to `GET /auth/session`, replaces the old unversioned session envelope/numeric balances with `Success<SessionResponse>` and canonical decimal strings, requires access cookie plus exact Origin, and is read-only with no CSRF or cookie rotation. The five added authoritative amendment routes are `/auth/csrf`, `/auth/refresh-csrf`, `/auth/refresh`, `/auth/password-reset/request`, and `/auth/password-reset/complete` with exactly the DTO/auth/CSRF semantics in this document. No old alias, path, envelope, numeric balance, cookie-CSRF refresh proof, or implicit response remains conforming.

## Cookies, Origin, CORS, cache, and body rules

| Cookie | Issue attributes | Clear attributes |
|---|---|---|
| `__Host-pdf2html_access` | `Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=900`; equivalent `Expires`; no `Domain` | same flags/path; `Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT` |
| `__Host-pdf2html_csrf` | `Secure; SameSite=Lax; Path=/; Max-Age=900`; equivalent `Expires`; no `Domain`, no `HttpOnly` | same flags/path; zero expiry |
| `__Secure-pdf2html_refresh` | `Secure; HttpOnly; SameSite=Lax; Path=/auth; Max-Age=2592000`; equivalent `Expires`; no `Domain` | same flags/path; zero expiry |

Each clear is a separate `Set-Cookie`, never comma-folded. Production rejects startup unless the API origin is exactly `https://api.press.zone`. Development uses separately named cookies, never a silent weakening of production prefixes.

All requests from the browser use `credentials: include`. Every actual auth request requires literal `Origin: https://tools.press.zone`, including `GET /auth/session`; absent, `null`, malformed, or any other Origin fails before parsing, authentication, rate limiting, or mutation. Allowed-origin actual responses always include:

```
Access-Control-Allow-Origin: https://tools.press.zone
Access-Control-Allow-Credentials: true
Vary: Origin
Cache-Control: no-store, private
Pragma: no-cache
Referrer-Policy: no-referrer
Content-Type: application/json
```

Disallowed-origin actual responses omit both access-control allow headers but retain `Vary`, cache, pragma, referrer, and JSON content type. `Access-Control-Expose-Headers: X-Refresh-CSRF-Token` is present only on successful register, login, refresh-CSRF, and refresh responses; it is absent otherwise. No response reflects Origin, uses `*`, redirects, or puts secrets in URLs/headers other than the specified request CSRF headers.

Every response, including OPTIONS, uses exactly one MVP JSON envelope. Let:

* `S(data)` be `{"version":"2026-08-23","request_id":"<uuid>","data":data}`;
* `E(code)` be `{"version":"2026-08-23","request_id":"<uuid>","error":{"code":code,"message":"<fixed message>","retryable":<fixed>,"field_errors":{}}}`;
* fixed messages/retryability be: `INVALID_REQUEST` → `Invalid request`/false; `INVALID_CREDENTIALS` → `Invalid credentials`/false; `AUTH_REQUIRED` → `Authentication required`/false; `CSRF_REJECTED` → `CSRF rejected`/false; `FORBIDDEN` → `Forbidden`/false; `STATE_CONFLICT` → `Request conflict`/false; `IDEMPOTENCY_CONFLICT` → `Idempotency key conflict`/false; `RATE_LIMITED` → `Too many requests`/true; `DEPENDENCY_UNAVAILABLE` → `Service temporarily unavailable`/true.

A schema failure may populate `field_errors` only with allow-listed public DTO field names and fixed messages; malformed JSON, content-type, origin, method, auth, CSRF, dependency, and internal failures use `{}`. Internal exceptions are normalized to `503 DEPENDENCY_UNAVAILABLE`; no `500` body exposes internals.

### Authentication and CSRF prerequisites

* Register/login/password-reset request are public but exact-Origin protected. MVP API-001, API-002, and API-003 additionally require `Idempotency-Key`; the app stores scope+key+canonical-request-hash and exact original status/body for at least 72 hours, returns that outcome for the same hash, and returns `409 E(IDEMPOTENCY_CONFLICT)` with zero auth mutation for a different hash. Login/register success establishes access, refresh, ordinary CSRF, and refresh CSRF.
* `/auth/session` requires valid access and is read-only; it never rotates cookies or CSRF.
* `/auth/csrf` requires valid access and exact Origin, requires no prior CSRF, durably replaces only ordinary session-bound CSRF.
* `/auth/refresh-csrf` requires a valid active refresh credential and a side-effect-free pre-rotation lookup. It requires no access or prior CSRF. It changes only the family-bound synchronizer token hash/generation, never refresh bytes or expiry. The raw token exists only in the JSON response and the identical `X-Refresh-CSRF-Token` response header; no refresh-CSRF cookie is set.
* `/auth/refresh` and `/auth/logout` require the refresh cookie, `X-Refresh-CSRF-Token`, and the same safe-integer generation in `refresh_csrf_generation` body. The header is checked timing-safely against the session/family-bound server hash and generation before rotation/revocation; there is no API CSRF cookie and therefore no browser-monotonic `Set-Cookie` race.
* Password-reset complete invalidates all user sessions and both CSRF classes only after the reset token and new password validate and the password update commits.
* Ordinary cookie-authenticated mutations outside this auth table require `X-CSRF-Token`, double submit, and session-bound stored hash. Bearer authentication is exempt only after bearer authentication succeeds.

## Exact OPTIONS matrices

Successful preflight response body is `S({"preflight":true})`, status `200`, and includes the common allowed-origin headers plus `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, and `Access-Control-Max-Age: 0`. It sets no cookie and performs no parse/auth/rate/dependency/store call. Requested-header comparison is a trimmed, case-insensitive set comparison; duplicates normalize; any extra name is forbidden. A forbidden preflight has status `403`, body `E("FORBIDDEN")`, only `Vary: Origin` plus common cache/pragma/referrer/JSON headers, no allow/max-age headers, no cookie, and zero side effects.

| Route | Exact successful preflight request | Exact success response |
|---|---|---|
| `/auth/register` | Origin exact; ACR-Method `POST`; ACR-Headers set `{content-type,idempotency-key}` | allow-methods `POST`; allow-headers `Content-Type, Idempotency-Key` |
| `/auth/login` | same | same |
| `/auth/logout` | Origin exact; ACR-Method `POST`; ACR-Headers set `{content-type,idempotency-key,x-refresh-csrf-token}` | allow-methods `POST`; allow-headers `Content-Type, Idempotency-Key, X-Refresh-CSRF-Token` |
| `/auth/session` | Origin exact; ACR-Method `GET`; ACR-Headers absent/empty | allow-methods `GET`; allow-headers absent |
| `/auth/csrf` | Origin exact; ACR-Method `POST`; ACR-Headers set `{content-type}` | allow-methods `POST`; allow-headers `Content-Type` |
| `/auth/refresh-csrf` | same | same |
| `/auth/refresh` | Origin exact; ACR-Method `POST`; ACR-Headers set `{content-type,x-refresh-csrf-token}` | allow-methods `POST`; allow-headers `Content-Type, X-Refresh-CSRF-Token` |
| `/auth/password-reset/request` | Origin exact; ACR-Method `POST`; ACR-Headers set `{content-type}` | allow-methods `POST`; allow-headers `Content-Type` |
| `/auth/password-reset/complete` | same | same |

The following is the exact per-route failure matrix; every cell means status/body above, failure CORS headers above, and **zero side effects** (no parser, auth, limiter, dependency, DB, cookie, email, token, audit-success, rotation, revocation, or reset call).

| Route | absent/null/malformed/disallowed Origin | absent/malformed ACR-Method | wrong ACR-Method | forbidden/extra ACR-Header |
|---|---|---|---|---|
| `/auth/register` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/login` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/logout` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/session` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/csrf` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/refresh-csrf` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/refresh` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/password-reset/request` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |
| `/auth/password-reset/complete` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` | `403 E(FORBIDDEN)` |

## Exact actual-request matrices

For every row: allowed Origin receives the common allowed-origin/cache headers; an Origin-failure row receives the disallowed-origin headers. Any non-success row guarantees zero business side effects and no `Set-Cookie`, except the explicitly named invalid-auth cookie-clear outcome. Parse/order is: method → Origin → content type → JSON syntax → DTO schema → limiter → auth/CSRF → dependency/business commit → response. Wrong-method responses do not advertise `Allow` because only preflight is the CORS method advertisement.

### `POST /auth/register`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success | `201 S(RegisterResponse)` | user/session plus all three cookies commit once |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| DTO schema failure | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid Idempotency-Key | `400 E(INVALID_REQUEST)` | zero |
| same key with different canonical request hash | `409 E(IDEMPOTENCY_CONFLICT)` | zero |
| invalid/conflicting registration or stale terms/privacy | `400 E(INVALID_REQUEST)` | zero; no enumeration |
| auth/CSRF failure | not applicable before success; Origin is bootstrap defense | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | no partial user/session/cookie state |

### `POST /auth/login`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success | `200 S(LoginResponse)` | session plus all three cookies commit once |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| DTO schema failure | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid Idempotency-Key | `400 E(INVALID_REQUEST)` | zero |
| same key with different canonical request hash | `409 E(IDEMPOTENCY_CONFLICT)` | zero |
| invalid credentials/auth failure | `401 E(INVALID_CREDENTIALS)` | zero; identical account/timing surface |
| CSRF failure | not applicable before success; Origin is bootstrap defense | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | no session/cookie state |

### `POST /auth/logout`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| refresh cookie absent | `200 S({"logged_out":true})` | idempotently clear all three cookies; no lookup-derived revocation claim or family mutation |
| refresh cookie malformed or unknown (no credential/ledger row) | `200 S({"logged_out":true})` | idempotently clear all three cookies; no family mutation or replay claim |
| known credential expired, or family already inactive/expired | `200 S({"logged_out":true})` | idempotently clear all three cookies; no new revocation claim |
| known credential consumed/replayed | `200 S({"logged_out":true})` | after proof-independent ledger classification, idempotently clear all three cookies; family remains/re-enters revoked, but no CSRF failure can mask replay |
| active family and current refresh credential with exact refresh-CSRF proof | `200 S({"logged_out":true})` | family/session revoked, CSRF rows invalidated, all three cookies cleared |
| active family and current credential but missing/stale generation or token, or header/stored-hash mismatch | `403 E(CSRF_REJECTED)` | zero; no clears, mutation, reuse decision, or revocation |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero, no clears |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| DTO schema failure | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid Idempotency-Key | `400 E(INVALID_REQUEST)` | zero |
| same key with different canonical request hash | `409 E(IDEMPOTENCY_CONFLICT)` | zero |
| missing/stale token or generation, or header/hash mismatch | `403 E(CSRF_REJECTED)` | zero; no clears, reuse decision, or revocation |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | zero; no unproven revocation/clears |

Logout's immutable evaluation order is: method → Origin → content type → JSON/DTO → idempotency lookup → limiter → side-effect-free refresh-cookie classification. Classification is complete and disjoint, in this precedence: (1) absent; (2) malformed or unknown; (3) known expired or family inactive/expired; (4) known consumed/replayed; (5) known current credential in an active family. Classes 1–3 return the idempotent 200 clear-only outcome without family mutation. Class 4 is classified from the consumed-token ledger before CSRF evaluation, preserves/atomically establishes family revocation, and returns idempotent 200 clears; replay can never be downgraded to CSRF mismatch. Only class 5 evaluates body generation, raw header token, timing-safe stored-hash equality, and current generation: mismatch is 403 with no mutation or clears; exact proof permits one CAS that revokes family/session and invalidates both CSRF classes. Persist the exact idempotent outcome before building clears and sending. Every family write, including bootstrap, refresh, logout, reset invalidation, and exact-outcome recovery, is guarded by a server compare-and-swap on family identity, active state, and current generation; zero-row CAS is `409 E(STATE_CONFLICT)` unless an exact committed idempotent outcome is recoverable.

### `GET /auth/session`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success | `200 S(SessionResponse)` | read only; no `Set-Cookie` |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing Content-Type with no body | governed by the matching success/auth row; Content-Type is not required for GET | read only |
| Content-Type present but unsupported | `400 E(INVALID_REQUEST)` | zero |
| request body present or malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| query/body schema failure | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid/expired access auth | `401 E(AUTH_REQUIRED)` | zero; invalid-auth outcome clears access and ordinary-CSRF cookies only |
| CSRF failure | not applicable to read-only GET | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | zero |

### `POST /auth/csrf`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success | `200 S({"csrf_token":"<raw>"})` | replace stored ordinary hash and ordinary cookie only |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| non-empty/schema-invalid body | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid/expired access auth | `401 E(AUTH_REQUIRED)` | zero; invalid-auth clears access and ordinary-CSRF cookies only |
| CSRF failure | no prior CSRF required | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | old state remains current unless one atomic commit invalidated it; no token/cookie emitted |

### `POST /auth/refresh-csrf`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success | `200 S(RefreshCsrfBootstrapResponse)` | CAS installs family-bound synchronizer token hash and generation; raw token is returned in body and identical response header; no CSRF cookie; refresh credential byte/expiry unchanged |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| non-empty/schema-invalid body | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid/expired refresh auth | `401 E(AUTH_REQUIRED)` | clear all three cookies and invalidate applicable CSRF rows; no token field |
| CSRF failure | no prior CSRF required | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | no response token/header; refresh unchanged; committed generation, if any, remains authoritative |

### `POST /auth/refresh`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success/committed recovery | `200 S(RefreshResponse)` | atomically recover/rotate auth plus ordinary and refresh CSRF; emit access, ordinary-CSRF, and HttpOnly refresh cookies; raw refresh-CSRF only in body/header |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| DTO schema failure | `400 E(INVALID_REQUEST)` | zero |
| missing/invalid/expired refresh auth or proven consumed-token reuse after CSRF success | `401 E(AUTH_REQUIRED)` | revoke if proven reuse; invalidate records; clear all three cookies |
| missing/stale token or generation, or header/hash mismatch | `403 E(CSRF_REJECTED)` | zero; specifically no rotation, reuse detection, or revocation |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal/indeterminate outcome | `503 E(DEPENDENCY_UNAVAILABLE)` | no blind retry/rotation and no credentials exposed unless exact committed outcome is recoverable |

### `POST /auth/password-reset/request`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success, existing or absent account | `202 S({"accepted":true})` | indistinguishable bounded enqueue; no auth cookies |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| DTO schema failure | `400 E(INVALID_REQUEST)` | zero |
| auth/CSRF failure | not applicable; exact Origin required | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero and non-enumerating |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | no partial token/email claim |

### `POST /auth/password-reset/complete`

| Case | Status and body | Cookies/side effects |
|---|---|---|
| success | `200 S({"completed":true})` | password commit, all user sessions/CSRF invalidated; all three browser cookies cleared |
| wrong method | `400 E(INVALID_REQUEST)` | zero |
| absent/disallowed Origin | `403 E(FORBIDDEN)` | zero |
| missing/unsupported Content-Type | `400 E(INVALID_REQUEST)` | zero |
| malformed JSON | `400 E(INVALID_REQUEST)` | zero |
| DTO schema failure | `400 E(INVALID_REQUEST)` | zero |
| invalid/expired/used reset token | `400 E(INVALID_REQUEST)` | zero and non-enumerating |
| auth/CSRF failure | reset token is route authorization; no session CSRF prerequisite | zero |
| rate failure | `429 E(RATE_LIMITED)` | zero |
| dependency/internal failure | `503 E(DEPENDENCY_UNAVAILABLE)` | no partial password/session state and no clears |

## Durable refresh generation and multi-tab ordering

Each refresh family stores `refreshCsrfGeneration` as a durable safe integer beginning at 1 and increasing by exactly one. It is returned beside every raw token in login/register, refresh-CSRF bootstrap, and refresh responses. Refresh/logout send both `X-Refresh-CSRF-Token: <raw>` and JSON `refresh_csrf_generation:<n>`.

Bootstrap uses compare-and-swap: read active family at generation `g`; generate raw synchronizer token; atomically `UPDATE ... SET hash=H(token), generation=g+1 WHERE family_id=? AND generation=g AND active`; only the winner returns `(token,g+1)` in the response body and identical response header, never a cookie. A loser re-reads and attempts at most one new CAS; if it loses again, return `409 E(STATE_CONFLICT)` with no token/cookie. The server accepts refresh/logout only when body generation equals the durable current generation and header/hash match timing-safely.

The browser has exactly one shared atomic record in IndexedDB database `pdf2html-auth`, version 1, object store `family`, key `current`: `{key:"current", family_id:string|null, generation:safe-integer>=0, refresh_csrf_token:string|null, auth_epoch:safe-integer>=0, last_operation_id:UUID, state:"anonymous"|"active"|"invalid", updated_at:UTC}`. Database creation atomically installs the anonymous generation-0 record. Every read-modify-write is one `readwrite` transaction over that key: read the current record, compare the expected `(auth_epoch,family_id,generation)`, and either put the complete successor or abort with `STATE_CONFLICT`; missing/corrupt/partial records recover only by atomically replacing them with `invalid`, incrementing `auth_epoch`, clearing token/family, and requiring a locked server bootstrap/login. No localStorage copy is authoritative. After transaction commit, publish `{auth_epoch,family_id,generation,last_operation_id,state}` on `BroadcastChannel("pdf2html:auth-family")`; listeners use it only as an invalidation notice and re-read IndexedDB. Startup and `pageshow` re-read the record.

**Every operation that can emit or clear any auth cookie is serialized by the same exclusive Web Lock** `pdf2html:auth-family`: register, login, refresh-CSRF bootstrap, refresh, logout, password-reset completion, ordinary-CSRF bootstrap, and invalid-auth clearing from session/CSRF/bootstrap/refresh responses. The callback holds the lock from before fetch until the user agent has applied all `Set-Cookie` fields, the response body/header proofs have been validated, and the IndexedDB transaction and broadcast commit. Unsupported Web Locks or IndexedDB is an explicit **BLOCKED unsupported-browser** outcome; BroadcastChannel/localStorage leases are not substitutes.

Before each fetch, capture `(auth_epoch,family_id,generation)` and operation UUID. After headers/body arrive **and after browser cookie application**, re-read the record in the same locked callback. A response may update the record only when its captured tuple still equals current, its operation is still pending, and its returned generation is strictly greater (or the route's defined clear transition increments `auth_epoch`). Older/equal or wrong-epoch responses are stale and their body state is discarded. Crucially, serialization extends through browser cookie application, so no later response that was initiated under an older epoch can apply cookies after a newer clear/rotation; any fetch not created inside this lock is a conformance failure. Connection loss after a server commit is resolved only by exact committed-outcome recovery while still locked; otherwise the client commits `invalid`, increments `auth_epoch`, performs the route-defined cookie clears under the lock, and exposes BLOCKED/reauthentication—never a blind retry. This closes delayed `Set-Cookie` overwrite and post-logout cookie repopulation, not merely JavaScript token races.

Across tabs, on `403 CSRF_REJECTED`, discard the stale pair while locked, perform at most one bootstrap, then retry the original operation at most once under that same lock. At most two server CAS attempts per bootstrap and one bootstrap+operation retry bound each user action. A second mismatch or any `401`, `409`, `429`, `503`, timeout, or indeterminate refresh outcome terminates the action and requires explicit later user/client re-entry; no loop or churn continues. Lost bootstrap response is recovered by a fresh bounded bootstrap with unchanged refresh auth only when no server mutation/cookie emission could have committed. A stale generation mismatch is never classified as consumed-token reuse and never triggers ledger insertion or family revocation.

Refresh itself is not blindly retryable. The adapter must provide side-effect-free pre-rotation lookup, CSRF-before-rotation ordering, atomic rotation or durable exact-outcome recovery, and a consumed-token ledger covering every ancestor for family lifetime plus replay window. `grace`/`race_lost`, timeout, or crash must resolve to not-started, exact committed outcome, rejected/reused, or safe indeterminate failure. Current platform interfaces do not provide this; launch remains **BLOCKED**.

## Closed production redaction data-flow inventory

Raw cookies, authorization, `X-CSRF-Token`, `X-Refresh-CSRF-Token`, request passwords/reset tokens, and response `csrf_token`/`refresh_csrf_token` are forbidden capture data. Scrub before serialization, buffering, queuing, sampling, metrics labels, breadcrumbs, or export. When structure is necessary use exactly `[REDACTED:REFRESH_CSRF]`; do not hash, truncate, fingerprint, retain length, or encode the value.

The production inventory is closed and must enumerate every concrete instance in each class: API gateway/load balancer; edge worker/CDN/WAF; reverse proxy; service mesh/sidecar; app/runtime stdout, stderr, and journald; request/response and structured logs; APM, profiles, metrics, traces, and OpenTelemetry collectors/exporters; database query, slow-query, statement, audit, WAL, replication, and error logs; service worker; browser extensions used in production/support; browser automation traces, videos, screenshots, HAR/network and console logs; packet/debug capture; process/core/heap dumps; crash reporters; session replay; ticket/support systems; data warehouse/SIEM; backup/archive; every vendor transit/storage hop; and the complete reset-mail path: outbound mail delivery service/provider, mailbox, durable queue and dead-letter queue, template renderer and preview/logging, bounce handler, complaint handler, and every reset-link scanner/security gateway/link-rewriter path.

A versioned `auth-sensitive-data-flow.json` manifest is mandatory and validates `quality/acceptance/auth-split-origin/schemas/redaction-manifest.schema.json`. Each entry has stable `hop_id`, class, product/service and version, environment, owner, upstream/downstream hop IDs, data classes observable, capture policy, redaction control/config revision, retention, storage region, access policy, test probe ID, enabled/disabled state, and owner signature. The manifest states the exact assertion: **every byte path from browser creation or ingress through processing, observability, support, backup, and deletion is represented by a hop; there is no uninventoryed hop.** Owners sign each entry and the security owner signs closure. The retained `sink-inventory.json` is an exact JSON-value copy of `manifest.entries`, not a projection. Its `manifest_sha256` equals SHA-256 of the exact signed manifest bytes, while `environment.json.redaction_manifest.sha256` equals `sha256:` plus that same digest; verifier digest equality and exact-copy equality derive closure evidence without trusting the producer. CI/deployment fails closed for missing owner/field/test, copy or digest mismatch, unknown live hop, graph discontinuity, stale config hash, enabled forbidden capture, or canary detection. Debug mode, malformed input, crashes, sampling, retries, and `4xx`/`5xx` do not relax policy.

## Executable acceptance evidence

The executable evidence contract is committed at `quality/acceptance/auth-split-origin/`. Its Draft 2020-12 schemas cover production environment input, case/oracle records, summary, sink inventory, sink search, DB assertions, crash injection, artifact manifest, and the signed redaction manifest. Exact production input is `quality/acceptance/auth-split-origin/input/<run_id>/`; exact retained output is `artifacts/platform-auth/<run_id>/`. The fail-closed command is:

```sh
python3 quality/acceptance/auth-split-origin/verify.py --input quality/acceptance/auth-split-origin/input/<run_id> --manifest quality/acceptance/auth-split-origin/input/<run_id>/auth-sensitive-data-flow.json --publication artifacts/platform-auth/<run_id>
```

`quality/acceptance/auth-split-origin/README.md` is normative for filenames, digest derivation, and output semantics. Missing or non-production-equivalent input, any schema/digest/copy error, or any FAIL/BLOCKED record produces BLOCKED with exit 2 and can never fabricate PASS.

The required suite implementation target is `apps/api/test/e2e/platform-auth.contract.spec.ts`; browser orchestration is `apps/web/e2e/platform-auth.browser.spec.ts`. The single production-equivalent browser command is:

```sh
pnpm exec playwright test --config tests/platform-auth/playwright.config.ts apps/api/test/e2e/platform-auth.contract.spec.ts apps/web/e2e/platform-auth.browser.spec.ts
```

It must run only against an environment whose identity file validates `tests/platform-auth/schemas/environment-identity.schema.json` with: `run_id`, UTC `started_at`, git SHA, dirty=false, API/web image digests, package versions, schema migration IDs, public origins, TLS certificate fingerprints, cookie-policy revision, auth adapter/engine revision, DB identity/region, edge/gateway/WAF/CDN/proxy/mesh instance+config digests, telemetry/APM/collector instance+config digests, browser name/version, OS/container digest, feature flags, crash-seam revision, redaction-manifest path+SHA-256, and deployment ID. Any absent/mismatched field makes the run invalid, not failed evidence.

Cases are emitted to `artifacts/platform-auth/<run_id>/cases.ndjson` and validate `tests/platform-auth/schemas/case-result.schema.json`: `schema_version`, `run_id`, `case_id`, route, phase (`options|actual|crash|redaction|browser`), request fixture ID, expected oracle ID, observed status, canonical-body SHA-256, normalized response-header map, cookie-metadata array with values removed, side-effect counters before/after, DB assertion IDs/results, sink-probe IDs/results, crash seam, start/end UTC, and `PASS|FAIL`. Oracles live at `tests/platform-auth/oracles/<case_id>.json` and specify exact status, canonical envelope with request-id placeholder, exact present/absent headers, cookie metadata, allowed durable mutations, forbidden mutations, and retry/reuse result.

Retain: `environment.json`, `cases.ndjson`, `summary.json`, `sink-inventory.json`, `sink-search.ndjson`, `db-assertions.ndjson`, `crash-injection.ndjson`, `artifact-manifest.json`, the signed `auth-sensitive-data-flow.json`, sanitized Playwright traces under `traces/`, and `SHA256SUMS` under `artifacts/platform-auth/<run_id>/`. All listed paths are normalized relative POSIX paths (no absolute path, `..`, backslash, duplicate, symlink, or unlisted file). To avoid recursive digests: `SHA256SUMS` lists every retained file except itself, including `summary.json` and `artifact-manifest.json`; `artifact-manifest.json.files` and `summary.json.artifacts` each list exactly every retained payload file except the three control files `SHA256SUMS`, `artifact-manifest.json`, and `summary.json`, with identical path set, SHA-256, and byte counts. The verifier parses strict GNU checksum lines, checks exact file-set equality, bytes and digests, reconciles summary counts from records, then requires a distinct publication copy and repeats all set/byte/digest checks before PASS. Raw secrets, auth response bodies, cookie values, and HAR bodies are never retained.

The finite crash-injection seam list is exactly the `SEAMS` tuple in `verify.py` and includes every pre-existing server seam plus these browser/post-send seams: register and login `after-send-before-cookie-application`, `during-cookie-application`; ordinary CSRF `after-send-before-cookie-application`, `during-cookie-application`; bootstrap `after-send-before-client-generation-cas`; refresh `after-send-before-cookie-application`, `during-cookie-application`, `after-cookie-application-before-generation-cas`; logout `after-send-before-cookie-application`, `during-cookie-application`; reset request `after-send-connection-loss`; reset completion `after-session-invalidate-before-clear-build`, `after-clear-build-before-send`, `after-send-before-cookie-application`, `during-cookie-application`. The tuple is the single machine-checked closed enumeration; prose must not create a second divergent list.

Every session creation, durable idempotency transition, family CAS, token/hash persistence, enqueue, accepted-outcome persistence, response-cookie build, browser cookie application, and send boundary is represented. Adding/removing/renaming a seam changes `crash-seam revision` and invalidates older completeness evidence.

A case is PASS only when its `(route, phase, category, crash_seam)` identity is one exact member of the verifier-registered closed set and occurs exactly once; aggregate row counts never establish coverage. Status, canonical body, every required-present and required-absent header, cookie metadata, side-effect delta, DB assertions, retry bound, generation ordering, reuse/revocation classification, and all applicable sink probes must exactly equal its oracle, and no secret may be retained. The verifier recomputes canonical observed-outcome, trace-file, and evidence-file SHA-256 values and rejects arbitrary or all-zero claims. Every case has nonempty exact DB, sink, trace, and evidence references. Every DB assertion is referenced exactly once by its matching case/action, has expected and observed deltas equal to the independently derived case operation delta, and matches the case's nonempty owner/session/family identity and before/after generation. A crash case additionally requires the named seam fired exactly once and the resulting state resolves to its oracle without blind retry. A redaction case passes only when the signed inventory has exactly one designated graph root, every hop is reachable from it, every enabled hop was probed exactly once, raw canary and URL/base64/hex forms are absent, and marker classes exactly equal the hop's declared data classes with one marker hit per required class. Multiple disconnected signed root components are invalid even if every entry and closure signature verifies. Suite PASS requires every named route/case category, every OPTIONS failure, every registered browser phase, every crash seam, and every inventory hop to have exactly one current PASS record; missing, duplicate, skipped, invalid-environment, stale-hash, unrelated-reference, or uninventoryed results fail closed.

The public harness self-test deterministically constructs the complete current 209-identity/32-sink signed baseline and two historical adversarial fixtures: the exact fabricated 102-case/32-sink bundle and a fully signed disconnected 32-sink graph. `generate_test_bundle.py` labels every fixture owner `NON-PRODUCTION TEST KEY`; normal verification rejects that label, while the self-test-only mode can exercise all signatures, identities, DB/action/context/delta/generation links, marker/class links, hashes, traces, evidence, and publication closure without claiming production PASS.

The public `self_test.py` deterministically generates a visibly labelled non-production signed fixture and traverses the complete 209-identity/32-sink success path, including recomputed traces, evidence, DB/action/context/delta/generation closure, marker coverage, artifact hashes, and publication-copy equality. It also reproduces the exact prior 102-case/32-sink fabricated bundle and a separately signed disconnected graph; the verifier must BLOCK them for identity-set and root-connectivity failures respectively. The hidden fixture switch accepts only owners labelled `NON-PRODUCTION TEST KEY`; normal verification rejects that label, so fixture success cannot be represented as production evidence.

Required evidence cases are: split-origin login/register; cookie unreadability and exact paths/flags; all OPTIONS and actual matrix rows; access/refresh expiry; ordinary and refresh bootstrap; full-family ancestor replay; valid/invalid logout; password reset non-enumeration and invalidation; two-tab reordered responses; bounded contention/retry; every crash seam; and canary search over the closed sink inventory.

Until the MVP path/DTO amendment, implemented route schemas, safe adapter, production stack, real-browser suite, and retained current PASS artifacts all exist, status remains **SPECIFIED / BLOCKED**.
