API Reference

All endpoints are served under /api. Requests and responses are JSON.

Authentication

CityHall uses server-side sessions. A successful POST /api/auth/login sets an HttpOnly session cookie (cityhall_session); send it on subsequent requests. Browsers do this automatically; from a client, use a cookie jar (e.g. curl -c jar -b jar).

Every endpoint except GET /api/health and POST /api/auth/login requires a valid session and returns 401 Unauthorized without one.

Forced password change

While a user’s must_change_password flag is set, all user-management endpoints return 403 Forbidden with {"error":"password change required"}. Only GET /api/auth/me, POST /api/auth/change-password, and POST /api/auth/logout are permitted until the password is changed.

Authorization (RBAC)

Every user has a role, and a role holds a set of permission keys (the wildcard * grants all). Endpoints require a specific permission; a caller lacking it gets 403 Forbidden with {"error":"insufficient permissions"}. The current keys are users.read, users.write, roles.read, roles.write, settings.read, settings.write, workspaces.use, workspaces.read, and workspaces.write. GET /api/auth/me returns the caller’s effective permission list so a client can gate its UI. Built-in roles are admin (all permissions) and member (users.read, workspaces.use).

Errors

Errors use HTTP status codes with a JSON body:

{ "error": "human-readable message" }

Common codes: 400 (bad request), 401 (unauthenticated), 403 (password change required or insufficient permissions), 404 (not found), 409 (conflict, e.g. duplicate username).

Endpoints

GET /api/health

Liveness check. Returns 200 OK with the body ok. No authentication.

POST /api/auth/login

{ "username": "admin", "password": "..." }

On success, sets the session cookie and returns:

{ "must_change_password": true }

Invalid credentials return 401. A self-signup account that has not yet verified its email returns 403.

POST /api/auth/logout

Clears the session (server-side and cookie). Returns 200.

GET /api/auth/me

Returns the current user, including their role and effective permissions:

{
  "id": 1,
  "username": "admin",
  "email": null,
  "must_change_password": false,
  "role_id": 1,
  "role": "admin",
  "permissions": ["users.read", "users.write", "roles.read", "roles.write", "settings.read", "settings.write"]
}

POST /api/auth/change-password

{ "current_password": "...", "new_password": "..." }

Verifies the current password and requires the new one to be at least 8 characters. Returns the updated user (with must_change_password: false).

POST /api/auth/forgot-password

Public (no authentication).

{ "email": "user@example.com" }

Always returns 200 regardless of whether the email matches an account, so it cannot be used to enumerate addresses. When it matches a user and SMTP is configured, a single-use reset link (valid 1 hour) is emailed. Requires SMTP to be configured (see Configuration); the link’s base URL comes from CITYHALL_BASE_URL or the request host.

POST /api/auth/reset-password

Public (no authentication).

{ "token": "...", "new_password": "..." }

Redeems a reset or setup token and sets the new password (minimum 8 characters), clearing must_change_password. The token is single-use; an unknown, expired, or already-used token returns 400.

GET /api/auth/providers

Public (no authentication). Reports which login methods are available so the login page can render accordingly:

{ "oidc": true, "signup": false }

oidc is true when OIDC SSO is configured and enabled; signup is true when self-signup is enabled.

GET /api/auth/oidc/login

Public. Begins the OIDC flow: sets a short-lived flow cookie (CSRF state, nonce, PKCE verifier) and 303-redirects the browser to the identity provider. Returns 400 if SSO is not configured. Open it as a top-level navigation, not via fetch.

GET /api/auth/oidc/callback

Public. The provider’s redirect target. Exchanges the code, verifies the ID token, links or provisions the user (see Configuration), starts a session, and 303-redirects to /. Linking to an existing account always works; creating a new account on first login requires self-signup to be enabled, otherwise it redirects to /login?error=... (as with any failure).

POST /api/auth/register

Public. Only works when self-signup is enabled (see Configuration), else 400.

{ "username": "bob", "email": "bob@example.com", "password": "..." }

Creates an unverified account and emails a verification link (SMTP must be configured). Password must be at least 8 characters. Returns 400 if signup is disabled, the email domain is not allowed, or SMTP is unconfigured; 409 if the username or email is already taken. On success returns 200; the account cannot log in until verified.

POST /api/auth/verify-email

Public.

{ "token": "..." }

Redeems a verification token and marks the account verified so it can log in. The token is single-use; an unknown, expired, or used token returns 400.

GET /api/users

Requires users.read. Returns all users:

[
  { "id": 1, "username": "admin", "email": null, "must_change_password": false, "created_at": "2026-07-07T09:20:50Z", "role_id": 1 }
]

Password hashes are never included in any response.

POST /api/users

Requires users.write.

{ "username": "bob", "email": "bob@example.com", "password": "...", "send_setup_email": false, "role_id": 2 }

email may be omitted or null. password, send_setup_email, and role_id are optional; role_id defaults to the member role. Behavior:

  • send_setup_email: true emails the user a setup link (requires an email and configured SMTP, else 400); the user sets their own password.
  • password given: used as-is.
  • password omitted or empty: a password is generated and the user must change it on first login.

Returns the created user plus generated_password, which is the generated password when one was generated and null otherwise (including when a setup email was sent):

{ "id": 2, "username": "bob", "email": null, "must_change_password": true, "created_at": "...", "generated_password": "..." }

A duplicate username returns 409.

GET /api/users/{id}

Returns a single user, or 404 if not found.

PATCH /api/users/{id}

Requires users.write. Partial update; every field is optional:

{ "username": "bob2", "email": "new@example.com", "password": "...", "role_id": 3 }

Renaming to an existing username returns 409; an unknown role_id returns 400. Returns the updated user.

DELETE /api/users/{id}

Requires users.write. Deletes a user. Deleting your own account returns 400. Returns:

{ "deleted": true }

GET /api/permissions

Requires roles.read. Returns the permission-key catalog for building a role editor:

[
  { "key": "users.read", "description": "View users" },
  { "key": "users.write", "description": "Create, edit, and delete users" }
]

GET /api/roles

Requires roles.read. Returns all roles. permissions is the array of keys (["*"] for the wildcard); user_count is how many users hold the role.

[
  {
    "id": 1,
    "name": "admin",
    "description": "Full access to everything",
    "permissions": ["*"],
    "is_system": true,
    "created_at": "2026-07-07T09:20:50Z",
    "user_count": 1
  }
]

POST /api/roles

Requires roles.write.

{ "name": "editor", "description": "Manage users", "permissions": ["users.read", "users.write"] }

description may be omitted or null. Unknown permission keys return 400; a duplicate name returns 409. Returns the created role.

PATCH /api/roles/{id}

Requires roles.write. Partial update (name, description, permissions). The built-in admin role cannot be modified (403); a built-in role cannot be renamed (403). Unknown permission keys return 400. Returns the updated role.

DELETE /api/roles/{id}

Requires roles.write. Deletes a role. Built-in roles cannot be deleted (403), and a role still assigned to users cannot be deleted (409). Returns { "deleted": true }.

GET /api/settings/smtp

Requires settings.read. Returns the effective SMTP configuration. The password itself is never returned, only whether one is stored (password_set).

{
  "env_managed": false,
  "enabled": true,
  "host": "smtp.example.com",
  "port": 587,
  "encryption": "starttls",
  "username": "cityhall",
  "from_address": "cityhall@example.com",
  "from_name": "CityHall",
  "password_set": true,
  "secret_key_available": true
}

env_managed is true when SMTP is configured through environment variables (see Configuration); the values then come from the environment and PUT is rejected. secret_key_available reflects whether CITYHALL_SECRET_KEY is set, which is required to store a password.

PUT /api/settings/smtp

Requires settings.write. Updates the stored SMTP configuration:

{
  "host": "smtp.example.com",
  "port": 587,
  "encryption": "starttls",
  "username": "cityhall",
  "password": "...",
  "from_address": "cityhall@example.com",
  "from_name": "CityHall",
  "enabled": true
}

encryption is one of none, starttls, tls. username, password, and from_name may be omitted or null. Omitting password keeps the stored one; sending a value replaces it. Storing a password requires CITYHALL_SECRET_KEY to be set, otherwise the request returns 400. When SMTP is env-managed, this returns 409. Returns the updated settings (same shape as GET).

POST /api/settings/smtp/test

Requires settings.write.

{ "to": "you@example.com" }

Sends a test email using the effective configuration. Always returns 200 with the result, so a delivery failure surfaces the provider’s message rather than an HTTP error:

{ "ok": false, "error": "send failed: connection refused" }

Returns 400 if SMTP is not configured or not enabled.

GET /api/settings/oidc

Requires settings.read. Returns the OIDC configuration. The client secret is never returned, only whether one is stored (client_secret_set).

{
  "env_managed": false,
  "enabled": true,
  "issuer": "https://accounts.example.com",
  "client_id": "cityhall",
  "scopes": "openid email profile",
  "allowed_domains": "example.com",
  "client_secret_set": true,
  "secret_key_available": true,
  "callback_path": "/api/auth/oidc/callback"
}

env_managed is true when OIDC is configured through environment variables; the values then come from the environment and PUT is rejected. Register {base_url}{callback_path} with the identity provider.

PUT /api/settings/oidc

Requires settings.write. Updates the stored OIDC configuration:

{
  "enabled": true,
  "issuer": "https://accounts.example.com",
  "client_id": "cityhall",
  "client_secret": "...",
  "scopes": "openid email profile",
  "allowed_domains": "example.com"
}

issuer and client_id are required. Omitting client_secret keeps the stored one; sending a value replaces it (and requires CITYHALL_SECRET_KEY, else 400). allowed_domains is comma-separated (empty allows any domain). When OIDC is env-managed, this returns 409. Returns the updated settings.

GET /api/settings/signup

Requires settings.read. Returns the self-signup configuration.

{ "signup_enabled": false, "signup_allowed_domains": "example.com", "signup_default_role_id": null }

signup_default_role_id is the role assigned to new sign-ups; null means the member role.

PUT /api/settings/signup

Requires settings.write. Updates the self-signup configuration (same shape as GET). signup_allowed_domains is comma-separated (empty allows any domain); an unknown signup_default_role_id returns 400. Enabling signup while SMTP is unconfigured returns 400 (verification email cannot be sent). Returns the updated settings.

GET /api/workspaces

Requires workspaces.read. Returns every user with their workspace state (see Workspaces). status is not_created, stopped, running, or unknown (runtime unreachable); effective_version is the pin or the default.

[
  {
    "user_id": 2,
    "username": "bob",
    "status": "running",
    "pinned_version": null,
    "effective_version": "v0.5.0",
    "last_active_at": "2026-07-15T09:20:50Z"
  }
]

GET /api/workspaces/me

Requires workspaces.use. The caller’s own workspace plus the origin browsers use to reach the workspace proxy:

{
  "status": "stopped",
  "pinned_version": null,
  "effective_version": "v0.5.0",
  "proxy_origin": "http://127.0.0.1:3001"
}

POST /api/workspaces/{user_id}/start

Requires workspaces.write. Starts (or resumes) the user’s workspace. Returns 503 with a descriptive error when the runtime or image is unavailable.

POST /api/workspaces/{user_id}/stop

Requires workspaces.write. Stops the workspace, keeping its data volume.

DELETE /api/workspaces/{user_id}

Requires workspaces.write. Destroys the workspace AND its data volume. Returns { "destroyed": true }.

PATCH /api/workspaces/{user_id}

Requires workspaces.write. Pins the served aoe version (null or empty unpins, following the default). A running workspace is recreated with the new image on its next start or proxied request; its volume is kept.

{ "pinned_version": "v0.5.1" }

PATCH /api/workspaces

Requires workspaces.write. Grouped pin: same as above for several users in one call.

{ "user_ids": [2, 3], "pinned_version": "v0.5.1" }

GET /api/settings/workspaces

Requires settings.read.

{
  "image_template": "cityhall/aoe:{version}",
  "default_version": "v0.5.0",
  "idle_stop_minutes": 30
}

PUT /api/settings/workspaces

Requires settings.write. Same shape as GET. image_template is required; idle_stop_minutes must be at least 1.