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
14 KiB
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
# 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:
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— APIENV,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 |
`% |
widgets.R |
Reusable UI components: stepper_row, toggle_row, section_card, etc. |
app.R pattern:
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:
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):
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 ...")withposition: 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 fromwidgets.R
Inline interaction (avoiding ID collisions in lapply loops):
# 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:
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 | eingegangenclosed_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 (1–4)
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
typeis 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 | projectt_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.