Skip to content

Sign in with username and password

POST
/auth/login

Verifies credentials against the RS user table using the same HMAC-then-bcrypt hashing scheme RS uses (so PHP-created users log in unchanged). On success: writes a new session token to user.session and sets the rs_session cookie.

object
username
required
string
Example
admin
password
required
string format: password
provider

Name of the identity provider to authenticate against. Defaults to “password” (the built-in flow). Enterprise installs can name a registered LDAP provider (“ldap” or a per-server alias like “ldap-engineering”). Requests naming an unregistered provider get 401 — same shape as a bad password, so a probing client can’t enumerate which enterprise providers are active.

string
Example
password
totp_code

TOTP code (6 digits) or recovery code (10 chars + optional hyphen) for users with 2FA confirmed. When 2FA is enabled and this field is missing or wrong the server returns 401 with error: "2fa_required" (no totp_code on the request) or error: "invalid_2fa_code" (wrong code) so the frontend can re-prompt.

string

Authenticated. Returns the current user; an rs_session cookie is set in the response headers.

object
ref
required
integer format: int64
username
required
string
fullname
string
nullable
email
string
nullable
usergroup
integer format: int64
nullable
auth_method
required

How the caller was authenticated for this request.

string
Allowed values: session token
language

User’s language pref (BCP47). Empty string = follow system / browser. Joined from user_profiles at /auth/me time so the frontend can hydrate the language store on first paint without a separate round-trip.

string
theme

User’s theme pref. system follows the OS; empty string means the account has NO stored preference and the device falls back to the app default (dark, #590). The two are deliberately distinct — collapsing them made an explicit “follow my OS” indistinguishable from “never chose”, so it could not travel to a second device (#677). Same “join on /auth/me” rationale as language.

string
Allowed values: "" light dark system
default_views

The account’s default-view selections, joined from user_preferences at /auth/me time (#706).

Same rationale as language and theme, and the same timing guarantee, which is the point: the root layout’s load awaits /auth/me before any page renders, so the browse store can consult these synchronously in its init() rather than seeding itself from a second round-trip that lands after the grid has already painted.

object
home_tab

Which feed segment browse lands on. Empty/unset falls back to latest. trending and for_you were removed in #736: neither existed in the feed enum, so choosing one put the plain latest feed under a label promising otherwise. They come back when a ranking model does.

string
Allowed values: "" latest following
browse_layout

Default browse-feed layout. Empty/unset falls back to the per-device default (feed on a coarse pointer, grid otherwise). feed is selectable here as of #706 — it was already a mode a phone could land on but not one a user could ask for.

string
Allowed values: "" grid masonry thumbnail list feed
browse_sort

Default browse ordering: newest (posted_at descending, the built-in) or oldest. Empty/unset falls back to newest. popular and trending were removed in #706 — GET /posts has no ranking parameter and no ranking model, so both were labels over the default order.

string
Allowed values: "" newest oldest
feed_filters

The account’s browse-feed content filters (#891), joined from user_preferences at /auth/me time.

The filtering itself is the SERVER’s — GET /posts reads this preference and applies it, so no client can forget to and no client can turn it off. What rides the session is the fact that it is ON, because a feed that is quietly shorter than it would otherwise be has to say so on the same paint that renders it. Learning it from a second round-trip means the browse page renders a thinner grid with no explanation and then pops a banner in afterwards — the same first-paint race #867 and #872 closed for default_views, theme and capabilities, arriving at the same surface.

OMITTED when every filter is off, which is every account that has not opted in — so this changes no existing session response.

object
show_restricted

Keep the restricted-member placeholders (#883) in GET /posts instead of subtracting them.

Default false — the feed hides them (#921). #891 shipped this machinery as an opt-in on the theory that the placeholder is the more informative answer. Measured against the stock seed dataset, a reader with no capabilities got 82 posts of which 27 were entirely placeholders: a third of the grid was locked doors. The principle the default now encodes is that a placeholder belongs where the reader ASKED A QUESTION or OPENED A CONTAINER, not where they were handed a feed.

With the default (false), three rules, and the third is the one that matters:

  1. A member the caller cannot read is OMITTED from members rather than sent as a restricted: true placeholder.
  2. A post left with NO visible members — one that HAD members and none survived — is omitted from the page. A post with no members at all (an article, ADR 0073) is unaffected: it was never showing you anything you couldn’t see.
  3. A post the CALLER AUTHORED is never omitted, however restricted its members are. An author’s post can carry other people’s assets, so rule 2 alone would make your own work vanish from your own feed over a display default.

Set it true and GET /posts returns the pre-#921 payload exactly: every placeholder present, every all-restricted post back in the page.

Applies to GET /posts and stops there. GET /posts/{id} is an explicit request for one post and answers with the placeholders intact whatever this is set to, which is not an inconsistency: rule 2 exists BECAUSE an all-restricted post rendering as an empty card is worse than the placeholder it replaced, and filtering members on the single-post read would rebuild that empty card on the one surface rule 2 could not protect. Collection contents render placeholders for the same reason — an opened container should say what is in it.

It also keeps the ask alive. #913’s “Request access” button lives on the placeholder, so it survives wherever the placeholder does: the post page and collections. The trade the DEFAULT makes — no placeholders in the feed means no Request access in the feed — is stated in the setting’s own help text rather than left to be discovered.

boolean
browse_rail

The account’s browse-rail curation — team chips (#1113) and followed-tag chips (#1123) — joined from user_preferences at /auth/me time.

Here rather than behind a second fetch because these lists decide WHAT THE RAIL DRAWS. Learning them after first paint means the rail paints the uncurated list and then rearranges itself in front of the reader — the layout shift the rail’s single loaded gate exists to avoid, reintroduced one level up. Same first-paint argument as default_views (#706).

OMITTED when every list is empty, which is every account that has not curated its rail — so this changes no existing session response.

object
hidden_team_ids

Teams the reader removed from their rail. Empty = nothing hidden.

A hide-list rather than a show-list, deliberately: an empty allow-list would have to mean “show everything”, which is the opposite of what an empty allow-list says, and the first partial write would blank the rail.

Array<string>
team_order

The reader’s explicit ordering, applied to the FOLLOWED group (the manage panel’s drag-reorder). Empty = the server’s order.

PARTIAL LISTS ARE LEGAL and are the normal case: the ids named here lead, in this order, and everything else keeps its previous relative position behind them. That is what lets “drag one team to the top” persist one id rather than a full snapshot that the next follow would immediately make stale.

Array<string>
hidden_tags

Followed tags whose chip the reader removed from their rail (#1123). Empty = nothing hidden.

HIDING IS NOT UNFOLLOWING, and the two verbs sit next to each other in the manage panel precisely so the difference is visible. Unfollowing drops the tag_follows row and changes what the Following feed CONTAINS. Hiding only takes the chip off the strip; the tag’s posts keep reaching the feed exactly as before.

Array<string>
tag_order

The reader’s explicit ordering of their followed-tag chips. Empty = the server’s order (most recently followed first).

Partial lists are legal and normal, exactly as team_order.

Array<string>
mature_content

The account’s mature-content consent, joined from user_preferences at /auth/me time (#1115).

Here rather than behind a second fetch for the same first-paint reason as feed_filters: it decides what the feed CONTAINS, and a page that learns it after paint renders one library and then swaps it. #706 is the precedent.

⚠️ IT IS ONE OF THREE CONJUNCTS, NOT THE ANSWER. The reader also has to be signed in (they are, if they are reading this) and the INSTANCE has to allow mature content — that third conjunct is mature_content_allowed below. A client rendering “mature content is on for you” from THIS field alone will be wrong on an instance that has switched the feature off.

OMITTED when the opt-in is off, which is every account that has not opted in — so this changes no existing session response.

object
show

Opt in to being shown mature content. Absent or false — the default for every account — hides it.

boolean
mature_content_allowed
required

Whether this INSTANCE allows mature content at all (#1116, ADR 0090 §2) — the operator’s switch, not this account’s.

⚠️ REQUIRED, unlike mature_content above, and the asymmetry is the point. mature_content may be omitted because absent and false mean the same thing there (not opted in). Here they do NOT: the unconfigured default is true, so a client that read an absent field as false would hide the opt-in control on every install that has never touched the setting — which is all of them. Declared required so the guess is impossible to need: every producer of this schema must answer, and the compiler says so.

It rides the session response rather than getting a public endpoint because every consumer is behind authentication: the upload self-label, the operator’s per-asset override, and the account opt-in are all signed-in surfaces, and an anonymous viewer can never opt in (there is nowhere to store the answer). The operator’s read/write pair is /admin/system/mature-content.

What a client does with it: when false, the account’s mature opt-in control is not rendered AT ALL — not rendered disabled — because a toggle the server will not honour is a control that lies. Server-side nothing depends on the client obeying: the same switch is ANDed into the qualification predicate on every request.

boolean
capabilities

The caller’s resolved GLOBAL capability codes — the same set GET /auth/me/capabilities reports in its capabilities field, after role inheritance, per-user grants and revokes have all been applied. Team-scoped capabilities are NOT included; ask the scoped endpoints for those.

Rides the session response for the same reason theme and default_views do, and for a sharper one (#871): the client marks its auth store “ready” the instant this response lands, and every capability-gated surface reads the store at that moment. A capability set that arrives on a SECOND round-trip therefore arrives after the gate has already decided, which rendered a “you don’t have permission” panel at real admins until the follow-up landed. One response, one decision, no window.

OMITTED when capabilities_status is unavailable. Read that field first: this one is only meaningful when the lookup succeeded.

Array<string>
capabilities_status
required

Whether capabilities above could be determined for this session. THREE outcomes, not two, and the difference between the last two is the whole point of this field (#956):

  • resolved + a non-empty list — the caller holds those codes.
  • resolved + an empty list — the lookup succeeded and the account genuinely holds nothing.
  • unavailable — the lookup FAILED. capabilities is omitted and says nothing whatsoever about what this account holds.

Before this field existed, the second and third collapsed into one wire shape, and the SPA rendered “You don’t have permission to view this page.” for both. That panel is correct for a powerless account and a lie for a resolver blip, and neither the operator reading it nor a test asserting on it could tell which had happened — which is exactly how #956’s nightly failure survived four triage passes.

Clients MUST fail closed on unavailable: grant nothing, render no gated control. What they MUST NOT do is describe it as a permission decision. It is the absence of one, and the honest surface is an error with a retry.

This is a status, not a permission: unavailable never widens what a caller may do, and every action is enforced server-side against a freshly resolved set regardless of what any session response said.

Required, and deliberately so. Making it optional and treating absent as “unknown” was the smaller diff and the wrong one: capabilities is ALREADY legitimately absent from POST /setup/complete, whose response omits it while the admin’s role is committed. Overloading absence would have made that response assert a degraded state that is not true, and would have let any future producer that forgets a field silently emit an error state on a healthy session. A required enum makes every producer answer the question.

string
Allowed values: resolved unavailable
impersonated_by

Non-null when the session was minted via POST /admin/users/{ref}/impersonate. Carries the acting admin’s ref + username so the persistent “you are acting as @target” banner can render without a separate round-trip.

object
ref
required
integer format: int64
username
required
string

Authentication required, missing, or invalid

object
error
required

Human-readable error summary

string
Example
the request could not be completed
Example
{
"error": "authentication required: sign in and retry with a valid session or API token"
}

Unexpected server error

object
error
required

Human-readable error summary

string
Example
the request could not be completed

Identity provider was registered (license includes its feature flag) but the credential-verification impl has not yet landed in this binary. Distinct from 401 so the admin UI can render “build pending” vs “wrong password”.

object
error
required

Human-readable error summary

string
Example
the request could not be completed