> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vinmake.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Auth, actors and org scoping

> The three ways to authenticate, why the acting identity is derived rather than sent, how the org is resolved, and the error contract.

# Auth, actors and org scoping

Two different questions have two different answers here, and keeping them apart is the point of this page.

| Question                                                           | Answered by                                                                    |
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------ |
| **may this request run at all?**                                   | authentication - a bearer token, a verified session, or a trusted proxy header |
| **who is acting, for the audit trail and the maker-checker rule?** | the **derived actor** - never a header the caller chose                        |

## Authenticating

### The backend GL API

Three ways, checked in order.
If none matches, the request is rejected - locked by default.

| # | Credential                        | Header                                 | Notes                                                                                                                                   |
| - | --------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | the static service token          | `Authorization: Bearer <WS_API_TOKEN>` | compared with a constant-time comparison                                                                                                |
| 2 | a Supabase JWT                    | `Authorization: Bearer <jwt>`          | HS256, verified against the project's JWT secret                                                                                        |
| 3 | a trusted internal proxy identity | `X-WorthState-User-Id`                 | accepted **only** when `TRUST_PROXY_IDENTITY=true`; safe because the backend is internal-only and the proxy validated the session first |

<Note>
  The `X-WorthState-*` strings are legacy wire identifiers still required by the shipped backend, not the product name or a public URL.
  The docs retain the exact literals so an integration does not send an invented replacement; retiring them requires a compatible backend protocol migration.
</Note>

### The DevCenter API

One middleware gates every route.
The service-token check goes through the *same* helper the actor derivation uses, so "is this the service token?" has one answer in both places.

| Path                                           | Auth                                                                                               |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `/ok`, `/`, `/openapi.json`, `/docs`, `/redoc` | open                                                                                               |
| `/connectors/qbo/*`                            | open redirect boundaries; deliberately inert because no OAuth client or trusted binding is created |
| everything else                                | the service token, or a trusted-proxy `X-WorthState-User-Id`                                       |

A failure is `401` with `WWW-Authenticate: Bearer`.

<Note>
  CORS is an explicit allowlist, never `*`, because these endpoints serve ledger data.
  The UI's resolved origin is passed in as configuration - hardcoding a port once blinded every data page whenever the UI was served elsewhere: the request still returned `200`, but the browser discarded the response and the page rendered empty.
  On a finance UI an empty page reads as "no data" rather than "blocked".
</Note>

## The actor is derived, not sent

`X-Actor` used to be a browser-supplied header that nothing on the server re-derived, so the approver simply chose it.
The submitter of a document could send `X-Actor: <a colleague>` and approve their own document: maker-checker passed, and the audit trail named someone who never touched it.

> An audit trail the subject of the audit writes is not an audit trail.

The rule now: **the actor comes from the identity the request was authenticated with, and `X-Actor` is ignored whenever such an identity exists.**

| Caller                                              | Actor                                            | `X-Actor`                                                                   |
| --------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| a logged-in human through the UI proxy              | the session identity the proxy validated and set | **ignored**, and a mismatch is logged as an attempted spoof                 |
| a server-to-server caller holding the service token | the human named in `X-Actor`                     | **honoured** - no browser is in the loop, and the bearer is a server secret |
| anything else                                       | none                                             | `401` - an unattributable money decision is rejected, not defaulted         |

A service caller must still name someone.
There is no default.

```bash theme={null}
# a cron sweeping the launch outbox, acting for a named operator
curl -X POST "https://<devcenter-host>/intake/launches/sweep?limit=50" \
  -H "Authorization: Bearer $WS_API_TOKEN" \
  -H "X-Actor: ops@example.com"
```

<Warning>
  This is why the acting identity is not a convenience field.
  The derived actor becomes `approved_by`, `reviewed_by`, the ledger's `created_by`, and the value the `maker != checker` comparison is made against.
  A client-chosen actor would defeat the posting gate, not just the intake one.
</Warning>

### Two forms of the same person

Some tables key a person by a **uuid** rather than by the actor string, because that is what the row-level-security policies on those tables compare against - the Typewriter document's creator and an approval step's approver, for instance.

Both facts are derived from the same verified session rather than one being looked up from the other later.
A service-token caller has no browser session and therefore no uuid: it gets `None`, and a route that needs one **refuses** rather than inventing it.

## Org scoping

| Source                | When                                                                 |
| --------------------- | -------------------------------------------------------------------- |
| `X-WorthState-Org-Id` | whenever the proxy forwards it, and for multi-org or testing callers |
| `CLERK_ORG_ID`        | the single-org deploy default                                        |
| neither               | `503` - no org context, rather than a silent read of the wrong book  |

Every read is scoped to that org by **row-level security**, so a cross-org caller sees zero rows.
That is a property of the database, not a `WHERE` clause a future refactor can drop.

<Note>
  The DevCenter asserts at boot that `CLERK_ORG_ID` names exactly one existing org, and warns loudly if it is missing or ambiguous - so an empty review inbox is diagnosable from the logs rather than being mistaken for "no documents".
  The assertion is best-effort and never breaks app import.
</Note>

## The error contract

The backend maps its typed ledger errors to **RFC 9457 problem details**, served as `application/problem+json`, so a client branches on a machine code rather than parsing prose.

```json theme={null}
{
  "title": "Entry does not balance",
  "status": 422,
  "detail": "unbalanced: debit_base 1100000.0000 != credit_base 1000000.0000 (tolerance 0.01)",
  "error_type": "gl-unbalanced"
}
```

| `error_type`                     | Status | Meaning                                                                     |
| -------------------------------- | ------ | --------------------------------------------------------------------------- |
| `gl-unbalanced`                  | 422    | debits and credits do not agree in the base currency                        |
| `gl-too-few-lines`               | 422    | fewer than two lines                                                        |
| `gl-line-not-one-sided`          | 422    | a line carries both a debit and a credit                                    |
| `gl-negative-amount`             | 422    | a negative amount, or a `float`                                             |
| `gl-currency-unresolved`         | 422    | a currency that cannot be resolved, or a non-positive rate                  |
| `fx-rate-unavailable`            | 422    | no rate on or before the entry date - **quarantined, never posted at par**  |
| `account-invalid`                | 422    | the account does not exist, is inactive, or belongs to another org          |
| `dimension-required`             | 422    | a required Tier-1 dimension is missing                                      |
| `period-closed`                  | 422    | the entry date is in a closed period, or in none                            |
| `gl-unmapped-account`            | 422    | no posting rule maps this transaction - **not** silently booked to suspense |
| `gl-checksum`                    | 422    | the hash chain does not extend from the expected tail                       |
| `already-posted`                 | 409    | this source key conflicts with an existing entry's immutable request facts  |
| `subledger-party-required`       | 422    | an AP/AR obligation with nobody on it                                       |
| `subledger-base-amount-required` | 422    | a foreign obligation with no base-currency amount                           |

The DevCenter's own routes use plain HTTP status codes with a `detail` string, and the mapping is deliberate:

| Status        | Cause                                                                                        |
| ------------- | -------------------------------------------------------------------------------------------- |
| `400`         | a malformed body, or a service caller with no `X-Actor`                                      |
| `401`         | no verified identity                                                                         |
| `403` / `404` | not visible to this org, or not found                                                        |
| `409`         | an illegal state transition, a separation-of-duties breach, or a posting a guardrail refused |
| `503`         | the database or the posting runtime is not reachable or not configured                       |

<Warning>
  The Typewriter routes apply one asymmetry on purpose: an error the module raised itself carries a message written for the person reading it, and **anything else is a 500 with no detail**.
  A database driver error names columns and can quote values, and these responses reach a browser.
</Warning>

## Where it is enforced

| Concern                                                  | Code                                                       |
| -------------------------------------------------------- | ---------------------------------------------------------- |
| the DevCenter's actor derivation and service-token check | `langgraph_chat/devcenter/identity.py`                     |
| the DevCenter's route middleware and CORS allowlist      | `langgraph_chat/devcenter/app.py`                          |
| the backend's three authentication paths                 | `backend/app/core/auth.py`                                 |
| the problem-detail shape and the typed errors it maps    | `backend/app/core/problem_detail.py`, `errors.py`          |
| org scoping and RLS                                      | [Tenancy and RLS](/greatbook/capabilities/tenancy-and-rls) |

## The test that would fail if it broke

`langgraph_chat/devcenter/tests/test_auth.py` drives the derivation table directly, including that a browser-sent `X-Actor` is ignored when a session identity exists and that a service caller with no `X-Actor` is refused.

## Related

* [Maker-checker and separation of duties](/greatbook/capabilities/maker-checker) - what the derived actor is compared against
* [Tenancy and RLS](/greatbook/capabilities/tenancy-and-rls) - how org scoping is enforced beneath the API
* [The security boundary](/greatbook/proof/security) - the attacks this design was hardened against
* [Intake and the posting gate](/greatbook/api/intake) - the routes where the actor matters most
