switching clears the token & responses
No token
52

Creating the genesis operator

Bootstrap an empty platform: seed the very first operator out of band (account + one iam-platform-admin grant + a setup token), then bring it online through the ordinary product endpoints — the operator sets their own password and enrols their own TOTP. Once per platform; a second attempt with the same email is a 409.

  1. OOB · POST /v1/dev/oob/genesis-operator → userId + the raw setupToken (copy it — only its hash is stored)
  2. OOB · POST /v1/dev/oob/operator-setup-token → only if the seed returned 409, or the token expired/was lost: re-mint one for the existing account
  3. POST /v1/admin/auth/accept-setup → sets the password, returns the TOTP provisioningUri (token verified, NOT consumed)
  4. OOB · GET /v1/dev/oob/operator-totp → a current TOTP code
  5. POST /v1/admin/auth/mfa/enroll/confirm → recovery codes, status: active, setup token consumed
  6. POST /v1/admin/auth/login → { mfaRequired, mfaToken } — never an access token
  7. OOB · GET /v1/dev/oob/operator-totp → a FRESH TOTP code (the window has almost certainly rolled)
  8. POST /v1/admin/auth/mfa/verify → operator accessToken (auto-captured) + refresh
  9. GET /v1/admin/operators → 200 proves the context verified and the seeded grant resolved (the doc's gateway-token step is not needed here — Kong mints the context from the bearer)
POST/v1/dev/oob/genesis-operatorpublicOOB · Seed the genesis operator

Out-of-band (dev only — 403 unless APP_ENV=development). Stands in for the privileged migration / break-glass seed that does this in staging and production. One transaction writes three rows: the identity.users row (status pending_verification, password_hash NULL, has_mfa_enabled false), ONE active identity.operator_roles grant of iam-platform-admin with a 1-year expiry, and an operator_setup verification token (7-day TTL) holding only sha256(setupToken). Copy setupToken from the response now — nothing can read it back. role is optional (defaults to iam-platform-admin) and must NOT be platform_admin (IAM-ROLE-02) — a bad value surfaces as a foreign-key error on operator_roles.role, not a friendly 422. 409 (“user already exists — use /operator-setup-token instead”) means you are past genesis for that email: skip to the re-mint card below.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Dev-only out-of-band helper (403 unless APP_ENV=development). Stands in for the privileged migration / break-glass seed that creates the FIRST operator in staging and production. One transaction writes three rows: the identity.users row (pending_verification, no password hash, MFA off), one active identity.operator_roles grant with a 1-year expiry, and an operator_setup verification token (7-day TTL) of which only sha256(setupToken) is stored. Copy setupToken out of the response immediately — nothing can read it back. Pre-auth: no bearer, no x-gateway-context.

emailbodystringrequiredyou choose
The email address the genesis operator will sign in with. A 409 (“user already exists — use /operator-setup-token instead”) means you are past genesis for this address; switch to POST /v1/dev/oob/operator-setup-token.
rolebodyenumoptionalyou choose
The operator role to grant. Defaults to iam-platform-admin, which is what the bootstrap needs — it is the role every operator-creating and role-granting path is itself gated on. Must NOT be the unqualified platform_admin (IAM-ROLE-02); a bad value surfaces as a foreign-key error on operator_roles.role rather than a friendly 422.
  • iam-platform-adminThe default and the bootstrap role — can create operators and grant roles (scenario 34).
  • tenant-lifecycle-operatorProvision / suspend / delete tenants (scenarios 35–36).
  • support-acting-in-tenantMint act-as tokens into a tenant (scenario 37).
  • security-investigatorRead the cross-tenant audit log (scenario 38).
  • support-read-onlyRead-only control-plane access.
  • billing-adminBilling administration.
POST https://api.kerja.team/v1/dev/oob/genesis-operator
↩︎Response guide — what comes back & what each value means6 fields

200 OK with the { data } envelope. Dev-only. The one field that matters is setupToken: the service stores only its SHA-256 hash, so this response is the ONLY time the raw value exists anywhere — copy it before navigating away. A 409 instead means an account already holds this email; use POST /v1/dev/oob/operator-setup-token to re-mint against it.

data.setupTokenstringalways
The raw single-use operator setup token. Paste it into POST /v1/admin/auth/accept-setup, then reuse the SAME value in POST /v1/admin/auth/mfa/enroll/confirm — accept-setup verifies it without consuming it; the enrolment confirm is what spends it.
data.userIduuidalways
Id of the newly seeded operator (identity.users row, status pending_verification, password_hash NULL, has_mfa_enabled false).
data.emailstringalways
The operator email, echoed back — the address the login card will use.
data.rolestringalways
The operator role granted in the same transaction. iam-platform-admin unless you overrode it — this grant is what makes the final GET /v1/admin/operators return 200 rather than 403.
data.roleExpiresAtISO-8601always
When the role grant lapses (1 year for the seed). Roles resolve LIVE from identity.operator_roles on every request, never from the token, so an expired grant starts returning 403 with no token change.
data.setupTokenExpiresAtISO-8601always
When the setup token expires (~7 days). Past this, re-mint with POST /v1/dev/oob/operator-setup-token rather than re-running the seed, which would 409.
POST/v1/dev/oob/operator-setup-tokenpublicOOB · Re-mint a setup token (fallback for the 409 above)

Out-of-band (dev only). Run this INSTEAD of the seed above when it returns 409 “user already exists — use /operator-setup-token instead”, or whenever a setup token expired (7 days) or was lost. It inserts a fresh operator_setup token for an existing, not-yet-activated account and returns the raw value; it does NOT create accounts. Send email OR userId — exactly one. Two caveats, because this is not the genesis path: (1) it grants NO role — if the account was not seeded by the genesis call it holds no operator_roles grant, so the final GET /v1/admin/operators will 403 until an existing admin grants one (scenario 34); (2) if the account is already active, accept-setup is spent and a new token is useless — just go straight to the login card. In production this stands in for the setup-token email that POST /v1/admin/operators sends.

Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Dev-only out-of-band helper (403 unless APP_ENV=development). Mints a FRESH operator_setup token for an account that already exists and has not completed setup — the fallback when the genesis seed returns 409, or when a 7-day setup token expired or was lost. It does NOT create accounts and it grants NO role, so an account it did not seed will still 403 on operator routes until an existing admin grants one (scenario 34). In production this stands in for the setup-token email that POST /v1/admin/operators sends. Provide email OR userId — exactly one.

emailbodystringconditionalyou choose
The existing operator's email (case-insensitive). Supply this OR userId, never both. If the account is already ACTIVE, accept-setup is spent and a new token is useless — go straight to the login card instead.
userIdbodyuuidconditionalfrom a response
The operator's UUID, as returned by the genesis seed or GET /v1/admin/operators. Supply this OR email, never both.
POST https://api.kerja.team/v1/dev/oob/operator-setup-token
↩︎Response guide — what comes back & what each value means3 fields

200 OK with the { data } envelope. Dev-only. Inserts a fresh operator_setup token for an EXISTING, not-yet-activated account and hands back the raw value — it creates no account and grants no role.

data.setupTokenstringalways
The raw setup token — same use as the genesis seed's: accept-setup, then the same value again at mfa/enroll/confirm. Only its hash is stored, so copy it now.
data.userIduuidalways
The existing operator's id. If this account was not created by the genesis seed it holds NO operator_roles grant, so operator routes will 403 until an existing admin grants one (scenario 34).
data.expiresAtISO-8601always
When this setup token expires (~7 days).
POST/v1/admin/auth/accept-setuppublicAccept setup: set your own password, begin TOTP enrolment

Product endpoint — byte-for-byte the same in every environment. Pre-auth: the setup token IS the authorization (no bearer, no x-gateway-context). The password (≥ 12 chars) is argon2id-hashed and the TOTP secret is written in the SAME transaction, so the account can never be left half-provisioned. Save the returned provisioningUri (scan it into an authenticator app for a realistic setup). The account stays pending_verification — a password now exists but still cannot sign in. The token is verified but NOT consumed here: you need the same value again in step 4.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Pre-auth, internal-only: the single-use out-of-band setup token authorises the call, so no Authorization or x-gateway-context header is needed. The strict JSON body takes only setupToken and password; it consumes the token, sets the operator password, and begins TOTP MFA enrolment (returns a provisioningUri to render as a QR).

setupTokenbodystringrequiredout-of-band
You must OBTAIN this; you do not type it freely. It is the single-use, out-of-band operator setup token minted when the operator account was created (POST /v1/admin/operators, §3.58) and delivered via a side channel (e.g. setup email/link). 1-512 chars. It authorises this pre-auth call and is consumed on success.
passwordbodystringrequiredyou choose
The new operator's chosen password, freely typed by the user. Must be >=12 and <=1024 chars; it is argon2id-hashed server-side and set as the operator's password.
POST https://api.kerja.team/v1/admin/auth/accept-setup
↩︎Response guide — what comes back & what each value means2 fields

200 OK with a { data } envelope; response carries Cache-Control: no-store (no Set-Cookie). Body confirms MFA enrolment has begun: { data: { mfaEnrollmentRequired: true, provisioningUri } } — the setup token is consumed and the account remains inactive until enrolment is confirmed.

data.mfaEnrollmentRequiredbooleanalways
Signals that the operator must now complete TOTP MFA enrolment before the account becomes active. The client should render the QR/provisioning step and proceed to confirm enrolment (§3.52).
  • trueMFA enrolment has begun and must be confirmed; the account is not yet active. This is the only value returned on success.
data.provisioningUristringalways
An otpauth://totp/... URI containing the unconfirmed TOTP secret and issuer label. Render it as a QR code (or show the secret) for the operator's authenticator app, then submit a current code to POST /v1/admin/auth/mfa/enroll/confirm.
GET/v1/dev/oob/operator-totppublicOOB · Get a current TOTP code (for enrolment)

Out-of-band (dev only). Stands in for the authenticator app: it decrypts the stored TOTP secret server-side and computes the code, so it works on the pending, not-yet-confirmed secret written by accept-setup. Codes rotate every 30s — near a boundary take the next-window value rather than letting it expire mid-request.

Query parameters
ℹ︎Field guide — what each value means & where it comes from2 fields

Dev-only out-of-band helper (403 unless APP_ENV=development) — it stands in for the authenticator app by decrypting the stored TOTP secret server-side and computing the code. No body; both params are query string. It works on a PENDING, not-yet-confirmed secret too, which is what makes it usable for enrolment confirmation as well as sign-in. Despite the name it is not role-restricted: any account that has begun MFA enrolment can be looked up, operator or tenant user.

emailquerystringrequiredyou choose
Email of the account whose TOTP code you want — the genesis/operator account, or a tenant user who enrolled TOTP (e.g. smoke.user@example.com). The account must already hold a TOTP secret (pending or confirmed).
nextquerybooleanoptionalyou choose
Also return the NEXT window's code alongside the current one. Codes rotate every 30s, so if the response's secondsRemaining is small, submit the next value instead of racing the boundary.
  • trueResponse carries both code and next.
  • falseResponse carries only the current code.
GET https://api.kerja.team/v1/dev/oob/operator-totp?email=operator%40example.com&next=true
↩︎Response guide — what comes back & what each value means5 fields

200 OK with the { data } envelope. Dev-only helper standing in for the authenticator app — it DEFEATS MFA by decrypting the stored secret server-side, which is why it is production-guarded. Works on a pending, not-yet-confirmed secret, so the same card serves enrolment confirmation and sign-in.

data.codestringalways
The current 6-digit TOTP code — the value to paste into the MFA step (accept-setup's enrolment confirm, operator mfa/verify, or a tenant user's enrol/confirm).
data.nextstringconditional
The NEXT window's code. Present only when the request asked for next=true. Submit this one instead when secondsRemaining is small, rather than racing the 30-second boundary.
data.secondsRemainingnumberalways
Seconds until the current code rotates. A low value is the cue to use data.next — a code that expires between this call and the verify call fails with an indistinguishable 401.
data.emailstringalways
The account the code belongs to — confirm it matches the account you are signing in as.
data.userIduuidalways
That account's id.
POST/v1/admin/auth/mfa/enroll/confirmpublicConfirm MFA and activate the account

Product endpoint, pre-auth. One transaction re-verifies the token is still live, confirms the TOTP code, sets has_mfa_enabled=true, flips status to active, and CONSUMES the setup token (terminal — any later reuse is 401). Store the returned recovery codes now: shown exactly once, stored only as SHA-256 hashes, never logged, and the only way back in if the authenticator is lost. A wrong code 401s and the account does NOT go live — activation and a working second factor are the same atomic act. The account is now active + MFA-enabled + holds the iam-platform-admin grant: exactly the conjunction login re-checks every attempt.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Pre-auth setup-flow endpoint (no Authorization / x-gateway-context). Confirms an operator's TOTP enrolment begun at POST /v1/admin/auth/accept-setup (§3.51): send the same setup token plus a current TOTP code; on success it returns one-time recovery codes and activates the operator. No required Idempotency-Key.

setupTokenbodystringrequiredout-of-band
The same operator setup token issued/used by the enrol step (POST /v1/admin/auth/accept-setup, §3.51). You must obtain it from that setup flow side channel and paste it here, not type it freely. 1-512 chars. Identifies and authorizes the enrolment being confirmed; it is consumed on success.
codebodystringrequiredauthenticator
The current TOTP code generated by the operator's authenticator app for the secret being enrolled. The user reads it from their authenticator, not picks it freely. 1-32 chars. Must be valid for the enrolment to confirm; a wrong code yields 401 UNAUTHORIZED.
POST https://api.kerja.team/v1/admin/auth/mfa/enroll/confirm
↩︎Response guide — what comes back & what each value means2 fields

200 OK, { data } envelope, Cache-Control: no-store (recovery codes are sensitive and returned only once). No Set-Cookie. Body confirms TOTP enrolment, returns one-time recovery codes, and activates the operator account.

data.statusenumalways
New operator account status after successful enrolment confirmation. Signals the account is now usable.
  • activeOperator account is activated; TOTP enrolment confirmed and setup token consumed.
data.recoveryCodes[]arrayalways
One-time recovery codes (e.g. "a1b2-c3d4"), hashed at rest and returned ONLY in this response — never retrievable again. The client must display/store them securely for the operator; each code is single-use for MFA if the authenticator is lost.
POST/v1/admin/auth/loginpublicSign in, factor one

Product endpoint, pre-auth. Never returns an access token — MFA is mandatory for operators (IAM-MFA-01) — so this ALWAYS yields { mfaRequired, mfaToken }. Copy mfaToken into step 7. A uniform 401 invalid_credentials here means the enrolment confirm never completed, the password is wrong, or the account is not active; the response is deliberately identical for all three.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Operator password login, stage one — pre-auth, internal-only, throttled, never cached (no Authorization needed). Body is .strict() (unknown fields rejected). Because MFA is mandatory for every operator a correct password ALWAYS returns an MFA challenge { mfaRequired, mfaToken } — never a token — so copy mfaToken into POST /v1/admin/auth/mfa/verify to finish. No Idempotency-Key required.

emailbodystringrequiredyou choose
The operator account email; you type it. Must be a valid email, at most 320 chars. Combined with password to authenticate factor one. Failures return a uniform INVALID_CREDENTIALS that never reveals which factor was wrong.
passwordbodystringrequiredyou choose
The operator account password; you type it. 1–1024 chars. On success the response is only an MFA challenge (mfaToken), not an access token.
POST https://api.kerja.team/v1/admin/auth/login
↩︎Response guide — what comes back & what each value means2 fields

200 OK with the standard { data } envelope; Cache-Control: no-store (no Set-Cookie at this stage). Because operator MFA is mandatory, this stage-one password login ALWAYS returns an MFA challenge { mfaRequired, mfaToken } and NEVER an access/refresh token — the real session is minted only by §3.54 POST /v1/admin/auth/mfa/verify.

data.mfaRequiredbooleanalways
Always true — signals the client that stage two (MFA verify) is required before any token is issued. The tester should branch to the MFA verify call.
  • trueMFA challenge is mandatory; no access token has been issued. Proceed to POST /v1/admin/auth/mfa/verify with the mfaToken plus the TOTP/recovery code.
data.mfaTokenJWTalways
Single-purpose, short-TTL (~5 min) JWT identifying this in-progress login — NOT an access token. Capture it and pass it as the mfaToken field of the next call (POST /v1/admin/auth/mfa/verify) to complete login.
GET/v1/dev/oob/operator-totppublicOOB · Get a FRESH TOTP code (for sign-in)

Out-of-band (dev only). Generate a NEW code rather than reusing the enrolment value — the 30-second window has almost certainly rolled, and the mfaToken only lives ~5 minutes, so run this immediately after login.

Query parameters
ℹ︎Field guide — what each value means & where it comes from2 fields

Dev-only out-of-band helper (403 unless APP_ENV=development) — it stands in for the authenticator app by decrypting the stored TOTP secret server-side and computing the code. No body; both params are query string. It works on a PENDING, not-yet-confirmed secret too, which is what makes it usable for enrolment confirmation as well as sign-in. Despite the name it is not role-restricted: any account that has begun MFA enrolment can be looked up, operator or tenant user.

emailquerystringrequiredyou choose
Email of the account whose TOTP code you want — the genesis/operator account, or a tenant user who enrolled TOTP (e.g. smoke.user@example.com). The account must already hold a TOTP secret (pending or confirmed).
nextquerybooleanoptionalyou choose
Also return the NEXT window's code alongside the current one. Codes rotate every 30s, so if the response's secondsRemaining is small, submit the next value instead of racing the boundary.
  • trueResponse carries both code and next.
  • falseResponse carries only the current code.
GET https://api.kerja.team/v1/dev/oob/operator-totp?email=operator%40example.com&next=true
↩︎Response guide — what comes back & what each value means5 fields

200 OK with the { data } envelope. Dev-only helper standing in for the authenticator app — it DEFEATS MFA by decrypting the stored secret server-side, which is why it is production-guarded. Works on a pending, not-yet-confirmed secret, so the same card serves enrolment confirmation and sign-in.

data.codestringalways
The current 6-digit TOTP code — the value to paste into the MFA step (accept-setup's enrolment confirm, operator mfa/verify, or a tenant user's enrol/confirm).
data.nextstringconditional
The NEXT window's code. Present only when the request asked for next=true. Submit this one instead when secondsRemaining is small, rather than racing the 30-second boundary.
data.secondsRemainingnumberalways
Seconds until the current code rotates. A low value is the cue to use data.next — a code that expires between this call and the verify call fails with an indistinguishable 401.
data.emailstringalways
The account the code belongs to — confirm it matches the account you are signing in as.
data.userIduuidalways
That account's id.
POST/v1/admin/auth/mfa/verifypublicSign in, factor two

Product endpoint, pre-auth. This is where the operator session is created and the real credentials are minted: accessToken (aud: iam-platform, carries sid, roles NOT embedded) + a rotating refreshToken. data.accessToken is auto-captured as the active bearer for the verification call below and every other /v1/admin/* scenario. 401 means the mfaToken expired (~5 min) or the code is stale — redo login, then fetch a code immediately.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from2 fields

Stage-two operator MFA verification that completes login and mints the operator access + refresh tokens. Pre-auth and internal-only: no Authorization or x-gateway-context is sent yet; the mfaToken from §3.53 stand-in is the credential. No Idempotency-Key required.

mfaTokenbodystringrequiredfrom a response
Do not type this yourself: copy it from the prior operator login response (the mfaToken JWT returned by POST /v1/admin/auth/login, §3.53). It identifies the pending operator MFA challenge this verification completes. 1-4096 chars.
codebodystringrequiredauthenticator
The current 6-digit TOTP code from the operator's MFA authenticator app, OR a single-use recovery code. You obtain it from the authenticator, not from any API response. A correct recovery code is consumed (single-use). 1-32 chars.
POST https://api.kerja.team/v1/admin/auth/mfa/verify
↩︎Response guide — what comes back & what each value means8 fields

200 OK, { data } envelope, Cache-Control: no-store (refresh token returned in the body, not as a Set-Cookie). Stage-two operator MFA: mints the operator access token (aud: iam-platform) plus a rotating refresh token and returns the operator profile.

data.accessTokenJWTalways
The freshly minted operator access token (token_use: access, aud: iam-platform, sub = operator id, sid = operator session). Auto-captured by the tester as the active bearer token for subsequent admin/operator calls. Operator roles are NOT in this token.
data.tokenTypeenumalways
Token scheme to use in the Authorization header.
  • BearerSend the access token as 'Authorization: Bearer <accessToken>'.
data.expiresInintegeralways
Operator access-token TTL in seconds (short-lived; e.g. 600). Client uses it to know when to refresh before expiry.
data.refreshTokenstringalways
Rotating operator refresh token (opaque). Returned in the body (no-store); feed into POST /v1/admin/auth/refresh to rotate and obtain a new access token.
data.operatorobjectalways
The authenticated operator's profile summary.
data.operator.idUUIDalways
Operator identifier (the access token's sub). Identifies the logged-in operator.
data.operator.emailstringalways
Operator's email address (e.g. grace@fam.local).
data.operator.displayNamestringalways
Operator's human-readable display name for UI presentation.
GET/v1/admin/operatorsadminVerify: the seeded grant actually resolves

Product endpoint. A 200 with the operator list proves all three layers: Kong verified the operator access token and minted the x-gateway-context, the iam-platform-admin grant resolved live from identity.operator_roles (never from the token — AUTH-GUARD-01), and the route authorised. A 403 here means the context is fine but the grant is missing or expired (the seed's grant lasts 1 year). The doc's step 8 (POST /v1/dev/oob/gateway-token) is not needed in this tester: it exists to mint a context when calling IAM directly, and every request here goes through Kong.

Query parameters
ℹ︎Field guide — what each value means & where it comes from2 fields

Lists operator accounts (cursor-paginated). No request body. Requires platform-admin auth (Authorization / x-gateway-context handled by the tester+gateway); caller must hold the iam-platform-admin role. Only two optional query params.

limitqueryintegeroptionalyou choose
The user chooses how many operators to return per page. Integer 1-100, defaults to 20 if omitted. Out-of-range values cause a 400 VALIDATION_ERROR.
cursorquerystringoptionalfrom a response
Opaque pagination cursor of 1-512 chars. Do not type it freely; copy it from the prior response's meta.nextCursor field to fetch the next page. Omit on the first request.
Bearer token
GET https://api.kerja.team/v1/admin/operators?limit=20
↩︎Response guide — what comes back & what each value means10 fields

200 OK. Body is the standard { data, meta } envelope: data is an array of OperatorListItem objects, meta carries the page count and cursor-pagination token. No special response headers (no Set-Cookie, no Cache-Control: no-store).

dataarrayalways
Array of operator account summaries (OperatorListItem) for this page. Empty array when no operators match.
data[].idUUIDalways
Operator account identifier. Feed into GET/PATCH /v1/admin/operators/{operatorId} for detail or updates.
data[].emailstringalways
Operator's email address (login identifier).
data[].displayNamestringalways
Human-readable name shown in operator lists/UI.
data[].statusenumalways
Lifecycle status of the operator account.
  • activeAccount is enabled and may authenticate.
data[].createdAtISO-8601always
Timestamp when the operator account was created.
data[].activeRoleCountintegeralways
Number of roles currently assigned/active for this operator.
metaobjectalways
Pagination metadata for the returned page.
meta.countintegeralways
Number of items returned in data for this page.
meta.nextCursorstringalways
Opaque cursor for the next page; pass back as ?cursor to fetch more. Null when there are no further pages.