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):
| Type | Presentation | Use |
|---|---|---|
| Session JWT | Authorization: Bearer eyJhbGciOiJIUzI1NiIs... | HS256, 8h expiry, issued by /api/auth/login. extractUser middleware decodes it and attaches req.user (id, email, role). |
| Personal API token | Authorization: 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 family | Gate |
|---|---|
/api/health, /api/health/migrations, /api/license/*, /api/setup/* | Open (no license, no auth) |
/api/auth/*, /api/me, /api/roles, /api/onboarding | Any active license + signed in |
/api/connections, /api/databases, /api/migrations, /api/queries | Any 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/notifications | Any active license |
/api/db-devops/audit | Any active license + audit:read |
/api/auth/idp | Any 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" }
}
| Status | Body | Cause |
|---|---|---|
| 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
| Method | Endpoint | Notes |
|---|---|---|
| GET | /api/auth/tokens | List your tokens (metadata only; never the secret) |
| POST | /api/auth/tokens | { "name": "...", "expiresInDays": 90 } → returns the plaintext sdbs_... once |
| DELETE | /api/auth/tokens/:id | Revoke |
Mounted before the generic /api/auth router, licensed + authenticated.
Session identity and roles
| Method | Endpoint | Returns |
|---|---|---|
| GET | /api/me | The signed-in user, their permission keys and the DB DevOps sections they see |
| GET | /api/roles | Roles and the section catalog (admin); see the Roles API |
License lifecycle
| Method | Endpoint | Notes |
|---|---|---|
| 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/status | Whether a license is active, the customer, the seats (null = no seat limit), the expiry and when it was activated |
| GET | /api/license/seats | Signed 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-update | Asks 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/deactivate | Deactivate; 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
| Method | Endpoint | Notes |
|---|---|---|
| GET | /api/health | Overall status + changeops DB readiness. Returns STARTING until the DB is reachable (10× retry, 3s apart). |
| GET | /api/health/migrations | Applied internal migrations; used by deploy validators. |
Status codes
| Status | When |
|---|---|
| 200 / 201 / 202 | OK / created / accepted (async job queued, e.g. shadow run) |
| 400 | Missing or invalid parameters |
| 401 | No / invalid / expired / revoked credential |
| 403 | Valid credential, but the role or the license does not include the route |
| 404 | Resource does not exist |
| 409 | Conflict (duplicate, or FK reference blocking a delete) |
| 413 | Payload too large (e.g. SQL content over the 5MB per-script cap) |
| 429 | Rate limit exceeded |
| 500 | Unhandled server error (see logs by X-Request-Id) |
Rate limiting
Two per-IP limiters (express-rate-limit). Both configurable by env; defaults shown.
| Scope | Default | Window | Env |
|---|---|---|---|
General (/api/*) | 2000 | 15 min | GENERAL_RATE_LIMIT_MAX |
DB DevOps + auth (/api/db-devops/*, /api/auth/*) | 5000 | 1 min | DB_DEVOPS_RATE_LIMIT_MAX |
Standard X-RateLimit-Limit / -Remaining / -Reset headers are returned; a 429 carries Retry-After. The request body cap is 150mb.