Signing in when you belong to no organization
Signing inA complete account — a verified credential and a password — that holds NO membership of any kind can authenticate, yet has nowhere for a session to live; typically a member removed from their only organization. Sign-in answers a purpose-scoped bootstrapToken instead of tokens, and the caller founds their own organization with it. Routed here by the BODY of the sign-in answer (noOrganization: true), not by a setting. The signed-in counterpart is scenario 24. To reach the state: remove a member from their only tenant (scenario 55), then sign in as them.
- Optional: POST /v1/auth/registration/resume { email } or { phone } → state: awaiting_tenant, nextStep: POST /v1/auth/login — how a client that remembers only the identifier knows to show “Create your organization” rather than a login error
- POST /v1/auth/login → 200 { noOrganization: true, bootstrapToken, expiresIn } — no session, no refresh or device cookie, no access token. The same body ends every other credential sign-in (challenge/verify, mfa/verify, phone/otp/verify) when the chain clears and the account belongs nowhere
- POST /v1/auth/organizations/bootstrap { bootstrapToken, name } → 200 accessToken + refresh cookie, scoped to the organization just founded; the caller is its admin (amr: [bootstrap])
/v1/auth/registration/resumepublicOptional — confirm the state is awaiting_tenantstate: awaiting_tenant means the user is active, everything is proven, and there is NO membership of any kind. Its nextStep is still POST /v1/auth/login — and that is not a leftover: resume never hands back a credential, so it cannot mint the bootstrapToken itself; logging in genuinely is the next step. The STATE is what tells the UI to render “Create your organization”. Deliberately narrower than “no ACTIVE membership”: a user whose only membership is suspended still reports complete, and login reports the real reason.
Field guide — what each value means & where it comes from2 fields
Public, throttled per IP. Submit EXACTLY ONE of email / phone — the identifier the user started with is all they have to remember — and the resolver answers { state, method, maskedDestination, nextStep, canResend, expiresAt } with the step to render. It never returns a credential: no token, no code, no password reset. The state is DERIVED from facts that already exist (status, password hash, *_verified_at, live tokens, memberships), with precedence: a finished account outranks a stale token, a pending invitation outranks a half-finished self sign-up, an active membership outranks an invited one. SEVEN states: not_started (no user with that identifier → the sign-up form) · awaiting_phone_otp (phone, no user, a live register OTP → the OTP screen with resend) · awaiting_password (user, phone verified, no password, no membership → “finish setting up — choose a password”; the phone is re-challenged before a registrationToken is re-issued) · awaiting_email_code (email unverified — reported even when the code has LAPSED, with canResend: true) · awaiting_invitation (a membership is invited → “you have a pending invitation”) · awaiting_tenant (user active, everything proven, NO membership of any kind → “create your organization”, §136 / scenario 15 — its nextStep is still POST /v1/auth/login, because resume cannot mint the bootstrapToken and login answers noOrganization with it; this used to report complete and send the user to a login that answered 403) · complete (active, ≥1 active membership → “log in”; also a user whose only membership is SUSPENDED — login then reports the real reason). Deliberately an enumeration oracle (the same posture register's 409 email_already_registered already takes); CAPTCHA/bot defence remains a production prerequisite for this surface.
emailbodystringconditionalyou choosephonebodystringconditionalyou choosePOST https://api.kerja.team/v1/auth/registration/resumeResponse guide — what comes back & what each value means6 fields
200 with the resolver's answer. It tells you WHERE YOU ARE — never a token, a code, or a password reset. The state is DERIVED from facts that already exist (users.status, password_hash, the *_verified_at columns, live tokens, memberships), never from a duplicate state table which would be a second truth and the first thing to drift.
data.stateenumalwaysnot_started— No identity holds that identifier — show the sign-up form (POST /v1/auth/register, or POST /v1/auth/phone/otp/request).awaiting_phone_otp— A live register OTP exists but no identity yet — show the OTP screen with resend; continue at POST /v1/auth/registration/phone/verify.awaiting_password— The phone is proven but no password was chosen and no membership exists (scenario 3 step 3 pending) — “Finish setting up, choose a password”. Continuing costs a FRESH OTP first: the original registrationToken is short-lived by design and the flow may resume days later on another machine.awaiting_email_code— The email is not yet verified (scenario 2 step 2 pending) — show the code screen + resend. Reported even when the code has EXPIRED, with canResend: true: “your code lapsed, get another” is the right screen, where “nothing in flight” would strand the user.awaiting_invitation— An invited membership is waiting — continue at POST /v1/auth/invitations/accept.awaiting_tenant— Active account, everything proven, but NO membership of any kind — typically a member removed from their only organization (§136, scenario 15). Render “Create your organization”. nextStep is still POST /v1/auth/login, and deliberately: resume never hands back a credential, and login is what answers noOrganization with the bootstrapToken for POST /v1/auth/organizations/bootstrap. (This state used to report complete, which sent the user to a login that answered 403.)complete— Active account with at least one active membership — “your account is ready, log in”. Also reported for a user whose only membership is SUSPENDED; login then gives the real reason.
data.methodenumalwaysemail— The outstanding step is on the email channel.phone— The outstanding step is on the phone channel.
data.maskedDestinationstringconditionaldata.nextStepstringalwaysdata.canResendbooleanalwaysdata.expiresAttimestampconditional/v1/auth/loginpublicLog in (identifier + password)The credential is checked exactly as always; what differs is the answer. 200 { noOrganization: true, bootstrapToken, expiresIn } — READ THE BODY: branch on accessToken / challengeToken / mfaRequired / noOrganization / invitationPending before assuming the caller is signed in. Nothing session-shaped is minted — no session row, no refresh or device cookie, no SSO pointer, no access token, no tenant_id claim anywhere. The token opens exactly ONE door (token_use: bootstrap, BOOTSTRAP_TOKEN_TTL_SECONDS default 15 min) and is refused wherever an access token is expected — /internal/validate and POST /v1/auth/registration/complete included. NOT the same state as a pending invitation: when your only seat is an unaccepted invitation you get invitationPending + acceptToken instead (scenario 52), and the two tokens are not interchangeable. A caller whose only membership is SUSPENDED gets the real 403, not this.
Field guide — what each value means & where it comes from4 fields
Public, unauthenticated login. No Authorization header; send only the JSON body below. Throttled per credential/IP; response is no-store. A 200 is a BODY TO READ, not a status to trust — five shapes share it: tokens, a challenge chain (scenario 11–13), the legacy MFA challenge, { noOrganization, bootstrapToken } when the credential is good but you belong nowhere (scenario 15 — paste the token into POST /v1/auth/organizations/bootstrap), and { invitationPending, acceptToken, invitations[] } when your only seats are unaccepted invitations (scenario 52 — paste the token into POST /v1/auth/invitations/accept). The last two mint nothing session-shaped. Which one you get is NOT checkable beforehand (there is no pre-login MFA-status endpoint, by anti-enumeration design). Before the credential is examined at all, the active LOGIN policy is checked: if the channel this attempt uses is off, the answer is 403 login_method_disabled for a right and a wrong password alike (which discloses nothing — GET /v1/auth/login-policy publishes the same fact unauthenticated). Read that policy first in scenario 7.
identifierbodystringconditionalyou chooseemailbodystringconditionalyou chooseclientIdbodystringoptionalyou choosepasswordbodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/loginResponse guide — what comes back & what each value means23 fields
200 OK, { data } envelope, Cache-Control: no-store. A 200 here is a response you must READ, not a status you may trust: FIVE outcomes share it, and the login response itself is the only way to learn which applies (there is no pre-login MFA-status endpoint, by anti-enumeration design): (1) tokens — nothing more to prove; (2) a CHALLENGE CHAIN { challengeToken, challenge, remaining } when a device and/or MFA hop is pending, continued at POST /v1/auth/login/challenge/verify; (3) the LEGACY single-hop { mfaRequired, method, phoneHint, mfaToken } when DEVICE_TRUST_ENABLED is off; (4) NO ORGANIZATION { noOrganization: true, bootstrapToken, expiresIn } — the credential is good but the caller belongs nowhere (§136, scenario 15), continue at POST /v1/auth/organizations/bootstrap; (5) INVITATION PENDING { invitationPending: true, acceptToken, expiresIn, invitations[] } — every membership the caller holds is an unaccepted invitation (§139, scenario 52), continue at POST /v1/auth/invitations/accept. Branches 4 and 5 mint NOTHING session-shaped: no session row, no refresh or device cookie, no access token, no tenant_id claim. Only the token branch sets Set-Cookie: refresh_token. A caller whose only membership is suspended (or a mix of suspended and invited) is offered neither sessionless branch and still gets 403.
data.accessTokenstringconditionaldata.tokenTypestringconditionalBearer— Send the accessToken as 'Authorization: Bearer <accessToken>'.
data.expiresInnumberconditionaldata.user.idstringconditionaldata.user.emailstringconditionaldata.user.displayNamestringconditionaldata.user.tenantIdstringconditionaldata.challengeTokenstringconditionaldata.challenge.typeenumconditionaldevice_email_code— New browser, email-mode account (LN2) — a code was emailed. Continue at scenario 11.device_phone_otp— New browser, phone-mode account (LN1) — an OTP went out on the account's primary channel.mfa_phone_otp— An enrolled phone-OTP second factor (LE3) — the OTP is sent as the challenge is issued. A recognised device never skips this hop.mfa_totp— An enrolled authenticator app — nothing is sent; the code comes from the app, so no resend applies.
data.challenge.hintstringconditionaldata.challenge.expiresInnumberconditionaldata.remainingnumberconditionaldata.methodenumconditionalphone_otp— Send/resend the OTP with POST /v1/auth/mfa/otp/request, then verify. phone_otp is the method advertised when both are confirmed.totp— Read the code from the authenticator app — there is nothing to send, so skip the otp/request step.
data.phoneHintstringconditionaldata.mfaRequiredbooleanconditionaltrue— MFA challenge pending; proceed to /v1/auth/mfa/verify carrying mfaToken. No access token or refresh cookie issued yet.
data.mfaTokenstringconditionaldata.noOrganizationbooleanconditionaltrue— Carry bootstrapToken to POST /v1/auth/organizations/bootstrap to found an organization and receive the session there.
data.bootstrapTokenstringconditionaldata.invitationPendingbooleanconditionaltrue— Carry acceptToken to POST /v1/auth/invitations/accept (with the invitation link or code) — that call activates the seat and ends in a session.
data.acceptTokenstringconditionaldata.invitationsarrayconditionaldata.invitations[].tenantIdUUIDconditionaldata.invitations[].tenantNamestringconditional/v1/auth/organizations/bootstrappublicFound your organization with the bootstrap tokenPublic BY NECESSITY — the caller has no session, which is the defect; the bootstrapToken is what authenticates them, and the subject comes from the verified token, never the body. 200 with the ordinary token shape plus the refresh_token cookie (the accessToken is auto-captured), the session issued INTO the organization just founded — named explicitly rather than resolved as a default, so a concurrent invitation acceptance cannot land you somewhere you did not ask for. amr: [bootstrap]. CAPPED at one live founded organization per identity → 409 organization_already_owned; that same cap is what stops the token being replayed into a second organization inside its TTL (there is no single-use marker on the token). Once you belong somewhere, login answers normally and this answers 409. NO DEVICE TRUST: the body takes no trustDevice (.strict() refuses one) — the next ordinary login earns it. 401 invalid_credentials for a missing / expired / wrong-purpose token; 422 for a missing or over-200-char name or an unknown key; 429 over the per-user organization-creation cap.
Field guide — what each value means & where it comes from2 fields
The way out for a caller who belongs to NO organization (§136) — typically a member removed from their only one, who can authenticate but has nowhere for a session to live. PUBLIC BY NECESSITY: the caller has no session, which is the defect; the bootstrapToken authenticates them and the subject comes from the verified token, never the body. No step-up. 200 with the ordinary token shape plus the refresh_token cookie — the session is issued INTO the organization just founded (named explicitly, so a concurrent invitation acceptance cannot land you elsewhere), you are its admin, and amr is [bootstrap]. Capped exactly as POST /v1/me/organizations is — one live founded organization per identity → 409 organization_already_owned, which also stops the token being replayed into a second organization inside its TTL (there is no single-use marker). Once you belong somewhere, login answers normally and this answers 409. NO DEVICE TRUST: the body takes no trustDevice (.strict() refuses one); the next ordinary login earns it. 429 over the per-user organization-creation cap. Walk-through: scenario 15.
bootstrapTokenbodystringrequiredfrom a responsenamebodystringrequiredyou choosePOST https://api.kerja.team/v1/auth/organizations/bootstrapResponse guide — what comes back & what each value means6 fields
200 OK, { data } envelope — the ordinary token shape plus Set-Cookie: refresh_token (httpOnly). The way out for a caller who belongs to no organization (scenario 15): the bootstrapToken from the noOrganization login outcome founds their organization and the session is issued INTO it, named explicitly rather than resolved as a default. amr is [bootstrap] — not a new credential class, just an honest trail (the token was minted from a verified password or OTP). NO device trust and no device_token cookie (the next ordinary login earns it). Capped like POST /v1/me/organizations: 409 organization_already_owned once the caller has founded a live organization, which is also what stops a replay of the token. 401 invalid_credentials for a missing, expired, wrong-purpose or unknown-user token.
data.accessTokenstring (JWT)alwaysdata.tokenTypeenumalwaysBearer— Send as Authorization: Bearer <accessToken>.
data.expiresInnumberalwaysdata.user.idUUIDalwaysdata.user.tenantIdUUIDalwaysSet-Cookie: refresh_tokencookiealways