mitbringsl/AGENTS.md
Tronax 895725b5e5
Backend Phase C: Sync-Kern + Caddy behind-proxy
Sync-Kern:
- internal/sync/hlc.go: Hybrid Logical Clock (wall_ms<<16|counter)
  Tick/Now/After, global mutex, strikt monoton + kausal korrekt
- internal/sync/hlc_test.go: Unit-Tests (monoton, kausal, keine Duplikate)

Store-Schicht:
- internal/store/opstore.go: AppendOps idempotent via UNIQUE(client_id,
  client_seq) ON CONFLICT DO NOTHING; LWW-Projektion (list_create/
  rename/delete, item_add/update/remove) in derselben Transaktion;
  PullOps mit Cursor (seq > since, 500er Pages)
- internal/store/liststore.go: CreateList / GetLists / GetList
- internal/store/itemstore.go: GetItems (nicht-gelöschte Items)
- internal/store/suggeststore.go: Search (pg_trgm + LIKE-fallback, 10)

HTTP-Handler:
- internal/httpapi/lists.go: GET/POST /api/lists, GET /api/lists/{id}
- internal/httpapi/ops.go: POST /api/lists/{id}/ops (Push),
  GET /api/lists/{id}/ops (Pull ?since=)
- internal/httpapi/suggest.go: GET /api/suggestions?q=
- internal/httpapi/api.go: alle Routen verdrahtet (RequireAuth)

Deployment:
- deploy/Caddyfile.behind-proxy: auto_https off, trusted_proxies
- deploy/Caddyfile: X-Forwarded-Proto hinzugefügt, Kommentar aktualisiert
- deploy/docker-compose.yml: CADDY_HTTP_PORT + CADDY_HTTPS_PORT
- deploy/.env.example: Caddy-Port-Variablen dokumentiert

go build ./... && go vet ./... && go test ./... 
HLC-Tests: monoton, kausal, keine Duplikate 
AGENTS.md: Phase C vollständig 
2026-08-05 19:56:05 +02:00

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

# AGENTS.md Kontext & Fortschritt für KI-Agenten
> Diese Datei hält den Projekt-Stand und die Architekturentscheidungen fest, damit
> ein Agent (z.B. zuhause) sofort weiterarbeiten kann. Sie ist Teil des Repos.
## Projekt: Mitbringsl
Local-First Einkaufslisten-App (Bring-Alternative, werbefrei).
**Backend:** Go (net/http, pgx, golang-migrate, go-oidc) als Docker-Container.
**App:** Kotlin + Jetpack Compose, Room, Hilt, WorkManager, offline-first.
**Deployment:** docker-compose (Caddy + backend + migrate + postgres:16), auto-HTTPS.
Vollständiger Plan liegt als genehmigtem Plan zugrunde (siehe Abschnitt "Roadmap").
---
## Getroffene Architekturentscheidungen (verbindlich)
- **Backend-Sprache:** Go (nicht Rust).
- **DB:** PostgreSQL (`postgres:16-alpine`), Extensions `pgcrypto` + `pg_trgm`.
- **Sync:** Local-First, **keine Datenverluste** Append-only `op_log` (SOURCE OF TRUTH),
`items`/`lists` sind Projektionen; Konfliktlösung per LWW-Register `(hlc_ts, client_id)`
+ Tombstones (`deleted_at`). HLC = Hybrid Logical Clock.
- Push: `POST /api/lists/{id}/ops` (idempotent via `UNIQUE(client_id, client_seq)`).
- Pull: `GET /api/lists/{id}/ops?since={seq}`.
- **Auth:**
- Eigene User: E-Mail/Passwort, **Argon2id** (PHC-Format).
- OIDC: **Google** + **Generic OIDC**. Die **App macht den Code+PKCE-Flow selbst**
und schickt nur das `id_token` ans Backend (`POST /auth/oidc`).
Backend verifiziert via `github.com/coreos/go-oidc/v3` (Signatur gegen JWKS,
iss/aud/exp) und stellt **eigene opaque Session-Tokens** aus
(`sessions`-Tabelle, SHA-256-Hash gespeichert).
- **AppAuth-Android bewusst NICHT verwendet** (seit 2021 verwaist). Stattdessen:
Google via **Credential Manager**, Generic OIDC via **Custom Tabs + eigenes PKCE**.
- **Vorschläge:** aggregiert aus Item-Namen aller User (`item_names`-Tabelle,
pg_trgm fuzzy search). Endpoint `GET /api/suggestions?q=`.
- **MVP-Scope:** Single-Owner-Listen (`list_members` existiert, wird in Phase 2 für
geteilte Listen genutzt); keine Echtzeit-Push (nur Periodic-Pull 15 min + Pull-on-Online);
keine Web-UI.
---
## Technologie-Stacks (final)
### Backend
| Bereich | Wahl |
|---|---|
| Go-Version | **1.26** (`go.mod` hat `go 1.26`; Docker-Image `golang:1.26-alpine`) |
| HTTP | stdlib `net/http` (Go 1.22+ Routing mit Methoden + Path-Vars) |
| DB-Driver | `github.com/jackc/pgx/v5` (pgxpool) |
| Migrationen | `github.com/golang-migrate/migrate/v4` + `source/iofs` (eingebettet via `embed.FS`) |
| OIDC | `github.com/coreos/go-oidc/v3` + `golang.org/x/oauth2` |
| Passwörter | Argon2id (`golang.org/x/crypto/argon2`) |
| Sessions | opaque Tokens (32 B base64url), `sessions`-Tabelle, SHA-256-Hash |
| Config | `github.com/caarlos0/env/v11` (struct-tag env) |
| Logging | `log/slog` mit `NewJSONHandler` → stdout |
| Docker | Multi-Stage, `CGO_ENABLED=0`, `gcr.io/distroless/static-debian12:nonroot` |
| Queries | **direkt mit pgx** (sqlc wurde aus Skalierbarkeit bewusst auf später verschoben) |
### Android (noch nicht begonnen)
| Bereich | Wahl |
|---|---|
| Build | Kotlin DSL + `libs.versions.toml`, Single-Module `:app` |
| Kotlin/AGP/Gradle | Kotlin 2.x (K2), AGP 8.9+, Gradle 8.11+, JDK 17 |
| UI | Compose BOM (2025.x) + Material 3 |
| Arch | MVVM + `ViewModel` + `StateFlow`, UDF |
| DB | Room (KSP), Source of Truth via `Flow` |
| Netzwerk | Retrofit + OkHttp + kotlinx.serialization |
| Sync | WorkManager `CoroutineWorker` (Outbox-Drain + Cursor-Pull) |
| DI | Hilt (KSP) + `hilt-navigation-compose` + `hilt-work` |
| IDs | Client-seitige UUIDs |
| Auth | Google: Credential Manager; Generic OIDC: Custom Tabs + PKCE selbst |
| Suche | `OutlinedTextField` + `DropdownMenu`, Room-FTS, Server-Fallback |
| SDK | minSdk 26, compile/target 36 |
---
## Repository-Struktur
```
mitbringsl/
├── AGENTS.md ← DIES DATEI
├── README.md
├── .gitignore
├── backend/
│ ├── cmd/
│ │ ├── server/main.go # HTTP-Server-Einstieg (verdrahtet cfg/logger/pool/api)
│ │ └── migrate/main.go # Migrations-Runner (iofs-embedded)
│ ├── internal/
│ │ ├── config/config.go # Config (caarlos0/env)
│ │ ├── logging/logging.go # slog JSON-Setup
│ │ ├── store/db.go # pgxpool-Setup
│ │ ├── auth/ # PHASE B Password + Session + OIDC
│ │ │ ├── password.go # Argon2id im PHC-Format (HashPassword/VerifyPassword)
│ │ │ ├── password_test.go # PHC-Roundtrip-Tests
│ │ │ ├── user.go # UserStore: CreateUser/GetByEmail/GetByID/GetByOIDCSubject/CreateOIDCUser
│ │ │ ├── session.go # SessionStore: opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke
│ │ │ ├── oidc.go # OIDCService: id_token-Verifikation (JWKS/iss/aud/exp), Provider-Caching
│ │ │ └── pgcode.go # isUniqueViolation (SQLSTATE 23505)
│ │ ├── sync/ # PHASE C Hybrid Logical Clock
│ │ │ ├── hlc.go # HLC: Tick/Now/After, wall_ms<<16|counter, global mutex
│ │ │ └── hlc_test.go # HLC Unit-Tests (monoton, kausal, keine Duplikate)
│ │ ├── store/ # PHASE C Store-Schicht
│ │ │ ├── db.go # pgxpool-Setup
│ │ │ ├── opstore.go # AppendOps (idempotent, LWW-Projektion), PullOps (cursor)
│ │ │ ├── liststore.go # CreateList/GetLists/GetList
│ │ │ ├── itemstore.go # GetItems
│ │ │ └── suggeststore.go # Search (pg_trgm fuzzy)
│ │ └── httpapi/
│ │ ├── api.go # API-Objekt + Router (alle Routen aktiv)
│ │ ├── render.go # JSON-Render + Problem + Fehler-Sentinale + decodeJSON
│ │ ├── middleware.go # requestID/logging/recover/cors + Chain
│ │ ├── health.go # /healthz + /readyz
│ │ ├── auth.go # Register/Login/Logout/OIDC-Handler + RequireAuth-Middleware
│ │ ├── lists.go # GET/POST /api/lists, GET /api/lists/{id}
│ │ ├── ops.go # POST/GET /api/lists/{id}/ops (Push/Pull)
│ │ └── suggest.go # GET /api/suggestions?q=
│ ├── migrations/
│ │ ├── embed.go # //go:embed *.sql
│ │ ├── 000001_init_schema.up.sql # users/sessions/lists/list_members/items/op_log/item_names
│ │ └── 000001_init_schema.down.sql
│ ├── Dockerfile # Multi-Stage, baut server + migrate
│ ├── .dockerignore
│ ├── go.mod / go.sum
├── deploy/
│ ├── docker-compose.yml # caddy + backend + migrate + db (CADDY_HTTP/HTTPS_PORT)
│ ├── Caddyfile # standalone: auto-HTTPS (Let's Encrypt)
│ ├── Caddyfile.behind-proxy # hinter externem Reverse Proxy: auto_https off, trusted_proxies
│ ├── .env.example # alle env-Vars dokumentiert inkl. Caddy-Ports
│ └── db/init/001_extensions.sql # CREATE EXTENSION pgcrypto, pg_trgm
├── docs/ # NOCH LEER (folgt Phase F)
└── android/ # NOCH LEER (folgt Phase D)
```
---
## Roadmap / Fortschritt
Legende: ✅ erledigt · 🚧 in Arbeit · ⬜ offen
- ✅ Repo-Struktur + `.gitignore` + `README.md` + `git init` (Branch `main`)
-**Phase A Backend-Fundament:** Config, slog, pgxpool, `/healthz`+`/readyz`, Server-Main.
-**Phase A Migrationen:** vollständiges Init-Schema (up+down) + `migrate`-Binary (iofs).
-**Phase A Docker:** Dockerfile (Go 1.26 → distroless nonroot), docker-compose
(caddy/backend/migrate/db), Caddyfile, `.env.example`.
**Verifiziert:** Image baut, beide Binaries laufen im Container (Smoke-Test OK).
-**Phase B Auth (Password):** Argon2id im PHC-Format + `UserStore` (Create/GetByEmail/GetByID)
+ `SessionStore` (opaque Tokens, SHA-256-Hash, Create/Lookup/Revoke) + Handler
`Register`/`Login`/`Logout` + `RequireAuth`-Middleware.
**Verifiziert:** `go test ./internal/auth/...` grün, E2E-Smoke-Test gegen echtes
PostgreSQL via Docker (Register/Login/Logout/Duplicate/Short-PW/Wrong-PW alle korrekt).
-**Phase B OIDC:** `OIDCService` (go-oidc v3, JWKS-Signatur, iss/aud/exp, Provider-Caching)
+ `POST /auth/oidc` (find-or-create User via `(oidc_issuer, oidc_subject)`, reuses `issueSession`)
+ Config-Validierung (enabled → client_id/issuer Pflicht).
**Verifiziert:** E2E-Flow gegen lokalen Mock-IdP (Discovery → JWKS → signiertes id_token
→ User angelegt → 2. Login findet gleichen User → tampered Token → 401). Alle Fehlerpfade
geprüft (disabled→400, unknown provider→400, missing fields→400, invalid→401).
-**Phase C Sync-Kern:** HLC (`internal/sync/hlc.go`), `op_log`-Append idempotent mit
LWW-Projektion (items/lists) + Tombstones. `internal/store/opstore.go`.
**Verifiziert:** `go build/vet/test ./...` grün. HLC-Tests (monoton, kausal, keine Duplikate).
-**Phase C Endpoints:** `GET/POST /api/lists`, `GET /api/lists/{id}`,
`POST/GET /api/lists/{id}/ops` (Push idempotent + Pull mit Cursor `?since=`).
-**Phase C Suggestions:** `suggeststore.go` (pg_trgm fuzzy), `GET /api/suggestions?q=`,
`upsertItemName` in Projektion (innerhalb Push-Transaktion).
-**Phase C Caddy:** `Caddyfile.behind-proxy` (auto_https off, trusted_proxies),
`CADDY_HTTP_PORT`/`CADDY_HTTPS_PORT` in docker-compose.
-**Phase D Android-Fundament:** Gradle (Kotlin DSL, Version Catalog, Hilt/KSP, Compose BOM),
Theme, Nav, Room.
-**Phase D Repository + Retrofit-API + DTOs.**
-**Phase D Login-Screen** (eigene User + Google Credential Manager + Generic OIDC PKCE).
-**Phase E SyncEngine** (OutboxDrain + CursorPull via WorkManager), HLC client-side.
-**Phase E Listen-Übersicht + Detail + AddItemBar (Autocomplete) + Settings.**
-**Phase F Polish** (Fehlerbehandlung, Offline-Indikator, Empty States, Tests).
-**Phase F README + docs** (ARCHITECTURE/SYNC/API).
### Wo genau weitermachen?
**Phase C ist komplett ✅. Nächster Schritt = Phase D (Android-Fundament).**
Phase C erledigt:
-`internal/sync/hlc.go` HLC (wall_ms<<16|counter, Tick/Now/After, global mutex).
- `internal/store/opstore.go` AppendOps idempotent (ON CONFLICT DO NOTHING), LWW-Projektion
(list_create/rename/delete, item_add/update/remove), PullOps (cursor, 500er Pages).
- `internal/store/liststore.go` CreateList / GetLists / GetList (owner-check MVP).
- `internal/store/itemstore.go` GetItems (nicht-gelöschte Items einer Liste).
- `internal/store/suggeststore.go` Search (pg_trgm fuzzy, LIKE-fallback, 10 Ergebnisse).
- `internal/httpapi/lists.go` GET/POST /api/lists, GET /api/lists/{id} + Items.
- `internal/httpapi/ops.go` POST /api/lists/{id}/ops (Push), GET /api/lists/{id}/ops (Pull).
- `internal/httpapi/suggest.go` GET /api/suggestions?q=.
- `internal/httpapi/api.go` alle Routen verdrahtet.
- `deploy/Caddyfile.behind-proxy` auto_https off + trusted_proxies.
- `deploy/docker-compose.yml` CADDY_HTTP_PORT / CADDY_HTTPS_PORT.
Phase D Android-Fundament (offen):
1. Gradle-Setup: Kotlin DSL + `libs.versions.toml`, Hilt/KSP, Compose BOM, Room, Retrofit.
2. Theme (Material 3) + Navigation (Compose Nav).
3. Room-Datenbankschema (Listen/Items/OpLog/Outbox).
4. Retrofit-API + DTOs passend zu den Backend-Endpoints.
---
## Wichtige technische Notizen / Fallstricke (bereits gelöst)
- **golang-migrate `iofs`-Pfad:** Der Import ist
`github.com/golang-migrate/migrate/v4/source/iofs` (MIT `/source/`).
Die Pfade ohne `/source/` gibt es nicht mehr Build-Fehler.
- **Go-Version:** `pgx/v5 v5.10.0` braucht Go 1.25, deshalb Go 1.26
(lokal + Docker-Image `golang:1.26-alpine`). `go.mod` hat `go 1.26`.
- **Docker-Build auf Windows/Docker Desktop:** `--network host` im Build-Container
funktioniert NICHT (WSL2-NAT). Für Tests: echtes `docker network create` + Container-Namen nutzen.
- **Compose braucht zwingend `POSTGRES_PASSWORD`** (`.env` oder Env), da
`db.environment` mit `${POSTGRES_PASSWORD:?...}`-Assertion vor dem Build validiert wird.
- **Healthcheck im `backend`-Service wurde entfernt**, weil distroless/static kein
`wget`/`curl` enthält. Später: eigenes Binary, das `/healthz` per Go-HTTP prüft,
oder `healthcheck` über Caddy/extern.
- **SQL-Queries werden direkt mit pgx geschrieben** (kein sqlc im MVP), da sqlc lokal
nicht installiert ist und ein extra Code-Gen-Schritt nötig wäre. Bei Bedarf später
problemlos nachrüstbar.
---
## Build- & Test-Befehle
```bash
# Backend lokal bauen
cd backend && go build ./... && go vet ./...
# Backend-Image bauen
cd backend && docker build -t mitbringsl-backend:test .
# Komplettes Stack starten (braucht deploy/.env)
cd deploy && cp .env.example .env && docker compose up -d --build
# Migrationen manuell gegen bestehende DB anwenden
docker run --rm --network <net> \
-e DATABASE_URL="postgres://app:PW@<db-host>:5432/appdb?sslmode=disable" \
mitbringsl-backend:test /app/migrate up
```
## Git-Status
- Repo initialisiert, Branch `main`. Remote ist konfiguriert (`origin`).
- Phase A + Phase B (vollständig) committed und gepusht.
- Phase C (Sync-Kern + Caddy behind-proxy) committed und gepusht. **Phase C vollständig ✅.**
- Nächster Schritt: **Phase D Android-Fundament** (siehe Roadmap oben).