@discloai/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/dist/discloai.min.js +1 -0
- package/dist/index.d.mts +152 -0
- package/dist/index.d.ts +152 -0
- package/dist/index.js +795 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +764 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +58 -0
- package/src/__tests__/audit.test.ts +117 -0
- package/src/__tests__/init.test.ts +49 -0
- package/src/__tests__/wcag.test.ts +260 -0
- package/src/audit.ts +155 -0
- package/src/components/AIContentLabel.ts +108 -0
- package/src/components/BiometricNotice.ts +82 -0
- package/src/components/ChatbotDisclosure.ts +188 -0
- package/src/components/DeepfakeLabel.ts +123 -0
- package/src/config.ts +191 -0
- package/src/i18n/bg.json +9 -0
- package/src/i18n/cs.json +9 -0
- package/src/i18n/da.json +9 -0
- package/src/i18n/de.json +9 -0
- package/src/i18n/el.json +9 -0
- package/src/i18n/en.json +9 -0
- package/src/i18n/es.json +9 -0
- package/src/i18n/et.json +9 -0
- package/src/i18n/fi.json +9 -0
- package/src/i18n/fr.json +9 -0
- package/src/i18n/ga.json +9 -0
- package/src/i18n/hr.json +9 -0
- package/src/i18n/hu.json +9 -0
- package/src/i18n/index.ts +145 -0
- package/src/i18n/it.json +9 -0
- package/src/i18n/lt.json +9 -0
- package/src/i18n/lv.json +9 -0
- package/src/i18n/mt.json +9 -0
- package/src/i18n/nl.json +9 -0
- package/src/i18n/pl.json +9 -0
- package/src/i18n/pt.json +9 -0
- package/src/i18n/ro.json +9 -0
- package/src/i18n/sk.json +9 -0
- package/src/i18n/sl.json +9 -0
- package/src/i18n/sv.json +9 -0
- package/src/index.ts +19 -0
- package/src/init.ts +56 -0
- package/src/vendors.ts +29 -0
- package/src/version.ts +1 -0
- package/src/wcag.ts +46 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/config.ts","../src/audit.ts","../src/i18n/en.json","../src/i18n/fr.json","../src/i18n/de.json","../src/i18n/es.json","../src/i18n/bg.json","../src/i18n/hr.json","../src/i18n/cs.json","../src/i18n/da.json","../src/i18n/el.json","../src/i18n/et.json","../src/i18n/fi.json","../src/i18n/ga.json","../src/i18n/hu.json","../src/i18n/it.json","../src/i18n/lv.json","../src/i18n/lt.json","../src/i18n/mt.json","../src/i18n/nl.json","../src/i18n/pl.json","../src/i18n/pt.json","../src/i18n/ro.json","../src/i18n/sk.json","../src/i18n/sl.json","../src/i18n/sv.json","../src/i18n/index.ts","../src/version.ts","../src/components/AIContentLabel.ts","../src/components/DeepfakeLabel.ts","../src/components/ChatbotDisclosure.ts","../src/components/BiometricNotice.ts","../src/init.ts","../src/vendors.ts"],"sourcesContent":["import type { ChatbotDisclosureConfig } from \"./components/ChatbotDisclosure.js\";\nimport type { AIContentLabelConfig } from \"./components/AIContentLabel.js\";\nimport type { DeepfakeLabelConfig } from \"./components/DeepfakeLabel.js\";\nimport type { BiometricNoticeConfig } from \"./components/BiometricNotice.js\";\n\n// ---------------------------------------------------------------------------\n// Public types\n// ---------------------------------------------------------------------------\n\nexport interface DiscloAIInitOptions {\n /** Public site identifier. Not a secret — safe to log and include in requests. */\n siteId: string;\n /** CSP nonce to apply to all injected <style> elements. */\n cspNonce?: string;\n /** BCP-47 locale override. Falls back to navigator.language then 'en'. */\n locale?: string;\n /** Override config fetch URL for local dev. Only https:// or http://localhost accepted. */\n configEndpoint?: string;\n chatbotDisclosure?: ChatbotDisclosureConfig;\n aiContentLabel?: AIContentLabelConfig;\n deepfakeLabel?: DeepfakeLabelConfig;\n biometricNotice?: BiometricNoticeConfig;\n}\n\nexport interface DiscloAIConfig {\n siteId: string;\n locale: string;\n chatbotDisclosure?: ChatbotDisclosureConfig;\n aiContentLabel?: AIContentLabelConfig;\n deepfakeLabel?: DeepfakeLabelConfig;\n biometricNotice?: BiometricNoticeConfig;\n}\n\n// ---------------------------------------------------------------------------\n// Defaults\n// ---------------------------------------------------------------------------\n\nconst DEFAULTS: Omit<DiscloAIConfig, \"siteId\"> = {\n locale: \"en\",\n};\n\n// ---------------------------------------------------------------------------\n// CSS sanitizer\n// ---------------------------------------------------------------------------\n\n/** Forbidden patterns that could be used for XSS or data exfiltration via custom CSS. */\nconst FORBIDDEN_CSS_PATTERNS: RegExp[] = [\n /url\\s*\\(/i,\n /@import/i,\n /expression\\s*\\(/i,\n /javascript:/i,\n];\n\n/**\n * Sanitize a custom CSS string.\n * Returns an empty string and emits a warning if any forbidden pattern is found.\n * Forbidden: url(...), @import, expression(...), javascript:\n *\n * M-1 fix: CSS hex escape sequences (e.g. \\75rl() === url()) are normalized\n * before pattern matching to prevent bypass via Unicode escapes.\n */\nexport function sanitizeCSS(css: string): string {\n // Strip CSS escape sequences (\\XX or \\XXXXXX) before pattern matching\n // A CSS escape is a backslash followed by 1–6 hex digits and an optional whitespace\n const normalized = css.replace(/\\\\[0-9a-fA-F]{1,6}\\s?/g, \"ESCAPED\");\n for (const pattern of FORBIDDEN_CSS_PATTERNS) {\n if (pattern.test(normalized)) {\n console.warn(\"[DiscloAI] Custom CSS blocked: forbidden pattern detected\");\n return \"\";\n }\n }\n return css;\n}\n\n// ---------------------------------------------------------------------------\n// Remote config fetch\n// ---------------------------------------------------------------------------\n\n/**\n * Fetch remote config from the DiscloAI API.\n *\n * SECURITY NOTES:\n * - Public endpoint — no auth headers are sent.\n * - siteId is a public tenant identifier, not a secret.\n * - Remote response NEVER contains write tokens, session tokens, or server-side secrets.\n * - Only known component-config shape keys are extracted (see extractSafeConfigShape).\n *\n * Server is expected to respond with: Cache-Control: max-age=60\n *\n * @param siteId - Public site identifier\n * @returns Partial remote config, or null on timeout / network error\n */\nasync function fetchRemoteConfig(\n siteId: string,\n configEndpoint?: string,\n): Promise<Partial<DiscloAIConfig> | null> {\n const controller = new AbortController();\n const timeoutId = setTimeout(() => controller.abort(), 2000);\n\n // Allow http://localhost and http://127.0.0.1 for local dev; reject everything else non-HTTPS\n const defaultUrl = `https://api.discloai.com/v1/config/${encodeURIComponent(siteId)}`;\n const rawUrl = configEndpoint ?? defaultUrl;\n const isLocalDev =\n rawUrl.startsWith(\"http://localhost\") ||\n rawUrl.startsWith(\"http://127.0.0.1\");\n const url = rawUrl.startsWith(\"https://\") || isLocalDev ? rawUrl : defaultUrl;\n\n try {\n const response = await fetch(url, { signal: controller.signal });\n\n if (!response.ok) return null;\n\n const raw = (await response.json()) as unknown;\n return extractSafeConfigShape(raw);\n } catch {\n // 2-second timeout or network error — fall back silently to local/defaults\n return null;\n } finally {\n clearTimeout(timeoutId);\n }\n}\n\n/**\n * Extract only known, safe config keys from the remote response.\n * Never forwards unknown keys or any token/secret field.\n */\nfunction extractSafeConfigShape(raw: unknown): Partial<DiscloAIConfig> | null {\n if (typeof raw !== \"object\" || raw === null) return null;\n\n const obj = raw as Record<string, unknown>;\n const safe: Partial<DiscloAIConfig> = {};\n\n if (typeof obj[\"locale\"] === \"string\") {\n safe.locale = obj[\"locale\"];\n }\n if (\n typeof obj[\"chatbotDisclosure\"] === \"object\" &&\n obj[\"chatbotDisclosure\"] !== null\n ) {\n safe.chatbotDisclosure = obj[\n \"chatbotDisclosure\"\n ] as ChatbotDisclosureConfig;\n }\n if (\n typeof obj[\"aiContentLabel\"] === \"object\" &&\n obj[\"aiContentLabel\"] !== null\n ) {\n safe.aiContentLabel = obj[\"aiContentLabel\"] as AIContentLabelConfig;\n }\n if (\n typeof obj[\"deepfakeLabel\"] === \"object\" &&\n obj[\"deepfakeLabel\"] !== null\n ) {\n safe.deepfakeLabel = obj[\"deepfakeLabel\"] as DeepfakeLabelConfig;\n }\n if (\n typeof obj[\"biometricNotice\"] === \"object\" &&\n obj[\"biometricNotice\"] !== null\n ) {\n safe.biometricNotice = obj[\"biometricNotice\"] as BiometricNoticeConfig;\n }\n\n return safe;\n}\n\n// ---------------------------------------------------------------------------\n// Config resolution pipeline\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve the final runtime config by merging: Remote → Local → Defaults\n *\n * Resolution order (strict — never change):\n * 1. Remote — GET https://api.discloai.com/v1/config/{siteId} (2s timeout, silent fallback)\n * 2. Local — DiscloAI.init({...}) options (overrides remote; intended for dev/testing)\n * 3. Defaults — hardcoded fallbacks in this file\n */\nexport async function resolveConfig(\n local: DiscloAIInitOptions,\n): Promise<DiscloAIConfig> {\n const remote = await fetchRemoteConfig(local.siteId, local.configEndpoint);\n\n return {\n siteId: local.siteId,\n locale: local.locale ?? remote?.locale ?? DEFAULTS.locale,\n chatbotDisclosure: local.chatbotDisclosure ?? remote?.chatbotDisclosure,\n aiContentLabel: local.aiContentLabel ?? remote?.aiContentLabel,\n deepfakeLabel: local.deepfakeLabel ?? remote?.deepfakeLabel,\n biometricNotice: local.biometricNotice ?? remote?.biometricNotice,\n };\n}\n","// ---------------------------------------------------------------------------\n// DiscloAI Audit Event Client\n// ---------------------------------------------------------------------------\n// Security invariants:\n// - NEVER use innerHTML / outerHTML / insertAdjacentHTML\n// - NEVER log PII: pageUrl and sessionId are SHA-256 hashed before sending\n// - NEVER throw: all errors are swallowed — audit must never break the page\n// - Primary transport: navigator.sendBeacon (non-blocking, survives unload)\n// - Fallback: fetch({ keepalive: true }) when sendBeacon unavailable\n// ---------------------------------------------------------------------------\n\nexport interface AuditEventParams {\n /** One of the four EU AI Act Article 50 disclosure types */\n disclosureType: string;\n /** Semver string of the component that fired the event */\n componentVersion: string;\n /** Caller-supplied session identifier — will be hashed before sending */\n sessionId: string;\n /** Whether the disclosure was rendered to the user (default: true) */\n rendered?: boolean;\n}\n\n// ---------------------------------------------------------------------------\n// Window config shape (set by the dashboard embed snippet)\n// ---------------------------------------------------------------------------\n\ninterface DiscloAIWindowConfig {\n siteId?: string;\n auditEndpoint?: string;\n /** Override the config fetch URL — only http://localhost or https:// are accepted. */\n configEndpoint?: string;\n // NOTE: writeToken is intentionally omitted — HMAC signing is server-side only.\n // Browser-initiated audit events are authenticated by Origin header validation\n // on the server. The write token MUST NEVER be present in browser code.\n}\n\ndeclare global {\n interface Window {\n __DISCLOAI_CONFIG__?: DiscloAIWindowConfig;\n }\n}\n\nconst DEFAULT_AUDIT_ENDPOINT = \"https://app.discloai.com/api/v1/audit/event\";\n\n// ---------------------------------------------------------------------------\n// Crypto helpers\n// ---------------------------------------------------------------------------\n\n/**\n * SHA-256 hash of a string, returned as lowercase hex.\n * Uses the Web Crypto API (available in all modern browsers and Node ≥ 18).\n */\nexport async function sha256Hex(input: string): Promise<string> {\n const encoded = new TextEncoder().encode(input);\n const buffer = await crypto.subtle.digest(\"SHA-256\", encoded);\n return hexFromBuffer(buffer);\n}\n\n/**\n * HMAC-SHA-256 of `message` signed with `secret`, returned as lowercase hex.\n */\nexport async function hmacHex(\n message: string,\n secret: string,\n): Promise<string> {\n const encoder = new TextEncoder();\n const key = await crypto.subtle.importKey(\n \"raw\",\n encoder.encode(secret),\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const buffer = await crypto.subtle.sign(\"HMAC\", key, encoder.encode(message));\n return hexFromBuffer(buffer);\n}\n\nfunction hexFromBuffer(buffer: ArrayBuffer): string {\n return Array.from(new Uint8Array(buffer))\n .map((b) => b.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n\n// ---------------------------------------------------------------------------\n// Main export\n// ---------------------------------------------------------------------------\n\n/**\n * Fire-and-forget audit event.\n *\n * Uses navigator.sendBeacon (primary) or fetch({ keepalive: true }) (fallback).\n * Never throws — any error is caught and silently swallowed.\n */\nexport async function sendAuditEvent(params: AuditEventParams): Promise<void> {\n try {\n const cfg =\n typeof window !== \"undefined\" ? (window.__DISCLOAI_CONFIG__ ?? {}) : {};\n\n const siteId = cfg.siteId ?? \"\";\n // M-2: Reject non-HTTPS endpoints to prevent MITM against hashed PII.\n // Exception: http://localhost and http://127.0.0.1 are allowed for local dev.\n const rawEndpoint = cfg.auditEndpoint ?? DEFAULT_AUDIT_ENDPOINT;\n const isLocalDev =\n rawEndpoint.startsWith(\"http://localhost\") ||\n rawEndpoint.startsWith(\"http://127.0.0.1\");\n const endpoint =\n rawEndpoint.startsWith(\"https://\") || isLocalDev\n ? rawEndpoint\n : DEFAULT_AUDIT_ENDPOINT;\n // H-2: ISO 8601 timestamp — numeric epoch strings fail new Date() in Node.js\n const timestamp = new Date().toISOString();\n\n // Hash PII before it leaves the browser\n const pageHref =\n typeof globalThis.location !== \"undefined\"\n ? globalThis.location.href\n : \"\";\n\n const [pageUrlHash, sessionHash] = await Promise.all([\n sha256Hex(pageHref),\n sha256Hex(params.sessionId),\n ]);\n\n const body = JSON.stringify({\n siteId,\n timestamp,\n // C-1: No signature — authentication is via Origin header on the server\n pageUrlHash,\n sessionHash,\n disclosureType: params.disclosureType,\n componentVersion: params.componentVersion,\n rendered: params.rendered ?? true,\n });\n\n const blob = new Blob([body], { type: \"application/json\" });\n\n if (\n typeof navigator !== \"undefined\" &&\n typeof navigator.sendBeacon === \"function\"\n ) {\n navigator.sendBeacon(endpoint, blob);\n } else {\n // keepalive fetch survives page unload just like sendBeacon\n void fetch(endpoint, {\n method: \"POST\",\n body: blob,\n keepalive: true,\n }).catch(() => {\n // swallow network errors silently\n });\n }\n } catch {\n // NEVER propagate — audit failures must never break the host page\n }\n}\n","{\n \"chatbot.disclosure.default\": \"You are talking to an AI assistant.\",\n \"chatbot.disclosure.prominent\": \"This chat is powered by artificial intelligence.\",\n \"content.label.default\": \"AI-generated content\",\n \"content.label.artistic\": \"AI-assisted creative work\",\n \"deepfake.label.default\": \"AI-generated image\",\n \"deepfake.label.artistic\": \"AI-generated artistic content\",\n \"biometric.notice.default\": \"This system processes biometric data. EU AI Act Article 50 §3 applies.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Vous discutez avec un assistant IA.\",\n \"chatbot.disclosure.prominent\": \"Cette conversation est alimentée par l'intelligence artificielle.\",\n \"content.label.default\": \"Contenu généré par IA\",\n \"content.label.artistic\": \"Œuvre créative assistée par IA\",\n \"deepfake.label.default\": \"Image générée par IA\",\n \"deepfake.label.artistic\": \"Contenu artistique généré par IA\",\n \"biometric.notice.default\": \"Ce système traite des données biométriques. L'article 50 §3 de la loi européenne sur l'IA s'applique.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Sie sprechen mit einem KI-Assistenten.\",\n \"chatbot.disclosure.prominent\": \"Dieser Chat wird durch künstliche Intelligenz betrieben.\",\n \"content.label.default\": \"KI-generierter Inhalt\",\n \"content.label.artistic\": \"KI-unterstütztes kreatives Werk\",\n \"deepfake.label.default\": \"KI-generiertes Bild\",\n \"deepfake.label.artistic\": \"KI-generierter künstlerischer Inhalt\",\n \"biometric.notice.default\": \"Dieses System verarbeitet biometrische Daten. EU-KI-Gesetz Artikel 50 §3 gilt.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Estás hablando con un asistente de IA.\",\n \"chatbot.disclosure.prominent\": \"Este chat está impulsado por inteligencia artificial.\",\n \"content.label.default\": \"Contenido generado por IA\",\n \"content.label.artistic\": \"Obra creativa asistida por IA\",\n \"deepfake.label.default\": \"Imagen generada por IA\",\n \"deepfake.label.artistic\": \"Contenido artístico generado por IA\",\n \"biometric.notice.default\": \"Este sistema procesa datos biométricos. Se aplica el artículo 50 §3 de la Ley de IA de la UE.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Разговаряте с AI асистент.\",\n \"chatbot.disclosure.prominent\": \"Този чат е захранван от изкуствен интелект.\",\n \"content.label.default\": \"Съдържание, генерирано от AI\",\n \"content.label.artistic\": \"Творческа работа с помощта на AI\",\n \"deepfake.label.default\": \"AI-генерирано изображение\",\n \"deepfake.label.artistic\": \"AI-генерирано художествено съдържание\",\n \"biometric.notice.default\": \"Тази система обработва биометрични данни. Прилага се Член 50 §3 от Закона за AI на ЕС.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Razgovarate s AI asistentom.\",\n \"chatbot.disclosure.prominent\": \"Ovaj chat pokreće umjetna inteligencija.\",\n \"content.label.default\": \"Sadržaj generiran AI-jem\",\n \"content.label.artistic\": \"Kreativni rad uz pomoć AI-ja\",\n \"deepfake.label.default\": \"AI-generirana slika\",\n \"deepfake.label.artistic\": \"AI-generirani umjetnički sadržaj\",\n \"biometric.notice.default\": \"Ovaj sustav obrađuje biometrijske podatke. Primjenjuje se Članak 50 §3 Zakona EU o AI.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Mluvíte s AI asistentem.\",\n \"chatbot.disclosure.prominent\": \"Tento chat je poháněn umělou inteligencí.\",\n \"content.label.default\": \"Obsah generovaný AI\",\n \"content.label.artistic\": \"Kreativní práce s pomocí AI\",\n \"deepfake.label.default\": \"Obrázek generovaný AI\",\n \"deepfake.label.artistic\": \"Umělecký obsah generovaný AI\",\n \"biometric.notice.default\": \"Tento systém zpracovává biometrické údaje. Platí čl. 50 §3 zákona EU o AI.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Du taler med en AI-assistent.\",\n \"chatbot.disclosure.prominent\": \"Denne chat drives af kunstig intelligens.\",\n \"content.label.default\": \"AI-genereret indhold\",\n \"content.label.artistic\": \"AI-assisteret kreativt arbejde\",\n \"deepfake.label.default\": \"AI-genereret billede\",\n \"deepfake.label.artistic\": \"AI-genereret kunstnerisk indhold\",\n \"biometric.notice.default\": \"Dette system behandler biometriske data. EU AI Act artikel 50 §3 finder anvendelse.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Μιλάτε με έναν βοηθό AI.\",\n \"chatbot.disclosure.prominent\": \"Αυτή η συνομιλία τροφοδοτείται από τεχνητή νοημοσύνη.\",\n \"content.label.default\": \"Περιεχόμενο που δημιουργήθηκε από AI\",\n \"content.label.artistic\": \"Δημιουργική εργασία με βοήθεια AI\",\n \"deepfake.label.default\": \"Εικόνα που δημιουργήθηκε από AI\",\n \"deepfake.label.artistic\": \"Καλλιτεχνικό περιεχόμενο που δημιουργήθηκε από AI\",\n \"biometric.notice.default\": \"Αυτό το σύστημα επεξεργάζεται βιομετρικά δεδομένα. Ισχύει Άρθρο 50 §3 Νόμου ΕΕ για AI.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Räägite AI-assistendiga.\",\n \"chatbot.disclosure.prominent\": \"Seda vestlust käitab tehisintellekt.\",\n \"content.label.default\": \"AI loodud sisu\",\n \"content.label.artistic\": \"AI-abistatud loominguline töö\",\n \"deepfake.label.default\": \"AI loodud pilt\",\n \"deepfake.label.artistic\": \"AI loodud kunstiline sisu\",\n \"biometric.notice.default\": \"See süsteem töötleb biomeetrilisi andmeid. Kohaldub EL AI seaduse artikkel 50 §3.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Puhut AI-assistentin kanssa.\",\n \"chatbot.disclosure.prominent\": \"Tämä chat toimii tekoälyn avulla.\",\n \"content.label.default\": \"Tekoälyn tuottama sisältö\",\n \"content.label.artistic\": \"Tekoälyavusteinen luova työ\",\n \"deepfake.label.default\": \"Tekoälyn luoma kuva\",\n \"deepfake.label.artistic\": \"Tekoälyn luoma taiteellinen sisältö\",\n \"biometric.notice.default\": \"Tämä järjestelmä käsittelee biometrisiä tietoja. EU:n tekoälylain 50 artiklan 3 kohta soveltuu.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Tá tú ag caint le cúntóir AI.\",\n \"chatbot.disclosure.prominent\": \"Tá an comhrá seo faoi chumhacht na hintleachta saorga.\",\n \"content.label.default\": \"Ábhar arna ghiniúint ag AI\",\n \"content.label.artistic\": \"Obair chruthaitheach le cúnamh AI\",\n \"deepfake.label.default\": \"Íomhá arna ghiniúint ag AI\",\n \"deepfake.label.artistic\": \"Ábhar ealaíne arna ghiniúint ag AI\",\n \"biometric.notice.default\": \"Próiseálann an córas seo sonraí bithfhéiniúlachta. Tá Airteagal 50 §3 de Dhlí AI an AE infheidhme.\"\n}\n","{\n \"chatbot.disclosure.default\": \"AI-asszisztenssel beszél.\",\n \"chatbot.disclosure.prominent\": \"Ez a csevegés mesterséges intelligencia által működik.\",\n \"content.label.default\": \"AI által generált tartalom\",\n \"content.label.artistic\": \"AI-val támogatott kreatív munka\",\n \"deepfake.label.default\": \"AI által generált kép\",\n \"deepfake.label.artistic\": \"AI által generált művészeti tartalom\",\n \"biometric.notice.default\": \"Ez a rendszer biometrikus adatokat dolgoz fel. Az EU MI-törvény 50. cikk 3. §-a alkalmazandó.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Stai parlando con un assistente AI.\",\n \"chatbot.disclosure.prominent\": \"Questa chat è alimentata dall'intelligenza artificiale.\",\n \"content.label.default\": \"Contenuto generato dall'AI\",\n \"content.label.artistic\": \"Lavoro creativo assistito dall'AI\",\n \"deepfake.label.default\": \"Immagine generata dall'AI\",\n \"deepfake.label.artistic\": \"Contenuto artistico generato dall'AI\",\n \"biometric.notice.default\": \"Questo sistema elabora dati biometrici. Si applica l'Articolo 50 §3 della Legge AI dell'UE.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Jūs runājat ar AI asistentu.\",\n \"chatbot.disclosure.prominent\": \"Šo tērzēšanu darbina mākslīgais intelekts.\",\n \"content.label.default\": \"AI ģenerēts saturs\",\n \"content.label.artistic\": \"AI asistēts radošs darbs\",\n \"deepfake.label.default\": \"AI ģenerēts attēls\",\n \"deepfake.label.artistic\": \"AI ģenerēts māksliniecisks saturs\",\n \"biometric.notice.default\": \"Šī sistēma apstrādā biometriskos datus. Piemērojams ES MI likuma 50. pants §3.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Jūs kalbatės su AI asistentu.\",\n \"chatbot.disclosure.prominent\": \"Šis pokalbis vyksta dirbtinio intelekto pagalba.\",\n \"content.label.default\": \"AI sugeneruotas turinys\",\n \"content.label.artistic\": \"AI padedamas kūrybinis darbas\",\n \"deepfake.label.default\": \"AI sugeneruotas vaizdas\",\n \"deepfake.label.artistic\": \"AI sugeneruotas meninis turinys\",\n \"biometric.notice.default\": \"Ši sistema apdoroja biometrinius duomenis. Taikomas ES DI įstatymo 50 straipsnio 3 dalis.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Qed titkellem ma' assistent AI.\",\n \"chatbot.disclosure.prominent\": \"Dan il-chat huwa mħaddem mill-intelliġenza artifiċjali.\",\n \"content.label.default\": \"Kontenut iġġenerat minn AI\",\n \"content.label.artistic\": \"Xogħol kreattiv assistit minn AI\",\n \"deepfake.label.default\": \"Immaġni iġġenerata minn AI\",\n \"deepfake.label.artistic\": \"Kontenut artistiku iġġenerat minn AI\",\n \"biometric.notice.default\": \"Dan is-sistema tipproċessa data bijometrika. Japplika l-Artikolu 50 §3 tal-Liġi AI tal-UE.\"\n}\n","{\n \"chatbot.disclosure.default\": \"U praat met een AI-assistent.\",\n \"chatbot.disclosure.prominent\": \"Deze chat wordt aangedreven door kunstmatige intelligentie.\",\n \"content.label.default\": \"AI-gegenereerde inhoud\",\n \"content.label.artistic\": \"AI-ondersteund creatief werk\",\n \"deepfake.label.default\": \"AI-gegenereerde afbeelding\",\n \"deepfake.label.artistic\": \"AI-gegenereerde artistieke inhoud\",\n \"biometric.notice.default\": \"Dit systeem verwerkt biometrische gegevens. EU AI-wet artikel 50 §3 is van toepassing.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Rozmawiasz z asystentem AI.\",\n \"chatbot.disclosure.prominent\": \"Ten czat jest obsługiwany przez sztuczną inteligencję.\",\n \"content.label.default\": \"Treść wygenerowana przez AI\",\n \"content.label.artistic\": \"Praca twórcza wspierana przez AI\",\n \"deepfake.label.default\": \"Obraz wygenerowany przez AI\",\n \"deepfake.label.artistic\": \"Artystyczne treści wygenerowane przez AI\",\n \"biometric.notice.default\": \"Ten system przetwarza dane biometryczne. Stosuje się art. 50 §3 Ustawy UE o AI.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Você está falando com um assistente de IA.\",\n \"chatbot.disclosure.prominent\": \"Este chat é alimentado por inteligência artificial.\",\n \"content.label.default\": \"Conteúdo gerado por IA\",\n \"content.label.artistic\": \"Trabalho criativo assistido por IA\",\n \"deepfake.label.default\": \"Imagem gerada por IA\",\n \"deepfake.label.artistic\": \"Conteúdo artístico gerado por IA\",\n \"biometric.notice.default\": \"Este sistema processa dados biométricos. Aplica-se o Artigo 50 §3 da Lei de IA da UE.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Vorbiți cu un asistent AI.\",\n \"chatbot.disclosure.prominent\": \"Acest chat este alimentat de inteligența artificială.\",\n \"content.label.default\": \"Conținut generat de AI\",\n \"content.label.artistic\": \"Lucrare creativă asistată de AI\",\n \"deepfake.label.default\": \"Imagine generată de AI\",\n \"deepfake.label.artistic\": \"Conținut artistic generat de AI\",\n \"biometric.notice.default\": \"Acest sistem procesează date biometrice. Se aplică Articolul 50 §3 din Legea AI a UE.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Hovoríte s AI asistentom.\",\n \"chatbot.disclosure.prominent\": \"Tento chat je poháňaný umelou inteligenciou.\",\n \"content.label.default\": \"Obsah generovaný AI\",\n \"content.label.artistic\": \"Kreatívna práca s pomocou AI\",\n \"deepfake.label.default\": \"Obrázok generovaný AI\",\n \"deepfake.label.artistic\": \"Umelecký obsah generovaný AI\",\n \"biometric.notice.default\": \"Tento systém spracúva biometrické údaje. Platí čl. 50 §3 zákona EÚ o AI.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Pogovarjate se z AI pomočnikom.\",\n \"chatbot.disclosure.prominent\": \"Ta klepet poganja umetna inteligenca.\",\n \"content.label.default\": \"Vsebina, ustvarjena z AI\",\n \"content.label.artistic\": \"Ustvarjalno delo s pomočjo AI\",\n \"deepfake.label.default\": \"Slika, ustvarjena z AI\",\n \"deepfake.label.artistic\": \"Umetniška vsebina, ustvarjena z AI\",\n \"biometric.notice.default\": \"Ta sistem obdeluje biometrične podatke. Velja Člen 50 §3 Zakona EU o AI.\"\n}\n","{\n \"chatbot.disclosure.default\": \"Du pratar med en AI-assistent.\",\n \"chatbot.disclosure.prominent\": \"Denna chatt drivs av artificiell intelligens.\",\n \"content.label.default\": \"AI-genererat innehåll\",\n \"content.label.artistic\": \"AI-assisterat kreativt arbete\",\n \"deepfake.label.default\": \"AI-genererad bild\",\n \"deepfake.label.artistic\": \"AI-genererat konstnärligt innehåll\",\n \"biometric.notice.default\": \"Detta system behandlar biometriska uppgifter. EU AI-lagens artikel 50 §3 är tillämplig.\"\n}\n","import en from \"./en.json\";\nimport fr from \"./fr.json\";\nimport de from \"./de.json\";\nimport es from \"./es.json\";\nimport bg from \"./bg.json\";\nimport hr from \"./hr.json\";\nimport cs from \"./cs.json\";\nimport da from \"./da.json\";\nimport el from \"./el.json\";\nimport et from \"./et.json\";\nimport fi from \"./fi.json\";\nimport ga from \"./ga.json\";\nimport hu from \"./hu.json\";\nimport it from \"./it.json\";\nimport lv from \"./lv.json\";\nimport lt from \"./lt.json\";\nimport mt from \"./mt.json\";\nimport nl from \"./nl.json\";\nimport pl from \"./pl.json\";\nimport pt from \"./pt.json\";\nimport ro from \"./ro.json\";\nimport sk from \"./sk.json\";\nimport sl from \"./sl.json\";\nimport sv from \"./sv.json\";\n\nexport type Locale =\n | \"bg\"\n | \"hr\"\n | \"cs\"\n | \"da\"\n | \"de\"\n | \"el\"\n | \"en\"\n | \"es\"\n | \"et\"\n | \"fi\"\n | \"fr\"\n | \"ga\"\n | \"hu\"\n | \"it\"\n | \"lv\"\n | \"lt\"\n | \"mt\"\n | \"nl\"\n | \"pl\"\n | \"pt\"\n | \"ro\"\n | \"sk\"\n | \"sl\"\n | \"sv\";\n\nexport const SUPPORTED_LOCALES: Locale[] = [\n \"bg\",\n \"hr\",\n \"cs\",\n \"da\",\n \"de\",\n \"el\",\n \"en\",\n \"es\",\n \"et\",\n \"fi\",\n \"fr\",\n \"ga\",\n \"hu\",\n \"it\",\n \"lv\",\n \"lt\",\n \"mt\",\n \"nl\",\n \"pl\",\n \"pt\",\n \"ro\",\n \"sk\",\n \"sl\",\n \"sv\",\n];\n\ntype Messages = Record<string, string>;\n\nexport const messages: Record<Locale, Messages> = {\n bg: bg as Messages,\n hr: hr as Messages,\n cs: cs as Messages,\n da: da as Messages,\n de: de as Messages,\n el: el as Messages,\n en: en as Messages,\n es: es as Messages,\n et: et as Messages,\n fi: fi as Messages,\n fr: fr as Messages,\n ga: ga as Messages,\n hu: hu as Messages,\n it: it as Messages,\n lv: lv as Messages,\n lt: lt as Messages,\n mt: mt as Messages,\n nl: nl as Messages,\n pl: pl as Messages,\n pt: pt as Messages,\n ro: ro as Messages,\n sk: sk as Messages,\n sl: sl as Messages,\n sv: sv as Messages,\n};\n\n/**\n * Resolve the best matching locale from navigator.language.\n * Falls back to 'en' when running server-side or if no match is found.\n */\nexport function resolveLocale(): Locale {\n if (typeof navigator === \"undefined\") return \"en\";\n\n const lang = navigator.language;\n\n // Exact match (e.g. 'fr' or 'en')\n if (SUPPORTED_LOCALES.includes(lang as Locale)) return lang as Locale;\n\n // Prefix match (e.g. 'fr-FR' → 'fr', 'fr-CA' → 'fr')\n const prefix = lang.split(\"-\")[0];\n if (prefix !== undefined && SUPPORTED_LOCALES.includes(prefix as Locale)) {\n return prefix as Locale;\n }\n\n return \"en\";\n}\n\n/**\n * Look up a translation key for the given (or auto-resolved) locale.\n * Never throws, never returns undefined — falls back to en.json, then to the key itself.\n */\nexport function t(key: string, locale?: Locale): string {\n const resolved = locale ?? resolveLocale();\n const dict = messages[resolved];\n const value = dict[key];\n if (value !== undefined) return value;\n\n // Fall back to English\n const fallback = messages[\"en\"][key];\n if (fallback !== undefined) return fallback;\n\n // Last resort: return the key itself\n return key;\n}\n","export const VERSION = \"0.1.0\";\n","// EU AI Act Article 50 §4¶2 — AI-generated content label\n//\n// Invariants:\n// - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent\n// - Custom CSS must be sanitized before injection via sanitizeCSS()\n// - All errors are swallowed — never throw\n\nimport { sendAuditEvent } from \"../audit.js\";\nimport { sanitizeCSS } from \"../config.js\";\nimport { t } from \"../i18n/index.js\";\nimport { VERSION } from \"../version.js\";\n\nexport interface AIContentLabelConfig {\n enabled?: boolean;\n variant?: \"default\" | \"artistic\";\n /** CSS selector for elements to label */\n selector?: string;\n /** Custom CSS string — sanitized before injection */\n customCSS?: string;\n exemptions?: {\n /** Both editorialControl AND editorialResponsibility must be present to suppress rendering */\n editorialControl?: boolean;\n editorialResponsibility?: string;\n };\n}\n\nconst DEFAULT_LABEL_CSS =\n '[data-discloai-label=\"ai-content\"] {' +\n \" position: relative; display: inline-block;\" +\n \" background: #e0e7ff; color: #3730a3;\" +\n \" font-size: 0.75rem; padding: 2px 6px;\" +\n \" border-radius: 3px; font-family: sans-serif;\" +\n \" white-space: nowrap; }\";\n\n/** Render the AIContentLabel widget (EU AI Act Article 50 §4¶2). */\nexport function renderAIContentLabel(\n siteId: string,\n config: AIContentLabelConfig,\n cspNonce?: string,\n): void {\n try {\n if (typeof document === \"undefined\") return;\n\n // editorialControl exemption: BOTH fields must be present\n if (\n config.exemptions?.editorialControl === true &&\n typeof config.exemptions?.editorialResponsibility === \"string\"\n ) {\n void sendAuditEvent({\n disclosureType: \"AIContentLabel\",\n componentVersion: VERSION,\n sessionId: siteId,\n rendered: false,\n });\n return;\n }\n\n // Find target elements\n const selector = config.selector ?? '[data-discloai-content=\"true\"]';\n const elements = Array.from(document.querySelectorAll(selector));\n\n const labelText =\n config.variant === \"artistic\"\n ? t(\"content.label.artistic\")\n : t(\"content.label.default\");\n\n for (const element of elements) {\n if (element.hasAttribute(\"data-discloai-labeled\")) continue;\n\n const span = document.createElement(\"span\");\n span.textContent = labelText;\n span.setAttribute(\"data-discloai-label\", \"ai-content\");\n span.setAttribute(\"role\", \"img\");\n span.setAttribute(\"aria-label\", labelText);\n\n if (element.parentNode) {\n element.parentNode.insertBefore(span, element);\n }\n element.setAttribute(\"data-discloai-labeled\", \"true\");\n }\n\n // Inject default label styles\n const styleEl = document.createElement(\"style\");\n styleEl.textContent = DEFAULT_LABEL_CSS;\n if (cspNonce) styleEl.setAttribute(\"nonce\", cspNonce);\n document.head.appendChild(styleEl);\n\n // Inject custom CSS if provided\n if (config.customCSS) {\n const sanitized = sanitizeCSS(config.customCSS);\n if (sanitized) {\n const customStyleEl = document.createElement(\"style\");\n customStyleEl.textContent = sanitized;\n if (cspNonce) customStyleEl.setAttribute(\"nonce\", cspNonce);\n document.head.appendChild(customStyleEl);\n }\n }\n\n void sendAuditEvent({\n disclosureType: \"AIContentLabel\",\n componentVersion: VERSION,\n sessionId: siteId,\n rendered: true,\n });\n } catch (_e) {\n // Never throw — swallow all errors silently\n }\n}\n","// EU AI Act Article 50 §4¶1 — Deepfake / synthetic media label\n//\n// Invariants:\n// - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent\n// - z-index: 2147483647 on label overlay\n// - Custom CSS must be sanitized before injection via sanitizeCSS()\n// - All errors are swallowed — never throw\n\nimport { sendAuditEvent } from \"../audit.js\";\nimport { sanitizeCSS } from \"../config.js\";\nimport { t } from \"../i18n/index.js\";\nimport { VERSION } from \"../version.js\";\n\nexport interface DeepfakeLabelConfig {\n enabled?: boolean;\n variant?: \"default\" | \"artistic\";\n /** CSS selector for synthetic media elements to label */\n selector?: string;\n /** Custom CSS string — sanitized before injection */\n customCSS?: string;\n exemptions?: {\n /** Uses artistic label text but still renders — does NOT suppress disclosure */\n artisticContext?: boolean;\n };\n}\n\nfunction wrapAndLabel(\n element: Element,\n labelText: string,\n topOffset: string,\n leftOffset: string,\n): void {\n if (!element.parentNode) return;\n\n // Create wrapper div\n const wrapper = document.createElement(\"div\");\n wrapper.style.cssText = \"position: relative; display: inline-block;\";\n element.parentNode.insertBefore(wrapper, element);\n wrapper.appendChild(element);\n\n // Create label overlay\n const labelDiv = document.createElement(\"div\");\n labelDiv.textContent = labelText;\n labelDiv.setAttribute(\"role\", \"img\");\n labelDiv.setAttribute(\"aria-label\", labelText);\n labelDiv.style.cssText =\n \"position: absolute;\" +\n \" top: \" +\n topOffset +\n \";\" +\n \" left: \" +\n leftOffset +\n \";\" +\n \" z-index: 2147483647;\" +\n \" background: rgba(0,0,0,0.7);\" +\n \" color: #fff;\" +\n \" font-size: 0.75rem;\" +\n \" padding: 2px 8px;\" +\n \" border-radius: 3px;\" +\n \" pointer-events: none;\";\n wrapper.appendChild(labelDiv);\n}\n\n/** Render the DeepfakeLabel widget (EU AI Act Article 50 §4¶1). */\nexport function renderDeepfakeLabel(\n siteId: string,\n config: DeepfakeLabelConfig,\n cspNonce?: string,\n): void {\n try {\n if (typeof document === \"undefined\") return;\n\n // artisticContext exemption uses the artistic label but still renders\n const useArtistic =\n config.exemptions?.artisticContext === true ||\n config.variant === \"artistic\";\n const labelText = useArtistic\n ? t(\"deepfake.label.artistic\")\n : t(\"deepfake.label.default\");\n\n // Find target elements\n let elements: Element[];\n if (config.selector) {\n elements = Array.from(document.querySelectorAll(config.selector)).filter(\n (el) => el.tagName === \"VIDEO\" || el.tagName === \"IMG\",\n );\n } else {\n elements = [\n ...Array.from(document.querySelectorAll(\"video\")),\n ...Array.from(document.querySelectorAll(\"img\")),\n ];\n }\n\n for (const element of elements) {\n if (element.hasAttribute(\"data-discloai-labeled\")) continue;\n\n const isVideo = element.tagName === \"VIDEO\";\n const offset = isVideo ? \"8px\" : \"4px\";\n wrapAndLabel(element, labelText, offset, offset);\n element.setAttribute(\"data-discloai-labeled\", \"true\");\n }\n\n // Inject custom CSS if provided\n if (config.customCSS) {\n const sanitized = sanitizeCSS(config.customCSS);\n if (sanitized) {\n const customStyleEl = document.createElement(\"style\");\n customStyleEl.textContent = sanitized;\n if (cspNonce) customStyleEl.setAttribute(\"nonce\", cspNonce);\n document.head.appendChild(customStyleEl);\n }\n }\n\n void sendAuditEvent({\n disclosureType: \"DeepfakeLabel\",\n componentVersion: VERSION,\n sessionId: siteId,\n rendered: true,\n });\n } catch (_e) {\n // Never throw — swallow all errors silently\n }\n}\n","// EU AI Act Article 50 §1 — Chatbot AI disclosure\n//\n// Invariants:\n// - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent\n// - z-index: 2147483647 on the disclosure overlay\n// - sessionStorage key: discloai:{siteId}:ChatbotDisclosure:seen\n// - Supports triggerEvent: 'on-load' | 'on-open'\n// - On obviousContext exemption: log console.info and send audit event with rendered: false\n// - All errors are swallowed — never throw\n\nimport { sendAuditEvent } from \"../audit.js\";\nimport { sanitizeCSS } from \"../config.js\";\nimport { t } from \"../i18n/index.js\";\nimport { VERSION } from \"../version.js\";\n\nexport interface ChatbotDisclosureConfig {\n enabled?: boolean;\n /** CSS selector for the target chatbot widget element */\n selector?: string;\n /** One of the 6 built-in vendor presets */\n vendor?: \"intercom\" | \"crisp\" | \"tidio\" | \"zendesk\" | \"drift\" | \"livechat\";\n /** When to show the disclosure: on page load or when the chat widget opens */\n triggerEvent?: \"on-load\" | \"on-open\";\n /** Style variant */\n variant?: \"minimal\" | \"prominent\" | \"custom\";\n /** Custom CSS string — sanitized before injection; url(), @import, expression(), javascript: are blocked */\n customCSS?: string;\n exemptions?: {\n /** Suppress disclosure when AI use is already obvious from context */\n obviousContext?: boolean;\n /** Suppress for editorial/creative AI use */\n editorialControl?: boolean;\n };\n}\n\nconst VENDOR_SELECTOR_MAP: Record<string, string> = {\n intercom: \"#intercom-container\",\n crisp: \"#crisp-chatbox\",\n tidio: \"#tidio-chat\",\n zendesk: \"#launcher\",\n drift: \"#drift-frame-controller\",\n livechat: \"#chat-widget-container\",\n};\n\n/** Render the ChatbotDisclosure widget (EU AI Act Article 50 §1). */\nexport function renderChatbotDisclosure(\n siteId: string,\n config: ChatbotDisclosureConfig,\n cspNonce?: string,\n): void {\n try {\n if (typeof document === \"undefined\") return;\n\n // sessionStorage check — skip if user already acknowledged this session\n const storageKey = `discloai:${siteId}:ChatbotDisclosure:seen`;\n try {\n if (\n typeof sessionStorage !== \"undefined\" &&\n sessionStorage.getItem(storageKey) === \"true\"\n ) {\n return;\n }\n } catch (_e) {\n // sessionStorage unavailable (private mode, cross-origin) — continue\n }\n\n // obviousContext exemption: suppress rendering, send audit event with rendered: false\n if (config.exemptions?.obviousContext === true) {\n console.info(\n \"[DiscloAI] ChatbotDisclosure: obviousContext exemption applied\",\n );\n void sendAuditEvent({\n disclosureType: \"ChatbotDisclosure\",\n componentVersion: VERSION,\n sessionId: siteId,\n rendered: false,\n });\n return;\n }\n\n const showDisclosure = (): void => {\n const overlay = document.createElement(\"div\");\n overlay.setAttribute(\"role\", \"alert\");\n overlay.setAttribute(\"aria-live\", \"polite\");\n overlay.style.cssText =\n \"position: fixed;\" +\n \" bottom: 80px; right: 24px;\" +\n \" z-index: 2147483647;\" +\n \" background: #fff;\" +\n \" border: 1px solid #c7d2fe;\" +\n \" border-radius: 8px;\" +\n \" padding: 12px 16px;\" +\n \" max-width: 320px;\" +\n \" box-shadow: 0 4px 16px rgba(0,0,0,0.12);\" +\n \" font-family: sans-serif;\";\n\n const labelText =\n config.variant === \"prominent\"\n ? t(\"chatbot.disclosure.prominent\")\n : t(\"chatbot.disclosure.default\");\n\n const p = document.createElement(\"p\");\n p.textContent = labelText;\n overlay.appendChild(p);\n\n const closeOverlay = (): void => {\n if (overlay.parentNode) {\n overlay.parentNode.removeChild(overlay);\n }\n try {\n if (typeof sessionStorage !== \"undefined\") {\n sessionStorage.setItem(storageKey, \"true\");\n }\n } catch (_e) {\n // ignore — sessionStorage might be unavailable\n }\n };\n\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\\u2715\"; // ✕\n closeBtn.addEventListener(\"click\", closeOverlay);\n overlay.appendChild(closeBtn);\n\n // Inject custom CSS\n if (config.customCSS) {\n const sanitized = sanitizeCSS(config.customCSS);\n if (sanitized) {\n const styleEl = document.createElement(\"style\");\n styleEl.textContent = sanitized;\n if (cspNonce) styleEl.setAttribute(\"nonce\", cspNonce);\n document.head.appendChild(styleEl);\n }\n }\n\n document.body.appendChild(overlay);\n\n // Auto-dismiss after 8 seconds\n setTimeout(closeOverlay, 8000);\n\n void sendAuditEvent({\n disclosureType: \"ChatbotDisclosure\",\n componentVersion: VERSION,\n sessionId: siteId,\n rendered: true,\n });\n };\n\n const triggerEvent = config.triggerEvent ?? \"on-load\";\n\n if (triggerEvent === \"on-load\") {\n showDisclosure();\n return;\n }\n\n // on-open: watch for the target element to appear and become visible\n let vendorSelector = \"\";\n if (config.vendor) {\n vendorSelector = VENDOR_SELECTOR_MAP[config.vendor] ?? \"\";\n } else if (config.selector) {\n vendorSelector = config.selector;\n }\n\n if (!vendorSelector) {\n // No selector — fall back to global banner mode (render immediately)\n showDisclosure();\n return;\n }\n\n const observer = new MutationObserver(() => {\n const target = document.querySelector(vendorSelector);\n if (target) {\n const style = window.getComputedStyle(target);\n if (style.display !== \"none\" && style.visibility !== \"hidden\") {\n observer.disconnect();\n showDisclosure();\n }\n }\n });\n\n observer.observe(document.body, {\n childList: true,\n subtree: true,\n attributes: true,\n });\n } catch (_e) {\n // Never throw — swallow all errors silently\n }\n}\n","// EU AI Act Article 50 §3 — Biometric / emotion recognition system notice\n//\n// Invariants:\n// - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent\n// - z-index: 2147483647 on the notice banner\n// - Custom CSS must be sanitized before injection via sanitizeCSS()\n// - All errors are swallowed — never throw\n\nimport { sendAuditEvent } from \"../audit.js\";\nimport { sanitizeCSS } from \"../config.js\";\nimport { t } from \"../i18n/index.js\";\nimport { VERSION } from \"../version.js\";\n\nexport interface BiometricNoticeConfig {\n enabled?: boolean;\n /** CSS selector for the element that triggers the biometric system */\n selector?: string;\n /** Custom CSS string — sanitized before injection */\n customCSS?: string;\n}\n\n/** Render the BiometricNotice widget (EU AI Act Article 50 §3). */\nexport function renderBiometricNotice(\n siteId: string,\n config: BiometricNoticeConfig,\n cspNonce?: string,\n): void {\n try {\n if (typeof document === \"undefined\") return;\n\n const banner = document.createElement(\"div\");\n banner.setAttribute(\"role\", \"alert\");\n banner.setAttribute(\"aria-live\", \"assertive\");\n banner.style.cssText =\n \"position: fixed;\" +\n \" top: 0; left: 0; right: 0;\" +\n \" z-index: 2147483647;\" +\n \" background: #fef3c7;\" +\n \" border-bottom: 2px solid #f59e0b;\" +\n \" padding: 12px 24px;\" +\n \" font-family: sans-serif;\" +\n \" font-size: 0.875rem;\" +\n \" text-align: center;\";\n\n const noticeText = t(\"biometric.notice.default\");\n\n const textSpan = document.createElement(\"span\");\n textSpan.textContent = noticeText;\n banner.appendChild(textSpan);\n\n const closeBtn = document.createElement(\"button\");\n closeBtn.textContent = \"\\u2715\"; // ✕\n closeBtn.addEventListener(\"click\", () => {\n if (banner.parentNode) {\n banner.parentNode.removeChild(banner);\n }\n });\n banner.appendChild(closeBtn);\n\n // Inject custom CSS if provided\n if (config.customCSS) {\n const sanitized = sanitizeCSS(config.customCSS);\n if (sanitized) {\n const styleEl = document.createElement(\"style\");\n styleEl.textContent = sanitized;\n if (cspNonce) styleEl.setAttribute(\"nonce\", cspNonce);\n document.head.appendChild(styleEl);\n }\n }\n\n document.body.appendChild(banner);\n\n void sendAuditEvent({\n disclosureType: \"BiometricNotice\",\n componentVersion: VERSION,\n sessionId: siteId,\n rendered: true,\n });\n } catch (_e) {\n // Never throw — swallow all errors silently\n }\n}\n","// @lighthouse-budget: TTI delta < 100ms\n\nimport { resolveConfig } from \"./config.js\";\nimport type { DiscloAIConfig, DiscloAIInitOptions } from \"./config.js\";\nimport { renderAIContentLabel } from \"./components/AIContentLabel.js\";\nimport { renderDeepfakeLabel } from \"./components/DeepfakeLabel.js\";\nimport { renderChatbotDisclosure } from \"./components/ChatbotDisclosure.js\";\nimport { renderBiometricNotice } from \"./components/BiometricNotice.js\";\n\nexport { resolveLocale, t, SUPPORTED_LOCALES } from \"./i18n/index.js\";\n\n/**\n * Initialise DiscloAI EU AI Act Article 50 disclosure widgets.\n *\n * Non-blocking contract:\n * - init() itself is synchronous and returns immediately\n * - Config resolution (remote fetch + merge) is fire-and-forget\n * - Component injection is always deferred — never blocks DOMContentLoaded\n *\n * @param options - Init options including siteId and per-component config\n */\nexport function init(options?: Partial<DiscloAIInitOptions>): void {\n if (!options?.siteId) {\n console.warn(\"[DiscloAI] siteId is required\");\n return;\n }\n\n const cspNonce = options.cspNonce;\n\n // Fire-and-forget: resolveConfig is async (remote fetch + merge).\n // Using void to explicitly discard the promise — never block render.\n void resolveConfig(options as DiscloAIInitOptions).then((config) => {\n injectComponents(config, cspNonce);\n });\n}\n\n/**\n * Inject configured disclosure components after config is resolved.\n * Always called asynchronously — never on the critical render path.\n */\nfunction injectComponents(config: DiscloAIConfig, cspNonce?: string): void {\n const { siteId } = config;\n\n if (config.aiContentLabel?.enabled !== false) {\n renderAIContentLabel(siteId, config.aiContentLabel ?? {}, cspNonce);\n }\n if (config.deepfakeLabel?.enabled !== false) {\n renderDeepfakeLabel(siteId, config.deepfakeLabel ?? {}, cspNonce);\n }\n if (config.chatbotDisclosure?.enabled !== false) {\n renderChatbotDisclosure(siteId, config.chatbotDisclosure ?? {}, cspNonce);\n }\n if (config.biometricNotice?.enabled !== false) {\n renderBiometricNotice(siteId, config.biometricNotice ?? {}, cspNonce);\n }\n}\n","/**\n * Built-in chatbot vendor selector presets.\n * Used by ChatbotDisclosure to locate the widget element automatically.\n * When no selector matches, the component falls back to global banner mode.\n */\nexport const VENDOR_PRESETS = {\n intercom: \"#intercom-frame, .intercom-launcher\",\n crisp: \".crisp-client, #crisp-chatbox\",\n tidio: \"#tidio-chat, #tidio-chat-iframe\",\n zendesk: \"#launcher, .zEWidget-launcher\",\n drift: \"#drift-widget, .drift-widget-welcome\",\n livechat: \"#chat-widget-container, .livechat-widget\",\n} as const;\n\nexport type VendorPreset = keyof typeof VENDOR_PRESETS;\n\n/**\n * Resolve the CSS selector for a given vendor preset key.\n * Returns null if the key is not a known preset.\n *\n * SECURITY: The returned selector string must never be passed to eval() or\n * used in a way that allows script injection. Use only with querySelector().\n */\nexport function resolveVendorSelector(vendor: string): string | null {\n if (vendor in VENDOR_PRESETS) {\n return VENDOR_PRESETS[vendor as VendorPreset];\n }\n return null;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@discloai/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "EU AI Act Article 50 disclosure SDK \u2014 chatbot, AI content, deepfake, and biometric notices",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://discloai.com",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/DiscloAI/sdk",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"EU AI Act",
|
|
14
|
+
"Article 50",
|
|
15
|
+
"disclosure",
|
|
16
|
+
"AI compliance",
|
|
17
|
+
"chatbot disclosure",
|
|
18
|
+
"deepfake label",
|
|
19
|
+
"WCAG"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"main": "dist/index.js",
|
|
26
|
+
"module": "dist/index.mjs",
|
|
27
|
+
"types": "dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.mjs",
|
|
32
|
+
"require": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:coverage": "vitest run --coverage",
|
|
39
|
+
"test:e2e": "playwright test --config=tests/playwright.config.ts",
|
|
40
|
+
"test:e2e:integration": "playwright test --config=tests/playwright.config.ts tests/integration/",
|
|
41
|
+
"test:e2e:headed": "playwright test --config=tests/playwright.config.ts --headed",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"prepublishOnly": "pnpm run build && pnpm run test"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public",
|
|
47
|
+
"registry": "https://registry.npmjs.org"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@playwright/test": "^1.40.0",
|
|
51
|
+
"@types/jsdom": "^28.0.3",
|
|
52
|
+
"@vitest/coverage-v8": "^2.0.0",
|
|
53
|
+
"jsdom": "^29.1.1",
|
|
54
|
+
"tsup": "^8.0.0",
|
|
55
|
+
"typescript": "^5.4.5",
|
|
56
|
+
"vitest": "^2.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { sendAuditEvent, sha256Hex, hmacHex } from "../audit.js";
|
|
4
|
+
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// Stub fetch so no real network requests are made
|
|
7
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true } as Response));
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.unstubAllGlobals();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("sha256Hex()", () => {
|
|
15
|
+
it("returns a 64-char lowercase hex string", async () => {
|
|
16
|
+
const result = await sha256Hex("hello");
|
|
17
|
+
expect(result).toHaveLength(64);
|
|
18
|
+
expect(result).toMatch(/^[0-9a-f]+$/);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns the known SHA-256 of 'hello'", async () => {
|
|
22
|
+
// SHA-256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
|
|
23
|
+
const result = await sha256Hex("hello");
|
|
24
|
+
expect(result).toBe(
|
|
25
|
+
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("hmacHex()", () => {
|
|
31
|
+
it("returns a 64-char lowercase hex string", async () => {
|
|
32
|
+
const result = await hmacHex("message", "secret");
|
|
33
|
+
expect(result).toHaveLength(64);
|
|
34
|
+
expect(result).toMatch(/^[0-9a-f]+$/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("sendAuditEvent()", () => {
|
|
39
|
+
it("resolves without throwing when navigator.sendBeacon is not available", async () => {
|
|
40
|
+
// Replace navigator with a version that has no sendBeacon
|
|
41
|
+
vi.stubGlobal("navigator", { language: "en" });
|
|
42
|
+
|
|
43
|
+
await expect(
|
|
44
|
+
sendAuditEvent({
|
|
45
|
+
disclosureType: "ChatbotDisclosure",
|
|
46
|
+
componentVersion: "0.1.0",
|
|
47
|
+
sessionId: "test-session-id",
|
|
48
|
+
}),
|
|
49
|
+
).resolves.toBeUndefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("resolves without throwing when navigator is undefined", async () => {
|
|
53
|
+
vi.stubGlobal("navigator", undefined);
|
|
54
|
+
|
|
55
|
+
await expect(
|
|
56
|
+
sendAuditEvent({
|
|
57
|
+
disclosureType: "AIContentLabel",
|
|
58
|
+
componentVersion: "0.1.0",
|
|
59
|
+
sessionId: "test-session-id",
|
|
60
|
+
}),
|
|
61
|
+
).resolves.toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("calls navigator.sendBeacon when it is available", async () => {
|
|
65
|
+
const sendBeaconMock = vi.fn().mockReturnValue(true);
|
|
66
|
+
vi.stubGlobal("navigator", {
|
|
67
|
+
language: "en",
|
|
68
|
+
sendBeacon: sendBeaconMock,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
await sendAuditEvent({
|
|
72
|
+
disclosureType: "DeepfakeLabel",
|
|
73
|
+
componentVersion: "0.1.0",
|
|
74
|
+
sessionId: "abc123",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
expect(sendBeaconMock).toHaveBeenCalledOnce();
|
|
78
|
+
// Verify first arg is the default endpoint
|
|
79
|
+
expect(sendBeaconMock.mock.calls[0]![0]).toBe(
|
|
80
|
+
"https://app.discloai.com/api/v1/audit/event",
|
|
81
|
+
);
|
|
82
|
+
// Verify second arg is a Blob
|
|
83
|
+
const blob = sendBeaconMock.mock.calls[0]![1] as Blob;
|
|
84
|
+
expect(blob).toBeInstanceOf(Blob);
|
|
85
|
+
|
|
86
|
+
// C-1 regression: body must NOT contain a writeToken or signature field
|
|
87
|
+
const bodyText = await blob.text();
|
|
88
|
+
const body = JSON.parse(bodyText) as Record<string, unknown>;
|
|
89
|
+
expect(body).not.toHaveProperty("signature");
|
|
90
|
+
expect(body).not.toHaveProperty("writeToken");
|
|
91
|
+
|
|
92
|
+
// H-2 regression: timestamp must be ISO 8601, not a numeric epoch string
|
|
93
|
+
expect(typeof body.timestamp).toBe("string");
|
|
94
|
+
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("falls back to fetch when sendBeacon is unavailable", async () => {
|
|
98
|
+
const fetchMock = vi.fn().mockResolvedValue({ ok: true } as Response);
|
|
99
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
100
|
+
vi.stubGlobal("navigator", { language: "en" });
|
|
101
|
+
|
|
102
|
+
await sendAuditEvent({
|
|
103
|
+
disclosureType: "BiometricNotice",
|
|
104
|
+
componentVersion: "0.1.0",
|
|
105
|
+
sessionId: "xyz789",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Allow microtasks to flush (the void fetch().catch() is fire-and-forget)
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
110
|
+
|
|
111
|
+
expect(fetchMock).toHaveBeenCalledOnce();
|
|
112
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
113
|
+
expect(url).toBe("https://app.discloai.com/api/v1/audit/event");
|
|
114
|
+
expect(init.method).toBe("POST");
|
|
115
|
+
expect(init.keepalive).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { init } from "../init.js";
|
|
3
|
+
|
|
4
|
+
// Mock fetch globally to prevent real network requests during tests.
|
|
5
|
+
// fetchRemoteConfig catches all errors and falls back silently, so a
|
|
6
|
+
// rejected fetch is safe here.
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false } as Response));
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
vi.unstubAllGlobals();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("DiscloAI.init()", () => {
|
|
16
|
+
it("emits console.warn and does not throw when siteId is missing", () => {
|
|
17
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
18
|
+
|
|
19
|
+
expect(() => init()).not.toThrow();
|
|
20
|
+
expect(warnSpy).toHaveBeenCalledOnce();
|
|
21
|
+
expect(warnSpy).toHaveBeenCalledWith("[DiscloAI] siteId is required");
|
|
22
|
+
|
|
23
|
+
warnSpy.mockRestore();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("does not throw when siteId is provided", () => {
|
|
27
|
+
expect(() => init({ siteId: "test" })).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("does not call console.warn when siteId is provided", () => {
|
|
31
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
32
|
+
|
|
33
|
+
init({ siteId: "test" });
|
|
34
|
+
|
|
35
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
36
|
+
warnSpy.mockRestore();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("accepts optional cspNonce without throwing", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
init({ siteId: "test", cspNonce: "abc123nonce" }),
|
|
42
|
+
).not.toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns undefined (non-blocking — no return value)", () => {
|
|
46
|
+
const result = init({ siteId: "test" });
|
|
47
|
+
expect(result).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
|
+
import { renderAIContentLabel } from "../components/AIContentLabel.js";
|
|
4
|
+
import { renderDeepfakeLabel } from "../components/DeepfakeLabel.js";
|
|
5
|
+
import { renderChatbotDisclosure } from "../components/ChatbotDisclosure.js";
|
|
6
|
+
import { renderBiometricNotice } from "../components/BiometricNotice.js";
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true } as Response));
|
|
10
|
+
vi.stubGlobal("navigator", {
|
|
11
|
+
language: "en",
|
|
12
|
+
sendBeacon: vi.fn().mockReturnValue(true),
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.unstubAllGlobals();
|
|
18
|
+
// Clean up DOM between tests without using innerHTML
|
|
19
|
+
while (document.body.firstChild) {
|
|
20
|
+
document.body.removeChild(document.body.firstChild);
|
|
21
|
+
}
|
|
22
|
+
while (document.head.firstChild) {
|
|
23
|
+
document.head.removeChild(document.head.firstChild);
|
|
24
|
+
}
|
|
25
|
+
sessionStorage.clear();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// AIContentLabel — WCAG 2.1 AA checks
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
describe("WCAG 2.1 AA — AIContentLabel", () => {
|
|
32
|
+
it('renders a <span> with role="img" and non-empty aria-label', () => {
|
|
33
|
+
const target = document.createElement("div");
|
|
34
|
+
target.setAttribute("data-discloai-content", "true");
|
|
35
|
+
document.body.appendChild(target);
|
|
36
|
+
|
|
37
|
+
renderAIContentLabel("test-site", {});
|
|
38
|
+
|
|
39
|
+
const span = document.querySelector('[data-discloai-label="ai-content"]');
|
|
40
|
+
expect(span).not.toBeNull();
|
|
41
|
+
expect(span?.getAttribute("role")).toBe("img");
|
|
42
|
+
const ariaLabel = span?.getAttribute("aria-label") ?? "";
|
|
43
|
+
expect(ariaLabel.length).toBeGreaterThan(0);
|
|
44
|
+
expect(ariaLabel).toBe("AI-generated content");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses artistic label text when variant is "artistic"', () => {
|
|
48
|
+
const target = document.createElement("div");
|
|
49
|
+
target.setAttribute("data-discloai-content", "true");
|
|
50
|
+
document.body.appendChild(target);
|
|
51
|
+
|
|
52
|
+
renderAIContentLabel("test-site", { variant: "artistic" });
|
|
53
|
+
|
|
54
|
+
const span = document.querySelector('[data-discloai-label="ai-content"]');
|
|
55
|
+
expect(span?.textContent).toBe("AI-assisted creative work");
|
|
56
|
+
expect(span?.getAttribute("aria-label")).toBe("AI-assisted creative work");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("does NOT render when both editorialControl exemption fields are present", () => {
|
|
60
|
+
const target = document.createElement("div");
|
|
61
|
+
target.setAttribute("data-discloai-content", "true");
|
|
62
|
+
document.body.appendChild(target);
|
|
63
|
+
|
|
64
|
+
renderAIContentLabel("test-site", {
|
|
65
|
+
exemptions: {
|
|
66
|
+
editorialControl: true,
|
|
67
|
+
editorialResponsibility: "Editorial Team",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const span = document.querySelector('[data-discloai-label="ai-content"]');
|
|
72
|
+
expect(span).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renders normally when only editorialControl is set (without editorialResponsibility)", () => {
|
|
76
|
+
const target = document.createElement("div");
|
|
77
|
+
target.setAttribute("data-discloai-content", "true");
|
|
78
|
+
document.body.appendChild(target);
|
|
79
|
+
|
|
80
|
+
renderAIContentLabel("test-site", {
|
|
81
|
+
exemptions: { editorialControl: true },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const span = document.querySelector('[data-discloai-label="ai-content"]');
|
|
85
|
+
expect(span).not.toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("does not double-label already labeled elements", () => {
|
|
89
|
+
const target = document.createElement("div");
|
|
90
|
+
target.setAttribute("data-discloai-content", "true");
|
|
91
|
+
document.body.appendChild(target);
|
|
92
|
+
|
|
93
|
+
renderAIContentLabel("test-site", {});
|
|
94
|
+
renderAIContentLabel("test-site", {});
|
|
95
|
+
|
|
96
|
+
const spans = document.querySelectorAll(
|
|
97
|
+
'[data-discloai-label="ai-content"]',
|
|
98
|
+
);
|
|
99
|
+
expect(spans.length).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// DeepfakeLabel — WCAG 2.1 AA checks
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
describe("WCAG 2.1 AA — DeepfakeLabel", () => {
|
|
107
|
+
it('renders a label div with role="img" and non-empty aria-label on an <img>', () => {
|
|
108
|
+
const img = document.createElement("img");
|
|
109
|
+
img.setAttribute("data-discloai-media", "true");
|
|
110
|
+
document.body.appendChild(img);
|
|
111
|
+
|
|
112
|
+
renderDeepfakeLabel("test-site", {
|
|
113
|
+
selector: '[data-discloai-media="true"]',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const label = document.querySelector('[role="img"]');
|
|
117
|
+
expect(label).not.toBeNull();
|
|
118
|
+
const ariaLabel = label?.getAttribute("aria-label") ?? "";
|
|
119
|
+
expect(ariaLabel.length).toBeGreaterThan(0);
|
|
120
|
+
expect(ariaLabel).toBe("AI-generated image");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("uses artistic label text when artisticContext exemption is set", () => {
|
|
124
|
+
const img = document.createElement("img");
|
|
125
|
+
img.setAttribute("data-discloai-art", "true");
|
|
126
|
+
document.body.appendChild(img);
|
|
127
|
+
|
|
128
|
+
renderDeepfakeLabel("test-site", {
|
|
129
|
+
selector: '[data-discloai-art="true"]',
|
|
130
|
+
exemptions: { artisticContext: true },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const label = document.querySelector('[role="img"]');
|
|
134
|
+
expect(label?.textContent).toBe("AI-generated artistic content");
|
|
135
|
+
expect(label?.getAttribute("aria-label")).toBe(
|
|
136
|
+
"AI-generated artistic content",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("wraps element in a div and appends the label", () => {
|
|
141
|
+
const img = document.createElement("img");
|
|
142
|
+
img.setAttribute("data-discloai-wrap", "true");
|
|
143
|
+
document.body.appendChild(img);
|
|
144
|
+
|
|
145
|
+
renderDeepfakeLabel("test-site", {
|
|
146
|
+
selector: '[data-discloai-wrap="true"]',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// img should now be inside a wrapper div
|
|
150
|
+
const wrapper = img.parentElement;
|
|
151
|
+
expect(wrapper?.tagName).toBe("DIV");
|
|
152
|
+
expect(wrapper?.style.position).toBe("relative");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("does not double-label already labeled elements", () => {
|
|
156
|
+
const img = document.createElement("img");
|
|
157
|
+
img.setAttribute("data-discloai-media2", "true");
|
|
158
|
+
document.body.appendChild(img);
|
|
159
|
+
|
|
160
|
+
renderDeepfakeLabel("test-site", {
|
|
161
|
+
selector: '[data-discloai-media2="true"]',
|
|
162
|
+
});
|
|
163
|
+
renderDeepfakeLabel("test-site", {
|
|
164
|
+
selector: '[data-discloai-media2="true"]',
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const labels = document.querySelectorAll('[role="img"]');
|
|
168
|
+
expect(labels.length).toBe(1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// ChatbotDisclosure — WCAG 2.1 AA checks
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
describe("WCAG 2.1 AA — ChatbotDisclosure", () => {
|
|
176
|
+
it('renders overlay with role="alert" and aria-live="polite"', () => {
|
|
177
|
+
renderChatbotDisclosure("test-site", {});
|
|
178
|
+
|
|
179
|
+
const overlay = document.querySelector('[role="alert"]');
|
|
180
|
+
expect(overlay).not.toBeNull();
|
|
181
|
+
expect(overlay?.getAttribute("aria-live")).toBe("polite");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("renders prominent variant text", () => {
|
|
185
|
+
renderChatbotDisclosure("test-site", { variant: "prominent" });
|
|
186
|
+
|
|
187
|
+
const overlay = document.querySelector('[role="alert"]');
|
|
188
|
+
expect(overlay?.textContent).toContain("artificial intelligence");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("does NOT render when obviousContext exemption is active", () => {
|
|
192
|
+
const infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
|
|
193
|
+
renderChatbotDisclosure("test-site", {
|
|
194
|
+
exemptions: { obviousContext: true },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const overlay = document.querySelector('[role="alert"]');
|
|
198
|
+
expect(overlay).toBeNull();
|
|
199
|
+
expect(infoSpy).toHaveBeenCalledWith(
|
|
200
|
+
"[DiscloAI] ChatbotDisclosure: obviousContext exemption applied",
|
|
201
|
+
);
|
|
202
|
+
infoSpy.mockRestore();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("does NOT render when sessionStorage key is already set", () => {
|
|
206
|
+
sessionStorage.setItem("discloai:test-site:ChatbotDisclosure:seen", "true");
|
|
207
|
+
|
|
208
|
+
renderChatbotDisclosure("test-site", {});
|
|
209
|
+
|
|
210
|
+
const overlay = document.querySelector('[role="alert"]');
|
|
211
|
+
expect(overlay).toBeNull();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('sets sessionStorage key to "true" when close button is clicked', () => {
|
|
215
|
+
renderChatbotDisclosure("test-site-close", {});
|
|
216
|
+
|
|
217
|
+
const closeBtn = document.querySelector(
|
|
218
|
+
'[role="alert"] button',
|
|
219
|
+
) as HTMLButtonElement | null;
|
|
220
|
+
expect(closeBtn).not.toBeNull();
|
|
221
|
+
closeBtn?.click();
|
|
222
|
+
|
|
223
|
+
expect(
|
|
224
|
+
sessionStorage.getItem("discloai:test-site-close:ChatbotDisclosure:seen"),
|
|
225
|
+
).toBe("true");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// BiometricNotice — WCAG 2.1 AA checks
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
describe("WCAG 2.1 AA — BiometricNotice", () => {
|
|
233
|
+
it('renders with role="alert" and aria-live="assertive"', () => {
|
|
234
|
+
renderBiometricNotice("test-site", {});
|
|
235
|
+
|
|
236
|
+
const banner = document.querySelector('[role="alert"]');
|
|
237
|
+
expect(banner).not.toBeNull();
|
|
238
|
+
expect(banner?.getAttribute("aria-live")).toBe("assertive");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("renders the biometric notice text", () => {
|
|
242
|
+
renderBiometricNotice("test-site", {});
|
|
243
|
+
|
|
244
|
+
const banner = document.querySelector('[role="alert"]');
|
|
245
|
+
expect(banner?.textContent).toContain("biometric");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("renders a close button that removes the banner", () => {
|
|
249
|
+
renderBiometricNotice("test-site-bio", {});
|
|
250
|
+
|
|
251
|
+
const closeBtn = document.querySelector(
|
|
252
|
+
'[role="alert"] button',
|
|
253
|
+
) as HTMLButtonElement | null;
|
|
254
|
+
expect(closeBtn).not.toBeNull();
|
|
255
|
+
closeBtn?.click();
|
|
256
|
+
|
|
257
|
+
const banner = document.querySelector('[role="alert"]');
|
|
258
|
+
expect(banner).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
});
|