commit a1a8147ae24aebca47ea892e6b1afddb49100d77 Author: jensmohr Date: Thu May 21 19:58:08 2026 +0200 Initialize VG-Environment from stable VG-IV baseline diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8916849 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# OS +Thumbs.db +Desktop.ini + +# VS Code / Codium +.vscode/ + +# Node / tooling +node_modules/ + +# Build / package artifacts +dist/ +build/ +*.zip + +# Logs +*.log + +# Temporary exports / captures +exports/ +captures/ +har/ +*.har + +# Local test data +tmp/ +temp/ + +# Firefox extension debug artifacts +web-ext-artifacts/ + +# SQLite / DB dumps +*.sqlite +*.sqlite3 +*.db + +# Environment files +.env +.env.* diff --git a/VG-IV-Logo.png b/VG-IV-Logo.png new file mode 100644 index 0000000..c2c3db1 Binary files /dev/null and b/VG-IV-Logo.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..09a162b --- /dev/null +++ b/manifest.json @@ -0,0 +1,68 @@ +{ + "manifest_version": 3, + "name": "VendorGet", + "version": "4.0.0", + "description": "Local TCF/CMP transparency and evidence tool", + + "permissions": [ + "storage", + "tabs", + "cookies", + "scripting", + "webRequest" + ], + + "host_permissions": [ + "" + ], + +"background": { + "scripts": [ + "src/background/db/db-constants.js", + "src/background/db/db-core.js", + "src/background/gvl/gvl-vendor-normalizer.js", + "src/background/gvl/gvl-vendor-relationship-normalizer.js", + "src/background/gvl/gvl-catalog-normalizer.js", + "src/background/db/db-retention.js", + "src/background/db/db-record-locks.js", + "src/background/utils.js", + "src/background/settings.js", + "src/background/maintenance-guard.js", + "src/background/consent-memory.js", + "src/background/request-fingerprint.js", + "src/background/request-observer.js", + "src/background/gvl-service.js", + "src/background.js" + ] +}, + + "action": { + "default_title": "VendorGet-IV", + "default_popup": "src/popup/popup.html" + }, + + "content_scripts": [ + { + "matches": [""], + "js": [ + "src/content/tcf-listener.js" + ], + "run_at": "document_idle" + } + ], + + "web_accessible_resources": [ + { + "resources": [ + "src/injected/tcf-bridge.js" + ], + "matches": [""] + } + ], + + "browser_specific_settings": { + "gecko": { + "id": "vendorget@local" + } + } +} diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..59f661e --- /dev/null +++ b/src/background.js @@ -0,0 +1,569 @@ +console.log("VendorGet-IV background loaded"); + +const OFFICIAL_IAB_GVL_URL = + "https://vendor-list.consensu.org/v3/vendor-list.json"; +const EVIDENCE_RECORDING_SOURCE = "vendorget_background_mirror"; + +browser.runtime.onMessage.addListener((message, sender) => + handleVendorGetMessage(message, sender) +); + +browser.webRequest.onBeforeRequest.addListener( + handleObservedRequest, + { urls: [""] } +); + +async function handleVendorGetMessage(message, sender) { + if (!message) { + return null; + } + + if (message.type === "gvl_import_json") { + return handleGvlImportJsonMessage(message); + } + + if (message.type === "fetch_official_gvl") { + return handleFetchOfficialGvlMessage(); + } + + if (message.type === "start_evidence_maintenance_session") { + return startEvidenceMaintenanceSession(message?.payload?.source); + } + + if (message.type === "refresh_evidence_maintenance_session") { + return refreshEvidenceMaintenanceSession(message?.payload?.source); + } + + if (message.type === "end_evidence_maintenance_session") { + return endEvidenceMaintenanceSession(message?.payload?.source); + } + + if (message.type === "get_evidence_maintenance_status") { + return getEvidenceMaintenanceStatus(); + } + + if (message.type === "get_evidence_retention_status") { + return handleGetEvidenceRetentionStatusMessage(); + } + + if (message.type === "purge_unlocked_evidence_records") { + return handlePurgeUnlockedEvidenceRecordsMessage(); + } + + if (message.type === "delete_all_evidence_database") { + return handleDeleteAllEvidenceDatabaseMessage(); + } + + if (message.type === "lock_all_evidence_records") { + return lockAllEvidenceRecords( + message?.payload?.reason ?? "dsar_used", + message?.payload?.note ?? null + ); + } + + if (message.type === "unlock_all_evidence_records") { + return unlockAllEvidenceRecords(); + } + + if (message.type !== "vendorget_capture") { + return; + } + + if (!(await isConsentCaptureEnabled())) { + return; + } + + const eventName = message?.payload?.eventName ?? null; + const tabId = sender?.tab?.id ?? null; + + if (eventName === "tcf_ping") { + const pingData = message?.payload?.payload?.data ?? null; + + if (tabId !== null && pingData) { + rememberLatestTcfPing(tabId, pingData); + } + + console.log("VendorGet-IV tcf ping", { + payload: message.payload.payload, + sender + }); + + return; + } + + if (eventName !== "consent_capture") { + console.log("VendorGet-IV ignored event", message); + return; + } + + if (isEvidenceWriteSuspended()) { + console.info("VendorGet-IV evidence write skipped: maintenance mode"); + return; + } + + const latestPingData = tabId !== null ? getLatestTcfPing(tabId) : null; + + const consentState = buildConsentStateV1( + message.payload.payload, + sender, + latestPingData + ); + + consentState.stateFingerprint = await sha256Hex( + stableStringify(consentState.fingerprintSource) + ); + + rememberLatestConsentState(consentState); + + const result = await persistConsentState( + consentState, + message.payload.payload?.rawTcData ?? null + ); + + console.log("VendorGet-IV consent state persisted", result); +} + +async function handleGetEvidenceRetentionStatusMessage() { + const db = await openVendorGetDb(); + const totalCount = await countEvidenceRecords(db); + const lockedCount = await countLockedEvidenceRecords(db); + const storeCounts = await getEvidenceStoreCounts(db); + + return { + success: true, + totalCount, + lockedCount, + unlockedCount: totalCount - lockedCount, + storeCounts + }; +} + +async function handlePurgeUnlockedEvidenceRecordsMessage() { + const db = await openVendorGetDb(); + + return purgeUnlockedEvidenceRecords(db); +} + +function handleDeleteAllEvidenceDatabaseMessage() { + return deleteVendorGetDatabase(); +} + +async function handleGvlImportJsonMessage(message) { + const rawJson = message?.payload?.rawJson ?? null; + + if (!isGvlImportCandidate(rawJson)) { + return { + success: false, + error: "invalid_gvl_json" + }; + } + + const db = await openVendorGetDb(); + const result = await VendorGetGvlService.ingestGvlSnapshot(db, rawJson, { + sourceUrl: message?.payload?.sourceUrl ?? null, + diagnostics: { + importSource: "local_file" + } + }); + + return { + success: true, + alreadyKnown: result.alreadyKnown, + vendorListVersion: result.snapshot.vendorListVersion, + sha256: result.snapshot.sha256 + }; +} + +function isGvlImportCandidate(value) { + return ( + value && + typeof value === "object" && + !Array.isArray(value) && + value.vendorListVersion !== undefined && + value.vendors && + typeof value.vendors === "object" && + !Array.isArray(value.vendors) + ); +} + +async function handleFetchOfficialGvlMessage() { + try { + const response = await fetch(OFFICIAL_IAB_GVL_URL, { + method: "GET", + cache: "no-store" + }); + + if (!response.ok) { + return { + success: false, + error: "official_gvl_fetch_failed", + responseStatus: response.status + }; + } + + const rawJson = await response.json(); + + if (!isGvlImportCandidate(rawJson)) { + return { + success: false, + error: "invalid_gvl_json", + responseStatus: response.status + }; + } + + const db = await openVendorGetDb(); + const result = await VendorGetGvlService.ingestGvlSnapshot(db, rawJson, { + sourceUrl: OFFICIAL_IAB_GVL_URL, + fetchedAt: new Date().toISOString(), + diagnostics: { + ingestionSource: "official_iab_fetch", + responseStatus: response.status + } + }); + + return { + success: true, + alreadyKnown: result.alreadyKnown, + vendorListVersion: result.snapshot.vendorListVersion, + sha256: result.snapshot.sha256 + }; + } catch (error) { + console.warn("VendorGet-IV official GVL fetch failed", error); + + return { + success: false, + error: "official_gvl_fetch_failed" + }; + } +} + +function buildConsentStateV1(rawCapture, sender, latestPingData) { + const decodedTcString = decodeTcStringCoreMetadata(rawCapture?.tcString ?? null); + + // Firefox/CMP/browser storage remains the consent source of truth; VG-IV + // stores an evidentiary mirror of the observed TCF state. + const state = { + schemaVersion: 1, + capturedAt: new Date().toISOString(), + + page: { + url: rawCapture?.url ?? sender?.tab?.url ?? sender?.url ?? null, + origin: rawCapture?.origin ?? sender?.origin ?? null, + tabId: sender?.tab?.id ?? null, + frameId: sender?.frameId ?? null, + incognito: sender?.tab?.incognito ?? null, + cookieStoreId: sender?.tab?.cookieStoreId ?? null + }, + + cmp: { + cmpId: + rawCapture?.cmpId ?? + decodedTcString?.cmpId ?? + latestPingData?.cmpId ?? + null, + + cmpVersion: + rawCapture?.cmpVersion ?? + decodedTcString?.cmpVersion ?? + latestPingData?.cmpVersion ?? + null, + + tcfPolicyVersion: + rawCapture?.tcfPolicyVersion ?? + decodedTcString?.tcfPolicyVersion ?? + latestPingData?.tcfPolicyVersion ?? + null, + + gdprApplies: rawCapture?.gdprApplies ?? latestPingData?.gdprApplies ?? null, + + isServiceSpecific: + rawCapture?.isServiceSpecific ?? + decodedTcString?.isServiceSpecific ?? + null, + + useNonStandardTexts: rawCapture?.useNonStandardTexts ?? null, + + publisherCC: + rawCapture?.publisherCC ?? + decodedTcString?.publisherCC ?? + null, + + purposeOneTreatment: + rawCapture?.purposeOneTreatment ?? + decodedTcString?.purposeOneTreatment ?? + null + }, + + observation: { + eventStatus: rawCapture?.eventStatus ?? null, + cmpStatus: rawCapture?.cmpStatus ?? latestPingData?.cmpStatus ?? null + }, + + gvl: { + vendorListVersion: + rawCapture?.vendorListVersion ?? + rawCapture?.gvlVersion ?? + latestPingData?.gvlVersion ?? + latestPingData?.vendorListVersion ?? + decodedTcString?.vendorListVersion ?? + null + }, + + consent: { + tcString: rawCapture?.tcString ?? null, + addtlConsent: rawCapture?.addtlConsent ?? null + }, + + purposes: { + consents: rawCapture?.purpose?.consents ?? {}, + legitimateInterests: rawCapture?.purpose?.legitimateInterests ?? {} + }, + + vendors: { + consents: rawCapture?.vendor?.consents ?? {}, + legitimateInterests: rawCapture?.vendor?.legitimateInterests ?? {}, + disclosedVendors: + rawCapture?.vendor?.disclosedVendors ?? + rawCapture?.disclosedVendors ?? + {} + }, + + specialFeatureOptins: rawCapture?.specialFeatureOptins ?? {}, + + publisher: { + restrictions: rawCapture?.publisher?.restrictions ?? {}, + consents: rawCapture?.publisher?.consents ?? {}, + legitimateInterests: rawCapture?.publisher?.legitimateInterests ?? {} + }, + + diagnostics: { + bridgeTimestampUtc: rawCapture?.timestampUtc ?? null, + rawTopLevelKeys: Object.keys(rawCapture ?? {}), + decodedTcStringCore: decodedTcString, + latestPingData: latestPingData + }, + + fingerprintSource: null, + stateFingerprint: null + }; + + state.fingerprintSource = buildFingerprintSource(state); + + return state; +} + +function buildFingerprintSource(consentState) { + return { + cmp: consentState.cmp, + gvl: consentState.gvl, + consent: consentState.consent, + purposes: consentState.purposes, + vendors: consentState.vendors, + specialFeatureOptins: consentState.specialFeatureOptins, + publisher: consentState.publisher + }; +} + +async function persistConsentState(consentState, rawTcData) { + const db = await openVendorGetDb(); + const now = new Date().toISOString(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(["consent_states", "consent_events"], "readwrite"); + + const statesStore = tx.objectStore("consent_states"); + const eventsStore = tx.objectStore("consent_events"); + + const getRequest = statesStore.get(consentState.stateFingerprint); + + getRequest.onerror = () => reject(getRequest.error); + + getRequest.onsuccess = () => { + const existingState = getRequest.result; + + if (existingState) { + existingState.recordedAt = + existingState.recordedAt ?? existingState.createdAt ?? now; + existingState.recordingSource = + existingState.recordingSource ?? EVIDENCE_RECORDING_SOURCE; + existingState.lastSeenAt = now; + existingState.seenCount = (existingState.seenCount ?? 1) + 1; + existingState.updatedAt = now; + + statesStore.put(existingState); + + eventsStore.add({ + eventType: "duplicate_state", + capturedAt: consentState.capturedAt, + recordedAt: now, + recordingSource: EVIDENCE_RECORDING_SOURCE, + stateFingerprint: consentState.stateFingerprint, + page: consentState.page, + rawEventName: "consent_capture", + rawTcData: rawTcData, + diagnostics: consentState.diagnostics + }); + + resolve({ + action: "duplicate_state_updated", + stateFingerprint: consentState.stateFingerprint, + seenCount: existingState.seenCount + }); + + return; + } + + const newStateRecord = { + ...consentState, + recordedAt: now, + recordingSource: EVIDENCE_RECORDING_SOURCE, + firstSeenAt: now, + lastSeenAt: now, + seenCount: 1, + createdAt: now, + updatedAt: now + }; + + statesStore.add(newStateRecord); + + eventsStore.add({ + eventType: "new_state", + capturedAt: consentState.capturedAt, + recordedAt: now, + recordingSource: EVIDENCE_RECORDING_SOURCE, + stateFingerprint: consentState.stateFingerprint, + page: consentState.page, + rawEventName: "consent_capture", + rawTcData: rawTcData, + diagnostics: consentState.diagnostics + }); + + resolve({ + action: "new_state_inserted", + stateFingerprint: consentState.stateFingerprint, + seenCount: 1 + }); + }; + + tx.onerror = () => reject(tx.error); + }); +} + +async function persistObservedRequest(observedRequest) { + const db = await openVendorGetDb(); + + return new Promise((resolve, reject) => { + const tx = db.transaction(["observed_requests"], "readwrite"); + const requestsStore = tx.objectStore("observed_requests"); + const getRequest = requestsStore.get(observedRequest.requestFingerprint); + + getRequest.onerror = () => reject(getRequest.error); + + getRequest.onsuccess = () => { + const existingRequest = getRequest.result; + + if (existingRequest) { + const updatedRequest = { + ...existingRequest, + requestFingerprintSource: observedRequest.requestFingerprintSource, + recordedAt: existingRequest.recordedAt ?? observedRequest.recordedAt, + recordingSource: + existingRequest.recordingSource ?? observedRequest.recordingSource, + lastSeenAt: observedRequest.lastSeenAt, + seenCount: (existingRequest.seenCount ?? 1) + 1, + request: observedRequest.request, + consentParams: observedRequest.consentParams, + context: observedRequest.context, + correlation: observedRequest.correlation + }; + + requestsStore.put(updatedRequest); + + resolve({ + action: "observed_request_updated", + requestFingerprint: observedRequest.requestFingerprint, + seenCount: updatedRequest.seenCount + }); + + return; + } + + requestsStore.add(observedRequest); + + resolve({ + action: "observed_request_inserted", + requestFingerprint: observedRequest.requestFingerprint, + seenCount: observedRequest.seenCount + }); + }; + + tx.onerror = () => reject(tx.error); + }); +} + +function decodeTcStringCoreMetadata(tcString) { + if (!tcString || typeof tcString !== "string") { + return null; + } + + const coreSegment = tcString.split(".")[0]; + + try { + const bits = base64UrlToBits(coreSegment); + + return { + version: bitsToInt(bits, 0, 6), + cmpId: bitsToInt(bits, 78, 12), + cmpVersion: bitsToInt(bits, 90, 12), + consentScreen: bitsToInt(bits, 102, 6), + consentLanguage: bitsToString(bits, 108, 12), + vendorListVersion: bitsToInt(bits, 120, 12), + tcfPolicyVersion: bitsToInt(bits, 132, 6), + isServiceSpecific: bitsToBoolean(bits, 138), + useNonStandardStacks: bitsToBoolean(bits, 139), + purposeOneTreatment: bitsToBoolean(bits, 200), + publisherCC: bitsToString(bits, 201, 12) + }; + } catch (error) { + console.warn("VendorGet-IV could not decode TC string core metadata", error); + return null; + } +} + +function base64UrlToBits(value) { + const base64 = value + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(value.length / 4) * 4, "="); + + const binary = atob(base64); + let bits = ""; + + for (let index = 0; index < binary.length; index += 1) { + bits += binary + .charCodeAt(index) + .toString(2) + .padStart(8, "0"); + } + + return bits; +} + +function bitsToInt(bits, start, length) { + return parseInt(bits.slice(start, start + length), 2); +} + +function bitsToBoolean(bits, index) { + return bits[index] === "1"; +} + +function bitsToString(bits, start, length) { + let result = ""; + + for (let index = start; index < start + length; index += 6) { + const charCode = bitsToInt(bits, index, 6) + 65; + result += String.fromCharCode(charCode); + } + + return result; +} diff --git a/src/background/consent-memory.js b/src/background/consent-memory.js new file mode 100644 index 0000000..4b051e1 --- /dev/null +++ b/src/background/consent-memory.js @@ -0,0 +1,46 @@ +const latestTcfPingByTab = new Map(); +const latestConsentStateByTabFrame = new Map(); + +function rememberLatestTcfPing(tabId, pingData) { + latestTcfPingByTab.set(tabId, pingData); +} + +function getLatestTcfPing(tabId) { + return latestTcfPingByTab.get(tabId) ?? null; +} + +function rememberLatestConsentState(consentState) { + const key = buildTabFrameKey( + consentState?.page?.tabId, + consentState?.page?.frameId + ); + + if (key === null) { + return; + } + + latestConsentStateByTabFrame.set(key, { + stateFingerprint: consentState.stateFingerprint, + capturedAt: consentState.capturedAt, + tabId: consentState.page.tabId, + frameId: consentState.page.frameId + }); +} + +function getLatestConsentStateForRequest(tabId, frameId) { + const key = buildTabFrameKey(tabId, frameId); + + if (key === null) { + return null; + } + + return latestConsentStateByTabFrame.get(key) ?? null; +} + +function buildTabFrameKey(tabId, frameId) { + if (tabId === null || tabId === undefined || frameId === null || frameId === undefined) { + return null; + } + + return `${tabId}:${frameId}`; +} diff --git a/src/background/db/db-constants.js b/src/background/db/db-constants.js new file mode 100644 index 0000000..b5718ce --- /dev/null +++ b/src/background/db/db-constants.js @@ -0,0 +1,34 @@ +"use strict"; + +const VENDORGET_DB_NAME = "vendorget_iv"; +const VENDORGET_DB_VERSION = 5; + +const VENDORGET_STORE_NAMES = { + consentStates: "consent_states", + consentEvents: "consent_events", + observedRequests: "observed_requests", + gvlSnapshots: "gvl_snapshots", + gvlSnapshotEvents: "gvl_snapshot_events", + gvlVendors: "gvl_vendors", + gvlPurposes: "gvl_purposes", + gvlSpecialPurposes: "gvl_special_purposes", + gvlFeatures: "gvl_features", + gvlSpecialFeatures: "gvl_special_features", + gvlDataCategories: "gvl_data_categories", + gvlVendorRelationships: "gvl_vendor_relationships" +}; + +const VENDORGET_EVIDENCE_STORE_NAMES = [ + VENDORGET_STORE_NAMES.consentStates, + VENDORGET_STORE_NAMES.consentEvents, + VENDORGET_STORE_NAMES.observedRequests, + VENDORGET_STORE_NAMES.gvlSnapshots, + VENDORGET_STORE_NAMES.gvlSnapshotEvents, + VENDORGET_STORE_NAMES.gvlVendors, + VENDORGET_STORE_NAMES.gvlPurposes, + VENDORGET_STORE_NAMES.gvlSpecialPurposes, + VENDORGET_STORE_NAMES.gvlFeatures, + VENDORGET_STORE_NAMES.gvlSpecialFeatures, + VENDORGET_STORE_NAMES.gvlDataCategories, + VENDORGET_STORE_NAMES.gvlVendorRelationships +]; diff --git a/src/background/db/db-core.js b/src/background/db/db-core.js new file mode 100644 index 0000000..2f49d3d --- /dev/null +++ b/src/background/db/db-core.js @@ -0,0 +1,204 @@ +"use strict"; + +let vendorGetDbPromise = null; + +function openVendorGetDb() { + if (vendorGetDbPromise) { + return vendorGetDbPromise; + } + + vendorGetDbPromise = new Promise((resolve, reject) => { + const request = indexedDB.open(VENDORGET_DB_NAME, VENDORGET_DB_VERSION); + + request.onerror = () => reject(request.error); + + request.onupgradeneeded = (event) => { + const db = event.target.result; + + if (!db.objectStoreNames.contains("consent_states")) { + const consentStates = db.createObjectStore("consent_states", { + keyPath: "stateFingerprint" + }); + + consentStates.createIndex("pageOrigin", "page.origin", { unique: false }); + consentStates.createIndex("firstSeenAt", "firstSeenAt", { unique: false }); + consentStates.createIndex("lastSeenAt", "lastSeenAt", { unique: false }); + } + + if (!db.objectStoreNames.contains("consent_events")) { + const consentEvents = db.createObjectStore("consent_events", { + keyPath: "id", + autoIncrement: true + }); + + consentEvents.createIndex("stateFingerprint", "stateFingerprint", { + unique: false + }); + + consentEvents.createIndex("eventType", "eventType", { unique: false }); + consentEvents.createIndex("capturedAt", "capturedAt", { unique: false }); + consentEvents.createIndex("pageOrigin", "page.origin", { unique: false }); + } + + if (!db.objectStoreNames.contains("observed_requests")) { + const observedRequests = db.createObjectStore("observed_requests", { + keyPath: "requestFingerprint" + }); + + observedRequests.createIndex("lastSeenAt", "lastSeenAt", { + unique: false + }); + + observedRequests.createIndex("firstSeenAt", "firstSeenAt", { + unique: false + }); + + observedRequests.createIndex("requestOrigin", "request.origin", { + unique: false + }); + + observedRequests.createIndex("requestType", "request.type", { + unique: false + }); + + observedRequests.createIndex("requestThirdParty", "request.thirdParty", { + unique: false + }); + + observedRequests.createIndex( + "correlationStateFingerprint", + "correlation.stateFingerprint", + { unique: false } + ); + } + + ensureGvlStores(db); + }; + + request.onsuccess = () => resolve(request.result); + }); + + return vendorGetDbPromise; +} + +async function closeVendorGetDb() { + if (!vendorGetDbPromise) { + return; + } + + try { + const db = await vendorGetDbPromise; + db.close(); + } finally { + vendorGetDbPromise = null; + } +} + +async function deleteVendorGetDatabase() { + await closeVendorGetDb(); + + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(VENDORGET_DB_NAME); + + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("database_delete_blocked")); + request.onsuccess = () => { + resolve({ + success: true + }); + }; + }); +} + +function ensureGvlStores(db) { + if (!db.objectStoreNames.contains("gvl_snapshots")) { + const gvlSnapshots = db.createObjectStore("gvl_snapshots", { + keyPath: "sha256" + }); + + gvlSnapshots.createIndex("gvlRevision", "gvlRevision", { unique: false }); + gvlSnapshots.createIndex("vendorListVersion", "vendorListVersion", { + unique: false + }); + gvlSnapshots.createIndex("fetchedAt", "fetchedAt", { unique: false }); + gvlSnapshots.createIndex("sourceUrl", "sourceUrl", { unique: false }); + } + + if (!db.objectStoreNames.contains("gvl_snapshot_events")) { + const gvlSnapshotEvents = db.createObjectStore("gvl_snapshot_events", { + keyPath: "id", + autoIncrement: true + }); + + gvlSnapshotEvents.createIndex("eventType", "eventType", { unique: false }); + gvlSnapshotEvents.createIndex("capturedAt", "capturedAt", { unique: false }); + gvlSnapshotEvents.createIndex("gvlRevision", "gvlRevision", { + unique: false + }); + gvlSnapshotEvents.createIndex("vendorListVersion", "vendorListVersion", { + unique: false + }); + gvlSnapshotEvents.createIndex("sha256", "sha256", { unique: false }); + gvlSnapshotEvents.createIndex("sourceUrl", "sourceUrl", { unique: false }); + } + + ensureGvlRelationshipStores(db); +} + +function ensureGvlRelationshipStores(db) { + const gvlRelationshipStoreDefinitions = [ + { + name: "gvl_vendors", + indexes: [ + "vendorListVersion", + "vendorId", + "name", + "policyUrl", + "deletedDate" + ] + }, + { + name: "gvl_purposes", + indexes: ["vendorListVersion", "purposeId", "name"] + }, + { + name: "gvl_special_purposes", + indexes: ["vendorListVersion", "specialPurposeId", "name"] + }, + { + name: "gvl_features", + indexes: ["vendorListVersion", "featureId", "name"] + }, + { + name: "gvl_special_features", + indexes: ["vendorListVersion", "specialFeatureId", "name"] + }, + { + name: "gvl_data_categories", + indexes: ["vendorListVersion", "dataCategoryId", "name"] + }, + { + name: "gvl_vendor_relationships", + indexes: [ + "vendorListVersion", + "vendorId", + "relationshipType", + "relatedId" + ] + } + ]; + + gvlRelationshipStoreDefinitions.forEach((storeDefinition) => { + if (db.objectStoreNames.contains(storeDefinition.name)) { + return; + } + + const objectStore = db.createObjectStore(storeDefinition.name, { + keyPath: "id" + }); + + storeDefinition.indexes.forEach((indexName) => { + objectStore.createIndex(indexName, indexName, { unique: false }); + }); + }); +} diff --git a/src/background/db/db-record-locks.js b/src/background/db/db-record-locks.js new file mode 100644 index 0000000..44ade99 --- /dev/null +++ b/src/background/db/db-record-locks.js @@ -0,0 +1,67 @@ +"use strict"; + +async function lockAllEvidenceRecords(reason, note) { + const db = await openVendorGetDb(); + const lockedAt = new Date().toISOString(); + const recordLockReason = reason ?? "dsar_used"; + const recordLockNote = note ?? null; + + return updateAllEvidenceRecords((record) => ({ + ...record, + bolRecordLock: true, + recordLockReason, + recordLockedAt: lockedAt, + recordLockNote + }), "lockedCount", db); +} + +async function unlockAllEvidenceRecords() { + const db = await openVendorGetDb(); + const unlockedAt = new Date().toISOString(); + + return updateAllEvidenceRecords((record) => ({ + ...record, + bolRecordLock: false, + recordLockReason: null, + recordLockNote: null, + recordLockedAt: null, + recordUnlockedAt: unlockedAt + }), "unlockedCount", db); +} + +function updateAllEvidenceRecords(buildUpdatedRecord, countKey, db) { + return new Promise((resolve, reject) => { + let updatedCount = 0; + const tx = db.transaction(VENDORGET_EVIDENCE_STORE_NAMES, "readwrite"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => { + resolve({ + success: true, + [countKey]: updatedCount + }); + }; + + for (const storeName of VENDORGET_EVIDENCE_STORE_NAMES) { + const cursorRequest = tx.objectStore(storeName).openCursor(); + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + const updateRequest = cursor.update(buildUpdatedRecord(cursor.value)); + updateRequest.onerror = () => { + reject(updateRequest.error); + }; + updateRequest.onsuccess = () => { + updatedCount += 1; + cursor.continue(); + }; + }; + } + }); +} diff --git a/src/background/db/db-retention.js b/src/background/db/db-retention.js new file mode 100644 index 0000000..b93b2b2 --- /dev/null +++ b/src/background/db/db-retention.js @@ -0,0 +1,118 @@ +"use strict"; + +function countEvidenceRecords(db) { + return countRecordsInStores(db, VENDORGET_EVIDENCE_STORE_NAMES); +} + +function countLockedEvidenceRecords(db) { + return countRecordsMatching(db, VENDORGET_EVIDENCE_STORE_NAMES, (record) => { + return record?.bolRecordLock === true; + }); +} + +function getEvidenceStoreCounts(db) { + return new Promise((resolve, reject) => { + const storeCounts = {}; + const tx = db.transaction(VENDORGET_EVIDENCE_STORE_NAMES, "readonly"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(storeCounts); + + for (const storeName of VENDORGET_EVIDENCE_STORE_NAMES) { + const countRequest = tx.objectStore(storeName).count(); + + countRequest.onsuccess = () => { + storeCounts[storeName] = countRequest.result; + }; + } + }); +} + +function purgeUnlockedEvidenceRecords(db) { + return new Promise((resolve, reject) => { + let deletedCount = 0; + let keptLockedCount = 0; + const tx = db.transaction(VENDORGET_EVIDENCE_STORE_NAMES, "readwrite"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => { + resolve({ + success: true, + deletedCount, + keptLockedCount + }); + }; + + for (const storeName of VENDORGET_EVIDENCE_STORE_NAMES) { + const cursorRequest = tx.objectStore(storeName).openCursor(); + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + if (cursor.value?.bolRecordLock === true) { + keptLockedCount += 1; + cursor.continue(); + return; + } + + deletedCount += 1; + cursor.delete(); + cursor.continue(); + }; + } + }); +} + +function countRecordsInStores(db, storeNames) { + return new Promise((resolve, reject) => { + let totalCount = 0; + const tx = db.transaction(storeNames, "readonly"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(totalCount); + + for (const storeName of storeNames) { + const countRequest = tx.objectStore(storeName).count(); + + countRequest.onsuccess = () => { + totalCount += countRequest.result; + }; + } + }); +} + +function countRecordsMatching(db, storeNames, predicate) { + return new Promise((resolve, reject) => { + let totalCount = 0; + const tx = db.transaction(storeNames, "readonly"); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(totalCount); + + for (const storeName of storeNames) { + const cursorRequest = tx.objectStore(storeName).openCursor(); + + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + if (predicate(cursor.value)) { + totalCount += 1; + } + + cursor.continue(); + }; + } + }); +} diff --git a/src/background/gvl-service.js b/src/background/gvl-service.js new file mode 100644 index 0000000..f54eca5 --- /dev/null +++ b/src/background/gvl-service.js @@ -0,0 +1,156 @@ +async function calculateGvlSnapshotSha256(rawJson) { + const stableValue = normalizeGvlSnapshotValueForHash(rawJson); + const data = new TextEncoder().encode(stableValue); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + return hashArray + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +async function buildGvlSnapshotRecord(rawJson, sourceUrl, fetchedAt) { + const gvlJson = normalizeGvlSnapshotValueForMetadata(rawJson); + + return { + sha256: await calculateGvlSnapshotSha256(rawJson), + vendorListVersion: gvlJson?.vendorListVersion ?? null, + gvlSpecificationVersion: gvlJson?.gvlSpecificationVersion ?? null, + tcfPolicyVersion: gvlJson?.tcfPolicyVersion ?? null, + fetchedAt: fetchedAt, + sourceUrl: sourceUrl, + rawJson: rawJson, + vendorCount: countObjectEntries(gvlJson?.vendors), + purposeCount: countObjectEntries(gvlJson?.purposes), + // Existing GVL snapshots already use createdAt as the local mirror timestamp; + // keep that field instead of duplicating it as recordedAt. + createdAt: new Date().toISOString() + }; +} + +function storeGvlSnapshotIfNew(db, snapshot) { + return new Promise((resolve, reject) => { + const tx = db.transaction(["gvl_snapshots"], "readwrite"); + const snapshotsStore = tx.objectStore("gvl_snapshots"); + const getRequest = snapshotsStore.get(snapshot.sha256); + let result = null; + + getRequest.onerror = () => reject(getRequest.error); + + getRequest.onsuccess = () => { + if (getRequest.result) { + result = { + stored: false, + sha256: snapshot.sha256, + vendorListVersion: snapshot.vendorListVersion + }; + return; + } + + snapshotsStore.add(snapshot); + + result = { + stored: true, + sha256: snapshot.sha256, + vendorListVersion: snapshot.vendorListVersion + }; + }; + + tx.onerror = () => reject(tx.error); + tx.oncomplete = () => resolve(result); + }); +} + +function recordGvlSnapshotEvent(db, event) { + return new Promise((resolve, reject) => { + const tx = db.transaction(["gvl_snapshot_events"], "readwrite"); + const eventsStore = tx.objectStore("gvl_snapshot_events"); + const addRequest = eventsStore.add({ + eventType: event.eventType, + capturedAt: event.capturedAt ?? new Date().toISOString(), + vendorListVersion: event.vendorListVersion, + sha256: event.sha256, + sourceUrl: event.sourceUrl, + diagnostics: event.diagnostics ?? null + }); + let eventId = null; + + addRequest.onerror = () => reject(addRequest.error); + addRequest.onsuccess = () => { + eventId = addRequest.result; + }; + tx.onerror = () => reject(tx.error); + tx.oncomplete = () => resolve(eventId); + }); +} + +async function ingestGvlSnapshot(db, rawJson, options = {}) { + const snapshot = await buildGvlSnapshotRecord( + rawJson, + options.sourceUrl ?? null, + options.fetchedAt ?? null + ); + const storeResult = await storeGvlSnapshotIfNew(db, snapshot); + const alreadyKnown = !storeResult.stored; + + await recordGvlSnapshotEvent(db, { + eventType: alreadyKnown + ? "gvl_snapshot_already_known" + : "gvl_snapshot_ingested", + capturedAt: new Date().toISOString(), + vendorListVersion: snapshot.vendorListVersion, + sha256: snapshot.sha256, + sourceUrl: snapshot.sourceUrl, + diagnostics: { + ingestionSource: "manual_pipeline", + ...(options.diagnostics ?? {}), + alreadyKnown: alreadyKnown + } + }); + + return { + snapshot: snapshot, + storeResult: storeResult, + alreadyKnown: alreadyKnown + }; +} + +function normalizeGvlSnapshotValueForHash(rawJson) { + if (typeof rawJson !== "string") { + return stableStringify(rawJson); + } + + try { + return stableStringify(JSON.parse(rawJson)); + } catch (error) { + return rawJson; + } +} + +function normalizeGvlSnapshotValueForMetadata(rawJson) { + if (typeof rawJson !== "string") { + return rawJson; + } + + try { + return JSON.parse(rawJson); + } catch (error) { + return null; + } +} + +function countObjectEntries(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return 0; + } + + return Object.keys(value).length; +} + +globalThis.VendorGetGvlService = { + calculateGvlSnapshotSha256, + buildGvlSnapshotRecord, + storeGvlSnapshotIfNew, + recordGvlSnapshotEvent, + ingestGvlSnapshot +}; diff --git a/src/background/gvl/gvl-catalog-normalizer.js b/src/background/gvl/gvl-catalog-normalizer.js new file mode 100644 index 0000000..c02dbe9 --- /dev/null +++ b/src/background/gvl/gvl-catalog-normalizer.js @@ -0,0 +1,348 @@ +"use strict"; + +async function normalizeGvlCatalogsFromSnapshot(snapshot) { + const summary = { + ok: false, + vendorListVersion: snapshot?.vendorListVersion ?? null, + snapshotSha256: snapshot?.sha256 ?? null, + snapshotFetchedAt: snapshot?.fetchedAt ?? null, + gvlLastUpdated: null, + catalogsSeen: 0, + catalogsWritten: 0, + skipped: 0, + error: null + }; + + try { + if (!snapshot) { + return warnAndReturnGvlCatalogSummary(summary, "missing_snapshot"); + } + + const gvl = parseGvlCatalogRawJson(snapshot.rawJson); + + if (!gvl) { + return warnAndReturnGvlCatalogSummary( + summary, + "invalid_snapshot_raw_json" + ); + } + + const vendorListVersion = + gvl.vendorListVersion ?? snapshot.vendorListVersion ?? null; + const normalizedAt = new Date().toISOString(); + + summary.vendorListVersion = vendorListVersion; + summary.gvlLastUpdated = gvl.lastUpdated ?? null; + + if (vendorListVersion === null || vendorListVersion === undefined) { + return warnAndReturnGvlCatalogSummary( + summary, + "missing_vendor_list_version" + ); + } + + const recordsByStoreName = buildGvlCatalogRecordsByStoreName({ + gvl: gvl, + snapshot: snapshot, + vendorListVersion: vendorListVersion, + normalizedAt: normalizedAt, + summary: summary + }); + + summary.catalogsWritten = await putGvlCatalogRecords(recordsByStoreName); + summary.ok = true; + + return summary; + } catch (error) { + summary.error = error?.message ?? String(error); + console.warn("GVL catalog normalization failed", { + error: error, + snapshotSha256: summary.snapshotSha256, + vendorListVersion: summary.vendorListVersion + }); + + return summary; + } +} + +async function normalizeLatestGvlCatalogs() { + const summary = { + ok: false, + vendorListVersion: null, + snapshotSha256: null, + snapshotFetchedAt: null, + gvlLastUpdated: null, + catalogsSeen: 0, + catalogsWritten: 0, + skipped: 0, + error: null + }; + + try { + const snapshot = await getLatestGvlCatalogSnapshot(); + + if (!snapshot) { + return warnAndReturnGvlCatalogSummary( + summary, + "no_gvl_snapshots_found" + ); + } + + return normalizeGvlCatalogsFromSnapshot(snapshot); + } catch (error) { + summary.error = error?.message ?? String(error); + console.warn("Latest GVL catalog normalization failed", error); + + return summary; + } +} + +function parseGvlCatalogRawJson(rawJson) { + if (!rawJson) { + return null; + } + + if (typeof rawJson === "string") { + try { + return JSON.parse(rawJson); + } catch (error) { + console.warn("GVL snapshot rawJson is not valid JSON", error); + return null; + } + } + + if (typeof rawJson === "object" && !Array.isArray(rawJson)) { + return rawJson; + } + + return null; +} + +function buildGvlCatalogRecordsByStoreName({ + gvl, + snapshot, + vendorListVersion, + normalizedAt, + summary +}) { + const recordsByStoreName = {}; + + getGvlCatalogDefinitions().forEach((definition) => { + const catalog = gvl[definition.gvlField]; + + if (catalog === undefined || catalog === null) { + return; + } + + if (typeof catalog !== "object" || Array.isArray(catalog)) { + summary.skipped += 1; + return; + } + + Object.entries(catalog).forEach(([catalogKey, catalogEntry]) => { + summary.catalogsSeen += 1; + + if ( + !catalogEntry || + typeof catalogEntry !== "object" || + Array.isArray(catalogEntry) + ) { + summary.skipped += 1; + return; + } + + const catalogId = + catalogEntry.id ?? parseGvlCatalogNumericId(catalogKey); + + if (catalogId === null || catalogId === undefined) { + summary.skipped += 1; + return; + } + + if (!recordsByStoreName[definition.storeName]) { + recordsByStoreName[definition.storeName] = []; + } + + recordsByStoreName[definition.storeName].push( + buildGvlCatalogRecord({ + definition: definition, + catalogEntry: catalogEntry, + catalogId: catalogId, + vendorListVersion: vendorListVersion, + snapshot: snapshot, + gvl: gvl, + normalizedAt: normalizedAt + }) + ); + }); + }); + + return recordsByStoreName; +} + +function getGvlCatalogDefinitions() { + return [ + { + entityType: "purpose", + gvlField: "purposes", + storeName: VENDORGET_STORE_NAMES.gvlPurposes, + keySegment: "purpose" + }, + { + entityType: "specialPurpose", + gvlField: "specialPurposes", + storeName: VENDORGET_STORE_NAMES.gvlSpecialPurposes, + keySegment: "specialPurpose" + }, + { + entityType: "feature", + gvlField: "features", + storeName: VENDORGET_STORE_NAMES.gvlFeatures, + keySegment: "feature" + }, + { + entityType: "specialFeature", + gvlField: "specialFeatures", + storeName: VENDORGET_STORE_NAMES.gvlSpecialFeatures, + keySegment: "specialFeature" + } + ]; +} + +function buildGvlCatalogRecord({ + definition, + catalogEntry, + catalogId, + vendorListVersion, + snapshot, + gvl, + normalizedAt +}) { + return { + id: `${vendorListVersion}:${definition.keySegment}:${catalogId}`, + entityType: definition.entityType, + vendorListVersion: vendorListVersion, + catalogId: catalogId, + name: catalogEntry.name ?? null, + description: catalogEntry.description ?? null, + descriptionLegal: catalogEntry.descriptionLegal ?? null, + illustrations: catalogEntry.illustrations ?? null, + snapshotSha256: snapshot.sha256, + snapshotFetchedAt: snapshot.fetchedAt ?? null, + gvlLastUpdated: gvl.lastUpdated ?? null, + normalizedAt: normalizedAt, + rawCatalog: catalogEntry + }; +} + +function parseGvlCatalogNumericId(value) { + const numericId = Number(value); + + if (!Number.isFinite(numericId)) { + return null; + } + + return numericId; +} + +async function putGvlCatalogRecords(recordsByStoreName) { + const storeNames = Object.keys(recordsByStoreName); + + if (!storeNames.length) { + return 0; + } + + const db = await openVendorGetDb(); + + storeNames.forEach((storeName) => { + if (!db.objectStoreNames.contains(storeName)) { + throw new Error(`missing_gvl_catalog_store:${storeName}`); + } + }); + + return new Promise((resolve, reject) => { + const tx = db.transaction(storeNames, "readwrite"); + let written = 0; + + storeNames.forEach((storeName) => { + const objectStore = tx.objectStore(storeName); + + recordsByStoreName[storeName].forEach((record) => { + const putRequest = objectStore.put(record); + + putRequest.onsuccess = () => { + written += 1; + }; + }); + }); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(written); + }); +} + +async function getLatestGvlCatalogSnapshot() { + const db = await openVendorGetDb(); + + if (!db.objectStoreNames.contains(VENDORGET_STORE_NAMES.gvlSnapshots)) { + throw new Error("missing_gvl_snapshots_store"); + } + + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const getAllRequest = snapshotsStore.getAll(); + + getAllRequest.onerror = () => reject(getAllRequest.error); + getAllRequest.onsuccess = () => { + const snapshots = getAllRequest.result ?? []; + + resolve(sortGvlCatalogSnapshotsNewestFirst(snapshots)[0] ?? null); + }; + }); +} + +function sortGvlCatalogSnapshotsNewestFirst(snapshots) { + return snapshots.slice().sort((left, right) => { + const fetchedAtComparison = + toGvlCatalogComparableTime(right.fetchedAt) - + toGvlCatalogComparableTime(left.fetchedAt); + + if (fetchedAtComparison !== 0) { + return fetchedAtComparison; + } + + return ( + toGvlCatalogComparableNumber(right.vendorListVersion) - + toGvlCatalogComparableNumber(left.vendorListVersion) + ); + }); +} + +function toGvlCatalogComparableTime(value) { + const timestamp = Date.parse(value ?? ""); + + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function toGvlCatalogComparableNumber(value) { + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : 0; +} + +function warnAndReturnGvlCatalogSummary(summary, error) { + summary.error = error; + + console.warn("GVL catalog normalization skipped", { + error: error, + snapshotSha256: summary.snapshotSha256, + vendorListVersion: summary.vendorListVersion + }); + + return summary; +} + +globalThis.normalizeGvlCatalogsFromSnapshot = normalizeGvlCatalogsFromSnapshot; +globalThis.normalizeLatestGvlCatalogs = normalizeLatestGvlCatalogs; diff --git a/src/background/gvl/gvl-vendor-normalizer.js b/src/background/gvl/gvl-vendor-normalizer.js new file mode 100644 index 0000000..366eb68 --- /dev/null +++ b/src/background/gvl/gvl-vendor-normalizer.js @@ -0,0 +1,288 @@ +"use strict"; + +async function normalizeGvlVendorsFromSnapshot(snapshot) { + const summary = { + ok: false, + vendorListVersion: snapshot?.vendorListVersion ?? null, + snapshotSha256: snapshot?.sha256 ?? null, + snapshotFetchedAt: snapshot?.fetchedAt ?? null, + gvlLastUpdated: null, + vendorsSeen: 0, + vendorsWritten: 0, + skipped: 0, + error: null + }; + + try { + if (!snapshot) { + return warnAndReturnGvlVendorNormalizerSummary( + summary, + "missing_snapshot" + ); + } + + const gvl = parseGvlSnapshotRawJson(snapshot.rawJson); + + if (!gvl) { + return warnAndReturnGvlVendorNormalizerSummary( + summary, + "invalid_snapshot_raw_json" + ); + } + + const vendorListVersion = + gvl.vendorListVersion ?? snapshot.vendorListVersion ?? null; + const vendors = gvl.vendors; + const normalizedAt = new Date().toISOString(); + + summary.vendorListVersion = vendorListVersion; + summary.gvlLastUpdated = gvl.lastUpdated ?? null; + + if (vendorListVersion === null || vendorListVersion === undefined) { + return warnAndReturnGvlVendorNormalizerSummary( + summary, + "missing_vendor_list_version" + ); + } + + if (!vendors || typeof vendors !== "object" || Array.isArray(vendors)) { + return warnAndReturnGvlVendorNormalizerSummary(summary, "missing_vendors"); + } + + const vendorRecords = Object.entries(vendors).reduce( + (records, [vendorKey, vendor]) => { + summary.vendorsSeen += 1; + + if (!vendor || typeof vendor !== "object" || Array.isArray(vendor)) { + summary.skipped += 1; + return records; + } + + const vendorId = vendor.id ?? parseNumericVendorId(vendorKey); + + if (vendorId === null || vendorId === undefined) { + summary.skipped += 1; + return records; + } + + records.push( + buildGvlVendorRecord({ + vendor: vendor, + vendorId: vendorId, + vendorListVersion: vendorListVersion, + snapshot: snapshot, + gvl: gvl, + normalizedAt: normalizedAt + }) + ); + + return records; + }, + [] + ); + + summary.vendorsWritten = await putGvlVendorRecords(vendorRecords); + summary.ok = true; + + return summary; + } catch (error) { + summary.error = error?.message ?? String(error); + console.warn("GVL vendor normalization failed", { + error: error, + snapshotSha256: summary.snapshotSha256, + vendorListVersion: summary.vendorListVersion + }); + + return summary; + } +} + +async function normalizeLatestGvlSnapshotVendors() { + const summary = { + ok: false, + vendorListVersion: null, + snapshotSha256: null, + snapshotFetchedAt: null, + gvlLastUpdated: null, + vendorsSeen: 0, + vendorsWritten: 0, + skipped: 0, + error: null + }; + + try { + const snapshot = await getLatestGvlSnapshot(); + + if (!snapshot) { + return warnAndReturnGvlVendorNormalizerSummary( + summary, + "no_gvl_snapshots_found" + ); + } + + return normalizeGvlVendorsFromSnapshot(snapshot); + } catch (error) { + summary.error = error?.message ?? String(error); + console.warn("Latest GVL vendor normalization failed", error); + + return summary; + } +} + +function parseGvlSnapshotRawJson(rawJson) { + if (!rawJson) { + return null; + } + + if (typeof rawJson === "string") { + try { + return JSON.parse(rawJson); + } catch (error) { + console.warn("GVL snapshot rawJson is not valid JSON", error); + return null; + } + } + + if (typeof rawJson === "object" && !Array.isArray(rawJson)) { + return rawJson; + } + + return null; +} + +function buildGvlVendorRecord({ + vendor, + vendorId, + vendorListVersion, + snapshot, + gvl, + normalizedAt +}) { + return { + id: `${vendorListVersion}:vendor:${vendorId}`, + entityType: "vendor", + vendorListVersion: vendorListVersion, + vendorId: vendorId, + snapshotSha256: snapshot.sha256, + snapshotFetchedAt: snapshot.fetchedAt ?? null, + gvlLastUpdated: gvl.lastUpdated ?? null, + tcfPolicyVersion: gvl.tcfPolicyVersion ?? null, + gvlSpecificationVersion: gvl.gvlSpecificationVersion ?? null, + normalizedAt: normalizedAt, + name: vendor.name ?? null, + policyUrl: vendor.policyUrl ?? null, + deletedDate: vendor.deletedDate ?? null, + cookieMaxAgeSeconds: vendor.cookieMaxAgeSeconds ?? null, + usesCookies: vendor.usesCookies ?? null, + usesNonCookieAccess: vendor.usesNonCookieAccess ?? null, + deviceStorageDisclosureUrl: vendor.deviceStorageDisclosureUrl ?? null, + legitimateInterestDisclosureUrl: + vendor.legitimateInterestDisclosureUrl ?? null, + domains: vendor.domains ?? null, + rawVendor: vendor + }; +} + +function parseNumericVendorId(value) { + const vendorId = Number(value); + + if (!Number.isFinite(vendorId)) { + return null; + } + + return vendorId; +} + +async function putGvlVendorRecords(vendorRecords) { + if (!vendorRecords.length) { + return 0; + } + + const db = await openVendorGetDb(); + + if (!db.objectStoreNames.contains(VENDORGET_STORE_NAMES.gvlVendors)) { + throw new Error("missing_gvl_vendors_store"); + } + + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlVendors], "readwrite"); + const vendorsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlVendors); + let written = 0; + + vendorRecords.forEach((record) => { + const putRequest = vendorsStore.put(record); + + putRequest.onsuccess = () => { + written += 1; + }; + }); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(written); + }); +} + +async function getLatestGvlSnapshot() { + const db = await openVendorGetDb(); + + if (!db.objectStoreNames.contains(VENDORGET_STORE_NAMES.gvlSnapshots)) { + throw new Error("missing_gvl_snapshots_store"); + } + + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const getAllRequest = snapshotsStore.getAll(); + + getAllRequest.onerror = () => reject(getAllRequest.error); + getAllRequest.onsuccess = () => { + const snapshots = getAllRequest.result ?? []; + + resolve(sortGvlSnapshotsNewestFirst(snapshots)[0] ?? null); + }; + }); +} + +function sortGvlSnapshotsNewestFirst(snapshots) { + return snapshots.slice().sort((left, right) => { + const fetchedAtComparison = + toComparableTime(right.fetchedAt) - toComparableTime(left.fetchedAt); + + if (fetchedAtComparison !== 0) { + return fetchedAtComparison; + } + + return ( + toComparableNumber(right.vendorListVersion) - + toComparableNumber(left.vendorListVersion) + ); + }); +} + +function toComparableTime(value) { + const timestamp = Date.parse(value ?? ""); + + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function toComparableNumber(value) { + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : 0; +} + +function warnAndReturnGvlVendorNormalizerSummary(summary, error) { + summary.error = error; + + console.warn("GVL vendor normalization skipped", { + error: error, + snapshotSha256: summary.snapshotSha256, + vendorListVersion: summary.vendorListVersion + }); + + return summary; +} + +globalThis.normalizeGvlVendorsFromSnapshot = normalizeGvlVendorsFromSnapshot; +globalThis.normalizeLatestGvlSnapshotVendors = normalizeLatestGvlSnapshotVendors; diff --git a/src/background/gvl/gvl-vendor-relationship-normalizer.js b/src/background/gvl/gvl-vendor-relationship-normalizer.js new file mode 100644 index 0000000..2bd9502 --- /dev/null +++ b/src/background/gvl/gvl-vendor-relationship-normalizer.js @@ -0,0 +1,350 @@ +"use strict"; + +async function normalizeGvlVendorRelationshipsFromSnapshot(snapshot) { + const summary = { + ok: false, + vendorListVersion: snapshot?.vendorListVersion ?? null, + snapshotSha256: snapshot?.sha256 ?? null, + snapshotFetchedAt: snapshot?.fetchedAt ?? null, + gvlLastUpdated: null, + vendorsSeen: 0, + relationshipsSeen: 0, + relationshipsWritten: 0, + skipped: 0, + error: null + }; + + try { + if (!snapshot) { + return warnAndReturnGvlVendorRelationshipSummary( + summary, + "missing_snapshot" + ); + } + + const gvl = parseGvlVendorRelationshipRawJson(snapshot.rawJson); + + if (!gvl) { + return warnAndReturnGvlVendorRelationshipSummary( + summary, + "invalid_snapshot_raw_json" + ); + } + + const vendorListVersion = + gvl.vendorListVersion ?? snapshot.vendorListVersion ?? null; + const vendors = gvl.vendors; + const normalizedAt = new Date().toISOString(); + + summary.vendorListVersion = vendorListVersion; + summary.gvlLastUpdated = gvl.lastUpdated ?? null; + + if (vendorListVersion === null || vendorListVersion === undefined) { + return warnAndReturnGvlVendorRelationshipSummary( + summary, + "missing_vendor_list_version" + ); + } + + if (!vendors || typeof vendors !== "object" || Array.isArray(vendors)) { + return warnAndReturnGvlVendorRelationshipSummary( + summary, + "missing_vendors" + ); + } + + const relationshipRecords = Object.entries(vendors).reduce( + (records, [vendorKey, vendor]) => { + summary.vendorsSeen += 1; + + if (!vendor || typeof vendor !== "object" || Array.isArray(vendor)) { + summary.skipped += 1; + return records; + } + + const vendorId = + vendor.id ?? parseGvlVendorRelationshipNumericId(vendorKey); + + if (vendorId === null || vendorId === undefined) { + summary.skipped += 1; + return records; + } + + getGvlVendorRelationshipDefinitions().forEach((definition) => { + const relatedIds = vendor[definition.vendorField]; + + if (relatedIds === undefined || relatedIds === null) { + return; + } + + if (!Array.isArray(relatedIds)) { + summary.skipped += 1; + return; + } + + relatedIds.forEach((relatedId) => { + if (relatedId === null || relatedId === undefined) { + summary.skipped += 1; + return; + } + + summary.relationshipsSeen += 1; + records.push( + buildGvlVendorRelationshipRecord({ + vendorListVersion: vendorListVersion, + vendorId: vendorId, + relationshipType: definition.relationshipType, + relatedId: relatedId, + snapshot: snapshot, + gvl: gvl, + normalizedAt: normalizedAt + }) + ); + }); + }); + + return records; + }, + [] + ); + + summary.relationshipsWritten = await putGvlVendorRelationshipRecords( + relationshipRecords + ); + summary.ok = true; + + return summary; + } catch (error) { + summary.error = error?.message ?? String(error); + console.warn("GVL vendor relationship normalization failed", { + error: error, + snapshotSha256: summary.snapshotSha256, + vendorListVersion: summary.vendorListVersion + }); + + return summary; + } +} + +async function normalizeLatestGvlVendorRelationships() { + const summary = { + ok: false, + vendorListVersion: null, + snapshotSha256: null, + snapshotFetchedAt: null, + gvlLastUpdated: null, + vendorsSeen: 0, + relationshipsSeen: 0, + relationshipsWritten: 0, + skipped: 0, + error: null + }; + + try { + const snapshot = await getLatestGvlVendorRelationshipSnapshot(); + + if (!snapshot) { + return warnAndReturnGvlVendorRelationshipSummary( + summary, + "no_gvl_snapshots_found" + ); + } + + return normalizeGvlVendorRelationshipsFromSnapshot(snapshot); + } catch (error) { + summary.error = error?.message ?? String(error); + console.warn("Latest GVL vendor relationship normalization failed", error); + + return summary; + } +} + +function parseGvlVendorRelationshipRawJson(rawJson) { + if (!rawJson) { + return null; + } + + if (typeof rawJson === "string") { + try { + return JSON.parse(rawJson); + } catch (error) { + console.warn("GVL snapshot rawJson is not valid JSON", error); + return null; + } + } + + if (typeof rawJson === "object" && !Array.isArray(rawJson)) { + return rawJson; + } + + return null; +} + +function getGvlVendorRelationshipDefinitions() { + return [ + { + vendorField: "purposes", + relationshipType: "purpose" + }, + { + vendorField: "legIntPurposes", + relationshipType: "legIntPurpose" + }, + { + vendorField: "flexiblePurposes", + relationshipType: "flexiblePurpose" + }, + { + vendorField: "specialPurposes", + relationshipType: "specialPurpose" + }, + { + vendorField: "features", + relationshipType: "feature" + }, + { + vendorField: "specialFeatures", + relationshipType: "specialFeature" + } + ]; +} + +function buildGvlVendorRelationshipRecord({ + vendorListVersion, + vendorId, + relationshipType, + relatedId, + snapshot, + gvl, + normalizedAt +}) { + return { + id: `${vendorListVersion}:vendor:${vendorId}:${relationshipType}:${relatedId}`, + vendorListVersion: vendorListVersion, + vendorId: vendorId, + relationshipType: relationshipType, + relatedId: relatedId, + snapshotSha256: snapshot.sha256, + snapshotFetchedAt: snapshot.fetchedAt ?? null, + gvlLastUpdated: gvl.lastUpdated ?? null, + normalizedAt: normalizedAt + }; +} + +function parseGvlVendorRelationshipNumericId(value) { + const numericId = Number(value); + + if (!Number.isFinite(numericId)) { + return null; + } + + return numericId; +} + +async function putGvlVendorRelationshipRecords(relationshipRecords) { + if (!relationshipRecords.length) { + return 0; + } + + const db = await openVendorGetDb(); + + if ( + !db.objectStoreNames.contains( + VENDORGET_STORE_NAMES.gvlVendorRelationships + ) + ) { + throw new Error("missing_gvl_vendor_relationships_store"); + } + + return new Promise((resolve, reject) => { + const tx = db.transaction( + [VENDORGET_STORE_NAMES.gvlVendorRelationships], + "readwrite" + ); + const relationshipsStore = tx.objectStore( + VENDORGET_STORE_NAMES.gvlVendorRelationships + ); + let written = 0; + + relationshipRecords.forEach((record) => { + const putRequest = relationshipsStore.put(record); + + putRequest.onsuccess = () => { + written += 1; + }; + }); + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(written); + }); +} + +async function getLatestGvlVendorRelationshipSnapshot() { + const db = await openVendorGetDb(); + + if (!db.objectStoreNames.contains(VENDORGET_STORE_NAMES.gvlSnapshots)) { + throw new Error("missing_gvl_snapshots_store"); + } + + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlSnapshots], "readonly"); + const snapshotsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlSnapshots); + const getAllRequest = snapshotsStore.getAll(); + + getAllRequest.onerror = () => reject(getAllRequest.error); + getAllRequest.onsuccess = () => { + const snapshots = getAllRequest.result ?? []; + + resolve( + sortGvlVendorRelationshipSnapshotsNewestFirst(snapshots)[0] ?? null + ); + }; + }); +} + +function sortGvlVendorRelationshipSnapshotsNewestFirst(snapshots) { + return snapshots.slice().sort((left, right) => { + const fetchedAtComparison = + toGvlVendorRelationshipComparableTime(right.fetchedAt) - + toGvlVendorRelationshipComparableTime(left.fetchedAt); + + if (fetchedAtComparison !== 0) { + return fetchedAtComparison; + } + + return ( + toGvlVendorRelationshipComparableNumber(right.vendorListVersion) - + toGvlVendorRelationshipComparableNumber(left.vendorListVersion) + ); + }); +} + +function toGvlVendorRelationshipComparableTime(value) { + const timestamp = Date.parse(value ?? ""); + + return Number.isNaN(timestamp) ? 0 : timestamp; +} + +function toGvlVendorRelationshipComparableNumber(value) { + const numberValue = Number(value); + + return Number.isFinite(numberValue) ? numberValue : 0; +} + +function warnAndReturnGvlVendorRelationshipSummary(summary, error) { + summary.error = error; + + console.warn("GVL vendor relationship normalization skipped", { + error: error, + snapshotSha256: summary.snapshotSha256, + vendorListVersion: summary.vendorListVersion + }); + + return summary; +} + +globalThis.normalizeGvlVendorRelationshipsFromSnapshot = + normalizeGvlVendorRelationshipsFromSnapshot; +globalThis.normalizeLatestGvlVendorRelationships = + normalizeLatestGvlVendorRelationships; diff --git a/src/background/maintenance-guard.js b/src/background/maintenance-guard.js new file mode 100644 index 0000000..74ceafb --- /dev/null +++ b/src/background/maintenance-guard.js @@ -0,0 +1,88 @@ +"use strict"; + +const EVIDENCE_MAINTENANCE_TTL_MS = 15 * 1000; + +let evidenceMaintenanceSession = null; + +function startEvidenceMaintenanceSession(source) { + evidenceMaintenanceSession = buildEvidenceMaintenanceSession(source); + + return getEvidenceMaintenanceStatus(); +} + +function refreshEvidenceMaintenanceSession(source) { + evidenceMaintenanceSession = buildEvidenceMaintenanceSession(source); + + return getEvidenceMaintenanceStatus(); +} + +function endEvidenceMaintenanceSession(source) { + cleanupExpiredEvidenceMaintenanceSession(); + + if ( + evidenceMaintenanceSession && + evidenceMaintenanceSession.source === normalizeMaintenanceSource(source) + ) { + evidenceMaintenanceSession = null; + } + + return getEvidenceMaintenanceStatus(); +} + +function isEvidenceWriteSuspended() { + cleanupExpiredEvidenceMaintenanceSession(); + + return evidenceMaintenanceSession !== null; +} + +function getEvidenceMaintenanceStatus() { + cleanupExpiredEvidenceMaintenanceSession(); + + if (!evidenceMaintenanceSession) { + return { + success: true, + evidenceWriteSuspended: false, + source: null, + lastHeartbeatAt: null, + expiresAt: null + }; + } + + return { + success: true, + evidenceWriteSuspended: true, + source: evidenceMaintenanceSession.source, + lastHeartbeatAt: evidenceMaintenanceSession.lastHeartbeatAt, + expiresAt: evidenceMaintenanceSession.expiresAt + }; +} + +function buildEvidenceMaintenanceSession(source) { + const now = Date.now(); + const lastHeartbeatAt = new Date(now).toISOString(); + const expiresAt = new Date(now + EVIDENCE_MAINTENANCE_TTL_MS).toISOString(); + + return { + source: normalizeMaintenanceSource(source), + lastHeartbeatAt: lastHeartbeatAt, + expiresAt: expiresAt + }; +} + +function cleanupExpiredEvidenceMaintenanceSession() { + if (!evidenceMaintenanceSession) { + return; + } + + const expiresAt = Date.parse(evidenceMaintenanceSession.expiresAt); + + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + evidenceMaintenanceSession = null; + } +} + +function normalizeMaintenanceSource(source) { + return typeof source === "string" && source.trim() + ? source.trim() + : "unknown"; +} diff --git a/src/background/request-fingerprint.js b/src/background/request-fingerprint.js new file mode 100644 index 0000000..8dd62e9 --- /dev/null +++ b/src/background/request-fingerprint.js @@ -0,0 +1,59 @@ +const VOLATILE_REQUEST_QUERY_PARAMS = new Set([ + "ts", + "timestamp", + "_", + "cb", + "cachebuster", + "rnd", + "random", + "sid", + "session", + "sessionid", + "rid", + "reqid", + "auctionId", + "bidId", + "impId", + "correlator", + "ord" +]); + +function buildObservedRequestFingerprintSource(details) { + const url = new URL(details.url); + const stableQueryParams = {}; + + Array.from(url.searchParams.keys()) + .sort() + .forEach((key) => { + if ( + VOLATILE_REQUEST_QUERY_PARAMS.has(key) || + key === "gdpr" || + key === "gdpr_consent" || + key === "addtlConsent" + ) { + return; + } + + stableQueryParams[key] = url.searchParams.getAll(key).sort(); + }); + + return { + origin: url.origin, + pathname: url.pathname, + method: details.method, + type: details.type, + thirdParty: details.thirdParty, + consentParams: { + gdpr: url.searchParams.get("gdpr"), + gdpr_consent: url.searchParams.get("gdpr_consent"), + addtlConsent: url.searchParams.get("addtlConsent") + }, + stableQueryParams: stableQueryParams + }; +} + +async function buildObservedRequestFingerprint(details) { + return sha256Hex( + stableStringify(buildObservedRequestFingerprintSource(details)) + ); +} diff --git a/src/background/request-observer.js b/src/background/request-observer.js new file mode 100644 index 0000000..99e4a14 --- /dev/null +++ b/src/background/request-observer.js @@ -0,0 +1,150 @@ +const REQUEST_DEDUPE_WINDOW_MS = 60 * 1000; +const REQUEST_CORRELATION_WINDOW_MS = 5 * 60 * 1000; +const OBSERVED_REQUEST_RECORDING_SOURCE = "vendorget_background_mirror"; +const observedRequestFingerprints = new Map(); + +async function handleObservedRequest(details) { + if (!(await isRequestMonitoringEnabled())) { + return; + } + + if (!hasConsentQueryParam(details.url)) { + return; + } + + if (isEvidenceWriteSuspended()) { + console.info("VendorGet-IV evidence write skipped: maintenance mode"); + return; + } + + // The browser request is the primary observation; VG-IV records a mirror copy + // with its own recordedAt timestamp for provenance. + const requestFingerprint = await buildObservedRequestFingerprint(details); + const requestFingerprintSource = buildObservedRequestFingerprintSource(details); + const observedAt = new Date(details.timeStamp).toISOString(); + const recordedAt = new Date().toISOString(); + const correlation = buildObservedRequestCorrelation( + details.tabId, + details.frameId, + observedAt + ); + const now = Date.now(); + const existingFingerprint = observedRequestFingerprints.get(requestFingerprint); + const observedRequest = { + schemaVersion: 1, + requestFingerprint: requestFingerprint, + requestFingerprintSource: requestFingerprintSource, + recordedAt: recordedAt, + recordingSource: OBSERVED_REQUEST_RECORDING_SOURCE, + firstSeenAt: observedAt, + lastSeenAt: observedAt, + seenCount: 1, + request: { + url: details.url, + origin: requestFingerprintSource.origin, + pathname: requestFingerprintSource.pathname, + method: details.method, + type: details.type, + thirdParty: details.thirdParty + }, + consentParams: { + gdpr: requestFingerprintSource.consentParams.gdpr, + gdpr_consent: requestFingerprintSource.consentParams.gdpr_consent, + addtlConsent: requestFingerprintSource.consentParams.addtlConsent + }, + context: { + tabId: details.tabId, + frameId: details.frameId + }, + correlation: correlation + }; + + if ( + existingFingerprint && + now - existingFingerprint.lastSeenAt < REQUEST_DEDUPE_WINDOW_MS + ) { + existingFingerprint.lastSeenAt = now; + existingFingerprint.seenCount += 1; + await persistObservedRequest(observedRequest); + return; + } + + const seenCount = existingFingerprint + ? existingFingerprint.seenCount + 1 + : 1; + + observedRequestFingerprints.set(requestFingerprint, { + lastSeenAt: now, + seenCount: seenCount + }); + + await persistObservedRequest(observedRequest); + + const normalizedObject = { + observedAt: observedAt, + recordedAt: recordedAt, + requestId: details.requestId, + tabId: details.tabId, + frameId: details.frameId, + url: details.url, + method: details.method, + type: details.type, + thirdParty: details.thirdParty, + requestFingerprint: requestFingerprint, + seenCount: seenCount, + requestFingerprintSource: requestFingerprintSource + }; + + console.log("VendorGet-IV observed request", normalizedObject); +} + +function hasConsentQueryParam(url) { + try { + const params = new URL(url).searchParams; + + return ( + params.has("gdpr") || + params.has("gdpr_consent") || + params.has("addtlConsent") + ); + } catch (error) { + return false; + } +} + +function buildObservedRequestCorrelation(tabId, frameId, observedAt) { + const latestConsentState = getLatestConsentStateForRequest(tabId, frameId); + + if (!latestConsentState) { + return buildEmptyObservedRequestCorrelation(); + } + + const requestTime = Date.parse(observedAt); + const consentTime = Date.parse(latestConsentState.capturedAt); + + if (!Number.isFinite(requestTime) || !Number.isFinite(consentTime)) { + return buildEmptyObservedRequestCorrelation(); + } + + const deltaMs = requestTime - consentTime; + + if (deltaMs < 0 || deltaMs > REQUEST_CORRELATION_WINDOW_MS) { + return buildEmptyObservedRequestCorrelation(); + } + + return { + stateFingerprint: latestConsentState.stateFingerprint, + deltaMs: deltaMs, + method: "latest_in_memory_same_tab_frame", + windowMs: REQUEST_CORRELATION_WINDOW_MS + }; +} + +function buildEmptyObservedRequestCorrelation() { + return { + stateFingerprint: null, + deltaMs: null, + method: null, + windowMs: REQUEST_CORRELATION_WINDOW_MS + }; +} diff --git a/src/background/settings.js b/src/background/settings.js new file mode 100644 index 0000000..6cdc850 --- /dev/null +++ b/src/background/settings.js @@ -0,0 +1,36 @@ +const DEFAULT_VENDORGET_SETTINGS = { + consentCaptureEnabled: true, + requestMonitoringEnabled: false +}; + +const VENDORGET_SETTINGS_STORAGE_KEY = "vendorgetSettings"; + +async function getVendorGetSettings() { + const storedSettings = await browser.storage.local.get( + VENDORGET_SETTINGS_STORAGE_KEY + ); + + return { + ...DEFAULT_VENDORGET_SETTINGS, + ...(storedSettings[VENDORGET_SETTINGS_STORAGE_KEY] ?? {}) + }; +} + +async function setVendorGetSetting(key, value) { + const settings = await getVendorGetSettings(); + + await browser.storage.local.set({ + [VENDORGET_SETTINGS_STORAGE_KEY]: { + ...settings, + [key]: value + } + }); +} + +async function isConsentCaptureEnabled() { + return (await getVendorGetSettings()).consentCaptureEnabled; +} + +async function isRequestMonitoringEnabled() { + return (await getVendorGetSettings()).requestMonitoringEnabled; +} diff --git a/src/background/utils.js b/src/background/utils.js new file mode 100644 index 0000000..91f6497 --- /dev/null +++ b/src/background/utils.js @@ -0,0 +1,30 @@ +async function sha256Hex(input) { + const data = new TextEncoder().encode(input); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + + return hashArray + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +function stableStringify(value) { + return JSON.stringify(sortObjectKeys(value)); +} + +function sortObjectKeys(value) { + if (Array.isArray(value)) { + return value.map(sortObjectKeys); + } + + if (value && typeof value === "object") { + return Object.keys(value) + .sort() + .reduce((result, key) => { + result[key] = sortObjectKeys(value[key]); + return result; + }, {}); + } + + return value; +} diff --git a/src/content/tcf-listener.js b/src/content/tcf-listener.js new file mode 100644 index 0000000..bbba45f --- /dev/null +++ b/src/content/tcf-listener.js @@ -0,0 +1,20 @@ +console.log("VendorGet content listener loaded:", window.location.href); + +window.addEventListener("VendorGetFromPage", async (event) => { + + console.log("VendorGet message from page:", event.detail); + + await browser.runtime.sendMessage({ + type: "vendorget_capture", + payload: event.detail + }); + +}); + +const script = document.createElement("script"); + +script.src = browser.runtime.getURL("src/injected/tcf-bridge.js"); + +script.onload = () => script.remove(); + +(document.documentElement || document.head || document.body).appendChild(script); diff --git a/src/dashboard/dashboard.css b/src/dashboard/dashboard.css new file mode 100644 index 0000000..92ad67e --- /dev/null +++ b/src/dashboard/dashboard.css @@ -0,0 +1,208 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; + font-family: Arial, sans-serif; + color: #e5edf5; + background: #111827; +} + +.dashboard { + width: min(1040px, 100%); + margin: 0 auto; + padding: 24px; +} + +.dashboard-header { + display: grid; + gap: 8px; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #334155; +} + +h1, +h2, +p { + margin: 0; +} + +h1 { + font-size: 24px; + font-weight: 700; +} + +h2 { + margin-bottom: 12px; + font-size: 15px; + font-weight: 700; +} + +.dashboard-status { + min-height: 18px; + font-size: 13px; + color: #cbd5e1; +} + +.dashboard-notice { + max-width: 760px; + padding: 10px 12px; + border: 1px solid #334155; + border-radius: 4px; + background: #182231; +} + +.maintenance-status { + display: grid; + gap: 10px; + max-width: 760px; + padding: 12px; + border: 1px solid #3f6f56; + border-radius: 4px; + background: #14251d; +} + +.maintenance-status strong { + font-size: 14px; + color: #bbf7d0; +} + +.maintenance-status dl { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.maintenance-status dt { + margin: 0 0 4px; + font-size: 11px; + color: #cbd5e1; +} + +.maintenance-status dd { + margin: 0; + font-size: 12px; + font-weight: 700; + word-break: break-word; +} + +.panel { + margin-bottom: 22px; + padding-bottom: 20px; + border-bottom: 1px solid #334155; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin: 0; +} + +.metric-grid div { + min-width: 0; + padding: 12px; + border: 1px solid #334155; + border-radius: 4px; + background: #1f2937; +} + +.metric-grid dt { + margin: 0 0 8px; + font-size: 12px; + color: #cbd5e1; +} + +.metric-grid dd { + margin: 0; + font-size: 24px; + font-weight: 700; + word-break: break-word; +} + +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + background: #1f2937; +} + +th, +td { + padding: 10px 12px; + border: 1px solid #334155; + text-align: left; +} + +th { + color: #cbd5e1; + font-weight: 700; + background: #182231; +} + +td:last-child, +th:last-child { + width: 140px; + text-align: right; +} + +p { + max-width: 720px; + font-size: 13px; + line-height: 1.5; + color: #cbd5e1; +} + +.retention-actions, +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 14px; +} + +.admin-status { + min-height: 18px; + margin-top: 12px; + font-size: 13px; + line-height: 1.4; + color: #cbd5e1; +} + +button { + padding: 8px 10px; + border: 1px solid #475569; + border-radius: 4px; + font: inherit; + font-size: 13px; + color: #e5edf5; + background: #1f2937; +} + +button:disabled { + cursor: default; + opacity: 0.65; +} + +@media (max-width: 640px) { + .dashboard { + padding: 16px; + } + + .metric-grid { + grid-template-columns: 1fr; + } + + .maintenance-status dl { + grid-template-columns: 1fr 1fr; + } + + .retention-actions, + .admin-actions { + display: grid; + } +} diff --git a/src/dashboard/dashboard.html b/src/dashboard/dashboard.html new file mode 100644 index 0000000..c4b8947 --- /dev/null +++ b/src/dashboard/dashboard.html @@ -0,0 +1,133 @@ + + + + + + VendorGet-IV Evidence Dashboard + + + +
+
+

VendorGet-IV Evidence Dashboard

+
+ Loading evidence status +
+

+ Verwaltungsbereich: Lesen und manuelle Aktionen bleiben verfügbar. + VG-IV dokumentiert browserseitige Consent-/TCF-Zustände als + evidenzielle Spiegelung. +

+
+ Verwaltungsmodus aktiv: Hintergrundaufzeichnung ist pausiert. +
+
+
Write Suspend
+
-
+
+
+
Quelle
+
-
+
+
+
Heartbeat
+
-
+
+
+
Ablauf
+
-
+
+
+
+
+ +
+

Overview

+
+
+
Total Evidence Records
+
-
+
+
+
Locked Records
+
-
+
+
+
Unlocked Records
+
-
+
+
+
+ +
+

Evidence Stores

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
StoreRecords
Consent States
Consent Events
Observed Requests-
GVL Snapshots-
GVL Snapshot Events-
+
+ +
+

Retention Status

+

+ Locked records are protected from partial purge. Full deletion still + requires explicit confirmation. +

+
+ + +
+
+ +
+

Administration

+
+ + + +
+
+ Bereit +
+
+
+ + + + diff --git a/src/dashboard/dashboard.js b/src/dashboard/dashboard.js new file mode 100644 index 0000000..38c9a36 --- /dev/null +++ b/src/dashboard/dashboard.js @@ -0,0 +1,436 @@ +"use strict"; + +const EVIDENCE_MAINTENANCE_SOURCE = "dashboard"; +const EVIDENCE_MAINTENANCE_HEARTBEAT_MS = 5 * 1000; + +const dashboardStatus = document.getElementById("dashboard-status"); +const totalCount = document.getElementById("total-count"); +const lockedCount = document.getElementById("locked-count"); +const unlockedCount = document.getElementById("unlocked-count"); +const maintenanceWriteSuspend = document.getElementById( + "maintenance-write-suspend" +); +const maintenanceSource = document.getElementById("maintenance-source"); +const maintenanceHeartbeat = document.getElementById("maintenance-heartbeat"); +const maintenanceExpires = document.getElementById("maintenance-expires"); +const lockAllButton = document.getElementById("lock-all-button"); +const unlockAllButton = document.getElementById("unlock-all-button"); +const gvlFetchOfficialButton = document.getElementById( + "gvl-fetch-official-button" +); +const gvlImportButton = document.getElementById("gvl-import-button"); +const evidenceDeleteButton = document.getElementById("evidence-delete-button"); +const adminStatus = document.getElementById("admin-status"); +const gvlImportFileInput = document.createElement("input"); + +gvlImportFileInput.type = "file"; +gvlImportFileInput.accept = ".json,application/json"; +gvlImportFileInput.hidden = true; + +let evidenceMaintenanceHeartbeatId = null; + +const storeCells = { + consent_states: document.getElementById("store-consent-states"), + consent_events: document.getElementById("store-consent-events"), + observed_requests: document.getElementById("store-observed-requests"), + gvl_snapshots: document.getElementById("store-gvl-snapshots"), + gvl_snapshot_events: document.getElementById("store-gvl-snapshot-events") +}; + +document.addEventListener("DOMContentLoaded", async () => { + document.body.appendChild(gvlImportFileInput); + + await startEvidenceMaintenanceMode(); + evidenceMaintenanceHeartbeatId = setInterval(() => { + void refreshEvidenceMaintenanceMode(); + }, EVIDENCE_MAINTENANCE_HEARTBEAT_MS); + + lockAllButton.addEventListener("click", async () => { + await handleLockAllClick(); + }); + + unlockAllButton.addEventListener("click", async () => { + await handleUnlockAllClick(); + }); + + gvlFetchOfficialButton.addEventListener("click", async () => { + await fetchOfficialGvl(); + }); + + gvlImportButton.addEventListener("click", () => { + gvlImportFileInput.value = ""; + gvlImportFileInput.click(); + }); + + gvlImportFileInput.addEventListener("change", async () => { + const file = gvlImportFileInput.files?.[0] ?? null; + + if (!file) { + return; + } + + await importGvlFile(file); + }); + + evidenceDeleteButton.addEventListener("click", async () => { + await handleEvidenceDeleteClick(); + }); + + await renderEvidenceStatus(); +}); + +window.addEventListener("beforeunload", () => { + endEvidenceMaintenanceMode(); +}); + +window.addEventListener("pagehide", () => { + endEvidenceMaintenanceMode(); +}); + +async function startEvidenceMaintenanceMode() { + try { + const status = await sendEvidenceMaintenanceMessage( + "start_evidence_maintenance_session" + ); + + renderEvidenceMaintenanceStatus(status); + } catch (error) { + renderEvidenceMaintenanceUnavailable(); + console.warn("VendorGet-IV maintenance start failed", error); + } +} + +async function refreshEvidenceMaintenanceMode() { + try { + const status = await sendEvidenceMaintenanceMessage( + "refresh_evidence_maintenance_session" + ); + + renderEvidenceMaintenanceStatus(status); + } catch (error) { + renderEvidenceMaintenanceUnavailable(); + console.warn("VendorGet-IV maintenance heartbeat failed", error); + } +} + +function endEvidenceMaintenanceMode() { + if (evidenceMaintenanceHeartbeatId !== null) { + clearInterval(evidenceMaintenanceHeartbeatId); + evidenceMaintenanceHeartbeatId = null; + } + + void sendEvidenceMaintenanceMessage("end_evidence_maintenance_session").catch( + (error) => { + console.warn("VendorGet-IV maintenance end failed", error); + } + ); +} + +async function sendEvidenceMaintenanceMessage(type) { + const status = await browser.runtime.sendMessage({ + type: type, + payload: { + source: EVIDENCE_MAINTENANCE_SOURCE + } + }); + + if (!status?.success) { + throw new Error(status?.error ?? `${type}_failed`); + } + + return status; +} + +function renderEvidenceMaintenanceStatus(status) { + maintenanceWriteSuspend.textContent = status.evidenceWriteSuspended + ? "aktiv" + : "inaktiv"; + maintenanceSource.textContent = status.source ?? "-"; + maintenanceHeartbeat.textContent = formatMaintenanceTimestamp( + status.lastHeartbeatAt + ); + maintenanceExpires.textContent = formatMaintenanceTimestamp(status.expiresAt); +} + +function renderEvidenceMaintenanceUnavailable() { + maintenanceWriteSuspend.textContent = "unbekannt"; + maintenanceSource.textContent = "-"; + maintenanceHeartbeat.textContent = "-"; + maintenanceExpires.textContent = "-"; +} + +async function renderEvidenceStatus() { + try { + const status = await browser.runtime.sendMessage({ + type: "get_evidence_retention_status" + }); + + if (!status?.success) { + throw new Error(status?.error ?? "get_evidence_retention_status_failed"); + } + + totalCount.textContent = String(status.totalCount); + lockedCount.textContent = String(status.lockedCount); + unlockedCount.textContent = String(status.unlockedCount); + + renderStoreCounts(status.storeCounts ?? {}); + renderStatusMessage("Evidence status loaded"); + } catch (error) { + renderStatusMessage("Evidence status could not be loaded"); + console.warn("VendorGet-IV dashboard status failed", error); + } +} + +function renderStoreCounts(storeCounts) { + for (const [storeName, cell] of Object.entries(storeCells)) { + cell.textContent = String(storeCounts[storeName] ?? 0); + } +} + +async function fetchOfficialGvl() { + gvlFetchOfficialButton.disabled = true; + renderAdminStatus("Fetching official IAB GVL..."); + + try { + const result = await browser.runtime.sendMessage({ + type: "fetch_official_gvl" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "official_gvl_fetch_failed"); + } + + await renderEvidenceStatus(); + renderAdminStatus( + "Fetched successfully - " + + `${result.alreadyKnown ? "already known" : "newly stored"} - ` + + `vendorListVersion ${result.vendorListVersion ?? "n/a"} - ` + + `sha256 ${shortenSha256(result.sha256)}` + ); + } catch (error) { + renderAdminStatus("Official GVL fetch failed"); + console.warn("VendorGet-IV official GVL fetch failed", error); + } finally { + gvlFetchOfficialButton.disabled = false; + } +} + +async function importGvlFile(file) { + gvlImportButton.disabled = true; + renderAdminStatus("Import läuft..."); + + try { + const fileContent = await readFileAsText(file); + const rawJson = JSON.parse(fileContent); + + if (!isGvlImportCandidate(rawJson)) { + throw new Error("invalid_gvl_json"); + } + + const result = await browser.runtime.sendMessage({ + type: "gvl_import_json", + payload: { + rawJson: rawJson, + sourceUrl: "local-file-import" + } + }); + + if (!result?.success) { + throw new Error(result?.error ?? "gvl_import_failed"); + } + + await renderEvidenceStatus(); + renderAdminStatus( + `${result.alreadyKnown ? "already known" : "imported"} - ` + + `vendorListVersion ${result.vendorListVersion ?? "n/a"} - ` + + `sha256 ${shortenSha256(result.sha256)}` + ); + } catch (error) { + renderAdminStatus("Import fehlgeschlagen"); + console.warn("VendorGet-IV GVL import failed", error); + } finally { + gvlImportButton.disabled = false; + } +} + +function readFileAsText(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onerror = () => reject(reader.error); + reader.onload = () => resolve(reader.result); + reader.readAsText(file); + }); +} + +function isGvlImportCandidate(value) { + return ( + value && + typeof value === "object" && + !Array.isArray(value) && + value.vendorListVersion !== undefined && + value.vendors && + typeof value.vendors === "object" && + !Array.isArray(value.vendors) + ); +} + +async function handleEvidenceDeleteClick() { + evidenceDeleteButton.disabled = true; + + try { + const status = await getEvidenceRetentionStatus(); + + const confirmed = confirm( + "Alle lokal gespeicherten VG-IV-Evidenzdaten wirklich löschen?" + ); + + if (!confirmed) { + renderAdminStatus("Löschung abgebrochen"); + return; + } + + if (status.lockedCount === 0) { + await deleteAllEvidenceDatabase(); + await renderEvidenceStatus(); + renderAdminStatus("Evidenzdaten gelöscht"); + return; + } + + const deleteLockedRecords = confirm( + `Achtung: ${status.lockedCount} Datensätze wurden als ` + + "DSGVO-/DSAR-relevant markiert. Sollen auch diese Datensätze " + + "wirklich gelöscht werden?" + ); + + if (deleteLockedRecords) { + await deleteAllEvidenceDatabase(); + await renderEvidenceStatus(); + renderAdminStatus("Evidenzdaten gelöscht"); + return; + } + + const result = await browser.runtime.sendMessage({ + type: "purge_unlocked_evidence_records" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "purge_unlocked_evidence_records_failed"); + } + + await renderEvidenceStatus(); + renderAdminStatus( + `${result.deletedCount} Datensätze gelöscht, ` + + `${result.keptLockedCount} gesperrte Datensätze behalten` + ); + } catch (error) { + renderAdminStatus("Löschung fehlgeschlagen"); + console.warn("VendorGet-IV evidence delete failed", error); + } finally { + evidenceDeleteButton.disabled = false; + } +} + +async function getEvidenceRetentionStatus() { + const status = await browser.runtime.sendMessage({ + type: "get_evidence_retention_status" + }); + + if (!status?.success) { + throw new Error(status?.error ?? "get_evidence_retention_status_failed"); + } + + return status; +} + +async function deleteAllEvidenceDatabase() { + const result = await browser.runtime.sendMessage({ + type: "delete_all_evidence_database" + }); + + if (!result?.success) { + throw new Error(result?.error ?? "delete_all_evidence_database_failed"); + } +} + +async function handleLockAllClick() { + const confirmed = confirm( + "Alle vorhandenen VG-IV-Evidenzdatensätze als DSGVO-/DSAR-relevant markieren?" + ); + + if (!confirmed) { + renderStatusMessage("Record lock update cancelled"); + return; + } + + await runRecordLockAction({ + type: "lock_all_evidence_records", + payload: { + reason: "dsar_used", + note: null + } + }); +} + +async function handleUnlockAllClick() { + const confirmed = confirm( + "Alle VG-IV-Evidenzsperren wirklich entfernen?" + ); + + if (!confirmed) { + renderStatusMessage("Record lock update cancelled"); + return; + } + + await runRecordLockAction({ + type: "unlock_all_evidence_records" + }); +} + +async function runRecordLockAction(message) { + setRecordLockButtonsDisabled(true); + + try { + const result = await browser.runtime.sendMessage(message); + + if (!result?.success) { + throw new Error(result?.error ?? `${message.type}_failed`); + } + + await renderEvidenceStatus(); + } catch (error) { + renderStatusMessage("Record lock update failed"); + console.warn("VendorGet-IV dashboard record lock update failed", error); + } finally { + setRecordLockButtonsDisabled(false); + } +} + +function setRecordLockButtonsDisabled(disabled) { + lockAllButton.disabled = disabled; + unlockAllButton.disabled = disabled; +} + +function renderStatusMessage(message) { + dashboardStatus.textContent = message; +} + +function renderAdminStatus(message) { + adminStatus.textContent = message; +} + +function shortenSha256(value) { + if (!value) { + return "n/a"; + } + + return `${value.slice(0, 12)}...`; +} + +function formatMaintenanceTimestamp(value) { + if (!value) { + return "-"; + } + + return value; +} diff --git a/src/injected/tcf-bridge.js b/src/injected/tcf-bridge.js new file mode 100644 index 0000000..323392a --- /dev/null +++ b/src/injected/tcf-bridge.js @@ -0,0 +1,146 @@ +(function () { + + console.log("VendorGet injected bridge loaded:", window.location.href); + + if (typeof window.__tcfapi !== "function") { + console.log("VendorGet: __tcfapi not found"); + return; + } + + console.log("VendorGet: __tcfapi found"); + + function emitToContentScript(eventName, payload) { + window.dispatchEvent(new CustomEvent("VendorGetFromPage", { + detail: { + eventName: eventName, + payload: payload + } + })); + } + + function cloneSerializable(value, seen) { + if (value === undefined) { + return null; + } + + if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return value; + } + + if (typeof value === "function" || typeof value === "symbol") { + return undefined; + } + + if (typeof value !== "object") { + return undefined; + } + + const seenValues = seen || new WeakSet(); + + if (seenValues.has(value)) { + return "[Circular]"; + } + + seenValues.add(value); + + if (Array.isArray(value)) { + return value.map(function (item) { + const clonedItem = cloneSerializable(item, seenValues); + return clonedItem === undefined ? null : clonedItem; + }); + } + + if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) { + return undefined; + } + + const result = {}; + + Object.keys(value).forEach(function (key) { + let propertyValue; + + try { + propertyValue = value[key]; + } catch (error) { + return; + } + + const clonedValue = cloneSerializable(propertyValue, seenValues); + + if (clonedValue !== undefined) { + result[key] = clonedValue; + } + }); + + return result; + } + + window.__tcfapi("ping", 2, function (pingData, pingSuccess) { + + console.log("VendorGet __tcfapi ping:", { + success: pingSuccess, + data: pingData + }); + + emitToContentScript("tcf_ping", { + success: pingSuccess, + data: pingData + }); + + }); + + window.__tcfapi("addEventListener", 2, function (tcData, success) { + + if (!success || !tcData) { + return; + } + + console.log("VendorGet raw event:", tcData); + + if (tcData.eventStatus === "useractioncomplete") { + + const capture = { + url: window.location.href, + origin: window.location.origin, + + timestampUtc: new Date().toISOString(), + + cmpId: tcData.cmpId, + cmpVersion: tcData.cmpVersion, + + gdprApplies: tcData.gdprApplies, + + tcfPolicyVersion: tcData.tcfPolicyVersion, + vendorListVersion: tcData.vendorListVersion, + + tcString: tcData.tcString, + + eventStatus: tcData.eventStatus, + cmpStatus: tcData.cmpStatus, + + isServiceSpecific: tcData.isServiceSpecific, + useNonStandardTexts: tcData.useNonStandardTexts, + + publisherCC: tcData.publisherCC, + purposeOneTreatment: tcData.purposeOneTreatment, + + purpose: tcData.purpose, + vendor: tcData.vendor, + + specialFeatureOptins: tcData.specialFeatureOptins, + + publisher: tcData.publisher, + + addtlConsent: tcData.addtlConsent, + + rawTcData: cloneSerializable(tcData) + }; + + console.log("VendorGet CONSENT CAPTURE:", capture); + + emitToContentScript("consent_capture", capture); + } + + }); + +})(); diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..a9cd5d7 --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,177 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 280px; + font-family: Arial, sans-serif; + color: #e5edf5; + background: #111827; +} + +.popup { + padding: 14px; +} + +h1 { + margin: 0 0 12px; + font-size: 17px; + font-weight: 700; +} + +.status { + display: grid; + gap: 6px; + margin-bottom: 14px; +} + +.status-row { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 14px; + padding: 7px 0; + border-bottom: 1px solid #334155; + font-size: 13px; +} + +.toggle { + display: grid; + grid-template-columns: 42px 34px; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 700; +} + +.toggle input { + position: absolute; + width: 1px; + height: 1px; + margin: 0; + opacity: 0; +} + +.toggle span[id$="-status"] { + width: 42px; + text-align: right; +} + +.toggle-slider { + position: relative; + width: 34px; + height: 18px; + border-radius: 999px; + background: #475569; + transition: background 120ms ease; +} + +.toggle-slider::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #ffffff; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.25); + transition: transform 120ms ease; +} + +.toggle input:checked ~ .toggle-slider { + background: #22c55e; +} + +.toggle input:checked ~ .toggle-slider::before { + transform: translateX(16px); +} + +.toggle input:focus-visible ~ .toggle-slider { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.toggle input:disabled ~ .toggle-slider { + opacity: 0.65; +} + +.evidence-retention { + display: grid; + gap: 10px; +} + +.evidence-retention h2 { + margin: 0; + font-size: 13px; + font-weight: 700; +} + +.maintenance-warning { + padding: 8px 10px; + border: 1px solid #3f6f56; + border-radius: 4px; + font-size: 12px; + font-weight: 700; + line-height: 1.35; + color: #bbf7d0; + background: #14251d; +} + +.maintenance-warning[hidden] { + display: none; +} + +.evidence-counts { + display: grid; + gap: 6px; + margin: 0; +} + +.evidence-counts div { + display: grid; + grid-template-columns: 1fr auto; + align-items: baseline; + gap: 10px; + min-width: 0; +} + +.evidence-counts dt { + margin: 0; + font-size: 12px; + color: #cbd5e1; +} + +.evidence-counts dd { + margin: 0; + min-width: 28px; + font-size: 13px; + font-weight: 700; + text-align: right; + word-break: break-word; +} + +.retention-status { + min-height: 16px; + font-size: 12px; + line-height: 1.35; + color: #cbd5e1; + word-break: break-word; +} + +button { + width: 100%; + padding: 8px 10px; + border: 1px solid #475569; + border-radius: 4px; + font: inherit; + font-size: 13px; + color: #e5edf5; + background: #1f2937; +} + +button:disabled { + cursor: default; + opacity: 0.65; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..7509e8a --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,75 @@ + + + + + + VendorGet-IV + + + +
+

VendorGet-IV

+ +
+
+ Consent-Capture + +
+
+ Request-Monitoring + +
+
+ +
+

Evidenzdaten

+ +
+
+
Consent States
+ +
+
+
Consent Events
+ +
+
+
Recorded Observations
+
-
+
+
+
GVL Snapshots
+
-
+
+
+
GVL Events
+
-
+
+
+
Gesperrt (DSGVO)
+
-
+
+
+ +
+ Status wird geladen +
+
+
+ + + + + diff --git a/src/popup/popup.js b/src/popup/popup.js new file mode 100644 index 0000000..4eb8ffe --- /dev/null +++ b/src/popup/popup.js @@ -0,0 +1,136 @@ +"use strict"; + +const requestMonitoringToggle = document.getElementById( + "request-monitoring-toggle" +); +const requestMonitoringStatus = document.getElementById( + "request-monitoring-status" +); +const consentCaptureToggle = document.getElementById("consent-capture-toggle"); +const consentCaptureStatus = document.getElementById("consent-capture-status"); +const maintenanceWarning = document.getElementById("maintenance-warning"); +const evidenceLockedCount = document.getElementById("evidence-locked-count"); +const evidenceDashboardButton = document.getElementById( + "evidence-dashboard-button" +); +const evidenceRetentionStatus = document.getElementById( + "evidence-retention-status" +); + +const evidenceStoreCountCells = { + consent_states: document.getElementById("evidence-consent-states-count"), + consent_events: document.getElementById("evidence-consent-events-count"), + observed_requests: document.getElementById("evidence-observed-requests-count"), + gvl_snapshots: document.getElementById("evidence-gvl-snapshots-count"), + gvl_snapshot_events: document.getElementById( + "evidence-gvl-snapshot-events-count" + ) +}; + +document.addEventListener("DOMContentLoaded", async () => { + await renderSettings(); + await renderEvidenceMaintenanceStatus(); + await renderEvidenceRetentionStatus(); + + consentCaptureToggle.addEventListener("change", async () => { + consentCaptureToggle.disabled = true; + + await setVendorGetSetting( + "consentCaptureEnabled", + consentCaptureToggle.checked + ); + await renderSettings(); + + consentCaptureToggle.disabled = false; + }); + + requestMonitoringToggle.addEventListener("change", async () => { + requestMonitoringToggle.disabled = true; + + await setVendorGetSetting( + "requestMonitoringEnabled", + requestMonitoringToggle.checked + ); + await renderSettings(); + + requestMonitoringToggle.disabled = false; + }); + + evidenceDashboardButton.addEventListener("click", async () => { + await browser.tabs.create({ + url: browser.runtime.getURL("src/dashboard/dashboard.html") + }); + }); +}); + +async function renderSettings() { + const settings = await getVendorGetSettings(); + const consentCaptureEnabled = settings.consentCaptureEnabled; + const requestMonitoringEnabled = settings.requestMonitoringEnabled; + + consentCaptureToggle.checked = consentCaptureEnabled; + consentCaptureStatus.textContent = consentCaptureEnabled ? "aktiv" : "aus"; + + requestMonitoringToggle.checked = requestMonitoringEnabled; + requestMonitoringStatus.textContent = requestMonitoringEnabled + ? "aktiv" + : "aus"; +} + +async function renderEvidenceRetentionStatus() { + try { + const status = await getEvidenceRetentionStatus(); + + renderEvidenceStoreCounts(status.storeCounts ?? {}); + evidenceLockedCount.textContent = formatStatusValue(status.lockedCount); + renderEvidenceRetentionMessage("Bereit"); + } catch (error) { + renderEvidenceStoreCounts({}); + evidenceLockedCount.textContent = "-"; + renderEvidenceRetentionMessage("Status konnte nicht geladen werden"); + console.warn("VendorGet-IV evidence retention status failed", error); + } +} + +async function renderEvidenceMaintenanceStatus() { + try { + const status = await browser.runtime.sendMessage({ + type: "get_evidence_maintenance_status" + }); + + if (!status?.success) { + throw new Error(status?.error ?? "get_evidence_maintenance_status_failed"); + } + + maintenanceWarning.hidden = !status.evidenceWriteSuspended; + } catch (error) { + maintenanceWarning.hidden = true; + console.warn("VendorGet-IV maintenance status failed", error); + } +} + +async function getEvidenceRetentionStatus() { + const status = await browser.runtime.sendMessage({ + type: "get_evidence_retention_status" + }); + + if (!status?.success) { + throw new Error(status?.error ?? "get_evidence_retention_status_failed"); + } + + return status; +} + +function renderEvidenceStoreCounts(storeCounts) { + for (const [storeName, cell] of Object.entries(evidenceStoreCountCells)) { + cell.textContent = formatStatusValue(storeCounts[storeName]); + } +} + +function formatStatusValue(value) { + return Number.isFinite(value) ? String(value) : "-"; +} + +function renderEvidenceRetentionMessage(message) { + evidenceRetentionStatus.textContent = message; +} diff --git a/vendor-get-ERD.png b/vendor-get-ERD.png new file mode 100644 index 0000000..27fad2b Binary files /dev/null and b/vendor-get-ERD.png differ