Merge branch 'main' of ssh://coolify.meghsakha.com:22222/Benjamin_Boenisch/breakpilot-core
Build pitch-deck / build-push-deploy (push) Successful in 1m48s
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 30s
CI / test-python-voice (push) Successful in 29s
CI / test-bqas (push) Successful in 29s

This commit is contained in:
Sharang Parnerkar
2026-05-12 17:45:50 +02:00
11 changed files with 407 additions and 210 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
**Fuer:** Naechste Claude-Session
**Repo:** breakpilot-core (~/Projekte/breakpilot-core)
**Datum:** 2026-05-07 bis 2026-05-11 (5 Tage Marathon)
**Repo:** breakpilot-core + breakpilot-compliance
---
## 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 |
|-------|------|----------|---------|
| MC-8292 | monitoring | 6.157 | Alles von Video bis Vulnerability |
| MC-2260 | procedure | 4.176 | Generisch |
| MC-8302 | alerting | 3.126 | Meldepflichten aller Gesetze gemischt |
| MC-8306 | personal_data | 3.057 | DSGVO + NIS2 + AT/CH gemischt |
| MC-8312 | training | 2.572 | |
| MC-7932 | certificate_management | 2.350 | |
| MC-8317 | incident | 2.288 | |
| MC-8329 | encryption | 1.790 | |
| 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 | |
### Gap-Analyse Engine (Compliance)
- **12 Regulierungen** automatisch klassifiziert (CRA, AI Act, NIS2, DSGVO, MiCA, PSD2, AML, etc.)
- **IST-Zustand Assessment:** CE-Kennzeichnung, angewandte Normen, bestehende Prozesse, IACE-Projekt-Link
- **Norm→Control Mapping:** 20 Normen → MC-Topic Coverage
- **Prioritäts-Engine:** Severity × Deadline × Dependency
- **5 Branchentemplates:** IoT, Exchange, Cobot, SaaS, Medical
- **Frontend:** 2-Step Wizard (Produkt + IST-Zustand) + Dashboard mit Ampel-Status
- **API:** 8 Endpoints unter `/sdk/v1/gap/`
- **Persistente Projekte:** Speichern + wieder öffnen
- **Getestet:** SmartFactory Gateway → 5 Regulierungen, 500 Gaps
### 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:
```
MC "encryption" (1.790 Controls)
→ encryption_dsgvo (DSGVO Art. 32, ~200)
→ 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.
### Master Controls Browser (Compliance)
- **Neue Seite** `/sdk/master-controls` — reused Control Library UI
- Sidebar-Eintrag zwischen Control Library und Provenance
- 13.588 MCs mit allen Filtern, Paginierung, Klick-Detail
- Verbindet sich mit Production-DB
---
## SESSION 03-06.05.2026 KOMPLETT ERLEDIGT
## DB-Tabellen (neu/geändert)
### Block F (Hardcoded Knowledge → DB)
- F1: regulation_registry (223 Eintraege) ✅
- F2: action_types (34) + action_synonyms (368) ✅
- F3: object_synonyms (320) ✅
- F4: LLM Enrichment (+468 Synonyme via Ollama) ✅
- F5: Validation (8 Tests, Dicts als Fallback) ✅
### Control Generation Pipeline
- 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)
| Tabelle | Repo | Rows (lokal) | Rows (Production) |
|---------|------|-------------|-------------------|
| compliance.master_controls | Core | 13.588 | 13.588 |
| compliance.master_control_members | Core | 83.073 | 83.073 |
| compliance.object_ontology | Core | 74 | 74 |
| compliance.object_groups | Core | 16.683 | — |
| compliance.doc_check_controls | Core | 1.874 | 1.874 |
| compliance.gap_projects | Compliance | 1 | 0 |
---
## DB-Tabellen (alle Bloecke)
## OFFEN / NÄCHSTE SESSION
| Tabelle | Rows | Migration |
|---------|------|-----------|
| compliance.regulation_registry | 223 | 002 |
| compliance.action_types | 34 | 003 |
| compliance.action_synonyms | 368 | 003 |
| 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/..."
```
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
3. **Tenant Document Upload** deployen (RAG Service rebuild)
4. **Compliance-Repo auf gitea pushen** — aktuell "Everything up-to-date", Orca muss manuell redeployt werden
5. **MC-Browser erweitern** — Detail-View mit Member-Controls verbessern
---
## BACKUPS (auf MacBook)
| Datei | Inhalt | Groesse |
|-------|--------|---------|
| controls_backup_20260505.csv | 1.599 neue Controls | 7.2 MB |
| obligations_backup_20260505.csv | 11.522 Obligations | 6.2 MB |
| production_backup_20260505.dump | Production komprimiert | 30 MB |
| production_backup_20260505_plain.sql | Production plain | 1.3 GB |
| local_backup_20260506.dump | Lokale DB komprimiert | ~30 MB |
| production_backup_20260506.dump | Production komprimiert | ~30 MB |
| Datei | Inhalt |
|-------|--------|
| `backup_pre_gpre3_20260510.dump` | Vor gpre3 Live-Run (171 MB) |
| `backup_session_end_20260511.dump` | Session-Ende |
| `production_backup_20260508.dump` | Production nach Phase 2 |
| `gpre0_checkpoints_backup_20260508/` | 10 Corrections-JSONs |
---
## GESTOPPTE CONTAINER
## API-Kosten (Anthropic)
```bash
# Vault: Erst nach Fix-Deploy starten (Marker-File noetig)
ssh macmini "/usr/local/bin/docker start bp-core-vault"
# OpenSearch: Bei Bedarf
ssh macmini "/usr/local/bin/docker start bp-lehrer-opensearch"
# fewo-finance-agent: Fremder Container, nicht starten
```
| Phase | Modell | Kosten |
|-------|--------|--------|
| Phase 0: Quality Audit | Sonnet | $2.92 |
| Phase 0b: Quality Audit v2 | Sonnet | $5.93 |
| Phase 2: 174K Re-Klassifizierung | Haiku | ~$50 |
| Phase 2b: Generic Token Fix | Haiku | $7.54 |
| Phase 2c: Subtopics R1 | Haiku | $20.22 |
| 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
# Pipeline (454 Tests)
PYTHONPATH=control-pipeline python3 -m pytest control-pipeline/tests/ -v
```
---
## 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.
1. **3 Use Cases:** Gap-Analyse (Prio 1), Vendor Risk (Prio 2), Web3/Crypto als Vertikal (Prio 3)
2. **Keine Norm-Reproduktion:** Obligation Extraction statt ISO-Texte (juristisch sicher)
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
@@ -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) {
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`, {
method: 'POST',
@@ -13,13 +19,11 @@ export async function POST(req: NextRequest) {
'Content-Type': 'application/json',
'X-Tenant-ID': TENANT_ID,
},
body,
// Accept self-signed certs on internal network
...(process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0' ? {} : {}),
body: JSON.stringify(data),
})
const data = await res.text()
return new NextResponse(data, {
const resBody = await res.text()
return new NextResponse(resBody, {
status: res.status,
headers: { 'Content-Type': 'application/json' },
})
+30 -17
View File
@@ -31,7 +31,17 @@ export default function DatenschutzPage() {
<p>
Diese Website wird auf Servern der Hetzner Online GmbH in Deutschland gehostet.
Es findet kein Drittlandtransfer fuer das Hosting statt.
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an zuverlaessigem Betrieb).
</p>
<p className="mt-2">
<span className="text-white font-medium">Rechtsgrundlage:</span> Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse).
</p>
<p className="mt-1">
<span className="text-white font-medium">Interessenabwaegung:</span> Unser berechtigtes Interesse liegt im
zuverlaessigen und sicheren Betrieb der Website. Ohne Hosting-Infrastruktur koennen wir unser Angebot
nicht bereitstellen. Die Verarbeitung beschraenkt sich auf technisch notwendige Verbindungsdaten
(IP-Adresse, Zeitstempel). Entgegenstehende Interessen der Betroffenen ueberwiegen nicht, da die
Daten nur kurzzeitig (7 Tage) gespeichert, nicht mit anderen Datenquellen zusammengefuehrt und
ausschliesslich zur Sicherstellung des Betriebs und der IT-Sicherheit verwendet werden.
</p>
</div>
@@ -80,26 +90,29 @@ export default function DatenschutzPage() {
<div>
<h2 className="text-lg font-semibold text-white mb-2">5. Server-Logfiles</h2>
<p>
Der Hosting-Provider erhebt technisch notwendige Logfiles (IP-Adresse, Browsertyp, Zeitstempel).
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse an der Sicherheit).
Logfiles werden nach 7 Tagen automatisch geloescht.
Der Hosting-Provider erhebt technisch notwendige Logfiles (IP-Adresse, Browsertyp, Zeitstempel,
aufgerufene Seite, HTTP-Statuscode).
</p>
<p className="mt-2">
<span className="text-white font-medium">Rechtsgrundlage:</span> Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse).
</p>
<p className="mt-1">
<span className="text-white font-medium">Interessenabwaegung:</span> Die Erhebung von Server-Logfiles ist
fuer die Erkennung und Abwehr von Cyberangriffen, die Fehlerbehebung und die Gewaehrleistung der
Systemstabilitaet unerlasslich. Die Daten werden automatisiert nach 7 Tagen geloescht und nicht
zur Profilbildung oder Identifizierung einzelner Nutzer verwendet. Eine Zusammenfuehrung mit anderen
Datenquellen findet nicht statt. Das Interesse der Betroffenen am Schutz ihrer Daten wird durch die
kurze Speicherdauer und die rein technische Nutzung angemessen gewahrt.
</p>
</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>
Diese Website laedt Schriftarten von Google Fonts. Dabei wird Ihre IP-Adresse an Google LLC,
1600 Amphitheatre Parkway, Mountain View, CA 94043, USA uebermittelt.
Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO.
Google ist unter dem EU-US Data Privacy Framework (DPF) zertifiziert (Angemessenheitsbeschluss
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>
Diese Website verwendet die Schriftarten Inter, Plus Jakarta Sans und JetBrains Mono.
Die Schriften werden lokal auf unserem Server gehostet es findet kein Abruf von
externen Servern (z.B. Google Fonts) statt. Es werden keine personenbezogenen Daten
an Dritte uebermittelt.
</p>
</div>
@@ -116,8 +129,8 @@ export default function DatenschutzPage() {
<h2 className="text-lg font-semibold text-white mb-2">8. Empfaenger und Auftragsverarbeiter</h2>
<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>Google LLC Schriftarten (Google Fonts CDN, EU-US DPF)</li>
</ul>
<p className="mt-1">Schriftarten werden lokal gehostet kein Drittanbieter-Transfer.</p>
</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');
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
/* Self-hosted fonts — kein Drittlandtransfer zu Google */
@font-face {
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 components;
+2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'
import { AppProvider } from '@/lib/context'
import ConsentBanner from '@/components/layout/ConsentBanner'
import ScriptManager from '@/components/layout/ScriptManager'
import './globals.css'
export const metadata: Metadata = {
@@ -27,6 +28,7 @@ export default function RootLayout({
<AppProvider>
{children}
<ConsentBanner />
<ScriptManager />
</AppProvider>
</body>
</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 {
const nav = typeof navigator !== 'undefined' ? navigator : null
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'
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 {
const { device_type, browser, os } = detectDevice()
const { blocked, released } = detectScripts()
const cookies_set = detectCookies()
await fetch('/api/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -100,7 +143,8 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
...(consent.functional ? ['functional'] : []),
...(consent.analytics ? ['analytics'] : []),
],
vendors: [],
vendors: Object.keys(vendorConsents || {}).filter(k => vendorConsents?.[k]),
vendor_consents: vendorConsents || {},
user_agent: navigator.userAgent,
consent_method: method,
page_url: window.location.href,
@@ -110,6 +154,11 @@ async function sendConsent(consent: ConsentState, method: ConsentMethod) {
os,
screen_resolution: `${screen.width}x${screen.height}`,
consent_scope: 'domain',
session_id: getSessionId(),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
scripts_blocked: blocked,
scripts_released: released,
cookies_set,
}),
})
} 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() {
const { lang } = useApp()
const t = texts[lang]
const [visible, setVisible] = useState(false)
const [showDetails, setShowDetails] = useState(false)
const [consent, setConsent] = useState<ConsentState>(defaultConsent)
const [vendors, setVendors] = useState<VendorConfig[]>([])
const [vendorConsents, setVendorConsents] = useState<Record<string, boolean>>({})
useEffect(() => {
const saved = getSavedConsent()
if (!saved) {
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) => {
localStorage.setItem(COOKIE_NAME, JSON.stringify(state))
sendConsent(state, method)
sendConsent(state, method, vendorConsents)
setVisible(false)
window.dispatchEvent(new CustomEvent('consent-change', { detail: state }))
}, [])
}, [vendorConsents])
const acceptAll = () => save({ essential: true, functional: true, analytics: true }, 'accept_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"
>
<div className="space-y-2 pt-2 border-t border-white/[0.06]">
{categories.map(([key, cat]) => (
<label
key={key}
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 className="flex-1 mr-4">
<span className="text-xs font-semibold text-white">{cat.name}</span>
<p className="text-xs text-white/40 mt-0.5">{cat.description}</p>
{categories.map(([key, cat]) => {
const catVendors = vendors.filter(v => v.category_key === key)
return (
<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">
<span className="text-xs font-semibold text-white">{cat.name}</span>
<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>
<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>
</motion.div>
)}
@@ -0,0 +1,115 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
/**
* ScriptManager — active consent-aware script blocking + injection.
*
* Two mechanisms:
* 1. INJECTION: Scripts in CONSENT_SCRIPTS are only injected AFTER consent.
* 2. BLOCKING: Existing <script data-consent="category" type="text/plain">
* elements in the page are activated after consent by changing type to
* "text/javascript". This is the standard CMP blocking pattern.
*
* Usage for inline scripts in pages:
* <script type="text/plain" data-consent="analytics">
* // This won't execute until analytics consent is given
* gtag('config', 'G-XXXXXX');
* </script>
*
* Usage for adding new third-party scripts:
* Add to CONSENT_SCRIPTS below. They'll be injected only after consent.
*/
interface ConsentScript {
src: string
async?: boolean
id?: string
}
const CONSENT_SCRIPTS: Record<string, ConsentScript[]> = {
analytics: [
// { src: 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXX', async: true, id: 'gtag' },
// { src: 'https://plausible.io/js/script.js', async: true, id: 'plausible' },
],
marketing: [
// { src: 'https://connect.facebook.net/en_US/fbevents.js', async: true, id: 'fb-pixel' },
// { src: 'https://snap.licdn.com/li.lms-analytics/insight.min.js', async: true, id: 'li-insight' },
],
functional: [
// { src: 'https://widget.example.com/chat.js', async: true, id: 'chat-widget' },
],
}
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>())
const applyConsent = useCallback((consent: ConsentState) => {
const accepted = new Set<string>()
accepted.add('essential') // always allowed
if (consent.functional) accepted.add('functional')
if (consent.analytics) accepted.add('analytics')
// 1. INJECT: Add scripts from CONSENT_SCRIPTS for accepted categories
for (const cat of accepted) {
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
if (script.id) el.id = script.id
el.dataset.consent = cat
document.head.appendChild(el)
injected.current.add(script.src)
}
}
// 2. ACTIVATE: Unblock <script type="text/plain" data-consent="...">
const blocked = document.querySelectorAll('script[type="text/plain"][data-consent]')
for (const el of blocked) {
const cat = el.getAttribute('data-consent') || ''
if (accepted.has(cat)) {
const clone = document.createElement('script')
// Copy attributes
for (const attr of el.attributes) {
if (attr.name === 'type') continue // skip type="text/plain"
clone.setAttribute(attr.name, attr.value)
}
clone.type = 'text/javascript'
clone.textContent = el.textContent
el.parentNode?.replaceChild(clone, el)
}
}
}, [])
useEffect(() => {
// On mount: apply saved consent (return visitors)
const saved = getStoredConsent()
if (saved) applyConsent(saved)
// Listen for new consent decisions
function onConsentChange(e: Event) {
const detail = (e as CustomEvent<ConsentState>).detail
if (detail) applyConsent(detail)
}
window.addEventListener('consent-change', onConsentChange)
return () => window.removeEventListener('consent-change', onConsentChange)
}, [applyConsent])
return null
}
Binary file not shown.