Admin Console
Every endpoint behind the operator role: security reports, users, delivery logs, and the AI-assisted admin tools. Three deliberate exceptions to read-only, everything else is a report.
Every endpoint on this page requires an access token carrying a role: "admin" claim, not just a valid one.
Postgres only — the whole console, not parts of it
Every admin route answers 501 not_implemented_on_sqlite on a SQLite deployment, before the token is even examined. This is resolved once at router construction, not per request: on Postgres the admin routes run behind RequireAdmin; on SQLite they're a flat 501 regardless of who's asking.
The reasoning is worth carrying into any client-facing message, because it's counterintuitive: RequireAdmin depends on the operators table, and a SQLite deployment has no such table at all. Checking the token first would tell a legitimate operator 403 not_operator — which reads as "you personally lack access" when the truth is "this deployment has no console." 501 is a statement about the deployment, not about the caller.
# on a SQLite deployment, this is what every /v1/admin/* route answers,
# even with a perfectly valid admin token:
curl https://api.example.com/v1/admin/users \
-H "Authorization: Bearer $ADMIN_TOKEN"
# → 501 { "error": { "code": "not_implemented_on_sqlite", ... } }Becoming an operator
There is deliberately no HTTP endpoint for this — a network-reachable "make me an admin" route is unnecessary attack surface for something that only ever needs to happen from a trusted machine with direct database access.
./grant-operator -db "$DATABASE_URL" -email devray@example.com
./grant-operator -db "$DATABASE_URL" -email devray@example.com -role superadmin
./grant-operator -db "$DATABASE_URL" -email devray@example.com -revokeOnce granted, that account's next login or token refresh carries the role claim — not retroactively over an already-issued token.
The read-only rule, and its three named exceptions
The engine's AI-assisted admin tools are read-only and surface-only by construction, and that rule extends to this whole console — but it covers the AI tools specifically, not literally every route under /v1/admin. There are exactly three places this surface writes, each a named exception rather than a gap in the rule:
- Per-user metadata — a write is the entire feature.
- The flagged-event review queue — an operator recording a judgement they made by hand; nothing here can act on the account itself.
- Settings — a human-confirmed save, the one place a tuning suggestion may end up after a person chooses to apply it.
None of the three is reachable from any AI-assisted handler — no suggestion is ever accepted as input to a write.
Security reports
GET /admin/security/hash-migration
Reports what the deployment is configured to write, not what's actually stored — the engine exposes no bulk way to inspect stored hash algorithms.
curl "https://api.example.com/v1/admin/security/hash-migration?window_days=7" \
-H "Authorization: Bearer $ADMIN_TOKEN"Response 200: { "data": { "hasher": { "algorithm": "argon2id", "memory_kib": 65536, "iterations": 3, "parallelism": 4 }, "total_users": 240, "upgraded_events": 58, "estimated_remaining": 182, "window_days": 7, "upgraded_events_in_window": 12 } }
upgraded_events counts events, not users — a user whose hash gets rewritten twice contributes two, so this number can exceed total_users. Watch upgraded_events_in_window, not the all-time total, to see whether migration is actually still draining — the all-time count only ever rises.
GET /admin/security/mfa-adoption
Deliberately not "N of M users have MFA." The engine's TOTP and WebAuthn stores are per-user with no Count/ListAll — the only way to answer a real adoption percentage would be counting rows directly, which means SQL against the engine's own schema. So this reports events instead, and every field is named *_events so the number can't be misread as coverage.
curl "https://api.example.com/v1/admin/security/mfa-adoption?window_days=7" \
-H "Authorization: Bearer $ADMIN_TOKEN"Response 200: { "data": { "total_users": 240, "window_days": 7, "factors": [ { "factor": "totp", "enrolled_events": 61, "removed_events": 4, "enrolled_events_in_window": 3, "removed_events_in_window": 0 } ] } }
Recovery codes are excluded — they're a fallback for an account that already has a factor, not a factor of their own.
GET /admin/oauth/health
Bare GETs against each configured provider's authorize endpoint, not a real OAuth exchange — so a 4xx counts as proof of life.
curl https://api.example.com/v1/admin/oauth/health \
-H "Authorization: Bearer $ADMIN_TOKEN"Response 200: { "data": [ { "provider": "google", "configured": true, "status": "ok", "http_status": 400, "latency_ms": 82 }, { "provider": "apple", "configured": false, "status": "not_configured" } ] }
status is ok (any 4xx), degraded (5xx), unreachable (no HTTP response), or not_configured. An unconfigured provider is never probed at all. All configured providers are probed concurrently, 5s timeout each.
Users
GET /admin/users
This is the only place in the API where an operator can see an account that isn't their own — so what's absent matters as much as what's present: no lock, no unlock, no password reset, no delete. The engine's store exposes LockAccount; wiring it to a button here would make this repository the thing capable of locking someone out.
Search is exact-email and case-sensitive only — Alice@example.com will not find alice@example.com. Partial search was declined, not deferred: a LIKE against the engine's own users table would be a second, silent definition of what a user is.
curl "https://api.example.com/v1/admin/users?q=devray@example.com&limit=50&offset=0" \
-H "Authorization: Bearer $ADMIN_TOKEN"Response 200: { "data": { "users": [ { "id": "019f...", "email": "devray@example.com", "created_at": "...", "updated_at": "...", "failed_attempts": 0, "locked": false, "locked_until": null } ], "total": 1, "limit": 50, "offset": 0, "match": "exact_email", "query": "devray@example.com" } }
match is always "exact_email" or "browse", so a client can label the result and explain a zero-result search rather than leaving an operator guessing whether the account exists. A search that finds nothing is an empty 200, never 404. locked is computed live from the lockout deadline, not mirrored from a stored flag — the engine clears a lockout by time passing, not by writing a value, so a locked_until already in the past correctly reports locked: false. limit accepts 1–500 (default 50); offset accepts 0–10000.
GET /admin/users/{userID}
curl https://api.example.com/v1/admin/users/019f... \
-H "Authorization: Bearer $ADMIN_TOKEN"Response 200: { "data": { "user": { ... same shape as above ... }, "active_sessions": 2, "recent_activity": [ { "type": "login_success", "created_at": "..." } ] } }
active_sessions is a count, not a list — listing them would expose every IP and user agent an account has signed in from to anyone holding an operator token. recent_activity is the account's own audit history, newest first, capped at 20. The password hash is never on this response regardless of what the underlying struct carries.
Per-user metadata
The first of the three write exceptions. Used for JWT claim mapping — these keys are merged into every access token issued for that user going forward.
curl https://api.example.com/v1/admin/users/019f.../metadata \
-H "Authorization: Bearer $ADMIN_TOKEN"
curl -X PUT https://api.example.com/v1/admin/users/019f.../metadata/tier \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"value": "pro"}'
curl -X DELETE https://api.example.com/v1/admin/users/019f.../metadata/tier \
-H "Authorization: Bearer $ADMIN_TOKEN"Response, all three: { "data": { "user_id": "019f...", "metadata": { "tier": "pro" }, "reserved_claim_names": ["iss", "sub", "aud", "exp", "nbf", "iat", "jti", "role"] } }
A change lands on that user's next login or refresh, never retroactively on an already-issued token — the same latency as an operator grant. It's a per-key PUT, not a whole-map replace, specifically so two operators editing different fields of the same user can't clobber each other. reserved_claim_names is returned so a client can grey those out rather than an operator discovering the rule by being rejected — writing sub wouldn't be ignored, it would fail the affected user's next login, which is why it's refused where an operator can still see why.
Cost worth knowing: this adds two queries to every login and refresh, for every user, whether or not they have any metadata set.
Errors: reserved_metadata_key / invalid_metadata_key (400), metadata_key_not_found (404)
The flagged-event review queue
The second write exception. Confirming an event takes no action on the account — no lock, no revoke, no threshold change. There's nothing here to trigger, which is what keeps this inside the read-only rule rather than being an exception to it in spirit.
curl "https://api.example.com/v1/admin/anomalies?status=unreviewed&limit=50" \
-H "Authorization: Bearer $ADMIN_TOKEN"
curl -X PUT https://api.example.com/v1/admin/anomalies/019f... \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"status": "confirmed", "note": "verified with the user by phone"}'status is one of unreviewed, confirmed, dismissed — dismiss is a status, not a delete. There's no DELETE on this route; withdrawing a judgement means setting status back to unreviewed, so the record of who reviewed it survives a changed mind. status is required in the PUT body — a review with no decision isn't a review. note is optional, capped at 500 characters. reviewer_id and updated_at come from the verified token and server clock, never the request body, and are both omitted entirely for an event nobody has reviewed yet.
The queue only covers the two signal-carrying event types, anomaly_detected and credential_stuffing_detected — widening it would make the whole audit table the queue. The list is keyed on the audit event's own ID; acting on a stale ID is 404 audit_event_not_found.
Errors: audit_event_not_found (404), invalid_review_status / invalid_review_note (400)
Delivery and log history
Both answer 404 not_configured when the underlying feature is off — deliberately, not an empty list. With no webhook URL set, nothing writes a delivery row at all; an empty list would misleadingly read as "nothing has failed" rather than "nothing runs."
curl "https://api.example.com/v1/admin/webhooks/deliveries?status=failed&limit=50" \
-H "Authorization: Bearer $ADMIN_TOKEN"
curl "https://api.example.com/v1/admin/logging/recent?level=warn&limit=50" \
-H "Authorization: Bearer $ADMIN_TOKEN"Webhook deliveries include the exact payload bytes that were sent (so a retry can be shown alongside the original), status (pending/in_flight/delivered/failed), and an unredacted IP — this is the record of what was actually sent to the receiver, so the redaction that applies to log shipping doesn't apply here. response_code is absent specifically when no response arrived at all (a timeout or connection failure), distinct from the receiver answering with an error status.
Shipped log events are the redacted, filtered copy, not the full-detail line on the deployment's own stdout — reading this response is reading exactly what a hosted log aggregator would have received. level filtering means at or above: level=warn returns both warn and error.
Digest
Two endpoints because they answer different questions, and only one of them can write.
curl "https://api.example.com/v1/admin/digest?window_days=7" \
-H "Authorization: Bearer $ADMIN_TOKEN"
curl "https://api.example.com/v1/admin/digest/history?limit=20" \
-H "Authorization: Bearer $ADMIN_TOKEN"GET /admin/digest builds a fresh digest ending now and records nothing — asking it twenty times doesn't fill history with twenty near-identical reports. GET /admin/digest/history reads only what a scheduled background job recorded; nothing on this surface can write there, and with no schedule configured it answers 404 not_configured while the on-demand endpoint keeps working regardless. History entries store the digest's rendered text as it was at the time, since a digest describes a window that has already ended — re-running its underlying query later wouldn't reproduce the same report.
Support and tuning
curl "https://api.example.com/v1/admin/support/diagnose?email=devray@example.com" \
-H "Authorization: Bearer $ADMIN_TOKEN"An unrecognized email is an answer (found: false), not a 404 — "we've never seen this address" is exactly what a support ticket needs to hear. It describes a locked account; it can't unlock one, since the engine builds this through interfaces with no way to clear a lockout.
curl "https://api.example.com/v1/admin/config-tuning?window_days=30" \
-H "Authorization: Bearer $ADMIN_TOKEN"Response 200: structured suggestions, not prose — { "data": { "suggestions": [ { "area": "lockout", "finding": "...", "suggestion": "consider..." } ], "counts": { ... } } }. Wording is always suggestive ("consider..."), never imperative, since nothing here has been applied. This route only accepts GET — there's no matching POST that takes a suggestion, which is the HTTP-level half of "pre-fill, never auto-apply": a suggestion becomes real only by a human saving it through the settings endpoints below. window_days defaults to 30, wider than the digest's week, since a config knob should be judged against a month of traffic.
AI settings (the third write exception)
Nine endpoints, three settings, each GET/PUT/DELETE. Credentials here are sealed with AES-256-GCM under SETTINGS_ENCRYPTION_KEY before they reach the table — deliberately a different key from the engine's own ENCRYPTION_KEY, so rotating one never silently makes the other's rows unreadable.
LLM provider
curl -X PUT https://api.example.com/v1/admin/settings/llm-provider \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"kind": "anthropic", "model": "claude-sonnet-4-6", "api_key": "sk-ant-...", "max_tokens": 1024}'Response (GET/PUT/DELETE all return this shape): { "data": { "kind": "anthropic", "model": "claude-sonnet-4-6", "max_tokens": 1024, "api_key_set": true, "configured": true } }
api_key is required on every write — there's no "leave blank to keep the existing key." There's no field anywhere in the response that could leak or mask the key; a client renders "saved" purely from api_key_set.
Database provider
curl -X PUT https://api.example.com/v1/admin/settings/database-provider \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"label": "Analytics read replica", "dsn": "postgres://readonly_user:...@host/db", "max_rows": 100}'Response: { "data": { "label": "Analytics read replica", "max_rows": 100, "dsn_set": true, "host": "host", "database": "db", "configured": true } } — the DSN itself has no field at all in the response, by type, not just by omission.
This write is noticeably slower than its neighbors, on purpose. The server connects with the supplied credentials and attempts a write against a temporary table before storing anything — nothing is saved unless that write is rejected. Three outcomes, and the third is the one that matters: the write being refused (SQLSTATE 42501) is the only pass; the write succeeding is 400 database_role_not_read_only; and anything else — a timeout, a connection error, an ambiguous result — is 400 database_role_unverified, treated as a failure, not as a pass. A connection that couldn't be tested is not a credential that was verified. This isn't defensive extra caution — the engine's own design decision is that this credential boundary, not the query allowlist, is what actually makes the AI query surface safe.
Ask-AI widget config
The one setting here that isn't a credential — nothing to redact, so this is both the PUT body and the GET response shape.
curl -X PUT https://api.example.com/v1/admin/settings/ask-ai-widget \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"enabled": true, "allowed_origins": ["https://yourapp.com"], "entities": ["sessions", "audit_events"], "greeting": "Ask me about your account", "placeholder": "e.g. what sessions do I have open?"}'allowed_origins (max 20) refuses a literal "*" by name — the widget answers questions about the signed-in user's own sessions and audit events, so a wildcard would let any page on the internet ask on a visitor's behalf. entities (max 8) is validated against the engine's own allowlist directly, not a copy of it, and is the one field here with real teeth: it's enforced in front of the provider, since the engine's own scoping is per-user but covers every entity it allowlists. greeting is required (max 200) when enabled is true; placeholder is optional (max 200).
DELETE exists on all three specifically so a settings screen always has a way to clear a credential — a screen with no way to leave isn't done.
Errors across this section: not_configured (404, unset SETTINGS_ENCRYPTION_KEY is not a startup failure, just this section going dark), invalid_llm_provider / invalid_database_provider / invalid_ask_ai_widget (400), database_role_not_read_only / database_role_unverified (400), setting_undecryptable (409 — distinct from "not configured": the row exists, but SETTINGS_ENCRYPTION_KEY has likely changed since it was written; clearing the setting still works without the old key).