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

14 KiB
Raw Blame History

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>:latestpodman 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 — 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 `%
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 ...") 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):

# 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 | 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.