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.
This commit is contained in:
310
apps/banner/src/reporter.ts
Normal file
310
apps/banner/src/reporter.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user