neosourceDocs
Search docs

Log in with username and password

POST/api/login

loginWithPassword

Validates local credentials. Returns either a session cookie (when no TOTP is configured) or a 200 `LoginStep::TotpChallenge` body the caller must satisfy via `/api/auth/login/challenge/totp`.

Authentication is not described for this operation in the spec — that does not mean it is public. Check tokens and scopes.

curl

curl -X POST 'https://neosource.dev/api/login' \
  -H 'Content-Type: application/json' \
  -d '{"handle":"dev","password":"dev"}'

fetch

fetch("https://neosource.dev/api/login", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"handle":"dev","password":"dev"}),
});

Request bodyrequired

application/json

PasswordLoginRequest

object

handlerequired

string

example"dev"

passwordrequired

string

example"dev"

additional propertiesnot allowed

Responses

200Secondary factor required

application/json

LoginStep

Reply shape for both `POST /api/login` and `POST /api/signup`. The route may either issue a session straight away or hand back a secondary challenge / pivot signal that the SPA has to handle.

one of
  • object

    Login (or signup) complete; the response also set the session cookie.

    kindrequired

    string

    "session"

    userrequired

    UserResponse

    object

    commit_email_privaterequired

    boolean

    display_namerequired

    string

    email

    string | null

    email_verified_at

    integer | nullint64

    Millis-since-epoch when the email was provably verified (OAuth `email_verified=true` at link time, or the email- verification flow in `plans/auth-flows.md` §"Email verification"). NULL = unverified — SPA renders a banner.

    handlerequired

    string

    linked_providers

    array

    OAuth provider slugs (e.g. `"github"`) linked to this account. Populated only on the self endpoint (`/api/user/me`) so the SPA knows link state at first paint (onboarding cards, settings); public profile responses omit it — another user's linked providers are not public information.

    items

    string

    password_changed_at

    integer | nullint64

    Millis-since-epoch when the password hash was last set or rotated. `None` for OAuth-only accounts that have never set a password. Drives the "Password last changed N days ago" copy on `/settings/security` (plans/auth-flows.md slice #4).

    totp_enabledrequired

    boolean

    user_idrequired

    string

    workspaces

    array | null

    Every workspace this account belongs to — personal first-class, orgs included — with the viewer's role in each. Populated only on the **self** surfaces (`/api/user/me` and the session-creating responses); the public profile (`/api/users/{handle}`) omits it, since another user's membership is not public. Also absent for a resource-narrowed token (`TokenGrant::permits_account` false) — a workspace-locked token must not enumerate every workspace the owning account can reach. It rides the viewer payload rather than sitting behind `/api/orgs` because the SPA's workspace switcher cannot paint without it: which workspace the URL's `:owner` denotes is a *membership* question, and answering it one round trip after the viewer resolves is what made the chip commit to the personal workspace and then flip to the org (docs/frontend-design.md §"Ride the boot path", issue #15). `Option`, not a bare `Vec`: absent ("this response does not carry membership" → the client falls back to `/api/orgs`) and `[]` ("confirmed: no workspaces") must stay distinguishable. Collapsing them is exactly what would make the client paint the personal workspace for an org member. See plans/viewer-workspace-membership.md §4.

  • object

    Password verified, but the account has TOTP. Send the code via `POST /api/auth/login/challenge/totp`.

    challenge_idrequired

    string

    factorrequired

    LoginFactor

    string

    "totp"

    kindrequired

    string

    "totp_challenge"

  • object

    Signup-only: the handle is taken on GitHub. SPA pivots to the claim-by-proof flow with `start_oauth(intent=claim_handle)`.

    kindrequired

    string

    "requires_github_proof"

    providerrequired

    string

    provider_loginrequired

    string

  • object

    OAuth-only: the returned email already exists on a neosource account. SPA shows "log in with that account first, then link".

    existing_account_idrequired

    string

    kindrequired

    string

    "requires_linking"

    providerrequired

    string

201Session created and cookie set

application/json

SessionResponse

object

userrequired

UserResponse

object

commit_email_privaterequired

boolean

display_namerequired

string

email

string | null

email_verified_at

integer | nullint64

Millis-since-epoch when the email was provably verified (OAuth `email_verified=true` at link time, or the email- verification flow in `plans/auth-flows.md` §"Email verification"). NULL = unverified — SPA renders a banner.

handlerequired

string

linked_providers

array

OAuth provider slugs (e.g. `"github"`) linked to this account. Populated only on the self endpoint (`/api/user/me`) so the SPA knows link state at first paint (onboarding cards, settings); public profile responses omit it — another user's linked providers are not public information.

items

string

password_changed_at

integer | nullint64

Millis-since-epoch when the password hash was last set or rotated. `None` for OAuth-only accounts that have never set a password. Drives the "Password last changed N days ago" copy on `/settings/security` (plans/auth-flows.md slice #4).

totp_enabledrequired

boolean

user_idrequired

string

workspaces

array | null

Every workspace this account belongs to — personal first-class, orgs included — with the viewer's role in each. Populated only on the **self** surfaces (`/api/user/me` and the session-creating responses); the public profile (`/api/users/{handle}`) omits it, since another user's membership is not public. Also absent for a resource-narrowed token (`TokenGrant::permits_account` false) — a workspace-locked token must not enumerate every workspace the owning account can reach. It rides the viewer payload rather than sitting behind `/api/orgs` because the SPA's workspace switcher cannot paint without it: which workspace the URL's `:owner` denotes is a *membership* question, and answering it one round trip after the viewer resolves is what made the chip commit to the personal workspace and then flip to the org (docs/frontend-design.md §"Ride the boot path", issue #15). `Option`, not a bare `Vec`: absent ("this response does not carry membership" → the client falls back to `/api/orgs`) and `[]` ("confirmed: no workspaces") must stay distinguishable. Collapsing them is exactly what would make the client paint the personal workspace for an org member. See plans/viewer-workspace-membership.md §4.

Standard errors

Bodies documented once for the whole API — see standard errors.

  • 400Invalid request body
  • 401Invalid credentials
  • 429Rate limited — retry after the interval in the `Retry-After` header
  • 500Internal server error
  • 503Service temporarily unavailable / at capacity — retry after the `Retry-After` header
  • 504Gateway timeout — the request exceeded the server's handling budget

Schemas

Referenced above. Listed here rather than expanded inline, so the same definition is not repeated at every level.

ViewerWorkspace

object

One workspace the viewer belongs to, in the shape the SPA's workspace switcher needs at first paint. Deliberately leaner than [`OrgResponse`]: no `description` / `created_at` / `updated_at` — this rides `/api/user/me`, which every authenticated page fetches, so it pays for bytes on the app's hottest request. `/api/orgs` keeps the full shape for CLI/API consumers.

default_team_id

string | null

The default team's id, beside its key — because the key addresses a URL while every team-scoped read is id-keyed. The rail's tier-0 Cycles row labels itself from `GET /api/teams/{id}/cycles`, and without this it would have to resolve the key first: two hops for a label, on the page this field exists to make paint in one. See [`ViewerWorkspace::team_count`] — same UX-hint status.

default_team_key

string | null

See [`ViewerWorkspace::team_count`].

display_namerequired

string

kindrequired

WorkspaceKind

string

The kind of workspace (namespace container).

"personal""organization"

slugrequired

string

team_countrequired

integerint32

How many teams this workspace has, and the `key` of its default team. These two exist so ADR 0077 §3's team-disclosure tier is known at FIRST PAINT. The tier is `team_count >= 2`, and every surface that has to know — the rail's Teams group, its tier-0 Board/Cycles rows, the global New ticket page's picker — used to learn it from a second round trip (`/api/workspaces/{slug}/teams`) or, worse, from a remembered count in localStorage that could be a different account's. Riding this request is rung 1 of docs/frontend-design.md's loading ladder: `/api/user/me` is fetched by every authenticated page and prefetched at HTML-parse time, so the tier arrives in the frame the rail first paints. A **UX hint only**, exactly like `viewer_role` above: what a viewer may see of a team stays server-side in `PolicyAuthzService`. A member of the workspace who is not on the team still gets the rows, and the surfaces behind them render their own not-a-member state (ADR 0077 §6). `default_team_key` is `None` when the workspace has no team at all, and also for a legacy workspace whose teams predate default-provisioning.

viewer_rolerequired

WorkspaceRole

string

Workspace membership role. Ordered low → high (`Member < Admin < Owner`) so that `if actor_role >= WorkspaceRole::Admin` reads naturally.

"member""admin""owner"

workspace_idrequired

string