teecup/CLAUDE.md
Erol Haagenrud cc66d26cba TeeCup er nå containerisert og live på https://teecup.teeoff.no. Dette var den mest hendelsesrike runden denne økten — første gang noe rørte ekte, permanent infrastruktur, og det viste seg berettiget:
To reelle driftshendelser, begge funnet og rettet i sanntid:

Caddy plukket ikke opp filendringen min — enkeltfil-bind-mount er låst til inoden fra da containeren sist startet; min redigering (atomisk rename) laget en ny inode, så validate/reload/admin-API opererte alle stille på den gamle filen. Løst med full omstart av teeoff_caddy (du bekreftet eksplisitt, siden det avvek fra planens "ingen omstart"-løfte).
Alvorlig nettverkskollisjon — oppdaget rett etterpå da ekte teeoff-trafikk (/api/facilities?... med ekte klubbnavn) dukket opp i teecup_api sin logg. docker-compose.yml sin service-nøkkel api: kolliderte med teeoffs eget api-servicenavn på det delte nettverket — Docker Compose gir service-navn som DNS-alias, så begge containerne delte alias api, og Caddy kunne tilfeldig sende ekte brukertrafikk til TeeCup i stedet. Stoppet teecup_api umiddelbart, ga den navnet teecup_api i stedet, bekreftet kollisjonen er borte.
Mindre ting underveis: jeg eksponerte ved et uhell to secret-verdier i eget debug-output (du roterte passordet), og lærte at Docker ikke leser .env på nytt for en kjørende container (krever --force-recreate etter hver endring) og at # i et upassordet passord kuttes som kommentar av Compose.

Sluttresultat, verifisert ende-til-ende mot den ekte, live stacken: teeoff.no upåvirket gjennom hele prosessen, teecup.teeoff.no/health → 200 med automatisk TLS, full magic-link-innlogging med ekte e-postlevering og Secure-cookie.

CLAUDE.md/FEATURE_BACKLOG.md oppdatert med full detaljer, inkludert en generell lærdom for fremtidige tjenester på delt nettverk. To repoer har uncommittede endringer: /opt/teecup (nye Dockerfile/docker-compose.yml/.dockerignore + statusfiler) og /opt/teeoff (Caddyfile-endringen, egen repo). Ingenting committet ennå — si ifra når du vil det.
2026-07-16 21:47:34 +02:00

12 KiB

CLAUDE.md — arbeidsinstruks for TeeCup

Les dette først i hver økt. Det koder hva vi har bestemt og hvordan vi jobber.

Autoritative kilder (les før du gjør noe)

  • ARCHITECTURE_DECISIONS.md — hva som er bestemt og hvorfor (ADR-001…014). Fasit.
  • FEATURE_BACKLOG.md — hva som gjenstår, hva som er utsatt, hva som mangler.
  • Endres en beslutning: legg til en ny ADR, ikke slett historikk. Hold begge filene oppdatert når noe avgjøres.

Sikkerhetsregler (ufravikelige)

  • Rør ALDRI teeoff-databasen eller den ekte teecup_db uten at brukeren eksplisitt har bekreftet det i samme økt. Test alltid migrasjoner mot en egen scratch-database først, og rydd opp etterpå.
  • Vis planen (hvilke kommandoer, mot hvilken database) FØR du kjører noe som skriver, migrerer eller sletter. Vent på bekreftelse.
  • Hemmeligheter (passord, secrets) bor i .env (filrettigheter 600), dekkes av .gitignore, committes aldri, og skrives aldri i klartekst i chatten eller i SQL-filer. Generer dem på serveren (openssl rand -base64 32).
  • Kjør appen som databaserollen teecup_app (NOSUPERUSER, NOBYPASSRLS) — aldri som teeoff_admin/superuser i runtime.

Arkitektur-invarianter (ikke bryt uten en ny ADR)

  • Tenant = organisasjon. organization_id på alle domenetabeller, håndhevet av RLS. App-koden setter app.current_org med SET LOCAL per transaksjon.
  • Verifiser at brukeren er medlem av organisasjonen FØR org-konteksten settes. RLS stoler blindt på app.current_org.
  • Egen innlogging (uavhengig av teeoff). Banedata hentes fra teeoff via lesende API, ikke delt database.
  • v1 = nøyaktig to lag (Ryder Cup-format), håndhevet i app-laget. Match-modellen holdes generell (to sider) så knockout/flere lag kan komme senere.
  • Handicap-/matchlogikk skal ligge i handicap_engine.py (rent, testet, uten db/API-avhengigheter). Allowances er konfig, ikke hardkodet.
  • Media (bilder/video) skal i objektlagring (MinIO), ikke i Postgres. Postgres holder bare metadata + nøkkel.

Arbeidsmåte

  • Inkrementelt. Ingenting tas for gitt før det er testet. Bekreft hvert steg før du går videre.
  • Bruk git (remote: brukerens Forgejo). Commit i logiske steg med tydelige meldinger.
  • Er du usikker på omfang eller en beslutning: spør heller enn å gjette.

Status (oppdater denne når ting endres)

Ferdig og verifisert:

  • Handicap-motor + tester (24/24, R&A-verifisert).
  • Skjema 001 + roller 002 + scoring/blind draw 003. Isolasjon bevist med test_isolation.sql (RLS-oppførsel, ikke bare at skjemaet kjører).
  • 002 hadde en reell bug (psql interpolerer ikke :'var' inne i DO $$...$$) — permanent fikset, verifisert mot scratch to ganger.
  • API-et kjørt for ekte (ikke bare syntaks-sjekket) i en engangs Docker- container mot en scratch-database, RLS bevist gjennom hele asyncpg-pool-stacken (ikke bare i rå SQL).
  • Oppsett-endepunktene er bygget og verifisert: app/routers/players.py (spillerpool), tournaments.py (turnering/lag/roster/økter, ADR-011 to-lags-grense håndhevet med FOR UPDATE-lås), matches.py (matcher/ deltakere/blind draw-lås, ADR-013-synlighet push-down i SQL). Delt feiloversettelse i app/errors.py, delte synlighetsspørringer i app/blind_draw.py. main.py er nå bare app-factory + include_router.
  • Scoring-runden er bygget og verifisert for ekte mot scratch-db (18-hulls bane med tee_rating, 4 spillere for fourball-testing): app/handicap.py (ADR-014 fire brytere via parse_allowance_config, handicap beregnes i compute_and_store_side_handicaps rett etter deltaker-innsetting — singles/fourball per spiller umiddelbart, foursome/greensome/scramble kun når siden er komplett), app/routers/scoring.py (hole-scores/ hole-results-upsert, scorecard-GET, matchstatus-recompute med FOR UPDATE-lås mot race og SAMMENHENGENDE-prefiks-regel for uferdige hull). app/team_authz.py skilt ut fra matches.py (delt med scoring.py). Alle 10 planlagte tester bestått, inkl. fourball better-ball-aggregering (MIN av to nettoer, venter til begge partnere har registrert), poeng-caching ved tidlig avgjort match, og ADR-014-bryteren use_handicap=false. Fant og fikset underveis: tournaments.py sin SessionCreate manglet scoring_mode helt (økter kunne aldri opprettes i hole_result-modus via API-et) — lagt til. Bevisst utelatt/kjente begrensninger: en side som aldri når forventet deltakerantall (no-show) får aldri beregnet handicap og matchen kan da aldri avgjøres — ingen manuell overstyring bygget. Score-skriving er upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser. Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ); bar er «rostret på laget».
  • Match-lås ved avgjørelse (2026-07-16): submit_hole_score/ submit_hole_result avviser nå 409 hvis match.points_side_a IS NOT NULL (matchen er avgjort) — FØR upserten kjøres, både for nye hull og korrigering av allerede talte hull. Tetter en reell bug: uten dette kunne «spøkelses-hull» lagt inn etter avgjørelse endre en allerede cachet margin ved neste omregning. Automatisk, ingen ny autorisasjon involvert.
  • Ekte autentisering bygget og verifisert (2026-07-16): X-Debug-User-Id- stubben er HELT fjernet (ingen fallback). Magic-link + JWT-sesjon i app/routers/auth.py + app/auth.py (request-link/verify-link/ logout/me), ny migrasjon 004_auth.sql (magic_link_token-tabell + unik e-post-indeks på app_user). Token = secrets.token_urlsafe(32), kun SHA-256-hash lagres, atomisk forbruk (UPDATE ... RETURNING, ikke les-sjekk-skriv), generisk respons uansett om e-posten finnes (unngår enumerering), gamle uforbrukte lenker ugyldiggjøres når en ny utstedes, app_user opprettes FØRST ved vellykket verifisering (ikke ved forespørsel). Sesjons-JWT (PyJWT, algorithms=["HS256"] eksplisitt) i HttpOnly/SameSite=Lax/dynamisk-Secure-cookie, 30 dager, med et ekte eksistens-oppslag mot app_user på hver forespørsel (faktisk tilbakekalling — en slettet bruker kan ikke ri ut sesjonen). Alle 12 planlagte tester bestått. Fant og fikset underveis: ON CONFLICT (email) matchet ikke den nye PARTIELLE unike indeksen uten eksplisitt WHERE email IS NOT NULL (samme klasse feil som hole_scores partielle indekser i scoring-runden). Fant, IKKE fikset i denne runden (egen runde rett etterpå — se under): organization-tabellens RLS-policy kastet en 500 i stedet for skjemaets lovede "trygg standard: se ingenting" ved tomstreng-GUC.
  • RLS-tomstreng-bug FIKSET (2026-07-16): ny migrasjon 005_rls_null_guard.sql — delt STABLE SQL-funksjon app_current_org() gjør NULLIF(current_setting('app.current_org', true), '')::uuid i stedet for det rå uttrykket, brukt av alle 15 RLS-policyer (ALTER POLICY, 14 org_isolation + org_self). Verifisert med 3 nye regresjonstester i test_isolation.sql (Test 10-12) OG ved faktisk å gjenskape original- buggen mot en ekte container (pool-størrelse 1, varm opp med org_connection(), deretter /auth/me på samme gjenbrukte tilkobling — gikk fra 500 til 200). Viktig presisering fra denne runden: fiksen gjør IKKE at /auth/me kan joine organization direkte via plain_connection() — det var en feilaktig antakelse i forrige runde. org_self krever fortsatt en MATCHENDE app.current_org for å vise en rad (riktig RLS-design, ikke noe fiksen skulle endre), og en bruker kan tilhøre flere organisasjoner samtidig, så det finnes ingen ÉN kontekst å sette for en tverr-org-spørring. /auth/me slår derfor opp hvert org-navn ett om gangen via org_connection() (N+1, N = antall org-er brukeren tilhører) — dette er riktig løsning, ikke en omvei.
  • Organisasjon-bootstrap bygget og verifisert (2026-07-16): nytt POST /orgs (app/routers/organizations.py) — det ENESTE stedet i API-et som setter inn en organization-rad. Fant under statusgjennomgang at dette manglet helt (alle tidligere org-er var seedet med superbruker-SQL). Ingen ny migrasjon. Selvrefererende RLS-bootstrap bekreftet å fungere: generer org-ens uuid i Python, sett app.current_org til nøyaktig den via eksisterende org_connection(), sett inn organization-raden med samme id — org_selfs implisitte WITH CHECK blir da trivielt sann, ingen privilegert tilkobling nødvendig (i motsetning til hva 002s kommentar antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende id avvist med insufficient_privilege) og full kryss-org-isolasjon mellom to uavhengig opprettede organisasjoner.
  • Ekte SMTP-utsending bygget og verifisert (2026-07-16): ny app/email.py (send_magic_link_email, smtplib via asyncio.to_thread, håndterer både implisitt TLS/port 465 og STARTTLS dynamisk). Brukeren la egne SMTP-credentials i .env (TEECUP_SMTP_*, TEECUP_FROM_EMAIL — ADR-009, ikke delt med teeoff); jeg leste kun nøkkelnavnene for å bekrefte de fantes, aldri verdiene. app/config.py sin SMTP_CONFIGURED er valgfri (ikke _required) — dev-only logging (TEECUP_DEV_LOG_MAGIC_LINKS) fortsatt fungerer uendret når SMTP ikke er satt opp. Driftsfeil i utsendingen lekker aldri til klientresponsen (bevarer anti-enumerering). Verifisert med faktisk levering: sendte én ekte test-e-post til en adresse brukeren oppga — brukeren bekreftet mottak. Første gang noe i prosjektet er bevist ved ekte levering, ikke bare curl/scratch.
  • Containerisert og LIVE på teecup.teeoff.no (2026-07-16): ekte teecup_db opprettet (migrasjoner 001→005 kjørt permanent, test_isolation.sql består), Dockerfile + docker-compose.yml (tjeneste teecup_api, joiner det eksisterende teeoff_default-nettverket), Caddy-blokk lagt til i /opt/teeoff/deploy/Caddyfile. Ekte innlogging (magic-link → e-post → JWT-sesjon med Secure-cookie) verifisert ende-til-ende mot den live stacken. teeoff.no upåvirket gjennom hele prosessen. To reelle hendelser underveis, begge løst:
    1. Caddy plukket ikke opp filendringenteeoff_caddy sin Caddyfile-mount er en ENKELTFIL-bind-mount, låst til inoden som fantes da containeren sist startet. Min fil-redigering (atomisk rename) laget en ny inode på samme sti, så containeren fortsatte å lese den GAMLE filen uansett hvor mange ganger caddy validate/caddy reload/admin-API /load ble kjørt (alle validerte/lastet den uendrede gamle filen, derav ingen feilmelding). Løst med en full docker restart teeoff_caddy (brukeren bekreftet — avvek fra planens "kun graceful reload, ingen omstart"-løfte, noen sekunders nedetid for teeoff.no).
    2. Alvorlig nettverksalias-kollisjon (funnet RETT ETTER omstarten, da ekte teeoff-trafikk som /api/facilities?... med ekte klubb-slugs dukket opp i teecup_api sin logg): docker-compose.yml sin service-nøkkel var api: — SAMME nøkkel som teeoffs eget api-servicenavn (docker-compose.prod.yml). Docker Compose registrerer nettverksalias basert på service-NAVNET (ikke bare container_name) på delte nettverk, så BEGGE containerne fikk alias apiteeoff_default — Caddys reverse_proxy api:8000 i teeoff sin egen config kunne da tilfeldig treffe enten ekte teeoff_api eller teecup_api. Rettet umiddelbart (stoppet teecup_api først for å hindre videre feilruting av ekte teeoff-trafikk, ga service-nøkkelen navnet teecup_api i stedet, gjenopprettet — bekreftet med docker network inspect at alias api nå KUN peker på ekte teeoff_api). Mindre driftslærdom: (a) jeg eksponerte ved et uhell TEECUP_SMTP_PASS/TEECUP_FROM_EMAIL i eget debug-output mens jeg feilsøkte en .env-korrupsjon (manglende linjeskift fra min egen >>-tilføyelse) — brukeren roterte passordet som forsiktighetsregel; (b) Docker leser IKKE .env på nytt for en allerede kjørende container — docker compose up -d --force-recreate kreves etter enhver .env-endring som skal tas i bruk; (c) et #-tegn i et upassordet .env-passord kuttes som en kommentar av Compose sin parser — anførselstegn (fortrinnsvis enkle) løser dette.

Neste steg:

  1. Frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, siden 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE startet ( i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — helt frem til nå har API-et vært nåbart, men uten noe grensesnitt en sluttbruker kan bruke.