# UnlikeOtherAuthenticator integration guide

UnlikeOtherAuthenticator (UOA) is a centralized OAuth and authentication service. A client application does NOT post raw configuration to UOA. The client exposes an HTTPS `config_url` that returns a signed RS256 config JWT, and UOA fetches and verifies that JWT on every auth request. There is no per-app OAuth client registration in UOA — the trust comes from the signed config JWT alone.

For machine-readable JSON, endpoint schemas, and config contracts, use [/api](/api).

## Two trust mechanisms (do not confuse them)

UOA uses two independent secrets. They cover different things and are stored in completely different places.

| Mechanism | Used for | Where it lives | Cryptography |
|---|---|---|---|
| **RS256 signing key + JWKS** | Signing the config JWT returned by your `config_url`. UOA verifies the signature on every fetch. | The PUBLIC JWK is registered with UOA (Phase 0 below). The PRIVATE key stays in your client backend only. | Asymmetric. RS256. UOA never sees the private key. |
| **Per-domain client secret** | Bearer authorization for backend-to-backend calls (`/auth/token`, `/auth/revoke`, `/domain/*`, etc.). | Created in the UOA Admin UI under **Configuration > Secrets** for one specific domain. Shown once. | Symmetric. `SHA256(domain + clientSecret)` is the bearer token. UOA stores only an HMAC digest of that hash. |

## Identifying UOA-issued values at a glance

Everything UOA mints is prefixed so you never mistake it for some other opaque string:

| Value | Prefix | Example |
|---|---|---|
| `client_secret` (per-domain) | `uoa_sec_` | `uoa_sec_2b9Xf…` |
| Claim token (embedded in claim URL) | `uoa_claim_` | `uoa_claim_7d4X…` |
| Public-JWK fingerprint (shown in admin + /api) | `uoa_fp_` | `uoa_fp_OYO4_OIgDb1…` |

The hashing rule is unchanged: **the entire string you were given** is the input. When UOA hands you `uoa_sec_abc123…`, pass that whole string as `clientSecret` into `SHA256(domain + clientSecret)`. Do not strip the prefix. `client_hash` is still the 64-hex SHA256 output; `hash_prefix` is still its first 12 hex chars.

You need BOTH to ship a working integration. Phase 0 + Phase 1 below cover them in order.

## Service discovery

- Home: [/](./)
- Admin UI: [/admin](/admin)
- LLM guide: [/llm](/llm) (this page)
- JSON API schema: [/api](/api)
- Config JWKS (RS256 public keys UOA accepts): [/.well-known/jwks.json](/.well-known/jwks.json)
- Apple domain association (when configured): [/.well-known/apple-developer-domain-association.txt](/.well-known/apple-developer-domain-association.txt)
- Health check: [/health](/health)
- Production-safe config validator: `POST /config/validate`
- DEBUG-only validator with custom JWKS: `POST /config/verify` (only when `DEBUG_ENABLED=true` and `NODE_ENV !== 'production'`)

---

## Phase 0 — Generate your RS256 signing keypair

Every config JWT is RS256-signed. You need an RSA-2048 keypair whose PUBLIC JWK is discoverable at a JWKS URL UOA can fetch, and whose PRIVATE key stays in your backend.

### 0.1 Generate an RSA-2048 keypair

OpenSSL:

```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out config-signing.private.pem
openssl rsa -in config-signing.private.pem -pubout -out config-signing.public.pem
```

Node (`jose` ≥ 5):

```js
import { generateKeyPair, exportJWK } from 'jose';

const { publicKey, privateKey } = await generateKeyPair('RS256', { modulusLength: 2048, extractable: true });
const publicJwk = await exportJWK(publicKey);
publicJwk.kid = 'voicepos-2026-04';   // pick a stable ID; rotate by adding a NEW kid, never reuse
publicJwk.alg = 'RS256';
publicJwk.use = 'sig';
console.log(JSON.stringify(publicJwk, null, 2));
```

### 0.2 Required public JWK shape

UOA's JWKS validator accepts ONLY these members and REJECTS the document if any private members are present.

```json
{
  "kty": "RSA",
  "kid": "voicepos-2026-04",
  "alg": "RS256",
  "use": "sig",
  "n": "<base64url modulus>",
  "e": "AQAB"
}
```

- `kty` MUST be `"RSA"`. EC, OKP, and oct keys are rejected.
- `kid` MUST be a non-empty string. It MUST be unique across all JWKs registered to your `domain`, and collisions with other partners' `kid`s are rejected.
- `n` and `e` MUST be non-empty base64url strings.
- `alg` SHOULD be `"RS256"` (the only algorithm UOA accepts for config JWTs).
- The following members are FORBIDDEN and will cause UOA to reject the JWK: `d`, `p`, `q`, `dp`, `dq`, `qi`, `oth`. Never paste a private key.

### 0.3 Store the private key in your client backend ONLY

The private PEM (or `privateKey` from `jose.generateKeyPair`) lives in your application backend's secret store. It must NEVER be committed to git, sent to UOA, or accessible from a browser. Rotate by generating a new key with a new `kid`, publishing it on your JWKS URL, and signing new config JWTs with the new `kid` — keep the old `kid` published until all caches expire (UOA caches the JWKS for ~10 minutes).

---

## Phase 1 — Auto-onboard with one `/auth` call

UOA uses **auto-onboarding**. There is no admin-side "Add Domain" button anymore; you register yourself by making one `/auth` call with a config JWT that contains two extra payload fields. A UOA superuser then sees your request in the admin console, approves it, and UOA emails you a one-time claim link.

### 1.1 Publish your JWKS URL on the SAME hostname as your `config_url`

Stand up a public HTTPS endpoint that returns a standard JWKS document with your public JWK:

```http
GET https://api.voicepos.unlikeotherai.com/.well-known/jwks.json

{ "keys": [ { "kty": "RSA", "kid": "voicepos-2026-04", "alg": "RS256", "use": "sig", "n": "...", "e": "AQAB" } ] }
```

- Hostname of `jwks_url` MUST equal the `domain` claim in your config JWT (case-insensitive). Cross-host JWKS are rejected with `INTEGRATION_JWKS_HOST_MISMATCH`.
- Same SSRF rules as `config_url`: HTTPS only, public DNS only, 5s timeout, 64 KiB cap, 3 redirects max.

### 1.2 Include `jwks_url` and `contact_email` in your config JWT payload

Add two optional fields to the payload described in Phase 2.4. These are only required on the auto-onboarding call; after approval they are inert but harmless.

```json
{
  "domain": "api.voicepos.unlikeotherai.com",
  "jwks_url": "https://api.voicepos.unlikeotherai.com/.well-known/jwks.json",
  "contact_email": "ops@voicepos.com",
  "redirect_urls": ["https://app.voicepos.unlikeotherai.com/oauth/callback"],
  "enabled_auth_methods": ["email_password", "google"],
  "ui_theme": { "...": "see /api" },
  "language_config": "en"
}
```

### 1.3 Make ONE `/auth` call to trigger auto-discovery

Open `/auth?config_url=<your_config_url>&redirect_url=<callback>&code_challenge=<S256>&code_challenge_method=S256` in a browser. UOA will:

1. Fetch and decode your config JWT (unverified) to read `jwks_url` and `contact_email`.
2. Verify `URL(jwks_url).hostname === payload.domain`.
3. Fetch `jwks_url` through the same SSRF-protected pipeline as `config_url`.
4. Verify the config JWT signature against the published public JWK.
5. Schema-validate the payload so it can store a safe `config_summary`.
6. Insert a PENDING row in `client_domain_integration_requests` (or touch the existing one).
7. Render a friendly **"Integration pending review"** page. No auth flow runs yet.

The browser now shows the pending page. Do not retry in a loop — UOA has everything it needs.

### 1.4 Wait for the approval email

A UOA superuser sees your request in **/admin > New Integrations**, inspects the fingerprint, `jwks_url`, and verified `config_summary`, and clicks **Accept**. UOA:

- Creates the `client_domains` row, the first `client_domain_jwks` row, and a new client secret inside one DB transaction.
- **Delivers the credentials one of two ways** depending on what the superuser picked on Accept:
  - **Email claim link (default):** `contact_email` receives a link of the form `https://<uoa-host>/integrations/claim/<token>`. The token is single-use and expires after 24 hours. You claim the secret yourself.
  - **Reveal to admin:** the superuser sees `domain`, `client_secret`, `client_hash`, and `hash_prefix` once in the admin UI and passes them to you through your own secure channel. No email is sent.

If the superuser declines, no email is sent. Contact your UOA superuser if you expected approval but did not receive an email (or a secret from them directly) within a business day.

### 1.5 Open the claim link and copy the secret ONCE

1. Open the claim link in a browser. You will see a "Confirm" page — link scanners and email previewers cannot burn the token because consumption requires a `POST`.
2. Click **Reveal secret**. UOA POSTs to `/integrations/claim/:token/confirm`, marks the token used, and renders the one-time reveal page containing `domain`, `client_secret`, `client_hash`, and `hash_prefix`.
3. Copy `client_secret` and `client_hash` into your backend secret store immediately. The page warns "This is the only time this secret will be displayed." Refreshing or re-opening the link returns the invalid-link page.

**What UOA stores.** UOA never persists the raw secret. `client_domains` holds the domain row, and `client_domain_secrets` holds an HMAC-SHA256 digest of `SHA256(domain + clientSecret)` keyed with the deployment-wide `SHARED_SECRET`, plus a 16-character display prefix used to identify the active secret in the admin UI. There is no decryption path.

**Computing the bearer token from the client secret.** If you only stored `client_secret`, recompute the bearer at runtime:

```js
import { createHash } from 'node:crypto';
const clientHash = createHash('sha256').update(domain + clientSecret).digest('hex'); // 64 hex chars
// Authorization: Bearer <clientHash>
```

**Resend claim link.** If you lose the email before clicking, ask a UOA superuser to use **Resend claim link** on the accepted request. The old token is revoked and a fresh one is emailed (or revealed in-UI if the superuser picks reveal mode on resend).

**Disabling.** A superuser can set a domain to `disabled` on the Secrets page; UOA rejects every domain bearer request for that domain until it is re-enabled.

### 1.6 Adding or deactivating signing keys later

Once your domain is registered, a superuser can add additional RSA JWKs through **Admin > Secrets > domain > Signing Keys**, or deactivate an old `kid`. Rotation flow: publish the new `kid` on your JWKS URL, ask a superuser to register it, start signing with the new `kid`, and once traffic with the old `kid` drains the superuser deactivates the old row.

---

## Phase 2 — Implement your `config_url` endpoint

Expose a public HTTPS GET endpoint on the SAME hostname you registered as the domain. UOA fetches it server-side on every `/auth` request.

### 2.1 Network requirements UOA enforces

- HTTPS only. HTTP, file, ftp, gopher schemes are rejected.
- Public DNS only. UOA resolves the hostname and rejects loopback, link-local, RFC1918 private ranges, IPv4-mapped IPv6, NAT64, multicast, and unspecified addresses. Cloud Run egress will reach you over public internet — your endpoint must be reachable from there.
- Hard timeout: 5 seconds end-to-end including redirects.
- Max 3 redirects. Each hop is re-validated with the same SSRF rules.
- Max response body: 64 KiB. Anything larger is rejected.
- UOA sends `Accept: text/plain, application/json` and `User-Agent: UnlikeOtherAuthenticator/config-fetch/<version>`.

### 2.2 Accepted response formats

UOA accepts ANY of these as the body of a `200 OK` response. Pick whichever is easiest:

1. **Bare JWT** (preferred): the response body is the JWT compact serialization, three base64url segments separated by dots. `Content-Type: application/jwt` or `text/plain`.
2. **`Bearer <jwt>`**: the body starts with the literal string `Bearer ` followed by the JWT.
3. **JSON envelope**: `{ "jwt": "<jwt>" }` — the field name may be `jwt`, `token`, `config_jwt`, `configJwt`, or `configJWT`. `Content-Type: application/json`.

Anything else (HTML, error JSON, empty body) fails with `CONFIG_FETCH_FAILED`.

### 2.3 Required JWT header

```json
{ "alg": "RS256", "kid": "voicepos-2026-04", "typ": "JWT" }
```

- `alg` MUST be exactly `"RS256"`. `HS256`, `none`, `ES256`, `PS256`, etc. are rejected.
- `kid` MUST be present, non-empty, and MUST resolve to a registered JWK — either a `client_domain_jwks` row for your domain, or the legacy deployment-wide `CONFIG_JWKS_JSON`. On the FIRST `/auth` call from a new domain the `kid` will not yet be registered; that is the signal that triggers auto-discovery against your `jwks_url` (see Phase 1). All subsequent calls must use a `kid` that resolves directly in UOA's tables — otherwise the request fails with `CONFIG_JWT_INVALID`.
- `typ` is optional but recommended.

### 2.4 Required payload fields

```json
{
  "domain": "api.voicepos.unlikeotherai.com",
  "jwks_url": "https://api.voicepos.unlikeotherai.com/.well-known/jwks.json",
  "contact_email": "ops@voicepos.com",
  "redirect_urls": ["https://app.voicepos.unlikeotherai.com/oauth/callback"],
  "enabled_auth_methods": ["email_password", "google"],
  "ui_theme": { "...": "see /api for the full ui_theme contract" },
  "language_config": "en"
}
```

- `domain` MUST exactly equal the hostname of the `config_url` UOA fetched. Mismatch fails with `CONFIG_DOMAIN_MISMATCH`.
- `jwks_url` and `contact_email` are **required on the first auto-onboarding call** and optional thereafter. `jwks_url` MUST be HTTPS and share the hostname of `domain`. `contact_email` is where UOA sends the one-time claim link on approval. See Phase 1 for the full flow.
- `redirect_urls` MUST be a non-empty array of redirect targets. Each entry is one of: an `https://` URL (any host); an `http://` URL on loopback only (`localhost`, `127.0.0.1`, or `[::1]` — for a native app's transient loopback listener, RFC 8252); or a native custom-scheme deep link (e.g. `com.acme.app://callback`, `nessie://auth/callback`). Non-loopback `http://` and dangerous schemes (`javascript:`, `data:`, …) are rejected. The runtime `redirect_url` must match one of these entries **byte-for-byte**, including scheme, host, port, path, AND query string. No normalization, no prefix matching, no query wildcards. If you need to propagate per-request CSRF / PKCE state, carry it out-of-band (`sessionStorage`, first-party cookie) — never on the URL. See Phase 3.1.
- `enabled_auth_methods` MUST be a non-empty array. Allowed values: `email_password`, `google`, `facebook`, `github`, `linkedin`, `apple`. There is no separate social-provider allowlist — listing a provider here both enables and allows it.
- `ui_theme` is required. See `/api` for the full contract (colors as hex only, radii/font sizes as CSS lengths, button + card styles, logo with required `url` and `alt`).
- `language_config` is one IETF code or a non-empty array of codes. To open the sign-in page in a specific language, append the OIDC `ui_locales` param to `/auth` (e.g. `&ui_locales=cs`). Precedence: first supported `ui_locales` tag (primary subtag, so `cs-CZ` → `cs`) → the config `language` claim → the first `language_config` entry. Emails sent from the flow use the language the page currently shows (the page sends it as `Accept-Language`), falling back to the browser's `Accept-Language`; hand-authored email locales are `en` and `cs` (anything else gets English).

The payload MUST NOT contain `SHARED_SECRET`, the `client_secret` from Phase 1, the `client_hash`, refresh tokens, OAuth codes, or any other secret. UOA scans for known secret patterns and refuses the config if it sees one.

Optional fields are documented at `/api` under `config_jwt_documentation.optional_fields`, including `2fa_enabled`, `debug_enabled`, `user_scope`, `allow_registration`, `existing_user_registration_behavior`, `registration_mode`, `allowed_registration_domains`, `registration_domain_mapping`, `session.*`, `org_features.*`, and `access_requests.*`.

### 2.5 Two-factor policy model

`2fa_enabled` in the config JWT is the master gate. If it is false or absent, UOA treats 2FA as off for that integration even if an Admin policy exists. If it is true, UOA resolves the effective policy from the DB-backed Service/domain policy, same-domain Organisation policies, and the exact selected Organisation even when a recognized product selected it across domains. The strongest policy wins: `off < optional < required`. Recognized products resolve exact scope before 2FA even with `workspace_selection: "off"`; authorization-code exchange rechecks current policy/enrollment against persisted interactive TOTP proof before creating tokens.

- `off`: login proceeds without a TOTP prompt.
- `optional`: users may enroll themselves; enrolled users must verify a code at login.
- `required`: an unenrolled user must complete setup before UOA grants an auth code.

Normal self-service setup uses `POST /2fa/setup` with `X-UOA-Access-Token`, then `POST /2fa/enroll` with the returned `setup_token` and TOTP code. In required-login flows, UOA returns `twofa_enroll_required` plus a short-lived setup token before any auth code exists; the Auth UI uses that token to render the QR and complete `/2fa/enroll`, which then resumes the normal auth-code finalization path. `POST /2fa/disable` requires a current TOTP code and is blocked while the effective policy is `required`.

### 2.6 Sign the JWT

```js
import { SignJWT, importPKCS8 } from 'jose';

const privateKey = await importPKCS8(process.env.UOA_CONFIG_SIGNING_PRIVATE_KEY_PEM, 'RS256');
const jwt = await new SignJWT(payload)
  .setProtectedHeader({ alg: 'RS256', kid: 'voicepos-2026-04', typ: 'JWT' })
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(privateKey);
return new Response(jwt, { headers: { 'content-type': 'application/jwt' } });
```

`exp` is **recommended**: UOA verifies the config JWT with a 30s clock tolerance, so an `exp` that has passed is rejected, but a JWT with no `exp` is accepted as unbounded — set a short lifetime (≈15m) so a leaked JWT cannot be replayed forever. `iss` is **not** required or checked on the config-signing path; UOA's trust anchor is the `domain` claim matching the `config_url` host. Do NOT cache the JWT for long (UOA fetches every request and caches the JWKS for ~10 minutes). Re-signing per request is fine; the limit is 5 seconds per fetch.

---

## Phase 3 — Trigger the auth flow

Open the auth UI from the browser:

```text
GET /auth?config_url=<your_config_endpoint_url>
        &redirect_url=<your_callback_url>
        &code_challenge=<S256_challenge>
        &code_challenge_method=S256
```

- `config_url` is the HTTPS endpoint from Phase 2. UOA URL-decodes it before fetching.
- `redirect_url` MUST appear EXACTLY in `redirect_urls` from Phase 2's payload.
- PKCE is mandatory: generate a random 43-128 char `code_verifier`, hash it with SHA-256, base64url-encode, and pass as `code_challenge`. `code_challenge_method` MUST be `S256`.
- Sign in with Apple uses `response_mode=form_post`; Apple POSTs to `/auth/callback/apple` with `application/x-www-form-urlencoded` `code`, `state`, and optional first-consent `user` JSON.

After the user authenticates, UOA redirects to `<redirect_url>?code=<authorization_code>`. The code is single-use and short-lived; treat it as sensitive.

### 3.1 Carrying CSRF / PKCE state across the callback

`redirect_url` is matched byte-for-byte against `config.redirect_urls`, **including the query string**. If you normally round-trip OAuth state as a query parameter (`?state=…`), UOA will reject the request with `REDIRECT_URL_NOT_ALLOWED`.

Pick one of these transports instead:

- **`sessionStorage` (recommended).** On `/start`, return the opaque state token alongside the redirect URL. Stash it in `sessionStorage` under a provider-scoped key, then read-and-delete on the callback page before POSTing to your token-exchange endpoint. Binds the token to the originating tab and avoids URL mutation.
- **First-party cookie.** Set a `__Host-sso_state` cookie with `SameSite=Lax; Secure; HttpOnly; Path=/auth/callback`. Works across full page reloads; requires a same-origin callback.
- **Fragment (`#state=…`).** Only viable if the callback is a SPA; the browser strips fragments before the request hits UOA, so UOA won't see it. Fragile and easy to misuse — prefer `sessionStorage`.

Do NOT append `state` (or any per-request query parameter) to `redirect_url`. The allowlist match is exact, and every added byte will be rejected.

**Worked example — sessionStorage round-trip.**

```ts
// /start — backend returns the redirect URL and an opaque state token separately.
// GET https://app.example.com/sso/start -> { redirectUrl, stateToken }

// Caller (Admin UI):
const { redirectUrl, stateToken } = await fetch('/sso/start').then((r) => r.json());
sessionStorage.setItem('sso:state:google', stateToken);
window.location.assign(redirectUrl); // redirectUrl == one of config.redirect_urls, verbatim

// /auth/callback — UOA has appended ?code=… to the exact allowlisted URL.
const code = new URLSearchParams(window.location.search).get('code');
const stateToken = sessionStorage.getItem('sso:state:google');
sessionStorage.removeItem('sso:state:google'); // read-and-delete: single use

await fetch('/sso/exchange', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ code, stateToken }), // backend validates stateToken, then calls POST /auth/token
});
```

The backend validates `stateToken` against whatever it issued in `/start` (short TTL, single use, bound to session) before calling `POST /auth/token`. The state token never touches `redirect_url`.

---

## Phase 4 — Backend token exchange

Sections 4.1–4.7 describe the legacy authorization-code / refresh-token profile.
Section 4.6a documents the separate confidential JWT assertion grant and its
resource-verifiable RS256 token.

This call is server-to-server. The browser MUST never see the bearer token.

```text
POST /auth/token?config_url=<your_config_endpoint_url>
Authorization: Bearer <client_hash from Phase 1>
Content-Type: application/json

{
  "code": "<authorization_code>",
  "redirect_url": "<same callback URL used in Phase 3>",
  "code_verifier": "<the PKCE verifier whose SHA-256 produced code_challenge>"
}
```

### 4.1 Canonical response body

The authorization-code grant returns exactly the shape below. **There is no top-level `user` field.** User identity is carried as claims inside `access_token`. If your RP code reads `response.user.id` you will always get `undefined` — decode the JWT and read `sub` instead.

```json
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<payload>.<sig>",
  "expires_in": 1800,
  "refresh_token": "<opaque, server-side only>",
  "refresh_token_expires_in": 2592000,
  "token_type": "Bearer",
  "firstLogin": {
    "memberships": {
      "orgs":  [{ "orgId": "org_…", "role": "member" }],
      "teams": [{ "teamId": "tm_…", "orgId": "org_…", "role": "member" }]
    },
    "pending_invites": [
      { "inviteId": "inv_…", "type": "team", "orgId": "org_…", "teamId": "tm_…", "teamName": "…" }
    ],
    "capabilities": { "can_create_org": false, "can_accept_invite": true }
  }
}
```

Store the refresh token server-side ONLY; browser clients never receive or persist refresh tokens. `firstLogin` is only present on the authorization-code grant; refresh-token grants never include it.
Presenting an already-rotated token is treated as theft/replay: UOA durably revokes the complete
family before returning the normal authentication failure, so the current replacement cannot be
used afterward. Replace stored refresh state atomically and never retry an older token.
Refresh, logout, and global credential recovery are serialized per user. When logout or a password/
2FA recovery returns, no concurrent refresh replacement or access token can have escaped it.

If optional agreement signatures are enabled for the domain, a newly published version or revoked signature can make the next refresh return the normal authentication failure. UOA deliberately leaves that still-valid refresh token unconsumed and unrotated; restart the interactive authorization flow so the authenticated user can review/sign the current version. Do not retry refresh in a loop.

If UOA rotated the refresh token but its successful response was lost, retry the
same predecessor within 120 seconds using the same application credential and
exact config/client context. UOA returns the one already-created current
successor (and its actual remaining lifetime) instead of rotating again.
Concurrent retries converge on that same value. After 120 seconds, predecessor
reuse is theft detection: UOA revokes the family and increments the user's
access-token version. Persist a successful UOA successor and access-token state
atomically. If only your own downstream response was lost after that local
commit, replay the locally persisted result rather than calling UOA again.

**Field-casing warning.** The outer envelope is snake_case (`access_token`, `refresh_token`, `expires_in`, `refresh_token_expires_in`). The key `firstLogin` itself and the IDs inside `memberships.*` and `pending_invites[]` (`orgId`, `teamId`, `inviteId`, `teamName`) are camelCase. `pending_invites` and `capabilities.can_*` are snake_case. Do not assume one style throughout.

### 4.2 Legacy access-token JWT claims

The `access_token` is a JWT (compact JWS, three base64url segments). Decode the payload — no signature verification on the RP side (see the trust-model note below).

| Claim | Source | Meaning |
|---|---|---|
| `sub` | standard | **Stable external user id.** Use this as the RP's foreign key into the UOA user. |
| `email` | custom | User's primary email. Advisory — user may change it; `sub` is the stable identity. |
| `role` | custom | **Platform-side UOA role** — `"user"` or `"superuser"`. Do NOT use this for tenant/org authorization. See 4.4. |
| `domain` | custom | The integration domain from your config JWT. Confirms which integration minted this token. |
| `client_id` | custom | `SHA256(domain + clientSecret)` hex. Identifies the exact client credential used. |
| `org` | custom (optional) | Present only when `org_features.enabled` and the user has an org on this domain. Shape: `{ org_id, org_role, teams[], team_roles{}, groups?[], group_admin?[] }`. |
| `iss` | standard | UOA host, e.g. `authentication.unlikeotherai.com`. |
| `aud` | standard | Always `"uoa:access-token"`. |
| `iat`, `exp` | standard | Epoch seconds. Respect `exp`. |

Minimal decode (no verification):

```ts
import { decodeJwt } from 'jose';
const claims = decodeJwt(response.access_token);
const userId = claims.sub;                 // stable
const email = claims.email as string;      // advisory
const platformRole = claims.role as 'user' | 'superuser';
```

### 4.3 Legacy trust model — authorization-code / refresh tokens are HS256-signed

Access tokens are signed with `HS256` using the deployment-wide `SHARED_SECRET`. **RPs cannot and should not cryptographically verify them.** The config JWKS at `/.well-known/jwks.json` is for verifying RS256 *config* JWTs, not access tokens, and there is no UOA-side public JWKS for access tokens.

The RP trust model is channel-based:

1. You received the `access_token` as the body of an HTTPS response to your backend's `POST /auth/token` call.
2. That call was authenticated with your per-domain `client_hash` bearer, which only UOA and your backend know.
3. You passed `code` + `code_verifier` (PKCE) that only your tab could have produced.

Because all three hold, the token's issuer is UOA by construction. Do not expose `access_token` to the browser; do not forward it to third parties; and treat it as opaque beyond decoding claims for user identity / expiry. When you need to validate a presented access token later, call UOA (e.g. use it in the `X-UOA-Access-Token` header against UOA's own endpoints such as `GET /org/me`) rather than attempting local verification.

### 4.4 Which role to honour for authorization

The JWT `role` claim (`"user"` | `"superuser"`) is the **UOA platform role** — it gates access to UOA's own admin surfaces, NOT to the RP's business features. It is almost never the right role for RP authorization decisions.

Use this precedence inside your RP:

1. **Per-tenant role:** `firstLogin.memberships.orgs[].role` (on first login) — subsequently, fetch the current role via `GET /org/me`. This is what your RP should honour for org-scoped authorization.
2. **Per-team role:** `firstLogin.memberships.teams[].role`.
3. **Platform role (`claims.role`):** only relevant if the RP itself is a UOA-internal admin surface. Treat unknown values as `"user"`.

`superuser` in the JWT does NOT mean the user is an admin *inside your product*; it only means they can use UOA's admin UI.

### 4.5 First-login tenant bootstrapping — empty memberships

When `firstLogin.memberships.orgs` is empty, the user is authenticated but has no tenant on this domain yet. Do NOT fall back to a synthetic tenant (`"default"`, the user's email domain, etc.) — you will cross-contaminate users. Branch on `capabilities`:

| `capabilities.can_create_org` | `capabilities.can_accept_invite` | RP action |
|---|---|---|
| `true` | any | Show "Create your organisation" UI. Your backend calls `POST /org/organisations?domain=<d>&config_url=<u>` with domain-hash auth **and the user's `X-UOA-Access-Token` header**; the body is `{ name }` only — the new org is owned by that token's user (there is no `owner_id` in the body). After success, re-issue the session and re-fetch `GET /org/me`. |
| `false` | `true` | User has a pending invite. Show "Accept invitation" UI; the invite link is delivered by email from UOA — or you can resolve it yourself via `firstLogin.pending_invites[0]`. |
| `false` | `false` | No tenant and no path to one. Reject the login with a "Contact your administrator" screen — do NOT silently grant access. Your UOA superuser must provision the org/team. |

The server-side behaviour controlling whether the first-login payload is already non-empty is set in the config JWT — see `registration_domain_mapping`, `org_features.auto_create_personal_org_on_first_login`, and `org_features.pending_invites_block_auto_create` at `/api`.

### 4.6 Reference implementation — authorization code → RP session

```ts
import { decodeJwt } from 'jose';

const res = await fetch(
  `${UOA}/auth/token?config_url=${encodeURIComponent(CONFIG_URL)}`,
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${CLIENT_HASH}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      code,
      redirect_url: redirectUrl,
      code_verifier: codeVerifier,
    }),
  },
);
if (!res.ok) throw new Error('UOA token exchange failed');
const body = await res.json() as {
  access_token: string;
  expires_in: number;
  refresh_token: string;
  refresh_token_expires_in: number;
  token_type: 'Bearer';
  firstLogin?: {
    memberships: { orgs: Array<{ orgId: string; role: string }>; teams: Array<{ teamId: string; orgId: string; role: string }> };
    pending_invites: Array<{ inviteId: string; type: 'team'; orgId: string; teamId: string; teamName: string }>;
    capabilities: { can_create_org: boolean; can_accept_invite: boolean };
  };
};

const claims = decodeJwt(body.access_token);
const externalUserId = claims.sub!;                // stable user id
const email = String(claims.email);                // advisory

const firstOrg = body.firstLogin?.memberships.orgs[0];
if (!firstOrg) {
  if (body.firstLogin?.capabilities.can_create_org) return redirectToCreateOrg();
  if (body.firstLogin?.capabilities.can_accept_invite) return redirectToAcceptInvite();
  return redirectToContactAdmin();
}
const tenantId = firstOrg.orgId;
const tenantRole = firstOrg.role;                  // use THIS for authz, not claims.role

await storeRefreshTokenServerSide(body.refresh_token, body.refresh_token_expires_in);
await issueRpSession({ externalUserId, email, tenantId, tenantRole });
```

Server-side behaviour on first verified login is controlled by `org_features`:

- `registration_domain_mapping` (top-level config) places the user into a configured org + team when the email domain matches.
- `auto_create_personal_org_on_first_login` (default `false`) creates a personal org with the user as `owner` plus a default team when no mapping matches. Skipped when `pending_invites_block_auto_create` is `true` and a pending invite exists for the email.
- `allow_user_create_org` (default `false`) gates `POST /org/organisations` for end-users. Superusers bypass. Keep `false` for admin-provisioned tenants.

### 4.6a Per-product confidential assertion exchange

A registered backend can exchange a short-lived user assertion, optionally
scoped to a selected workspace, for a resource-bound token without forwarding
its normal UOA access token or its domain credential to the resource server.
The calling product MUST authenticate this request with its own existing
per-domain app credential. UOA resolves that authenticated ClientDomain plus the
explicit product against an enabled database mapping containing one exact HTTPS
resource and a scope allowlist. There is no shared cross-product credential and
no singleton source/resource env fallback. This is a separate RFC 8693-style
grant on the same backend-only endpoint; the authorization-code and refresh
grants above remain unchanged.

```ts
import { SignJWT } from 'jose';

const now = Math.floor(Date.now() / 1000);
const workspace = orgId && teamId ? { active: { orgId, teamId } } : {};
const subjectToken = await new SignJWT({
  source_domain: 'api.nessie.works',
  ...workspace,
})
  .setProtectedHeader({ alg: 'RS256', kid: NESSIE_CONFIG_KEY_ID, typ: 'JWT' })
  .setIssuer('api.nessie.works')
  .setAudience('https://authentication.unlikeotherai.com/auth/token')
  .setSubject(uoaUserId) // stable UOA sub, never email
  .setJti(crypto.randomUUID())
  .setIssuedAt(now)
  .setExpirationTime(now + 60)
  .sign(nessieConfigPrivateKey);

const response = await fetch(
  'https://authentication.unlikeotherai.com/auth/token?config_url=' +
    encodeURIComponent(NESSIE_CONFIG_URL),
  {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${NESSIE_DOMAIN_HASH}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
      subject_token: subjectToken,
      subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
      product: 'nessie',
      resource: 'https://ledger.unlikeotherai.com',
      scope: 'ai.invoke billing.read',
    }),
  },
);
```

Before traffic is enabled, a UOA superuser creates the corresponding mapping
through `/internal/admin/confidential-delegations`. Source domain and product
are immutable after creation; resource, allowlisted scopes, and enabled state
are audited mutable policy. Nessie, DeepWater, DeepSignal, and DeepTest therefore
use their own registered domains and credentials even when their target resource
is the same Ledger deployment. A credential rotation remains valid because the
mapping binds the registered ClientDomain, never a plaintext secret, hash, or
individual secret row.

The source config JWT MUST publish `jwks_url` on the source domain. UOA fetches
that JWKS through its SSRF-protected, same-host pipeline and requires RS256 +
`kid`. The assertion requires exact `iss` and `source_domain`, exact
`aud = PUBLIC_BASE_URL + "/auth/token"`, stable UOA `sub`, non-empty `jti`,
`iat`/`exp` no more than 60 seconds apart. `active` is optional for
first-time or workspace-less users. When present it must be exactly
`{ orgId, teamId }` with both values non-empty; partial or malformed workspace
objects are rejected. Mint a fresh unique `jti` for every attempt: after
identity and optional workspace validation, UOA atomically consumes that
source-domain + `jti` once through `exp` plus clock tolerance. Exact and
concurrent replays are rejected across service instances.

UOA never trusts the assertion as current identity state. Before every issue it
re-reads the user and source-domain role. When `active` is supplied it also
re-reads the requested ACTIVE org and team memberships. Unknown users, missing
domain roles, and removed/deactivated or cross-org/team selections are rejected.

```json
{
  "access_token": "<5-minute RS256 JWT>",
  "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "token_type": "Bearer",
  "expires_in": 300,
  "scope": "ai.invoke billing.read"
}
```

The issued token is verified with `GET /oauth/jwks.json` and contains
`iss`, resource `aud`, stable `sub`, advisory `email`,
`source_domain`, non-secret `azp` (the source domain), `product`, the exact
requested `scope` subset, `jti`, `iat`, and `exp`. A validated workspace
adds current `org` and selected
`active`; an identity-only exchange omits both. It contains no
`client_id` and never contains the 64-character domain-hash bearer credential.
This grant returns no refresh token.

For a chained hop, the next product authenticates with that product's own
registered config and domain-hash credential. It submits the already UOA-issued
token whose `aud` is exactly that product's HTTPS API origin:

```json
{
  "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
  "subject_token": "<UOA RS256 at+jwt issued to DeepSignal>",
  "subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
  "product": "deepsignal",
  "resource": "https://ledger.unlikeotherai.com",
  "scope": "ai.invoke"
}
```

UOA binds the request credential and mapping to DeepSignal, verifies the inbound
UOA signature/issuer/expiry and exact DeepSignal audience, requires non-null
`org` + `active`, and revalidates the original source product mapping,
stable user/source-domain role, ACTIVE organisation, and ACTIVE selected team.
The requested scope must be allowed by DeepSignal's mapping and be a subset of
the inbound scope. The result never outlives the inbound token.

The chained result identifies the immediate caller through
`source_domain`, `azp`, and `product`, while `act` preserves upstream
product provenance (for Nessie→DeepSignal:
`{"sub":"api.nessie.works","product":"nessie"}`). A chained access-token
subject remains reusable until `exp`; only the first-hop source JWT assertion
is one-time. This supports concurrent multi-process calls without weakening the
exact audience, app-credential, workspace, or scope checks.

Unknown or disabled mappings, a product selected with another app credential,
an inexact resource (including path/trailing-slash differences), duplicate or
unsupported scopes, and scope widening all fail closed before assertion
verification. Supported delegation scopes are `ai.invoke`, `billing.read`,
and `token.provision`; the last is a separate high-privilege app capability
and is never implied by `ai.invoke`. The response and token contain only what that request asked
for, never the full mapping allowlist.

The confidential grant is rate-limited per authenticated source domain
(600/minute) and per verified source-domain user (60/minute), so users behind
one Nessie egress IP do not consume a shared 10/minute bucket.

### 4.6b Server-to-server organisation/team management (backend mode)

Every `/org/*` route already authenticates the **domain pairing**: the
per-domain hash bearer in `Authorization`, plus the partner's signed config JWT
fetched from `?config_url=` and tied to `?domain=`. That pairing proves *"this
is the backend for domain X"*, and it is the only authentication several `/org`
routes have ever needed (`GET /org/organisations`, the bulk branch of
`POST .../invitations`, the access-request family).

**Backend mode simply omits `X-UOA-Access-Token`.** There is no second
credential, no token exchange, and no extra header.

**Omit the header — do not send it empty.** A header that is present but blank
(an empty string, spaces, a tab, a newline) is a malformed credential, not an absent one,
and is `401 MISSING_ACCESS_TOKEN`. This bites the common BFF shape: if you
attach the domain-hash bearer server-side and forward the end user's session
token, an anonymous visitor forwards an empty string — and that must stay a 401
rather than silently becoming a whole-tenant backend call. Send the header only
when you actually have a token. A repeated `X-UOA-Access-Token` header is
rejected for the same reason.

```text
POST /org/organisations/<orgId>/teams?domain=<d>&config_url=<u>
Authorization: Bearer <client_hash>
Content-Type: application/json

{ "name": "Kitchen" }
```

**Opt in first.** Set `org_features.backend_org_management: true` in the signed
config for that domain. It defaults to `false`, and while it is `false` a
missing `X-UOA-Access-Token` is still `401 MISSING_ACCESS_TOKEN` exactly as
before. The flag is not a new credential — it is a second secret in the path: an
attacker who steals only the domain-hash bearer still cannot turn the flag on,
because the config JWT is signed with the partner's own private key.

**There is no acting user.** That is the whole point, and it has consequences:

- Per-user org/team role checks (owner/admin, team manager, "must be a member")
  do not apply — there is no user to check them against, and the pairing already
  proves authority over the entire tenant, which outranks any single member's
  role.
- Every check that is NOT about the acting user is unchanged and applies to both
  modes: the organisation must belong to the verified domain, the last owner
  cannot be removed, membership and team caps still hold, one-org-per-domain
  still holds, a user cannot be removed from their last team.
- Where a route needs to name a user, name it explicitly. `POST
  /org/organisations` takes `owner_user_id`; the member routes already take
  `userId`. Nothing is inferred.
- A named `owner_user_id` must **belong to your domain** — the user must have
  authenticated there at least once (UOA records a domain role on login).
  Existence alone is not enough: with the default `user_scope: global`, user
  rows are visible across domains, so "the id resolves" would let you name a
  stranger.

The three org-level checks below are **deliberately not enforced** in backend
mode, because each protects members from one another and the backend is not a
member: only-the-owner-may-change-roles, only-the-owner-may-delete-the-org, and
the `allow_user_create_org` gate. Everything in the bullet above this one still
applies.

**Domain isolation is absolute.** The call binds to the domain in the VERIFIED
config, never the raw query string — a `?domain=` that differs is
`400 DOMAIN_MISMATCH`. Every handler then resolves the organisation as
`(orgId, verified domain)`, so an `:orgId` that belongs to another domain is a
plain `404`. A backend for domain X cannot see or touch domain Y.

**Per route, in backend mode:**

| Route | Backend mode |
|---|---|
| `GET /org/me` | **No** — 401. It answers "who am I", which has no meaning without a caller. |
| `GET /org/organisations` | Yes (already was). |
| `POST /org/organisations` | Yes. Body **must** carry `owner_user_id`; `allow_user_create_org` does not apply. |
| `GET|PUT|DELETE /org/organisations/:orgId` | Yes. |
| `GET|POST /org/organisations/:orgId/members` | Yes. `POST` takes `userId` as today. |
| `PUT|DELETE .../members/:userId` | Yes. |
| `POST .../members/:userId/deactivate|reactivate` | Yes. |
| `POST .../transfer-ownership` | Yes. Demotes the org's **current owner** to admin. |
| `GET .../groups`, `GET .../groups/:groupId` | Yes. |
| `GET .../teams` | Yes — and lists `HIDDEN` teams too; that filter is member-to-member discovery. |
| `POST .../teams` | Yes. |
| `GET .../teams/:teamId` | Yes, including `?include=invited`. |
| `PUT|DELETE .../teams/:teamId` | Yes. |
| `POST .../teams/:teamId/members` | Yes. Takes `userId` as today. |
| `PUT|DELETE .../teams/:teamId/members/:userId` | Yes. |
| `GET|PUT|DELETE .../teams/:teamId/avatar` | Yes. |
| `POST .../teams/:teamId/join` | **No** — 401. Self-join's subject IS the acting user. |
| `POST|GET .../teams/:teamId/invitations`, `.../resend` | Yes (already was — this is the bulk-invite contract). |
| `POST|GET|DELETE .../teams/:teamId/invite-links` | Yes. A link created this way has `created_by_user_id: null`. |
| `GET .../invitations?approval=pending` | Yes. |
| `POST .../invitations/:inviteId/approve|deny` | Yes. No reviewer is recorded. |
| `GET|POST .../access-requests…` | Yes (already was). Optional `reviewedByUserId` in the body, unchanged — it is a label, never an actor. The `:orgId`/`:teamId` must be your own domain's, even when your signed config names them as the access-request target. |

**Errors specific to this mode:**

| Status | Code | Meaning |
|---|---|---|
| 401 | `MISSING_ACCESS_TOKEN` | No user token and `backend_org_management` is not `true` (or the domain-hash guard did not pass). Also: an `X-UOA-Access-Token` that is present but blank (`""`, whitespace, `"Bearer "`) — a blank credential is malformed, never "omitted". |
| 401 | `ACCESS_TOKEN_NOT_ALLOWED` | `GET /org/organisations` is backend-only and has no user mode. It refuses ANY present `X-UOA-Access-Token`, valid or blank — omit the header. For a user's own workspaces use `GET /org/me`. |
| 400 | `DOMAIN_MISMATCH` | `?domain=` does not equal the verified config `domain`. |
| 400 | `OWNER_REQUIRED` | `POST /org/organisations` in backend mode without `owner_user_id`. |
| 400 | `OWNER_NOT_ALLOWED` | `POST /org/organisations` with a user token AND `owner_user_id` — ambiguous. |
| 400 | (generic) | `owner_user_id` names a user who does not exist, does not belong to this domain, or already has an active organisation here. |
| 429 | `RATE_LIMITED` | Backend organisation creation is capped per domain per hour (well above normal provisioning volume). The end-user path keeps its own, much lower, per-user cap. |
| 404 | `ORG_FEATURES_DISABLED` | `org_features.enabled` is false. |
| 404 | (generic) | The `:orgId` is not on this domain. |

**Audit attribution.** A backend-initiated mutation writes
`actor_user_id: null` and records who acted under the reserved `uoa_actor` key
of `OrgAuditLog.metadata`:

```json
{ "uoa_actor": { "via": "domain_backend", "source_domain": "api.hugopos.eu" } }
```

Rows without that key were made by a user directly.

### 4.7 Organisation member lifecycle — deactivate, reactivate, soft-remove

Membership rows carry a `status`: `ACTIVE` | `DEACTIVATED` | `REMOVED`. Deactivation suspends access without deleting history (Slack's "deactivate", not "kick"); removal is a tombstone, not a hard delete, so audit history survives.

- `POST /org/organisations/:orgId/members/:userId/deactivate` — suspends the member: their org and team rows move to `DEACTIVATED`. In the same fail-closed transaction, UOA revokes every refresh family scoped to that exact user+org across all issuing product domains, plus same-domain legacy/unscoped sessions. Cannot deactivate an `owner` — transfer ownership first. The user disappears from `GET /org/organisations/:orgId/members` (default view) and from `firstLogin`/`GET /org/me` on their next token refresh.
- `POST /org/organisations/:orgId/members/:userId/reactivate` — flips a `DEACTIVATED` member back to `ACTIVE`. No sessions are restored; the user simply signs in again.
- `DELETE /org/organisations/:orgId/members/:userId` — now a soft-remove: status becomes `REMOVED` (not a row delete), and the exact-org plus legacy same-domain revocation above applies. Re-adding a previously removed user via `POST /org/organisations/:orgId/members` **reactivates** their existing row (and re-activates their default-team membership) but never restores revoked sessions.
- `DELETE /org/organisations/:orgId/teams/:teamId/members/:userId` — tombstones only that team membership and atomically revokes exact user+team refresh families across all issuing product domains. Other-team sessions remain valid; re-add does not restore the revoked families.
- `GET /org/organisations/:orgId/members` defaults to `ACTIVE` members only. Pass `?status=DEACTIVATED`, `?status=REMOVED`, or `?status=all` to see other lifecycle states (e.g. for an admin roster view that lists suspended/removed accounts).

Org lifecycle is serialized with both current scoped refresh and legacy unscoped same-domain
refresh. A concurrent refresh therefore cannot mint a surviving replacement after revocation.

To revoke on logout:

```text
POST /auth/revoke?config_url=<your_config_endpoint_url>
Authorization: Bearer <client_hash>
Content-Type: application/json

{ "refresh_token": "<refresh token to revoke>" }
```

This returns the same success for unknown/mismatched tokens (no token oracle). For a valid token it
re-reads under the user-global and user/domain transaction locks, then atomically revokes the family
and increments the user's token version. Password reset, password binding, and all 2FA reset/disable
paths atomically revoke every refresh token and increment the same version.

Domain admin APIs (`/domain/users`, `/domain/logs`, etc.) and team-invite / access-request review APIs use the same `Authorization: Bearer <client_hash>` mechanism. The old global shared-secret bearer is NOT accepted for any customer-facing endpoint.

### 4.7a Team join policies + member-initiated invites (Phase 4)

Every `Team` has a `joinPolicy`: `INVITE_ONLY` (default) | `APPROVED_DOMAIN` | `REQUEST_TO_JOIN` | `OPEN_TO_ORG` | `HIDDEN`. The policy **gates** the existing join mechanisms rather than replacing them:

- **Auto-enrolment** via `access_requests.auto_grant_domains` only auto-adds a user when the configured target team's `joinPolicy` is `APPROVED_DOMAIN`.
- **Request-to-join** (`access_requests.enabled`) only accepts a request when the target team's `joinPolicy` is `REQUEST_TO_JOIN`; any other policy fails the login with a generic error when `request_access=true` is set.
- **Self-join** — `POST /org/organisations/:orgId/teams/:teamId/join` (access token required) — succeeds only when the team's `joinPolicy` is `OPEN_TO_ORG` and the caller is an ACTIVE member of the team's org. Reactivates a previously removed/deactivated row instead of duplicating it.
- **HIDDEN** teams are excluded from `GET /org/organisations/:orgId/teams` for callers who are not already an ACTIVE member of that team.
- Set the policy with `PUT /org/organisations/:orgId/teams/:teamId` (`{ "joinPolicy": "OPEN_TO_ORG" }`, owner/admin only).

Team invites now carry an `expiresAt` (30 days from send/resend — resending refreshes it) and an `approvalStatus`: `not_required` | `pending` | `approved` | `denied`. The derived invite `status` gains `expired` alongside `pending | accepted | declined | replaced`; an expired or not-yet-approved invite cannot be accepted and is excluded from the workspace chooser / `firstLogin.pending_invites`.

Member-initiated invites: `POST /org/organisations/:orgId/teams/:teamId/invitations` accepts the same path used by the trusted backend bulk-invite call, but when called WITH an `X-UOA-Access-Token` header it becomes a single-invite, permission-gated call instead:

- Org or team `owner`/`admin`: always allowed, sent immediately (`approvalStatus: not_required`).
- A plain ACTIVE team member: gated by the organisation's `memberInvites` setting (`allowed` default | `admin_approval` | `disabled`, set via `PUT /org/organisations/:orgId` `{ "member_invites": "admin_approval" }`). `admin_approval` creates the invite as `pending` and sends **no email** until an owner/admin approves it.
- A deactivated member, or a plain member when `disabled`, is rejected generically.
- The response is always `{ "status": "ok" }` regardless of outcome — whether the email already has an account is never revealed (no enumeration).

Owner/admin review the pending queue with `GET /org/organisations/:orgId/invitations?approval=pending`, then `POST /org/organisations/:orgId/invitations/:inviteId/approve` (sends the invite email) or `.../deny` (silent to the invitee, sends nothing).

### 4.7b Sidebar workspace stack, "Invited" tab, and workspace icons (gap-fix A, design §11.3–§11.5)

`GET /org/me` now returns two additive fields inside `org` alongside the existing `org_id`,
`org_role`, `teams`, `team_roles`, `groups` (unchanged — this is purely additive):

```json
{
  "org": {
    "org_id": "org_…",
    "org_role": "admin",
    "teams": ["team_1", "team_2"],
    "team_roles": { "team_1": "owner", "team_2": "member" },
    "workspaces": [
      {
        "teamId": "team_1",
        "orgId": "org_…",
        "name": "Backend Team",
        "slug": "backend-team",
        "orgName": "Acme Inc",
        "iconUrl": "https://cdn.example.com/backend.png",
        "role": "owner",
        "lastLoginAt": "2026-07-01T12:00:00.000Z"
      },
      {
        "teamId": "team_2",
        "orgId": "org_…",
        "name": "Design",
        "slug": "design",
        "orgName": "Acme Inc",
        "iconUrl": null,
        "role": "member",
        "lastLoginAt": null
      }
    ],
    "pending_invites": [
      { "inviteId": "inv_…", "teamId": "team_3", "teamName": "Growth", "invitedBy": "Alice Admin", "expiresAt": "2026-08-01T00:00:00.000Z" }
    ]
  }
}
```

- `workspaces[]` — one entry per ACTIVE team membership on this domain, ordered `lastLoginAt` DESC
  with nulls last, then `name` ASC (this IS the sidebar order — render it as-is). `lastLoginAt` is
  `null` when the caller never opened a session scoped to that specific workspace (e.g. a
  pre-chooser session, or a workspace never actually signed into).
- `pending_invites[]` — the caller's own pending invites on this domain (same eligibility as the
  workspace chooser: unaccepted/undeclined/unrevoked, not expired, and not still awaiting
  member-invite approval).
- Render this straight into the Slack-style sidebar: active workspace highlighted (match
  `active.teamId` from the access-token claim, §4.2), the rest one click away via `team_hint` on
  `/auth`, invite cards for `pending_invites`.

**"Invited" tab** — `GET /org/organisations/:orgId/teams/:teamId?include=invited` (exact literal;
any other value for `include` is ignored, same as omitting it):

```json
{
  "id": "team_1",
  "name": "Backend Team",
  "slug": "backend-team",
  "iconUrl": "https://cdn.example.com/backend.png",
  "members": [ { "userId": "user_…", "teamRole": "owner" } ],
  "invited": [
    {
      "inviteId": "inv_…",
      "email": "new.hire@acme.com",
      "inviteName": "New Hire",
      "teamRole": "member",
      "invitedByName": "Alice Admin",
      "invitedByEmail": "alice@acme.com",
      "lastSentAt": "2026-07-05T00:00:00.000Z",
      "expiresAt": "2026-08-04T00:00:00.000Z",
      "approvalStatus": "pending",
      "openCount": 0
    }
  ]
}
```

- Without `?include=invited`, the response is byte-identical to before — no `invited` key at all.
- With it, `invited` is **always present** as an array. Unlike every other pending-invite surface,
  it INCLUDES `approvalStatus: "pending"` entries (an admin reviewing the tab must see invites still
  awaiting member-invite approval, §4.7a) — the field itself tells you which.
- `invited` is gated to an org **or** team `owner`/`admin` (invite emails are PII): a plain member
  gets `invited: []`, never a `403` — the rest of the team read is unaffected either way.

**Workspace icons** (design §11.3) — `Team.iconUrl` / `Organisation.iconUrl`, external URL only, no
local storage (brief §15). Set with the existing `PUT` endpoints:

```jsonc
PUT /org/organisations/:orgId/teams/:teamId
{ "icon_url": "https://cdn.example.com/backend.png" }   // or "icon_url": null to clear
```

Same body/response shape for `PUT /org/organisations/:orgId`. Rules, identical for both:

- `icon_url` omitted → unchanged. `null` → clears it. Otherwise must be an `https:` URL, max 2048
  characters — anything else (`http:`, a bare string, oversized) is rejected with the same generic
  `400` UOA uses everywhere else (no "must be https" specificity leaked back).
- Owner/admin only — the same authorization the `PUT` already enforced.
- `iconUrl` is echoed on every team/org read and write, the workspace chooser's `teams[]`, `/org/me`'s
  `workspaces[]`, and `firstLogin.memberships.teams[]` — one column, one field name, everywhere.


### 4.7 Two-factor login branches

When config `2fa_enabled` is false or absent, no 2FA branch runs. When it is true, UOA resolves DB policy from the Service/domain, same-domain Organisations, and the exact selected Organisation using strongest-wins (`off < optional < required`). A recognized product resolves that exact workspace before 2FA even when its chooser is off. Authorization-code exchange rechecks current policy and enrollment against persisted interactive proof, so a newly stricter policy fails closed before token issuance.

- Enrolled users get `{ ok: true, twofa_required: true, twofa_token }`; submit `{ twofa_token, code }` to `POST /2fa/verify?config_url=...` and follow `redirect_to`.
- Required but unenrolled users get `{ ok: true, kind: "twofa_enroll_required", twofa_enroll_required: true, setup_token, otpauth_uri?, qr_svg?, manual_secret? }`; the Auth UI completes `POST /2fa/enroll` with the setup token and initial code before any authorization code is granted.
- Optional and unenrolled users continue normally.

For account settings, an authenticated user can call `POST /2fa/setup` with `X-UOA-Access-Token`, enroll with `POST /2fa/enroll`, and disable with `POST /2fa/disable` plus a current TOTP code. Disable is rejected generically when the effective policy is required.

---

## Phase 5 — Server startup payload: kill switch + feature flags

Your backend can request the startup payload using the same signed config JWT trust path as `/auth/login` and `/auth/register`: pass `config_url`, UOA fetches the RS256 config JWT, verifies the signature, validates the payload, and checks that `domain` matches the `config_url` hostname.

```text
GET /apps/startup?config_url=<your_config_endpoint_url>&appIdentifier=com.acme.ios&platform=ios&versionName=1.5.0&buildNumber=142
```

Optional query params:

- `userId` — applies per-user flag overrides and kill-switch test targeting when the user belongs to the app's org.
- `versionCode` — Android numeric version code.
- `teamId` — reserved for multi-team flag resolution.

Response:

```json
{
  "killSwitch": null,
  "flags": { "dark_mode": true, "new_checkout": false },
  "cacheTtl": 300,
  "serverTime": "2026-04-22T12:00:00.000Z"
}
```

- Unknown, inactive, or cross-domain apps return a clear startup payload: `killSwitch: null`, `flags: {}`. App matching uses the registered `appIdentifier` plus the config JWT domain being present in the app's registered domains.
- Feature flags return a flat key-to-boolean map. If feature flags are disabled for the App, `flags` is `{}`.
- A matched hard or maintenance kill switch appears in `killSwitch`; callers should block startup before loading app content.

---

## Validate at every step

UOA ships a production-safe validator that runs the same pipeline as the auth runtime:

```text
POST /config/validate
Content-Type: application/json

{ "config_url": "https://api.voicepos.unlikeotherai.com/auth/config" }
```

Body may instead be `{ "config_jwt": "<jwt>" }` or `{ "config": { ... raw payload ... } }`. Source priority is `config` > `config_jwt` > `config_url`.

The response includes:

- `ok`: every executed check passed.
- `checks`: per-stage results for `source`, `fetch`, `decode`, `secret_scan`, `signature`, `schema`, `runtime_policy`, `domain_match`.
- `issues`: structured failures with stage, code, summary, details.
- `recommendations`: required next steps and optional customization guidance (logo, custom font, language selector, token TTL, org features, access requests).
- `config_summary`: a safe parsed summary when schema validation succeeds.

In a non-production deployment with `DEBUG_ENABLED=true` you can additionally pass `jwks_url` to `POST /config/verify` to verify against a JWKS other than `CONFIG_JWKS_URL` — useful for testing a new `kid` before it is registered with the production UOA.

---

## Errors and what they actually mean

| Error code | Stage | Almost-always cause |
|---|---|---|
| `CONFIG_FETCH_FAILED` | UOA fetching your `config_url` | Endpoint unreachable from UOA, returned non-200, returned > 64 KiB, took > 5s, returned a body that did not contain a recognizable JWT, or resolved to a private/blocked IP. Check the **Connection Errors** page in /admin for the captured request/response context. |
| `CONFIG_URL_NETWORK_ERROR` | UOA fetching your `config_url` | TLS, DNS, or socket-level failure before HTTP could complete. |
| `CONFIG_JWT_INVALID` | Header / signature verification | Your `kid` is not registered and your payload does not include `jwks_url` + `contact_email` to trigger auto-discovery. Other causes: `alg` is not `RS256`, `kid` missing, signature does not match the registered public key, JWKS endpoint unreachable. |
| `INTEGRATION_JWKS_HOST_MISMATCH` | Auto-discovery | The hostname of the `jwks_url` you published in the payload does not match the `domain` claim (case-insensitive). Fix: host the JWKS on the same hostname. |
| `INTEGRATION_KID_NOT_IN_JWKS` | Auto-discovery | UOA fetched your `jwks_url` but the JWT's `kid` is not present in the document. Fix: publish the correct public JWK with the `kid` you signed with. |
| `INTEGRATION_PENDING_REVIEW` | Auto-discovery | A valid request has been captured and is waiting for a UOA superuser to approve. Wait for the email to `contact_email`; do not retry in a loop. |
| `INTEGRATION_DECLINED` | Auto-discovery | A UOA superuser declined your integration request for this domain. Contact support. |
| `CONFIG_DOMAIN_MISMATCH` | Post-decode | `payload.domain` does not exactly match the hostname of the `config_url` UOA fetched. Hostnames are compared case-insensitively but must otherwise be identical (no trailing dot, no port mismatch). |
| `REDIRECT_URL_NOT_ALLOWED` | `/auth` + `/auth/token` | `redirect_url` is not in `config.redirect_urls`. **Common cause:** the bare URL is allowlisted but a per-request `?state=…` (or any query parameter) was appended — matching is byte-for-byte including the query string. Carry state out-of-band (see Phase 3.1), do not mutate the URL. |
| Schema validation failures | Schema stage | A required field is missing or malformed. `/config/validate` returns the exact JSON path and reason in `issues`. |
| `auth_failed` (final redirect) | Post-callback | Intentionally generic. With `allow_registration: false`, social login does not create users — the user must already exist for that domain. Check `/internal/admin/handshake-errors`. |
| Google `redirect_uri_mismatch` | Provider | Your Google OAuth client does not list the exact callback URL UOA generated from `PUBLIC_BASE_URL` + `/auth/callback/google`. |

For deep diagnostics of failed `/auth` requests, a UOA superuser can open **/admin > Security > Connection Errors**. UOA records the sanitized request/response context for handshake failures, including JWT header/payload (with secrets redacted), the failing phase, and the resolved `config_url`.

---

## What NOT to do

- Do NOT use HS256 or any algorithm other than RS256 for the CONFIG JWT. UOA rejects everything else on the config-signing path. (Access tokens returned by `/auth/token` are separately HS256-signed by UOA and are not your concern — see 4.3.)
- Do NOT reuse a `kid` after rotation. Always pick a new `kid`.
- Do NOT put `client_secret`, `client_hash`, `SHARED_SECRET`, refresh tokens, or OAuth codes into the config JWT payload.
- Do NOT call `/auth/token` or `/auth/revoke` from the browser. The bearer is backend-only.
- Do NOT host `config_url` on a private DNS name, internal load balancer, loopback, or VPN-only host. UOA fetches over the public internet and rejects private IPs.
- Do NOT assume a `200` from your `config_url` in a browser implies UOA can fetch it — UOA enforces SSRF rules a browser does not.
- Do NOT replay OAuth `code` values from logs or chat. They are one-time credentials.
- Do NOT skip `/config/validate` before pointing real users at UOA.
- Do NOT append `?state=…` (or any per-request query) to `redirect_url`. The allowlist match is byte-for-byte; your `/start` endpoint must return the state token **separately** so the caller can stash it in `sessionStorage` or a first-party cookie. See Phase 3.1.
- Do NOT assume `POST /auth/token` returns a top-level `user` object. It does not. See 4.1.
- Do NOT attempt to verify `access_token` against the config JWKS. It is HS256 and not RP-verifiable. See 4.3.
- Do NOT fall back to a synthetic tenant ID (`"default"`, email domain, etc.) when `firstLogin.memberships.orgs` is empty. See 4.5.
- Do NOT use `claims.role` for RP authorization. It is the UOA platform role, not your tenant role. See 4.4.

---

## Admin access

The first-party Admin UI is served from [/admin](/admin). It dogfoods the same auth system with its own first-party config:

- `/admin/login` redirects into `/auth` with PKCE.
- The admin config is served from `/internal/admin/config` and verified against the public JWKS at `/.well-known/jwks.json`.
- The admin config uses the admin domain, disables registration, and allows only Google.
- `/admin/auth/callback` exchanges the authorization code at `POST /internal/admin/token`.
- Admin access tokens are signed with `ADMIN_ACCESS_TOKEN_SECRET`.
- Only `role: "superuser"` tokens for `ADMIN_AUTH_DOMAIN` can access `/internal/admin/*`. The sole exception is the feature-flag/kill-switch/apps-list subset, which **also** accepts a scoped **Admin API key** (`X-API-Key`) for terminal/CI use — see "Admin API keys" below.
- `ADMIN_AUTH_DOMAIN` defaults to the resolved auth service identifier (inferred from `PUBLIC_BASE_URL` unless `AUTH_SERVICE_IDENTIFIER` overrides it).
- DB-backed deployments also require a `SUPERUSER` row in `domain_roles` for that admin domain.

---

## Operational endpoints

- `GET /domain/users` — list users for a domain.
- `GET /domain/logs` — domain login logs.
- `GET /org/me` — current user's org context.
- `POST /email/send` — send a transactional email for a configured domain. Supply `X-UOA-Config-JWT: <signed config JWT>`; UOA verifies the RS256 config JWT directly from the header, requires the domain email config to be enabled and SES verification/DKIM to both be `Success`, then sends `{ to, subject, text, html?, reply_to? }`.
- `GET /internal/admin/handshake-errors` — sanitized handshake and config JWT errors for superusers, including redacted request/response context when `config_url` fetches fail before a JWT can be decoded.
- `PUT /internal/admin/domains/:domain`, `PATCH /internal/admin/organisations/:orgId`, and `PATCH /internal/admin/organisations/:orgId/teams/:teamId` — admin-only login access whitelist writes. Send `allowed_email_domains` and/or `allowed_emails`; omitted lists remain unchanged.
- `POST /internal/admin/organisations` — admin-only organisation creation for an existing owner user; creates the default `General` team and may set `allowed_email_domains` / `allowed_emails`.
- `POST /internal/admin/apps` — admin-only app registration used by feature flags and `/apps/startup`. Superuser JWT only — an Admin API key cannot create apps.
- `GET /internal/admin/apps` — list registered apps with their feature flag definitions and kill-switch rules. Superuser JWT **or** an Admin API key (`X-API-Key`). This is the discovery call a terminal/CI user makes to find the `appId`/`flagId`/`killSwitchId`.
- `POST/PATCH/DELETE /internal/admin/apps/:appId/flags` and `/kill-switches` — feature flag definitions and version kill-switch rules. Superuser JWT **or** an Admin API key (`X-API-Key`).
- `GET/POST/DELETE /internal/admin/api-keys` — mint, list, and revoke Admin API keys. **Superuser JWT only** (key management is the escalation boundary; an Admin API key can never reach these).

---

## Admin API keys — control feature flags & kill switches from a terminal

Feature flags and kill switches are normally written through the Admin UI with a short-lived superuser browser token, so there is no way to flip one from a script or CI job. An **Admin API key** closes that gap.

A superuser mints a key in the Admin panel (**API Keys** page). It is shown **once** — copy it immediately. The key:

- looks like `uoa_ak_<random>` and is sent as `X-API-Key: <key>` (or `Authorization: Bearer uoa_ak_…`),
- can do **exactly** three things: list apps, write feature flags, and write kill switches,
- **cannot** mint/list/revoke keys or reach any other `/internal/admin/*` route (users, orgs, domains, dashboard, settings, logs, search). Those stay superuser-JWT-only.

When an `X-API-Key`/`Bearer uoa_ak_…` credential is present it is authoritative: a bad, expired, or revoked key returns a generic `401` and is **not** retried as a JWT. Only requests with no API-key credential fall through to the superuser browser-token path, so the Admin UI is unaffected.

### Terminal recipe

```bash
UOA=https://authentication.unlikeotherai.com
KEY=uoa_ak_your_key_minted_in_the_admin_panel

# 1. Discover the app — find its appId, flag ids, and kill-switch ids.
curl -fsS -H "X-API-Key: $KEY" "$UOA/internal/admin/apps"
# => [ { "id": "app_…", "identifier": "com.acme.ios", "flags": 1, "flagDefinitions": [ { "id": "flag_…", "key": "new_checkout" } ], "killSwitches": [ … ] }, … ]

APP=app_…

# 2. Create a feature flag definition (default OFF).
curl -fsS -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "key": "new_checkout", "description": "New checkout flow", "default_state": false }' \
  "$UOA/internal/admin/apps/$APP/flags"

# 2b. Toggle it ON later (PATCH the returned flagId).
curl -fsS -X PATCH -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "key": "new_checkout", "default_state": true }' \
  "$UOA/internal/admin/apps/$APP/flags/flag_…"

# 3. Flip a kill switch — block versions < 1.5.0 with a hard stop.
#    Optional "store_url" (https) overrides where the client's Update CTA leads
#    (e.g. a TestFlight link during closed testing); omit/null = App Store / Play.
curl -fsS -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "platform": "both", "type": "hard", "version_field": "versionName",
        "operator": "lt", "version_value": "1.5.0", "version_scheme": "semver",
        "active": true, "priority": 100,
        "store_url": "https://testflight.apple.com/join/XXXXXXXX" }' \
  "$UOA/internal/admin/apps/$APP/kill-switches"
```

The result is visible immediately to clients calling `GET /apps/startup`. See [/api](/api) for the full request/response schema of each route (flag and kill-switch bodies, the apps-list shape, and the superuser-only `/internal/admin/api-keys` management routes).

---

## Public-client / MCP OAuth profile (`/oauth/*`, brief §22.14)

A second, opt-in profile for **standards public clients** (e.g. MCP clients) that have
no `config_url` and no shared secret. Disabled unless `MCP_OAUTH_ACCESS_TOKEN_PRIVATE_JWK`
is set (routes 404 otherwise). It does not change the config-JWT flow above.

- `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata (discover the endpoints below).
- `POST /oauth/register` — RFC 7591 dynamic registration. PUBLIC clients only: send
  `redirect_uris` (https / loopback http / native scheme), get a `client_id`, **no secret**.
- `GET /oauth/authorize?response_type=code&client_id=&redirect_uri=&code_challenge=&code_challenge_method=S256&state=&resource=`
  — renders the first-party login UI; on success redirects to `redirect_uri?code=&state=`.
- `POST /oauth/token` — PKCE exchange (`code`, `redirect_uri`, `code_verifier`, `client_id`),
  **no client secret**. Returns a resource-bound **RS256** access token (`aud` = the requested `resource`).
- `GET /oauth/jwks.json` — verify those access tokens here (separate from the config JWKS).

Standard OAuth 2.1 + PKCE authorization-code flow; resource servers validate the token
statelessly via `/oauth/jwks.json` with issuer + audience checks.

---

Use [/api](/api) for the complete JSON endpoint schema and the canonical `config_jwt` field contract.
## Phase 4.8 — Slack-style email sign-in codes + workspace selection (opt-in)

Additive on top of `/auth/register` and `/auth/login` — chooser UI remains gated by config
`login_flow`. Legacy clients are unchanged at the defaults; server-recognized products still
pre-bind their mandatory exact workspace before 2FA when the chooser is off:

```jsonc
"login_flow": {
  "email_code_enabled": false,      // offer a 6-digit sign-in code alongside the magic link
  "workspace_selection": "off"      // "off" suppresses the chooser; "auto" may show it
}
```

**Security note (read before enabling):** workspace names, team membership, and pending invites are
revealed ONLY after the user's identity has been verified (a valid sign-in code or magic link) —
never from `/auth/start`, which always returns the same generic message regardless of whether the
email exists. The `login_token` bridge issued by `/auth/verify-code` (and, when the chooser is on,
by every verified identity path) authorizes ONLY the original chooser continuation for that user.
It signs the exact config URL plus a canonical fingerprint of the current verified parsed config,
redirect, PKCE, remember-me, access-request flag, subject/domain, expiry, and JTI. Final selection
claims the hashed JTI as the first statement in the same transaction as invite/membership mutation
and authorization. A concurrent replay therefore loses before it can send an access-request email
or emit invite audit state, while any later failure rolls the claim back so the original continuation
can be retried; `session-choices` and invite decline validate but do not consume it.

1. `POST /auth/start?config_url=...` — body `{ email }`. Same generic response as
   `/auth/register`; when `email_code_enabled` is true it additionally emails a 6-digit code.
2. `POST /auth/verify-code?config_url=...` — body `{ email, code }`. IP + email rate-limited; wrong
   code, expired code, no code, and a dead (5-attempt) code all return the identical generic auth
   error. On success:
   - `workspace_selection: "auto"` → `{ login_token, teams: [{ teamId, orgId, name, slug, role, iconUrl }], pending_invites: [{ inviteId, teamName, invitedBy }], can_create_org }`.
   - `workspace_selection: "off"` (default) → finalizes immediately, same response shape as
     `/auth/login` (`{ ok, code, redirect_to }`, or a `twofa_token`/`twofa_enroll_required` branch —
     2FA still applies, only the chooser step is skipped). A recognized product first resolves its
     exact server-owned workspace and includes that Organisation in strongest-wins 2FA policy.
3. `POST /auth/select-team?config_url=...` — body `{ login_token, teamId }` (or
   `{ login_token, inviteId, action: "accept" | "decline" }`). Validates the bridge token, that the
   caller has not changed any signed continuation field, and that the user holds exact ACTIVE
   organisation and team memberships. Legacy clients remain limited to their own config domain. A
   product domain expands to all exact ACTIVE memberships only when UOA's server-owned control plane
   has an active ClientDomain and current CUSTOMER_LIFECYCLE app keys whose exact HTTPS actorIssuer
   maps unambiguously to one active BillingService. Unknown, inactive, expired, revoked, or ambiguous
   mappings remain same-domain; pending invites are never expanded. Ineligible or inactive scope is
   rejected exactly like an invalid token — no IDOR oracle. Enforces the selected
   org's 2FA policy, then finalizes with the resolved
   workspace scope: the returned `code` carries `orgId`/`teamId`, so the eventual `POST /auth/token`
   exchange's access token includes the `active: { orgId, teamId }` claim (§4.2) next to the existing
   `org` claim. Code exchange locks and revalidates the exact ACTIVE organisation/team scope in the
   same transaction that consumes the code and issues the refresh/access-token family, so a
   concurrent membership deactivation either finishes first and rejects exchange without consuming
   the code, or waits until the successful exchange has committed.
   The same central policy builds `firstLogin.memberships`. Every scoped refresh revalidates the
   exact mapping and memberships and fails without rotating if they changed; UOA never silently
   drops `active`, switches tenants, or creates a product-domain workspace.
4. `POST /auth/login` (password) also routes into the chooser when `workspace_selection: "auto"` and
   2FA is already satisfied: it returns `{ login_token, teams, pending_invites, can_create_org }`
   instead of finalizing directly, and the client then calls `/auth/select-team`. With the default
   `"off"`, legacy clients remain unchanged; recognized products suppress the chooser but resolve
   one exact workspace (or required first placement) before any 2FA decision and fail closed on
   ambiguous choices.
5. Social login (`GET /auth/callback/:provider`) resolves workspace immediately after identity,
   before 2FA. With `workspace_selection: "auto"`, 2+ ACTIVE teams, any pending invite, or zero
   teams with `can_create_org: true` route to the chooser. Exactly one ACTIVE team and no invite is
   an unambiguous server-side selection: its
   exact `orgId`/`teamId` is carried through any 2FA challenge or required-enrollment setup token
   into the authorization code, so access and rotated refresh sessions retain
   `active: { orgId, teamId }`. With `workspace_selection: "off"`, legacy clients remain unscoped,
   while recognized products pre-bind the same exact scope without showing a chooser. As a GET
   redirect the multi-choice branch can't inline the chooser
   JSON, so it mints the same `login_token` bridge and redirects to
   `/auth?config_url=...&redirect_url=...&login_token=...&flow=workspace_chooser`.
   The Auth UI then calls `POST /auth/session-choices?config_url=...` `{ login_token }` to hydrate
   `{ teams, pending_invites, can_create_org }` — generic rejection for an invalid/expired token, no
   enumeration.
6. Magic links (`GET /auth/email/link`, both the passwordless LOGIN_LINK/VERIFY_EMAIL auto-consume
   and the VERIFY_EMAIL_SET_PASSWORD → `POST /auth/verify-email` path) join the same chooser gate —
   the design's "magic links join the same flow". LOGIN_LINK/VERIFY_EMAIL redirect to `/auth?...&
   login_token=...&flow=workspace_chooser` exactly like the social callback; `POST /auth/verify-email`
   instead returns the inline JSON chooser payload exactly like `/auth/login`. The same exact-one
   rule binds the sole ACTIVE team's `orgId`/`teamId` when the chooser is skipped, and that exact
   scope survives an enrolled-2FA challenge or required-enrollment setup token before code issuance.
   Zero teams with `can_create_org: true` still enter the chooser so the create-workspace action is
   reachable. An invite-bound link never sees the chooser: token consumption returns the accepted
   invite's exact `orgId`/`teamId`, applies the effective 2FA policy, and preserves that scope
   through the authorization code, access token, refresh token, and rotation.
   Both email continuations perform chooser reads, recognized-product placement, exact policy/2FA
   finalization, and immediate code issuance in one admin transaction. Its per-user placement lock
   remains held through commit, so concurrent first use from different products reuses one
   canonical workspace; an issuance failure rolls the workspace and code back together while the
   one-time email token remains consumed.
   A `LOGIN_LINK` resolves only the existing `userId` stored when it was issued; a missing,
   deleted, or identity-mismatched account fails closed and can never become new-user registration.
   At code exchange UOA re-resolves the current exact-workspace policy and enrollment state. The
   code carries whether TOTP was completed, so a newly stricter policy rejects a proof-free code
   transactionally instead of issuing a refresh/access-token family.
7. `GET /auth` accepts an optional `team_hint=<teamId|slug>` — a chooser preselect / one-click
   workspace switch (design §11.4): a product's sidebar links back into `/auth` with the workspace
   the user clicked, and if a team in that user's own (already-verified) chooser payload matches by
   `teamId` or `slug`, the Auth UI auto-selects it via the same code path as the single-team
   auto-skip. Client-side preselect ONLY — an invalid or non-matching hint is silently ignored (the
   chooser just renders normally); `select-team`'s product-policy + exact ACTIVE-membership check is the sole
   authority and a hint can never select a team the user isn't already a member of.

---

## Phase 4.9 — Shareable team invite links (opt-in)

Additive on top of the invite system in 4.7a — a link that can be shared outside of email (Slack's
"Copy invite link"). A link authorizes **joining** a team; it never authorizes **authentication** on
its own — redemption only completes on the verified-session path below, after the visitor has
already proven their email via a magic link or (if enabled) a sign-in code.

1. `POST /org/organisations/:orgId/teams/:teamId/invite-links` (domain hash + access token; org or
   team `owner`/`admin` only) creates a link. Body:
   `{ roleToAssign?: "member"|"admin", maxUses?: number (<=400, default 400), expiresInDays?: number (<=30, default 30) }`.
   Response: `{ token, link: { id, roleToAssign, expiresAt, maxUses, useCount, revokedAt, createdAt } }`
   — **the plaintext `token` is returned exactly once**; only its hash is ever stored. Refused with a
   generic error when the team's `joinPolicy` is `HIDDEN` (invite links are not a self-serve
   backdoor around a hidden team).
2. `GET .../invite-links` lists a team's links (never the token itself).
   `DELETE .../invite-links/:linkId` revokes one — idempotent, revoking twice still returns success.
3. Share the link as `GET /auth/team-invite-link/:token?config_url=...`. This is a public,
   IP-rate-limited landing page — it validates the token WITHOUT redeeming it (no `useCount`
   increment, no membership change) and renders the normal Auth UI bootstrapped to start email
   verification. An unknown, revoked, expired, over-cap, or HIDDEN-team token all render the
   identical generic invalid-link page — there is no oracle on which condition failed.
4. Once the visitor verifies their identity and holds a `login_token` bridge (§4.8), the client
   calls `POST /auth/select-team?config_url=...` with `{ login_token, inviteLinkToken }` instead of
   `teamId`/`inviteId` (the three are mutually exclusive in one call). UOA re-validates the link with
   the same generic-error rule, atomically increments `useCount` (a conditional update guarantees
   concurrent redemptions can never push `useCount` past `maxUses`), adds the caller as an ACTIVE
   team member with the link's `roleToAssign` (reactivating a previously removed/deactivated row;
   idempotent if already an ACTIVE member), then finalizes exactly like a normal team selection —
   including the 2FA policy check for that org. An absent organisation membership may be created,
   but an existing inactive organisation tombstone is rejected before `useCount`, team-membership
   creation/reactivation, authorization, or audit mutation. The team membership may be absent,
   ACTIVE, or inactive and is created/reactivated as needed; the resulting exact organisation +
   team membership scope must be ACTIVE before handoff, or the transaction rolls back in full.

---

## Phase 5 — Server startup payload: kill switch + feature flags

Your backend can request the startup payload using the same signed config JWT trust path as `/auth/login` and `/auth/register`: pass `config_url`, UOA fetches the RS256 config JWT, verifies the signature, validates the payload, and checks that `domain` matches the `config_url` hostname.

```text
GET /apps/startup?config_url=<your_config_endpoint_url>&appIdentifier=com.acme.ios&platform=ios&versionName=1.5.0&buildNumber=142
```

Optional query params:

- `userId` — applies per-user flag overrides and kill-switch test targeting when the user belongs to the app's org.
- `versionCode` — Android numeric version code.
- `teamId` — exact active UOA team context for flag resolution.

Response:

```json
{
  "killSwitch": null,
  "flags": { "dark_mode": true, "new_checkout": false },
  "cacheTtl": 300,
  "serverTime": "2026-04-22T12:00:00.000Z"
}
```

- Unknown, inactive, or cross-domain apps return a clear startup payload: `killSwitch: null`, `flags: {}`. App matching uses the registered `appIdentifier` plus the config JWT domain being present in the app's registered domains.
- Feature flags return a flat key-to-boolean map. If feature flags are disabled for the App, `flags` is `{}`.
- A matched hard or maintenance kill switch appears in `killSwitch`; callers should block startup before loading app content.

For a backend authorization decision that must observe current state, use the
domain-hash-authenticated direct query rather than the config-only startup
payload:

```text
GET /apps/<opaque App.id>/flags?domain=api.deepwater.live&userId=<UOA JWT sub>&teamId=<UOA Team.id>
Authorization: Bearer <hex SHA256(normalizedDomain + fullClientSecret)>
```

`:appId` is the opaque database App ID returned by UOA Admin, not the bundle
identifier. The `domain` query is the exact config domain registered on that
App. UOA binds the active App to that domain, validates active organisation and
exact team membership for the UOA subject, and returns only a flat
`{ "flag_key": boolean }` map with `Cache-Control: private, no-store`.
Wrong/inactive app, domain, user, or team returns `{}` without enumeration;
missing or invalid backend credentials return 401. Security capabilities must
require an explicit `true`; missing flags and request failures fail closed.

---

## Validate at every step

UOA ships a production-safe validator that runs the same pipeline as the auth runtime:

```text
POST /config/validate
Content-Type: application/json

{ "config_url": "https://api.voicepos.unlikeotherai.com/auth/config" }
```

Body may instead be `{ "config_jwt": "<jwt>" }` or `{ "config": { ... raw payload ... } }`. Source priority is `config` > `config_jwt` > `config_url`.

The response includes:

- `ok`: every executed check passed.
- `checks`: per-stage results for `source`, `fetch`, `decode`, `secret_scan`, `signature`, `schema`, `runtime_policy`, `domain_match`.
- `issues`: structured failures with stage, code, summary, details.
- `recommendations`: required next steps and optional customization guidance (logo, custom font, language selector, token TTL, org features, access requests).
- `config_summary`: a safe parsed summary when schema validation succeeds.

In a non-production deployment with `DEBUG_ENABLED=true` you can additionally pass `jwks_url` to `POST /config/verify` to verify against a JWKS other than `CONFIG_JWKS_URL` — useful for testing a new `kid` before it is registered with the production UOA.

---

## Errors and what they actually mean

| Error code | Stage | Almost-always cause |
|---|---|---|
| `CONFIG_FETCH_FAILED` | UOA fetching your `config_url` | Endpoint unreachable from UOA, returned non-200, returned > 64 KiB, took > 5s, returned a body that did not contain a recognizable JWT, or resolved to a private/blocked IP. Check the **Connection Errors** page in /admin for the captured request/response context. |
| `CONFIG_URL_NETWORK_ERROR` | UOA fetching your `config_url` | TLS, DNS, or socket-level failure before HTTP could complete. |
| `CONFIG_JWT_INVALID` | Header / signature verification | Your `kid` is not registered and your payload does not include `jwks_url` + `contact_email` to trigger auto-discovery. Other causes: `alg` is not `RS256`, `kid` missing, signature does not match the registered public key, JWKS endpoint unreachable. |
| `INTEGRATION_JWKS_HOST_MISMATCH` | Auto-discovery | The hostname of the `jwks_url` you published in the payload does not match the `domain` claim (case-insensitive). Fix: host the JWKS on the same hostname. |
| `INTEGRATION_KID_NOT_IN_JWKS` | Auto-discovery | UOA fetched your `jwks_url` but the JWT's `kid` is not present in the document. Fix: publish the correct public JWK with the `kid` you signed with. |
| `INTEGRATION_PENDING_REVIEW` | Auto-discovery | A valid request has been captured and is waiting for a UOA superuser to approve. Wait for the email to `contact_email`; do not retry in a loop. |
| `INTEGRATION_DECLINED` | Auto-discovery | A UOA superuser declined your integration request for this domain. Contact support. |
| `CONFIG_DOMAIN_MISMATCH` | Post-decode | `payload.domain` does not exactly match the hostname of the `config_url` UOA fetched. Hostnames are compared case-insensitively but must otherwise be identical (no trailing dot, no port mismatch). |
| `REDIRECT_URL_NOT_ALLOWED` | `/auth` + `/auth/token` | `redirect_url` is not in `config.redirect_urls`. **Common cause:** the bare URL is allowlisted but a per-request `?state=…` (or any query parameter) was appended — matching is byte-for-byte including the query string. Carry state out-of-band (see Phase 3.1), do not mutate the URL. |
| Schema validation failures | Schema stage | A required field is missing or malformed. `/config/validate` returns the exact JSON path and reason in `issues`. |
| `auth_failed` (final redirect) | Post-callback | Intentionally generic. With `allow_registration: false`, social login does not create users — the user must already exist for that domain. Check `/internal/admin/handshake-errors`. |
| Google `redirect_uri_mismatch` | Provider | Your Google OAuth client does not list the exact callback URL UOA generated from `PUBLIC_BASE_URL` + `/auth/callback/google`. |

For deep diagnostics of failed `/auth` requests, a UOA superuser can open **/admin > Security > Connection Errors**. UOA records the sanitized request/response context for handshake failures, including JWT header/payload (with secrets redacted), the failing phase, and the resolved `config_url`.

---

## What NOT to do

- Do NOT use HS256 or any algorithm other than RS256 for the CONFIG JWT. UOA rejects everything else on the config-signing path. (Legacy authorization-code/refresh access tokens returned by `/auth/token` are separately HS256-signed; confidential assertion tokens are RS256 and use `/oauth/jwks.json` — see 4.3 and 4.6a.)
- Do NOT reuse a `kid` after rotation. Always pick a new `kid`.
- Do NOT put `client_secret`, `client_hash`, `SHARED_SECRET`, refresh tokens, or OAuth codes into the config JWT payload.
- Do NOT call `/auth/token` or `/auth/revoke` from the browser. The bearer is backend-only.
- Do NOT host `config_url` on a private DNS name, internal load balancer, loopback, or VPN-only host. UOA fetches over the public internet and rejects private IPs.
- Do NOT assume a `200` from your `config_url` in a browser implies UOA can fetch it — UOA enforces SSRF rules a browser does not.
- Do NOT replay OAuth `code` values from logs or chat. They are one-time credentials.
- Do NOT skip `/config/validate` before pointing real users at UOA.
- Do NOT append `?state=…` (or any per-request query) to `redirect_url`. The allowlist match is byte-for-byte; your `/start` endpoint must return the state token **separately** so the caller can stash it in `sessionStorage` or a first-party cookie. See Phase 3.1.
- Do NOT assume `POST /auth/token` returns a top-level `user` object. It does not. See 4.1.
- Do NOT attempt to verify a legacy authorization-code/refresh `access_token` against the config JWKS. It is HS256 and not RP-verifiable. A confidential assertion token is verified against `/oauth/jwks.json`, never the config JWKS. See 4.3 and 4.6a.
- Do NOT fall back to a synthetic tenant ID (`"default"`, email domain, etc.) when `firstLogin.memberships.orgs` is empty. See 4.5.
- Do NOT use `claims.role` for RP authorization. It is the UOA platform role, not your tenant role. See 4.4.

---

## Admin access

The first-party Admin UI is served from [/admin](/admin). It dogfoods the same auth system with its own first-party config:

- `/admin/login` redirects into `/auth` with PKCE.
- The admin config is served from `/internal/admin/config` and verified against the public JWKS at `/.well-known/jwks.json`.
- The admin config uses the admin domain, disables registration, and allows only Google.
- `/admin/auth/callback` exchanges the authorization code at `POST /internal/admin/token`.
- Admin access tokens are signed with `ADMIN_ACCESS_TOKEN_SECRET`.
- Only `role: "superuser"` tokens for `ADMIN_AUTH_DOMAIN` can access `/internal/admin/*`. The sole exception is the feature-flag/kill-switch/apps-list subset, which **also** accepts a scoped **Admin API key** (`X-API-Key`) for terminal/CI use — see "Admin API keys" below.
- `ADMIN_AUTH_DOMAIN` defaults to the resolved auth service identifier (inferred from `PUBLIC_BASE_URL` unless `AUTH_SERVICE_IDENTIFIER` overrides it).
- DB-backed deployments also require a `SUPERUSER` row in `domain_roles` for that admin domain.

---

## Operational endpoints

- `GET /domain/users` — list users for a domain.
- `GET /domain/logs` — domain login logs.
- `GET /org/me` — current user's org context.
- `POST /email/send` — send a transactional email for a configured domain. Supply `X-UOA-Config-JWT: <signed config JWT>`; UOA verifies the RS256 config JWT directly from the header, requires the domain email config to be enabled and SES verification/DKIM to both be `Success`, then sends `{ to, subject, text, html?, reply_to? }`.
- `GET /internal/admin/handshake-errors` — sanitized handshake and config JWT errors for superusers, including redacted request/response context when `config_url` fetches fail before a JWT can be decoded.
- `PUT /internal/admin/domains/:domain`, `PATCH /internal/admin/organisations/:orgId`, and `PATCH /internal/admin/organisations/:orgId/teams/:teamId` — admin-only login access whitelist writes. The team route also accepts `name` and `description` for team edits. Send only the fields to change; omitted fields remain unchanged.
- `POST /internal/admin/organisations` — admin-only organisation creation for an existing owner user; creates the default `General` team and may set `allowed_email_domains` / `allowed_emails`.
- `POST /internal/admin/apps` — admin-only app registration used by feature flags and `/apps/startup`. Superuser JWT only — an Admin API key cannot create apps.
- `GET /internal/admin/apps` — list registered apps with their feature flag definitions and kill-switch rules. Superuser JWT **or** an Admin API key (`X-API-Key`). This is the discovery call a terminal/CI user makes to find the `appId`/`flagId`/`killSwitchId`.
- `POST/PATCH/DELETE /internal/admin/apps/:appId/flags` and `/kill-switches` — feature flag definitions and version kill-switch rules. Superuser JWT **or** an Admin API key (`X-API-Key`).
- `GET/POST/DELETE /internal/admin/api-keys` — mint, list, and revoke Admin API keys. **Superuser JWT only** (key management is the escalation boundary; an Admin API key can never reach these).

---

## Admin API keys — control feature flags & kill switches from a terminal

Feature flags and kill switches are normally written through the Admin UI with a short-lived superuser browser token, so there is no way to flip one from a script or CI job. An **Admin API key** closes that gap.

A superuser mints a key in the Admin panel (**API Keys** page). It is shown **once** — copy it immediately. The key:

- looks like `uoa_ak_<random>` and is sent as `X-API-Key: <key>` (or `Authorization: Bearer uoa_ak_…`),
- can do **exactly** three things: list apps, write feature flags, and write kill switches,
- **cannot** mint/list/revoke keys or reach any other `/internal/admin/*` route (users, orgs, domains, dashboard, settings, logs, search). Those stay superuser-JWT-only.

When an `X-API-Key`/`Bearer uoa_ak_…` credential is present it is authoritative: a bad, expired, or revoked key returns a generic `401` and is **not** retried as a JWT. Only requests with no API-key credential fall through to the superuser browser-token path, so the Admin UI is unaffected.

### Terminal recipe

```bash
UOA=https://authentication.unlikeotherai.com
KEY=uoa_ak_your_key_minted_in_the_admin_panel

# 1. Discover the app — find its appId, flag ids, and kill-switch ids.
curl -fsS -H "X-API-Key: $KEY" "$UOA/internal/admin/apps"
# => [ { "id": "app_…", "identifier": "com.acme.ios", "flags": 1, "flagDefinitions": [ { "id": "flag_…", "key": "new_checkout" } ], "killSwitches": [ … ] }, … ]

APP=app_…

# 2. Create a feature flag definition (default OFF).
curl -fsS -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "key": "new_checkout", "description": "New checkout flow", "default_state": false }' \
  "$UOA/internal/admin/apps/$APP/flags"

# 2b. Toggle it ON later (PATCH the returned flagId).
curl -fsS -X PATCH -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "key": "new_checkout", "default_state": true }' \
  "$UOA/internal/admin/apps/$APP/flags/flag_…"

# 3. Flip a kill switch — block versions < 1.5.0 with a hard stop.
curl -fsS -X POST -H "X-API-Key: $KEY" -H 'Content-Type: application/json' \
  -d '{ "platform": "both", "type": "hard", "version_field": "versionName",
        "operator": "lt", "version_value": "1.5.0", "version_scheme": "semver",
        "active": true, "priority": 100 }' \
  "$UOA/internal/admin/apps/$APP/kill-switches"
```

The result is visible immediately to clients calling `GET /apps/startup`. See [/api](/api) for the full request/response schema of each route (flag and kill-switch bodies, the apps-list shape, and the superuser-only `/internal/admin/api-keys` management routes).

---

## Public-client / MCP OAuth profile (`/oauth/*`, brief §22.14)

A second, opt-in profile for **standards public clients** (e.g. MCP clients) that have
no `config_url` and no shared secret. Disabled unless
`MCP_OAUTH_PUBLIC_PROFILE_ENABLED=true` **and** the signing key and dedicated
`MCP_OAUTH_DOMAIN` are configured (routes 404 otherwise). Signing-key presence
alone publishes `/oauth/jwks.json` for confidential resource-token verification
and never opens public registration/login/token routes. It does not change the
config-JWT flow above.

- `GET /.well-known/oauth-authorization-server` — RFC 8414 metadata (discover the endpoints below).
- `POST /oauth/register` — RFC 7591 dynamic registration. PUBLIC clients only: send
  `redirect_uris` (https / loopback http / native scheme), get a `client_id`, **no secret**.
- `GET /oauth/authorize?response_type=code&client_id=&redirect_uri=&code_challenge=&code_challenge_method=S256&state=&resource=`
  — renders the first-party login UI; on success redirects to `redirect_uri?code=&state=`.
- `POST /oauth/token` — PKCE exchange (`code`, `redirect_uri`, `code_verifier`, `client_id`),
  **no client secret**. Returns a resource-bound **RS256** access token (`aud` = the requested `resource`).
- `GET /oauth/jwks.json` — verify those access tokens here (separate from the config JWKS).

Standard OAuth 2.1 + PKCE authorization-code flow; resource servers validate the token
statelessly via `/oauth/jwks.json` with issuer + audience checks.

---

Use [/api](/api) for the complete JSON endpoint schema and the canonical `config_jwt` field contract.

## Canonical tariff and entitlement control plane

Tariffs live in UOA. Ledger and product backends consume signed, content-free effective
tariff snapshots; they do not maintain independent tariff truth.

### Commercial semantics

- Precedence is deterministic: **team assignment → organisation assignment → service default**.
- Tariff versions are immutable. A new revision appends a version; changing a default or
  assignment changes only the pointer used for later snapshots.
- Modes are `standard`, `free`, `at_cost`, and `custom`.
- Payment collection is an independent immutable tariff term:
  `collection_mode=stripe|manual|none`. `none` keeps usage rating and cost
  visibility while explicitly collecting no payment. `free` always requires
  `none`; `at_cost + none + monthly amount 0` is the canonical
  "provider cost visible, no payment" plan.
- `markup_bps` is a price adjustment: 2,000 bps means 20.00%. `free` has a usage-price
  multiplier of 0; `at_cost` has 10,000; `standard`/`custom` have
  `10,000 + markup_bps`.
- The optional monthly component is an exact integer minor-unit string plus ISO currency.
  A `free` tariff has zero markup and zero monthly amount. An `at_cost` tariff may have a
  separate monthly subscription, but its usage component remains provider cost.
- **Raw token, request, byte, and search counts are never multiplied, rewritten, or
  relabeled.** Ledger keeps immutable raw usage/provider cost and attribution only.
  UOA applies the signed `usage_price_multiplier_bps` when rating money and deriving
  separately labeled customer billable units:
  `raw_metered_units × usage_price_multiplier_bps / 10000`. The result is a commercial
  unit, not provider output; Ledger retains exact decimal-safe operands and consumers
  show raw usage, billable units, and money separately. Its label follows the underlying
  meter: token-equivalent for token-metered AI, search-equivalent for SERP, and
  research-equivalent for DeepWater.

### Dedicated product app keys

A platform superuser creates one UOA app key per consuming product/environment at:

`POST /internal/admin/billing/services/:serviceId/app-keys`

The request binds the opaque `uoa_app_…` key to exactly one product, one endpoint
purpose, and one RS256 actor public JWK, issuer, and audience. `purpose=entitlement`
can call only the effective-tariff endpoint and forbids redirect origins.
`purpose=customer_lifecycle` can call only direct-session access confirmation,
the canonical customer statement, Checkout, subscription summary, customer portal,
and cancellation preview/confirm endpoints and requires at least one exact HTTPS
return origin.
The plaintext key is returned once; UOA stores only a peppered HMAC digest. The same
Ledger signing JWK may be bound to multiple credentials, but Nessie, DeepWater,
DeepSignal, and DeepTest must keep distinct app secrets so every connection is
independently revocable and attributable.

### Resolve an effective tariff

`POST /billing/v1/effective-tariff`

Headers:

- `X-UOA-App-Key: uoa_app_…`
- `X-UOA-Actor: <RS256 JWT>`

Body:

```json
{
  "product": "deepwater",
  "organisation_id": "org_123",
  "team_id": "team_123",
  "user_id": "usr_123"
}
```

The actor JWT has `iss`/`aud` exactly equal to the credential binding,
`sub=user_id`, and matching `product`, `organisation_id`, and `team_id`, plus
non-empty `jti` and `iat`/`exp` no more than 60 seconds apart. UOA verifies the
signature and re-resolves the ACTIVE org and team memberships before returning anything.

The response is `{ snapshot, payload }`. `snapshot` is an RS256 JWT with
`typ=uoa-tariff+jwt`, `iss=PUBLIC_BASE_URL`, `aud=actor_issuer`, and a five-minute
expiry. Its business claims mirror `payload`:

```json
{
  "schema_version": 1,
  "snapshot_id": "uuid",
  "product": { "id": "svc_123", "identifier": "deepwater" },
  "authorized_party": { "app_key_id": "app_key_123" },
  "subject": {
    "user_id": "usr_123",
    "organisation_id": "org_123",
    "team_id": "team_123"
  },
  "tariff": {
    "id": "tariff_123",
    "key": "standard",
    "version": 2,
    "mode": "standard",
    "collection_mode": "stripe",
    "markup_bps": 2000,
    "markup_percent": "20.00",
    "usage_price_multiplier_bps": 12000,
    "monthly_subscription": { "amount_minor": "2000", "currency": "USD" },
    "usage_billing_enabled": true,
    "payment_collection_enabled": true,
    "raw_usage_preserved": true
  },
  "assignment": { "scope": "team", "id": "assignment_123" },
  "issued_at": "2026-07-19T00:00:00.000Z",
  "expires_at": "2026-07-19T00:05:00.000Z"
}
```

Verify the JWT through `GET /billing/v1/jwks.json`, including algorithm, `kid`,
issuer, audience, type, expiry, subject, and exact business-claim agreement with the
returned payload. Require the signed product ID and identifier, authorized app-key ID,
and user/organisation/team subject to equal the expected credential and request. A
snapshot for another product is invalid even when the same Ledger actor signer is bound
to both products. Never accept the unsigned payload on its own.

The JWKS contains the current and overlapping retired public keys. UOA signs with the
private key whose exact public pair is present in that set and imports every key before
serving, so malformed or incomplete rotation configuration fails at startup.

All tariff catalog/default/assignment/key mutations are platform-superuser-only and are
written to the UOA admin audit log. See [/api](/api) for exact mutation contracts.

### Confirm a direct product session

Every product backend calls `POST /billing/v1/service-access/confirm` immediately
after its own successful UOA SSO exchange or session establishment. It supplies its
own `customer_lifecycle` app key, a fresh bound actor assertion, and the exact
product/organisation/team/user subject body. UOA rechecks both active memberships
and records direct-access evidence; success is `204 No Content`.

This call is forbidden for proxy or agent use of another product. For example, Nessie
using DeepWater through Ledger remains indirect: Nessie must not confirm DeepWater
access. This authenticated direct-session seam, never Ledger metering, determines
which products can appear as related direct cancellation choices.

### Canonical customer statement

UOA is the sole commercial billing engine. Ledger returns only immutable
`metering-usage-v1` facts; it never returns tariff, subscription, markup,
billable-unit, customer-charge, add-on, credit, payment, or cancellation fields.

`GET /schemas/billing-statement-v1.json` publishes the frozen Draft 2020-12 response
schema. `GET /schemas/billing-statement-v2.json` adds the complete SSO-filled,
team-wide connected-service portfolio without mutating v1. The open-source-safe
`@unlikeotherai/billing-statement-protocol` package
is the TypeScript source used by UOA itself; it has no private server imports or
credentials. Until registry publication, consumers can vendor/pack that package
directory or fetch the matching synthetic fixture from
`GET /schemas/billing-statement-v2.example.json` and its exact OpenAPI 3.1
component from `GET /schemas/billing-statement-v2.openapi.json`. Product backends
call:

`POST /billing/v2/customer-statement`

with their own `customer_lifecycle` app key, a fresh bound `X-UOA-Actor`, and the
same product/organisation/team/user subject body (plus optional `billing_month`).
The response is display-ready: exact current plan and subscription, raw and billable
usage, service/caller/origin attribution, per-user totals, monthly/usage/add-on/credit
lines, exact currency totals, capabilities, and action descriptors. V2 additionally
contains team-wide raw totals for every connected billing product, complete
`origin_product` contributions and shares, and per-user service shares. UOA rates
only the requested product. Other-service totals are explanatory and never become
line items or charges on the current statement. One pinned, user-grouped
`metering-portfolio-v1` snapshot covers the exact team and month; UOA derives
commercial rating plus all service, origin, and user totals from it.

Products render the supplied labels, descriptions, totals, shares, and actions
unchanged. They never derive totals, markup wording, direct access, cancellation
choices, or a missing-origin remainder. A Nessie-originated DeepWater call therefore
appears in Nessie’s DeepWater origin share but remains indirect access and cannot
create a related cancellation choice. A null legacy origin renders as
`Unattributed origin` and likewise creates no service, access, or choice.

The same package freezes the full product-facing action protocol. Its exact
Draft 2020-12 schema bundle, synthetic fixtures, and OpenAPI 3.1 components are
served at `/schemas/billing-consumer-actions-v1.json`,
`/schemas/billing-consumer-actions-v1.example.json`, and
`/schemas/billing-consumer-actions-v1.openapi.json`. They cover the normalized
hosted redirect, cancellation selection, preview (including the fixed
`POST /billing/v1/cancellation/confirm` action), confirm request/response, and
minimal error envelope. Every message object rejects unknown properties.

Action IDs and paths are fixed: `upgrade` →
`/billing/v1/stripe/checkout-session`, `portal` →
`/billing/v1/stripe/portal-session`, and `cancel` →
`/billing/v1/cancellation/preview`. The action body contains the exact subject and
server-pinned allowlisted return URLs. Product backends whitelist the ID/path pair and
proxy the body; browser code receives neither UOA app key nor actor JWT.

Direct access comes only from the explicit direct-session call (or another authenticated
first-party billing call) made with that exact product app key. A Ledger caller/origin
product is indirect and cannot create a direct entitlement.
Cancellation preview returns all dialog copy, affected direct services, explanatory
indirect services, an opaque five-minute token, and a server-generated idempotency key.
It offers a related-direct-products choice only when the team has another same-account
direct subscription. Confirmation uses `POST /billing/v1/cancellation/confirm` and
revalidates the pinned subscriptions under lock. The matching idempotency key may
resume/replay; the old one-step cancel route does not exist.

Platform superusers create exact organisation/team one-time or monthly add-ons and
credits in UOA Admin. Values are integer minor-currency strings, audited, and
deactivated rather than deleted. Ledger never receives them.

### Shared team credits and recurring add-ons

`POST /billing/v1/credits` is UOA's display-ready shared credit read. A product calls it
with its own `customer_lifecycle` app key, a fresh bound actor assertion, and the exact
product/organisation/team/user subject. The first field is always **Remaining credits**.
Billing managers receive the full per-service and per-user breakdown plus safe payment
and consent summaries; members receive their own usage, other-team-member aggregates,
and unattributed totals without another user's identity or card detail.

Before projecting a read, UOA pins one exact user-grouped Ledger portfolio cursor for
the team and settles every service in that snapshot together under a serializable team
credit-account lock. Replay is idempotent. Corrections release prior allocations before
reallocating them deterministically. Available credits never cross below zero from new
usage, but the full centrally rated service/user liability remains recorded; only a
verified reversal can create a debt balance. Products never rate, debit, aggregate, or
reallocate shared credits locally.

The exact response schema, synthetic fixture, and OpenAPI 3.1 artifact are public at
`/schemas/billing-credits-v1.json`, `/schemas/billing-credits-v1.example.json`, and
`/schemas/billing-credits-v1.openapi.json`.

Billing-manager mutation actions in that projection are live only when UOA has the
active exact service policy, fixed offer/option, matching active Stripe-account catalog,
and required consent/payment evidence. Products relay the complete action body unchanged:
`/billing/v1/credits/top-up-checkout`, `/billing/v1/credits/auto-top-up/setup`,
`/update`, `/disable`, and `/recover`. These routes require the product's exact
customer-lifecycle app key, a fresh actor assertion, and current ACTIVE organisation/team
billing-manager membership. A caller can identify only the UOA offer or option shown in
the projection; it cannot supply an amount, Stripe Price, currency, quantity, customer,
metadata, PaymentIntent, or return/recovery URL. UOA writes the immutable local intent
before Stripe, derives account/mode-scoped idempotency, and accepts webhook completion
only when Stripe metadata and stored subject/catalog/customer evidence match exactly.
Checkout responses contain one verified HTTPS `redirect_url`; update and disable return
`204`. Missing or drifted policy, catalog, consent, payment, or Stripe evidence fails
closed and keeps the matching action disabled in later projections.

`POST /billing/v1/recurring-addons` returns UOA-owned offers and exact
organisation/team/subscribing-user subscription projections for that product. Manager
views may contain subscription identity; member views expose only the viewer's
relationship to a subscription and never another user's identity or payment details.
Enabled manager actions relay their frozen UOA body to
`/billing/v1/recurring-addons/checkout` or
`/billing/v1/recurring-addons/cancellation/preview`. Checkout is one exact licensed
monthly Stripe item with server-derived price, customer, return URLs, and idempotency.
Checkout completion does not activate the entitlement: UOA requires an exact paid,
undiscounted initial invoice. A preview returns an opaque five-minute token and UOA
idempotency key; `/billing/v1/recurring-addons/cancellation/confirm` rechecks the actor
and subscription, schedules period-end cancellation, and replays the stored exact result.
The matching public artifacts use the `billing-recurring-addons-v1` filenames under
`/schemas`. Recurring add-ons, including DeepWater privacy, remain separate from shared
credits and from usage rating.

### Contract invoices

UOA also owns manual organisation-contract invoicing. A platform superuser creates a
contract, appends immutable forward-effective versions, and sets one organisation-wide
usage markup plus one exact currency and payment term. Activating a version atomically
creates an immutable `CUSTOM + MANUAL` tariff and organisation assignment for each
selected service. A future-effective version can be authored but cannot be activated
before its UTC month. Activation fails on carried-assignment drift, while any covered
service has a team override, during a nonterminal Stripe Checkout/subscription, or while
a completed Checkout still awaits terminal subscription reconciliation. Stripe
projection and manual activation share the exact service/organisation lock in both
directions. The
service set and monthly price per service are pinned to the version; an assignment
pointer may move for a later version and is not historical proof—the term's immutable
tariff ID is authoritative.

The calculator accepts only a closed UTC month. It fetches one immutable organisation-
scoped `metering-usage-v1` snapshot per contracted service (no team filter), applies the
same central exact rating core used by customer statements and Stripe exports, adds that
service's monthly amount, then rounds once at the currency boundary. Credits come only
from the latest canonical funded exact-team usage settlements, remain a separately
labelled aggregate, and never alter a service line. Paid recurring add-ons are shown as
collected separately and never enter the manual invoice total. Currency mismatch, missing selected provider
cost, absent explicit issuer/buyer legal profiles, or unavailable Ledger evidence fails
closed. UOA performs no tax or FX inference.

`POST /internal/admin/billing/invoices/calculate` and every invoice list/detail/mutation
return only final customer price per service, legal profile snapshots, totals, and
separate credit/payment/write-off/outstanding settlement totals plus display-only
separately collected recurring add-ons. They never return
provider cost, token or other units, calls, usage markup, Ledger cursor/hash, or the
internal calculation digest. Credits are never folded into or renamed as service prices.
The contract-editor routes are the only place the organisation markup is shown.

Contract and version controls are also server-authored. A contract returns
`actions.add_version`, and every version returns
`actions.{activation_state,activate}`. The activation state is exactly `active`,
`ready`, `scheduled`, `superseded`, or `contract_terminated`. Admin clients
render those controls as returned and never decide locally which authored version may be
activated.

Every customer-safe invoice DTO includes a server-authored `actions` projection.
`issue` is `issue` only when UOA's database readiness function proves the active
contract, issuer, exact service/metering/credit evidence, collector exclusivity, and
active-invoice uniqueness; it is `resume_issue` for a recoverable issuing invoice,
and otherwise null. `download_pdf` and `void` are exact booleans.
`payment_limits` contains nullable `payment`, `refund`, and `write_off`
maximum Money values. Admin clients render these values and do not reconstruct lifecycle
eligibility, settlement limits, or commercial arithmetic. The private PDF object key and
SHA-256 remain server-only; clients receive only `download_pdf` and stream verified bytes
through the PDF endpoint.
Any private positive credit-settlement reference blocks voiding even if its converted
minor-currency display rounds to zero.

Issuance allocates a contiguous contract/month revision and monotonic issuer/year number
under database advisory locks, stores a
private create-only Unicode PDF, and verifies its SHA-256 on download. Every calculated
draft header, line, and private metering reference freezes at calculation commit. Voiding
retains the number and PDF and is forbidden after settlement; changed calculations and
corrections are new revisions.
Manual payment, refund, and write-off events are positive, append-only, idempotent records and
never mutate a service price. All routes are platform-superuser-only and private,
no-store.

### Stripe subscription invariants

Stripe is an account-and-mode-scoped payment projection. Test and live resources never
share local identities or idempotency keys. Checkout is a recoverable billing-scope lease:
a fresh actor JWT for the same exact product key, tariff source/assignment, scope, customer,
and return URLs recovers the winner, including after a crash between Stripe creation and
the local write. Organisation subscriptions exclude team subscriptions for that product
and organisation; independent team scopes may coexist.

A hosted Checkout anchors the subscription to the first day of the next UTC month with
proration disabled. The initial alignment stub is therefore free, the first invoice
covers the first complete UTC calendar month, and later renewals remain calendar-aligned.
Customer applications use `POST /billing/v1/stripe/subscription-summary` to read a
safe projection without Stripe IDs, `POST /billing/v1/stripe/portal-session` to open
an allowlisted Stripe portal, and the canonical
`/billing/v1/cancellation/preview` → `/billing/v1/cancellation/confirm` flow to
schedule period-end cancellation.
Portal and cancellation require an owner/admin at the exact billing scope. Summary
remains truthful while collection is disabled by returning the last unambiguous local
projection with `stripe_collection_enabled=false`; portal and cancellation fail closed.

A subscription stays pinned to Checkout's immutable tariff version, precedence source,
assignment ID, and billing scope until terminal. Conflicting default or assignment
mutations fail with `STRIPE_TARIFF_PINNED`; UOA does not silently reprice the next cycle.
Signed webhooks are reconciled against current Stripe state. Subscriptions require exactly
the expected quantity-one monthly item when non-zero and one metered usage item, with no
extras, duplicates, or discounts. Missing current Stripe state tombstones an existing
local subscription, and reordered updates cannot resurrect canceled state.

### UOA-to-Ledger billing collection

When Stripe collection is explicitly enabled, UOA reads Ledger’s immutable monthly
raw-metering snapshot with **UOA’s own dedicated Ledger app key** in
`X-Ledger-App-Key`. It never borrows a Nessie, DeepWater, DeepSignal, DeepTest, user,
or webhook credential. A fresh `X-UOA-Service-Assertion` independently binds that app
key ID to `scope=metering.read`, the exact product, organisation, optional team, and
UTC billing month. Ledger verifies the assertion through
`GET /billing/v1/service-jwks.json`; those keys are dedicated to this service
assertion and rotate with a current/retired overlap.

Stripe export continues to request `GET /v1/metering/usage?group_by=service`
for one exact billed product. BillingStatementV2 instead requests
`GET /v1/metering/portfolio?group_by=user` with an exact signed team and
`view=team_portfolio`, producing one immutable `metering-portfolio-v1`
snapshot across billing products. UOA derives commercial rating plus every
service, origin, and user total from that same pinned snapshot. The statement
product in that assertion is a display perspective, never a filter or a tariff grant.
The public product-scoped Ledger schema is
`https://ledger.unlikeotherai.com/schemas/metering-usage-v1.json`. Exact
`mus_…` cursor/ID snapshots contain string raw units, provider-estimated,
provider-actual, and exact selected cost
(`SUM(COALESCE(actual, estimated))`), currency/provenance, and
billing/caller/origin dimensions. UOA rejects unknown
commercial fields and centrally applies the pinned tariff before anything reaches
Stripe. Null caller/origin attribution remains unattributed; it never creates a
service, direct-access record, or cancellation choice.

Platform superusers can exercise or replay one subscription/month through
`POST /internal/admin/billing/stripe/usage-exports`. The optional
`ledger_snapshot_cursor` replays an exact immutable Ledger snapshot. The response
separates `billing_product`, `caller_product`, the exact UOA-rated cumulative
customer charge, and cumulative/delta integer Stripe quantities. When
`STRIPE_BILLING_ENABLED=true`,
UOA automatically invokes the same idempotent export for active Stripe-paid full calendar
periods, polls at `STRIPE_USAGE_EXPORT_INTERVAL_MINUTES`, and schedules an additional
pre-boundary safety pass at `STRIPE_PRE_BOUNDARY_SAFETY_OFFSET_MINUTES` once inside
the configured lead window. It skips the free alignment stub and all free/manual/none
tariffs. A non-calendar period after alignment is an operator-visible failure, never
silently treated as free.

The safety pass is not final. For a draft `subscription_cycle` invoice, the signed
`invoice.created` webhook retrieves current Stripe state, verifies the exact
account/mode/subscription/customer/currency/just-ended calendar period, fetches a fresh
Ledger snapshot, and exports the remaining delta before committing the webhook event.
The retrieved cycle invoice must retain at least one hour between `created` and
`automatically_finalizes_at`; an absent or shorter window fails closed. Failure returns
non-success and leaves the event uncommitted, so Stripe retries during its
invoice-finalization grace period. `invoice.finalization_failed` is also recorded and
logged with structured error/automatic-tax status, and retries post-period export while
the invoice remains a valid draft. Meter-event creation is durable delivery evidence,
not instant aggregate visibility; invoice reconciliation is verified after Stripe
asynchronously recomputes the draft at finalization.

---


---

## Optional per-domain agreement signatures

Agreement signatures are an authenticator service that a UOA platform superuser enables per domain. The process default and every new domain default are disabled, so existing authorization and refresh behavior remain unchanged until the domain is explicitly configured and enabled.

Current operator workflow, all under `/internal/admin/domains/:domain/signatures/*` with an admin-domain superuser bearer token:

1. `POST .../agreements` creates an ordered agreement definition.
2. `POST .../agreements/:agreementId/versions` uploads `multipart/form-data` containing exactly one `application/pdf` field named `file`, plus `title`, `signing_method` (`clickwrap` or `typed_name`), and the exact `acceptance_statement`.
3. UOA enforces configured byte/page bounds, rejects active PDF content, requires a successful ClamAV scan, hashes the exact upload with SHA-256, and writes it to private immutable object storage.
4. Draft metadata/source may be edited or the draft may be deleted. `POST .../versions/:versionId/publish` makes it immutable, supersedes the prior published version atomically, and increments `policy_revision`.
5. `PUT .../settings` with `{ "enabled": true, "retention_days": 365 }` enables the gate only after private storage, ClamAV, dedicated evidence private/public keys, explicit retention, and at least one active published required version are ready.

`GET /internal/admin/domains/:domain/signatures` returns settings, agreements and versions in display order, signature counts, and the latest audit events. Exact source bytes are available only through the authenticated no-store `.../versions/:versionId/source` download.

Operators search retained evidence through `GET .../signatures/records`, filtered by signer, agreement/version, or date range with bounded cursor pagination. `GET .../records/:signatureId/receipt` verifies the private object's SHA-256 before download and records the access in both audit logs. `POST .../records/:signatureId/revoke` requires a reason and appends one immutable revocation; retries return the original revocation rather than overwriting history.

Published versions, signatures, revocations, and signature audit events are protected by database append-only/immutability triggers. A required published version cannot be withdrawn while the domain gate is enabled. Publishing a replacement is the supported atomic transition.

The evidence signing key is a dedicated RS256 RSA JWK with a unique `kid`; it must never reuse config, access-token, admin-token, shared-secret, or email-token key material. `SIGNATURE_EVIDENCE_PUBLIC_JWKS_JSON` contains public-only current and retired keys so historical evidence remains verifiable after rotation.

### Authorization and refresh behavior

Every successful identity path—password, social, email link, email code, workspace selection, 2FA completion, and public-client OAuth—calls the same current-policy evaluator after authentication and required 2FA but before authorization-code creation. A missing signature returns the hosted Auth signing URL instead of a code. The opaque `signing_token` is short-lived, stored only as a keyed hash, removed from the browser address bar after hydration, and accepted only in JSON bodies under `POST /signatures/session/*`.

The hosted UI shows and downloads the exact hash-verified source PDF, presents the exact acceptance statement separately, requires explicit confirmation, captures a typed name only for `typed_name`, and offers the resulting receipt. A typed name is a user assertion—not independent identity verification. UOA describes this output as authenticated agreement evidence, never notarisation, PAdES, a qualified signature, or legal advice.

`POST /signatures/session/sign` first reserves one immutable durable claim for the exact continuation/version while holding the continuation and domain-policy locks. It commits that claim before reading object storage, generating the deterministic receipt PDF, signing the canonical RS256 evidence manifest, or writing the create-only receipt object. It then records evidence readiness and relocks continuation → policy → claim to recheck the exact policy revision, published-version inputs, signature/revocation history, and expiry immediately before atomically appending the signature and audit event. Duplicate/lost-response retries converge on the same claim; an existing receipt object is accepted only when its bytes match exactly. Any policy, version, revocation, or expiry drift fails closed without a signature.

`POST /signatures/session/complete` locks the domain policy, re-evaluates current versions/revocations, consumes the continuation once, and creates the authorization code in the same transaction. If policy changed during signing, the same session returns the newly missing version instead. Public OAuth `state`, `scope`, `resource`, client, redirect, and PKCE challenge are preserved exactly; authorize-time scope is bound to the one-time code and cannot be widened at token exchange.

Before rotating a config-JWT refresh token, UOA locks and re-evaluates the current domain signature policy in the same transaction as rotation and access-token construction. Missing or revoked evidence returns the normal invalid-grant-style failure and leaves the valid refresh token untouched; the client must restart interactive authorization. Disabled domains preserve the pre-module authorization and refresh behavior.

### Signer, backend, and public reads

* `GET /signatures/me/status` and `GET /signatures/me/receipts/:signatureId` require `X-UOA-Access-Token` and are restricted to that token's subject and domain.
* `POST /domain/signatures/status?config_url=...` requires the verified config plus the domain-hash bearer and accepts `{ "user_id": "..." }` only for a user on that domain.
* `GET /signatures/verify/:reference` is IP-rate-limited and verifies the evidence JWS, canonical-manifest hash, exact private source bytes, receipt bytes, evidence `kid`, and revocation state. Its response is deliberately limited to reference state, agreement/version identifiers, hashes, timestamp, and `kid`; it never exposes user ID, signer name, email, IP address, or user agent.

Published versions and retained evidence use restrictive foreign keys and append-only database triggers, so user/domain deletion cannot silently cascade away evidence. Drafts remain the only deletable agreement versions.

See [the JSON endpoint contract](/api) and `Docs/Requirements/domain-signatures.md` for the complete fixed security and lifecycle requirements.


---

## User avatars

Every UOA user always has an avatar. The API resolves one with a fixed precedence and the avatar `GET` endpoints **always return image bytes, never JSON** — there is no "no avatar" case to handle and no 404 for a known user.

1. **Uploaded** — an image set through UOA (`PUT` on any avatar endpoint). Stored in Postgres, one row per user. Wins over everything.
2. **Provider** — the social provider avatar captured in `avatar_url`. UOA fetches it server-side and returns the bytes, so the caller never has to hotlink a provider CDN. HTTPS-only, SSRF-guarded, ~5s timeout, 5 MiB cap, and the response must sniff as a raster image. Nothing is persisted.
3. **Generated** — a deterministic, dependency-free SVG UOA draws itself. Always available.

Any provider-fetch failure silently degrades to the generated image; the response is still `200` and `X-UOA-Avatar-Source` tells you it happened. Apple, which returns no avatar URL, therefore always resolves to `generated`.

### Endpoints

| Method | Path | Auth |
| ------ | ---- | ---- |
| GET / PUT / DELETE | `/domain/users/:userId/avatar?domain=…` | domain hash bearer — backend-driven administration |
| GET / PUT / DELETE | `/avatar/me?domain=…` | domain hash bearer **and** `X-UOA-Access-Token` — the end user's own choice, relayed by your backend |
| GET / PUT / DELETE | `/internal/admin/users/:userId/avatar` | admin superuser bearer; the mutations are audit-logged |

For `/domain/users/:userId/avatar` the user must be visible to the authenticated domain under the same rules as `GET /domain/users`; anything else is the standard generic 404. For `/avatar/me` the access token's `domain` claim must equal `?domain=`, and the acting identity is always the token subject — you cannot act for another user through this route. Operators can also set and remove any user's avatar from the Admin panel through `/internal/admin/users/:userId/avatar`, audit-logged as `user.avatar_updated` / `user.avatar_deleted` against that user's domain.

`PUT` takes `multipart/form-data` with exactly one part named `file`: PNG, JPEG or WebP, at most 1 MiB. The stored type is decided by **magic-byte sniffing**, not the mimetype you send — SVG, HTML, PDF and everything else non-raster is rejected with the generic error envelope. It returns `{ ok, avatar: { source, content_type, size_bytes, updated_at } }`. `DELETE` returns `{ ok: true }` and is idempotent; resolution then falls back to the provider URL or the generated image. Both mutations are rate-limited per domain + user, and both are audit-logged as `domain.user_avatar_updated` / `domain.user_avatar_deleted` against the acting domain. Note that a `global`-scope user is one identity shared by every domain they belong to, so the image you set here is the image every other integration renders for that person — the audit row records which domain changed it.

### Generated styles and caching

Four styles: `tiles` (symmetric identicon mosaic), `waves` (stacked Bézier bands), `rings` (offset concentric rings), and `mono` (black-and-white geometric pattern). Selection order:

1. `?style=` on the GET — one of the four values; use it to preview or pin a style per call.
2. The signed config claim `avatars.default_style` for the domain. Pass `?config_url=` on the avatar GET to have UOA fetch and verify your config and apply it; its `domain` claim must match `?domain=`. The fetch happens only after your bearer checks out — an unauthenticated call is rejected before UOA touches the network.
3. Neither → a stable per-user pick derived from the user id.

Generation is deterministic: the same user always gets the same image, so it is safe to cache and it never flickers between styles. `?size=` (default 128, clamped 16–512) sets the SVG's rendered width/height — the `viewBox` is constant and `size` is ignored for raster images.

Every avatar GET responds with `X-UOA-Avatar-Source: uploaded | provider | generated`, an `ETag` (send it back as `If-None-Match` for a `304`), `X-Content-Type-Options: nosniff`, `Content-Disposition: inline`, and `Cache-Control: private, max-age=300` for uploaded/proxied images or `private, max-age=86400` for generated ones. SVG responses additionally carry `Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'` — generated SVGs contain no scripts, foreign objects, or external references.

Since these endpoints need a bearer credential, a plain `<img src>` cannot call them directly: fetch the image with your credential and render the resulting blob/object URL.

`GET /domain/users` additionally returns `avatar_source` per user so you can label or prioritise without a request per avatar. `avatar_url` is unchanged: it remains the provider URL and is still overwritten on every social login, which is exactly why uploads live in their own table and cannot be clobbered by a later sign-in.

### Avatar URLs in identity payloads

You never have to build an avatar URL yourself. **Wherever a JSON response carries a UOA user's identity — a `userId` and/or the name/email of a UOA user — it also carries an absolute avatar image URL** that always resolves to an image, and that is fetchable with the *same credential class* you used for that request. Two forms, picked by the context you are already in:

| Context | Field | URL |
| ------- | ----- | --- |
| Domain-hash and dual-auth (`/domain/*`, `/org/*`) | `avatar_image_url` / `avatarImageUrl` (each payload keeps its own casing) | `<PUBLIC_BASE_URL>/domain/users/<userId>/avatar?domain=<domain>` |
| Admin bearer (`/internal/admin/*`) | `avatarImageUrl` (`user_avatar_image_url` on the snake_case signature rows) | `<PUBLIC_BASE_URL>/internal/admin/users/<userId>/avatar` |

So a domain-hash caller listing `/domain/users`, org/team/group members, invites or access requests can fetch every returned URL with the bearer it already holds, and an admin-panel caller can do the same with the admin bearer. As with the avatar endpoints themselves, the URL needs that credential — fetch it and render the blob rather than dropping it into `<img src>`. Invite records expose `invitedByAvatarImageUrl` and `acceptedAvatarImageUrl`, both `null` until the matching user id exists.

### Team ("company") avatars

Teams have avatars too, and they work **exactly** like user avatars — same four generated styles, same `?style=`/`?size=`, same upload rules, same response headers, same ETag/`304` behaviour. Only the precedence middle step differs:

1. **Uploaded** — an image set through UOA (`PUT` on any team avatar endpoint). Stored in Postgres, one row per team.
2. **Icon URL** — the team's `iconUrl` (the externally hosted workspace icon from `PUT /org/organisations/:orgId/teams/:teamId`). UOA proxies it server-side under the same HTTPS-only, SSRF-guarded, ~5s/5 MiB rules and reports `X-UOA-Avatar-Source: provider`. Nothing is persisted, and `iconUrl` itself is never modified by the avatar endpoints.
3. **Generated** — the same deterministic SVG generator, seeded from the **team id**.

| Method | Path | Auth |
| ------ | ---- | ---- |
| GET / PUT / DELETE | `/domain/teams/:teamId/avatar?domain=…` | domain hash bearer only — **the path your backend wants** |
| GET | `/org/organisations/:orgId/teams/:teamId/avatar?domain=…&config_url=…` | the standard `/org/*` chain — domain hash bearer + `X-UOA-Access-Token` + verified config; any ACTIVE org member may read |
| PUT / DELETE | `/org/organisations/:orgId/teams/:teamId/avatar?domain=…&config_url=…` | same chain, **organisation owner/admin only** — the same authorization as updating the team |
| GET / PUT / DELETE | `/internal/admin/teams/:teamId/avatar` | admin superuser bearer; the mutations are audit-logged |

**Which one to call.** Use `/domain/teams/:teamId/avatar` from a product backend, and the `/org/*` routes only when you actually hold a live end-user access token (flows inside UOA itself). Products keep the bound refresh credential rather than a spendable access token, so the dual-auth `/org/*` mutations cannot be driven from a backend at all. The `/domain/*` mutations consequently apply **no role check** — per brief §24.10 the domain hash token is full system trust for that domain, exactly as with `PUT /domain/users/:userId/avatar`, so enforce your own owner/admin gating before relaying the call.

On every `/domain/teams/:teamId/avatar` call the team's organisation must belong to the authenticated domain — a team on another domain is the standard generic 404, exactly like a cross-domain user id. Team mutations are rate-limited 30/hour, keyed per domain + team on `/domain/*` and per organisation + team on `/org/*`. Because the `/domain/*` path enforces no role of its own, its mutations are audit-logged as `domain.team_avatar_updated` / `domain.team_avatar_deleted` against the acting domain.

Team records in JSON follow the same identity rule as users: **every team record carries `avatarImageUrl`**, an absolute URL that always resolves to an image and is fetchable with the credential class you already used — `<PUBLIC_BASE_URL>/domain/teams/<teamId>/avatar?domain=<domain>` on `/org/*` and `/internal/org/*`, `<PUBLIC_BASE_URL>/internal/admin/teams/<teamId>/avatar` on `/internal/admin/*`. It is never null. `iconUrl` is unchanged and keeps its own meaning: the external URL you set, or `null` if you set none.

A few payloads deliberately carry **no** avatar URL: bare actor-attribution emails with no user object (`created_by_email`, `published_by_email`, `actor_email`, the signature revocation actor), the frozen billing-statement protocol packages (their schemas reject unknown properties — adding one is a protocol version bump), the admin login-log rows (whose shape drops `userId`), the auth-popup chooser payloads (`/auth/session-choices`, `/auth/verify-code`, `/auth/select-team`, the `/auth/login` chooser, and `/org/me` `pending_invites.invitedBy` — browser-popup context with no credentialed fetch), and invite rows for invitees who have no user account yet.

See [the JSON endpoint contract](/api) and `Docs/Auth/avatars.md` for the full specification.
