# Admin API

> The dashboard's own JSON REST API — auth, SSO, projects, search, sources, members, MCP tokens, webhooks, metrics, the audit log and the error shapes every endpoint shares.

- Updated: 2026-09-25
- Source: https://contextator.com/en/docs/admin-api/
- Language: en-US
- Author: Muhammet Şafak

---
Everything the dashboard can do is a JSON REST call — the dashboard is just a client of this API. Use it
for scripted setup, scheduled re-indexing, or monitoring.

**Base URL** `http://localhost:3444/api`

## Authentication

Every request carries one of three credentials:

| | What it is | Who uses it |
|---|---|---|
| **A session cookie** | `contextator_session`, set by `POST /api/auth/login` | The dashboard, and anything driving a browser |
| **`Authorization: Bearer ctxk_…`** | An account's own **API token** — named, scoped to a subset of routes and, optionally, one project, revocable on its own | Scripts and CI jobs that should carry only *some* of an account's authority — see [API tokens](#api-tokens) below |
| **`Authorization: Bearer $ADMIN_TOKEN`** | Machine access with `root` permissions | Scripts, CI, cron that need the whole instance |

```bash
export API=http://localhost:3444/api
export TOKEN=your-admin-token
curl -s $API/projects -H "Authorization: Bearer $TOKEN" | jq
```

Five things follow from that split:

- **What you may do depends on who you are.** A session is bound to an account, so its instance role
  and its per-project memberships decide every call — see
  [Accounts and Permissions](/en/docs/accounts-and-permissions/). `ADMIN_TOKEN` always acts as `root`.
- **An API token can never do more than the account that minted it.** Its scope is checked *after*
  that account's own role and membership, so narrowing it — to one project, or to a handful of
  routes — can only take authority away, never add it.
- **A cookie request that changes something must come from this site.** The server checks
  `Sec-Fetch-Site`, falling back to `Origin` and `Referer`, and answers `403 csrf_blocked` otherwise.
  Bearer requests are exempt: they carry no ambient credential, which is why `curl` and CI — sending
  neither header — work unchanged.
- **Neither bearer token counts as a signed-in account, as far as these routes are concerned.** The
  endpoints that are about *your* account (`/api/auth/password`, `/api/auth/sessions`) require a
  session — that check refuses `ADMIN_TOKEN` and a scoped API token alike with `403 token_has_no_account`,
  even a token scoped to allow the exact route. Only a browser holding a session cookie can reach them.
- **A project you cannot reach answers `404`, not `403`**, the same as a project that does not exist,
  so ids cannot be probed. A route you may reach but not use in that way answers `403`.

`GET /api/health`, `GET /api/setup/status`, `POST /api/setup`, `POST /api/auth/login`,
`GET /api/auth/oidc/login` and `GET /api/auth/oidc/callback` are the only endpoints reachable with no
credential at all — the last two because the browser has no session yet when it asks for the redirect,
and the provider sends the **browser** back to the callback with the code, by redirect — not a
server-to-server call from the provider itself. Linking an already signed-in account to that same
provider (`POST /api/auth/oidc/link`, `DELETE /api/auth/oidc/link` — see
[Single Sign-On](/en/docs/sso/#linking-or-unlinking-your-own-account)) is different: it needs the
caller's own session, same as any other self-service account endpoint.

---

## Health

```bash
curl -s $API/health | jq
```

```json
{
  "ok": true,
  "version": "0.2.0",
  "uptimeSec": 3610,
  "db": "up",
  "embeddings": {
    "id": "local:Xenova/multilingual-e5-small:fp32:\"query: \"+\"passage: \"",
    "provider": "local", "model": "Xenova/multilingual-e5-small",
    "dimensions": 384, "dtype": "fp32", "ready": true
  },
  "sessions": { "total": 2, "streamable": 2, "sse": 0 },
  "allowedDocRoots": ["/docs"],
  "dataDir": "/data",
  "secretKeyConfigured": true,
  "uploads": { "maxFileBytes": 52428800, "maxFilesPerRequest": 500, "maxArchiveBytes": 268435456 }
}
```

Good liveness probe: `ok === true && db === "up"`. Good readiness probe: also `embeddings.ready`. The
response answers `503` with the same body while the database is unreachable, `200` otherwise — a monitor
does not need a second check for that.

`embeddings` also carries the model's input window and whether `CHUNK_MAX_TOKENS` fits inside it, so a
misconfiguration that would silently truncate chunks shows up here before it shows up in search quality —
see [Configuration](/en/docs/configuration/#applying-changes).

An anonymous caller gets a shorter answer — `ok`, `version`, `db`, `authRequired` and `needsSetup` —
because the rest describes the machine. The fields a monitor watches are in both shapes.

## Setup and sign-in

| Method & path | Description |
|---------------|-------------|
| `GET /api/setup/status` | `{ needsSetup }` — `true` while this instance has no account. Public |
| `POST /api/setup` | `{ code, username, displayName?, email?, password }` → `201 { user }`, and the session cookie. Creates the first `root` account. `403 invalid_setup_code`, `409` once an account exists. Public |
| `POST /api/auth/login` | `{ username, password }` → `{ user, mustChangePassword }` and the cookie. `401 invalid_credentials` for both a wrong password and an unknown username, `403 account_disabled`, `429` with `Retry-After` when rate-limited. Public |
| `POST /api/auth/logout` | Revokes this session and clears the cookie → `204`. Idempotent |
| `GET /api/auth/me` | The signed-in account, its role, `authKind` (`session`\|`token`\|`apiToken` — a dashboard session, `ADMIN_TOKEN`, or an API token), and — for a `member` — its per-project roles in `projects`. Also `oidc` (`{ enabled, buttonLabel }`), `canLinkOidc` (`false` for root) and `federatedProviders` — see [Single sign-on](#single-sign-on) below |
| `POST /api/auth/password` | `{ currentPassword, newPassword }` → `204`. Clears `mustChangePassword` and revokes this account's **other** sessions |
| `GET /api/auth/sessions` | Your own live sessions: when each started, when it was last seen, its user agent and IP, and which one is `current` |
| `DELETE /api/auth/sessions?scope=others\|all` | End them → `204`. `others` is the default; `all` signs you out too |

```bash
# sign in and keep the cookie in a jar, then use it like a browser would
curl -s -c jar -X POST $API/auth/login -H 'content-type: application/json' \
  -d '{"username":"alice","password":"…"}' | jq
curl -s -b jar $API/auth/me | jq
```

## Single sign-on

Only present when this instance has an OIDC provider configured — see [Single Sign-On](/en/docs/sso/)
for who can link and why root cannot.

| Method & path | Description |
|---------------|-------------|
| `GET /api/auth/oidc/login` | Redirects to the identity provider to sign in. Public — the browser has no session yet |
| `GET /api/auth/oidc/callback` | The identity provider sends the **browser** back here with the code, by redirect — not a server-to-server call from the provider. Public because a sign-in arrives with no session yet; a link arrives with the session that started it, carried by the browser's own cookie on the redirect. Refuses to sign a `root` account in (`root_local_only`), even one whose link was made before a promotion to `root` — the role is re-read off the account at callback time, not assumed from when the link was made |
| `POST /api/auth/oidc/link` | Self-service, for an already signed-in account: starts the same redirect, but to *connect* the provider to this account rather than sign in fresh. A `POST`, so the same-site check applies. → `200 { url }` to navigate to. Refused for root (`403 root_local_only`) — root never signs in over SSO |
| `DELETE /api/auth/oidc/link` | Removes the caller's own linked identity → `204`. Scoped to your own account; there is no admin surface over another account's link. Not refused for root — a link made before a promotion to root stays in place until removed, and this is how. Also revokes every session and API token the account holds, including the one making this call, and — in the same transaction — every MCP OAuth access and refresh token the account was issued, so an MCP client signed in as it gets `401` until it authorizes again; other accounts are untouched. The unlink's audit event records the count as `detail.revokedMcpCredentials`. **`409 last_sign_in_method`** — nothing removed, nothing revoked — when the account has no local password and this link is its only way to sign in; an admin sets a password first with `POST /api/users/:id/password`, and the unlink then succeeds |

## Accounts

`/api/users/*` needs `admin` or `root`. Only a `root` account may create, change or delete another
`root` account. `PATCH /api/users/:id` refuses `409 root_requires_unlink` when asked to set
`role: "root"` on an account that still has a linked SSO identity — unlink it first with
`DELETE /api/auth/oidc/link` above.

| Method & path | Description |
|---------------|-------------|
| `GET /api/users` | Every account: role, active flag, `mustChangePassword`, project count, last sign-in, and `activeSessionCount` |
| `POST /api/users` | `{ username, displayName?, email?, role?, password?, mustChangePassword? }` → `201 { user, temporaryPassword }`. `role` defaults to `member` and `mustChangePassword` to `true`; omit `password` and one is generated and returned **once** |
| `GET /api/users/:id` | `{ user, activeSessionCount }` |
| `PATCH /api/users/:id` | `{ displayName?, email?, role?, isActive? }` → the updated account. A demotion or a disable also ends that account's sessions |
| `POST /api/users/:id/password` | `{ password? }` → `{ temporaryPassword }`. Forces a change at the next sign-in and ends that account's sessions |
| `DELETE /api/users/:id` | Delete the account, its sessions and its memberships → `204` |
| `DELETE /api/users/:id/sessions` | Sign that account out everywhere → `204` |

`409` guards the last active root account against deletion, demotion and being disabled; `403` guards
an admin reaching for a root one, and anyone reaching for their own role or active flag.

```bash
# hand someone a new temporary password without touching the dashboard
ID=$(curl -s $API/users -H "Authorization: Bearer $TOKEN" | jq -r '.[] | select(.username=="alice") | .id')
curl -s -X POST $API/users/$ID/password -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{}' | jq -r .temporaryPassword
```

## API tokens

`/api/tokens*` needs only a session — any account manages its **own** tokens, and there is no admin
view over anyone else's. This is how a script or a CI job gets a credential narrower than
`ADMIN_TOKEN`: name it, optionally pin it to one project and an expiry date, and list exactly which
routes it may call. The dashboard's own **Account menu → API tokens** page is a client of exactly
these endpoints — mint, list and revoke there without touching `curl` at all.

| Method & path | Description |
|---------------|-------------|
| `GET /api/tokens` | Your own tokens: `id`, `name`, `prefix`, `scope`, `projectId`, `createdAt`, `lastUsedAt`, `revokedAt`, `expiresAt`. Never the secret |
| `POST /api/tokens` | `{ name?, scope, projectId?, expiresAt? }` → `201 { token, secret }`. `scope` is one or more `"<METHOD> <route template>"` strings, such as `"POST /api/projects/:id/reindex"`. Mint only checks that shape and, if given, that `expiresAt` is in the future — it does not check an entry against your own role, nor `projectId` against your own projects. What keeps the token bounded is that every request re-checks your *live* role and project access at call time. `secret` is returned this once and never again. `401 session_revoked` if your session was revoked before the mint could run |
| `DELETE /api/tokens/:tokenId` | Revoke one of your own tokens → `204`. Takes effect on the very next request |

```bash
# mint a token that can only reindex one project — /api/tokens needs a session, not
# ADMIN_TOKEN, so sign in first (see "Setup and sign-in" above) and reuse the cookie jar.
# a cookie-authenticated request that changes something needs Sec-Fetch-Site too — see the
# CSRF rule above — curl never sends it on its own, unlike a real page's fetch would
curl -s -X POST $API/tokens -b jar \
  -H 'content-type: application/json' -H 'Sec-Fetch-Site: same-origin' \
  -d '{"name":"ci-reindex","scope":["POST /api/projects/:id/reindex"],"projectId":"'"$P"'"}' | jq
```

Deleting the project named in `projectId` deletes the token with it, rather than widening its reach.

## Project members

Who reaches one project. `root`, `admin` and `ADMIN_TOKEN` reach every project without a membership,
so only `member` accounts appear here.

| Method & path | Description |
|---------------|-------------|
| `GET /api/projects/:id/members` | The project's members and their roles. Any member of the project may read it |
| `PUT /api/projects/:id/members/:userId` | `{ "role": "viewer" \| "editor" }` → the member. Creates or changes the membership (root/admin) |
| `DELETE /api/projects/:id/members/:userId` | Revoke access → `204` (root/admin) |

Adding a member to a project a `member` account can then see:

```bash
curl -s -X PUT $API/projects/$P/members/$ID -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"role":"editor"}' | jq
```

`400` if the target is an `admin` or `root` account — they already have access, so a membership row
would say something untrue.

## Projects

| Method & path | Description |
|---------------|-------------|
| `GET /api/projects` | All projects with counts, `mcpUrl` and the live indexing `job` |
| `POST /api/projects` | `{ "name": "demo", "rootPath": "/docs/demo", "index": true }` → `201`. `rootPath` is optional and creates a first local source |
| `GET /api/projects/:id/status` | One project plus its job |
| `POST /api/projects/:id/reindex?force=true` | Queue a run → `202 { job }` |
| `GET /api/projects/:id/runs` | Recent index runs, newest first |
| `GET /api/projects/:id/search?q=…&limit=…&source=…&path_prefix=…&version=…` | The same search the project's `search_docs` tool runs, as JSON: `{ query, limit, source, pathPrefix, version, belowFloor, scoreFloor, hits: [{ score, fusedScore, denseRank, lexicalRank, path, title, headingPath, chunkIndex, content, contextBefore, contextAfter }] }`. `score` is the cosine similarity and is shown rather than ranked on; `fusedScore` is what ordered the list, and the two ranks say which half of search found the excerpt (`null` for the half that did not). `belowFloor` is whether an agent would have been told *no good match* — the hits come back either way, so the dashboard can show what was withheld. `limit` is 1–20 (default 5); `source`, `path_prefix` and `version` are optional. `400 invalid_request` for a source or a version this project does not have (the message names the ones it does), `409 not_indexed` when the project has no chunks, `409 model_mismatch` when they were embedded with another model |
| `DELETE /api/projects/:id` | Delete the project → `204` (`409` while indexing) |

```bash
# create and index
curl -s -X POST $API/projects -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"name":"handbook","rootPath":"/docs/handbook","index":true}' | jq

# re-index everything, every night
for id in $(curl -s $API/projects -H "Authorization: Bearer $TOKEN" | jq -r '.[].id'); do
  curl -s -X POST "$API/projects/$id/reindex" -H "Authorization: Bearer $TOKEN" > /dev/null
done
```

### Watching a job

`GET /api/projects` includes a `job` for any project with a live run:

```json
{ "phase": "embedding", "filesTotal": 84, "filesDone": 31, "filesSkipped": 22,
  "filesRemoved": 0, "chunksDone": 96,
  "sources": [{ "name": "handbook", "type": "local", "status": "synced" }],
  "queue": null }
```

`phase` is one of `queued`, `syncing`, `scanning`, `embedding`, `finalizing`, `done`, `error`. A queued
job carries `queue.aheadProjectName`.

## MCP access

| Method & path | Description |
|---------------|-------------|
| `GET /api/projects/:id/mcp-tokens` | The project's live tokens: `id`, `name`, `prefix`, `createdAt`, `lastUsedAt`. The secret itself is never returned |
| `POST /api/projects/:id/mcp-tokens` | `{ "name": "claude-code" }` → `201 { token, secret }`. **`secret` is the only time the token is readable** |
| `DELETE /api/projects/:id/mcp-tokens/:tokenId` | Revoke it → `204`, closing the project's open MCP sessions |
| `PATCH /api/projects/:id/mcp-auth` | `{ "mode": "open" \| "token" \| "account" }` → `{ "mcpAuth" }`. Switching away from `open` also closes the project's open sessions. `account` requires the caller to be a member of the project through OAuth 2.1 on `/mcp/*`; a static `ctxm_…` token is refused in that mode |

`GET /api/projects` reports the current mode as `mcpAuth`. Closing an endpoint and minting a token:

```bash
curl -s -X PATCH $API/projects/$P/mcp-auth -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"mode":"token"}' | jq

curl -s -X POST $API/projects/$P/mcp-tokens -H "Authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"name":"claude-code"}' | jq -r .secret
```

The client then sends that secret as `Authorization: Bearer ctxm_…` on `/mcp/<project-name>` — see
[Connecting AI Clients](/en/docs/connecting-ai-clients/).

## Sources

| Method & path | Description |
|---------------|-------------|
| `GET /api/projects/:id/sources` | The project's sources. Secrets are never returned — only `hasSecret` |
| `POST /api/projects/:id/sources` `{ type, name, label?, flavor?, config?, secret?, syncIntervalMinutes?, index? }` | Add a source. `type` is `local`, `git`, `upload`, `notion`, `confluence` or `web`; `config` is type-specific — see the table below. `secret` is taken only by the types that use a credential (`git`, `notion`, `confluence`); on `local`, `upload` or `web` it answers `400 invalid_request` naming the type, and `secret: null` (meaning "no secret") is accepted on every type. `syncIntervalMinutes` is 5–43200 or `null`; omitted takes the instance default |
| `PATCH /api/projects/:id/sources/:sid` | Change label, content type, config or token (`"secret": null` removes it). Type and name are immutable |
| `DELETE /api/projects/:id/sources/:sid` | Remove it, its documents and its files (`409` while indexing) |
| `POST /api/projects/:id/sources/:sid/sync` | Queue a run → `202 { job }` |
| `POST /api/projects/:id/sources/:sid/test` | Connectivity check → `{ ok, message }` |
| `POST /api/projects/:id/sources/:sid/webhook-secret` | Generate a new webhook secret: rotates it on a git source, and on a Confluence source turns the webhook on (or regenerates it) — see [Push Webhooks](/en/docs/push-webhooks/#confluence-data-center) |
| `DELETE /api/projects/:id/sources/:sid/webhook-secret` | Confluence only: turn the webhook off. Deliveries are then refused with `not_enabled` again |

Adding a git source:

```bash
curl -s -X POST $API/projects/$PROJECT_ID/sources \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{
        "type": "git",
        "name": "api-docs",
        "label": "API reference",
        "config": { "url": "https://github.com/org/repo.git", "branch": "main", "subdir": "docs" },
        "secret": "ghp_…",
        "index": true
      }' | jq
```

`config` by type:

| Type | Keys |
|------|------|
| `local` | `path`, `extensions` |
| `git` | `url`, `branch`, `subdir`, `username`, `provider`, `extensions` |
| `upload` | `extensions` |
| `notion` | `rootIds`, `extensions` |
| `confluence` | `deployment` (`cloud` \| `datacenter`), `siteUrl`, `accountEmail` (Cloud only), `spaces` — see [Confluence](/en/docs/confluence/) |
| `web` | `entryUrl`, `entryKind` (`auto` \| `sitemap` \| `llms` \| `crawl`, default `auto`), `extensions` — public pages only, no credential |

## Uploads

| Method & path | Description |
|---------------|-------------|
| `POST /api/projects/:id/sources/:sid/uploads` | Open a session → `{ session }` |
| `POST …/uploads/:session/files` | `multipart/form-data`; each part's `filename` is the path inside the source. Archives are unpacked server-side |
| `POST …/uploads/:session/commit?mode=add\|replace` | Move the staged files in and queue a run → `202` |
| `DELETE …/uploads/:session` | Discard the staged upload |
| `GET /api/projects/:id/sources/:sid/files` | List an upload source's files |
| `DELETE /api/projects/:id/sources/:sid/files?path=…` | Delete one and re-index |

```bash
SESSION=$(curl -s -X POST $API/projects/$P/sources/$S/uploads -H "Authorization: Bearer $TOKEN" | jq -r .session)
curl -s -X POST "$API/projects/$P/sources/$S/uploads/$SESSION/files" \
     -H "Authorization: Bearer $TOKEN" -F 'file=@guide.md;filename=guides/guide.md'
curl -s -X POST "$API/projects/$P/sources/$S/uploads/$SESSION/commit?mode=add" \
     -H "Authorization: Bearer $TOKEN"
```

## Webhooks

| Method & path | Description |
|---------------|-------------|
| `POST /api/webhooks/git/:sourceId` | Push webhook for a git source, authenticated by the per-source secret — **not** by an account or `ADMIN_TOKEN` |
| `POST /api/webhooks/confluence/:sourceId` | Confluence Data Center webhook, signed with `X-Hub-Signature` over the per-source secret. `401 not_enabled` until the source's webhook is turned on |

See [Push Webhooks](/en/docs/push-webhooks/).

## Metrics

```bash
curl -s http://localhost:3444/metrics -H "Authorization: Bearer $METRICS_TOKEN"
```

`GET /metrics` is a separate, Prometheus-shaped endpoint (`text/plain; version=0.0.4`) with its own
credential rules — it is **not** part of `/api/*` and is **not public** by default. It reports the
indexing queue by lane, whether one is running, the last index run and whether it worked, searches by
actor, the database connection pool, and whether the database itself answers.

It accepts a signed-in session, `ADMIN_TOKEN`, a `METRICS_TOKEN` bearer, or no credential at all when
`METRICS_PUBLIC=1` — see [Configuration](/en/docs/configuration/#observability). It answers `200` even
while the database is down, with `contextator_db_up 0` and the rows that need a database left out, so a
scrape gap is never how an outage gets reported. **While the database is down only the three credentials
that need no database read still work** — `ADMIN_TOKEN`, `METRICS_TOKEN`, `METRICS_PUBLIC` — a session
cookie is a database row and cannot be confirmed, so a browser presenting one gets `401` rather than
`500` during an outage. That is the reason to configure `METRICS_TOKEN` before an outage, not during one.

## Audit log

`/api/audit` needs `root` or `admin` — the log is instance-wide, and a project membership is not
standing to read who was given the root role.

| Method & path | Description |
|---------------|-------------|
| `GET /api/audit?actor&actorUser&action&project&from&to&limit&cursor` | The audit log, newest first. Every filter is applied in SQL: `actor` is the label as it was recorded at the time; `actorUser` is an account id matched **exactly** against the acting account — it returns that account's own sessions **and** every API token it owns, whatever the tokens are named; a value that is not a UUID answers `400 validation_failed`. `action` is `<METHOD> <route template>`; `project` is a project id or `none`. `from`/`to` are **UTC days**, and `to` is inclusive of the named day. `limit` is 1–200 (default 50). `cursor` is the previous page's `nextCursor` — a **row id**, never an encoded instant, so two events inside the same millisecond cannot lose one of themselves at a page boundary; a cursor naming no row answers `400`. Answers `{ events, nextCursor, filters, retentionDays }` — `nextCursor` is `null` on the last page, and `filters` (distinct actors, actions, projects, and the accounts with events, for the pickers) comes back on the first page only |

```bash
curl -s "$API/audit?action=DELETE%20/api/projects/:id&limit=50" \
  -H "Authorization: Bearer $TOKEN" | jq
```

### How it is written

Every state-changing request above leaves a row in `audit_events` naming the account that made it — what
was done, to which project, source, token, membership or account, and when. A sign-in and the creation
of the first account are recorded too, and so is `POST /oauth/authorize`, a person granting a connector
lasting read access to one project.

It is written by the **policy layer**, not by each handler, so there is no route that can be added
without being covered — and none that can opt out. The exceptions are **eight** — a source's test and
upload-session routes, the three webhook deliveries (git, Notion, Confluence) and the three OAuth
client-registration routes (`register`, `token`, `revoke`) — each named with its reason in
`src/auth/policy.ts`.

A route that *creates* something names nothing in its own path, so the new object's id is read back out
of the response, through a table of fixed paths in that same file, and kept only when the value found
there is a UUID — "who minted this token" is therefore a question the log answers, and it lines up
against the revocation that names the same id.

### What it does not hold

The rows carry no user content: a question, a document and an excerpt never reach a column of this
table. The only body fields any action may record are named in that same policy file, each restricted to
a closed set of values (`mode` is one of `open`/`token`/`account`, and so on). That keeps it a different
record from the query log, which holds what agents asked, is governed by its own retention, and has its
own per-project switch — the two are deliberately not one table.

Two things beyond that are deliberately not recorded. **Refused requests** — a refusal is the permission
matrix working, and logging every probe would turn the table into a scan log; the one exception is a
person refusing a connector at `/oauth/authorize`, which is somebody deciding rather than the matrix
declining. And **an action that changed something and then answered `5xx`**: the row is written only for
a response under 400, so a handler that commits and then fails afterwards leaves none. No handler in this
API is currently shaped that way, and `SECURITY.md` names it as the limit it is.

### Reading it

`GET /api/audit` and the dashboard's **Audit log** panel (account menu, beside Users) are both root/admin
only. Each row renders as a sentence ("dana deleted a source from handbook") rather than as the columns
it is stored in. "Who" is two pickers: **Actor** is the label as it was recorded, and **Account** is the
account id — which finds that account's own actions together with those of every API token it owns, since
a token's label is only its name and owner and cannot gather them on its own. A project that has since
been deleted still has its rows: `project_id` carries no foreign key, so the panel cannot look its *name*
up, and says so on the row rather than leaving it blank.

The two columns the panel filters on — `actor_label`, because it outlives the account, and `action` — are
indexed by migration `0011`. At 200,004 rows a selective actor filter runs in 0.07 ms against 9.7 ms
without the index, a page turn is 3 ms, and a first page is 19–52 ms because it also fills the filter
dropdowns with three `DISTINCT` scans no index can help — paid once per filter change, never per page
turn.

The log is **not tamper-evident** — anyone with database access can remove a row and nothing here would
show it (`SECURITY.md`) — and rows older than `AUDIT_LOG_RETENTION_DAYS` are swept on the same
quarter-hourly timer as expired sessions.

## Errors

| Status | Meaning |
|--------|---------|
| `400` | Validation failed, or an invalid request (bad path, missing `SECRET_KEY`, a password the policy refuses) — `message` explains |
| `401` | No credential, or a wrong one. `setup_required` means this instance has no account yet |
| `403` | Signed in, but not allowed: `forbidden`, `csrf_blocked`, `password_change_required`, `account_disabled`, `token_has_no_account` |
| `404` | Unknown project, source or session — **and** any project this account has no access to |
| `409` | Duplicate name, the project is indexing, setup is already complete, or the last root account. `DELETE /api/auth/oidc/link` answers `409 last_sign_in_method` instead when the account has no local password — unlinking would leave it no way to sign in, so nothing is removed until an admin sets one |
| `429` | Too many sign-in attempts. `Retry-After` and `retryAfterSec` say how long |
| `500` | Internal error — the response says only that; the details are in the logs |

All errors are JSON: `{ "error": "conflict", "message": "…" }`.

## Notes

- Secrets are never returned by any endpoint; a source only reports `hasSecret`, and a password is
  never returned at all except the one time a temporary one is generated.
- `POST /api/projects/:id/reindex` is safe to call often: indexing is incremental.
- Authorization is decided server-side from one policy table covering routes by method and shape, so a
  new endpoint is protected the moment it exists. What the dashboard hides is cosmetic.
- The API is bound to the same port as the dashboard and the MCP endpoints, so anything that can reach
  one can reach the others ([Security](/en/docs/security/)).
