# API contract for programmatic clients

> The HTTP surface (`src/BookTranslation.Api`) is the substrate the web app, the MCP server, and the
> desktop apps all consume. This document is the **stable contract** an agent/MCP client codes against:
> the error envelope, the idempotency guarantee, the list convention, and the discovery endpoint.
> Shipped by **PBI #1428** (Feature **#1410**, Public API GA). It complements `docs/spec.md §7`.

The driving constraint: **an agent can't pattern-match a human sentence.** Every response is shaped so a
client can branch on typed, predictable fields — never on prose that may be reworded.

## 1. Errors — RFC 9457 `ProblemDetails`

Every non-2xx response is a single `application/problem+json` envelope:

```json
{
  "type":   "https://kenly/errors/project_wrong_state",
  "title":  "Invalid state transition",
  "status": 409,
  "detail": "Cannot 'normalize' from state 'Calibrating'. Expected: Created.",
  "code":   "project_wrong_state"
}
```

- **`code` is the contract.** It is a stable, machine-readable string clients switch on. `title`/`detail`
  are human-facing and may be reworded; `code` and `type` (which is `https://kenly/errors/{code}`) will
  not change for a given condition.
- The shape is produced in **exactly one place** — the `ApiProblem` factory in
  `src/BookTranslation.Api/ApiContract.cs`, plus a registered `IExceptionHandler` that maps an uncaught
  `InvalidProjectStateException` through the same factory — so it cannot drift per endpoint.
- **Domain extras ride along as typed extension members**, not a different top-level shape: a `402`
  carries the affordability `quote`.

### Stable codes

| `code` | Status | Meaning |
|---|---|---|
| `validation` | 400 | A field was missing, malformed, too large, or otherwise invalid. |
| `invalid_credentials` | 401 | `/auth/login` credentials did not match. |
| `insufficient_credit` | 402 | The wallet can't cover the next batch. Carries `quote` (a `BatchQuote`). |
| `project_not_found` | 404 | No project with that id exists **for the caller** (owner-scoped; never leaks existence). |
| `account_not_found` / `job_not_found` / `notification_not_found` / `avatar_not_found` / `source_not_found` / `export_not_found` / `image_not_found` | 404 | The named resource was not found. |
| `topup_not_found` | 404 | No top-up/redeem with that reference exists **for the caller** (`GET /v1/wallet/top-up/{reference}`; owner-scoped). |
| `email_taken` | 409 | Registration: the email already has an account. |
| `project_wrong_state` | 409 | A pipeline transition was invoked from a state its guard forbids. |
| `no_failed_pages` | 409 | `POST /projects/{id}/retry` when the project has no failed pages to retry. |
| `queue_changed` | 409 | The queue changed under a reorder (an item started/finished). |
| `idempotency_in_flight` | 409 | An identical request for the same operation is already being processed (server-side dedup, §2); retry shortly. |
| `rate_limited` | 429 | The per-principal request rate limit was exceeded. Carries `Retry-After` + `RateLimit-*` headers (§8). |

## 2. Idempotency — internal, no client action (PBI #2120)

Retry-safety is a **pure backend concern**. There is **no client `Idempotency-Key` header** — a client
never sends, reads, or sees an idempotency key. For each billable / state-changing `POST` the server
**derives** a stable dedup key from the request content and the project's current state, so a
double-submit can't double-charge or double-enqueue. This protects even clients that would
never have thought to send a key.

Internal-only mechanics (not part of the public contract; documented here so the behaviour is traceable):

- **Server-derived keys.** Project transitions (`normalize`, `process`, `process-next-batch`, `export`,
  `retry`, `cancel`) key on `{scope}:{owner}:{projectId}:{state}`, where `{state}` is the status + batch
  cursor (`ApprovalCount`), or the failed-page set for `retry` — so a duplicate of the *same* operation
  is deduped while the legitimately-next one
  composes a fresh key. The wallet purchase (§6) has no state cursor, so it keys on the request content
  (owner + amount + method + card) within a short **time window**: a double-submit collapses while a
  deliberate repeat after the window proceeds.
- **Precision trade-off (flagged).** A server-derived content+window key is slightly less precise than a
  client-chosen one: two *intentional* identical top-ups in the same short window would collapse into one.
  Acceptable for this app; the simplification is worth it.
- **Pre-reservation guards.** Affordability (`402`) is checked **before**
  the key is reserved, so a rejected attempt is never cached.
- **Fail-open.** Backed by Redis (the Aspire `cache` resource) via `IIdempotencyStore`; if Redis is
  unreachable the store returns `Bypassed` and the endpoint proceeds (retry protection is lost for that
  request, availability is not). With no Redis configured (a bare `dotnet run` or the tests) an in-memory
  store with identical semantics stands in. See `CLAUDE.md → Redis (cache) + idempotency`.

## 3. List convention

Collection endpoints return a **bare JSON array**. The one exception is the searchable, sortable,
paginated project library — `GET /projects` — which returns a `PagedResult` envelope:

```json
{ "items": [ … ], "total": 128, "page": 1, "pageSize": 25 }
```

## 4. Versioning + OpenAPI (PBI #1427)

The whole surface is served under a stable **`/v1`** base path (e.g. `POST /v1/projects`). A narrow
server-side back-compat rewrite still accepts the old unversioned paths, so `/projects` is an alias for
`/v1/projects` — older clients keep working while `/v1` is the versioned contract.

A generated **OpenAPI 3** document is served at **`GET /openapi/v1.json`** (.NET 10 first-party
`Microsoft.AspNetCore.OpenApi`). Every operation has a stable `operationId`, a resource tag, and typed
request/response + `ProblemDetails` (402/404/409) responses; the document declares the **`Bearer`** (JWT)
and **`ApiKey`** (`X-Api-Key`, PBI #1426) security schemes so a code generator / MCP server can authenticate.

### 4a. One cross-surface status/event contract (PBI #2006)

Every surface — the SPA, the MCP server (#1433), a future mobile app, outbound webhooks (#2003) — reasons
about the **same** book status + lifecycle events, so the contract for them is published **once**, typed, in
the OpenAPI doc and generated against (never hand-duplicated):

- The **status DTO** (`ProjectStatusView`, `GET /v1/projects/{id}/status`, #2000/#2004) and the **lifecycle
  event** (`ProjectLifecycleEvent`, `GET /v1/projects/{id}/events`, #2002) are named schemas in the doc.
- Their **closed taxonomies** are declared as **enum** types so a generated client gets exhaustive,
  non-drifting string-literal types instead of a bare `string`:
  `state` · `phase` (`intake｜working｜waiting｜done｜error｜cancelled`) · `waitingOn`
  (`gpu_capacity｜gpu_warmup｜queue｜credit`) · `blockedOn` (`credit｜retry`) · `nextAction`
  (`add_credit｜retry｜download`), plus the transition `event` (`create｜normalize｜processBatch｜retry｜
  autoAdvance｜cancel｜finalizeCancel`). `waitingOn=credit` is **reserved** — published for forward-compat but
  never emitted (an out-of-credit book is *blocked*, not waiting; see `book-status-state-machine.md §4.2`).
  Each set is also published as a reusable, named enum schema (`ProjectPhase`, `ProjectWaitingOn`, …).
- The enums are **single-sourced** from the server's own constants (`ProjectStatusTaxonomy`,
  `ProjectLifecycleEventNames`, the `ProjectStatus` enum) via an OpenAPI document transformer, so the
  published contract and the emitted payloads cannot diverge. Two **conformance tests** enforce it and fail
  CI on any break: `StatusEventContractConformanceTests` (every value the server can emit is inside the
  published set — always-on, no container) and `OpenApiContractTests` (the doc declares exactly those enums).
- The **SPA generates its types** from the doc: a focused slice `src/BookTranslation.Web/openapi/status-contract.json`
  is committed and `npm run codegen` turns it into `src/api/generated/status-contract.ts` (re-exported from
  `src/api/types.ts`). `StatusContractSpecTests` guards the committed slice against the live doc.

### 4b. Versioning rule — additive-only within `/v1`

`/v1` is a **stable, additive-only** contract. Within `/v1` we only ever make backward-compatible changes, so
a generated agent/mobile client never breaks silently:

- **Allowed (additive):** new endpoints; new **optional** response fields; a **new** value appended to a
  closed enum **only when** every documented consumer already treats the enum as open / has a default branch
  (e.g. activating the reserved `waitingOn=credit`); new optional request parameters with safe defaults.
- **Breaking (requires a new version, e.g. `/v2`):** removing/renaming a field or endpoint; changing a
  field's type or making an optional field required; removing an enum value; changing the meaning of an
  existing value. A breaking change is a new versioned surface, never an in-place edit of `/v1`.

Because the enum sets are single-sourced and conformance-tested, an accidental breaking change (e.g. deleting
an enum value the DTO still emits, or renaming one) fails CI rather than shipping.

### 4c. Real-time push — SignalR + signed webhooks (PBI #2003)

Clients don't have to poll: the server **pushes** a book's status the instant it changes, over two transports
driven off the same #2002 event log, both carrying the §4a `ProjectStatusChange` envelope (the durable
`event` with its `eventId` replay cursor + the current status DTO — the same shape `GET …/status` returns).

- **SignalR hub (the SPA)** — `GET /v1/hubs/status` (WebSocket). Authenticated with the same JWT as the REST
  API (it rides via the `access_token` query on the handshake). Each connection joins **only its own owner
  group**, so a client can never receive another account's events (cross-tenant subscription is impossible —
  the group is keyed by the authenticated `uid`, never client input). Server → client method `statusChanged`
  delivers the envelope; the client method `CatchUp(projectId, afterEventId)` replays missed events from the
  last id it saw, so a reconnect never drops a terminal state. A **Redis backplane** (the Aspire `cache`)
  makes it work across multiple API instances.
- **Signed webhooks (headless agents / MCP)** — register per-owner endpoints under **`/v1/webhooks`**
  (`POST` to register — the signing secret is returned **once**; `GET` to list without secrets; `DELETE` to
  remove). Each delivery is a JSON `POST` of the envelope, **HMAC-SHA256 signed** with the subscription
  secret: header `X-Kenly-Signature: t=<unixSeconds>,v1=<hex>` over `"{t}.{body}"` (Stripe-style — the `t`
  makes it replay-checkable), plus `X-Kenly-Event-Id` for idempotent receivers. Delivery retries with
  exponential backoff; an exhausted delivery is recoverable via the `GET …/events` replay feed.

## 5. Discovery — `GET /v1/meta`

Anonymous (no token required) so a client can self-configure before it authenticates:

```json
{
  "apiVersion": "1.0",
  "batchLadder": [1, 5, 25, 100],
  "finalBatch": "rest",
  "exportFormats": ["All","Markdown","PlainText","Html","Epub","Docx","Pdf","Archive"],
  "problemTypeBase": "https://kenly/errors/"
}
```

> **The OpenAPI document is the discovery mechanism for the closed vocabularies (PBI #2112).** There is
> deliberately **no** separate public `/v1/discovery` endpoint — a runtime endpoint would duplicate contract
> facts into a second source of truth that can drift. Instead the published doc (`GET /openapi/v1.json`)
> **self-describes** the app-wide facts a typed SDK/MCP client needs, as enums/constants it self-configures
> from: the **API version** (`info.version`), the **export formats** (`ExportFormat` enum), and the
> **destination languages** — `targetLanguageCode` is constrained to the supported ISO-639 set (published once
> as the reusable `TargetLanguageCode` component schema) everywhere a client *chooses* a destination language
> (the create-project body and the anonymous-estimate query). The **source** language is auto-detected and is
> never enumerated. `/v1/meta` stays **Internal** because it also carries deployment/feature-flag internals a
> public client must not see (OAuth client ids + enablement, payment-rail flags, the batch ladder, the
> problem-type base). Mechanically this mirrors the status/event contract (`StatusContractDocumentTransformer`)
> — see `LanguageContractDocumentTransformer` + `OpenApiContractTests`.

### Model-backend readiness — `GET /v1/model-status` (PBI #1833)

Anonymous, poll-friendly. Reports whether the real model backend is up and GPU-ready right now, so the SPA
can show a "GPU infrastructure is starting, please stand by…" banner and gate the process action instead of
letting it fail (the graceful counterpart to the fail-closed backend, #1840):

```json
{
  "backend": "RunPod",
  "isReal": true,
  "ready": false,
  "state": "starting",
  "detail": "GPU infrastructure is starting (cuda:false (device=cpu))"
}
```

- **`state`** is `ready` | `starting` (a real backend that is cold/unreachable) | `unconfigured` (no backend
  configured); **`ready`** is the convenience boolean (`state == "ready"`). **`isReal`** surfaces
  `ModelBackendInfo.IsReal` (true only for RunPod).
- The readiness check runs the worker's `health` op through the **same `RunPodClient` transport** that serves
  real OCR/translation, so it is **pod/serverless-agnostic** — identical behaviour on the dev persistent pod
  and a production serverless endpoint (only `BaseUrl`/`EndpointId` differ; see
  `docs/runbooks/runpod-model-processing.md` §3).
- The result is **cached for a short TTL** server-side (and coalesced), so many polling tabs trigger at most
  one backend health call per window — important on serverless, where a health op can wake a worker.

## 6. Agent wallet surface — `/v1/wallet` (PBI #1429)

The machine-callable credit surface the MCP `purchase_tokens` tool (#1433) binds to: read the balance and
add credit **without a human opening the web app**. A thin, versioned layer over the one credit-grant
routine (`docs/payments.md §2`) — it adds no second ledger; every credit is a confirmed `CreditTransaction`.
All routes require auth and are owner-scoped.

| Method & route | Body | Returns |
|---|---|---|
| `GET /v1/wallet` | — | `{ balanceUsd, currency, unlimited, lowBalance }` |
| `POST /v1/wallet/top-up` | `{ amountUsd, method?, coin?, savedCardId? }` | `{ reference, status, method, amountUsd, creditedUsd, balanceUsd, checkoutUrl?, message, cryptoCoin?, cryptoAddress?, cryptoAmount? }` |
| `GET /v1/wallet/top-up/{reference}` | — | the same top-up view (poll) |
| `POST /v1/wallet/redeem` | `{ code }` | a confirmed top-up view |

**`POST /v1/wallet/top-up` is the ONE canonical way to add credit** (#2114 — it consolidated the former
`/wallet/top-up` + `/wallet/checkout` + `/wallet/checkout-saved-card`, which no longer exist). One call, one
unified response: either `status: confirmed` (credit landed now) or `status: pending` with a `checkoutUrl`
to complete (poll `GET /v1/wallet/top-up/{reference}` until `confirmed`). Routing by `method`:

| `method` | + | Behaviour |
|---|---|---|
| `card` | `savedCardId` | Charges that saved card **in-app** — credit lands immediately (`confirmed`). |
| `card` | — | Returns a hosted **Stripe Checkout** `checkoutUrl` (`pending`); completable headlessly. |
| `crypto` | `coin?` | Returns a hosted **CoinPayments** `checkoutUrl` (`pending`) for the chosen coin. |
| `manual` | — | Credits immediately (`confirmed`) — **development-only** (see below). |

- **Card model (PCI).** Raw card numbers must never touch this API, so **saving a new card happens on
  Stripe's hosted page** — `method=card` with no `savedCardId` returns a hosted `checkoutUrl` the user opens
  once. After a card is saved, **charging it is pure API**: pass its `savedCardId` (from
  `GET /v1/payment-methods`) and the charge happens in-app, no browser. So the model is: *new card → hosted
  URL (once); saved card → API charge; crypto → hosted URL.*
- **`manual` (instant credit) is development-only.** It credits with no payment taken, so it is **off in
  production/UAT** (`BookTranslation:Billing:ManualCreditEnabled`, default = Development-only). A `manual`
  request on a production deployment is rejected `validation` (400) — a public caller can never mint free
  credit. Pay by `card` or `crypto` instead.
- **Retry-safety is automatic and backend-owned (§2).** The server derives its own dedup key from the
  purchase (owner + amount + method + card + a short time window) and forwards it to Stripe, so a
  double-submit within the window can't take money twice — the caller sends **no** header. Hosted checkout /
  crypto flows are naturally single-use.
- **`status`** is lowercase (`confirmed` / `pending` / `failed` / `cancelled` / `expired`); **`method`** is
  `manual` / `card` / `crypto`. `method` defaults to `manual` when omitted.
- **`lowBalance`** is `true` once a charged wallet falls to/below the configured threshold
  (`BookTranslation:Billing:LowBalanceThresholdUsd`, default `$1`) — the cue to prompt a top-up before the
  next batch is blocked with `insufficient_credit` (402).
- **Redeem codes** (`POST /v1/wallet/redeem`, `BookTranslation:Billing:RedeemCodes`) are a **separate**
  operator-issued mechanism (not consolidated): redeeming a configured code credits immediately so a `402`'d
  batch can resume. Single-use per owner; an unknown or already-redeemed code is `validation` (400).
- **Errors:** out-of-bounds amount / unknown method / `manual` in production / bad code → `validation` (400);
  unknown reference → `topup_not_found` (404).

## 7. Authentication — JWT + API keys (PBI #1426)

Every non-auth endpoint accepts **either** credential, both resolving to the same owner identity (the same
owner-scoped 404-on-cross-tenant behavior applies to both):

- **Interactive JWT** — a 12-hour bearer token from `POST /v1/auth/login` / `/v1/auth/register`, sent as
  `Authorization: Bearer <jwt>`. For the browser SPA.
- **Scoped API key** — a long-lived, revocable secret for headless agent/MCP clients, sent as
  `Authorization: Bearer kenly_sk_live_…` **or** `X-Api-Key: kenly_sk_live_…`. A "smart" auth scheme picks
  the handler per request by the credential shape.

Key management (owner-scoped, under `/v1/api-keys`):

| Method & route | Body | Returns |
|---|---|---|
| `POST /v1/api-keys` | `{ name, scopes?, expiresInDays? }` | `{ id, name, prefix, scopes, createdAtUtc, expiresAtUtc, secret }` — **`secret` is shown exactly once** |
| `GET /v1/api-keys` | — | `[{ id, name, prefix, scopes, createdAtUtc, lastUsedAtUtc, expiresAtUtc, revokedAtUtc }]` (never the secret) |
| `DELETE /v1/api-keys/{id}` | — | `204`; revocation is immediate |

- **Capability boundary — key management + identity/security are interactive-only (PBI #2117).** An API
  key can do everything the product does *for its owner* (create/drive/observe projects, upload, read the
  wallet + **buy credit**, manage webhooks, read the account, edit low-risk **preferences**), but it can
  **never mint another credential or change identity/security settings** — the OpenAI/Stripe/Cloudflare
  model. Concretely, an **API-key** principal is rejected with **`403 interactive_auth_required`** on:
  `POST /v1/api-keys` (mint a key), `DELETE /v1/api-keys/{id}` (revoke a key), `DELETE /v1/me` (delete
  the account), `POST /v1/me/change-password` (rotate the password, §7b), `POST /v1/me/set-password` (add a
  first password to an OAuth-only account, §7b-i), and `POST /v1/me/change-email` (change the login email,
  §7c). An interactive **JWT** (a human in the dashboard) is accepted on all of
  them. `GET /v1/api-keys` (observe) stays open to both. Create your first key in the web dashboard (JWT),
  then use it for everything else.

### 7b. Change password (PBI #2124)

A signed-in user rotates their own password without triggering the forgotten-password email:

| Method & route | Body | Returns |
|---|---|---|
| `POST /v1/me/change-password` | `{ currentPassword, newPassword }` | `200 { changed: true, token }` |

- **Interactive-only (§7).** An **API-key** principal is rejected with **`403 interactive_auth_required`**
  before any password check; an interactive **JWT** is accepted.
- **Current-password gated.** The `currentPassword` is verified against the stored hash — a mismatch is
  **`403 password_incorrect`** and nothing is persisted (mirrors the `DELETE /v1/me` confirmation).
- **New-password rules.** `newPassword` must meet the minimum-strength rule and **differ** from the current
  one; either failure is **`400 validation`** with the requirement message.
- **OAuth-only accounts.** An account that signs in only through Google / Apple has no password to change →
  **`409 password_not_set`** (a clear, non-leaky message — never a 500). Setting a *first* password on such
  an account is out of scope (a separate flow).
- **On success** the new password is re-hashed + persisted, a best-effort **security audit event** is
  recorded (actor uid, UTC, request IP / UA — never blocks the change), and a **fail-soft** "password
  changed" alert email is sent to the account address (a send failure never fails the change).
- **Rate-limited per principal** (anti-brute-force on the current-password confirmation): over the per-uid
  ceiling returns **`429`** with the uniform RFC-9457 body + `Retry-After` / `RateLimit-*` headers (§8).
- **Signs you out everywhere else (PBI #2126, §7d).** The change rotates the account's security stamp, so
  **every other session's token is revoked** (401 on its next call). The response returns a fresh **`token`**
  for the acting (calling) session — swap your stored JWT for it so the device that changed the password stays
  signed in.

### 7b-i. Set a first password — OAuth-only accounts (PBI #2154)

A user who signed up with **Sign in with Google / Apple** has **no password** (`PasswordHash` is empty), so
they can use neither §7b (change password) nor §7c (change email) — both re-enter a current password there
is none of. This adds a password **in-session** as an optional backup sign-in method (the gold standard:
one account, multiple methods). It is structurally §7b **minus** the current-password verify **plus** a
guard that the account does not already have a password.

| Method & route | Body | Returns |
|---|---|---|
| `POST /v1/me/set-password` | `{ newPassword }` | `200 { changed: true, token }` |

- **Interactive-only (§7).** An **API-key** principal is rejected with **`403 interactive_auth_required`**
  before any work; an interactive **JWT** is accepted.
- **No current password.** There is none to confirm — the interactive session *is* the authorization.
- **New-password rule.** `newPassword` must meet the minimum-strength rule; a too-weak password is
  **`400 validation`** with the requirement message.
- **Already has a password → non-leaky `409 password_already_set`.** An account that already has a password
  must use §7b (change password, which verifies the current one) — this endpoint only ever **adds** a first
  password and never silently overwrites an existing credential (so it can never remove the last sign-in
  method).
- **On success** the password is hashed + persisted, a best-effort **security audit event** is recorded
  (`account.password_set` — actor uid, UTC, request IP / UA), and a **fail-soft** "password added" alert
  email is sent to the account address (a send failure never fails the change). After this the account has
  **both** the external provider *and* password sign-in.
- **Signs you out everywhere else (PBI #2126, §7d).** Adding the password rotates the account's security
  stamp, so **every other session's token is revoked**; the response returns a fresh **`token`** for the
  acting session — swap your stored JWT for it so the device that added the password stays signed in. A
  subsequent `GET /v1/me` then reports `hasPassword = true`, so the SPA flips from the "Create a password"
  form to the normal change-password / change-email forms.
- **Rate-limited per principal** (§8): over the per-uid ceiling returns **`429`** with the uniform RFC-9457
  body + `Retry-After` / `RateLimit-*` headers.

### 7c. Change email — verify-before-flip (PBI #2125)

A signed-in user changes their **login email** safely. The design decision is **verify-before-flip**: the
account email is **not** touched until the new address proves ownership — so a typo or a hijacked session can
never strand a real user out of their account, and the change is always visible to the current owner.

| Method & route | Auth | Body | Returns |
|---|---|---|---|
| `POST /v1/me/change-email` | JWT only | `{ newEmail, password }` | `202 { pending: true }` — login email **unchanged** |
| `POST /v1/auth/confirm-email-change` | anonymous (token) | `{ token }` | `200 { changed: true }` — email **flips now** |
| `POST /v1/auth/cancel-email-change` | anonymous (token) | `{ token }` | `200 { cancelled }` — old-address kill switch |
| `POST /v1/me/change-email/cancel` | JWT/key | — | `200 { cancelled }` — in-app cancel |

- **Initiate (`POST /v1/me/change-email`).** Interactive-only (§7 — an API key is `403
  interactive_auth_required`) and **current-password gated** (mismatch → `403 password_incorrect`, mirroring
  §7b / `DELETE /v1/me`). The new address is validated + normalized (invalid → `400 validation`; equal to the
  current address → `400`); an address already registered to **another** account → **`409 email_taken`**. On
  success the login email is **not** changed — a *pending* change is recorded, a single-use verification link
  is emailed to the **new** address, and a "this wasn't me" cancel alert to the **old** address (the
  out-of-band takeover-detection channel, which names only the *masked* new address). An OAuth-only account
  (no password) is a non-leaky **`409 password_not_set`**.
- **Confirm (`POST /v1/auth/confirm-email-change`).** Anonymous — the single-use token from the new-address
  link is the credential (mirrors verify-email / reset-password). On a valid token the login email **flips**
  to the pending address, `EmailVerified` is set **true** (ownership just proven), the token + its paired
  cancel token are consumed, and an "email changed" confirmation is sent to the old address. `email_taken` is
  **re-checked at confirm time** — if the address was registered elsewhere in the interim, **`409
  email_taken`** and the change is not applied. An unknown / used / expired token is a non-leaky **`400
  invalid_email_change_token`**.
- **Cancel (either route).** A pending change can be cancelled by the **signed-in user** (`POST
  /v1/me/change-email/cancel`, owner-scoped, idempotent) **or** by the real owner via the old-address alert's
  **cancel link** (`POST /v1/auth/cancel-email-change`, anonymous token). Either burns the pending change so
  the outstanding verify link can no longer complete. The two links carry **distinct secrets** — the old
  address gets only the *cancel* secret, so the owner can abort a change they didn't start without ever being
  able to complete it.
- **On both events** a best-effort **security audit event** is recorded (`account.email_change_requested` /
  `account.email_changed` — actor uid, UTC, request IP / UA, the *masked* new address; never blocks the
  action). All emails are **fail-soft** (a send failure never fails the operation).
- **Rate-limited (§8):** the initiate is capped **per uid** (anti-brute-force on the password + anti-mail-bomb
  on the new/old addresses); confirm + cancel are capped **per IP**. Over the ceiling → **`429`** with the
  uniform RFC-9457 body + `Retry-After` / `RateLimit-*` headers.
- **Signs you out everywhere (PBI #2126, §7d).** Confirming the flip rotates the account's security stamp, so
  **every existing session is revoked** — the confirm is an anonymous link flow with no live JWT session to
  preserve, so the user simply signs in fresh with the new address.

### 7d. Session invalidation on credential change (PBI #2126)

Every JWT is stamped with the account's current **security stamp** (a `stamp` claim). Each authenticated
request validates that claim against the stored value, and any credential change **rotates** the stamp — so
one write invalidates **every outstanding token** at once. This is the gold-standard "changing your password
signs you out everywhere else" behaviour, and it means a stolen or leaked token is cut off the moment the
owner rotates a credential.

- **What rotates the stamp:** an authenticated **password change** (§7b), a **forgotten-password reset**
  (`POST /v1/auth/reset-password`), and an **email-change confirm** (§7c). A token minted before the change
  returns **`401`** on its next call.
- **Acting-session continuity.** The one flow with a live JWT session — **change-password** — returns a fresh
  `token` carrying the new stamp so the calling device stays signed in. The two link-completion flows (reset,
  email-change confirm) are anonymous and end at the sign-in screen, so the user authenticates fresh; there is
  no live session to re-issue.
- **API keys are unaffected.** A `kenly_sk_…` principal carries no `stamp` and is never gated by this check —
  keys are a separate, independently revocable credential (revoke them under `/v1/api-keys`).
- **Cost + the trade-off.** Validation is a short in-memory cache of each user's current stamp over a single
  indexed primary-key read: the common case (a matching token) is one memory hit with no database round-trip.
  A **mismatch is always re-confirmed against the store** before it 401s, so a just-re-issued token is never
  wrongly rejected by a stale cache. The cache can lag the database by up to ~30 s, so on a multi-instance
  deployment a revocation reaches instances that cached the prior value within that window (the instance that
  made the change evicts its own entry immediately) — an availability-over-immediacy choice that is still far
  tighter than ASP.NET Identity's 30-minute default. Validation is **fail-safe**: a lookup error admits the
  request rather than locking everyone out.
- **Out of scope:** refresh-token rotation, a device/session-management UI, and sliding-expiration changes.

- **Storage:** only a SHA-256 hash of the secret is persisted; the raw `kenly_sk_live_…` value is never
  stored or logged. The non-secret `prefix` is kept for display + leak scanning.
- **Scopes** describe a key's intended capability and are recorded on the key and echoed back when you
  list it. The recognized set is **`projects:write`** (the default — read/write) and **`projects:read`**
  (read-only); a create request for any other token (including the retired `approve` scope) is rejected
  with `validation` (400). A **revoked or expired** key is `401` (distinct from a `401` for an unknown
  credential). A JWT (a human) authenticates with full capability.
  - **Enforcement today.** The only scope-gated route was the per-page human-approval endpoint, removed in
    #1455 (translation is fully automatic, review is read-only). With it gone, the `approve` scope had
    nothing left to guard and was **removed in #2073**; `projects:read`/`projects:write` are carried as a
    forward-compatible seam but are **not yet enforced per route** — a valid key authenticates for every
    owner-scoped operation regardless of its scope. Finer read-vs-write route enforcement is additive
    future work (Feature #1410); when it lands it will only ever *tighten* a read-only key, never loosen
    the default write key, so it is a backward-compatible change under the §4b additive-only rule.

### 7a. Google / Apple OAuth at the sign-up gate (PBI #1891)

A third way to sign in / up, **alongside** email+password (which is unchanged), on the same gate:

| Method & route | Body | Returns |
|---|---|---|
| `POST /v1/auth/oauth/{provider}` | `{ credential }` | `{ token, user }` — the **same** JWT + account shape as `/auth/login` |

- `{provider}` is `google` or `apple`. `credential` is the **OIDC identity token** the provider's browser
  SDK returns (Google's ID token / Apple's identity token); the backend validates it against the provider's
  published JWKS (issuer + audience + signature + expiry) before trusting any claim.
- **Account resolution — all to the same `uid` model** (an `ExternalLogin` row links `(provider, subject)`
  → an `AppUser`): a returning provider identity → its linked account; else a **provider-verified** email
  matching an existing account → link this provider to it (so Google **and** Apple for one person, or an
  OAuth sign-in after an email+password signup, land on **one** account); else a new password-less account
  is created and linked. Linking by email is **only** ever done for a provider-verified address.
- **Enablement is per-deployment + surfaced in `/v1/meta`** (`googleOAuthEnabled` / `appleOAuthEnabled` +
  the public `googleOAuthClientId` / `appleOAuthClientId`), so the SPA only shows a button the backend can
  verify. Config: `BookTranslation:Auth:OAuth:GoogleClientId` / `AppleClientId` (public values, not secrets);
  a provider with no client id is **off** and the endpoint `400`s it. `BookTranslation:Auth:OAuth:UseFakeVerifier`
  wires a deterministic in-process verifier for dev/tests (the OAuth analogue of the Stripe/CoinPayments
  `UseFakeGateway`), so the whole flow is exercisable without real provider credentials.
- **Errors:** unsupported/disabled provider or missing credential → `400`; an unverifiable token → `401`; an
  existing but provider-**unverified** email → `409 email_taken` (sign in with the password, then link).
- **Go-live** with real Google/Apple credentials is gated with the rest of real-money go-live (**#1491**),
  exactly as the real payment rails are — this PBI ships the flow + the fake-verifier-verified path.

## 8. Rate limits (PBI #2071)

The public API is metered per **authenticated principal** so a runaway or hostile client can't drain GPU
budget or starve other tenants. Limits are keyed by the caller's `uid` — so a JWT (human) and an API key
both resolve to the same budget, **two keys for one account share it**, and **two accounts are isolated**.

### Tiers

All tiers are fixed-window (per minute) and **config-driven per environment** (defaults below; nothing is
hardcoded). Normal interactive use — the SPA, a well-behaved agent — is never throttled.

| Surface | Keyed by | Default | Config key (`BookTranslation:RateLimits:…`) |
|---|---|---|---|
| Authenticated default (the whole `/v1` authed surface) | `uid` | 300 / min | `DefaultPerMinute` |
| **Billable** processing (`/projects/{id}/process`, `/process-next-batch`) | `uid` | 20 / min | `BillableProcessingPerMinute` |
| Status/event poll (`…/status`, `…/events`) | `uid` | 120 / min | `StatusPollPerMinute` |
| Webhook management (`/v1/webhooks…`) | `uid` | 30 / min | `WebhookAdminPerMinute` |
| Anonymous estimate (`/v1/anonymous-estimate`) | caller IP | 10 / min | `AnonymousEstimate:RateLimitPerMinute` |
| Anonymous page image (picker thumbnails) | caller IP | 600 / min | `AnonymousEstimate:ImageRateLimitPerMinute` |

The billable tier is deliberately the tightest: each processing call can spend real GPU money. It is the
coarse **request** quota that complements the wallet gate (`402 insufficient_credit`) — the wallet refuses
work an account can't afford; this bounds the request *rate* on top, so a `$0` or abusive account can't
hammer the expensive path. An endpoint's own tier overrides the group default (the SignalR hub and the
anonymous payment webhooks are mapped outside the authed group, so neither is throttled).

### The `429` response

Exceeding a limit returns the same RFC 9457 envelope as every other error, with the stable code
`rate_limited`, plus the standard back-off headers so a client / the MCP server can wait exactly long
enough:

```
HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 20
RateLimit-Remaining: 0
RateLimit-Reset: 12

{ "type":"https://kenly/errors/rate_limited", "title":"Too many requests",
  "status":429, "code":"rate_limited", "detail":"You have exceeded the request rate limit. …" }
```

`Retry-After` and `RateLimit-Reset` are seconds until the window resets.

### Distribution + fail-open

Limiter state is held in **Redis** (the Aspire `cache` resource — the same Redis the idempotency store
uses), so limits hold across **every** API instance. It **fails open**, mirroring the idempotency store: if
Redis is unreachable the request is permitted rather than errored (rate-limit enforcement lapses for that
request, availability does not), and a down Redis never blocks startup. With no Redis configured (a bare
`dotnet run` or the tests) an in-memory counter with identical fixed-window semantics stands in.

## Out of scope (here)

Full OAuth 2.0 / OIDC as a general authorization grant is a later option under **#1356** — §7a covers
**identity-token sign-in** only. This document covers the **shape & safety** of responses, not new
transitions.
