feat: IAB TCF 2.2 — TC String encoder + purpose mapping + UI

- TCFEncoderService: generates base64url-encoded TC Strings per IAB spec
  with 12 purposes, vendor consent bitfield, CMP metadata
- Category-to-purpose mapping (necessary→none, statistics→1,7,8,9,10,
  marketing→1,2,3,4,5,6,7,12, functional→1,11)
- tcf_routes: 5 endpoints (purposes, features, mapping, encode, encode-categories)
- banner_consent_service: auto-generates TC String when tcf_enabled=true
- TCFSettings.tsx: enable/disable toggle, purpose grid with category mapping,
  TC String test generator, CMP registration info
- New "TCF/IAB" tab in cookie-banner page (7 tabs total)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-04 07:01:37 +02:00
parent c89a68e59e
commit d3c8811fdb
6 changed files with 505 additions and 1 deletions
@@ -0,0 +1,209 @@
"""
TCF 2.2 TC String Encoder — generates IAB Transparency & Consent strings.
Implements the TC String v2.2 format per IAB specification.
The TC String is a base64url-encoded bitfield containing:
- CMP metadata (ID, version, screen, consent language)
- Purpose consents (12 standard IAB purposes)
- Vendor consents (per IAB vendor ID)
- Legitimate interest signals
Reference: https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework
NOTE: This is a simplified encoder for CMP integration. For full GVL
(Global Vendor List) support, integrate with the IAB GVL API.
"""
import base64
import math
from datetime import datetime, timezone
from typing import Any
# IAB TCF 2.2 Standard Purposes
IAB_PURPOSES = {
1: {"name": "Store and/or access information on a device", "name_de": "Informationen auf Geraet speichern/abrufen"},
2: {"name": "Select basic ads", "name_de": "Einfache Anzeigen auswaehlen"},
3: {"name": "Create a personalised ads profile", "name_de": "Personalisiertes Anzeigenprofil erstellen"},
4: {"name": "Select personalised ads", "name_de": "Personalisierte Anzeigen auswaehlen"},
5: {"name": "Create a personalised content profile", "name_de": "Personalisiertes Inhaltsprofil erstellen"},
6: {"name": "Select personalised content", "name_de": "Personalisierte Inhalte auswaehlen"},
7: {"name": "Measure ad performance", "name_de": "Anzeigen-Leistung messen"},
8: {"name": "Measure content performance", "name_de": "Inhalte-Leistung messen"},
9: {"name": "Apply market research to generate audience insights", "name_de": "Marktforschung fuer Zielgruppen"},
10: {"name": "Develop and improve products", "name_de": "Produkte entwickeln und verbessern"},
11: {"name": "Use limited data to select content", "name_de": "Eingeschraenkte Daten fuer Inhalte nutzen"},
12: {"name": "Use limited data to select ads", "name_de": "Eingeschraenkte Daten fuer Anzeigen nutzen"},
}
# IAB Special Features
IAB_SPECIAL_FEATURES = {
1: {"name": "Use precise geolocation data", "name_de": "Praezise Standortdaten verwenden"},
2: {"name": "Actively scan device characteristics for identification", "name_de": "Geraetemerkmale aktiv scannen"},
}
# Category-to-Purpose mapping (how our banner categories map to IAB purposes)
CATEGORY_PURPOSE_MAP = {
"necessary": [], # No consent needed
"functional": [1, 11], # Device access + limited data for content
"statistics": [1, 7, 8, 9, 10], # Device access + measurement + research
"marketing": [1, 2, 3, 4, 5, 6, 7, 12], # Most purposes
}
def _int_to_bits(value: int, length: int) -> str:
"""Convert integer to fixed-length bit string."""
return bin(value)[2:].zfill(length)
def _datetime_to_deciseconds(dt: datetime) -> int:
"""Convert datetime to deciseconds since epoch (IAB format)."""
epoch = datetime(2000, 1, 1, tzinfo=timezone.utc)
return int((dt - epoch).total_seconds() * 10)
def _bits_to_base64url(bits: str) -> str:
"""Convert bit string to base64url encoding (TC String format)."""
# Pad to multiple of 8
padding = (8 - len(bits) % 8) % 8
bits += "0" * padding
# Convert to bytes
byte_array = bytearray()
for i in range(0, len(bits), 8):
byte_array.append(int(bits[i:i+8], 2))
# Base64url encode (no padding)
return base64.urlsafe_b64encode(bytes(byte_array)).rstrip(b"=").decode("ascii")
class TCFEncoderService:
"""Generates TC Strings per IAB TCF 2.2 specification."""
def __init__(
self,
cmp_id: int = 1,
cmp_version: int = 1,
consent_screen: int = 1,
consent_language: str = "DE",
):
self.cmp_id = cmp_id
self.cmp_version = cmp_version
self.consent_screen = consent_screen
self.consent_language = consent_language
def encode(
self,
purpose_consents: dict[int, bool],
vendor_consents: dict[int, bool],
purpose_li: dict[int, bool] | None = None,
special_features: dict[int, bool] | None = None,
) -> str:
"""Generate a TC String from consent decisions.
Args:
purpose_consents: {purpose_id: True/False} for purposes 1-12
vendor_consents: {vendor_id: True/False} for IAB vendor IDs
purpose_li: Legitimate interest signals per purpose
special_features: Special feature opt-ins
Returns:
Base64url-encoded TC String
"""
now = datetime.now(timezone.utc)
created = _datetime_to_deciseconds(now)
updated = created
bits = ""
# Core TC String v2 fields
bits += _int_to_bits(2, 6) # Version (6 bits) = 2
bits += _int_to_bits(created, 36) # Created (36 bits)
bits += _int_to_bits(updated, 36) # LastUpdated (36 bits)
bits += _int_to_bits(self.cmp_id, 12) # CmpId (12 bits)
bits += _int_to_bits(self.cmp_version, 12) # CmpVersion (12 bits)
bits += _int_to_bits(self.consent_screen, 6) # ConsentScreen (6 bits)
# ConsentLanguage (12 bits = 2 × 6-bit letters)
lang = self.consent_language.upper()[:2]
bits += _int_to_bits(ord(lang[0]) - ord("A"), 6)
bits += _int_to_bits(ord(lang[1]) - ord("A"), 6)
# VendorListVersion (12 bits) — use 0 if not fetching GVL
bits += _int_to_bits(0, 12)
# TcfPolicyVersion (6 bits) = 4 for TCF 2.2
bits += _int_to_bits(4, 6)
# IsServiceSpecific (1 bit) = 1
bits += "1"
# UseNonStandardTexts (1 bit) = 0
bits += "0"
# SpecialFeatureOptIns (12 bits)
sf = special_features or {}
for i in range(1, 13):
bits += "1" if sf.get(i, False) else "0"
# PurposesConsent (24 bits)
for i in range(1, 25):
bits += "1" if purpose_consents.get(i, False) else "0"
# PurposesLITransparency (24 bits)
li = purpose_li or {}
for i in range(1, 25):
bits += "1" if li.get(i, False) else "0"
# Purpose one treatment (1 bit) = 0, PublisherCC (12 bits) = DE
bits += "0"
bits += _int_to_bits(ord("D") - ord("A"), 6)
bits += _int_to_bits(ord("E") - ord("A"), 6)
# Vendor consents — Range encoding
max_vendor = max(vendor_consents.keys()) if vendor_consents else 0
bits += _int_to_bits(max_vendor, 16) # MaxVendorId
# Use bitfield encoding (simpler than range)
bits += "0" # IsRangeEncoding = 0 (bitfield)
for i in range(1, max_vendor + 1):
bits += "1" if vendor_consents.get(i, False) else "0"
# Vendor legitimate interests (same pattern)
bits += _int_to_bits(max_vendor, 16)
bits += "0"
for i in range(1, max_vendor + 1):
bits += "1" if vendor_consents.get(i, False) else "0" # Simplified: same as consent
return _bits_to_base64url(bits)
def encode_from_categories(
self,
categories: list[str],
vendor_consents: dict[int, bool] | None = None,
) -> str:
"""Generate TC String from banner category selections.
Maps our banner categories (necessary, statistics, marketing, functional)
to IAB purposes and generates the TC String.
"""
purpose_consents: dict[int, bool] = {}
for cat in categories:
for purpose_id in CATEGORY_PURPOSE_MAP.get(cat, []):
purpose_consents[purpose_id] = True
return self.encode(
purpose_consents=purpose_consents,
vendor_consents=vendor_consents or {},
)
@staticmethod
def get_purposes() -> list[dict[str, Any]]:
"""Return all 12 IAB purposes with translations."""
return [
{"id": pid, "name": info["name"], "name_de": info["name_de"]}
for pid, info in IAB_PURPOSES.items()
]
@staticmethod
def get_special_features() -> list[dict[str, Any]]:
return [
{"id": fid, "name": info["name"], "name_de": info["name_de"]}
for fid, info in IAB_SPECIAL_FEATURES.items()
]
@staticmethod
def get_category_purpose_map() -> dict[str, list[int]]:
return CATEGORY_PURPOSE_MAP