# Control API

> The authenticated /v1 management API over the durable Postgres executor.

Source: https://scribase.com/docs/control-api

# Production control API

`scribase-control-api serve` runs the authenticated HTTP/1.1 management API over
the durable PostgreSQL executor. It defaults to `127.0.0.1:8080`; terminate TLS
at a trusted ingress or same-host sidecar. A non-loopback bind is rejected
unless `SCRIBASE_TRUST_PROXY_TLS=1` explicitly acknowledges that deployment
boundary.

Required configuration:

| Variable | Purpose |
| --- | --- |
| `SCRIBASE_API_DATABASE_URL` | TLS URL using the least-privilege `scribase_api` role |
| `SCRIBASE_AUTH_MODE` | `oidc` (default; the five values below are then required) or `builtin` (the control plane's own password sign-in, no IdP; see DEPLOY.md "Operator sign-in") |
| `SCRIBASE_OIDC_INTROSPECTION_ENDPOINT` | HTTPS RFC 7662 token-introspection endpoint |
| `SCRIBASE_OIDC_ISSUER` | Exact trusted `iss` claim, including any trailing slash |
| `SCRIBASE_OIDC_AUDIENCE` | Required API audience in the `aud` claim |
| `SCRIBASE_OIDC_CLIENT_ID` | Confidential introspection client identifier |
| `SCRIBASE_OIDC_CLIENT_SECRET` | Confidential introspection client secret |

The database URL must contain `sslmode=require`. PostgreSQL 16+ `psql`, `curl`,
and `jq` must be installed. The introspection request and its Basic client
credential are streamed to `curl` over stdin and never appear in arguments,
debug output, or error messages. A token is accepted only when introspection
reports it active with the exact configured issuer and audience, a future
numeric expiry, an optional valid not-before time, and a bounded subject.
Organization roles are then loaded from current forced-RLS membership state;
token metadata is not used for authorization.
The API process is never given the `scribase_operator` or schema-migrator database
credential.

Optional configuration:

| Variable | Default |
| --- | --- |
| `SCRIBASE_HTTP_BIND` | `127.0.0.1:8080` |
| `SCRIBASE_TRUST_PROXY_TLS` | unset; required as `1` for non-loopback binding |
| `SCRIBASE_MAX_ENVIRONMENTS` | `10` for newly bootstrapped organizations |
| `SCRIBASE_DATABASE_MAX_CONNECTIONS` | `10` |
| `SCRIBASE_HTTP_READ_TIMEOUT_SECONDS` | `15` |
| `SCRIBASE_HTTP_WRITE_TIMEOUT_SECONDS` | `15` |
| `SCRIBASE_HTTP_MAXIMUM_HEADER_BYTES` | `32768` |
| `SCRIBASE_HTTP_MAXIMUM_BODY_BYTES` | `1048576` |
| `SCRIBASE_HTTP_WORKER_THREADS` | `4` |
| `SCRIBASE_HTTP_MAXIMUM_QUEUED_CONNECTIONS` | `64` |
| `SCRIBASE_HTTP_KEEP_ALIVE_SECONDS` | `5` (idle time between requests on one connection; `0` closes after every response) |
| `SCRIBASE_HTTP_MAXIMUM_REQUESTS_PER_CONNECTION` | `1000` |
| `SCRIBASE_PSQL_BINARY` | `psql` |
| `SCRIBASE_DB_DRIVER` | `pool` (default when unset or empty): adapter database sessions and the per-request membership check use an in-process driver pool (ADR 0001, SX-D1), so SQL goes over the wire protocol and no `psql` process starts. `psql` is the rollback: one `psql` process per session and the core executor for membership. URLs with client certificates or a service file keep using `psql`. An unknown value keeps the pool and is logged at start. |
| `SCRIBASE_DB_POOL_MAX_IDLE_PER_DATABASE` | `8` (idle pooled connections kept per database URL, at most 64) |
| `SCRIBASE_DB_POOL_MAX_IDLE` | `64` (idle pooled connections kept in total, at most 1024) |
| `SCRIBASE_CURL_BINARY` | `curl` |
| `SCRIBASE_JQ_BINARY` | `jq` |

The adapter rejects transfer encoding, duplicate headers, request pipelining,
unknown JSON fields, oversized input, percent-encoded management paths, and
unsupported content types. Responses disable caching and MIME sniffing. The
operation lookup endpoint requires `X-Scribase-Organization-ID` so membership is
resolved before accessing an operation identifier.

## Scribase Mail (transactional email)

Sent directly over SMTP (OCI Email Delivery) when `SCRIBASE_MAIL_SMTP_HOST` is
set (`_PORT`, `_USER`, `_PASS`; `SCRIBASE_MAIL_APPROVED_SENDERS`,
`SCRIBASE_MAIL_PRODUCT_DOMAINS` and `SCRIBASE_MAIL_INTERNAL_DAILY_CAP` have
defaults). Tables: migration 0100.

| Route | Contract |
|---|---|
| `POST /emails`, `GET /emails/{id}` | Resend: `from`, `to`, `cc`, `bcc`, `reply_to`, `subject`, `html`, `text`, `tags`, `headers`; `Idempotency-Key` header; `200 {"id"}`; errors `{"statusCode","name","message"}` |
| `POST /api/rally/v1/transactional/send` | Rally Send: `{product, project, to, from_local, from_name, subject, html, text, idempotency_key, unsubscribe_url, tags}`; From is `<from_local>@<product mail domain>`; `202 {"id"}`, `401`, `403` (other product), `409`, `422` (suppressed or unapproved sender), `429` (daily cap), `503` |

Both take `Authorization: Bearer smail_...`. A key is scoped to sender
addresses or domains, and the From address must also be an approved sender.
Each send checks suppressions, claims its idempotency key (a retry never sends
twice; a failed send is retried under the same id), and takes the per-key,
per-project daily cap. App email (`.../email/send`) uses the same path, with
Rally Send kept only as the fallback when SMTP is not set.

Keys (printed once, as env lines; only the SHA-256 is stored):

```bash
docker exec scribase-control-api-1 scribase-control-api mail-key mint \
  --product menivor --senders mail.menivor.com [--label web] [--daily-cap 2000]
docker exec scribase-control-api-1 scribase-control-api mail-key list
docker exec scribase-control-api-1 scribase-control-api mail-key revoke mk_...
```