Files
consentos/apps/banner/src/reporter.ts
James Cottrill fbf26453f2 feat: initial public release
ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
2026-04-14 09:18:18 +00:00

311 lines
8.1 KiB
TypeScript

/**
* Client-side cookie reporter.
*
* Runs on a configurable sampling basis (e.g. 10% of page loads).
* Enumerates document.cookie, localStorage, and sessionStorage keys.
* Batches reports and POSTs to the scanner/report API endpoint.
*
* @module reporter
*/
/** A single discovered storage item from the client. */
export interface DiscoveredCookie {
name: string;
domain: string;
storage_type: 'cookie' | 'local_storage' | 'session_storage';
/** Raw value length (not the value itself, for privacy). */
value_length: number;
/** HTTP cookie attributes if available. */
path?: string;
is_secure?: boolean;
same_site?: string;
/** Script URL that likely set this cookie (from PerformanceObserver). */
script_source?: string;
}
/** Report payload sent to the API. */
export interface CookieReport {
site_id: string;
page_url: string;
cookies: DiscoveredCookie[];
/** ISO 8601 timestamp of when the report was collected. */
collected_at: string;
/** User agent string for classification context. */
user_agent: string;
}
/** Reporter configuration. */
export interface ReporterConfig {
/** Site ID for this report. */
siteId: string;
/** Base URL for the API (e.g. https://api.example.com/api/v1). */
apiBase: string;
/** Sampling rate: 0.0 to 1.0 (e.g. 0.1 = 10% of page loads). */
sampleRate: number;
/** Delay in ms before collecting cookies (allows page to load). */
collectDelay: number;
/** Whether to include localStorage keys. */
includeLocalStorage: boolean;
/** Whether to include sessionStorage keys. */
includeSessionStorage: boolean;
}
const DEFAULT_CONFIG: Omit<ReporterConfig, 'siteId' | 'apiBase'> = {
sampleRate: 0.1,
collectDelay: 3000,
includeLocalStorage: true,
includeSessionStorage: true,
};
/** Track loaded scripts for attribution via PerformanceObserver. */
let observedScripts: string[] = [];
let observer: PerformanceObserver | null = null;
/**
* Install a PerformanceObserver to track script loads for attribution.
* Must be called early (ideally in the loader) to capture all scripts.
*/
export function installScriptObserver(): void {
if (typeof PerformanceObserver === 'undefined') return;
try {
observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'resource') {
const resourceEntry = entry as PerformanceResourceTiming;
if (resourceEntry.initiatorType === 'script') {
observedScripts.push(resourceEntry.name);
}
}
}
});
observer.observe({ type: 'resource', buffered: true });
} catch {
// PerformanceObserver not supported — degrade gracefully
}
}
/**
* Remove the script observer. Used for testing and cleanup.
*/
export function removeScriptObserver(): void {
if (observer) {
observer.disconnect();
observer = null;
}
observedScripts = [];
}
/**
* Get all scripts observed since the observer was installed.
*/
export function getObservedScripts(): string[] {
return [...observedScripts];
}
/**
* Parse document.cookie into individual cookies.
*/
export function parseCookies(): DiscoveredCookie[] {
const cookies: DiscoveredCookie[] = [];
if (typeof document === 'undefined' || !document.cookie) return cookies;
const cookieStr = document.cookie;
const pairs = cookieStr.split(';');
for (const pair of pairs) {
const trimmed = pair.trim();
if (!trimmed) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex < 0) continue;
const name = trimmed.substring(0, eqIndex).trim();
const value = trimmed.substring(eqIndex + 1);
if (!name) continue;
cookies.push({
name,
domain: window.location.hostname,
storage_type: 'cookie',
value_length: value.length,
});
}
return cookies;
}
/**
* Enumerate localStorage keys.
*/
export function enumerateLocalStorage(): DiscoveredCookie[] {
const items: DiscoveredCookie[] = [];
try {
if (typeof localStorage === 'undefined') return items;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key) continue;
const value = localStorage.getItem(key) ?? '';
items.push({
name: key,
domain: window.location.hostname,
storage_type: 'local_storage',
value_length: value.length,
});
}
} catch {
// localStorage may be blocked in some browsers/contexts
}
return items;
}
/**
* Enumerate sessionStorage keys.
*/
export function enumerateSessionStorage(): DiscoveredCookie[] {
const items: DiscoveredCookie[] = [];
try {
if (typeof sessionStorage === 'undefined') return items;
for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i);
if (!key) continue;
const value = sessionStorage.getItem(key) ?? '';
items.push({
name: key,
domain: window.location.hostname,
storage_type: 'session_storage',
value_length: value.length,
});
}
} catch {
// sessionStorage may be blocked in some browsers/contexts
}
return items;
}
/**
* Collect all storage items from the current page.
*/
export function collectAll(config: ReporterConfig): DiscoveredCookie[] {
const items: DiscoveredCookie[] = [];
items.push(...parseCookies());
if (config.includeLocalStorage) {
items.push(...enumerateLocalStorage());
}
if (config.includeSessionStorage) {
items.push(...enumerateSessionStorage());
}
return items;
}
/**
* Build the report payload.
*/
export function buildReport(
config: ReporterConfig,
cookies: DiscoveredCookie[],
): CookieReport {
return {
site_id: config.siteId,
page_url: typeof window !== 'undefined' ? window.location.href : '',
cookies,
collected_at: new Date().toISOString(),
user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
};
}
/**
* Send a cookie report to the API.
* Uses navigator.sendBeacon for reliability, falling back to fetch.
*/
export async function sendReport(
apiBase: string,
report: CookieReport,
): Promise<boolean> {
const url = `${apiBase}/scanner/report`;
const body = JSON.stringify(report);
// Prefer sendBeacon — fires even if page is closing
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' });
return navigator.sendBeacon(url, blob);
}
// Fallback to fetch
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
keepalive: true,
});
return resp.ok;
} catch {
return false;
}
}
/**
* Determine if this page load should be sampled for reporting.
*/
export function shouldSample(sampleRate: number): boolean {
return Math.random() < sampleRate;
}
/**
* Start the reporter. Call once per page load.
*
* The reporter will:
* 1. Check the sampling rate — skip if not sampled
* 2. Wait for the configured delay (to allow scripts to run)
* 3. Enumerate all cookies and storage keys
* 4. POST the report to the scanner API
*/
export function startReporter(config: Partial<ReporterConfig> & { siteId: string; apiBase: string }): void {
const fullConfig: ReporterConfig = { ...DEFAULT_CONFIG, ...config };
if (!shouldSample(fullConfig.sampleRate)) return;
// Install script observer for attribution
installScriptObserver();
// Delay collection to allow page to finish loading
setTimeout(() => {
const cookies = collectAll(fullConfig);
if (cookies.length === 0) return;
const report = buildReport(fullConfig, cookies);
sendReport(fullConfig.apiBase, report);
}, fullConfig.collectDelay);
}
/**
* Collect and report immediately (for testing or manual triggers).
*/
export async function reportNow(
config: Partial<ReporterConfig> & { siteId: string; apiBase: string },
): Promise<CookieReport | null> {
const fullConfig: ReporterConfig = { ...DEFAULT_CONFIG, ...config };
const cookies = collectAll(fullConfig);
if (cookies.length === 0) return null;
const report = buildReport(fullConfig, cookies);
await sendReport(fullConfig.apiBase, report);
return report;
}