Development

Layout

api/    Rust backend (axum + SeaORM)
  build.rs     Builds web/dist during `cargo build` (skip: SKIP_FRONTEND_BUILD=1)
  src/
    entities/    SeaORM models (user, role, session, smtp_settings, oidc_settings, auth_settings, password_reset_token)
    migration/   Embedded migrations
    handlers/    HTTP handlers (auth, users, roles, settings, oidc, signup)
    auth.rs      Password hashing, sessions, the AuthUser extractor (with permissions)
    rbac.rs      Permission-key catalog and the Perms set resolved from a role
    crypto.rs    AES-256-GCM encryption for secrets at rest
    mailer.rs    SMTP config resolution (env vs database) and sending
    oidc.rs      OIDC config resolution (env vs database) and provisioning helpers
    service.rs   User operations shared by the API and CLI
    server.rs    Router + static file serving
    cli.rs       clap CLI (serve, user ...)
    db.rs        Connection + migration runner
    seed.rs      First-launch admin seed
web/    React frontend (Vite + TypeScript + Tailwind)
  src/
    components/  Pages and UI (Login, ChangePassword, Users, Settings, dialogs)
      ui.tsx       Shared primitives (Button, Input, Card, Modal, Toggle, ...)
      AppShell.tsx Sidebar + PageHeader/PageBody, the shell every signed-in page renders into
    index.css    Design tokens (the `empire` palette) and base styles
    lib/api.ts   Typed API client

Frontend conventions

The UI projects the Agent of Empires empire TUI theme onto the web, so a status color means the same thing in both places.

  • Colors, fonts, and radii are Tailwind v4 theme tokens declared in web/src/index.css: surfaces (canvas, surface, surface-elevated), border / border-soft, a five-step text scale (text-bright, text, text-dim, text-hint, text-faint), the copper accent family, and the status colors (running, waiting, idle, error). Use the tokens, never a raw hex value or a stock Tailwind palette color, so a palette change stays one edit.
  • Primitives live in web/src/components/ui.tsx. Prefer them over hand-rolled markup: a new card, input, toggle, or dialog should be Card, Input, Toggle, or Modal. Tables use the exported class constants (tableCardClass, tableHeadClass, thClass, trClass, tdClass) with real <table> markup.
  • Page shape: AppShell renders the sidebar and an <Outlet/>; a signed-in page renders <PageHeader title meta actions /> followed by <PageBody>, and receives { me }. Unauthenticated screens use AuthCard instead.
  • Settings is one route per tab. The tab list is web/src/lib/settingsTabs.ts, read by both the sidebar sub-nav and SettingsPage, so adding a tab is one entry plus a branch in the page.
  • Status vocabulary for workspaces lives in web/src/lib/workspaceStatus.ts so every surface shows the same glyph and color for the same state.

Backend

cargo run                        # run migrations + serve
cargo test --workspace           # tests
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt                        # format (CI checks with --check)

Logs are controlled with CITYHALL_LOG / RUST_LOG; see Configuration.

Frontend live reload

Run the backend and the Vite dev server side by side. Vite proxies /api to the backend, so cookies and API calls work as in production:

# terminal 1
cargo run
# terminal 2
cd web
npm install
npm run dev

For a production-style run, build the frontend and let the backend serve it:

cd web && npm run build          # outputs web/dist
cargo run                        # serves web/dist via STATIC_DIR

Frontend checks:

cd web
npm run lint
npm run format:check
npm run build

Docker Compose

A docker-compose.yml at the repo root brings up the full stack: CityHall (built from the Dockerfile), Postgres, and Mailpit as a local mail sink for testing email flows.

docker compose up --build

SMTP is pre-configured via SMTP_* environment variables to point at Mailpit, so email works out of the box and sent mail appears in the Mailpit UI. Because it is env-managed, the Settings form is read-only; comment out the SMTP_* variables in docker-compose.yml to configure SMTP in the UI instead (host mailpit, port 1025, encryption none). The compose file sets a dev CITYHALL_SECRET_KEY; replace it for anything but local testing (openssl rand -base64 32).

Database and migrations

CityHall targets SQLite, Postgres, and MySQL through SeaORM; the backend is chosen at runtime from DATABASE_URL. Migrations live in api/src/migration/ and run automatically on startup and before every CLI command.

To add a schema change:

  1. Add a m0004_*.rs module in api/src/migration/ implementing MigrationTrait.
  2. Register it in the migrations() list in api/src/migration/mod.rs.
  3. Update or add the matching entity in api/src/entities/.

Keep migrations backend-agnostic (use the sea_orm_migration::schema helpers) so they apply across all supported databases.

Adding an endpoint

  1. Add a handler in api/src/handlers/. Take State<DatabaseConnection> and, if it requires auth, the AuthUser extractor. Gate it with caller.require("some.permission")?, adding the key to the CATALOG in rbac.rs (this also enforces the forced-password-change gate).
  2. Reuse shared logic in service.rs where possible so the CLI and API stay in sync.
  3. Wire the route into api_router in api/src/server.rs.
  4. Add a matching method to web/src/lib/api.ts for the frontend.

Conventions

See CONTRIBUTING.md. PR titles follow Conventional Commits, and prose must not use em dashes.