switching clears the token & responses
No token
40

Configure the login policy

Spans both profiles. The sign-in counterpart of scenario 39: a single GLOBAL setting governing which methods an EXISTING account may authenticate with, for every tenant. The write lives here on the control plane; the public profile only reads it. Requires iam-platform-admin. Restricting sign-up and restricting sign-in are separate levers on purpose — one must not quietly move the other.

  1. GET /v1/admin/login-policy → read the active allowedMethods + updatedAt
  2. PUT /v1/admin/login-policy → set the policy (step-up; audits login_policy_changed with an old→new summary)
  3. GET /v1/auth/login-policy (public profile) → the sign-in UI reads the now-current policy (scenario 7)
GET/v1/admin/login-policyadminRead login policy

Role gate only — no step-up, because a read discloses nothing the public GET /v1/auth/login-policy does not already serve unauthenticated; the operator view merely adds updatedAt. updatedAt is null when the singleton row is absent — the read falls back to BOTH methods rather than failing, so a healthy-looking 200 is not evidence the row exists (only the PUT proves it).

ℹ︎Field guide — what each value means & where it comes fromno inputs

No request body, path params, or query params. A bodyless GET that returns the single global LOGIN policy — which channels an account that already exists may authenticate with. Role gate only (iam-platform-admin), no step-up: the read discloses nothing the public GET /v1/auth/login-policy does not already serve unauthenticated; the operator view merely adds updatedAt. Authorization / x-gateway-context are supplied by the tester + gateway. No Idempotency-Key (read-only).

Bearer token
GET https://api.kerja.team/v1/admin/login-policy
↩︎Response guide — what comes back & what each value means3 fields

200 OK with the { data } envelope carrying the active global LOGIN policy — the operator view of what GET /v1/auth/login-policy serves publicly, plus updatedAt. No special response headers.

dataobjectalways
The active global LoginPolicy object: which channels an account that already EXISTS may authenticate with. Distinct from the registration policy, which governs account creation.
data.allowedMethods[]arrayalways
Non-empty array of the sign-in methods currently open. Never empty — the platform cannot be sealed shut — and a method absent here answers 403 login_method_disabled on every surface that uses it.
  • emailSign-in with an email identifier is open (and the device-resolved subject when the account's loginMode is email).
  • phoneBoth phone doors are open: phone + password, and the password-less one-time-code login.
data.updatedAtISO-8601 | nullalways
When the policy was last written via PUT /v1/admin/login-policy. NULL when the migration-seeded singleton row is ABSENT — the read then falls back to both methods rather than failing, so a healthy-looking 200 with updatedAt: null is the signal that the row is missing, not that the policy has simply never been edited. Only the PUT proves the row exists (it is an UPDATE, not an upsert).
PUT/v1/admin/login-policyadminSet login policy (step-up)

Step-up required, and this one earns it: a single PUT can take an entire authentication channel out of service for every account on the platform at once. allowedMethods must be a non-empty, duplicate-free subset of { email, phone }. combinationMode is REJECTED (422, .strict()) — it belongs to the registration policy only, and copying that body here is a mistake worth being told about rather than silently ignored. Takes effect immediately on commit, INCLUDING for already-issued phone login codes, so the restriction does not lapse as outstanding codes drain. Never signs anyone out (sessions, refresh, switch-tenant and act-as continue) and never strips MFA — the phone-OTP second factor is a separate surface. Always reversible: allowedMethods cannot be emptied and operator sign-in (scenario 33) is never gated by this policy, so the surface that undoes a restrictive setting can never be locked out by one. This is an UPDATE, not an upsert — 404 not_found (“Login policy row missing.”) means the migration-seeded singleton is absent, an environment fault rather than a client one.

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

Sets the active LOGIN policy (platform-admin, control-plane) — which channels an EXISTING account may authenticate with. Needs Authorization/x-gateway-context (handled by tester+gateway) plus a step-up factor in the body: supply EITHER stepUp.password OR stepUp.recoveryCode. Body is .strict(), and combinationMode is the field to watch — sending it is a deliberate 422 rather than a silent ignore, because it belongs to the REGISTRATION policy only and a caller copying that body must be told the two policies differ. No path/query params or required custom headers.

allowedMethodsbodystring[]requiredyou choose
You choose which sign-in channels stay open. Non-empty array, no duplicates; each value must be one of the allowed methods. Cannot be emptied — enforced by both the schema and a DB CHECK — so the platform can never be sealed shut and a restrictive setting is always reversible. Takes effect on the next request, including for phone login codes that were ALREADY sent.
  • emailPermits sign-in with an email identifier (POST /v1/auth/login with an email, and the device-resolved subject when the account's loginMode is email).
  • phonePermits BOTH phone doors: phone + password (POST /v1/auth/login with an E.164 identifier) and the password-less one-time-code login (phone/otp/request purpose=login, plus phone/otp/verify). Dropping it closes both at once.
stepUpbodyobjectrequiredyou choose
Step-up re-authentication wrapper, verified BEFORE the mutation. Must contain at least one of password or recoveryCode. This write earns the step-up more than most: one PUT can take an entire authentication channel out of service for every account on the platform simultaneously.
stepUp.passwordbodystringconditionalyou choose
Your current operator password (1-1024 chars). Required unless you instead supply stepUp.recoveryCode; at least one of the two must be present.
stepUp.recoveryCodebodystringconditionalauthenticator
A one-time recovery code from your MFA enrolment (1-128 chars), pasted in. Required unless you instead supply stepUp.password; at least one of the two must be present.
Bearer token
PUT https://api.kerja.team/v1/admin/login-policy
↩︎Response guide — what comes back & what each value means3 fields

200 OK returning the UPDATED LoginPolicy — the same shape GET /v1/admin/login-policy serves, so the response is the read-back. The change is live on commit: GET /v1/auth/login-policy serves the new value immediately and the gated surfaces begin refusing, including for phone login codes that were already delivered.

dataobjectalways
The LoginPolicy as it now stands. Echoing the stored value back is the confirmation that the UPDATE matched the singleton row — a 404 here means it did not exist to update.
data.allowedMethods[]arrayalways
The channels now open for sign-in. Compare against what you sent: this is the authoritative post-write value, and the audit event login_policy_changed records the same old→new summary.
  • emailEmail sign-in remains/becomes open.
  • phonePhone sign-in remains/becomes open — both the password and one-time-code doors.
data.updatedAtISO-8601always
Stamp of this write. Non-null after a successful PUT — which is the only positive proof the singleton row exists, since both reads fall back to a default when it is absent.
GET/v1/auth/login-policypublicPublic sign-in reads the policy

The public profile (scenario 7) reads the now-current policy to render the right sign-in form. This is the read side of the cross-profile login-policy bridge — the exact mirror of the registration-policy bridge in scenario 39.

ℹ︎Field guide — what each value means & where it comes fromno inputs

No request body, path params, or query params — and no bearer token: this is the unauthenticated read the sign-in screen makes before rendering. Returns the active LOGIN policy. This is NOT GET /v1/auth/registration-policy under another name: that one answers “what may a new account be created with?”, this one “what may an account that already exists sign in with?”. A sign-in screen reading the registration policy is answering the wrong question and will offer a method login refuses, or hide one it accepts.

GET https://api.kerja.team/v1/auth/login-policy
↩︎Response guide — what comes back & what each value means2 fields

200 OK with the { data } envelope and Cache-Control: public, max-age=30 — lightly cacheable, safe to call on page load, and safe to call alongside GET /v1/auth/device. No secrets and no user data: this is the same fact the endpoint already publishes to anyone who asks, which is why the 403 login_method_disabled it predicts can be raised before a credential is even examined without leaking anything.

dataobjectalways
The active global LoginPolicy. Note what is NOT here: there is no combinationMode. Combining channels is a sign-up concept, and requiring two channels at every login is MFA, which is per-tenant (isMfaRequired) rather than part of this policy.
data.allowedMethods[]arrayalways
Non-empty subset of ["email","phone"] — render only these forms. Defaults to BOTH when the policy row is missing, deliberately more permissive than the registration policy's fallback: a missing row here would otherwise lock out every account whose only credential is the excluded channel.
  • emailOffer the email + password form (scenario 9).
  • phoneOffer the phone forms — phone + password (scenario 10) and the one-time-code login (scenario 14). Both close together when this is absent, including codes already sent.