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.
- OOB · POST /v1/dev/oob/genesis-operator → userId + the raw setupToken (copy it — only its hash is stored)
- 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
- POST /v1/admin/auth/accept-setup → sets the password, returns the TOTP provisioningUri (token verified, NOT consumed)
- OOB · GET /v1/dev/oob/operator-totp → a current TOTP code
- POST /v1/admin/auth/mfa/enroll/confirm → recovery codes, status: active, setup token consumed
- POST /v1/admin/auth/login → { mfaRequired, mfaToken } — never an access token
- OOB · GET /v1/dev/oob/operator-totp → a FRESH TOTP code (the window has almost certainly rolled)
- POST /v1/admin/auth/mfa/verify → operator accessToken (auto-captured) + refresh
- 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)
/v1/dev/oob/genesis-operatorpublicOOB · Seed the genesis operatorOut-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.
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 chooserolebodyenumoptionalyou chooseiam-platform-admin— The default and the bootstrap role — can create operators and grant roles (scenario 34).tenant-lifecycle-operator— Provision / suspend / delete tenants (scenarios 35–36).support-acting-in-tenant— Mint act-as tokens into a tenant (scenario 37).security-investigator— Read the cross-tenant audit log (scenario 38).support-read-only— Read-only control-plane access.billing-admin— Billing administration.
POST https://api.kerja.team/v1/dev/oob/genesis-operatorResponse 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.setupTokenstringalwaysdata.userIduuidalwaysdata.emailstringalwaysdata.rolestringalwaysdata.roleExpiresAtISO-8601alwaysdata.setupTokenExpiresAtISO-8601always/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.
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 chooseuserIdbodyuuidconditionalfrom a responsePOST https://api.kerja.team/v1/dev/oob/operator-setup-tokenResponse 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.setupTokenstringalwaysdata.userIduuidalwaysdata.expiresAtISO-8601always/v1/admin/auth/accept-setuppublicAccept setup: set your own password, begin TOTP enrolmentProduct 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.
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-bandpasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/admin/auth/accept-setupResponse 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.mfaEnrollmentRequiredbooleanalwaystrue— MFA enrolment has begun and must be confirmed; the account is not yet active. This is the only value returned on success.
data.provisioningUristringalways/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.
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 choosenextquerybooleanoptionalyou choosetrue— Response carries both code and next.false— Response carries only the current code.
GET https://api.kerja.team/v1/dev/oob/operator-totp?email=operator%40example.com&next=trueResponse 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.codestringalwaysdata.nextstringconditionaldata.secondsRemainingnumberalwaysdata.emailstringalwaysdata.userIduuidalways/v1/admin/auth/mfa/enroll/confirmpublicConfirm MFA and activate the accountProduct 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.
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-bandcodebodystringrequiredauthenticatorPOST https://api.kerja.team/v1/admin/auth/mfa/enroll/confirmResponse 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.statusenumalwaysactive— Operator account is activated; TOTP enrolment confirmed and setup token consumed.
data.recoveryCodes[]arrayalways/v1/admin/auth/loginpublicSign in, factor oneProduct 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.
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 choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/admin/auth/loginResponse 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.mfaRequiredbooleanalwaystrue— MFA 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/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.
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 choosenextquerybooleanoptionalyou choosetrue— Response carries both code and next.false— Response carries only the current code.
GET https://api.kerja.team/v1/dev/oob/operator-totp?email=operator%40example.com&next=trueResponse 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.codestringalwaysdata.nextstringconditionaldata.secondsRemainingnumberalwaysdata.emailstringalwaysdata.userIduuidalways/v1/admin/auth/mfa/verifypublicSign in, factor twoProduct 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.
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 responsecodebodystringrequiredauthenticatorPOST https://api.kerja.team/v1/admin/auth/mfa/verifyResponse 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.accessTokenJWTalwaysdata.tokenTypeenumalwaysBearer— Send the access token as 'Authorization: Bearer <accessToken>'.
data.expiresInintegeralwaysdata.refreshTokenstringalwaysdata.operatorobjectalwaysdata.operator.idUUIDalwaysdata.operator.emailstringalwaysdata.operator.displayNamestringalways/v1/admin/operatorsadminVerify: the seeded grant actually resolvesProduct 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.
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 choosecursorquerystringoptionalfrom a responseGET https://api.kerja.team/v1/admin/operators?limit=20Response 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).
dataarrayalwaysdata[].idUUIDalwaysdata[].emailstringalwaysdata[].displayNamestringalwaysdata[].statusenumalwaysactive— Account is enabled and may authenticate.
data[].createdAtISO-8601alwaysdata[].activeRoleCountintegeralwaysmetaobjectalwaysmeta.countintegeralwaysmeta.nextCursorstringalways