Contextator
ENTR

Administration & security

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:

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 below
Authorization: Bearer $ADMIN_TOKEN Machine access with root permissions Scripts, CI, cron that need the whole instance
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. 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) is different: it needs the caller’s own session, same as any other self-service account endpoint.


Health

curl -s $API/health | jq
{
  "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.

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

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

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)
# 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:

{ "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:

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.

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
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:

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
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
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 '[email protected];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.

Metrics

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

Arrow keys to move, Enter to open.