diff --git a/docs/superpowers/plans/2026-10-01-website-analyse.md b/docs/superpowers/plans/2026-10-01-website-analyse.md new file mode 100644 index 0000000..9ff94ad --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-website-analyse.md @@ -0,0 +1,137 @@ +# Website-Analyse – Umsetzungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Kunden mit Wartungs-Abo schalten im Portal eine Website-Analyse ein; Banner + Skript werden vom +Runner eingesetzt, ein eigener Sammeldienst zählt datenschutzkonform, das Portal zeigt Zahlen und Heatmap. + +**Architecture:** Neuer Dienst `analytics/` (Node 24, `node:sqlite`, keine Abhängigkeiten) unter +`https://project.webklar.com/a/…`; Client-Skripte `w.js`/`h.js` liefert er aus. Der Runner setzt das +Skript beim Veröffentlichen ein. Das Portal verwaltet Freischaltung (portalFeatures), spricht den +Sammeldienst per Admin-Token an und zeigt die Auswertung. WOMS bekommt einen Schalter. + +**Tech Stack:** Node 24 (Sammeldienst), Node 20 (Portal/Runner), vanilla JS (Portal-UI, Client-Skript), +React (WOMS), Playwright (Prüfung). + +**Spec:** `docs/superpowers/specs/2026-10-01-website-analyse-design.md` + +**Worktrees:** Portal/Runner/Analytics `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse` +(Branch `feature/website-analyse`). WOMS: `git -C /home/webklar/apps/ticket.webklar.com/build worktree add +-b feature/website-analyse ../dev-analyse origin/test`, node_modules skip-worktree + eigenes `npm install`. + +## Global Constraints + +- Nur Wartungs-Abo: `productKey === 'wartung'` und Status ∈ `ACTIVE_STATUSES` (stripeBilling.js). Hosting ausgeschlossen. +- Nur öffentliche Projekte (`isPublic === true`) mit Subdomain; Domains der Website = `.project.webklar.com` + `liveDomain` (falls gesetzt). +- Ereignisse exakt laut Spec-Tabelle (`pv`, `bn`, `en`, `ck`, `ct`) mit genau den dort genannten Feldern; `bn` ohne weitere Felder; `en`/`ck`/`ct` nur nach Annehmen. +- Keine IP, kein User-Agent gespeichert; keine Cookies; Banner-Wahl nur `localStorage['wk-consent'] = {v:1,a,t}`, 6 Monate gültig; GPC/DNT ⇒ wie Ablehnen ohne Banner. +- Besucher-Kennung `HMAC-SHA256(tagesSalz, siteKey|ip|ua)` → 16 Hex, Salz nur RAM, Wechsel 00:00 Europe/Berlin. +- Rohdaten/Klicks 30 Tage; Websites mit `offSince` > 60 Tage komplett löschen; Job täglich 02:30 Europe/Berlin. +- Limits: Body ≤ 4 KB, path ≤ 300, sel ≤ 200, sec 0–3600, scroll 0–100, x/y 0–1, 120 Ereignisse/Min je (site, IP). +- Banner: Leiste unten, „Ablehnen“ und „Annehmen“ gleich groß/gleich auffällig, Link „Datenschutz“ → `/datenschutz-analyse.html`; Akzentfarbe nur für „Annehmen“ (Kontrast ≥ 4,5:1, sonst Fallback `#111111`); Handy (< 768 px) kompakte Karte; Footer-Link „Cookie-Einstellungen“. +- siteKey `^[a-z0-9]{16,40}$`, Farbe `^#[0-9a-f]{6}$`. +- Heatmap-Link: HMAC-Token, 30 Minuten gültig, enthält siteKey, path, vp, exp. +- Schalter `ANALYSE_ENABLED` (Vorgabe off) ⇒ Kachel bleibt „Kommt bald“, Routen 409. +- Texte deutsch (Du-Form im Portal), kein `confirm()`. Nichts pushen/live schalten. + +## Review Focus + +1. Ohne „Annehmen“ verlässt kein `en`/`ck`/`ct` den Browser (auch nicht nach Widerruf). → Task 6 +2. Fremde Origin oder unbekannter siteKey ⇒ nichts gespeichert. → Task 2 +3. Analyse aus / Abo weg ⇒ nach Neubau kein Skript und keine Zusatzseite mehr im Ordner. → Task 7, 10 +4. Heatmap-Token einer Website liefert keine Punkte einer anderen Website. → Task 4 +5. Kontakt-Klick speichert nie Telefonnummer/E-Mail-Adresse. → Task 6 + +--- + +## Sammeldienst (`analytics/`) + +### Task 1: Gerüst, Datenbank, Website-Register +**Files:** `analytics/package.json` (type module, test `node --test test/*.test.js`), `src/config.js`, `src/db.js`, `src/server.js`, `src/index.js`, `test/sites.test.js`. +**Produces:** `loadConfig(env) ⇒ { port: 8090, dataDir: '/srv/webklar-analytics', adminToken, heatSecret, geoFile, baseHost: 'project.webklar.com' }`; `openDb(path) ⇒ db` (Schema: `sites(siteKey PK, projectId, domains JSON, active INT, offSince TEXT)`, `events_raw(id, site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)`, `clicks(id, site, ts, path, vp, x, y, sel)`, `daily(site, day, dim, key, count, PK(site,day,dim,key))`); Admin-Routen (Bearer adminToken, timingSafeEqual): `PUT /a/admin/sites/:siteKey {projectId, domains[], active}`, `DELETE /a/admin/sites/:siteKey`, `GET /a/health`. +- [ ] Tests: 401 ohne/mit falschem Token; PUT legt an/ändert; active=false setzt offSince einmalig; ungültiger siteKey 400; Domains nur Hostnamen. +- [ ] FAIL → implementieren → PASS → Commit „Analytics: Geruest, Datenbank, Website-Register“ + +### Task 2: Annahme `POST /a/e` +**Files:** `src/ingest.js`, `src/ua.js`, `src/salt.js`, `test/ingest.test.js`. +**Produces:** `classifyUa(ua) ⇒ { bot:boolean, device:'m'|'t'|'d' }` (kleine eigene Regeln, gängige Bots); `createSalt({ now }) ⇒ { visitorId(site, ip, ua) }` (Rotation 00:00 Berlin); Handler: Body ≤ 4 KB text/plain JSON; Website aktiv + Origin/Referer-Host ∈ domains, sonst 204 ohne Speichern; Felder streng nach Global Constraints validieren/kürzen; Land über `geo.lookup(ip)` (Task 3, injizierbar); Limit 120/Min je (site, ip); CORS-Header nur für passende Origin; Antwort 204; nie `Set-Cookie`. +- [ ] Tests: pv gespeichert ohne visitor; bn nur mit a; en/ck/ct mit visitor; fremde Origin ⇒ nichts; Bot ⇒ nichts; 121. Ereignis ⇒ verworfen; Query/Hash aus path entfernt; ref nur Host und nicht eigene Domain; kein IP/UA in der Datenbank (Tabelleninhalte prüfen); Salz wechselt um Mitternacht Berlin. +- [ ] FAIL → implementieren → PASS → Commit „Analytics: Ereignisse annehmen“ + +### Task 3: Länder (DB-IP Lite) +**Files:** `src/geo.js`, `scripts/geo-update.sh` (lädt monatliche CSV `dbip-country-lite-YYYY-MM.csv.gz` nach `dataDir/geo.csv`), `test/geo.test.js`. +**Produces:** `loadGeo(file) ⇒ { lookup(ip) ⇒ 'DE'|…|'' }` – IPv4 und IPv6 als BigInt-Bereiche, sortiert, Binärsuche; fehlende Datei ⇒ '' für alles. +- [ ] Tests mit kleiner Fixture-CSV (v4, v6, Grenzen, unbekannt). FAIL → implementieren → PASS → Commit „Analytics: Laenderzuordnung“ + +### Task 4: Verdichtung, Löschung, Statistik, Heatmap-Daten +**Files:** `src/jobs.js`, `src/stats.js`, `src/heat.js`, `test/stats.test.js`. +**Produces:** `runNightly(db, { now })` (Rohdaten > 1 Tag in `daily` verdichten: dims `pv_path`, `pv_ref`, `pv_country`, `pv_device`, `pv_total`, `visitors`, `en_sec_sum`, `en_count`, `scroll_sum`, `ct_kind`, `bn_shown|accept|reject`; Rohdaten/Klicks > 30 Tage löschen; Websites offSince > 60 Tage: alle Zeilen löschen); `GET /a/admin/stats/:siteKey?range=7|30|90` ⇒ `{ totals:{pv, visitors, contacts, consentRate}, series:[{day,pv}], pages, refs, countries, devices, contacts, banner }` (heute aus Rohdaten + Vergangenheit aus daily); `GET /a/heat?token=` (Token HMAC heatSecret: siteKey|path|vp|exp, abgelaufen/falsch ⇒ 403) ⇒ `{ points:[{x,y}] }` max 5000 neueste. +- [ ] Tests: Verdichtung + idempotent; 31 Tage alte Rohdaten weg; Website nach 61 Tagen weg; Stats-Form; consentRate = accept/(accept+reject); Token fremder Website/abgelaufen ⇒ 403. +- [ ] FAIL → implementieren → PASS → Commit „Analytics: Verdichtung, Statistik, Heatmap-Daten“ + +### Task 5: Auslieferung der Client-Skripte +**Files:** `src/assets.js`, Route `GET /a/w.js`, `GET /a/h.js` (Cache-Control `public, max-age=3600`, `Content-Type: text/javascript`), Quellen in `client/` (Task 6). +- [ ] Test: beide Dateien ausgeliefert, richtige Header, ETag. → Commit „Analytics: Skripte ausliefern“ + +### Task 6: Client-Skript `w.js` und Overlay `h.js` +**Files:** `analytics/client/w.js`, `analytics/client/h.js`, `analytics/client/core.js` (reine Funktionen, auch von Tests importiert), `test/client.test.js`. +**Produces (core):** `vpClass(width)`, `cleanPath(href)`, `refHost(referrer, ownHost)`, `cssPath(el)` (≤ 200, ohne IDs mit Zahlen-Hashes), `contactKind(a)` ⇒ `'tel'|'mail'|'wa'|'out'|null` (wa: wa.me / api.whatsapp.com), `readConsent(storage, now)`, `contrastOk(hex)`. +`w.js`: liest `data-site`/`data-color` aus dem eigenen ``; erzeugt `datenschutz-analyse.html` aus Vorlage `runner/src/templates/datenschutz-analyse.html` (Platzhalter HTML-maskiert; Task 7 legt die Vorlage mit vorläufigem Text und allen Platzhaltern an, Task 13 schreibt den endgültigen Inhalt); ohne `analytics` nichts tun (ein früher eingesetztes Skript existiert im neuen Ordner ohnehin nicht, da aus der Quelle neu gebaut). +- [ ] Tests: Einsetzen in mehrere Dateien, kein Doppel, ohne head, Maskierung (`