Skip to content

Latest commit

 

History

History
360 lines (263 loc) · 45.8 KB

api.md

File metadata and controls

360 lines (263 loc) · 45.8 KB
title API Reference & Clients
description The human-readable companion to the committed OpenAPI specs and generated clients.
group General
order 70

API Reference & Clients

Audience: developers consuming, extending, or integrating with the HTTP API — from a browser, a script, another service, or any language. This page is the human-readable companion to the committed OpenAPI specs; the route handlers under src/app/api/** and src/lib/api-auth/** are authoritative. Where this prose and the code disagree, the code wins — fix the doc.

Two related references carry the deeper detail this page links to rather than repeats:


1. Surface overview

Group Base path Auth Audience
Better Auth /api/auth/[...all] Better Auth (cookies) Browser auth flows & OAuth callbacks
Account self-service /api/account/*, /api/preferences/* Cookie session (/api/account/* also accepts a bearer credential carrying the matching account.* scope, except the data export GET /api/account/export, which is cookie-only, F-151) The signed-in user
Invitations /api/invitations/accept Cookie session Signed-in invitees accepting an organization invitation
Navigation /api/navigation/* Cookie session The web UI
SSO handoff /api/sso/launch, /api/sso/consume Cookie session / signed token Cross-subdomain SSO
Docs assets /api/docs/asset/[...path] Cookie session + shell.view In-app docs viewer
Administrator /api/administrator/* Cookie session (+ Origin on mutations) or Bearer (API key / JWT, scope-bound) + admin.* permission The admin console
Machine API (v1) /api/v1/* Bearer (API key or JWT) or cookie Integrations & the user's own self-service
MCP agent gateway /api/mcp, /api/mcp/register Bearer (API key or JWT); registration public AI agents (Model Context Protocol) — dark by default
Discovery /api/v1/openapi.json, /api/v1/jwks.json, /.well-known/oauth-* Public Tooling · MCP / OAuth clients

All handlers are dynamic = "force-dynamic" (no caching of authorized data). Every mutating route runs a permission check, an origin/CSRF guard, a per-actor rate-limit, Zod validation, and an audit write.

This page focuses on the two surfaces an external caller integrates with — the machine API (/api/v1) and the admin console API (/api/administrator) — plus the cross-cutting auth, envelope, and error model they share.

Authoritative machine spec: both surfaces are described by OpenAPI 3.1 documents — the v1 spec is served live at GET /api/v1/openapi.json and both are committed for offline client generation: docs/openapi.json (/api/v1) and docs/openapi-admin.json (/api/administrator). They are produced by the builders in src/lib/api-auth/ (openapi.ts, openapi-admin.ts) — the same builders that power the live route and the committed SDK. The builders are written by hand, not derived from the handlers. CI checks that the committed files match the builders (tests/unit/openapi-export.test.ts), that the admin SDK matches its file, and that every route has an operation and every operation a route (tests/unit/api-route-spec-parity.test.ts); a response's shape is checked only where a route test asserts it against the committed document (tests/helpers/openapi-response.ts, F-74). Treat the specs as the source of truth for exact request/response shapes; this page summarizes.

flowchart TB
    subgraph You["Your integration"]
        V["v1 client (generated)"]
        A["admin SDK (sdk/admin)"]
    end
    V -- "Authorization: Bearer drk_… / JWT" --> V1["/api/v1/*"]
    A -- "Cookie (+ Origin on mutations) or Bearer" --> ADM["/api/administrator/*"]
    V1 & ADM --> APP["DevResponse app"]
Loading

List envelope & conventions

List endpoints across both surfaces share one envelope and one set of query parameters:

  • Envelope — { items, page, pageSize, total }, plus sort on the full list-query endpoints (users, audit). pageSize is clamped to 1–200.
  • Query — page (1-indexed), pageSize, sort (repeatable field.asc / field.desc, applied in order), q (case-insensitive search), and repeatable filter[…] exact-match filters. Several values are repeated parameters (?filter[status]=blocked&filter[status]=suspended matches either; ?sort=created_at.desc&sort=status.asc), never a comma-joined list: a comma is part of the value. The console lists and their CSV export honour a repeated filter too, on every filter the admin spec declares repeatable (all but GET /mcp-agents' derived filter[status], which takes one value); most of them used to read only a single value, so a repeated one listed every row (F-74).
  • Bounds — page is at most 1,000,000 on both surfaces: a deeper page is a 400 before any query runs, invalid_request on v1 and invalid_query in the console envelope. The console's filters on an id (filter[organization_id], filter[app_user_id], filter[organization]) take a UUID or that filter's documented keyword, and anything else is the same invalid_query. Both mistakes used to reach Postgres, and most lists answered 500 (F-63).
  • Strict on v1 — every /api/v1 list refuses what it cannot apply with a 400 invalid_request problem whose detail names the accepted values: a filter[…] the operation does not list, an empty filter value or one outside the spec's enum, a sort on an unlisted field or with a direction other than asc / desc, a q on a list that does not search, and a single-valued parameter (page, pageSize, q, status, appUserId) given twice (F-34). Only /users searches. The credential listings (/admin/api-keys, /admin/oauth-clients) take no sort, q or filter[…] at all, only status (one of its enum) and, on api-keys, appUserId. The console API keeps the lenient contract of Admin Manager §7 and drops those instead.
  • Tenant scoping — an out-of-scope resource returns 404, never 403, so existence is never leaked (see Tenant scoping).
  • Wire format — list/detail endpoints return raw snake_case DB rows; create endpoints return small camelCase summaries. Both are modeled in the spec, so a generated client matches the wire format exactly.

2. Authentication

There are two auth models. Cookie session is what the browser uses everywhere; bearer credentials are the machine path. Both /api/v1 and /api/administrator accept either (one resolveCaller behind both guards); the account and navigation endpoints are cookie-only except where noted in §1.

Cookie session (browser & admin console)

Better Auth sets a session cookie on sign-in: __Secure-better-auth.session_token on any https origin (Better Auth's useSecureCookies prefix — production always), and the bare better-auth.session_token only on a plain-http dev origin such as http://localhost:3000. Its value is the signed cookie value exactly as the browser holds it — a session id or a row from the session table is rejected — so a server-side caller obtains it from a real sign-in, never by minting one.

Cross-site mutations (POST/PATCH/PUT/DELETE) are additionally protected by an origin guard: the request's Origin (or Referer) must be a trusted origin. The guard covers every cookie-session mutation — /api/administrator/*, /api/account/*, /api/preferences/* (active-org and locale switches), and /api/invitations/accept — so a non-browser caller must send both the session cookie and a matching Origin header; a miss is 403 untrusted_origin. (A browser sets Origin itself; it is a forbidden header name, so setting it from script is a no-op.)

Bearer credentials (machine API — and the admin API)

/api/v1/** and /api/administrator/** accept either credential in an Authorization: Bearer … header. Both paths are disabled by default and enabled per environment (API_KEYS_ENABLED, API_JWT_ENABLED — see Configuration). A bearer caller is exempt from the origin guard (a cross-site page cannot attach one) and is bounded by its scopes — see the authority rule below; on the admin surface that means the credential needs a scope covering the operation's admin.* permission (e.g. admin.users.read or admin.users.*), else 403 forbidden.

Type Looks like Enabled by At rest
API key drk_<env>_<rand> (drk_live_… / drk_test_…) API_KEYS_ENABLED=1 SHA-256 hash only
JWT access token eyJ… (EdDSA) API_JWT_ENABLED=1 + signing key Stateless; verified via JWKS

An API key is drk_<env>_<random> (32 base62 chars, ~190 bits of entropy); only its SHA-256 hash is stored, and the plaintext is shown once. A JWT is a short-lived EdDSA token verified by signature against GET /api/v1/jwks.json — no server-side secret. Get one by exchanging a long-lived credential (an API key, or OAuth2 client-credentials) at the token endpoint:

curl -X POST https://app.example.com/api/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"api_key","api_key":"drk_live_xxxxxxxx","scope":"admin.users.read"}'
# → { "access_token": "eyJ…", "token_type": "Bearer", "expires_in": 900, "scope": "admin.users.read" }

Then call the API:

curl https://app.example.com/api/v1/users -H "Authorization: Bearer eyJ…"

Details of the token endpoint worth knowing:

  • resource (RFC 8707) selects the audience. Omit it (or pass resource=<origin>/api/v1) for a v1 token — aud is API_JWT_AUDIENCE. Pass resource=<origin>/api/mcp for a token that drives the MCP gateway; such a token is refused at /api/v1 (401 invalid_token), and a v1 token is refused at /api/mcp. Any other value is 400 invalid_target. The accepted values are listed as resources_supported in /.well-known/oauth-authorization-server.
  • expires_in is capped by the key. For the api_key grant the lifetime is the smaller of API_JWT_ACCESS_TTL_SECONDS and the seconds left until the key's expires_at, so a token never outlives its key; a key with under a second left is refused (401 invalid_client).
  • The body is capped at 16 KiB. It is read only after the per-IP limiter admits the request, and a larger one is refused with 413 payload_too_large without being buffered (F-78). A token request is a few hundred bytes; even a scope naming every scope fits many times over.
  • Revoking or rotating the credential kills its tokens. Every JWT names the key/client it came from (cid), and each request re-checks that row: after a revoke or rotation the next call with an outstanding token returns 401 credential_revoked — do not retry it, mint a fresh one from the new credential.

Authority rule (the one invariant to remember): a credential's effective access is the intersection of its scopes and its owner's live permissions (src/lib/api-auth/scopes.ts, enforced by requireApiPermission in v1-guard.server.ts). A credential can never be minted with more authority than its creator holds, and GET /api/v1/me reports the resulting effectiveScopes.

Scopes are the permission vocabulary — every admin.* catalog key (see admin-manager.md §6.1) plus a small set of self-service account.* scopes (account.read, account.profile.write, account.preferences.write, account.apikeys.manage). A scope ending in .* (e.g. admin.users.*) matches every key under that prefix.

The self-service write scopes gate the account mutations for bearer callers: PATCH /api/account/profile requires account.profile.write, PUT /api/account/preferences requires account.preferences.write, and the /api/v1/me/api-keys* mutations require account.apikeys.manage. A read-only (account.read) or zero-scope credential gets 403 insufficient_scope on every one of them; a cookie session carries the user's full authority and is unaffected. The data-subject export GET /api/account/export (F-151) refuses every bearer credential, whatever its scopes, with 403 forbidden: it is the whole account, every organization's rows included, and a credential acts in one organization (Admin Manager, Data export and erasure).

For the full threat model, secret storage, issuance, rotation, and revocation, see Design: API Keys & Access Tokens.

3. Tenant scoping

Applies to the admin console and the admin.* v1 routes:

  • Super Admin (holds superuser): all organizations.
  • Org Admin (holds admin.*, no superuser): their single organization only.
  • An out-of-scope resource returns 404, never 403, so existence is not leaked.

See Architecture → Authorization.

4. Error model

Machine API (/api/v1)

RFC 7807 application/problem+json:

{ "type": "https://devresponse.com/problems/forbidden", "title": "Insufficient scope or permission", "status": 403, "code": "forbidden", "detail": "…", "requestId": "5f3c…" }

Administrator routes

A JSON envelope with a stable machine code, an i18n message key, and the correlation id (AdminError):

{ "error": "forbidden", "message": "errors.forbidden", "requestId": "5f3c…" }

The first-party routes (/api/account, /api/preferences, /api/invitations, /api/navigation and SSO launch and consume) answer the same envelope, and every message names a key under errors in each locale's messages, so a client can localize any code it receives (F-129).

Common statuses on both surfaces: 400 invalid body, 401 unauthenticated, 403 forbidden, 404 not found / out of scope, 409 conflict, 412 stale If-Match ETag (v1, and the admin organization/role/group PATCH), 429 rate-limited (with Retry-After). Every response (success or error) carries an x-request-id header that matches the audit log. That includes an unexpected server fault: a handler that throws answers 500 with the code internal_error in the surface's own envelope, never a bare 500 (on the MCP transport a JSON-RPC -32603 error, on MCP registration an RFC 7591 server_error, A-12). The two exceptions are the public, cacheable GET /api/v1/jwks.json and GET /api/v1/openapi.json, because a shared cache would hand one request's id to every later caller.

Every /api response is sent with Cache-Control: private, no-store, so no browser or intermediary keeps a copy of cookie-authenticated JSON (I-07). The exceptions are the routes that set their own caching: the two key sets (/api/sso/jwks.json, /api/v1/jwks.json) and /api/v1/openapi.json (public, max-age=300), and the docs and help images under /api/docs/asset/ and /api/help/asset/ (private, max-age=300).

5. Machine API (/api/v1)

Endpoint Methods Auth (scope) Purpose
/api/v1/auth/token POST API key or OAuth client credentials Exchange long-lived credentials for a short-lived JWT
/api/v1/me GET account.read Caller identity, permissions, effective scopes
/api/v1/me/api-keys GET / POST account.read / account.apikeys.manage List / mint the caller's own keys (plaintext once). A bearer caller sees only keys in the org its credential acts in
/api/v1/me/api-keys/[id] DELETE; …/rotate POST account.apikeys.manage Revoke / rotate one of the caller's own keys. A bearer caller reaches only keys in its own org (404 otherwise) and may rotate only a key whose scopes its own credential carries (403 invalid_scope)
/api/v1/users GET / POST admin.users.read / .create User administration. POST with the Better Auth role: "admin" also needs cross-org reach, which only a superadmin's cookie session has: every API key and JWT is bound to one org (MACHINE-2), so a bearer caller always gets 403 forbidden for it (F-13). A caller without cross-org reach (every API key and JWT, and an org admin's session) enrols the new user in the org it acts in, in the same transaction, with a membership whose status is the user's initialAppStatus (default pending_approval; approving the user activates both). A user's tenant is its membership, so without it that caller got 404 on every follow-up on /users/{id}. A superadmin's cookie session still creates the user in no org; place it with POST /api/administrator/users/{id}/memberships. The MCP createUser tool calls this route, so an agent's user joins the agent's org. That enrolment is a membership add, and an active one an approval, so such a caller also needs admin.users.update or admin.orgs.manage (F-69), plus admin.users.manage to pass initialAppStatus: "active", each held as a permission and granted as a scope. A key or agent scoped to admin.users.create alone gets 403 forbidden, with a detail naming what is missing and an admin.user.create_denied audit row, before anything is created; so does an address whose email domain a superadmin bound to another organization (F-480, Admin Manager §8.1). docs/openapi.json lists the two scope sets as createUser's security alternatives and states the rest in the operation's description, which the MCP tool carries. A pending user created this way is not activated by the org's sign-up policy when it signs in: approve it with POST /api/v1/users/{id}/status approve, which activates the user and the membership. Behaviour change: a client that used to create the user and then enrol it with POST /api/administrator/users/{id}/memberships in the same org now gets 409 membership_exists from the second call, since the create already holds that membership; approve the user instead (or change the membership's status with PATCH …/memberships). An optional name must be at most 200 characters with no control, line-break or invisible formatting characters, or the answer is 400 invalid_request (F-21, Form Validation). An address that already has an account is 409 conflict, including one the identity provider holds with no user row and the loser of two concurrent creates; a failure to store the new user is 502 (F-30)
/api/v1/users/[id] GET; …/status POST admin.users.read / .manage Read a user (emits a weak ETag); apply a status transition — a non-superadmin principal gets 403 forbidden (audited admin.user.action_denied) for a target who outranks them, exactly as the console does (Admin Manager §8.1), and a soft-deleted user is 409 use_restore: only the console's restore brings one back (F-57, Soft-delete and restore). approve or reactivate of an MCP agent's service account also needs admin.clients.manage, as permission and scope: 403 forbidden otherwise, audited admin.user.action_denied (F-77, Admin Manager §8.13); setUserStatus's description in docs/openapi.json says so, and the MCP tool carries it. A block or suspend that changes the account-wide status also ends the user's sessions; a bearer caller is confined to one org, so that is only for a user in no other org (F-147). If ending them fails, the status stays applied and the answer is 502 internal_error: re-read the user and retry, with the new ETag if you send If-Match
/api/v1/admin/api-keys GET; …/[id] DELETE admin.apikeys.read / .manage API-key governance (list / revoke any key)
/api/v1/admin/oauth-clients GET / POST; …/[id] GET/PATCH/DELETE; …/rotate-secret POST admin.clients.read / .manage OAuth client (client-credentials) management. A PATCH that narrows scopes also narrows the client's outstanding tokens, from their next request (F-71, API Security §6)
/api/v1/audit-events GET admin.audit.read Read the audit log
/api/v1/jwks.json GET public Public keys for verifying issued JWTs
/api/v1/openapi.json GET public OpenAPI 3.1 spec

The token endpoint accepts grant_type of api_key or client_credentials, with an optional scope to down-scope the token to a subset of the credential's scopes. Mutations are mutating-rate-limited per credential (the bucket keys on the credential id, so one noisy key can't exhaust the principal's whole budget). A token is charged to the key or client it was minted from, so minting a fresh token does not reset the bucket (F-73). The self-service key routes (/api/v1/me/api-keys and its revoke and rotate) are the exception: create, revoke and rotate share one bucket per principal, so a newly minted key never brings a fresh budget for minting the next. Every v1 429 is a rate_limited problem whose retryAfter and Retry-After header are the refusing bucket's own wait, and each is counted in devresponsekit_rate_limit_denials_total (F-130).

Example — who am I

curl https://app.example.com/api/v1/me -H "Authorization: Bearer eyJ…"
{
  "betterAuthUserId": "…", "appUserId": "…", "email": "svc@example.com",
  "status": "active", "organizationId": "…", "preferredLocale": "en",
  "authentication": { "kind": "api_key", "credentialId": "…" },
  "permissions": ["admin.users.read"], "grantedScopes": ["admin.users.read"], "effectiveScopes": ["admin.users.read"]
}

Generating & using a v1 client

The v1 surface is designed for generated SDKs — use it for anything outside the browser (integrations, cron jobs, other services, or letting a user manage their own resources). Every operation has a stable operationId (listUsers, createUser, issueToken, …) so generated method names are predictable.

# 1. Get the spec — committed, so no running server is needed:
#    docs/openapi.json   (…or fetch the live one)
curl https://app.example.com/api/v1/openapi.json -o openapi.json

# 2. Generate a client for your stack:
npx openapi-typescript docs/openapi.json -o api.d.ts                 # TS types only
npx @openapitools/openapi-generator-cli generate \                  # full typed client
  -i docs/openapi.json -g typescript-fetch -o ./clients/v1
npx @openapitools/openapi-generator-cli generate \                  # any other language
  -i docs/openapi.json -g python -o ./clients/python
// 3. Call it — authenticate with a bearer token (API key or JWT).
import { Configuration, UsersApi } from "./clients/v1";

const api = new UsersApi(
  new Configuration({
    basePath: "https://app.example.com/api/v1",
    headers: { Authorization: `Bearer ${process.env.API_TOKEN}` },
  }),
);

const page = await api.listUsers({ page: 1, pageSize: 25, q: "acme" });
console.log(page.items, page.total);

6. Administrator API (/api/administrator) & the committed admin SDK

This is the console surface (users, roles, permissions, groups, organizations, memberships, sign-up policy, invitations, enterprise apps, API keys, email, MCP agents, audit, CSV export). It is internal tooling that mirrors the admin console — not a public/integration API; prefer the v1 surface for integrations. Every endpoint accepts a cookie session (+ Origin on mutations) or a scope-bound bearer credential and requires the noted permission; mutations are rate-limited (429 with Retry-After / retryAfter) and audited. That includes the user actions backed by Better Auth (create, ban, unban, soft-delete, restore, password, sessions), which a bearer caller could not complete before F-13 (they answered 502). The exceptions are cookie-session only:

  • The Better Auth platform role. POST /users/[id]/role, and POST /users with role: "admin", need cross-org reach. Every API key and JWT is bound to one org (MACHINE-2), so a bearer caller gets 403 there (the role route refused one before F-13 too); only a superadmin's cookie session may set the role.
  • The two impersonation operations. POST /users/[id]/impersonate acts on the caller's own session cookie and hands back a new one, so a caller without that cookie session gets 403; DELETE /users/[id]/impersonate bypasses the permission guard by design.

The table below is generated from the spec by pnpm docs:admin-table (one row per tag; tests/unit/docs-admin-api-table.test.ts fails when it is stale) — edit openapi-admin.ts, not the table:

Resource Methods & paths Permissions
Users GET/POST /users; GET/PATCH/DELETE /users/[id]; POST /users/[id]/status; POST /users/[id]/password; POST /users/[id]/role; POST /users/[id]/ban; POST /users/[id]/unban; POST /users/[id]/restore; GET /users/[id]/export; POST /users/[id]/erase; POST/DELETE /users/[id]/impersonate; GET/DELETE /users/[id]/sessions; DELETE /users/[id]/sessions/[sessionId]; GET/POST/PATCH/DELETE /users/[id]/memberships; GET/POST/DELETE /users/[id]/app-roles; GET /users/[id]/roles; GET /users/[id]/audit; GET/POST/DELETE /users/[id]/groups; POST /users/bulk admin.users.* (per action; POST …/erase: admin.users.delete + superadmin)
Roles GET/POST /roles; GET/PATCH/DELETE /roles/[id]; POST /roles/[id]/duplicate; GET/POST/PATCH/DELETE /roles/[id]/permissions; GET /roles/[id]/members admin.roles.*
Permissions GET/POST /permissions; PATCH/DELETE /permissions/[id] admin.roles.read, admin.permissions.manage
Groups GET/POST /groups; GET/PATCH/DELETE /groups/[id]; GET/POST/DELETE /groups/[id]/members; GET/POST/PATCH/DELETE /groups/[id]/roles admin.groups.* (.assign for members and roles)
Organizations GET/POST /organizations; GET/PATCH/DELETE /organizations/[id]; GET/POST/PATCH/DELETE /organizations/[id]/members; GET/POST/DELETE /organizations/[id]/provider-bindings; GET/POST /organizations/[id]/invitations; DELETE /organizations/[id]/invitations/[invitationId]; POST /organizations/[id]/invitations/[invitationId]/resend; GET/PATCH/DELETE /organizations/[id]/auth-settings; GET/PATCH /auth-settings/defaults admin.orgs.* (.manage for members, invitations and provider bindings; /auth-settings/defaults, POST …/provider-bindings: + superadmin)
Memberships GET /memberships admin.orgs.read
Enterprise apps GET/POST /enterprise-apps; GET/PATCH/DELETE /enterprise-apps/[id] admin.apps.*
API keys GET/POST /api-keys; GET/DELETE /api-keys/[id]; POST /api-keys/[id]/rotate admin.apikeys.*
Email GET /email/outbox; GET /email/outbox/[id]; GET /email/templates; GET/PUT /email/templates/[id]; POST /email/test admin.email.*
MCP agents GET /mcp-agents; PATCH/DELETE /mcp-agents/[id]; POST /mcp-agents/[id]/approve admin.clients.read / admin.clients.manage
Audit GET /audit admin.audit.read
Export GET /export/[resource] the exported resource's read permission

The exact permission per action and request/response shapes live in docs/openapi-admin.json and admin-manager.md. GET /api/administrator/metrics exists but is intentionally excluded from the spec/SDK — it backs the console home dashboard only; it is the only exclusion (tests/unit/api-route-spec-parity.test.ts fails for any other admin route missing from the spec). A user's sessions are returned as a SessionItem projection (never the session token) and revoked by id.

The committed admin SDK

Unlike the v1 client, the admin SDK is already generated and committed at sdk/admin/ (openapi-generator typescript-fetch, zero runtime dependencies — it uses the global fetch). Import it directly, and build the Configuration with the hand-written createAdminClient — it sets basePath from your origin and the right credential shape for each mode (the generated apiKey/username/password knobs are unused by this API):

import { createAdminClient } from "../sdk/admin/client";
import { UsersApi, OrganizationsApi, MCPAgentsApi } from "../sdk/admin";

// Browser: the session cookie rides along (credentials: "include"); the
// browser sets Origin on mutations itself — it cannot be set from script.
const browser = createAdminClient({ origin: "https://app.example.com" });

// Server-side with a session: forwards the Cookie header and adds Origin.
// `cookie` is the SIGNED value of the __Secure-better-auth.session_token
// cookie from a real sign-in — `<token>.<44-char base64 signature>`, whose
// trailing "=" pad is why the SDK matches a leading "name=" rather than just
// looking for an "=" — or the full "name=value; …" header.
const server = createAdminClient({ origin: "https://app.example.com", cookie: signedCookieValue });

// Bearer: an API key or JWT — scope-bound, exempt from the Origin guard.
const machine = createAdminClient({ origin: "https://app.example.com", bearerToken: apiKey });

const users = new UsersApi(browser);
const page = await users.listUsers({ page: 1, pageSize: 25, filterStatus: ["active"] });
const created = await users.createUser({
  createUserRequest: { email: "new.user@example.com", password: "<temp>", name: "New User" },
});
const orgs = await new OrganizationsApi(server).listOrganizations({ q: "acme" });
const agents = await new MCPAgentsApi(machine).listMcpAgents({ filterStatus: "pending" });

Failed requests reject with a ResponseError carrying the AdminError envelope (message is an i18n key; a 429 body is RateLimitedError, a 422 scope grant is UnprocessableError). Regenerate after editing the admin API — a drift-guard test fails otherwise:

pnpm sdk:admin:typecheck   # type-check the committed client (sdk/admin/tsconfig.json)
pnpm sdk:admin:generate    # re-export docs/openapi-admin.json, verify the generator JAR, regenerate sdk/admin
pnpm docs:admin-table      # refresh the resource table above from the spec

Regenerating needs Java + network. openapi-generator runs on a JVM: the JAR version is pinned in openapitools.json, its SHA-256 is verified by scripts/verify-openapi-generator-jar.ts before it runs (the cache lives in the gitignored .cache/openapi-generator/), and the npm wrapper is an exact-pinned devDependency run via pnpm exec. The committed client itself has no dependencies. See sdk/admin/README.md.

Which surface should I use?

If you're… Use
Integrating from another service / script / language v1 machine API (bearer auth, generate from docs/openapi.json)
Letting a user manage their own resources programmatically v1 machine API (account.* scopes, /api/v1/me/*)
Building internal tooling that mirrors the admin console Admin SDK (sdk/admin/, cookie + Origin, or a scope-bound bearer)
Unsure v1 — it's the supported integration surface; the admin SDK is an internal convenience

The two surfaces overlap (both can manage users, both take a bearer) but differ in the browser path (only the admin surface is cookie + Origin), error format (RFC 7807 vs { error, message, requestId }) and stability (v1 is the supported contract).

7. SSO handoff endpoints

Endpoint Method Auth Purpose
/api/sso/launch GET Cookie session (not impersonated) Verify access to a registered app, mint a one-time handoff token, redirect to the destination
/api/sso/consume GET Signed token Verify the token and redirect to the confirmation interstitial (no nonce burn, no session)
/api/sso/consume POST Signed token + trusted origin Burn the token, establish the destination session, redirect to the dashboard
/api/sso/jwks.json GET Public The issuer's public handoff keys (Ed25519 JWKS, Cache-Control: public, max-age=300). Always mounted; { "keys": [] } when this deployment issues no handoffs

Query parameters for launch: applicationId (required; must match the app-id shape ^[a-z0-9][a-z0-9._-]{0,127}$ — anything else is a 400 invalid_application_id with no database work), locale (optional). The token is an EdDSA (Ed25519) JWT signed by the issuer's SSO_HANDOFF_PRIVATE_KEY and verified by the consumer against the issuer's /api/sso/jwks.json — single-use, valid ≤60s (enforced on both sides), with an audience bound to the destination application. Its claims are minimal — sub, email, locale, targetApplicationId, jti plus iss/aud/iat/exp; no roles, organization or app-user ids — because the token rides in a query string. A deployment without a signing key, or one holding the key without being the issuer SSO_HANDOFF_ISSUER names (F-80), answers 503 sso_not_configured on launch. See Architecture → SSO and Configuration.

Contract details that matter to a caller:

  • Impersonated sessions cannot launch. A session with impersonatedBy set gets 403 forbidden_while_impersonating (audited against the impersonating admin). The satellite session a handoff would mint carries no impersonation marker, outlives the impersonation cap, and is attributed to the target — so it is never minted.
  • Application-id binding on consume. Besides the aud check, both consume methods require the token's targetApplicationId claim to equal the consumer's own SSO_HANDOFF_APPLICATION_ID, and the nonce burn is predicated on that id too. A token minted for another registered app is rejected (401 invalid_token, audit reason target_application_mismatch) even if the two apps' audiences collide. The catalog refuses a duplicate sso_audience at registration (409 audience_taken), and never registers the deployment itself: its own SSO_HANDOFF_APPLICATION_ID (409 id_taken), its own audience (409 audience_taken) or its own origin (400 origin_not_allowed) (F-83).
  • The session consume opens is bounded. It ends SSO_SESSION_LIFETIME_HOURS (default 8) after the handoff however active it is, after which the consumer's pages, /api/v1 cookie calls and Better Auth endpoints treat it as signed out (401 SESSION_EXPIRED on /api/auth/*) and a new launch re-checks access. Disabling (PATCH … {"status":"disabled"}) or deleting an enterprise app on the issuer also deletes the handoff sessions for it that the issuer's own session table holds, and expires its handoffs in flight (F-82). Both hold for a consumer that runs this consume route; a satellite fork's only once it ports F-82.
  • Consume failures: a page for a browser, JSON for everyone else. A refused consume answers the { error, message, requestId } envelope with its status (400 missing_token; 401 invalid_token, token_expired, token_already_used or session_establishment_failed; 403 forbidden; 413 payload_too_large; 429 rate_limited; 500 audience_not_configured) — unless the request's Accept names text/html and not application/json, as a browser navigation and the confirm form's POST do. That caller gets a 303 to /{locale}/sso/confirm?error=<code>&requestId=<id>, which shows the failure in the user's language (F-85). token_expired means the token is past exp or the 60 s age ceiling, or its nonce row expired first (audit reason nonce_expired); token_already_used means only a nonce already burned (nonce_replay). A verified token with no nonce row for this application is invalid_token (nonce_unknown).
  • Rate limits. launch is throttled per principal (session user id, or trusted client IP while signed out); consume GET and POST are throttled per trusted client IP. Both use the standard 30-burst / 1 per second budget and answer 429 rate_limited with a Retry-After header before any sso.* audit row is written. The per-IP budgets (consume, and a signed-out launch) are shared by every instance, with no deployment-wide floor behind them, so traffic from other client IPs cannot refuse your handoff (F-19; Architecture → Rate limiting).

8. Secret-handling notes

  • API-key and OAuth-client secrets are returned once at creation/rotation and stored only as hashes. There is no endpoint to retrieve them again.
  • Never log or echo a plaintext credential; the audit log records metadata only.

9. Troubleshooting

Symptom Cause / fix
401 from /api/v1/* Missing/invalid bearer; or the path is disabled (API_KEYS_ENABLED / API_JWT_ENABLED are off by default).
403 with an unexpected scope error The credential's scope doesn't cover the endpoint, or exceeds the owner's permissions (it's capped to the creator).
401/403 from /api/administrator/* 401 unauthenticated: no session cookie and no bearer (or a revoked/expired one). 403 untrusted_origin: a cookie-session mutation without a trusted Origin header — add it (CSRF guard). 403 forbidden: missing permission, or a bearer whose scopes don't cover it.
404 for a resource you know exists Tenant scoping — an org admin can't see other orgs; out-of-scope is 404 by design.
412 from a v1 mutation or an admin organization/role/group PATCH Stale If-Match ETag — re-GET the resource and retry with the fresh ETag.
429 Rate-limited; respect the Retry-After header.
Generated client types don't match responses Regenerate from the current spec; list/detail endpoints return snake_case rows, create endpoints camelCase summaries (both modeled).
pnpm sdk:admin:generate fails It needs Java and network access (openapi-generator). The committed client doesn't.

10. Source of truth & keeping clients in sync

  • The committed OpenAPI 3.1 specs are authoritative for exact request/response shapes — docs/openapi.json (/api/v1) and docs/openapi-admin.json (/api/administrator). They are written by hand in the builders, not regenerated from the handlers. CI checks that the committed files and the admin SDK match the builders; the fix when that check fails is pnpm openapi:export (both specs) and, for the admin surface, pnpm sdk:admin:generate, then commit. A handler that answers a shape its operation does not declare passes that check: pin the answer in the route's test with expectResponseMatchesSpec (tests/helpers/openapi-response.ts), as the create and revoke routes do (F-74).
  • The account.* scopes are also enumerated in x-account-scopes of docs/openapi.json; the supported export resources/formats are described by the /api/administrator/export/[resource] operation in docs/openapi-admin.json.
  • Regenerate your downstream client whenever the relevant spec changes — watch docs/openapi.json / docs/openapi-admin.json, or the version in the spec's info.

11. MCP agent gateway (Model Context Protocol)

Dark by default (MCP_ENABLED), like the machine API it fronts. Flags: Configuration → AI agent gateway (MCP). Full design: Design: MCP Agent Gateway.

/api/mcp exposes the v1 machine API to AI agents over the Model Context Protocol — a stateless Streamable HTTP JSON-RPC 2.0 endpoint. It is a translation layer, not a second API: every tool call funnels through the same permission ∩ scope guard as /api/v1, so the MCP surface can never exceed the machine API's authority.

Endpoint Method Auth Purpose
/api/mcp POST Bearer (API key, or a JWT minted with resource=<origin>/api/mcp) JSON-RPC: initialize, tools/list, tools/call, ping
/api/mcp GET — 405 — no server-initiated stream
/api/mcp/register POST Public (rate-limited) RFC 7591 agent self-registration; gated by MCP_REGISTRATION_ENABLED
/.well-known/oauth-protected-resource/api/mcp GET Public RFC 9728 protected-resource metadata, at the path-suffixed location the 401 challenge names
/.well-known/oauth-protected-resource GET Public The same document, for clients of older MCP revisions that look only here
/.well-known/oauth-authorization-server GET Public RFC 8414 authorization-server metadata
  • Bearer-only. A cookie session is rejected (it is not an audience-bound OAuth token). An unauthenticated call returns 401 with WWW-Authenticate: Bearer …, resource_metadata="…/.well-known/oauth-protected-resource/api/mcp", so a compliant client auto-discovers the token endpoint (/api/v1/auth/token) and JWKS. A token that was presented and refused (expired, unknown, revoked) also gets error="invalid_token", so the client knows to fetch a new one (I-04). A valid credential whose owner is suspended, deactivated or pending, or whose membership in the credential's org is not active, gets 403 with JSON-RPC error -32003 for every method, as /api/v1 refuses it (I-15). A JSON-RPC request must carry a string or integer id: MCP forbids null, which is -32600.
  • Audience-bound (RFC 8707). A JWT must have been requested with resource=<origin>/api/mcp — the identifier advertised as resource in the protected-resource metadata. A token minted for /api/v1 gets 401 with error="invalid_token" and an error_description naming the resource to request, unless the operator has MCP_AUDIENCE_GRACE=1 on for a migration window. API keys are not audience-bound.
  • Tools are generated from the OpenAPI spec. Each scoped /api/v1 operation becomes one MCP tool (~18 today), carrying every scope its security requires (all of one set, or any one of several: createUser needs admin.users.create with admin.users.update or admin.orgs.manage, F-480) and the operation's description when it has one; a new scoped endpoint becomes a tool for free. issueToken, getJwks, and getOpenApi are excluded. An array argument (sort, filter[…]) is sent as one query parameter per value, and a value outside the spec's enum is refused (-32602) before the API is called (F-34). Header parameters are not tool arguments: setUserStatus runs without If-Match, and its description says so. tools/list returns only the tools the credential's scopes allow (an admin.* scope must also be held as a permission), so a zero-scope agent is offered none (I-04). createMyApiKey, rotateMyApiKey, createOauthClient and rotateOauthClientSecret return a plaintext secret, which lands in the model's context and the host's transcript; their descriptions say so.
  • Tool calls are rate-limited, reads are not audited. Each tools/call is charged to a bucket per source credential (60-call burst, then 1 per second; every token minted from one client shares it). A spent bucket answers 429 with Retry-After and JSON-RPC error -32029. The v1 route behind a tool audits an admin permission/scope denial (api.access.denied, naming the credential) and every mutation. It writes no audit row for a successful read, a refused token, an account.* scope denial, a status or membership refusal or a 429, and the gateway's own refusals (an unknown tool, a bad argument, a spent bucket) are not audited either (F-76).
  • Every request is throttled per IP and size-capped before it is authenticated. The body is parsed before the bearer check, so a per-IP bucket (300 burst, then 5 a second, shared across instances) runs first, then the body is read through a 1 MiB cap: a spent bucket is 429 with Retry-After and -32029, a larger body is 413 with -32600. /api/mcp/register caps its body at 64 KiB (413 invalid_client_metadata) behind its own limiter (F-78), whose 429 temporarily_unavailable carries Retry-After too (F-130).
  • Every response carries an x-request-id, on the transport and on registration, including the 500 a fault answers: a JSON-RPC -32603 "Internal error" with the id in error.data.requestId, or an RFC 7591 { "error": "server_error" }. Quote it when reporting a failure; the server log line (mcp.internal_error / mcp.register.internal_error) carries it (A-12).
  • Self-registration is safe by construction. A newly registered agent gets a machine service account + a zero-scope OAuth client. In approval mode (default) it cannot mint a token until an admin activates it; in open mode it is active but every tool 403s until scopes are granted. Admins approve, scope, and revoke agents from Administrator → Agents (Admin Manager §8.13), and a scheduled reaper expires registrations left pending longer than MCP_REGISTRATION_PENDING_TTL_DAYS. A request may only name an organization the operator opened (the default org or MCP_REGISTRATION_ALLOWED_ORGS); the per-org quota counts self-registered agents only and is enforced atomically.

The MCP surface is not modeled in the OpenAPI specs (it is JSON-RPC, not REST) — the design doc is authoritative.


See also: Design: API Keys & Access Tokens · Administrator Console — Specification · Configuration for the environment variables these endpoints depend on.