App user authentication
Covers 14 API operations in the app-auth category.
| Method | Path | Description |
|---|---|---|
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth | End-user auth status (runlot auth) |
| POST | /v1/orgs/{orgSlug}/projects/{projectName}/auth | Turns login back on after it was turned off |
| PATCH | /v1/orgs/{orgSlug}/projects/{projectName}/auth | Change settings (runlot auth set) |
| DELETE | /v1/orgs/{orgSlug}/projects/{projectName}/auth | Disables auth (runlot auth delete) |
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth/schema | The runlot_auth schema DDL |
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding | The sign-in page branding, defaults filled |
| PUT | /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding | Replace the sign-in page branding |
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth/funnel | Sign-in funnel counters |
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding/preview | Render the chooser with draft branding |
| POST | /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding/preview | A ten-minute preview ticket |
| PUT | /v1/orgs/{orgSlug}/projects/{projectName}/auth/providers/{provider} | Register or replace a social provider (runlot auth provider set) |
| DELETE | /v1/orgs/{orgSlug}/projects/{projectName}/auth/providers/{provider} | Disconnect a login provider (runlot auth provider rm) |
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth/users | The app's end users (runlot auth users) |
| GET | /v1/orgs/{orgSlug}/projects/{projectName}/auth/users/{userId} | One end user |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth
Whether end-user auth is granted and configured, which login providers are registered, and the host to point provider callbacks at.
The end-user table is not part of this API. User records live in the runlot_auth
schema of the project's database; read them through the worker's env.auth.users.* or
with runlot pg shell.
providers carries only the client id. Client secrets are stored encrypted and never
appear in any response.
Requires viewer or higher.
operationId getAuth
| Status code | Description | Response body |
|---|---|---|
| 200 | Auth status | AuthStatus |
| 403 | — | — |
| 404 | — | — |
| 503 | no_core — the service is temporarily unavailable | Error |
POST /v1/orgs/{orgSlug}/projects/{projectName}/auth
This is idempotent — calling it twice leaves the settings and login providers unchanged. Auth is either on or off; there is no "on but disabled" state.
A database is required. Without one this returns 409 database_required, because
the end-user tables live inside the project's database. Every project has one unless it
was deleted, so you will see this only after a database delete.
Once enabled, the built-in login endpoints start serving shortly afterwards. As with
env.db and env.storage, no redeploy is needed. Table initialization is idempotent,
so enabling twice performs it only once.
There is no request body. Settings start at their defaults; change them with PATCH.
Requires member or higher. Audited as auth.grant.
operationId grantAuth
| Status code | Description | Response body |
|---|---|---|
| 200 | Grant status (existing value if it was already there) | AuthStatus |
| 403 | — | — |
| 404 | — | — |
| 409 | database_required — this project's database was deleted. Create a new one with POST …/database (runlot pg on). | Error |
| 503 | no_core — the service is temporarily unavailable | Error |
PATCH /v1/orgs/{orgSlug}/projects/{projectName}/auth
This is a partial update. Only the fields you send change, and the rest stay as they are. Sending every field on each save can overwrite a change another member made moments earlier.
Returns 404 not_granted if auth has not been turned on for this project — settings
cannot be saved ahead of the grant.
sessionDays also sets how long an end user's session is kept.
password and magicLink turn the email-and-password and email-link methods on or
off. A change that would leave no sign-in method enabled is refused with 409
last_method; turn login off instead.
Requires member or higher. Audited as auth.settings.
operationId updateAuthSettings
Request body: application/json · AuthSettingsPatch
| Status code | Description | Response body |
|---|---|---|
| 200 | The status that changed | AuthStatus |
| 400 | — | — |
| 403 | — | — |
| 404 | not_granted — this project has no auth. When the project itself does not exist, the code is not_found, not this one. | Error |
| 409 | last_method — the change would leave no sign-in method enabled | Error |
| 503 | no_core — the service is temporarily unavailable | Error |
DELETE /v1/orgs/{orgSlug}/projects/{projectName}/auth
End-user data is not deleted. The runlot_auth tables stay in the project's
database as they are — deleting them is a database operation you run yourself with
runlot pg. Turning auth back on lets the same people log in as before.
Shortly afterwards the built-in login endpoints stop serving, and /__runlot/auth/*
passes straight through to your worker — if that path has no route, it is a 404.
Disabling is also idempotent. It returns 204 even when auth was not enabled.
Requires admin or higher — enabling takes member, but disabling signs out everyone who
is logged in. Audited as auth.revoke.
operationId revokeAuth
| Status code | Description | Response body |
|---|---|---|
| 204 | detached | — |
| 403 | — | — |
| 404 | — | — |
| 409 | access_mode_app — the deployment's access mode is app, which needs login. Choose another access mode first; login is never turned off under it silently. | Error |
| 503 | no_core — the service is temporarily unavailable | Error |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth/schema
The statements that install the runlot_auth schema, the same ones the auth
system installs on first use. The CLI applies them before a project's own
migrations so a migration can reference runlot_auth.users(id). Every statement
is idempotent; run them in order as one script. Viewer or higher.
operationId getAuthSchema
| Status code | Description | Response body |
|---|---|---|
| 200 | The schema version and its statements | object |
| 403 | — | — |
| 404 | — | — |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding
settings.brand (docs/custom-sign-in.md §12.3) with every default written out, and
effectiveMethods, what the chooser draws now. Viewer or higher.
operationId getAuthBranding
| Status code | Description | Response body |
|---|---|---|
| 200 | {brand, effectiveMethods} | AuthBranding |
| 404 | — | — |
PUT /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding
The whole brand object (absent fields are defaults). Refused fields answer 400
invalid_branding with error.details.fields: [{path, message}]. Member or higher.
operationId putAuthBranding
Request body: application/json · SignInBrand
| Status code | Description | Response body |
|---|---|---|
| 200 | {brand, effectiveMethods} | AuthBranding |
| 400 | — | — |
| 404 | not_granted | — |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth/funnel
Daily counters kept by the broker (docs/custom-sign-in.md §9.4), oldest first, 90-day
retention. Events: start (key: idp, chooser or silent), chooser_view,
method (key: password|google|github|passkey|session), success (key: method),
error (key: auth_error code, or passkey_failed). No per-person data.
operationId getAuthFunnel
| Parameter | Location | Required | Type | Description |
|---|---|---|---|---|
days | query | No | integer | — |
| Status code | Description | Response body |
|---|---|---|
| 200 | {days, rows: [{day, event, key, n}]} | object |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding/preview
The real chooser template with every link inert. Authenticated by the session bearer
or by ticket (from POST), so an <iframe> can load it; framable only by this
origin. /v1/projects/{projectId}/auth/branding/preview is the same by project id.
operationId previewAuthBranding
| Parameter | Location | Required | Type | Description |
|---|---|---|---|---|
ticket | query | No | string | — |
lang | query | No | string | — |
theme | query | No | string | — |
state | query | No | string | — |
draft | query | No | string | — |
| Status code | Description | Response body |
|---|---|---|
| 200 | HTML | string |
| 400 | — | — |
| 401 | no session and no valid ticket | — |
POST /v1/orgs/{orgSlug}/projects/{projectName}/auth/branding/preview
A ten-minute preview ticket
operationId mintAuthBrandingPreviewTicket
| Status code | Description | Response body |
|---|---|---|
| 200 | {url, ticket, expiresAt} | object |
PUT /v1/orgs/{orgSlug}/projects/{projectName}/auth/providers/{provider}
Three kinds (see AuthProviderPut). builtin registers your project's own
GitHub or Google OAuth app. oidc registers any OpenID Connect provider under a
project-chosen id; the CP reads its discovery document when saving. runlot
(id runlot) turns on "Continue with Runlot": email, Google and GitHub at Runlot,
through the sign-in broker, with nothing to register.
The callback URL to register with the provider is per host:
https://<host>/__runlot/auth/callback/<provider>. The host list is in
AuthStatus.hosts.
clientSecret is stored encrypted and never appears in any response
again. Rotation uses the same PUT — it is idempotent, so retries are safe.
Requires admin or higher. Recorded in the audit log as auth.provider.set
(the secret itself is not recorded).
operationId setAuthProvider
Request body: application/json · AuthProviderPut
| Status code | Description | Response body |
|---|---|---|
| 200 | The status that changed | AuthStatus |
| 400 | invalid_provider — the id does not match the pattern, the kind does not fit the id (builtin outside github/google, runlot outside runlot, oidc on a reserved id), or clientId/clientSecret is empty. bad_request — issuer on google, a non-http(s) GitHub base, a non-https OIDC issuer, or fields a runlot PUT does not take. oidc_discovery_failed — the issuer's /.well-known/openid-configuration could not be read; the message quotes the provider's answer. managed_removed — {"source":"managed"}; use the runlot provider. | Error |
| 403 | — | — |
| 404 | — | — |
| 409 | runlot_unavailable — this deployment has no sign-in broker, so the runlot provider cannot be turned on. Register your own app instead. | Error |
| 503 | no_core — the service is temporarily unavailable. | Error |
DELETE /v1/orgs/{orgSlug}/projects/{projectName}/auth/providers/{provider}
Anyone who signed in only with that provider will no longer be able to log in. The user record remains; only the matching linked identity is removed. Registering the provider again restores login with the same account.
Deleting a login provider that does not exist also returns 204.
Requires admin or higher. Recorded in the audit log as
auth.provider.delete.
operationId deleteAuthProvider
| Status code | Description | Response body |
|---|---|---|
| 204 | Disconnected | — |
| 403 | — | — |
| 404 | — | — |
| 409 | last_method — removing it would leave no sign-in method enabled | Error |
| 503 | no_core — the service is temporarily unavailable. | Error |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth/users
The people who signed up to your app — not the members of your organization.
They live in the project's own database, under the runlot_auth schema, and this
route reads them over the same connection …/database/connect hands you for psql.
Everything here is also visible through env.db and psql; this is a convenience,
not a second source.
It is read-only. Blocking a user, deleting one, or resetting a password is not
on this surface — write through env.db.
An app whose login has never been used has no schema yet (it is created on the first call), and that answers an empty list rather than an error.
Paging is by cursor, newest first. Feed it back unchanged; its absence is the
end.
Requires member or higher, and db:read on a token — the subscriber list is the
project's data, the same class as the SQL console.
operationId listAppUsers
| Parameter | Location | Required | Type | Description |
|---|---|---|---|---|
search | query | No | string | matches the email or the name, case-insensitively |
cursor | query | No | string | the cursor of the previous page |
limit | query | No | integer | — |
| Status code | Description | Response body |
|---|---|---|
| 200 | One page | AppUserPage |
| 400 | cursor is not one this endpoint issued, or limit is out of range | Error |
| 403 | — | — |
| 404 | no such project, or the project has no database (so it can have no users) | Error |
| 502 | could not reach this project's database (console_unreachable) | — |
| 504 | the database did not answer within the round-trip deadline (console_timeout) | — |
GET /v1/orgs/{orgSlug}/projects/{projectName}/auth/users/{userId}
The same record as the list, for one id. Requires member or higher, and db:read
on a token.
operationId getAppUser
| Status code | Description | Response body |
|---|---|---|
| 200 | The user | AppUser |
| 400 | userId is not a uuid | Error |
| 403 | — | — |
| 404 | — | — |
| 502 | could not reach this project's database (console_unreachable) | — |