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 copperaccentfamily, 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 beCard,Input,Toggle, orModal. Tables use the exported class constants (tableCardClass,tableHeadClass,thClass,trClass,tdClass) with real<table>markup. - Page shape:
AppShellrenders the sidebar and an<Outlet/>; a signed-in page renders<PageHeader title meta actions />followed by<PageBody>, and receives{ me }. Unauthenticated screens useAuthCardinstead. - Settings is one route per tab. The tab list is
web/src/lib/settingsTabs.ts, read by both the sidebar sub-nav andSettingsPage, so adding a tab is one entry plus a branch in the page. - Status vocabulary for workspaces lives in
web/src/lib/workspaceStatus.tsso 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
- CityHall: http://localhost:3000 (admin password is printed in the
cityhalllogs on first launch). - Mailpit web UI: http://localhost:8025.
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:
- Add a
m0004_*.rsmodule inapi/src/migration/implementingMigrationTrait. - Register it in the
migrations()list inapi/src/migration/mod.rs. - 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
- Add a handler in
api/src/handlers/. TakeState<DatabaseConnection>and, if it requires auth, theAuthUserextractor. Gate it withcaller.require("some.permission")?, adding the key to theCATALOGinrbac.rs(this also enforces the forced-password-change gate). - Reuse shared logic in
service.rswhere possible so the CLI and API stay in sync. - Wire the route into
api_routerinapi/src/server.rs. - Add a matching method to
web/src/lib/api.tsfor the frontend.
Conventions
See CONTRIBUTING.md. PR titles follow Conventional Commits, and prose must not use em dashes.