4 Commits

Author SHA1 Message Date
Benjamin Admin 9e3604fe31 Merge branch 'main' of ssh://gitea.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 39s
CI / test-python-voice (push) Successful in 28s
CI / test-bqas (push) Successful in 31s
2026-05-12 16:36:59 +02:00
Benjamin Admin 0c09b960b9 feat(cmp): Phase 2 complete — self-hosted fonts, ScriptManager, GeoIP, vendor UI
- Session ID via sessionStorage UUID
- Self-host Google Fonts (Inter, Plus Jakarta Sans, JetBrains Mono) — eliminates
  third-party transfer to Google, no more DSGVO violation
- ScriptManager component: consent-change listener for future analytics/marketing scripts
- GeoIP via browser timezone (Intl.DateTimeFormat) + IP injection in proxy
- Vendor-level consent UI: loads vendor config from backend, shows per-vendor
  toggles under each category, sends vendor_consents dict
- DSE updated: Google Fonts section now says "lokal gehostet"
- Config proxy route: GET /api/consent/config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-12 14:42:55 +02:00
Benjamin Admin f6489e7748 feat(cmp): Phase 2 — send scripts_blocked, scripts_released, cookies_set
ConsentBanner detects loaded scripts (analytics/marketing) and cookies
after consent, sends them to the CMP backend for transparency tracking.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 22:52:41 +02:00
Benjamin Admin 519cc274bb docs: session handover — MC Quality + Gap Engine + RAG Ingestion (5 Tage)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 21:47:22 +02:00
11 changed files with 352 additions and 206 deletions
+86 -163
View File
@@ -1,194 +1,117 @@
# Session-Instruktionen: Master Control Qualitaet + Regulation-Source Split # Session-Handover: MC Quality + Gap-Analyse + RAG Ingestion
**Datum:** 2026-05-06 **Datum:** 2026-05-07 bis 2026-05-11 (5 Tage Marathon)
**Fuer:** Naechste Claude-Session **Repo:** breakpilot-core + breakpilot-compliance
**Repo:** breakpilot-core (~/Projekte/breakpilot-core)
--- ---
## NAECHSTER SCHRITT: 25 grosse Master Controls aufsplitten ## ERLEDIGT
### Problem ### Master Control Quality Overhaul (Core)
- **74.5% → 92.8% Accuracy** (13.588 MCs, 83.073 Members)
- Phase 0: Quality Audit mit Claude Sonnet ($3)
- Phase 1: Ontologie 31 → 74 Tokens + LLM-Prompt fix
- Phase 2: 174K Controls re-klassifiziert via Haiku (10 Batches, ~$50)
- Phase 2b: Generic Tokens gefixt (documentation/procedure → echte Themen, $7.54)
- Phase 2c: L2 Sub-Topics (2 Runden, 172K Controls, ~$32)
- Phase 2d: Bad Subtopics gefixt (stakeholder_*, $0.50)
- Phase 3: Re-Clustering K=18704
- Phase 4: gpre2 Direct MC (13.588 MCs)
- Phase 6: Golden Dataset (20 Controls) + 8 Quality Tests (alle grün)
- **Production Sync:** MCs + Members + Hints + doc_check_controls
25 Master Controls sind zu generisch (>200 Atomic Controls pro MC). Sie basieren auf generischen Security-Domain-Keywords wie "monitoring", "encryption", "personal_data". Embedding-Clustering allein reicht nicht — die Controls handeln zwar alle von "monitoring", aber fuer unterschiedliche Regulierungen (DSGVO, NIS2, NIST, BSI etc.). ### doc_check_controls (Core → Production)
- **1.874 Controls** über 8 Dokumenttypen (DSE, Cookie, Impressum, AGB, Widerruf, DSFA, AVV, Löschkonzept)
- Jeder mit check_question + pass_criteria + fail_criteria
- Tabelle `compliance.doc_check_controls` lokal + Production
### Die 25 betroffenen MCs ### RAG Ingestion (Core)
- **126 BAuA PDFs** (TRBS/TRGS/ASR): 27.664 Chunks → `bp_compliance_ce`
- **OSHA Technical Manual** (23 Kapitel): 7.241 Chunks → `bp_compliance_ce`
- **OSHA 1910 Subpart O** (Volltext): 745 Chunks
- **EuGH C-588/21 P**: 216 Chunks
- **EU 2018/1725**: 842 Chunks → `bp_compliance`
- **CE-Obligations extrahiert:** 6.141 Obligations → `/tmp/ce_obligations_v2.json`
- Playwright-Crawler für BAuA + OSHA gebaut
| MC-ID | Name | Controls | Problem | ### Gap-Analyse Engine (Compliance)
|-------|------|----------|---------| - **12 Regulierungen** automatisch klassifiziert (CRA, AI Act, NIS2, DSGVO, MiCA, PSD2, AML, etc.)
| MC-8292 | monitoring | 6.157 | Alles von Video bis Vulnerability | - **IST-Zustand Assessment:** CE-Kennzeichnung, angewandte Normen, bestehende Prozesse, IACE-Projekt-Link
| MC-2260 | procedure | 4.176 | Generisch | - **Norm→Control Mapping:** 20 Normen → MC-Topic Coverage
| MC-8302 | alerting | 3.126 | Meldepflichten aller Gesetze gemischt | - **Prioritäts-Engine:** Severity × Deadline × Dependency
| MC-8306 | personal_data | 3.057 | DSGVO + NIS2 + AT/CH gemischt | - **5 Branchentemplates:** IoT, Exchange, Cobot, SaaS, Medical
| MC-8312 | training | 2.572 | | - **Frontend:** 2-Step Wizard (Produkt + IST-Zustand) + Dashboard mit Ampel-Status
| MC-7932 | certificate_management | 2.350 | | - **API:** 8 Endpoints unter `/sdk/v1/gap/`
| MC-8317 | incident | 2.288 | | - **Persistente Projekte:** Speichern + wieder öffnen
| MC-8329 | encryption | 1.790 | | - **Getestet:** SmartFactory Gateway → 5 Regulierungen, 500 Gaps
| MC-8333 | audit_logging | 1.645 | |
| MC-8321 | policy | 1.463 | |
| MC-8325 | patch_management | 1.155 | |
| MC-8338 | network_security | 1.071 | |
| ... | (13 weitere) | 200-960 | |
### Loesung: Regulation-Source Split ### Tenant Document Upload API (Core)
- `POST/GET/DELETE /api/v1/tenant/documents`
- Tenant-isolierte Qdrant-Collections
- Code fertig, nicht deployed (RAG Service rebuild nötig)
Statt nur nach Embedding-Aehnlichkeit zu clustern, nach **Regulation-Quelle** aufteilen: ### Master Controls Browser (Compliance)
- **Neue Seite** `/sdk/master-controls` — reused Control Library UI
``` - Sidebar-Eintrag zwischen Control Library und Provenance
MC "encryption" (1.790 Controls) - 13.588 MCs mit allen Filtern, Paginierung, Klick-Detail
→ encryption_dsgvo (DSGVO Art. 32, ~200) - Verbindet sich mit Production-DB
→ encryption_nis2 (NIS2 Art. 21, ~150)
→ encryption_nist (NIST SC-13, ~300)
→ encryption_bsi (BSI, ~200)
→ encryption_owasp (OWASP, ~100)
→ encryption_other (~840)
```
### Script-Ansatz
```python
# Fuer jeden der 25 grossen MCs:
# 1. Hole alle member controls mit source_citation->>'source'
# 2. Gruppiere nach source (Regulation)
# 3. Erstelle Sub-MCs pro Regulation
# 4. Controls ohne source → "general" Sub-MC
```
### Qualitaetsanforderung (WICHTIG!)
**Nur "sehr gut" ist akzeptabel.** Mittlere MCs (30-100 Controls) sind bereits excellent:
- MC-1082 (data_retention_policies, 52) → perfekt koharent
- MC-5477 (austausch_von_cybersicherheitsinformationen, 5) → perfekt
Ziel: ALLE MCs sollen diese Qualitaet haben. Kein MC >100 Controls.
--- ---
## SESSION 03-06.05.2026 KOMPLETT ERLEDIGT ## DB-Tabellen (neu/geändert)
### Block F (Hardcoded Knowledge → DB) | Tabelle | Repo | Rows (lokal) | Rows (Production) |
- F1: regulation_registry (223 Eintraege) ✅ |---------|------|-------------|-------------------|
- F2: action_types (34) + action_synonyms (368) ✅ | compliance.master_controls | Core | 13.588 | 13.588 |
- F3: object_synonyms (320) ✅ | compliance.master_control_members | Core | 83.073 | 83.073 |
- F4: LLM Enrichment (+468 Synonyme via Ollama) ✅ | compliance.object_ontology | Core | 74 | 74 |
- F5: Validation (8 Tests, Dicts als Fallback) ✅ | compliance.object_groups | Core | 16.683 | — |
| compliance.doc_check_controls | Core | 1.874 | 1.874 |
### Control Generation Pipeline | compliance.gap_projects | Compliance | 1 | 0 |
- 1.599 Rich Controls aus E-Block Chunks (~$17 Anthropic)
- 11.522 Obligations (Pass 0a, ~$4)
- 1.147 Atomic Controls (Pass 0b, ~$4.60)
- **Gesamtkosten: ~$25.60**
### Production Sync
- 2.625 Controls + 11.522 Obligations auf Production synchronisiert
- Production: 294.027 Controls total
- Backups: lokal + production auf MacBook
### Block G-pre (Master Controls)
- G-pre1: 144k Objects → 7.753 Gruppen (K-Means k=5000 + Sub-Cluster + Refinement)
- G-pre2: 5.329 Master Controls, 172.504+ Members
- G-pre3: Master Control API (list, stats, detail)
- **Qualitaet:** Kleine/mittlere MCs excellent, 25 grosse MCs brauchen Regulation-Source Split
### Block G (Compliance Execution Layer)
- G1: Decision Trace (decision_traces Tabelle + 6 API Endpoints) ✅
- G2: Compliance Commit Ledger (compliance_commits + 5 Endpoints) ✅
- G3: Full Decision Memory (decision_events + Timeline + 4 Endpoints) ✅
- G4: Pre-Deployment Enforcement (deployment_checks + Override + 4 Endpoints) ✅
### Infrastruktur
- Vault CPU-Fix committed (Marker-File + idempotente Checks)
- Pass 0a Endpoint im Core Control-Pipeline registriert
- Gitea Timezone-Fix (docker-compose.yml)
- 61 neue regulation_ids in regulation_registry
- Container-Cleanup (fewo-finance-agent, mediaanalysisd)
--- ---
## DB-Tabellen (alle Bloecke) ## OFFEN / NÄCHSTE SESSION
| Tabelle | Rows | Migration | 1. **Orca Deploy-Fix** — Production deployed nicht automatisch (Webhook + docker pull Problem)
|---------|------|-----------| 2. **Gap-Analyse v2 IST-Zustand** — Frontend Step 2 deployed, Backend deployed, aber Orca blockiert
| compliance.regulation_registry | 223 | 002 | 3. **Tenant Document Upload** deployen (RAG Service rebuild)
| compliance.action_types | 34 | 003 | 4. **Compliance-Repo auf gitea pushen** — aktuell "Everything up-to-date", Orca muss manuell redeployt werden
| compliance.action_synonyms | 368 | 003 | 5. **MC-Browser erweitern** — Detail-View mit Member-Controls verbessern
| compliance.object_synonyms | 320 | 003 |
| compliance.object_groups | 7.753 | 004 |
| compliance.master_controls | 5.329 | 005 |
| compliance.master_control_members | ~170k | 005 |
| compliance.decision_traces | 0 (Schema ready) | 006 |
| compliance.compliance_commits | 0 (Schema ready) | 007 |
| compliance.decision_events | 0 (Schema ready) | 008 |
| compliance.deployment_checks | 0 (Schema ready) | 009 |
---
## API Endpoints (Core Control-Pipeline, Port 8098)
### Bestehend
- `/v1/canonical/generate/*` — Control Generation Pipeline
- `/v1/canonical/generate/run-pass0a` — Pass 0a (NEU in dieser Session)
- `/v1/canonical/generate/submit-pass0b` — Pass 0b Batch API
### Neu (diese Session)
- `/v1/master-controls` — G-pre3: Liste, Stats, Detail
- `/v1/decision-traces` — G1: CRUD + Stats
- `/v1/controls/{id}/full-trace` — G1: Volle Kette
- `/v1/compliance-commits` — G2: Commit Ledger
- `/v1/decision-events` — G3: Lifecycle Events + Timeline
- `/v1/deployment-checks` — G4: Pre-Deploy Gate + Override
### API-Zugriff (WICHTIG)
```bash
# Nur via Docker exec (Port 8098 blockiert durch document-crawler)
ssh macmini "/usr/local/bin/docker exec bp-core-control-pipeline curl -sf http://127.0.0.1:8098/..."
```
--- ---
## BACKUPS (auf MacBook) ## BACKUPS (auf MacBook)
| Datei | Inhalt | Groesse | | Datei | Inhalt |
|-------|--------|---------| |-------|--------|
| controls_backup_20260505.csv | 1.599 neue Controls | 7.2 MB | | `backup_pre_gpre3_20260510.dump` | Vor gpre3 Live-Run (171 MB) |
| obligations_backup_20260505.csv | 11.522 Obligations | 6.2 MB | | `backup_session_end_20260511.dump` | Session-Ende |
| production_backup_20260505.dump | Production komprimiert | 30 MB | | `production_backup_20260508.dump` | Production nach Phase 2 |
| production_backup_20260505_plain.sql | Production plain | 1.3 GB | | `gpre0_checkpoints_backup_20260508/` | 10 Corrections-JSONs |
| local_backup_20260506.dump | Lokale DB komprimiert | ~30 MB |
| production_backup_20260506.dump | Production komprimiert | ~30 MB |
--- ---
## GESTOPPTE CONTAINER ## API-Kosten (Anthropic)
```bash | Phase | Modell | Kosten |
# Vault: Erst nach Fix-Deploy starten (Marker-File noetig) |-------|--------|--------|
ssh macmini "/usr/local/bin/docker start bp-core-vault" | Phase 0: Quality Audit | Sonnet | $2.92 |
| Phase 0b: Quality Audit v2 | Sonnet | $5.93 |
# OpenSearch: Bei Bedarf | Phase 2: 174K Re-Klassifizierung | Haiku | ~$50 |
ssh macmini "/usr/local/bin/docker start bp-lehrer-opensearch" | Phase 2b: Generic Token Fix | Haiku | $7.54 |
| Phase 2c: Subtopics R1 | Haiku | $20.22 |
# fewo-finance-agent: Fremder Container, nicht starten | Phase 2c: Subtopics R2 | Haiku | $12.03 |
``` | Phase 2d: Bad Subtopics | Haiku | ~$0.50 |
| 5K Test-Run | Sonnet | $5.32 |
| doc_check_controls | Haiku | ~$5 |
| **Gesamt** | | **~$110** |
--- ---
## TESTS ## STRATEGISCHE ENTSCHEIDUNGEN (in Memory)
```bash 1. **3 Use Cases:** Gap-Analyse (Prio 1), Vendor Risk (Prio 2), Web3/Crypto als Vertikal (Prio 3)
# Pipeline (454 Tests) 2. **Keine Norm-Reproduktion:** Obligation Extraction statt ISO-Texte (juristisch sicher)
PYTHONPATH=control-pipeline python3 -m pytest control-pipeline/tests/ -v 3. **Regulatory Ingestion Engine:** BAuA/OSHA Crawler als Vorlage für automatisierte Source-Feeds
``` 4. **CE-Compliance Crossover:** IACE × Master Controls für Trigger-basierte Compliance-Hinweise
---
## OFFENE PUNKTE FUER ANDERE SESSIONS
1. **Qdrant API-Key** fuer Production (qdrant-dev.breakpilot.ai) ist ungueltig (401). Muss in Coolify erneuert werden.
2. **DSI-Check False Positives**: Controls mischen interne Governance mit externen DSI-Anforderungen. Fix: nur Controls mit Art. 13/14 Referenz fuer DSI-Checks nutzen.
3. **Spotlight + mediaanalysisd** auf Mac Mini deaktivieren (braucht sudo):
```bash
sudo mdutil -a -i off
sudo launchctl disable system/com.apple.mediaanalysisd
```
4. **Production DB Sync** fuer neue G-Block Tabellen (decision_traces, compliance_commits, decision_events, deployment_checks) noch ausstehend — Tabellen sind leer, Schema muss auf Production deployed werden.
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.CONSENT_BACKEND_URL || 'https://macmini:3007/api/sdk/v1/banner'
const TENANT_ID = process.env.CONSENT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e'
const SITE_ID = process.env.NEXT_PUBLIC_CONSENT_SITE_ID || 'breakpilot-marketing'
export async function GET(req: NextRequest) {
try {
const siteId = req.nextUrl.searchParams.get('site_id') || SITE_ID
const res = await fetch(`${BACKEND_URL}/config/${siteId}`, {
headers: { 'X-Tenant-ID': TENANT_ID },
})
const data = await res.text()
return new NextResponse(data, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
})
} catch {
return NextResponse.json({ categories: [], vendors: [] }, { status: 200 })
}
}
+10 -6
View File
@@ -5,7 +5,13 @@ const TENANT_ID = process.env.CONSENT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.text() const data = await req.json()
// Inject client IP for backend GeoIP resolution
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|| req.headers.get('x-real-ip')
|| null
if (ip) data.ip_address = ip
const res = await fetch(`${BACKEND_URL}/consent`, { const res = await fetch(`${BACKEND_URL}/consent`, {
method: 'POST', method: 'POST',
@@ -13,13 +19,11 @@ export async function POST(req: NextRequest) {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID, 'X-Tenant-ID': TENANT_ID,
}, },
body, body: JSON.stringify(data),
// Accept self-signed certs on internal network
...(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? {} : {}),
}) })
const data = await res.text() const resBody = await res.text()
return new NextResponse(data, { return new NextResponse(resBody, {
status: res.status, status: res.status,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })
+6 -13
View File
@@ -87,19 +87,12 @@ export default function DatenschutzPage() {
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-white mb-2">6. Externe Schriften</h2> <h2 className="text-lg font-semibold text-white mb-2">6. Schriften</h2>
<p> <p>
Diese Website laedt Schriftarten von Google Fonts. Dabei wird Ihre IP-Adresse an Google LLC, Diese Website verwendet die Schriftarten Inter, Plus Jakarta Sans und JetBrains Mono.
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA uebermittelt. Die Schriften werden lokal auf unserem Server gehostet es findet kein Abruf von
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO. externen Servern (z.B. Google Fonts) statt. Es werden keine personenbezogenen Daten
Google ist unter dem EU-US Data Privacy Framework (DPF) zertifiziert (Angemessenheitsbeschluss an Dritte uebermittelt.
der EU-Kommission vom 10. Juli 2023).
</p>
<p className="mt-1">
Weitere Informationen:{' '}
<a href="https://policies.google.com/privacy" className="text-accent-electric hover:underline" target="_blank" rel="noopener noreferrer">
policies.google.com/privacy
</a>
</p> </p>
</div> </div>
@@ -116,8 +109,8 @@ export default function DatenschutzPage() {
<h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2> <h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2>
<ul className="list-disc list-inside space-y-1"> <ul className="list-disc list-inside space-y-1">
<li>Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen Hosting (AVV nach Art. 28 DSGVO)</li> <li>Hetzner Online GmbH, Industriestr. 25, 91710 Gunzenhausen Hosting (AVV nach Art. 28 DSGVO)</li>
<li>Google LLC Schriftarten (Google Fonts CDN, EU-US DPF)</li>
</ul> </ul>
<p className="mt-1">Schriftarten werden lokal gehostet kein Drittanbieter-Transfer.</p>
</div> </div>
<div> <div>
+25 -3
View File
@@ -1,6 +1,28 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap'); /* Self-hosted fonts — kein Drittlandtransfer zu Google */
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); @font-face {
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap'); font-family: 'Inter';
font-style: normal;
font-weight: 300 900;
font-display: swap;
src: url('/fonts/Inter-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Plus Jakarta Sans';
font-style: normal;
font-weight: 400 800;
font-display: swap;
src: url('/fonts/PlusJakartaSans-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400 600;
font-display: swap;
src: url('/fonts/JetBrainsMono-Latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { AppProvider } from '@/lib/context' import { AppProvider } from '@/lib/context'
import ConsentBanner from '@/components/layout/ConsentBanner' import ConsentBanner from '@/components/layout/ConsentBanner'
import ScriptManager from '@/components/layout/ScriptManager'
import './globals.css' import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -27,6 +28,7 @@ export default function RootLayout({
<AppProvider> <AppProvider>
{children} {children}
<ConsentBanner /> <ConsentBanner />
<ScriptManager />
</AppProvider> </AppProvider>
</body> </body>
</html> </html>
@@ -51,6 +51,18 @@ const texts = {
}, },
} }
function getSessionId(): string {
if (typeof window === 'undefined') return ''
try {
let sid = sessionStorage.getItem('bp_session_id')
if (!sid) {
sid = crypto.randomUUID()
sessionStorage.setItem('bp_session_id', sid)
}
return sid
} catch { return '' }
}
function getFingerprint(): string { function getFingerprint(): string {
const nav = typeof navigator !== 'undefined' ? navigator : null const nav = typeof navigator !== 'undefined' ? navigator : null
const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|') const raw = [nav?.language, nav?.platform, screen?.width, screen?.height, new Date().getTimezoneOffset()].join('|')
@@ -86,9 +98,40 @@ function detectDevice(): { device_type: string; browser: string; os: string } {
type ConsentMethod = 'accept_all' | 'reject_all' | 'custom_selection' type ConsentMethod = 'accept_all' | 'reject_all' | 'custom_selection'
async function sendConsent(consent: ConsentState, method: ConsentMethod) { interface ScriptEntry { src: string; category: string }
interface CookieEntry { name: string; domain: string; expiry_days: number; category: string }
function detectScripts(): { blocked: ScriptEntry[]; released: ScriptEntry[] } {
const scripts = Array.from(document.querySelectorAll('script[src]'))
const released: ScriptEntry[] = []
const blocked: ScriptEntry[] = []
for (const el of scripts) {
const src = el.getAttribute('src') || ''
if (/google.*tag|gtag|analytics/i.test(src)) released.push({ src, category: 'analytics' })
else if (/facebook|fbevents|linkedin|tiktok/i.test(src)) released.push({ src, category: 'marketing' })
}
return { blocked, released }
}
function detectCookies(): CookieEntry[] {
const cookies: CookieEntry[] = []
for (const c of document.cookie.split(';')) {
const name = c.trim().split('=')[0]
if (!name) continue
let category = 'functional'
if (/^_ga|^_gid|^_gat/i.test(name)) category = 'analytics'
else if (/^_fb|^_gcl|^_li/i.test(name)) category = 'marketing'
else if (/^bp_consent|^session|^csrf/i.test(name)) category = 'essential'
cookies.push({ name, domain: window.location.hostname, expiry_days: 0, category })
}
return cookies
}
async function sendConsent(consent: ConsentState, method: ConsentMethod, vendorConsents?: Record<string, boolean>) {
try { try {
const { device_type, browser, os } = detectDevice() const { device_type, browser, os } = detectDevice()
const { blocked, released } = detectScripts()
const cookies_set = detectCookies()
await fetch('/api/consent', { await fetch('/api/consent', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -100,7 +143,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
...(consent.functional ? ['functional'] : []), ...(consent.functional ? ['functional'] : []),
...(consent.analytics ? ['analytics'] : []), ...(consent.analytics ? ['analytics'] : []),
], ],
vendors: [], vendors: Object.keys(vendorConsents || {}).filter(k => vendorConsents?.[k]),
vendor_consents: vendorConsents || {},
user_agent: navigator.userAgent, user_agent: navigator.userAgent,
consent_method: method, consent_method: method,
page_url: window.location.href, page_url: window.location.href,
@@ -110,6 +154,11 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
os, os,
screen_resolution: `${screen.width}x${screen.height}`, screen_resolution: `${screen.width}x${screen.height}`,
consent_scope: 'domain', consent_scope: 'domain',
session_id: getSessionId(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
scripts_blocked: blocked,
scripts_released: released,
cookies_set,
}), }),
}) })
} catch { } catch {
@@ -117,26 +166,49 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
} }
} }
interface VendorConfig {
vendor_name: string
category_key: string
description_de?: string
description_en?: string
cookie_names?: string[]
retention_days?: number
}
export default function ConsentBanner() { export default function ConsentBanner() {
const { lang } = useApp() const { lang } = useApp()
const t = texts[lang] const t = texts[lang]
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [showDetails, setShowDetails] = useState(false) const [showDetails, setShowDetails] = useState(false)
const [consent, setConsent] = useState<ConsentState>(defaultConsent) const [consent, setConsent] = useState<ConsentState>(defaultConsent)
const [vendors, setVendors] = useState<VendorConfig[]>([])
const [vendorConsents, setVendorConsents] = useState<Record<string, boolean>>({})
useEffect(() => { useEffect(() => {
const saved = getSavedConsent() const saved = getSavedConsent()
if (!saved) { if (!saved) {
setVisible(true) setVisible(true)
} }
// Load vendor config from backend
fetch('/api/consent/config')
.then(r => r.json())
.then(data => {
const v = data?.vendors || []
setVendors(v)
// Default all vendors to true
const defaults: Record<string, boolean> = {}
for (const vendor of v) defaults[vendor.vendor_name] = true
setVendorConsents(defaults)
})
.catch(() => {})
}, []) }, [])
const save = useCallback((state: ConsentState, method: ConsentMethod) => { const save = useCallback((state: ConsentState, method: ConsentMethod) => {
localStorage.setItem(COOKIE_NAME, JSON.stringify(state)) localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
sendConsent(state, method) sendConsent(state, method, vendorConsents)
setVisible(false) setVisible(false)
window.dispatchEvent(new CustomEvent('consent-change', { detail: state })) window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
}, []) }, [vendorConsents])
const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_all') const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_all')
const rejectAll = () => save({ essential: true, functional: false, analytics: false }, 'reject_all') const rejectAll = () => save({ essential: true, functional: false, analytics: false }, 'reject_all')
@@ -181,24 +253,49 @@ export default function ConsentBanner() {
className="overflow-hidden mb-4" className="overflow-hidden mb-4"
> >
<div className="space-y-2 pt-2 border-t border-white/[0.06]"> <div className="space-y-2 pt-2 border-t border-white/[0.06]">
{categories.map(([key, cat]) => ( {categories.map(([key, cat]) => {
<label const catVendors = vendors.filter(v => v.category_key === key)
key={key} return (
className="flex items-center justify-between p-3 rounded-xl bg-white/[0.03] border border-white/[0.06] cursor-pointer hover:bg-white/[0.05] transition-colors" <div key={key} className="rounded-xl bg-white/[0.03] border border-white/[0.06]">
> <label className="flex items-center justify-between p-3 cursor-pointer hover:bg-white/[0.05] transition-colors">
<div className="flex-1 mr-4"> <div className="flex-1 mr-4">
<span className="text-xs font-semibold text-white">{cat.name}</span> <span className="text-xs font-semibold text-white">{cat.name}</span>
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p> <p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
</div>
<input
type="checkbox"
checked={cat.required || consent[key as keyof ConsentState]}
disabled={cat.required}
onChange={(e) => {
setConsent(prev => ({ ...prev, [key]: e.target.checked }))
for (const v of catVendors) {
setVendorConsents(prev => ({ ...prev, [v.vendor_name]: e.target.checked }))
}
}}
className="w-4 h-4 rounded accent-accent-electric"
/>
</label>
{catVendors.length > 0 && consent[key as keyof ConsentState] && (
<div className="px-3 pb-3 space-y-1">
{catVendors.map(v => (
<label key={v.vendor_name} className="flex items-center justify-between pl-4 py-1 text-xs cursor-pointer">
<div className="flex-1 mr-2">
<span className="text-white/60">{v.vendor_name}</span>
{v.retention_days && <span className="text-white/30 ml-1">({v.retention_days}d)</span>}
</div>
<input
type="checkbox"
checked={vendorConsents[v.vendor_name] ?? true}
onChange={(e) => setVendorConsents(prev => ({ ...prev, [v.vendor_name]: e.target.checked }))}
className="w-3 h-3 rounded accent-accent-electric"
/>
</label>
))}
</div>
)}
</div> </div>
<input )
type="checkbox" })}
checked={cat.required || consent[key as keyof ConsentState]}
disabled={cat.required}
onChange={(e) => setConsent(prev => ({ ...prev, [key]: e.target.checked }))}
className="w-4 h-4 rounded accent-accent-electric"
/>
</label>
))}
</div> </div>
</motion.div> </motion.div>
)} )}
@@ -0,0 +1,84 @@
'use client'
import { useEffect, useRef } from 'react'
/**
* ScriptManager — consent-aware script injection.
*
* Listens to the `consent-change` CustomEvent dispatched by ConsentBanner
* and injects/skips third-party scripts based on accepted categories.
*
* Add scripts to CONSENT_SCRIPTS when the site starts using analytics,
* marketing, or functional third-party tools.
*/
interface ConsentScript {
src: string
async?: boolean
}
const CONSENT_SCRIPTS: Record<string, ConsentScript[]> = {
analytics: [
// Example: { src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXX', async: true },
],
marketing: [
// Example: { src: 'https://connect.facebook.net/en_US/fbevents.js', async: true },
],
functional: [
// Example: { src: 'https://widget.example.com/chat.js', async: true },
],
}
interface ConsentState {
essential: boolean
functional: boolean
analytics: boolean
}
function getStoredConsent(): ConsentState | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem('bp_consent')
return raw ? JSON.parse(raw) : null
} catch { return null }
}
export default function ScriptManager() {
const injected = useRef(new Set<string>())
function injectScripts(consent: ConsentState) {
const categories: (keyof typeof CONSENT_SCRIPTS)[] = []
if (consent.analytics) categories.push('analytics')
if (consent.functional) categories.push('functional')
// marketing would need its own toggle in ConsentState
for (const cat of categories) {
for (const script of CONSENT_SCRIPTS[cat] ?? []) {
if (injected.current.has(script.src)) continue
const el = document.createElement('script')
el.src = script.src
if (script.async) el.async = true
el.dataset.consent = cat
document.head.appendChild(el)
injected.current.add(script.src)
}
}
}
useEffect(() => {
// On mount: apply saved consent (return visitors)
const saved = getStoredConsent()
if (saved) injectScripts(saved)
// Listen for new consent decisions
function onConsentChange(e: Event) {
const detail = (e as CustomEvent<ConsentState>).detail
if (detail) injectScripts(detail)
}
window.addEventListener('consent-change', onConsentChange)
return () => window.removeEventListener('consent-change', onConsentChange)
}, [])
return null // Invisible component
}
Binary file not shown.