Files
homekeeper/CLAUDE.md
T
friessn 6c9cd67a08 Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy
Replaces the single shared HTTP Basic service-account credential (which
caused a production outage from a username mismatch) with per-user login:
Keycloak (already running on this VM for gcnm, now also fronted on
auth.friessn.de with its own "homekeeper" realm) authenticates the user
once via the landing page, FastAPI verifies the OIDC id_token and mints
its own signed session JWT as a cookie, and both Shiny apps forward that
per-session token as a Bearer credential instead of a static shared one.
Authorization is a simple ALLOWED_USERS allowlist; the old auth.users
table and bcrypt seeding are gone entirely.

Also carries forward the in-progress rootless Podman/Quadlet migration
(gitea, homekeeper, podman roles) and fixes a pre-existing bug where
each role's handlers were malformed inside tasks/main.yml instead of
their own handlers/main.yml, which broke ansible-playbook entirely.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
2026-07-13 06:31:36 +00:00

313 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md
Guidance for Claude Code when working in this repository.
## Platform Overview
**Homekeeper** — self-hosted platform for a *Selbstversorgerhof* (self-sufficiency farm). Multiple focused Shiny apps behind one reverse proxy, all sharing one PostgreSQL database.
**Owner:** Nico Friess (nico.friess@googlemail.com). UI language: German. Respond in German when Nico writes in German.
| App | Route | R package | Purpose |
|---|---|---|---|
| **Beekeeper** | `/beekeeper/` | `./beekeeper/` | Beehive management (Imkerei) |
| **Listkeeper** | `/listkeeper/` | `./listkeeper/` | Shopping lists, to-dos, projects |
Planned: Garten, Tiere, Haus & Hof.
All UI must work on mobile (Android). Bootstrap 5, no fixed pixel widths.
---
## Architecture
```
Browser
└── nginx (reverse proxy)
├── /beekeeper/ → Shiny :3838
├── /listkeeper/ → Shiny :3839
└── /api/ → FastAPI :8000
└── PostgreSQL :5432
```
**Shiny apps do not touch the database directly.** All data access goes through the FastAPI service via `httr2` (see API Client Pattern below). The `db_<entity>.R` files in each R package are thin wrappers around API calls — there is no DBI anywhere in the Shiny code.
**FastAPI** (`./api/`) handles schema creation (`init_db()` on startup), auth (HTTP Basic), and all SQL. It exposes a versioned REST API at `/v1/`.
---
## Local Development
```bash
# Start everything (builds images on first run)
docker compose up --build -d
# Useful
docker compose logs -f beekeeper
docker compose logs -f listkeeper
docker compose logs -f api
docker compose up -d --build beekeeper # rebuild one service
docker compose up -d --force-recreate nginx # reload nginx config without rebuild
docker compose down
# App logs (JSONL, written to host via volume)
tail -f beekeeper/logs/$(date +%Y-%m-%d)_log.jsonl
tail -f listkeeper/logs/$(date +%Y-%m-%d)_log.jsonl
```
Local URLs: `http://localhost/beekeeper/`, `http://localhost/listkeeper/`, `http://localhost/api/docs`
---
## Repository Structure
```
. ← repo root (will rename to homekeeper later)
├── docker-compose.yml
├── nginx.conf ← local reverse proxy; routes to Docker service names
├── pg_data/ ← PostgreSQL data (bind mount, gitignored)
├── www/ ← landing page served at /
│ └── index.html
├── api/ ← FastAPI service (Python)
│ ├── Dockerfile
│ ├── requirements.txt
│ └── app/
│ ├── main.py ← lifespan, routers, root_path
│ ├── database.py ← engine, get_session, init_db()
│ ├── auth.py ← HTTP Basic auth, seed_users()
│ ├── models/
│ └── routers/ ← one file per resource
├── beekeeper/ ← R package
│ ├── Dockerfile
│ ├── DESCRIPTION ← version here
│ ├── NEWS.md
│ ├── logs/ ← JSONL logs (gitignored)
│ └── R/
├── listkeeper/ ← R package
│ ├── Dockerfile
│ ├── DESCRIPTION
│ ├── NEWS.md
│ ├── logs/
│ └── R/
└── infrastructure/ ← Ansible for Hetzner VM
├── site.yml
├── inventory/hosts.yml
├── group_vars/all.yml
└── roles/
├── common/ ← apt, ufw, fail2ban
├── podman/ ← install podman, auto-update timer
├── nginx/ ← system nginx + certbot (Let's Encrypt)
├── gitea/ ← Gitea as Podman Quadlet (git + container registry)
└── homekeeper/ ← app Quadlets (db, api, beekeeper, listkeeper)
```
---
## Production (Hetzner VM)
Target: Ubuntu 24.04, Podman + systemd Quadlets, system nginx.
| Domain | Purpose |
|---|---|
| `home.friessn.de` | Landing page + all apps |
| `git.friessn.de` | Gitea (git hosting + container registry) |
**Deployment flow:** push code → build images locally → `docker push git.friessn.de/nico/homekeeper-<app>:latest``podman auto-update` on VM pulls new image and restarts container (runs every 10s via systemd timer).
**Run the Ansible playbook:**
```bash
cd infrastructure
ansible-playbook -i inventory/hosts.yml site.yml
```
First-time setup has a Gitea bootstrap step — see `infrastructure/README.md`.
**Secrets:** all `CHANGE_ME` values in `group_vars/all.yml` must be set before first deploy. Use `ansible-vault encrypt_string` for passwords.
The local `docker-compose.yml` is for development only. Production uses Quadlet files in `infrastructure/roles/homekeeper/templates/`.
---
## Docker / Podman Services
| Service | Image | Port (internal) | Notes |
|---|---|---|---|
| `db` | `postgres:17` | 5432 | data in `./pg_data/` |
| `api` | built from `./api/` | 8000 | FastAPI, ROOT_PATH=/api |
| `beekeeper` | built from `./beekeeper/` | 3838 | Shiny |
| `listkeeper` | built from `./listkeeper/` | 3838 (local) / 3839 (prod) | Shiny, PORT env var |
| `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM |
**ENV vars (all services read from docker-compose / Quadlet):**
- `API_URL` — Shiny apps (no credentials needed — auth is per-session via cookie, see below)
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH` — API
- `ENV`, `COOKIE_SECURE`, `PUBLIC_BASE_URL`, `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_JWT_SECRET`, `ALLOWED_USERS` — API auth (see FastAPI section)
- `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`)
- `PORT` — listkeeper only (default 3838)
---
## FastAPI (`./api/`)
Python 3.12, FastAPI + SQLAlchemy (raw SQL via `session.execute(text(...))`). No ORM queries — complex joins written by hand.
**Auth:** Keycloak (running on the VM for the unrelated `gcnm` app too, exposed on its own subdomain `auth.friessn.de` with a dedicated `homekeeper` realm — see `infrastructure/group_vars/all.yml`) acts as OIDC identity provider. Login happens once, on the landing page (`/`), via `GET /v1/auth/login` → Keycloak OAuth2 → `GET /v1/auth/callback`. FastAPI verifies Keycloak's `id_token` (RS256, via its JWKS — everything driven off the standard `.well-known/openid-configuration` discovery doc, so the code isn't actually Keycloak-specific), checks the username against the `ALLOWED_USERS` allowlist (comma-separated env var — no DB table, stateless), then mints its own signed session JWT (`SESSION_JWT_SECRET`, HS256, 30-day expiry) and sets it as an `hk_session` cookie on the whole domain. Beekeeper/Listkeeper read that cookie from `session$request` and forward it as `Authorization: Bearer <jwt>` on every API call (see `get_session_token()` in each package's `api_client.R`). `get_current_user()` in `app/auth.py` accepts either the Bearer header or the cookie directly, so `Depends(get_current_user)` in routers is unchanged either way. Local dev has no real OIDC app to test against — `GET /v1/auth/dev-login?username=...` mints a session directly, but only when `ENV=development` (404s otherwise).
**Adding an endpoint:** add a file to `app/routers/`, include it in `main.py`. All routes return `dict(r._mapping)` from raw SQL rows.
**Performance pattern:** avoid N+1 — use subqueries or JOINs to fetch related data in one query. Example: `colonies` list endpoint includes `last_inspection_date` as a subquery and `item_count`/`checked_count` in the lists endpoint.
---
## R Package Pattern
Plain Shiny — no golem, no rhino. Each package exports one function: `run_app()`.
**File naming:**
| File | Purpose |
|---|---|
| `app.R` | `run_app()` — calls `log_init()`, builds `api` list, starts `shinyApp()` |
| `ui.R` | `ui()``bslib::page(fillable=FALSE)` with sticky header |
| `server.R` | `server()` — initializes modules, navigation switch |
| `screen_<name>.R` | One module per screen: `screen_<name>_ui()` + `screen_<name>_server()` |
| `db_<entity>.R` | API wrapper functions — call `api_get/post/put/patch/delete()` |
| `api_client.R` | HTTP helpers: `api_get`, `api_get_one`, `api_post`, `api_put`, `api_patch`, `api_delete` |
| `logger.R` | `log_init()`, `log_info()`, `log_debug()`, `log_error()` — writes JSONL |
| `utils.R` | `%||%`, constants, badge helpers |
| `widgets.R` | Reusable UI components: `stepper_row`, `toggle_row`, `section_card`, etc. |
**`app.R` pattern:**
```r
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
log_init()
server_fn <- function(input, output, session) {
log_info("session_start", session_id = session$token)
session$onSessionEnded(function() log_info("session_end", session_id = session$token))
# api/db is built per-session (not module-level) — the auth token comes
# from the browser's hk_session cookie, so it differs per visitor.
api <- list(
base_url = Sys.getenv("API_URL", "http://localhost:8000"),
token = get_session_token(session)
)
server(input, output, session, db = api)
}
shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE)
}
```
If `db$token` is `NULL` (no cookie — user never logged in via the landing page), `server()` should render a "please log in" screen instead of the normal nav/content (see `server.R` in either package).
**Module pattern:**
```r
screen_example_ui <- function(id) {
ns <- NS(id)
shiny::tagList(...)
}
screen_example_server <- function(id, db, nav) {
moduleServer(id, function(input, output, session) {
ns <- session$ns
# db = api list (base_url, user, pass) — pass to api_get/post/etc.
# nav = reactiveValues(screen=, apiary_id=, colony_id=) in beekeeper
})
}
```
**API client pattern (`db_<entity>.R`):**
```r
db_colonies_list <- function(db, apiary_id = NULL, include_closed = FALSE) {
api_get(db, "/v1/colonies", list(apiary_id = apiary_id, include_closed = include_closed))
}
db_colonies_insert <- function(db, name, apiary_id, ...) {
result <- api_post(db, "/v1/colonies", list(name = name, apiary_id = apiary_id, ...))
result$id
}
```
**Navigation (beekeeper):** stack-based via `nav` reactiveValues. `nav$screen` drives a `switch()` in `output$screen_content`. Back navigation via `BACK` named vector. No `page_navbar()`.
**Beekeeper screens:**
`apiaries → colonies → colony → inspection / varroa / harvest / queens / checklists / actions / lineage`
Plus: `quick_inspection` (Schnelldurchsicht, all colonies at once) and `bulk_action` (Sammelmaßnahme, one action for many colonies).
**UI conventions:**
- `bslib::page(fillable = FALSE)` — scrollable
- Sticky header: `div(class = "bk-header d-flex ...")` with `position: sticky; top: 0`
- Tap cards: `div(class = "card tap-card mb-2", onclick = "Shiny.setInputValue(...)")`
- Bootstrap 5 only — no fixed pixel widths
- `section_card()`, `collapsible_card()`, `stepper_row()`, `toggle_row()` — use widgets from `widgets.R`
**Inline interaction (avoiding ID collisions in `lapply` loops):**
```r
# Buttons: onclick + Shiny.setInputValue instead of actionButton
shiny::tags$button(
onclick = sprintf("Shiny.setInputValue('%s', %d, {priority:'event'})", ns("select"), row$id)
)
# Text inputs: pass value via JS
shiny::tags$input(
onkeyup = sprintf(
"if(event.key==='Enter'){var v=this.value.trim();if(v){Shiny.setInputValue('%s',{list_id:%d,text:v,ts:Date.now()},{priority:'event'});this.value=''}}",
ns("add_item"), row$id
)
)
```
**Logging:**
```r
log_init() # once at startup — creates today's JSONL file
log_info("msg", k = v) # structured fields as named args
log_debug("msg", ...)
log_error("msg", ...)
```
Log files: `./beekeeper/logs/YYYY-MM-DD_log.jsonl` (host), `/logs/` inside container.
**Versioning:** bump `DESCRIPTION` and add an entry to `NEWS.md` when something meaningful ships. Minor bump (0.x.0) for features, patch (0.0.x) for fixes.
---
## Database Schemas
### `public` — Beekeeper
**`apiaries`**: `id, name, location_text, lat, lng, notes, created_at`
**`colonies`**: `id, apiary_id, name, status, parent_colony_id, current_queen_id, created_at, closed_at`
- `status`: `wirtschaftsvolk | ableger | jungvolk | schwarmstimmung | eingegangen`
- `closed_at IS NULL` = active
**`queens`**: `id, colony_id, year, breed, origin_colony_id, marked_color, notes, introduced_at, superseded_at`
**`inspections`**: `id, colony_id, date, time, inspector, brood_frames, honey_frames, food_stores_kg, colony_strength, queen_seen, eggs_seen, queen_cells_count, notes`
**`inspection_traits`**: `inspection_id, trait, score` — trait: `sanftmut|schwarmtrieb|wabensitz|honigertrag|varroa_hygiene` (14)
**`varroa_counts`**: `id, colony_id, date, time, date_from, method, count, notes` — method: `natuerlicher_totenfall|alkoholwaesche|puderzucker`
**`varroa_treatments`**: `id, colony_id, date, time, product, method, dosage, notes`
**`varroa_controls`**: `id, treatment_id, colony_id, date, result, notes`
**`honey_harvests`**: `id, colony_id, apiary_id, date, time, weight_kg, type, notes`
**`colony_actions`**: `id, colony_id, date, time, type, notes, related_colony_id, created_at`
- `type` is free text. Used values: `ablegerbildung | zwischenbodenableger | brutentnahme | standwechsel | futterung | vereinigung | mittelwand | sonstig`
**`checklists` / `checklist_items` / `checklist_completions`**
Colony log: API endpoint `GET /v1/colonies/{id}/log` — UNION ALL across all event tables, returns `date, type, summary, ref_id, created_at`.
### `lk` — Listkeeper
**`lk.lists`**: `id, name, type, t_shirt_size, status, notes, created_at, closed_at`
- `type`: `shopping | todo | project`
- `t_shirt_size`: `XS|S|M|L|XL` (projects only)
- `closed_at IS NULL` = active
**`lk.list_items`**: `id, list_id, text, checked, position, created_at`
List endpoint returns `item_count` and `checked_count` as aggregated columns (not from a separate query).
### Auth
No DB table — auth is stateless. Identity comes from Keycloak (OIDC), authorization is an `ALLOWED_USERS` env var allowlist, and sessions are self-contained signed JWTs (`hk_session` cookie). See the FastAPI Auth section above.