A small web app that shows the Basel SBB (memox) floor plan and which hackathon team sits in which room.
- Visitors get a public, read-only page: click a room to see its teams, or browse the searchable assignment table underneath the plan.
- Organizers manage teams through a REST API with a shared API key, using the
bundled
bhroomsCLI. - A team can be in exactly one room; a room can hold many teams.
| Layer | Choice |
|---|---|
| Backend | Rust, axum 0.8, tokio, sqlx (SQLite), tower-http |
| Frontend | React + TypeScript + Vite + Tailwind CSS, hand-authored SVG |
| CLI | Rust bhrooms binary, clap + reqwest |
| Deploy | Single Rust binary in an OCI image, Caddy for TLS, Podman Compose on a Fedora VPS |
Cargo.toml workspace (server + cli)
server/ bhrooms-server: JSON API, SQLite, embedded SPA
migrations/ schema
tests/api.rs endpoint integration tests
cli/ bhrooms CLI client
web/ React SPA (Vite, Tailwind, Vitest)
deploy/ Dockerfile, compose, Caddyfile, build/deploy/remote scripts
scripts/dev.sh run server + Vite together
DESIGN.md design document
DEPLOY.md build → package → deploy guide
Prerequisites: Rust (1.75+), Node 20+, npm. SQLite is bundled through sqlx.
# API on http://localhost:8080
cargo run -p bhrooms-server
# Frontend with hot reload on http://localhost:5173 (proxies /api to :8080)
cd web
npm install
npm run devOr run both with scripts/dev.sh.
cargo build --release embeds web/dist into the binary, so run npm run build
in web/ first. The Docker build does this automatically.
The server prints a freshly generated API key on every start:
============================================================
API key: 6f1c... (keep secret; used by the CLI)
The key changes on every restart;
read it again from the logs.
============================================================
The key is held in memory only and the server is the only writer: the CLI never
creates or changes it. A restart invalidates it (unless BHROOMS_API_KEY is
set). See Security.
cargo run -p bhrooms -- rooms list
cargo run -p bhrooms -- teams create "Team Rocket" --size 4 --api-key <key>
cargo run -p bhrooms -- assign "Team Rocket" XL --api-key <key>
cargo run -p bhrooms -- overview
cargo run -p bhrooms -- teams rename "Team Rocket" "Team Phoenix" --api-key <key>
cargo run -p bhrooms -- unassign "Team Phoenix" --api-key <key>
cargo run -p bhrooms -- teams delete "Team Phoenix" --api-key <key>Team arguments accept an id or an exact name (the id wins when both match).
--json prints machine-readable output for every command. Exit codes: 0
success, 1 usage/API error, 2 auth error.
The CLI talks to http://localhost:8080 by default. Point it at another server
with --server <url>:
bhrooms --server https://rooms.example.com overviewor persist them so they stick across runs:
bhrooms config set server https://rooms.example.com
bhrooms config set api-key <key>
bhrooms config show # effective values and where they come fromPrecedence: --server/--api-key flags > BHROOMS_SERVER/BHROOMS_API_KEY
env vars > persisted config > http://localhost:8080. The config lives at
~/.config/bhrooms/config.toml (or $XDG_CONFIG_HOME/bhrooms/config.toml);
the API key is stored there in plaintext with file mode 0600.
Set the key once instead of repeating it:
export BHROOMS_API_KEY=<printed key>
export BHROOMS_SERVER=http://localhost:8080 # optionalThe CLI never generates or changes keys. It only carries the one the server
printed: --api-key, BHROOMS_API_KEY, or bhrooms config set api-key <key>.
To rotate, restart the server and read the new key from its log.
| Variable | Default | Purpose |
|---|---|---|
BHROOMS_ADDR |
0.0.0.0:8080 |
Listen address |
BHROOMS_DB |
./data/rooms.db |
SQLite file (WAL mode) |
BHROOMS_API_KEY |
generated per start | Pin a fixed key at startup |
RUST_LOG |
info |
Log filter |
Base path /api, JSON only. Errors use
{ "error": { "code": "...", "message": "..." } }.
Public:
| Method | Path | Description |
|---|---|---|
| GET | /api/overview |
Rooms with teams plus unassigned teams (one shot) |
| GET | /api/rooms |
Fixed room list |
| GET | /api/teams |
Teams with their current room |
| GET | /healthz |
Liveness probe |
Admin (Authorization: Bearer <API_KEY>):
| Method | Path | Body | Result |
|---|---|---|---|
| POST | /api/teams |
{ "name": "...", "size": 4 } |
201 team |
| PATCH | /api/teams/:id |
{ "name"?, "size"? } |
200 team |
| DELETE | /api/teams/:id |
– | 204 |
| PUT | /api/teams/:id/room |
{ "roomId": "L1" | null } |
200 team |
Validation: unknown room → 400, unknown team → 404, duplicate name → 409,
missing/invalid key → 401. size is an integer ≥ 0 or null.
- Writes require a shared bearer key, compared in constant time (SHA-256 + constant-time digest comparison).
- The key is generated with a CSPRNG on every start, kept in memory only and printed to the log. Nothing can change it at runtime: the server is the only generator and the only writer, so there is no key-management endpoint.
- A restart/redeploy invalidates existing keys: read the new one with
mise run deploy:key(orpodman compose logs app). - The web UI is read-only; there are no cookies, sessions or user accounts.
cargo test --workspace # server unit + API integration, CLI unit/mock tests
cargo clippy --workspace --all-targets -- -D warnings
cd web && npm test # Vitest + React Testing Library
cd web && npm run build # typecheck + production build- Start the server and open
http://localhost:8080(afternpm run buildinweb/), or use Vite on:5173. - Confirm all 10 rooms render (the safe space and the OK room greyed out with their red/orange outlines) and the table says “No teams yet.”
- Create a team via CLI, assign it to
L1, refresh the page: the room shows a1badge and the team appears in the table and room panel. - Assign a second team to
L1: both appear; a room can hold many teams. - Click a table row: the matching room is highlighted and scrolled into view.
- Search and room filter behave; unassigned teams are listed separately.
- Rename, resize (including clearing size with
teams size <team> none) and delete the team; the page reflects it after refresh. - Call a write endpoint without the key:
401; with the key: success. - Restart the server: a new key is printed and the old one is rejected;
PUT /api/keyis gone and answers404.
- The floor plan is a trace of the venue plan
(
Grundriss_Farben_und_qm_Basel-SBB_.pdf, open question Q1 inDESIGN.md). Room outlines follow the drawn walls; furniture, the atrium, corridors and technical zones are omitted or inert; room geometry lives inweb/src/rooms.ts. - Page title is “BaselHack — Team Rooms” (open question Q2).
- The official white BaselHack logo from baselhack.ch is vendored as
web/public/logo.webpand used by the header and favicon. - Fira Code is vendored under
web/public/fonts/(SIL OFL 1.1, license file included) and loaded via@font-faceinweb/src/index.css. - Rooms are fixed and seeded on every start:
S1, S2, S3, S4, M1, M2, M3, L1, L2, XL. The room of the location team is drawn on the plan but is inert: it cannot be selected or assigned. - Two rooms are reserved:
S1is the safe space (labelSafe Space, red outline) andS2is labelledOK(orange outline). Both are greyed out like the location team's room. The server marks themassignable: falseand rejects any attempt to assign a team to them (400); the web UI does not decide this itself, it only renders the flag.