Authentication API

The API is split across two processes. Next.js (default :3000) serves thin proxy handlers under /api/*; the Express service (default :5002) owns all business logic, gating, and persistence. The browser never talks to Express directly.

Browser -> Next.js /api/*  (pages/api/*.ts)  ->  Express /api/*  (:5002)

The proxy (lib/apiProxy.ts → forwardToBackend()) forwards Authorization, X-Request-Id, and X-QA-Bypass-Key verbatim and rewrites the path 1:1. BACKEND_URL (required) sets the Express target. Server-to-server integrations may skip the proxy and call Express directly; the contracts below are identical either way. Every path in this reference is the shared /api/* path.

Request correlation

Send X-Request-Id to correlate a call across the proxy, Express access logs (morgan :req-id), and the structured logs. If omitted, the server assigns one and echoes it back on the response.

Credentials

Two credential types are accepted on protected routes, both via the Authorization header (JWT also via cookie in the browser session):

TypePresentationUse
Session JWTAuthorization: Bearer eyJhbGciOiJIUzI1NiIs...HS256, 8h expiry, issued by /api/auth/login. extractUser middleware decodes it and attaches req.user (id, email, role).
Personal API tokenAuthorization: Bearer sdbs_... or X-API-Key: sdbs_...Non-interactive clients (CI, scripts). Created under Settings → API Tokens; the secret is shown once. Acts as the creating user (same role), optional expiry, revocable. Only the SHA-256 hash is stored server-side.

Missing, invalid, expired, or revoked credentials return 401. Test harnesses may send X-QA-Bypass-Key, which works only when QA_BYPASS_KEY is set (never allowed with NODE_ENV=production) and signs in as the qa@internal admin; X-Agent-Key only exempts internal automation from rate limits.

License and role gating

Every licensed route needs an active license (requireLicense) and, for some writes, a role (requireRole). In /api/db-devops/*, DB DevOps connections and AI agent runs, a non-admin's changes also need the matching section in one of their roles (403 SECTION_REQUIRED; see the Roles API). License failures return 403. A license activated on any app container applies on every container at once.

Route familyGate
/api/health, /api/health/migrations, /api/license/*, /api/setup/*Open (no license, no auth)
/api/auth/*, /api/me, /api/roles, /api/onboardingAny active license + signed in
/api/connections, /api/databases, /api/migrations, /api/queriesAny active license + signed in
/api/db-devops/* (scripts, releases, connections, executions, approvals, discovery, repo-connections, settings, compliance-rules, snapshots, consumers, shadow-runs, rbac)Any active license
/api/db-devops/metrics, /api/notificationsAny active license
/api/db-devops/auditAny active license + audit:read
/api/auth/idpAny active license
/api/ai/*Any active license + signed in
/api/data-protection/*Any active license

Response envelopes

Two shapes appear across the API and both are in active use. Newer routes use the success/error envelope; several core routes return the resource (or an { error } object) directly. Treat any non-2xx as an error and read error.

{ "success": true,  "data": ... }
{ "success": false, "error": "message" }
// or, on direct-return routes:
{ ...resource }          // 2xx
{ "error": "message" }   // non-2xx, sometimes with "code"

POST /api/auth/login

Exchange credentials for a JWT. No auth required.

curl -X POST http://localhost:5002/api/auth/login \\
  -H "Content-Type: application/json" \\
  -d '{ "email": "admin@company.com", "password": "SecureP@ssw0rd" }'

200 OK

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "user": { "id": 1, "email": "admin@company.com", "name": "Admin", "role": "admin" }
}
StatusBodyCause
400{"error":"Email and password are required"}Missing field
401{"error":"Invalid credentials"}Unknown email or wrong password (identical message prevents enumeration)
403{"error":"This account has no seat: …","code":"SEAT_REQUIRED"}The license has fewer seats than active users and this account is not among those holding one
429{"error":"Too many requests"}General limiter tripped

When MFA is enrolled, POST /api/auth/login answers { "mfaRequired": true, "mfaToken": "..." } instead of a session. The mfaToken is valid for 5 minutes and is refused by every other endpoint; exchange it with POST /api/auth/mfa/verify and { "mfaToken", "code" } (a TOTP code or an unused backup code) for the session token. A wrong code answers 401 and counts toward the account lockout (5 failed attempts lock the account for 15 minutes, 423).

POST /api/auth/register

Create a user. No auth required unless self-registration is disabled by policy. Returns 201 with the new id.

{ "email": "dev@company.com", "password": "SecureP@ssw0rd", "name": "Jane Developer" }
// 201 -> { "message": "User created successfully", "user": { "id": 2, "email": "dev@company.com" } }

Errors: 400 missing field; 409 email already exists, or NO_FREE_SEAT when every license seat is taken.

Personal API tokens

MethodEndpointNotes
GET/api/auth/tokensList your tokens (metadata only; never the secret)
POST/api/auth/tokens{ "name": "...", "expiresInDays": 90 } → returns the plaintext sdbs_... once
DELETE/api/auth/tokens/:idRevoke

Mounted before the generic /api/auth router, licensed + authenticated.

Session identity and roles

MethodEndpointReturns
GET/api/meThe signed-in user, their permission keys and the DB DevOps sections they see
GET/api/rolesRoles and the section catalog (admin); see the Roles API

License lifecycle

MethodEndpointNotes
POST/api/license/activate{ "licenseKey": "SDB-{payload}.{sig}" }; verifies signature + expiry, refreshes the cache. Open while no license is active (first-run setup); replacing an active license requires an admin session
GET/api/license/statusWhether a license is active, the customer, the seats (null = no seat limit), the expiry and when it was activated
GET/api/license/seatsSigned in. { "seats", "assigned", "withoutSeat" }: the license's seats, the active users holding one, and (for admins) the active users without a seat when the license has fewer seats than active users
POST/api/license/check-updateAsks the license service at LICENSE_SERVICE_URL for the newest key issued for the active license and applies it. Answers { "result": "current" }, "revoked", "unknown", or "updated" with license. Admin session while a usable license is active; open otherwise. 409 UPDATE_CHECK_OFF without LICENSE_SERVICE_URL, 409 NO_LICENSE, 502 LICENSE_SERVICE_UNREACHABLE, 502 LICENSE_SERVICE_ERROR or LICENSE_SERVICE_KEY_REFUSED
DELETE/api/license/deactivateDeactivate; app returns to unlicensed state, and the key is released by the license service so another setup can use it. Answers { "success": true, "release": { "released", "error"? } }. Requires an admin session
// GET /api/license/status (active)
{ "active": true, "state": "active", "daysLeft": 182, "customerName": "Acme Corp", "seats": 10,
  "expirationDate": "2027-01-15T00:00:00.000Z", "graceEndsAt": "2027-01-22T00:00:00.000Z",
  "activatedAt": "2026-01-15T14:02:11.000Z" }
// updateCheck is true when LICENSE_SERVICE_URL is set; installationId identifies this setup (every response has it)
// state is "active", "expiring" (30 days or fewer left) or "grace" (expired, still working);
// daysLeft counts to the expiry, or to the end of the grace period
// (none)
{ "active": false }
// (expired, grace period over)
{ "active": false, "expired": true, "customerName": "Acme Corp", "expirationDate": "2026-01-15T00:00:00.000Z",
  "graceEndsAt": "2026-01-22T00:00:00.000Z",
  "error": "The license expired on 2026-01-15 and its 7-day grace period has ended. Apply a renewed license key to continue." }

Activation errors are all 400: missing key, malformed SDB-{payload}.{signature}, a seat count that is not a positive whole number, an issue date more than a day after the current time, or signature/expiry validation failure. A key that belongs to another setup is 409 KEY_IN_USE; a key the setup could not register is 409 SETUP_NOT_REGISTERED (no LICENSE_SERVICE_URL and the key was not issued for this installation) or 502 LICENSE_SERVICE_UNREACHABLE; a revoked key is 409 KEY_REVOKED. While no license is usable (none, or expired past its grace period) activation needs no session. A key with fewer seats than active users is accepted; see Seats under Managing Users.

Health

MethodEndpointNotes
GET/api/healthOverall status + changeops DB readiness. Returns STARTING until the DB is reachable (10× retry, 3s apart).
GET/api/health/migrationsApplied internal migrations; used by deploy validators.

Status codes

StatusWhen
200 / 201 / 202OK / created / accepted (async job queued, e.g. shadow run)
400Missing or invalid parameters
401No / invalid / expired / revoked credential
403Valid credential, but the role or the license does not include the route
404Resource does not exist
409Conflict (duplicate, or FK reference blocking a delete)
413Payload too large (e.g. SQL content over the 5MB per-script cap)
429Rate limit exceeded
500Unhandled server error (see logs by X-Request-Id)

Rate limiting

Two per-IP limiters (express-rate-limit). Both configurable by env; defaults shown.

ScopeDefaultWindowEnv
General (/api/*)200015 minGENERAL_RATE_LIMIT_MAX
DB DevOps + auth (/api/db-devops/*, /api/auth/*)50001 minDB_DEVOPS_RATE_LIMIT_MAX

Standard X-RateLimit-Limit / -Remaining / -Reset headers are returned; a 429 carries Retry-After. The request body cap is 150mb.