@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +218 -0
  3. package/dist/discloai.min.js +1 -0
  4. package/dist/index.d.mts +152 -0
  5. package/dist/index.d.ts +152 -0
  6. package/dist/index.js +795 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/index.mjs +764 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/package.json +58 -0
  11. package/src/__tests__/audit.test.ts +117 -0
  12. package/src/__tests__/init.test.ts +49 -0
  13. package/src/__tests__/wcag.test.ts +260 -0
  14. package/src/audit.ts +155 -0
  15. package/src/components/AIContentLabel.ts +108 -0
  16. package/src/components/BiometricNotice.ts +82 -0
  17. package/src/components/ChatbotDisclosure.ts +188 -0
  18. package/src/components/DeepfakeLabel.ts +123 -0
  19. package/src/config.ts +191 -0
  20. package/src/i18n/bg.json +9 -0
  21. package/src/i18n/cs.json +9 -0
  22. package/src/i18n/da.json +9 -0
  23. package/src/i18n/de.json +9 -0
  24. package/src/i18n/el.json +9 -0
  25. package/src/i18n/en.json +9 -0
  26. package/src/i18n/es.json +9 -0
  27. package/src/i18n/et.json +9 -0
  28. package/src/i18n/fi.json +9 -0
  29. package/src/i18n/fr.json +9 -0
  30. package/src/i18n/ga.json +9 -0
  31. package/src/i18n/hr.json +9 -0
  32. package/src/i18n/hu.json +9 -0
  33. package/src/i18n/index.ts +145 -0
  34. package/src/i18n/it.json +9 -0
  35. package/src/i18n/lt.json +9 -0
  36. package/src/i18n/lv.json +9 -0
  37. package/src/i18n/mt.json +9 -0
  38. package/src/i18n/nl.json +9 -0
  39. package/src/i18n/pl.json +9 -0
  40. package/src/i18n/pt.json +9 -0
  41. package/src/i18n/ro.json +9 -0
  42. package/src/i18n/sk.json +9 -0
  43. package/src/i18n/sl.json +9 -0
  44. package/src/i18n/sv.json +9 -0
  45. package/src/index.ts +19 -0
  46. package/src/init.ts +56 -0
  47. package/src/vendors.ts +29 -0
  48. package/src/version.ts +1 -0
  49. package/src/wcag.ts +46 -0
package/src/audit.ts ADDED
@@ -0,0 +1,155 @@
1
+ // ---------------------------------------------------------------------------
2
+ // DiscloAI Audit Event Client
3
+ // ---------------------------------------------------------------------------
4
+ // Security invariants:
5
+ // - NEVER use innerHTML / outerHTML / insertAdjacentHTML
6
+ // - NEVER log PII: pageUrl and sessionId are SHA-256 hashed before sending
7
+ // - NEVER throw: all errors are swallowed — audit must never break the page
8
+ // - Primary transport: navigator.sendBeacon (non-blocking, survives unload)
9
+ // - Fallback: fetch({ keepalive: true }) when sendBeacon unavailable
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface AuditEventParams {
13
+ /** One of the four EU AI Act Article 50 disclosure types */
14
+ disclosureType: string;
15
+ /** Semver string of the component that fired the event */
16
+ componentVersion: string;
17
+ /** Caller-supplied session identifier — will be hashed before sending */
18
+ sessionId: string;
19
+ /** Whether the disclosure was rendered to the user (default: true) */
20
+ rendered?: boolean;
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Window config shape (set by the dashboard embed snippet)
25
+ // ---------------------------------------------------------------------------
26
+
27
+ interface DiscloAIWindowConfig {
28
+ siteId?: string;
29
+ auditEndpoint?: string;
30
+ /** Override the config fetch URL — only http://localhost or https:// are accepted. */
31
+ configEndpoint?: string;
32
+ // NOTE: writeToken is intentionally omitted — HMAC signing is server-side only.
33
+ // Browser-initiated audit events are authenticated by Origin header validation
34
+ // on the server. The write token MUST NEVER be present in browser code.
35
+ }
36
+
37
+ declare global {
38
+ interface Window {
39
+ __DISCLOAI_CONFIG__?: DiscloAIWindowConfig;
40
+ }
41
+ }
42
+
43
+ const DEFAULT_AUDIT_ENDPOINT = "https://app.discloai.com/api/v1/audit/event";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Crypto helpers
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * SHA-256 hash of a string, returned as lowercase hex.
51
+ * Uses the Web Crypto API (available in all modern browsers and Node ≥ 18).
52
+ */
53
+ export async function sha256Hex(input: string): Promise<string> {
54
+ const encoded = new TextEncoder().encode(input);
55
+ const buffer = await crypto.subtle.digest("SHA-256", encoded);
56
+ return hexFromBuffer(buffer);
57
+ }
58
+
59
+ /**
60
+ * HMAC-SHA-256 of `message` signed with `secret`, returned as lowercase hex.
61
+ */
62
+ export async function hmacHex(
63
+ message: string,
64
+ secret: string,
65
+ ): Promise<string> {
66
+ const encoder = new TextEncoder();
67
+ const key = await crypto.subtle.importKey(
68
+ "raw",
69
+ encoder.encode(secret),
70
+ { name: "HMAC", hash: "SHA-256" },
71
+ false,
72
+ ["sign"],
73
+ );
74
+ const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
75
+ return hexFromBuffer(buffer);
76
+ }
77
+
78
+ function hexFromBuffer(buffer: ArrayBuffer): string {
79
+ return Array.from(new Uint8Array(buffer))
80
+ .map((b) => b.toString(16).padStart(2, "0"))
81
+ .join("");
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Main export
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Fire-and-forget audit event.
90
+ *
91
+ * Uses navigator.sendBeacon (primary) or fetch({ keepalive: true }) (fallback).
92
+ * Never throws — any error is caught and silently swallowed.
93
+ */
94
+ export async function sendAuditEvent(params: AuditEventParams): Promise<void> {
95
+ try {
96
+ const cfg =
97
+ typeof window !== "undefined" ? (window.__DISCLOAI_CONFIG__ ?? {}) : {};
98
+
99
+ const siteId = cfg.siteId ?? "";
100
+ // M-2: Reject non-HTTPS endpoints to prevent MITM against hashed PII.
101
+ // Exception: http://localhost and http://127.0.0.1 are allowed for local dev.
102
+ const rawEndpoint = cfg.auditEndpoint ?? DEFAULT_AUDIT_ENDPOINT;
103
+ const isLocalDev =
104
+ rawEndpoint.startsWith("http://localhost") ||
105
+ rawEndpoint.startsWith("http://127.0.0.1");
106
+ const endpoint =
107
+ rawEndpoint.startsWith("https://") || isLocalDev
108
+ ? rawEndpoint
109
+ : DEFAULT_AUDIT_ENDPOINT;
110
+ // H-2: ISO 8601 timestamp — numeric epoch strings fail new Date() in Node.js
111
+ const timestamp = new Date().toISOString();
112
+
113
+ // Hash PII before it leaves the browser
114
+ const pageHref =
115
+ typeof globalThis.location !== "undefined"
116
+ ? globalThis.location.href
117
+ : "";
118
+
119
+ const [pageUrlHash, sessionHash] = await Promise.all([
120
+ sha256Hex(pageHref),
121
+ sha256Hex(params.sessionId),
122
+ ]);
123
+
124
+ const body = JSON.stringify({
125
+ siteId,
126
+ timestamp,
127
+ // C-1: No signature — authentication is via Origin header on the server
128
+ pageUrlHash,
129
+ sessionHash,
130
+ disclosureType: params.disclosureType,
131
+ componentVersion: params.componentVersion,
132
+ rendered: params.rendered ?? true,
133
+ });
134
+
135
+ const blob = new Blob([body], { type: "application/json" });
136
+
137
+ if (
138
+ typeof navigator !== "undefined" &&
139
+ typeof navigator.sendBeacon === "function"
140
+ ) {
141
+ navigator.sendBeacon(endpoint, blob);
142
+ } else {
143
+ // keepalive fetch survives page unload just like sendBeacon
144
+ void fetch(endpoint, {
145
+ method: "POST",
146
+ body: blob,
147
+ keepalive: true,
148
+ }).catch(() => {
149
+ // swallow network errors silently
150
+ });
151
+ }
152
+ } catch {
153
+ // NEVER propagate — audit failures must never break the host page
154
+ }
155
+ }
@@ -0,0 +1,108 @@
1
+ // EU AI Act Article 50 §4¶2 — AI-generated content label
2
+ //
3
+ // Invariants:
4
+ // - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent
5
+ // - Custom CSS must be sanitized before injection via sanitizeCSS()
6
+ // - All errors are swallowed — never throw
7
+
8
+ import { sendAuditEvent } from "../audit.js";
9
+ import { sanitizeCSS } from "../config.js";
10
+ import { t } from "../i18n/index.js";
11
+ import { VERSION } from "../version.js";
12
+
13
+ export interface AIContentLabelConfig {
14
+ enabled?: boolean;
15
+ variant?: "default" | "artistic";
16
+ /** CSS selector for elements to label */
17
+ selector?: string;
18
+ /** Custom CSS string — sanitized before injection */
19
+ customCSS?: string;
20
+ exemptions?: {
21
+ /** Both editorialControl AND editorialResponsibility must be present to suppress rendering */
22
+ editorialControl?: boolean;
23
+ editorialResponsibility?: string;
24
+ };
25
+ }
26
+
27
+ const DEFAULT_LABEL_CSS =
28
+ '[data-discloai-label="ai-content"] {' +
29
+ " position: relative; display: inline-block;" +
30
+ " background: #e0e7ff; color: #3730a3;" +
31
+ " font-size: 0.75rem; padding: 2px 6px;" +
32
+ " border-radius: 3px; font-family: sans-serif;" +
33
+ " white-space: nowrap; }";
34
+
35
+ /** Render the AIContentLabel widget (EU AI Act Article 50 §4¶2). */
36
+ export function renderAIContentLabel(
37
+ siteId: string,
38
+ config: AIContentLabelConfig,
39
+ cspNonce?: string,
40
+ ): void {
41
+ try {
42
+ if (typeof document === "undefined") return;
43
+
44
+ // editorialControl exemption: BOTH fields must be present
45
+ if (
46
+ config.exemptions?.editorialControl === true &&
47
+ typeof config.exemptions?.editorialResponsibility === "string"
48
+ ) {
49
+ void sendAuditEvent({
50
+ disclosureType: "AIContentLabel",
51
+ componentVersion: VERSION,
52
+ sessionId: siteId,
53
+ rendered: false,
54
+ });
55
+ return;
56
+ }
57
+
58
+ // Find target elements
59
+ const selector = config.selector ?? '[data-discloai-content="true"]';
60
+ const elements = Array.from(document.querySelectorAll(selector));
61
+
62
+ const labelText =
63
+ config.variant === "artistic"
64
+ ? t("content.label.artistic")
65
+ : t("content.label.default");
66
+
67
+ for (const element of elements) {
68
+ if (element.hasAttribute("data-discloai-labeled")) continue;
69
+
70
+ const span = document.createElement("span");
71
+ span.textContent = labelText;
72
+ span.setAttribute("data-discloai-label", "ai-content");
73
+ span.setAttribute("role", "img");
74
+ span.setAttribute("aria-label", labelText);
75
+
76
+ if (element.parentNode) {
77
+ element.parentNode.insertBefore(span, element);
78
+ }
79
+ element.setAttribute("data-discloai-labeled", "true");
80
+ }
81
+
82
+ // Inject default label styles
83
+ const styleEl = document.createElement("style");
84
+ styleEl.textContent = DEFAULT_LABEL_CSS;
85
+ if (cspNonce) styleEl.setAttribute("nonce", cspNonce);
86
+ document.head.appendChild(styleEl);
87
+
88
+ // Inject custom CSS if provided
89
+ if (config.customCSS) {
90
+ const sanitized = sanitizeCSS(config.customCSS);
91
+ if (sanitized) {
92
+ const customStyleEl = document.createElement("style");
93
+ customStyleEl.textContent = sanitized;
94
+ if (cspNonce) customStyleEl.setAttribute("nonce", cspNonce);
95
+ document.head.appendChild(customStyleEl);
96
+ }
97
+ }
98
+
99
+ void sendAuditEvent({
100
+ disclosureType: "AIContentLabel",
101
+ componentVersion: VERSION,
102
+ sessionId: siteId,
103
+ rendered: true,
104
+ });
105
+ } catch (_e) {
106
+ // Never throw — swallow all errors silently
107
+ }
108
+ }
@@ -0,0 +1,82 @@
1
+ // EU AI Act Article 50 §3 — Biometric / emotion recognition system notice
2
+ //
3
+ // Invariants:
4
+ // - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent
5
+ // - z-index: 2147483647 on the notice banner
6
+ // - Custom CSS must be sanitized before injection via sanitizeCSS()
7
+ // - All errors are swallowed — never throw
8
+
9
+ import { sendAuditEvent } from "../audit.js";
10
+ import { sanitizeCSS } from "../config.js";
11
+ import { t } from "../i18n/index.js";
12
+ import { VERSION } from "../version.js";
13
+
14
+ export interface BiometricNoticeConfig {
15
+ enabled?: boolean;
16
+ /** CSS selector for the element that triggers the biometric system */
17
+ selector?: string;
18
+ /** Custom CSS string — sanitized before injection */
19
+ customCSS?: string;
20
+ }
21
+
22
+ /** Render the BiometricNotice widget (EU AI Act Article 50 §3). */
23
+ export function renderBiometricNotice(
24
+ siteId: string,
25
+ config: BiometricNoticeConfig,
26
+ cspNonce?: string,
27
+ ): void {
28
+ try {
29
+ if (typeof document === "undefined") return;
30
+
31
+ const banner = document.createElement("div");
32
+ banner.setAttribute("role", "alert");
33
+ banner.setAttribute("aria-live", "assertive");
34
+ banner.style.cssText =
35
+ "position: fixed;" +
36
+ " top: 0; left: 0; right: 0;" +
37
+ " z-index: 2147483647;" +
38
+ " background: #fef3c7;" +
39
+ " border-bottom: 2px solid #f59e0b;" +
40
+ " padding: 12px 24px;" +
41
+ " font-family: sans-serif;" +
42
+ " font-size: 0.875rem;" +
43
+ " text-align: center;";
44
+
45
+ const noticeText = t("biometric.notice.default");
46
+
47
+ const textSpan = document.createElement("span");
48
+ textSpan.textContent = noticeText;
49
+ banner.appendChild(textSpan);
50
+
51
+ const closeBtn = document.createElement("button");
52
+ closeBtn.textContent = "\u2715"; // ✕
53
+ closeBtn.addEventListener("click", () => {
54
+ if (banner.parentNode) {
55
+ banner.parentNode.removeChild(banner);
56
+ }
57
+ });
58
+ banner.appendChild(closeBtn);
59
+
60
+ // Inject custom CSS if provided
61
+ if (config.customCSS) {
62
+ const sanitized = sanitizeCSS(config.customCSS);
63
+ if (sanitized) {
64
+ const styleEl = document.createElement("style");
65
+ styleEl.textContent = sanitized;
66
+ if (cspNonce) styleEl.setAttribute("nonce", cspNonce);
67
+ document.head.appendChild(styleEl);
68
+ }
69
+ }
70
+
71
+ document.body.appendChild(banner);
72
+
73
+ void sendAuditEvent({
74
+ disclosureType: "BiometricNotice",
75
+ componentVersion: VERSION,
76
+ sessionId: siteId,
77
+ rendered: true,
78
+ });
79
+ } catch (_e) {
80
+ // Never throw — swallow all errors silently
81
+ }
82
+ }
@@ -0,0 +1,188 @@
1
+ // EU AI Act Article 50 §1 — Chatbot AI disclosure
2
+ //
3
+ // Invariants:
4
+ // - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent
5
+ // - z-index: 2147483647 on the disclosure overlay
6
+ // - sessionStorage key: discloai:{siteId}:ChatbotDisclosure:seen
7
+ // - Supports triggerEvent: 'on-load' | 'on-open'
8
+ // - On obviousContext exemption: log console.info and send audit event with rendered: false
9
+ // - All errors are swallowed — never throw
10
+
11
+ import { sendAuditEvent } from "../audit.js";
12
+ import { sanitizeCSS } from "../config.js";
13
+ import { t } from "../i18n/index.js";
14
+ import { VERSION } from "../version.js";
15
+
16
+ export interface ChatbotDisclosureConfig {
17
+ enabled?: boolean;
18
+ /** CSS selector for the target chatbot widget element */
19
+ selector?: string;
20
+ /** One of the 6 built-in vendor presets */
21
+ vendor?: "intercom" | "crisp" | "tidio" | "zendesk" | "drift" | "livechat";
22
+ /** When to show the disclosure: on page load or when the chat widget opens */
23
+ triggerEvent?: "on-load" | "on-open";
24
+ /** Style variant */
25
+ variant?: "minimal" | "prominent" | "custom";
26
+ /** Custom CSS string — sanitized before injection; url(), @import, expression(), javascript: are blocked */
27
+ customCSS?: string;
28
+ exemptions?: {
29
+ /** Suppress disclosure when AI use is already obvious from context */
30
+ obviousContext?: boolean;
31
+ /** Suppress for editorial/creative AI use */
32
+ editorialControl?: boolean;
33
+ };
34
+ }
35
+
36
+ const VENDOR_SELECTOR_MAP: Record<string, string> = {
37
+ intercom: "#intercom-container",
38
+ crisp: "#crisp-chatbox",
39
+ tidio: "#tidio-chat",
40
+ zendesk: "#launcher",
41
+ drift: "#drift-frame-controller",
42
+ livechat: "#chat-widget-container",
43
+ };
44
+
45
+ /** Render the ChatbotDisclosure widget (EU AI Act Article 50 §1). */
46
+ export function renderChatbotDisclosure(
47
+ siteId: string,
48
+ config: ChatbotDisclosureConfig,
49
+ cspNonce?: string,
50
+ ): void {
51
+ try {
52
+ if (typeof document === "undefined") return;
53
+
54
+ // sessionStorage check — skip if user already acknowledged this session
55
+ const storageKey = `discloai:${siteId}:ChatbotDisclosure:seen`;
56
+ try {
57
+ if (
58
+ typeof sessionStorage !== "undefined" &&
59
+ sessionStorage.getItem(storageKey) === "true"
60
+ ) {
61
+ return;
62
+ }
63
+ } catch (_e) {
64
+ // sessionStorage unavailable (private mode, cross-origin) — continue
65
+ }
66
+
67
+ // obviousContext exemption: suppress rendering, send audit event with rendered: false
68
+ if (config.exemptions?.obviousContext === true) {
69
+ console.info(
70
+ "[DiscloAI] ChatbotDisclosure: obviousContext exemption applied",
71
+ );
72
+ void sendAuditEvent({
73
+ disclosureType: "ChatbotDisclosure",
74
+ componentVersion: VERSION,
75
+ sessionId: siteId,
76
+ rendered: false,
77
+ });
78
+ return;
79
+ }
80
+
81
+ const showDisclosure = (): void => {
82
+ const overlay = document.createElement("div");
83
+ overlay.setAttribute("role", "alert");
84
+ overlay.setAttribute("aria-live", "polite");
85
+ overlay.style.cssText =
86
+ "position: fixed;" +
87
+ " bottom: 80px; right: 24px;" +
88
+ " z-index: 2147483647;" +
89
+ " background: #fff;" +
90
+ " border: 1px solid #c7d2fe;" +
91
+ " border-radius: 8px;" +
92
+ " padding: 12px 16px;" +
93
+ " max-width: 320px;" +
94
+ " box-shadow: 0 4px 16px rgba(0,0,0,0.12);" +
95
+ " font-family: sans-serif;";
96
+
97
+ const labelText =
98
+ config.variant === "prominent"
99
+ ? t("chatbot.disclosure.prominent")
100
+ : t("chatbot.disclosure.default");
101
+
102
+ const p = document.createElement("p");
103
+ p.textContent = labelText;
104
+ overlay.appendChild(p);
105
+
106
+ const closeOverlay = (): void => {
107
+ if (overlay.parentNode) {
108
+ overlay.parentNode.removeChild(overlay);
109
+ }
110
+ try {
111
+ if (typeof sessionStorage !== "undefined") {
112
+ sessionStorage.setItem(storageKey, "true");
113
+ }
114
+ } catch (_e) {
115
+ // ignore — sessionStorage might be unavailable
116
+ }
117
+ };
118
+
119
+ const closeBtn = document.createElement("button");
120
+ closeBtn.textContent = "\u2715"; // ✕
121
+ closeBtn.addEventListener("click", closeOverlay);
122
+ overlay.appendChild(closeBtn);
123
+
124
+ // Inject custom CSS
125
+ if (config.customCSS) {
126
+ const sanitized = sanitizeCSS(config.customCSS);
127
+ if (sanitized) {
128
+ const styleEl = document.createElement("style");
129
+ styleEl.textContent = sanitized;
130
+ if (cspNonce) styleEl.setAttribute("nonce", cspNonce);
131
+ document.head.appendChild(styleEl);
132
+ }
133
+ }
134
+
135
+ document.body.appendChild(overlay);
136
+
137
+ // Auto-dismiss after 8 seconds
138
+ setTimeout(closeOverlay, 8000);
139
+
140
+ void sendAuditEvent({
141
+ disclosureType: "ChatbotDisclosure",
142
+ componentVersion: VERSION,
143
+ sessionId: siteId,
144
+ rendered: true,
145
+ });
146
+ };
147
+
148
+ const triggerEvent = config.triggerEvent ?? "on-load";
149
+
150
+ if (triggerEvent === "on-load") {
151
+ showDisclosure();
152
+ return;
153
+ }
154
+
155
+ // on-open: watch for the target element to appear and become visible
156
+ let vendorSelector = "";
157
+ if (config.vendor) {
158
+ vendorSelector = VENDOR_SELECTOR_MAP[config.vendor] ?? "";
159
+ } else if (config.selector) {
160
+ vendorSelector = config.selector;
161
+ }
162
+
163
+ if (!vendorSelector) {
164
+ // No selector — fall back to global banner mode (render immediately)
165
+ showDisclosure();
166
+ return;
167
+ }
168
+
169
+ const observer = new MutationObserver(() => {
170
+ const target = document.querySelector(vendorSelector);
171
+ if (target) {
172
+ const style = window.getComputedStyle(target);
173
+ if (style.display !== "none" && style.visibility !== "hidden") {
174
+ observer.disconnect();
175
+ showDisclosure();
176
+ }
177
+ }
178
+ });
179
+
180
+ observer.observe(document.body, {
181
+ childList: true,
182
+ subtree: true,
183
+ attributes: true,
184
+ });
185
+ } catch (_e) {
186
+ // Never throw — swallow all errors silently
187
+ }
188
+ }
@@ -0,0 +1,123 @@
1
+ // EU AI Act Article 50 §4¶1 — Deepfake / synthetic media label
2
+ //
3
+ // Invariants:
4
+ // - NEVER use innerHTML / outerHTML / insertAdjacentHTML — use element.textContent
5
+ // - z-index: 2147483647 on label overlay
6
+ // - Custom CSS must be sanitized before injection via sanitizeCSS()
7
+ // - All errors are swallowed — never throw
8
+
9
+ import { sendAuditEvent } from "../audit.js";
10
+ import { sanitizeCSS } from "../config.js";
11
+ import { t } from "../i18n/index.js";
12
+ import { VERSION } from "../version.js";
13
+
14
+ export interface DeepfakeLabelConfig {
15
+ enabled?: boolean;
16
+ variant?: "default" | "artistic";
17
+ /** CSS selector for synthetic media elements to label */
18
+ selector?: string;
19
+ /** Custom CSS string — sanitized before injection */
20
+ customCSS?: string;
21
+ exemptions?: {
22
+ /** Uses artistic label text but still renders — does NOT suppress disclosure */
23
+ artisticContext?: boolean;
24
+ };
25
+ }
26
+
27
+ function wrapAndLabel(
28
+ element: Element,
29
+ labelText: string,
30
+ topOffset: string,
31
+ leftOffset: string,
32
+ ): void {
33
+ if (!element.parentNode) return;
34
+
35
+ // Create wrapper div
36
+ const wrapper = document.createElement("div");
37
+ wrapper.style.cssText = "position: relative; display: inline-block;";
38
+ element.parentNode.insertBefore(wrapper, element);
39
+ wrapper.appendChild(element);
40
+
41
+ // Create label overlay
42
+ const labelDiv = document.createElement("div");
43
+ labelDiv.textContent = labelText;
44
+ labelDiv.setAttribute("role", "img");
45
+ labelDiv.setAttribute("aria-label", labelText);
46
+ labelDiv.style.cssText =
47
+ "position: absolute;" +
48
+ " top: " +
49
+ topOffset +
50
+ ";" +
51
+ " left: " +
52
+ leftOffset +
53
+ ";" +
54
+ " z-index: 2147483647;" +
55
+ " background: rgba(0,0,0,0.7);" +
56
+ " color: #fff;" +
57
+ " font-size: 0.75rem;" +
58
+ " padding: 2px 8px;" +
59
+ " border-radius: 3px;" +
60
+ " pointer-events: none;";
61
+ wrapper.appendChild(labelDiv);
62
+ }
63
+
64
+ /** Render the DeepfakeLabel widget (EU AI Act Article 50 §4¶1). */
65
+ export function renderDeepfakeLabel(
66
+ siteId: string,
67
+ config: DeepfakeLabelConfig,
68
+ cspNonce?: string,
69
+ ): void {
70
+ try {
71
+ if (typeof document === "undefined") return;
72
+
73
+ // artisticContext exemption uses the artistic label but still renders
74
+ const useArtistic =
75
+ config.exemptions?.artisticContext === true ||
76
+ config.variant === "artistic";
77
+ const labelText = useArtistic
78
+ ? t("deepfake.label.artistic")
79
+ : t("deepfake.label.default");
80
+
81
+ // Find target elements
82
+ let elements: Element[];
83
+ if (config.selector) {
84
+ elements = Array.from(document.querySelectorAll(config.selector)).filter(
85
+ (el) => el.tagName === "VIDEO" || el.tagName === "IMG",
86
+ );
87
+ } else {
88
+ elements = [
89
+ ...Array.from(document.querySelectorAll("video")),
90
+ ...Array.from(document.querySelectorAll("img")),
91
+ ];
92
+ }
93
+
94
+ for (const element of elements) {
95
+ if (element.hasAttribute("data-discloai-labeled")) continue;
96
+
97
+ const isVideo = element.tagName === "VIDEO";
98
+ const offset = isVideo ? "8px" : "4px";
99
+ wrapAndLabel(element, labelText, offset, offset);
100
+ element.setAttribute("data-discloai-labeled", "true");
101
+ }
102
+
103
+ // Inject custom CSS if provided
104
+ if (config.customCSS) {
105
+ const sanitized = sanitizeCSS(config.customCSS);
106
+ if (sanitized) {
107
+ const customStyleEl = document.createElement("style");
108
+ customStyleEl.textContent = sanitized;
109
+ if (cspNonce) customStyleEl.setAttribute("nonce", cspNonce);
110
+ document.head.appendChild(customStyleEl);
111
+ }
112
+ }
113
+
114
+ void sendAuditEvent({
115
+ disclosureType: "DeepfakeLabel",
116
+ componentVersion: VERSION,
117
+ sessionId: siteId,
118
+ rendered: true,
119
+ });
120
+ } catch (_e) {
121
+ // Never throw — swallow all errors silently
122
+ }
123
+ }