neosourceDocs
Search docs

Poll an approved CLI device session

POST/api/cli/login/exchange

exchangeCliLoginSession

Returns pending until browser approval. Login sessions issue the scoped CLI credential; SSH-enrollment sessions return the approved key without minting a PAT.

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/cli/login/exchange' \
  -H 'Content-Type: application/json' \
  -d '{"session_id":"string","device_code":"string"}'

fetch

fetch("https://neosource.dev/api/cli/login/exchange", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({"session_id":"string","device_code":"string"}),
});

Request bodyrequired

application/json

ExchangeCliLoginSessionRequest

object

device_coderequired

string

session_idrequired

string

additional propertiesnot allowed

Responses

200Pending approval, or the issued CLI credential

application/json

ExchangeCliLoginSessionResponse

Poll result. `pending` means keep polling, `complete` carries the issued scoped PAT (and optionally its SSH binding), and `ssh_enrollment` carries the approved key without minting a PAT. The `ssh_key_id` field on `complete` is additive so older clients can keep deserializing the response while newer clients can verify the key binding they requested.

one of
  • object

    statusrequired

    string

    "pending"

  • object

    machine_namerequired

    string

    raw_tokenrequired

    string

    ssh_key_id

    string | null

    statusrequired

    string

    "complete"

    token_idrequired

    string

    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

    machine_namerequired

    string

    ssh_key_idrequired

    string

    statusrequired

    string

    "ssh_enrollment"

    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.

  • 400Bad Request — one of: invalid_input
  • 404Not Found — one of: not_found
  • 409Conflict — one of: already_exists, conflict
  • 429Rate limited — retry after 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