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.
- GET /v1/admin/login-policy → read the active allowedMethods + updatedAt
- PUT /v1/admin/login-policy → set the policy (step-up; audits login_policy_changed with an old→new summary)
- GET /v1/auth/login-policy (public profile) → the sign-in UI reads the now-current policy (scenario 7)
/v1/admin/login-policyadminRead login policyRole 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).
GET https://api.kerja.team/v1/admin/login-policyResponse 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.
dataobjectalwaysdata.allowedMethods[]arrayalwaysemail— Sign-in with an email identifier is open (and the device-resolved subject when the account's loginMode is email).phone— Both phone doors are open: phone + password, and the password-less one-time-code login.
data.updatedAtISO-8601 | nullalways/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.
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 chooseemail— Permits 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).phone— Permits 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 choosestepUp.passwordbodystringconditionalyou choosestepUp.recoveryCodebodystringconditionalauthenticatorPUT https://api.kerja.team/v1/admin/login-policyResponse 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.
dataobjectalwaysdata.allowedMethods[]arrayalwaysemail— Email sign-in remains/becomes open.phone— Phone sign-in remains/becomes open — both the password and one-time-code doors.
data.updatedAtISO-8601always/v1/auth/login-policypublicPublic sign-in reads the policyThe 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-policyResponse 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.
dataobjectalwaysdata.allowedMethods[]arrayalwaysemail— Offer the email + password form (scenario 9).phone— Offer 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.