## Asynchronous SalesNerd research grants

SalesNerd may obtain a finite background-job grant when an owner admits a research job. This is separate from session:broker; it does not add broker scope or create a reusable user credential.

The authenticated SalesNerd backend calls POST /auth/job-grants?config_url=... with its existing domain-hash bearer and a fresh RS256 subject assertion. The assertion audience is <PUBLIC_BASE_URL>/auth/job-grants and its signed job_authorization binds request_id, the parent job_id, product salesnerd, resource https://ledger.unlikeotherai.com, purpose research_job, and integer authorized_until. It also carries the current UOA subject, credential epoch, selected active organisation/team, assertion ID and normal short expiry. The maximum job authorization is seven days; clients should use the bounded lifetime of the admitted job. UOA returns its persisted opaque grant_handle and exact expiry. A lost-response retry with the same request ID and identical signed binding recovers the same handle; changing any binding conflicts. The handle alone never authenticates a request.

For each background operation, the product calls POST /auth/job-grants/:grantHandle/renew?config_url=... with the same domain bearer and exact {request_id,job_id}. UOA rechecks the product mapping, active user/domain role, credential epoch and current organisation/team membership before issuing a Ledger-bound ai.invoke RS256 token. Its job_authorization claim identifies the UOA grant, request, parent job, purpose and expiry. Each access token lasts at most 120 seconds and never beyond the original job authorization. Child Ledger run IDs, tool-call IDs and Water IDs remain separately bound by their existing signed call protocol.

Cancel with POST /auth/job-grants/:grantHandle/revoke?config_url=... and the same exact request/job pair. Revocation is idempotent and serializes with renewal. It does not require current human membership, so cancellation remains possible after access is removed. Renewal does require fresh current authority. Revoked, expired, epoch-stale, unmapped or membership-removed jobs cannot renew. A new generation for the same parent job requires a fresh request ID and assertion, and only the original client, subject, organisation and team may create it after the previous generation is revoked, expired or credential-epoch stale; each grant record remains immutable. A new owner-authorized job is required to change that root scope. DeepWater compute renewal grants retain a reference to the exact parent grant, do not outlive it, and stop renewing/dispatching when it is revoked or expires. A product must persist the encrypted UOA handle before enabling worker dispatch, and must not copy refresh tokens or user profile data into its job store.

## Entity access and deletion (2026-10-03)
Lifecycle ACTIVE/DISABLED/DELETING/DELETED is distinct from membership. Parent disable denies descendant access without changing child state; no superuser bypass. Offline products must introspect or bound JWT expiry to honor revocation. See /api lifecycle and endpoint schemas. Admin templates have scoped plain customer text and revision snapshots; internal notes never reach account-status proof. Access-status diagnostics are disabled by default: the auth-server environment must explicitly set AUTH_ACCESS_STATUS_ENABLED=true to show Check access status and enable all four website/native start/verify endpoints. False or unset returns 404 before configuration/client lookup, mail, challenge or proof work, including previously issued proof redemption. DEBUG_ENABLED, signed product config, URL parameters and browser storage cannot enable it. When enabled, POST /auth/lifecycle-status/start and verify use signed config_url, mailbox code and enrolled TOTP solely to read authorized reasons, never to log in. Native clients use /oauth/lifecycle-status/start and verify with registered client_id and exact redirect_uri; current app revision binds the proof.
Platform-admin lifecycle endpoints provide preview, typed confirmation, durable jobs and progress. RETAIN_REFERENCE removes PII/credentials and exposes history as {id,deleted:true,name:"Deleted user"} via authorized /domain/historical-users/:id. ERASE_REFERENCE erases mutable identity references; immutable financial/signing evidence may retain restricted IDs/stubs, explicitly inventoried. Ownership must transfer first for retained organisations. Last-team deletion includes the now-empty organisation; shared/dependent accounts survive. Active collection/capabilities must close or transfer before confirmation. Old direct organisation/team DELETE routes now return 409 ENTITY_DELETION_WORKFLOW_REQUIRED.
Products authenticate their exact registered domain bearer to GET /domain/deletion-jobs?domain=... and POST /domain/deletion-jobs/:id/acknowledge?domain=... with {revision,outcome:"PURGED"|"RETAINED_EVIDENCE"}. Honor effectiveScope/effectiveTargetId, teamIds and committed accountsToDelete; replies acknowledge committed cleanup, never merely receipt. Keep only UOA references and product-owned data. Pending or unknown product inventory blocks completion; this repository does not deploy other product adapters. The pull response is {data:[jobs]}. Retained-evidence acknowledgements may include up to30 {label,count,reason} summaries, without unrelated PII; missing summaries are shown as details unavailable. The credential is rechecked under the product policy lock at acknowledgement. Admin retries finish bounded leased UOA cleanup stages only after all acknowledgements; READY after a retry means more stages remain.
# 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)
- 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'`)

---

## Concurrency limits on product data APIs

The data APIs your backend calls on its users' request paths — `/org/*`, `/domain/*`,
`/settings/*`, `/internal/org/*`, `/avatar/*`, `/email/*` — share a small per-instance
concurrency cap so sign-in always keeps database connections. An excess request waits briefly
(FIFO), then answers **`503`** with **`Retry-After: 1`** and code **`PRODUCT_API_BUSY`**. The
request had no effect: retry after the delay with backoff (you already treat 5xx as temporary).

Never fan out one UOA call per end-user request. Resolve `/org/me` and settings once per
session, cache them for the session, and refresh on your own schedule — a burst of ~100
`/org/me` calls in one second is exactly what this limit exists to absorb. Sign-in
(`/auth/*` including `/auth/token`, `/oauth/*`, `/2fa/*`), onboarding (`/integrations/*`),
billing and operator paths are never limited.

## 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`). Image logos have gently rounded corners by default; set `ui_theme.logo.rounded: false` to preserve square corners. The rounding uses `ui_theme.radii.button` and does not affect text logos.
- `language_config` is one IETF code or a non-empty array of codes.

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.*`. `org_features.org_roles` /
`org_features.team_roles` name this domain's role vocabularies and
`org_features.role_grants` binds those roles to capability names — the only place authority is
configured. Never gate on a role string; see `/llm` §4.4a.

### 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 `team_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`.

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 state across the callback — use `state`

Send an opaque `state` on the call that begins the flow and UOA echoes it back
verbatim beside `code`:

```text
GET /auth?config_url=<…>&redirect_url=<…>&code_challenge=<S256>&code_challenge_method=S256&state=<opaque>
```

```text
<redirect_url>?code=<authorization_code>&state=<opaque>
```

`state` is optional and UOA never interprets it — generate an unguessable
per-request value, store it against the pending request on your side, and refuse
any callback whose echoed `state` does not match. **PKCE and `state` are not
substitutes**: PKCE proves the party redeeming the code is the party that started
the flow, while `state` binds *this* callback to *your* pending record, which is
what defeats login-CSRF (an attacker feeding you their own code).

UOA binds the value to the login for its whole lifetime — through the team
chooser, the 2FA challenge and enrolment bridges, the social provider round-trip,
and the signature continuation — so no mid-flow hop can substitute a different
one. It is echoed on failure too: a failed social callback redirects to
`<redirect_url>?error=auth_failed&state=<opaque>`, so a pending record can be
retired instead of left to time out. Omit `state` and the redirect is exactly as
it was before the parameter existed.

Send it on whichever call begins the flow: `GET /auth`, `POST /auth/login`,
`POST /auth/start`, `POST /auth/register`, `POST /auth/verify-code`,
`POST /auth/verify-email`, `GET /auth/email/link`, or
`GET /auth/social/:provider`. Do **not** re-send it on later hops such as
`POST /auth/select-team` — the value signed into the `login_token` is
authoritative, and a hop that presents a different one is refused.

**Do not append `state` to `redirect_url`.** `redirect_url` is matched
byte-for-byte against `config.redirect_urls` **including the query string**, so
any per-request parameter added there is rejected with
`REDIRECT_URL_NOT_ALLOWED`. That is what the `state` parameter is for.

**Worked example.**

```ts
// Caller (Admin UI):
const state = crypto.randomUUID();
sessionStorage.setItem('sso:state:google', state);
const url = new URL('https://authentication.unlikeotherai.com/auth');
url.searchParams.set('config_url', CONFIG_URL);
url.searchParams.set('redirect_url', REDIRECT_URL); // verbatim from config.redirect_urls
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
window.location.assign(url.toString());

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

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

Keep the PKCE `code_verifier` in the same `sessionStorage` record: the two
protect different steps and both are needed.

## Admin activity and directory reads
GET /internal/admin/logs accepts limit (default 100, maximum 500) and optional exact userId under the admin-superuser bearer boundary. It returns latest successful login events with userId and ISO UTC occurredAt, a bounded window rather than all history. Organisation preapprovedMembers is the legacy field for invitation history: status pending, accepted, declined, replaced, revoked or expired, separate approvalStatus, and targetTeamId.

## Admin team membership
POST /internal/admin/users/:userId/teams accepts {orgId, teamId, teamRole: "member" | "admin"} with an admin-domain superuser bearer. It atomically adds missing organisation/default-team membership as member and the requested team role, using standard UOA membership limits. Exact retries are idempotent; active roles are never changed by Add. Removed memberships can be re-added with the requested role, without reviving revoked sessions. Suspended memberships and ownership changes are refused. All writes and audit records commit together.

## One-use debug login
POST /auth/debug-login/issue?config_url=... with the domain bearer and {refresh_token, previous_token?} returns {token, expires_in:1800}. Renew invalidates the previous code atomically. POST /auth/debug-login/redeem with the same verified config_url and bearer plus {token} consumes the code and returns an independent ordinary token pair. Codes bind the exact product/environment, source family, credential epoch and selected team. Supported Nessie theme variants in a signed config URL are presentation-only; other query parameters remain part of the environment binding. Expiry, logout, revocation or loss of membership refuse redemption. Normal session logout calls POST /auth/revoke with {refresh_token,scope:"family"}; it revokes only that family and its unused debug grants. Omitting scope retains global credential epoch revocation. Tokens must stay in POST bodies; the product owns the bottom-right issue/redeem UI and exports exactly {url,token}.
## First-party UOA Admin debug login
The production bottom-right Debug login button creates a one-use admin code through POST /internal/admin/debug-login/issue?config_url=<exact first-party /internal/admin/config>, using the current admin bearer and its exact bearer-bound HttpOnly source-session cookie. Body {previous_token?}; response {url,token,expires_in}. Clipboard exports exactly {url,token}. /admin/login keeps a deliberate Google button and an independent debug importer accepting that JSON or a bare code. POST /internal/admin/debug-login/redeem with {token} returns only a new short-lived admin access bearer and sets a separate private family cookie. All three admin debug/logout mutations require the canonical service Origin and JSON content type before fetching config. Current ACTIVE user, credential epoch and admin-domain SUPERUSER are checked under issuance locks; ordinary product credentials cannot use these routes. Codes last at most1800seconds, capped by the source session lifetime. Renew, expiry, source logout or revocation invalidates outstanding codes. POST /internal/admin/logout with {} revokes only the exact bearer-associated family. Cookies are HttpOnly, SameSite=Strict, HTTPS Secure, scoped to /internal/admin and expire with the access session; they are ownership handles and cannot refresh the admin browser. Existing sessions without a handle must sign in again.
## Selkie broker capability
The existing confidential exchange accepts session:broker only for product coder, source coder.unlikeotherai.com and exact resource https://api.selkie.live, with an explicit enabled operator mapping. Active org/team and credential epoch are required. Chained exchange is refused. POST /auth/session-broker/validate?config_url=<Selkie signed config> with Selkie domain bearer and {token} rechecks signature, exact audience/scope/source, current mapping, credential epoch, source domain role and active team. Target domain role and canonical target ban policy remain required. The current signed target config must explicitly select personal subject-only devices (org_features.enabled=false) or team admission (enabled=true); only the latter requires registered target team policy. Missing target scope fails closed and caller input cannot change it. No MFA completion is asserted. Response {sub,expires_at,active:{orgId,teamId}} contains no profiles. Selkie validates each broker request and caps its opaque local handle at the capability expiry; rebroker after expiry. Broker capabilities cannot issue debug login grants or independent refresh families.

## Independent website services in subfolders

The hostname-only instructions above remain valid for existing integrations.
A separate service at https://therockbottom.co.uk/rafikimedia/ instead signs
`domain: "therockbottom.co.uk/rafikimedia"`. The hostname plus canonical,
lowercase mount path (no trailing slash) is its complete service identity.

Publish its config and JWKS beneath that exact path. UOA verifies both hostname
and path-segment boundaries; /rafikimedia-other/ and the parent / are not this
service. JWKS redirects must stay inside the same service path. Paths permit
lowercase letters, digits, underscores and hyphens in nonempty segments only.
Do not use URL schemes, query strings, ports, percent escapes or dot segments
in the identity. Config/JWKS use HTTPS on the default port.

Register/approve this identity as its own entry in Admin > Services, with its
own signing key, secret, login allowlist, redirect allowlist and label. Derive
the backend bearer as SHA256(full service identity + its own client secret).
Send that full identity as every domain query/claim. URL-encode it when used
in an admin route parameter. Existing admin tools store the full identity;
they must never select the hostname record as a fallback.

Global UOA identities remain shared; access, domain roles, organisation origin
scope and credentials are separate. Granting Rock Bottom access does not grant
Rafiki access. Use separate UOA family membership/policy for Rafiki. This is an
additive registration: existing hostname rows are not renamed or migrated, and
no users, roles, memberships or signing keys are copied from the parent.

A shared browser origin is still a browser trust boundary: a subfolder is not
isolation against malicious JavaScript served elsewhere on that same origin.
Use separate origins if that threat must also be isolated. Machine-readable
configuration and endpoint contracts are at [/api](/api).

---

## 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, tenant_slug, org_role, teams[], team_roles{}, groups?[], group_admin?[] }`. |
| `active.tenantSlug` | custom (optional) | Canonical DNS-safe subdomain label for the selected team. It is sourced from the organisation slug and unique within this client domain. Treat its absence as a legacy-token compatibility case; never use `Team.slug` as a tenant host key. |
| `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';
const tenantSlug = (claims.active as { tenantSlug?: string } | undefined)?.tenantSlug;
```

### 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. A backend that deliberately retains no UOA access token may call `GET /org/me` with its one-minute, product-signed `X-UOA-Subject-Assertion` instead; UOA rechecks the assertion's authentication epoch and exact active org/team before returning roles. The response is role context, not resolved product capabilities.

### 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.

### Live platform administrator verdict

The top-level `system_admin: boolean` in `GET /org/me` is the current platform
administrator verdict for the authenticated person, resolved from `SUPERUSER` on
`ADMIN_AUTH_DOMAIN`. It is independent of org/team owner/admin standing and
product-domain bootstrap superuser roles. This clarifies the legacy `claims.role`
description above: that token claim can also represent a product-domain superuser
and must not authorize a platform-only operation.

The usual signed person credential, ACTIVE identity/membership and current credential
epoch checks still apply. No user ID may be supplied instead of that credential;
backend mode remains forbidden. Responses are private/no-store. Missing, malformed,
false or unavailable verdicts deny access. Fetch it for every privileged operation,
never persist it or cache a positive result. No database means false.

### 4.4a Roles are the domain's words; do not hard-code them

The org and team role **vocabularies are per-domain configuration**
(`org_features.org_roles` and `org_features.team_roles`, both defaulting to
`["owner","admin","member"]` and both required to contain `"owner"`). A domain may name
`auditor`, `editor`, `reader`, anything. So:

- **Never coerce an unrecognised role to `"member"`.** `member` is not a floor — in most
  products it is write access. An unknown role must resolve to *no* role and *no* capability.
- **Never compare a role string to decide what someone may do.** `role === "admin"` is already
  false the day a domain renames the role.
- Read `org_features.role_grants` instead: `{ "org": { <role>: <capability>[] },
  "team": { <role>: <capability>[] } }`, with `"owner"` implicitly holding everything at the
  scope it stands in, an unmentioned role holding nothing, and a team answer being the union
  of the org-role and team-role grants. UOA validates that table against the domain's vocabularies
  and declared `capabilities` at config load, so anything you read there is coherent.

UOA gates its own org and team surfaces on exactly this table, using three capability names —
`members.manage` (roster mutation at either scope), `teams.manage` (the team object), and
`organisation.manage` (the organisation object: rename, deletion, member-invites policy, icon; **org scope
only**, so a team role never reaches it). See `org_features.role_grants` at `/api`. Products
declare their own catalogue in `org_features.capabilities` and gate on their own verbs.

Ordinary org role changes use `members.manage`, and org deletion uses
`organisation.manage`. Under the default grant table both Owner and Admin can manage them.
Ownership is structural: only the ACTIVE canonical `Organisation.ownerId` holding the
`owner` role may hand over ownership. No ordinary add, role change, removal or deactivation
may assign or alter an owner, even if other owners remain. These target protections also apply
in backend mode. Billing management remains a verdict UOA computes from its own state,
never a grant. No `role_grants` entry can bypass ownership protection.

Structural does not mean exempt from your vocabulary. Ownership transfer is the one such operation
that also *writes* a role — the outgoing owner's — so it obeys `org_roles` like every other
membership write. `POST .../transfer-ownership` takes an optional `previousOwnerRole` naming the
role that owner is left with; it is validated exactly as `PUT .../members/:userId` validates a role
change, and `"owner"` is refused, since the endpoint's contract is a demotion. Omit it and UOA
writes `"admin"` when your vocabulary contains that role, and otherwise your vocabulary's first
non-owner entry — so order `org_roles` from most to least authority. A vocabulary of nothing but
`"owner"` has no demotion target at all, and the transfer is refused rather than writing a role
your own config would reject back.

### 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). The response carries `defaultTeam` — the first team, named after the organisation and created in the same transaction, with the owner already an ACTIVE member — so you can select the new team straight away instead of re-reading. A name whose address another organisation already holds is refused with 400 `ORG_SLUG_TAKEN` (never suffixed): ask the person for another name. 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
const tenantSlug = (claims.active as { tenantSlug?: string } | undefined)?.tenantSlug;
// Route a scoped, newly-issued token to `${tenantSlug}.your-tenant-domain`.
// Re-authenticate or resolve the team through UOA before routing a legacy
// token that has no tenantSlug.

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.
- `allow_user_create_team` (default `false`) gates `POST /auth/create-team`: whether a user may add a **further team** to an organisation they already run, from the SSO chooser. Separate from `allow_user_create_org` because it writes into an existing tenant rather than creating a new one, and the org owner/admin role check still applies on top.

When `login_flow.team_selection` is `"auto"`, the hosted chooser has one
card-corner creation control that opens a dialog. It offers **Create a new
organisation** whenever the signed configuration grants organisation creation through
`can_create_org: true`; it sends `{ login_token, name, join_policy? }` to
`POST /auth/create-organisation`. That endpoint creates the organisation and its
default team atomically with the remaining login continuation. Do not mint a
tenant locally or use an empty `/auth/select-team` selection for this path.

An **organisation is the level above a team**, and the two creation paths differ accordingly.
`can_create_org` permits a user to create a *new organisation*; the chooser's `creatable_orgs`
(`[{ orgId, orgName }]`) lists organisations the user may add a *further team* to — they are
an ACTIVE owner/admin there and the domain set `org_features.allow_user_create_team`. When there
is more than one permitted destination, the dialog presents these exact values in an organisation
dropdown; never infer, add, or substitute an `org_id`. It posts
`{ login_token, org_id, name, join_policy? }` to `POST /auth/create-team`, which re-checks that
role and the org's `max_teams_per_org` cap, adds the creator to the new team, and finalizes
the login in the same transaction.

Both hosted chooser creation routes accept the optional `join_policy` values
`"HIDDEN"`, `"INVITE_ONLY"` (the default), and `"OPEN_TO_ORG"`. The UI labels
`HIDDEN` as **Private**: it is not discoverable and only people who are invited can find it.
`INVITE_ONLY` requires an invite to join, while `OPEN_TO_ORG` lets active members of the selected
organisation join themselves. Other team policies are managed through the organisation API, not this
hosted creation continuation. A user with exactly one team and no pending invites still auto-skips
the chooser, so that user reaches creation through the product rather than the sign-in popup.

After exchanging the resulting authorization code, use
`access_token.active.tenantSlug` as the tenant's DNS label. It is the
organisation slug and is unique within the client domain. `Team.slug` is only
unique inside its organisation, so it must not be used as a subdomain. Existing
tokens may omit `tenantSlug` until they expire; re-authenticate or resolve the
team through UOA before redirecting a legacy session to a tenant host.

### 4.6a Per-product confidential assertion exchange

A registered backend can exchange a short-lived user assertion, optionally
scoped to a selected team, 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 team = orgId && teamId ? { active: { orgId, teamId } } : {};
const subjectToken = await new SignJWT({
  source_domain: 'api.nessie.works',
  ...team,
})
  .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. A product holds one mapping per exact resource, so
calling a second product (for example DeepCRM at `https://api.deepcrm.live`)
needs its own mapping and scope allowlist; a request for a resource with no
mapping of its own is refused. 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 team-less users. When present it must be exactly
`{ orgId, teamId }` with both values non-empty; partial or malformed team
objects are rejected. Mint a fresh unique `jti` for every attempt: after
identity and optional team 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.

Branch on the refusal body to tell the person's problem from your own. When the
refusal comes from the person's current state — their credential epoch (`tv`)
moved, the user is unknown, they lost the source-domain role, or the selected
team is no longer available to them — UOA answers `403` with exactly
`{"error":"Request failed","code":"TOKEN_EXCHANGE_SUBJECT_FORBIDDEN"}`. The code
never says which of those it was; ask the person to sign in again. A `403` with
the generic body `{"error":"Request failed"}` and no code is your product's
own request or configuration: no enabled mapping for the product, resource, and
scope, scope widening, an assertion without `active` where your team policy
requires a team, or an `active` team where your product supports none. UOA
decides those before looking at the subject, so they are identical for every
person: treat them as an integration fault, not a sign-in problem.

```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 team
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, team, 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`,
`memory.read`, `memory.write`, 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.

`memory.read` and `memory.write` are explicit resource-bound scopes. Neither
implies the other, and no AI/billing/provisioning grant implies memory access.
The recipient still checks current subject, source and destination authority.
New scope support does not create a mapping or expand existing grants.

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 omits both user credentials:** `X-UOA-Access-Token` and
`X-UOA-Subject-Assertion`. There is no acting user, no token exchange, and no
extra header.

### 4.6c Product-signed current-user assertion

An integrated product that needs UOA to authorize the person currently signed
into that product, but deliberately does not retain a UOA bearer token, sends a
short-lived RS256 JWT in `X-UOA-Subject-Assertion` instead of
`X-UOA-Access-Token`. This is user mode, never backend mode, and it still sends
the usual domain-hash bearer and verified config URL.

The assertion must be signed by the product key advertised in the verified
config JWT's JWKS, have `iss` and `source_domain` equal to that config domain,
have exact `aud` `https://authentication.unlikeotherai.com/org`, live for at
most 60 seconds, and contain `{ sub, tv, active: { orgId, teamId } }`. The
active organisation must match the route `orgId`. The active team records
where the person came from; when the route names a different team, UOA resolves
that exact target and the caller's current capability there before the action.
It never lets a product session silently choose a target team. UOA then verifies
the signature and re-resolves the subject's current credential epoch and ACTIVE
cross-product membership before it applies the ordinary user role/capability
gate and writes ordinary user audit attribution. A product assertion is not a
UOA access token, is never persisted by UOA, and cannot grant tenant-wide
backend authority.

If UOA cannot fetch the product’s public verification keys because of a network
error, timeout, rate limit or upstream server error, it returns
`503 INTEGRATION_JWKS_UNAVAILABLE`. No authority is admitted; retain the
product session and retry with backoff and a fresh assertion. A genuine invalid
signature, missing key, expired assertion or unsafe JWKS destination remains a
401 refusal. Never substitute local membership records for UOA’s answer.

Send exactly one user credential. A request with both headers, a blank or
repeated assertion, a stale epoch, invalid signature/audience/lifetime, or a
different active organisation is refused; it never falls through to backend mode.
`GET /org/organisations` and backend-only invitation acceptance refuse either
user credential with `401 ACCESS_TOKEN_NOT_ALLOWED`.

**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 have been created on the verified domain, the last
  owner cannot be removed, membership and team caps still hold, and 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 — in this mode.** The call binds to the domain in
the VERIFIED config, never the raw query string — a `?domain=` that differs is
`400 DOMAIN_MISMATCH`. The guard then checks the `:orgId` against
`organisations.domain` (the org's ORIGIN — the product that created it), so an
`:orgId` another product created is a plain `404`. A backend for domain X cannot
see or touch an organisation created on domain Y.

This is backend mode's *only* boundary, and the one place origin scoping is still
an authorization predicate. **User-token calls are different**: one organisation
is usable from every UOA-integrated product, so `/org/organisations/:orgId/**`
resolves the org by id alone and gates on the token's `domain` + `org` claim plus
live ACTIVE membership. A user who belongs to an org created on domain X manages
it from domain Y with a domain-Y token; a domain-Y *backend* still cannot.

**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. The response carries `defaultTeam` (below). |
| `GET|PUT /org/organisations/:orgId` | Yes. |
| `DELETE /org/organisations/:orgId` and `DELETE .../teams/:teamId` | Refused with `409 ENTITY_DELETION_WORKFLOW_REQUIRED`; use platform-admin lifecycle jobs. |
| `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** — see below for the role they land on. |
| `GET .../groups`, `GET .../groups/:groupId` | Yes. |
| `GET .../teams` | Yes — and lists `HIDDEN` teams too; that filter is member-to-member discovery. |
| `POST .../teams` | Yes — but `join_creator` is ignored: there is no acting user to add. |
| `GET .../teams/:teamId` | Yes, including `?include=invited`. |
| `PUT .../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). |
| `GET .../teams/:teamId/invitations/:inviteId` | Yes — reads one invitation by id, same record shape as the list. |
| `POST .../teams/:teamId/invitations/:inviteId/accept` | **Backend-only.** Strict body `{ userId }`; accepts for that asserted UOA invitee and returns `{ ok, orgId, teamId }`. A previously `REMOVED` invitee is reactivated as `member` with the invitation's team role; a `DEACTIVATED` one is refused 400 `MEMBERSHIP_DEACTIVATED`. Any present user token is refused. |
| `DELETE .../teams/:teamId/invitations/:inviteId` | Yes — revokes any pending invitation, including one still awaiting member-invite approval. |
| `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. |

**Creating an organisation gives you its team in the same answer.** The
create transaction also makes a default team named after the organisation and puts the owner
in it, and the response carries that whole team record as `defaultTeam`. Use
`defaultTeam.id` to address the new team immediately.

This matters most to a product driving creation from its own UI: there is no
follow-up read that recovers the id with a user credential, because a
`X-UOA-Subject-Assertion` has to name the org and team it is acting on, and
those are exactly what is unknown a millisecond after the org is born. Without
the field, such a product had to send the person back through the interactive
chooser purely to learn one id. Backend mode can of course also call
`GET /org/organisations/:orgId/teams` and pick the `isDefault` entry; keeping
that as a defensive fallback is reasonable, but it is no longer required.

**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 teams 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 or does not belong to this domain. |
| 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` was not created on this domain (backend mode only — user-token calls resolve any org the token is scoped to). |

**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.
- **Re-inviting a removed person works.** Accepting a valid invitation — email link, social sign-in, the hosted team chooser, or backend-mode `POST .../invitations/:inviteId/accept` — flips a `REMOVED` org and invited-team row back to `ACTIVE`. The org role is reset to `member` (an admin/owner role held before removal is never restored), the team role comes from the invitation, the seat counts against `max_members_per_org` / `max_members_per_team` / `max_team_memberships_per_user` like a new membership, and revoked sessions stay revoked. Other team rows removed with the member stay `REMOVED`. A `DEACTIVATED` membership is a suspension an invitation cannot lift: acceptance is refused with `MEMBERSHIP_DEACTIVATED` (the hosted invite page shows the generic "This invitation can’t be used"); reactivate the member first. An invitation that was already accepted never reactivates anything on replay.
- `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` (24 hours from send/resend — resending refreshes both the invite and emailed-token deadlines) and an `approvalStatus`: `not_required` | `pending` | `approved` | `denied`. The derived invite `status` gains `expired` and `revoked` alongside `pending | accepted | declined | replaced` (`replaced` = superseded by a newer invite for the same email; `revoked` = explicitly cancelled); an expired, revoked, or not-yet-approved invite cannot be accepted and is excluded from the team chooser / `firstLogin.pending_invites`. The mailbox-bound HTML journey says `This invitation has expired` only for an elapsed invitation/token deadline and `This invitation can’t be used` for every other unusable state.

The invitation e-mail is addressed to the invitee and names the inviter by display name only (never by address): its subject reads `<Inviter> invited you to join <team> on <product>`, or `You’ve been invited to join …` when UOA has no name for the inviter. The product is `ui_theme.logo.alt`; it is left out when the config has none. Filter on the sender address, not the subject — the subject is copy and may change.

**One live invitation per address.** At most one *actionable* invitation — not accepted, not declined, not revoked, and approval not `denied` — exists per `(team, lowercased email)`, enforced by a partial unique index rather than by application checks alone, so two concurrent invites to the same address can no longer both land. Inviting an address that already holds one replaces it: the previous invitation is revoked as `replaced` and the result is `resent_existing`. Terminal invitations are untouched and accumulate as history.

**Roles an invitation may grant.** Any role in the domain's `org_features.team_roles` vocabulary (default `owner | admin | member`) **except `owner`**. Ownership of a team comes from direct membership management, never from an emailed invitation, so an `owner` grant is rejected with the generic `400` on create and on resend, and by a database constraint underneath. A domain that defined its own roles invites into them normally — the rail names `owner` specifically, it is not a fixed `member|admin` list.

**Resending** (`POST .../invitations/:inviteId/resend`) requires an actionable invitation whose approval is settled. Accepted, declined, revoked, approval-denied, and still-awaiting-approval invitations all answer the generic `400`: a resend must not resurrect an invitation an administrator revoked, nor mail one nobody has approved. An *expired* invitation is still resendable — that is how it gets a fresh token and a fresh window.

Reading one invitation: `GET /org/organisations/:orgId/teams/:teamId/invitations/:inviteId` returns that single invitation in exactly the shape each entry of the list carries (derived `status`, `approvalStatus`, `expiresAt`, inviter, send/open and accepted/declined/revoked state, `invitedByAvatarImageUrl`/`acceptedAvatarImageUrl`) — so a caller holding an id can re-read one row instead of the whole team's invite history. Authorization is the list's: domain-hash + verified config + `org_features`, backend-mode capable. It is read-only — nothing is written and no audit row is produced. An unknown id, an invitation belonging to a foreign org/team, and an unknown org all answer the same generic `404`, so the endpoint is never an existence oracle. The org itself need not have been created on the calling product's domain — one organisation is usable from every UOA-integrated product, and the token's `org` claim plus live membership are the gate (backend mode stays origin-scoped).

Where the id comes from: the trusted-backend bulk call `POST /org/organisations/:orgId/teams/:teamId/invitations` answers `{ "results": [...] }` with one object per submitted email — `{ email, status, invite? }`. On `invited` and `resent_existing` rows the `invite` key is the full invitation record, so `results[i].invite.id` is the id of the invitation just created (or, for `resent_existing`, of the fresh invitation that superseded the previous one) and is what the by-id read, `.../resend`, and `DELETE` take. The `already_member`, `existing_user`, and `conflict` outcomes create no invitation and therefore carry `email` + `status` only, with no `invite` key. The member-initiated (`X-UOA-Access-Token`) variant returns `{ "status": "ok" }` and deliberately no id — revealing one would be enumeration.

Backend acceptance: `POST /org/organisations/:orgId/teams/:teamId/invitations/:inviteId/accept` with strict body `{ "userId": "<UOA invitee id>" }` accepts that exact invitation server-to-server. Authenticate with the domain-hash bearer and verified config, enable `org_features.backend_org_management`, and **omit** `X-UOA-Access-Token`; any present user-token header is `401 ACCESS_TOKEN_NOT_ALLOWED`. The backend asserts the invitee id within backend mode's existing trust boundary. Success is `200 { "ok": true, "orgId": "…", "teamId": "…" }`; replaying the same accepted invite for the same user is the same success. Unknown or path-mismatched ids, email mismatch, revoked, expired, and approval-pending invitations all retain the generic `400` body. The organisation's origin domain is never compared with the calling product's — every client domain is equal, and one organisation is usable from every UOA-integrated product, so an invitation into an organisation founded through another product is accepted normally. An invitee may hold active memberships in more than one organisation on the same origin domain.

Revoking an invitation: `DELETE /org/organisations/:orgId/teams/:teamId/invitations/:inviteId` cancels a pending invitation in either state — already emailed, or still awaiting member-invite approval. The row is kept (invite history is audit history) with `status: "revoked"`, outstanding emailed tokens are consumed, and the invite link's hosted landing page answers with the generic `This invitation can’t be used` page (it never says which unusable state it is). In user mode the caller must be an org/team `owner`/`admin` or the invite's original inviter; backend mode follows the table above. Responses: `200 { "ok": true }` (idempotent — an already-revoked or declined invitation also answers `200`), `409` with code `INVITATION_ALREADY_ACCEPTED` once the invite has been accepted (remove the member instead), generic `404` for an unknown id or a foreign org/team.

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:

- Anyone holding `members.manage` in the team — org or team `owner`/`admin` under the default grant table: 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 team stack, "Invited" tab, and team 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; `teams`
remains the array of team IDs that keys `team_roles`, and the renderable directory is the
separate `team_directory`). For a
recognized `all_active_memberships` product with no same-domain organisation, the complete legacy
block is anchored to the access token's exact selected `active.orgId` after UOA revalidates that
organisation and the product policy live:

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

- `team_directory[]` — one entry per ACTIVE team membership on this domain. When UOA's server-owned
  product policy is `all_active_memberships`, it instead contains the same complete active
  team directory as the hosted chooser. It is 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 team; cross-product entries intentionally
  remain `null` because another product's session history is not exposed. Group by each entry's
  own `orgId`/`orgName`; the directory may span organisations and is not implicitly scoped by the
  singular legacy `org_id` field.
- `avatarImageUrl` — the public, credential-free `<PUBLIC_BASE_URL>/teams/<teamId>/avatar` form.
  It is never null and resolves uploaded image → proxied `iconUrl` → deterministic generated image,
  so a native or browser client renders this field directly instead of fetching `iconUrl` itself.
- `pending_invites[]` — every actionable invitation addressed to the caller (same eligibility as
  the team chooser: unaccepted/undeclined/unrevoked, not expired, and not still awaiting
  member-invite approval). Its organisation reach is the directory's own: every organisation on
  this domain, plus — for an `all_active_memberships` product — every organisation the caller is
  an ACTIVE member of — the identical filter the hosted chooser's own `pending_invites` uses, so
  the two surfaces answer with one set. An invitation into an organisation the caller is NOT
  currently signed into therefore appears here, which is the whole point: the chooser offers it at
  sign-in and this is where the product finds the same invitation afterwards. Each row's
  `orgId`/`orgName`/`orgSlug` name the INVITING organisation — render "<teamName> · <orgName>"
  (two organisations can each own a team called "General"), file any notification under that
  organisation rather than the active one, and select it after acceptance. Never infer the
  organisation from the legacy singular `org.org_id`. `invitedBy` is the inviter's NAME — never
  their e-mail address, and `null` when no name is known, in which case render no inviter line
  rather than inventing one. Ordered oldest first.
- Render this straight into the Slack-style sidebar: active team 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.

**Team 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 team chooser's `teams[]`, `/org/me`'s
  `team_directory[]`, 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 team 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.

---

## Fresh verification of a product action

A backend can use POST /auth/action-verification/start and POST /auth/action-verification/verify
with the domain-hash bearer, signed config_url, domain query, and exactly one authenticated
user credential (X-UOA-Access-Token or a one-minute RS256 X-UOA-Subject-Assertion with /org audience).
Backend-only calls are rejected. Start accepts actionDigest (64 lowercase hex SHA-256 of the
exact user, action nonce and immutable terms) and description (1–240 characters for the email).
It sends a five-minute code to UOA's canonical account email and returns challengeId, expiresAt,
and twoFactorRequired. Verify accepts actionDigest, challengeId, code and, for enrolled accounts,
twoFactorCode. It consumes the challenge and returns verified:true and actionDigest to the
backend only. Required 2FA enrollment cannot be skipped. Five guesses exhaust a challenge;
resend invalidates prior codes for that exact action. Issuance is limited to 20 per person per
15 minutes across instances. Wrong person/domain/action, expired/replayed codes and revoked
credential epochs fail. The product must recheck its authorization and apply only the bound
change. Never treat an ordinary login/refresh or a client-provided verified flag as proof.

## Phase 4.8 — Slack-style email sign-in codes + team 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 team 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
  "team_selection": "off"      // "off" suppresses the chooser; "auto" may show it
}
```

**Security note (read before enabling):** team 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:
   - `team_selection: "auto"` → `{ login_token, teams: [{ teamId, orgId, name, slug, role, iconUrl, avatarImageUrl, orgName, orgSlug }], pending_invites: [{ inviteId, teamName, orgName, invitedBy }], can_create_org, creatable_orgs }`.
   - `team_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 team 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
   team 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 team.
4. `POST /auth/login` (password) also routes into the chooser when `team_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 team (or required first placement) before any 2FA decision and fail closed on
   ambiguous choices.
5. Social login (`GET /auth/callback/:provider`) resolves team immediately after identity,
   before 2FA. With `team_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 `team_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=team_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=team_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-team 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. An invitation opened
   straight from a mailbox has no PKCE verifier at all, so it can never reach that pipeline: a new
   address lands on the invite registration screen and signs in there, while an address that already
   has an account (a `LOGIN_LINK` token) has its invitation consumed on the spot and ends on a
   terminal confirmation page — the invitee then signs in to the product normally. Neither branch
   falls back to the login restart, which would leave the invitation pending forever.
   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 team; an issuance failure rolls the team and code back together while the
   one-time email token remains consumed.

### Direct email invitations

A personal team-invitation link is an account and membership flow, not a relying-party OAuth
initiation. A new password invite opens account creation with the invited email prefilled and
read-only. The server consumes the invitation capability itself, so a modified browser field cannot
change the account that is created. When an enabled social provider is selected, UOA keeps the raw
invitation capability in a signed, HttpOnly, short-lived cookie; provider-visible OAuth `state`
contains only a signed non-secret marker. The callback accepts the invitation only after the
provider returns a verified email that matches the invitation email exactly (case-insensitively).
Any mismatch, expired, used, or revoked invitation is rejected generically. Direct invitation
completion returns to the hosted Auth UI without issuing an OAuth authorization code; a normal
PKCE-bound product flow continues to use the regular authorization-code path.

Both terminal screens — the SPA's "You’re in" after registration, and the
server-rendered confirmation for an address that already had an account — end outside any
product session, so send `redirect_url` on the invitation link (`POST /org/:orgId/invitations`
`redirect_url`, carried through `GET /auth/email/team-invite` and `GET /auth/email/link`).
When it is one of your config's own `redirect_urls`, UOA renders a
"Continue to &lt;product&gt;" button pointing at it, naming the product from
`ui_theme.logo.alt` (falling back to the config domain), and the invitation email names the
product too. A value that is not in `redirect_urls` is dropped, not rejected: the pages read
exactly as they do without the parameter, and never redirect anywhere you did not allow-list.

The invitation registration screen and the public set-password screen both send an optional
`name` in the `POST /auth/verify-email` body (≤120 chars, trimmed). UOA stores it only when
the account has no name yet, so it can never overwrite an established profile name; for an
invitation, the inviter-supplied name pre-fills that field (the invitee can change it) and stays
the fallback when the field is left empty.
   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-team 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
   team switch (design §11.4): a product's sidebar links back into `/auth` with the team
   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.
8. `GET /auth` accepts an optional `prompt=select_account` for a person adding another account
   to the product. The social buttons forward it, and Google then shows its account chooser
   (including "Use another account") instead of silently reusing the Google account the browser is
   already signed into. Send it only for that flow — an ordinary sign-in omits it and is unchanged.
   Any other `prompt` value is ignored by the Auth UI; it selects a provider screen and grants
   nothing.

---

## Phase 4.8.1 — Seamless backend team switch

Once the user has a renewable session, the product backend can switch that session to a team from
the authorized directory without reopening the hosted chooser:

```http
POST /auth/token?config_url=<your_config_endpoint_url>
Authorization: Bearer <domain-hash credential>
Content-Type: application/json

{
  "grant_type": "urn:unlikeotherai:params:oauth:grant-type:team-switch",
  "refresh_token": "<opaque server-side refresh token>",
  "organization_id": "<exact target org id>",
  "team_id": "<exact target team id>"
}
```

There is deliberately no `operation_id`. UOA binds intent to the presented predecessor plus the exact
target and creates a deterministic successor. Before rotation it rechecks both ACTIVE memberships,
the server-owned cross-product policy, the target Organisation's current 2FA policy, user enrollment,
and signature policy. The refresh family carries the authorization code's completed-TOTP assurance
as an immutable bit across every descendant; old rows default to no assurance. If the target now needs
stronger proof, restart interactive authorization and complete 2FA.

Success is the normal token-pair envelope with `access_token.active` scoped exactly to the requested
`organization_id`/`team_id`; `firstLogin` is absent. Atomically commit both returned tokens and the
selected team before acknowledging the UI switch. A plain `grant_type=refresh_token` always
preserves its current org/team and can never be used as an implicit switch.

Response-loss recovery is strict: for 120 seconds, retry the same predecessor only with the same grant
and target. UOA returns the deterministic current successor only while every descendant retains that
exact target. If another refresh or switch won, or a later switch moved the family again, UOA returns
`409 TEAM_SWITCH_CONFLICT` and does not consume or revoke the valid family. After the 120-second
window, every predecessor use is treated as theft and revokes the family, regardless of supplied intent.
Same-scope switch requests are also non-consuming `TEAM_SWITCH_CONFLICT` no-ops.
If an exact retry proves the switch edge already committed but the target membership, 2FA, or
signature policy no longer passes, UOA retires that refresh family and returns authenticated
`401 INVALID_REFRESH_TOKEN`. This is definitive: clear the pending switch and require login. It
does not revoke sibling refresh families or increment the global access-token epoch.

Production-safe semantic errors:

- `403 TEAM_NOT_AVAILABLE` — the exact org/team pair is unavailable or server policy denies it;
  this intentionally does not reveal which check failed. Refresh the directory or reauthorize.
- `403 INTERACTION_REQUIRED` — current family assurance is insufficient for the target. Start a new
  interactive authorization flow.
- `409 TEAM_SWITCH_CONFLICT` — same-scope no-op or a valid successor belongs to another
  grant/target. Reconcile with the latest locally committed session; do not loop on the predecessor.
- After domain-client authentication succeeds, invalid, expired, revoked, structurally corrupt, or
  post-grace reused sources return the stable `401 INVALID_REFRESH_TOKEN` code without revealing
  which condition failed. Client-authentication failures remain generic 401 responses.

---

## 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_JWKS_UNAVAILABLE` | Product subject-assertion verification | HTTP 503: public key retrieval hit a network failure, transient DNS EAI_AGAIN, timeout, rate limit or upstream server error. Retain the product session and retry with backoff and a fresh assertion; no authentication or token issuance occurred. Real invalid signatures, missing keys, expired assertions and unsafe destinations remain 401 refusals. |
| `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. |
| `TEAM_NOT_AVAILABLE` | team-switch grant | The exact requested org/team is no longer available or server-owned product policy denies it. Refresh the authorized team directory; UOA intentionally does not reveal which condition failed. |
| `INTERACTION_REQUIRED` | team-switch grant | The renewable family does not carry enough completed-2FA assurance for the target's current policy. Restart interactive authorization. |
| `TEAM_SWITCH_CONFLICT` | team-switch grant | The request is same-scope or the deterministic family already advanced under another grant/target. Reconcile the latest committed session; do not retry a stale predecessor forever. |
| 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/team-switch `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; optional `user_id` resolves an exact subject while requiring the same domain visibility.
- `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`.
- `DELETE /internal/admin/organisations/:orgId` — legacy direct deletion, refused with `409 ENTITY_DELETION_WORKFLOW_REQUIRED`. Use the platform-admin lifecycle preview, confirmation and deletion-job endpoints; the preview identifies dependent accounts and restricted retained evidence.
- `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`).
  Clients registered with an enabled native-app `app_id` also receive `refresh_token` +
  `refresh_token_expires_in`, and renew with JSON
  `{ "grant_type": "refresh_token", "refresh_token": "...", "client_id": "..." }` (rotated on every
  use, bound to that exact client, never broader than the original scope/resource; every refusal is
  a generic 401). Plain dynamic registrations get no refresh token and re-authorize on expiry.
- `POST /oauth/revoke` — RFC 7009, JSON `{ "token": "...", "client_id": "..." }`; always 200, revokes the
  refresh-token family when it belongs to that client (same logout semantics as `/auth/revoke`).
- `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.

### 4.7c Member rosters and team-add candidates

Use the roster endpoints for a members page; do not build a local copy of UOA
identity or membership data.

- `GET /org/organisations/:orgId/members` is the organisation roster.
- `GET /org/organisations/:orgId/teams/:teamId/members` is the team roster.

Both accept `status=ACTIVE|DEACTIVATED|REMOVED|all` (default `ACTIVE`),
`limit` (maximum 200), `direction=forward|backward` (default `forward`),
and an **opaque** `cursor`. They return this common envelope:

```json
{
  "data": [{
    "subject": "uoa_user_…",
    "role": "member",
    "status": "ACTIVE",
    "identity": { "displayName": "Ada Lovelace", "avatarImageUrl": "…" }
  }],
  "total": 42,
  "meta": {
    "hasMore": true,
    "nextCursor": "opaque…",
    "prevCursor": null
  },
  "permissions": {
    "addMember": false,
    "changeMemberRole": false,
    "removeMember": false,
    "orgRoleOptions": []
  }
}
```

Treat `subject` as the stable UOA user reference. `userId` remains as a
legacy alias. Render `identity.displayName` and `identity.avatarImageUrl`;
do not infer a person from a role. `identity.email` is deliberately absent
unless the caller holds `members.manage` at the applicable scope. Backend mode
(no `X-UOA-Access-Token`) remains the tenant authority and receives the same
full verdicts and email visibility as existing mutation routes.

Use `meta.nextCursor` with `direction=forward` and `meta.prevCursor` with
`direction=backward`; never decode, construct, or persist cursor internals.
`next_cursor` is retained only as a compatibility alias for `meta.nextCursor`.

The `permissions` object reports individual action verdicts rather than an
overbroad `isManager` flag. Respect the relevant action key when deciding
whether to show a control; UOA still reauthorizes the eventual mutation.
For the organisation roster, `orgRoleOptions` is the verified config's complete
assignable organisation-role vocabulary, excluding `owner`, and is non-empty
only when `changeMemberRole` is true. Use it for the role picker; ownership
transfer has a separate route and must never be presented as a role option.

To power a debounced Add member picker, call
`GET /org/organisations/:orgId/teams/:teamId/members/candidates?q=…`.
`q` is required, limited to 100 characters, and matched server-side against
ACTIVE members of that organisation only. Results are cursor-paged with the
same meta contract, capped at 50 per page, and exclude users who are already
ACTIVE members of the target team. The endpoint itself
requires `members.manage`; it returns no invitation feed and does not search a
domain-wide user directory.

When an organisation manager opens a member, call
`GET /org/organisations/:orgId/members/:userId/teams`. Its rows are the
editable team memberships for that exact person, and `hasAccess` is the
live membership state. The feed contains only teams
where the caller currently holds `members.manage`; do not infer access to, or
attempt to change, a team omitted from it. Add or remove a selected team
through that exact team's existing member endpoints, which re-authorize each
write. Role changes use `PUT .../teams/:teamId/members/:userId` with
`{ "teamRole": "admin" }`; adding a member uses `POST .../members` with
`{ "userId": "uoa_user_…", "teamRole": "member" }`. These request fields
are camelCase; the returned roster's `role` is a display field.

For the Pending invitations tab, use
`GET /org/organisations/:orgId/member-invitations`. This is a separate,
actionable `TeamInvite` feed, not
`GET /org/organisations/:orgId/invitations?approval=pending` (the owner-only
approval work queue). Every invitation row contains its explicit target team.
The feed is cursor-paged with the same `meta` envelope and returns only teams
where the caller currently holds `members.manage`.
For a team Members page, use the same contract at
`GET /org/organisations/:orgId/teams/:teamId/member-invitations`.

Before creating an organisation-level invitation, obtain the explicit target
team from `GET /org/organisations/:orgId/member-invitation-targets`; it has
the same cursor envelope and only includes teams the caller can manage. Submit
the selected id to the existing exact-team invitation endpoint. Do not reuse a
product session's active team as a silent target.

Invitation history (`GET .../teams/:teamId/invitations`), invitation detail
(`GET .../invitations/:inviteId`) and resend
(`POST .../invitations/:inviteId/resend`) require a current
`members.manage` grant in that exact target team. Present either the user's
access token or a fresh `X-UOA-Subject-Assertion`; its session team supplies
provenance and does not silently narrow the target. A present but invalid,
blank or ambiguous credential fails closed. Omitting both credentials selects
backend mode only with `backend_org_management=true` and only for an
organisation created on the verified product domain. Resending still refuses
revoked, accepted, declined, denied or unapproved invitations.

Organisation roster permissions also include `transferOwnership`: true only for the ACTIVE
canonical owner (or an explicitly authorized backend). Owner and Admin have the same default
management capabilities, including changing non-owner org roles. Do not offer Owner in an
ordinary role picker, and do not offer removal, deactivation or demotion for an owner row.
Use `POST /org/organisations/:orgId/transfer-ownership` for handover to another ACTIVE member;
it atomically changes the canonical owner, promotes the recipient and demotes the outgoing owner.

## Ledger runtime-key operators
Current admin-domain SUPERUSER operators use Admin Billing > Product billing > selected product > Ledger runtime keys. GET /internal/admin/billing/ledger-runtime-keys returns only product/source/audience/prefix/id/time/revocation metadata. POST the same path with {product,source_domain,ledger_audience} issues a one-time secret bound to an active product, original config hostname and exact HTTPS Ledger origin. POST /internal/admin/billing/ledger-runtime-keys/:keyId/revoke revokes that exact key. All responses are no-store. The UI keeps plaintext only in the mounted dialog, outside storage, URLs and query caches, and clears it on close/navigation/sign-out. It never installs the key, creates a bootstrap credential or expands delegation permissions. Lost-response issuance may still have committed: inspect metadata and revoke unused keys before retrying.
## Canonical tariff and entitlement control plane

### Private paid-usage exception recovery

When a selected immutable Ledger provider receipt exceeds the original frozen
cost bound, ordinary settlement remains held. The exact product-bound Ledger
RuntimeKey calls `POST /billing/v1/ledger/reservations/:dispatchId/exception`
with the selected receipt, exact USD actual cost and lowercase SHA-256 digest.
The digest is over compact JSON of
`[dispatchId,receiptId,rawActualFixed18,"USD",requestFingerprint,rawBoundFixed18,contextDigest]`.
Paid reserve and original product-bound decision lookup expose the exact
private frozen `context_digest`; Ledger persists it before provider egress.
It creates only a held private exception, never a customer debit or release.
`GET` at the same path returns the held or exact terminal decision to Ledger.

Platform superusers review the oldest held receipts in Admin Billing → Usage
exceptions or `GET /internal/admin/billing/paid-usage-exceptions`. A freshly
issued (at most five minutes old), live-epoch superuser token may POST the exact
evidence digest, stable idempotency key and written reason to
`/internal/admin/billing/paid-usage-exceptions/:dispatchId/write-off`.
UOA books the frozen gross rated liability once, charges at most the original
reserved microcredits, explicitly waives the excess, and still counts gross
usage against credit budgets. PREPAID wallet debits only the collectible part.
Unknown actual cost, missing historical rating or a changed selected receipt
remain held for further evidence; no operator can turn unknown into zero.

Platform superusers configure append-only prepaid legal invoice tax policy at
`GET/POST /internal/admin/billing/credit-invoice-tax-policies`. A policy binds
one Stripe account, active legal issuer, issuer jurisdiction, inclusive tax
rate or documented no-tax treatment, legal basis and UTC effective time.
Accepted credit payments fund the wallet once and create one durable invoice
obligation. Missing buyer, issuer or tax facts leave a pending document; UOA
never adds tax to the already charged amount or fabricates a legal invoice.

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

### Commercial semantics

- UOA resolves immutable monthly terms by team assignment, organisation assignment,
  then service default. Only UOA rates selected Ledger provider cost.
- The customer receives exact usage charges, credit consumption,
  monthly subscription price, collection status, and payer scope. Provider cost,
  markup, margin, rate multipliers, and cost-basis modes stay inside UOA.
- Product backends verify signed entitlement binding and render UOA-authored
  customer statements. They do not calculate a customer price or credit debit.

### 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, DeepTest, and DocGen 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` 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.

**`aud` must name the endpoint you are calling.** Mint it per request as
`${PUBLIC_BASE_URL}` joined with the exact request path — `.../billing/v1/effective-tariff`
here, `.../billing/v2/customer-statement` there, and so on for every endpoint below.
Do not pin one audience and reuse it: an assertion whose audience names another
endpoint is refused with 401 `BILLING_ACTOR_AUDIENCE_MISMATCH`, which is what stops a
captured read assertion being spent at a funding or cancellation endpoint inside its
60-second window. Deployments in the `BILLING_ACTOR_AUDIENCE_MODE=warn` transition
still accept the single legacy audience
`${PUBLIC_BASE_URL}/billing/v1/effective-tariff` everywhere and log each use;
`enforce` refuses it. Move to per-endpoint audiences now — they are accepted in both
modes.

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",
    "collection_mode": "stripe",
    "monthly_subscription": {
      "amount_minor": "2000", "currency": "USD", "charge_basis": "per_seat",
      "seat_policy": "fixed", "seat_timing": "prorated",
      "amount_role": "per_seat_unit"
    },
    "usage_billing_enabled": true,
    "usage_payment_mode": "prepaid",
    "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 Draft 2020-12 response
schema. `GET /schemas/billing-statement-v2.json` adds the complete SSO-filled,
team-wide connected-service portfolio. Package 4.0.0 removes the public pinned
tariff identity and adds monthly cycles; strict consumers must update before UOA
serves the revised schemas. 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, customer
charges, credit consumption, monthly/usage/add-on/credit lines, exact currency
totals, capabilities, and action descriptors. V2 includes connected-service
customer credit totals without exposing token counts or provider costs. 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 the private rating evidence and public credit totals from it.

### Customer display language

Cycles and invoice list/detail reads accept the same optional
X-UOA-Billing-Presentation: 1.5.0 and X-UOA-Billing-Locale headers as statements
and credits. Supported locales are cs, en-US, en-GB, de, es, fr, it. Locale is
outside the signed subject. Only generated labels and exact decimal money
displays change, after source integrity checks. Stored snapshots, charge/rating
facts, line identities and legal PDF/CSV bytes stay immutable. Authored service
names, Stripe line descriptions and stored adjustment reasons remain verbatim.

### Actual customer charge invoices
Use the product's customer_lifecycle app key and a fresh exact-endpoint
X-UOA-Actor assertion for POST /billing/v1/invoices/list, /detail, and
/download. List by charge_month in UTC, then use only UOA's opaque invoice
and document IDs. A prepaid purchase or automatic recharge appears in the month
of Stripe's accepted payment event even if legal issue finishes later. Pending
documents have verified payment and purchased credits but no invented tax,
invoice number or download. An organisation invoice or pool purchase requires
current organisation billing-manager authority; a team manager only sees the
selected team's own charge. Legal PDFs list charges and tax, not private usage
units, provider cost or markup. The strict contract and synthetic examples are
at /schemas/billing-customer-invoices-v1.json and its example and OpenAPI
siblings. Refund and dispute effects are shown separately from
the original immutable legal PDF.

### Monthly billing cycles and documents

The canonical BillingCyclesV2 contract is published at
`/schemas/billing-cycles-v2.json`, `/schemas/billing-cycles-v2.example.json`, and
`/schemas/billing-cycles-v2.openapi.json`. A product's billing page calls
`POST /billing/v1/cycles/list` and `/detail` with its exact lifecycle app key,
fresh endpoint-audience actor assertion, and product/organisation/team/user body.
The caller must be a current billing manager for the cycle's frozen payer
scope. The requested team can have a measured-usage cycle even when its payer
is the organisation. Verified organisation billing managers also see a
separate organisation subscription cycle with null team id; that null is never
permission to expose another team's named usage. The list cursor includes
month and cycle scope. The detail contains customer seat charges, consumed credits,
and persisted invoice/breakdown metadata, never provider cost or markup.
An open preview has no final invoice download. Only a document's server-authored
`download_action` may be relayed to `POST /billing/v1/cycles/download`; UOA
rechecks the same authority and streams immutable SHA-verified bytes. Products
must not build a storage URL or invoice ID locally.

Products render the supplied labels, descriptions, totals, shares, and actions
unchanged. They never derive totals, private price terms, 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.

### Organisation-wide billing

An organisation can take billing over from all of its teams, across every service:
`POST /billing/v1/organisation-billing/assume`, `.../release`, and a read at
`POST /billing/v1/organisation-billing`. All three take the ordinary subject body, the
product's `customer_lifecycle` app key and a fresh actor assertion, and additionally
require an **organisation** billing manager — a verdict UOA makes itself and never accepts
from a product.

While it is on, `resolveCreditAccount` returns the organisation's credit account for
every team, so all metered spend lands on one balance with no change anywhere in Ledger
or in a product. The statement and the credits view then carry a `controlled_by` block
(protocol 1.3.0) whose `message` is display-ready and whose `can_manage` is UOA's
verdict about the exact caller. **Render `message` verbatim and offer only the action
UOA names.** A caller who cannot manage receives an empty action list and no funding
controls at all, so a product still vendoring 1.2.0 renders a read-only surface rather
than buttons that would 403. An organisation billing manager additionally receives
`organisation_scope` on the v2 statement: every team, each rated from its own pinned
`metering-portfolio-v1` snapshot, with organisation totals that are the sum of the team
totals. It sits beside the requested team's own fields, never in place of them.

Assuming is refused whole — never partially applied — while any team funding action is in
flight (`FUNDING_ACTION_IN_FLIGHT`, listing the exact rows) or while a live team-scoped
Stripe subscription exists (`TEAM_SUBSCRIPTIONS_ACTIVE`, listing them). Auto-migrating a
subscription would re-bill a customer without a decision, so an operator moves or ends it
first. Each team's stored automatic top-up consent is deactivated — never deleted — with
an audit row, and team credit balances are never swept: they remain the team's and are
reachable again on release. Releasing reverses credit resolution only; organisation-scoped
ledger rows, invoices and statements stay where the spend was incurred.

### 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. The operator must attest the invoice's tax treatment, exact rate and legal
basis before draft calculation; those facts and cumulative per-line tax are frozen.
UOA performs no FX inference. A later manual usage correction creates only a
delta-only supplemental invoice through
`/internal/admin/billing/cycle-corrections`, with the original issued tax policy
and legal invoice retained. Negative provider-cost differences remain held
without a separate verified correction source; immutable paid receipts do not
decrease. For an issuer-authorized cancellation of a paid, single-product manual
invoice with no wallet offset, Admin can prepare a separate legal credit note
against the exact original amount and frozen tax policy with a required reason.
POST /internal/admin/billing/invoices/:invoiceId/credit-note/prepare freezes it,
POST /internal/admin/billing/credit-notes/:id/issue allocates a separate legal
number/PDF, and GET /internal/admin/billing/credit-notes/:id/pdf downloads it.
The original invoice and accepted payment stay immutable. Verified refunds and
customer credit still due appear in an append-only customer cycle revision;
the note never mints prepaid wallet credits.

`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. A draft is ready only for a future effective month; scheduled means
an activated version will take effect in that future month.

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.

---

Automatic top-up disable remains available during an unresolved payment. Only the
original verified lifecycle event may settle that older attempt; settlement never
restores disabled consent. Recovery uses a bounded exact-account Stripe Events
scan and the existing validation and deduplication path, never a replacement charge.

### Customer language and exact credit purchase status

Presentation-capable clients send `x-uoa-billing-presentation: 1.5.0` and
`x-uoa-billing-locale` (cs, en-US, en-GB, de, es, fr, it) on billing reads,
previews and actions. These headers affect display only; signed subjects and frozen
action bodies stay unchanged. Legacy clients retain English and the old redirect
envelope. Negotiated credit Checkout additionally returns an opaque `purchase_id`.
Read it via `POST /billing/v1/credits/purchase-status` with the exact subject and
fresh endpoint-bound actor. The strict public schema is
`/schemas/billing-credit-purchase-status-v1.json`. A successful redirect or
Stripe PaymentIntent does not prove that credits were added: only the durable
credit entry plus its verified completion event yields `succeeded`. Read failures
must not be presented as declined payments. Poll only `awaiting_confirmation`
within a finite return window. When Stripe still reports the exact bound Checkout
open, the response may include `continuation` with the same opaque `purchase_id`;
opening it resumes that session without creating another Checkout or charge.
Current authority, account/customer/service/app-key binding and private no-store
responses prevent cross-context disclosure.
New hosted Stripe sessions receive the requested language. Resuming an existing
session preserves it; subscription Checkout retries reuse their saved locale.


## Organisation billing region
Every organisation has one billing region, UK, US or EUROPE, which sets the
currency of everything SMS (GBP, USD or EUR). It is chosen once by an
organisation owner or admin and never changes. POST /billing/v1/organisation-billing
returns billing_region: {region, currency} (both null until chosen),
{suggested_region, suggested_currency} to prefill the choice (from the
organisation's invoice billing country: GB → UK, the EU, EEA and CH → EUROPE,
anything else → US) and can_set (owner or admin, and no region yet). Send
x-uoa-billing-region-protocol: 1.1.0 to also receive suggested_from:
billing_country when a billing country decided the suggestion, or null when no
country is known and US is only the default.
POST /billing/v1/organisation-billing/region with the subject fields plus
region, the customer_lifecycle app key and a fresh X-UOA-Actor for that exact
endpoint chooses it and returns the same block. Repeating the same region is a
no-op; a different one answers 409 BILLING_REGION_IMMUTABLE; a non-manager gets
403 BILLING_ORG_MANAGER_REQUIRED. Until a region is chosen, SMS quotes, number
purchases and SMS top-ups answer 409 BILLING_REGION_REQUIRED: ask an owner or
admin to choose. AI usage stays USD for every region. Render the block; store
nothing.

## Credits bought in the region currency
Credits stay one USD-denominated unit (1,000 per USD). An organisation in the UK
or Europe buys them in GBP or EUR. Send x-uoa-billing-credits-protocol: 2.1.0 on
POST /billing/v1/credits to receive the one-time offers in the region currency
(USD until a region is chosen; older protocol versions keep the USD offers, which
stay purchasable). A GBP or EUR offer carries credit_rate {currency,
usd_per_unit, source, as_of, display}; its credits_received is what today's rate
buys. The exact credits are fixed when POST /billing/v1/credits/top-up-checkout
creates the checkout (payment times Stripe's live base rate, rounded down to
whole credits, no buffer) and granted unchanged when the payment succeeds. While
no rate can be read those offers are left out and the checkout answers 503
BILLING_CREDIT_FX_RATE_UNAVAILABLE; try again later.

## Automatic reload in the region currency
Send x-uoa-billing-credits-protocol: 2.2.0 to also receive the automatic reload
options in the region currency, one per refill amount (10, 25, 50, 100, 1,000),
each reloading when the balance is worth less than a fifth of its refill, up to
four reloads a month. A GBP or EUR option carries threshold_value and
credit_rate, with refill_payment_amount and monthly_cap in its currency; its
threshold and refill_credits_received are what today's rate makes of them.
automatic_top_up carries threshold_value and the monthly figures in the
consented option's currency. The balance is compared as a value: each credit lot
at the rate it was bought at. Each reload reads Stripe's live base rate when it
is claimed, just before its off-session payment, and its credits are fixed then.
A failed reload pauses automatic top-up (needs_review, one
payment_action_required attention item) until the card is updated; it is not
retried. Older protocol versions keep the USD options.

## Credits valued in the region currency
Send x-uoa-billing-credits-protocol: 2.3.0 to also receive what credits are
worth in the region currency; charges stay in credits. credit_balance.value is
the available balance with a label ("€42.18 left", or "€1.20 owed" with a
negative amount for debt), each lot at the rate it was bought at, so it never
moves with exchange rates. Each recent_entries item carries value, a positive
amount (direction gives the sign): a purchase is worth what was paid for it, any
other entry the sum of what it drew from or gave back to each lot.
region_rate {currency, usd_per_unit, credits_per_unit, source, as_of, display}
is the rate the next credits used are valued at: show credits ÷
credits_per_unit as an estimate beside a price in credits. Any of these is null
while it cannot be valued yet. Render them; store nothing.


## Mobile-number and SMS pricing
POST /billing/v1/sms/quotes with the product ENTITLEMENT app key and a fresh
X-UOA-Actor signed for this exact endpoint. Body contains product,
organisation_id, team_id, user_id, country, number_type: "mobile", direction,
destination (outbound E.164 or null), carrier:null, mcc:null and mnc:null.
UOA verifies outbound country through Basic Lookup and uses the conservative
maximum mobile carrier basis. Return only opaque id, final exact amount and its
currency, expiry, rate_basis and scope. Render the currency the quote carries.
A monthly quote is UOA's flat monthly number price in the organisation's billing
region currency (EUR 4.99, GBP 4.49 or USD 5.49 at release), the same in every
country. 409 BILLING_REGION_REQUIRED means an owner or admin must choose the
region first; 409 BILLING_SMS_MONTHLY_PRICE_BELOW_COST means UOA does not offer
numbers in that country at that price. Message quotes are USD. Missing provider
or accepted dated currency policy returns unavailable; no sample quote exists.
POST /billing/v1/sms/quotes/verify with the same scope plus quote_id and a fresh
actor for that exact verification endpoint. It refuses changed scope or expiry.
Monthly quote expiry is a display/admission limit, not cancellation of already
accepted paid terms for an exact number resource.
Registered prepaid routes require fresh customer authority for funding and grant
consent, ENTITLEMENT for human reserve, and SMS_RUNTIME for physical dispatch and
recovery. Inbound receipt requests include original organisation_id and team_id.
Missing revoke/release/standing retirement return strict minimal fenced proofs.
Customer SMS quotes include accepted additional-fee bounds and are maximum prices
per segment; actual verified settlement may be lower. No unknown charge is freed
or repeated merely because delivery failed.

### Credit balance attention and funding help

Negotiated POST /billing/v1/credits responses may include attention events for source-confirmed low or exhausted credits, required payment action, an expired saved card, or paused automatic top-up. Each event contains only an opaque stable event_key and a kind; it does not include a balance, card data, or a payment result. These fields are omitted for clients without Billing Presentation 1.5.0. UOA emits a low-credit event only when the active consent or an active configured automatic option supplies an explicit positive threshold. Free or non-metered access does not produce balance warnings.

The same negotiated projection may provide funding_request for metered billing with collection enabled to a viewer who cannot manage the current payer scope, only when a current authorized manager can receive it. Its frozen request is POST /billing/v1/credits/funding-request with the exact product, organisation_id, team_id, and user_id subject from the projection and the same Billing Presentation 1.5.0 headers. The caller cannot nominate recipients. UOA revalidates the fresh endpoint-bound actor, active subject memberships, current organisation billing responsibility, and manager roles, then returns {schema_version:1,request_id,recipient_user_ids}.

The response identifies people eligible to receive a consumer-created in-product alert. It does not send an alert and does not claim that one was delivered. Map only those exact source IDs to active local users, and deduplicate the local alert by the opaque request ID. UOA returns the same request ID for the same source account, team, requester, and UTC day. Its strict response schema is /schemas/billing-credit-funding-request-v1.json.


---

## 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, team 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 team 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 |
| GET | `/teams/:teamId/avatar` | **none — public.** The only unauthenticated avatar route |

**The public read.** `GET /teams/:teamId/avatar` takes no credential and no `?domain=`, so a browser can render a team logo straight from an `<img src>`. Its response explicitly carries `Cross-Origin-Resource-Policy: cross-origin`; credentialed avatar routes retain the default same-origin policy. It exists because the auth-window team chooser renders in a popup that holds no bearer of any class — every other form above needs one, which a plain `<img>` cannot send. It is not an existence oracle: an unknown or deleted team id returns the same deterministic generated SVG a real team with no image would, so every id answers 200 and no id can be probed. Team ids are unguessable cuids; anyone holding one can fetch that team's logo. Reads are rate-limited 300/hour per IP plus a global ceiling of 120,000/hour service-wide, and no `config_url` is accepted (an anonymous caller must not be able to aim the config fetcher), so the generated style is always the platform default.

**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 ordinary `/org/*` and `/internal/org/*` records, `<PUBLIC_BASE_URL>/internal/admin/teams/<teamId>/avatar` on `/internal/admin/*`. The cross-product `GET /org/me` team directory uses the public form described below so every returned team remains renderable regardless of its owning domain. 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 *user* identities in 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.

The chooser's **teams** and `GET /org/me`'s `team_directory[]`, unlike its user identities, do carry `avatarImageUrl` in the public `<PUBLIC_BASE_URL>/teams/<teamId>/avatar` form, the one URL that needs no credential. That is exactly why the public route exists; the user-identity exclusion above is unchanged, because there is no public user-avatar route to point at.

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


---

## User settings

Optional per-user namespaced JSON storage (e.g. `browser` → `bookmarks`, or `global` → `theme`) shared across every product the user signs into, through the dual-auth `/settings/me` endpoints — integration guide: [Docs/Auth/user-settings.md](https://github.com/UnlikeOtherAI/UnlikeOtherAuthenticator/blob/main/Docs/Auth/user-settings.md).
Native public clients can use PKCE at /oauth/authorize and /oauth/token, without a product backend or shared secret. Request profile, settings.read and settings.write scopes at registration and authorization. GET /oauth/me returns the current profile; GET /oauth/me/avatar resolves its avatar. GET /oauth/me/settings/:namespace/:key returns {value} and an ETag (null for an absent key). PUT the same path with {value} and If-Match. Retry 409 by rereading and reapplying the intended change, never by blindly overwriting. The subject comes exclusively from the token. Plain registrations get no refresh token and repeat hosted sign-in on expiry; registrations with an enabled native-app app_id also receive a rotating refresh token (see Native app sign-in). See Docs/Auth/native-accounts.md.


## Native app sign-in (public clients; no embedded secret)

Operators register a reverse-domain identifier in Admin → Apps. Store the app name,
PNG/JPEG/WebP icon (256 KB maximum), colors, exact return URLs, account scope allowlist,
Google/password methods, registration policy and enabled status there. These are
independent of billing credentials and feature-flag apps. Only admin superusers may
manage /internal/admin/native-apps. No hard-delete endpoint exists.

Send app_id with POST /oauth/register plus redirect_uris, scope and
token_endpoint_auth_method=none. app_id is public metadata, not binary identity or
permission to bypass authentication. The server checks its stored policy and issues
a public client_id. Numeric loopback HTTP ports may vary at registration; subsequent
steps bind the exact registered URI. Use fresh state and mandatory S256 PKCE with
/oauth/authorize; exchange its one-use code at /oauth/token. Never put session tokens
in return URLs. Native account endpoints and settings scopes are described below.

Registered native-app clients also receive an opaque refresh_token and
refresh_token_expires_in from that exchange. Keep only the refresh token and client_id in the
platform's encrypted secret store (never the access token or profile), persist every rotated
refresh token before using its access token, and renew with POST /oauth/token
{grant_type: "refresh_token", refresh_token, client_id}. Refreshing within 60 s of access-token
expiry, or once after a 401, is enough. A 400/401 means the session ended (reuse, sign-out
anywhere, password or second-factor change, app disabled or edited): clear it and sign in again.
Network errors, 429 and 5xx are transient: keep the stored token. On sign-out call
POST /oauth/revoke {token, client_id} (always 200); it ends the user's other native sessions too.

Google sign-in uses /oauth/social/google with the same validated public OAuth
parameters and the existing provider callback. Enabled registration allows verified
Google users to create accounts; native email registration/recovery is not advertised.
Current epoch, bans, second-factor and signature requirements still apply. The one-use public
Google completion is isolated from the signed-config website flow. App security edits
invalidate existing registrations and UOA account tokens; name/color/icon edits do
not. Offline resource servers enforce their own revocation and token expiry.

Kelpie account connectivity requires dedicated kelpie.connect consent, granted only
to the exact enabled registered com.unlikeotherai.kelpie app. POST /oauth/kelpie/connect
with the native UOA account bearer and {resource: "https://api.nessie.works/api/kelpie/connect"}
returns {connection_ticket,token_type:"Bearer",expires_in,gateway_url}. The resource
must exactly equal operator KELPIE_CONNECT_RESOURCE; absent disables this feature.
The RS256 at+jwt ticket is at most120 seconds and capped by source account expiry.
It carries iss/sub/aud/iat/exp/jti/client_id/app_id/tv/twofa, scope=kelpie.connect
and token_use=kelpie_connect; no email, name, avatar, organisation or team.
Send only that ticket in the gateway's first WSS frame. Renew after60seconds or
before expiry through the same endpoint, refreshing the native account token through
the existing grant as needed. A403 requires fresh consent; a401 permits one account
refresh then sign-in if still refused. Never widen an existing refresh grant.
App scope edits invalidate old client revisions: re-register and sign in once.
404 means operator configuration/profile is unavailable;429/network/5xx retry with
backoff while retaining native account credentials. All responses are no-store.

The gateway calls POST /oauth/kelpie/connect/validate?domain=api.nessie.works with
its existing domain bearer and {ticket}. UOA re-resolves current native and account
authority and authenticates the exact resource host. Success is
{active:true,iss,sub,aud,client_id,app_id,exp,jti}; invalid authority is a generic401.
Validate before dispatch and at most every30seconds, close on failure or ticket
expiry, and accept only renewed tickets for the same issuer/subject/client.
UOA remains the identity authority; Nessie resolves its existing team entitlements
separately and stores only stable references and product-specific device data.
App IDs are public registration metadata, never binary attestation.

Same-account LAN pairing uses POST /oauth/kelpie/pair with the requesting application's
existing Kelpie UOA account bearer and {challenge,clientId}. challenge is the receiving
Kelpie's fresh 43-character base64url nonce; clientId names the exact LAN caller.
Existing kelpie.connect consent is required. The returned {pairing_ticket,token_type,
expires_in} is profile-free, at most120seconds, capped by source expiry and carries
token_use=kelpie_pair, exact challenge/client binding and a challenge-bound urn audience.
Only that proof travels to the LAN; never forward a UOA account credential there.
This flow needs no KELPIE_CONNECT_RESOURCE and cannot authenticate a gateway/account.

The receiving registered Kelpie calls POST /oauth/kelpie/pair/validate using its own
UOA profile-scope account bearer and {ticket,challenge,clientId}. UOA checks both live
account authorities and exact subject equality, returning {active:true,expires_at}
(epoch milliseconds capped by both accounts) or200 {active:false}. Invalid requester
proof never signs the receiver out:401 is reserved for its own invalid account.
No identity/profile is returned. Accept only active proof, without an Allow dialog,
keep resulting local access in memory until expiry/sign-out/revoke, and revalidate
before every HTTP/MCP call. Account switch and failed validation deny access.
Calls without account authorization retain ordinary on-device pairing. Never silently
downgrade failed account proof to approval. All responses are no-store.

## SMS policy operators
Current platform SUPERUSER operators open Admin Billing > SMS policies.
GET /internal/admin/billing/sms-policies returns bounded private acceptance history.
POST /internal/admin/billing/sms-policies/fx/preview with {} fetches the fixed ECB
daily XML; {xml} imports that source document. Review its date, digest, exact
USD-per-EUR decimal and seven-day source-date expiry. POST /fx/accept under that
base with {preview_token,policy_understood:true,acceptance_reason} explicitly
adopts the informational reference rate as commercial policy.
POST /routes/preview under the same base imports exact account_sid, country,
direction, currency, additional_per_segment, additional_per_message, source,
evidence and expires_at. Review the server-computed complete-import digest;
/routes/accept also requires complete_segment_bound:true and
complete_message_bound:true. Zero bounds need documented support: missing
Pricing API fee evidence never means zero. Preserve original source documents
in the operator archive. Signed reviews last five minutes and bind operator and
credential epoch. Acceptance rechecks live ACTIVE platform authority under locks,
records subject/time/reason immutably and refuses stale evidence. Responses are
private/no-store. These controls neither enable billing nor provision resources.
Each record names accepted_by: operator (with accepted_by_user_id) or
renewal_job (no subject). With BILLING_SMS_POLICY_AUTORENEW_ENABLED a daily job
accepts a newer ECB rate within BILLING_SMS_POLICY_AUTORENEW_FX_BAND_BPS of the
last accepted one, and re-attests routes expiring within
BILLING_SMS_POLICY_AUTORENEW_LOOKAHEAD_DAYS whose account Pricing API price is
not above the provider_price recorded at operator acceptance (renewal_of_id
names that acceptance). It only appends records. Anything else opens one alert,
listed under alerts in GET, and emails platform superusers once. Rows without provider_price need a person.
POST /fx/refresh-accept with {policy_understood:true} is the one-button ECB
acceptance: it fetches the fixed XML and accepts it under the operator with a
generated reason only within the same band of the last accepted rate
(409 BILLING_SMS_FX_BASELINE_REQUIRED or BILLING_SMS_FX_OUTSIDE_BAND otherwise;
use the reviewed preview/accept flow for those).
POST /routes/country/preview with {country} is Add country: it reads the
configured account's outbound and inbound mobile Pricing API prices (the SID is
never supplied), applies rule TWILIO_LONG_CODE_2X_V1 (per segment 2x the highest
published long-code carrier fee, per message 2x the USD 0.001 failed-message
fee, 30 days) and returns both generated, hashed evidence documents. POST
/routes/country/accept with {preview_token,policy_understood,
complete_segment_bound,complete_message_bound} appends both policies under the
operator with a generated reason, each recording provider_price so it can
renew. A non-USD account price answers 409 BILLING_SMS_ROUTE_RULE_UNSUPPORTED;
enter such routes through /routes/preview instead.
GET /monthly-prices under the same base lists the flat monthly mobile-number
price of each currency (seeded EUR 4.99, GBP 4.49, USD 5.49); POST with
{amount:"4.99",currency:"EUR"} appends a new current price for one currency.
Every monthly quote uses its organisation region's currency in every country; a
country whose Twilio rental reaches it at the accepted ECB rates answers 409
BILLING_SMS_MONTHLY_PRICE_BELOW_COST and raises a monthly_price_below_cost alert.
Selected product > App keys > SMS runtime issues purpose sms_runtime independently
of customer lifecycle, entitlement and Ledger keys, with no redirect origins.
Transfer one-time plaintext only through approved deployment secrets.
The same SMS policies page exposes Number payment recovery. GET /recovery under
the policy base pages ending/recovery_required/refund_required resources; exact
/recovery/:resourceId shows the frozen final customer quote and original Stripe
account/mode/subscription/initial-invoice references. It cannot issue or verify a
refund. Existing manual contract-invoice adjustments do not settle SMS add-ons.
Use the authorized Stripe operator workflow and retain the unresolved resource
until verified refund reconciliation completes; no automatic zero-charge,
replacement resource or claimed refund completion follows from this read.
POST /recovery/:resourceId/verify-refund with exact subscription_id, distinct
existing refund_ids, reason and verify_existing_refunds:true reads the configured
Stripe account and mode, proves canceled original subscription, original paid
initial invoice and actual cash payments, full succeeded refunds and source-bound
balance movements. After locked operator/resource/binding rechecks it records an
immutable digest/time, audits the decision and changes refund_required to ended.
It never creates refunds or changes Stripe subscriptions. Incomplete, pending,
foreign or conflicting evidence remains unresolved; exact retry is idempotent.
GET /liabilities?kind=inbound|outbound pages original allocation/team/account/
message/dispatch bindings and final customer held/consumed/uncollected credits.
Unknown amounts remain null. This review makes no financial write and grants no
resend/release authority; recover original receipts through the product runtime.

Temporary source-JWKS DNS EAI_AGAIN returns 503 INTEGRATION_JWKS_UNAVAILABLE before identity admission. Retain the product session and retry with backoff; unsafe destinations and invalid assertions remain refused.
