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

# Sintropix API Authentication: Sessions, API Keys, and Roles

> How the Sintropix ERP authenticates callers: Better Auth sessions in the browser, API keys in the x-api-key header, roles, tenant scoping, and read-only keys.

Sintropix authenticates callers with [Better Auth](https://better-auth.com) (ADR 0015), self-hosted against the same Postgres that stores the ledger. A browser signs in with email and password and receives an httpOnly session cookie; an integration or agent presents a long-lived API key in the `x-api-key` header (ADR 0026). The two credentials share one authorization model: a global Role plus per-Entity Membership.

## Roles

Every User has exactly one Role, stored on the User record:

* **`staff`**: Sintropix employees. Full read and write access to every Entity, including Entities created later, with no per-Entity grant.
* **`client`**: everyone else. Read and write access to exactly the Entities they hold an Entity Membership for.

Role gates *which* Entities a User can touch, not *what* they can do inside one. There is no per-operation permission system.

A small set of routes require `staff`:

* `POST /api/entities` and `PATCH /api/entities/:entityId/parent` and `PATCH /api/entities/:entityId/accounting-policies`
* Every route under [`/api/onboarding/*`](/api-reference/onboarding)
* `POST /api/fx-rates/sync`
* Every route under `/api/agents/*` and `/api/entities/:entityId/agents/*` (Erwin surfaces)

Client roles that hit a staff-only route receive `403`.

## Tenant Scoping

Any route whose path contains `:entityId` runs through `TenantGuard` (`apps/erp-backend/src/auth/tenant.guard.ts`):

1. If the caller is `staff`, the guard passes.
2. If the caller authenticated with an API key **scoped to one Entity** (see [Erwin Key Leases](#erwin-key-leases-read-only-and-entity-scoped) below), the guard rejects requests to any other Entity with `404`.
3. Otherwise the guard requires an Entity Membership row for the caller's User and the requested `entityId`, and returns `404` when it is absent.

The guard raises `404`, never `403`, on missing membership. A caller cannot use the API to discover whether an Entity id they do not own exists.

## Session Cookies

The Sintropix web client signs in through Better Auth's REST surface at `/api/auth/*` (email and password today; sign-up is disabled — Users are provisioned by staff, see [Onboarding](/api-reference/onboarding)). The response sets an httpOnly, same-site session cookie that carries authentication on subsequent requests.

Two deployment consequences flow from the same-site cookie:

* The SPA and the API must share a registrable domain (for example `app.sintropix.com` and `api.sintropix.com`). Two `*.up.railway.app` subdomains are different sites under the Public Suffix List and Safari drops the cookie.
* Local development on `localhost` across ports is same-site and works out of the box.

Reset flows exist in Better Auth but Sintropix does not send email in v1: staff mint a set-password link and deliver it to the client through their existing channel ([`POST /api/onboarding/users/:userId/set-password-link`](/api-reference/onboarding)).

## API Keys

API keys are stand-ins for their owning User. A key authenticates as that User, with that User's Role and Memberships, with the following differences from a session:

* The credential is a header (`x-api-key`), not a cookie.
* The key is barred from the auth surface. Any request to `/api/auth/*` carrying `x-api-key` is rejected with `403` before Better Auth sees it (`apiKeyAuthSurfaceBar` middleware, ADR 0026). A leaked key therefore cannot mint further keys, change passwords, or manage Users.
* Every mutating request (`POST`, `PUT`, `PATCH`, `DELETE`) must include a Declared Actor (see below).
* Each key is rate-limited to **5,000 requests per hour** (Better Auth `apiKey` plugin, sliding window).

Key management (mint, list, revoke) is self-serve in the Sintropix web app over the session; the API does not expose a mint endpoint to key holders. Any User may mint keys — including staff. A staff key therefore reaches every Entity of every client; treat it as a high-value secret.

<Warning>
  Keys can live forever. Expiry is optional and unbounded by default (ADR 0026). Rotate keys on a schedule and revoke immediately on suspected leak.
</Warning>

### Declared Actor

Every key-authenticated mutation must carry `x-audit-actor`:

```http theme={null}
x-api-key: <secret>
x-audit-actor: my-reconciliation-bot
```

The Declared Actor is recorded verbatim on the [Audit Trail](/api-reference/audit-trail) row that the mutation writes. It is a self-reported software label ("what wrote this row"), not proof of identity — the key's owning User remains the verified Actor. Omitting the header on a mutation returns `400` with the required header name in the message.

Session-authenticated requests do not need `x-audit-actor`; the browser's session already identifies the User.

<Info>
  `GET` and `HEAD` requests never require `x-audit-actor`, because the audit trail captures mutations only.
</Info>

### Read-Only Keys

A key's `permissions` column marks it read-only by carrying `{"erp": ["read"]}` (ADR 0030). The `ReadOnlyKeyGuard` (`apps/erp-backend/src/auth/read-only-key.guard.ts`) rejects any non-`GET`/`HEAD` request authenticated with such a key by throwing an `HttpException` with code `API_KEY_READ_ONLY_ERROR`.

The narrowing is verb-level only: a read-only key still reads everything its owner can read, across every Entity the owner is a member of, unless the key is also Entity-scoped.

<Note>
  Read-only key minting is currently exposed to staff-side flows (chiefly Erwin Key Leases, described next). Sintropix has not published a self-serve read-only key mint UI as of this writing; verify current availability in the settings screen.
</Note>

### Erwin Key Leases (Read-Only and Entity-Scoped)

Erwin, the AI assistant, does not hold a static credential. On every message a user sends, the agent gateway mints a **fresh read-only key for that User**, scoped to one Entity, with a **1-hour** expiry, and pushes it to the flue-agent runtime for the duration of that submission (ADR 0030, amended by ADR 0032 and ADR 0039). Each key row carries `metadata.entityId`, which `TenantGuard` reads to reject requests to any other Entity.

The lease has no refresh protocol: an expired lease simply lapses, and the next user message mints a new one. Because the credential is per-submission and per-User, the [Audit Trail](/api-reference/audit-trail) records `actorUserId` = the human user and `declaredActor` = `erwin:<conversationId>`, tracing every write back to the exact chat transcript.

## Sign-In Example (Better Auth)

The frontend uses Better Auth's client library rather than raw HTTP, but the underlying request is a plain POST:

```bash theme={null}
curl -X POST "https://<your-erp-backend-host>/api/auth/sign-in/email" \
  -H "Content-Type: application/json" \
  --cookie-jar cookies.txt \
  -d '{"email": "you@example.com", "password": "…"}'
```

Subsequent business calls read the cookie jar:

```bash theme={null}
curl "https://<your-erp-backend-host>/api/entities" \
  --cookie cookies.txt
```

<Note>
  This is the raw Better Auth transport. Refer to [Better Auth's REST docs](https://www.better-auth.com/docs) for the full auth surface; Sintropix does not layer its own routes on top.
</Note>

## API Key Example

```bash theme={null}
# Read (no x-audit-actor required)
curl "https://<your-erp-backend-host>/api/entities/$ENTITY_ID/reports/balance-sheet?asOf=2025-12-31" \
  -H "x-api-key: $SINTROPIX_API_KEY"

# Mutate (x-audit-actor required)
curl -X POST "https://<your-erp-backend-host>/api/entities/$ENTITY_ID/partners" \
  -H "x-api-key: $SINTROPIX_API_KEY" \
  -H "x-audit-actor: my-reconciliation-bot" \
  -H "Content-Type: application/json" \
  -d '{"name": "Acme SpA", "rut": "76.123.456-7"}'
```

## Common Failure Modes

| Symptom                                                                | Cause                                                                                 |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `403` on `/api/auth/*`                                                 | You sent `x-api-key` to the auth surface. Use a session cookie.                       |
| `400` with a missing-header message on a `POST`/`PATCH`/`PUT`/`DELETE` | Key-authenticated mutation without `x-audit-actor`.                                   |
| `403` with `API_KEY_READ_ONLY_ERROR`                                   | Read-only key used to mutate. Mint a full-power key or drop the write.                |
| `404` on `/api/entities/:entityId/…`                                   | Caller is not a member of the Entity, or the id does not exist. Staff never see this. |
| `401` on every route                                                   | No valid session cookie or API key.                                                   |
| `429`                                                                  | Key exceeded 5,000 requests in the last hour. Back off.                               |
