Initialize VG-Environment from stable VG-IV baseline
Dieser Commit ist enthalten in:
@@ -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: ["<all_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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VendorGet-IV Evidence Dashboard</title>
|
||||
<link rel="stylesheet" href="dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="dashboard">
|
||||
<header class="dashboard-header">
|
||||
<h1>VendorGet-IV Evidence Dashboard</h1>
|
||||
<div id="dashboard-status" class="dashboard-status" aria-live="polite">
|
||||
Loading evidence status
|
||||
</div>
|
||||
<p class="dashboard-notice">
|
||||
Verwaltungsbereich: Lesen und manuelle Aktionen bleiben verfügbar.
|
||||
VG-IV dokumentiert browserseitige Consent-/TCF-Zustände als
|
||||
evidenzielle Spiegelung.
|
||||
</p>
|
||||
<section class="maintenance-status" aria-label="Verwaltungsmodus">
|
||||
<strong>Verwaltungsmodus aktiv: Hintergrundaufzeichnung ist pausiert.</strong>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>Write Suspend</dt>
|
||||
<dd id="maintenance-write-suspend">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Quelle</dt>
|
||||
<dd id="maintenance-source">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Heartbeat</dt>
|
||||
<dd id="maintenance-heartbeat">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ablauf</dt>
|
||||
<dd id="maintenance-expires">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</header>
|
||||
|
||||
<section class="panel" aria-labelledby="overview-title">
|
||||
<h2 id="overview-title">Overview</h2>
|
||||
<dl class="metric-grid">
|
||||
<div>
|
||||
<dt>Total Evidence Records</dt>
|
||||
<dd id="total-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Locked Records</dt>
|
||||
<dd id="locked-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Unlocked Records</dt>
|
||||
<dd id="unlocked-count">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="stores-title">
|
||||
<h2 id="stores-title">Evidence Stores</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Store</th>
|
||||
<th scope="col">Records</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Consent States</td>
|
||||
<td id="store-consent-states">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Consent Events</td>
|
||||
<td id="store-consent-events">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Observed Requests</td>
|
||||
<td id="store-observed-requests">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>GVL Snapshots</td>
|
||||
<td id="store-gvl-snapshots">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>GVL Snapshot Events</td>
|
||||
<td id="store-gvl-snapshot-events">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="retention-title">
|
||||
<h2 id="retention-title">Retention Status</h2>
|
||||
<p>
|
||||
Locked records are protected from partial purge. Full deletion still
|
||||
requires explicit confirmation.
|
||||
</p>
|
||||
<div class="retention-actions">
|
||||
<button id="lock-all-button" type="button">
|
||||
Alle Evidenzen als DSGVO-/DSAR-relevant markieren
|
||||
</button>
|
||||
<button id="unlock-all-button" type="button">
|
||||
Alle Evidenz-Sperren entfernen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="administration-title">
|
||||
<h2 id="administration-title">Administration</h2>
|
||||
<div class="admin-actions">
|
||||
<button id="gvl-fetch-official-button" type="button">
|
||||
Official IAB GVL Fetch
|
||||
</button>
|
||||
<button id="gvl-import-button" type="button">
|
||||
GVL JSON importieren
|
||||
</button>
|
||||
<button id="evidence-delete-button" type="button">
|
||||
Evidence Delete
|
||||
</button>
|
||||
</div>
|
||||
<div id="admin-status" class="admin-status" aria-live="polite">
|
||||
Bereit
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VendorGet-IV</title>
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="popup">
|
||||
<h1>VendorGet-IV</h1>
|
||||
|
||||
<section class="status" aria-label="Status">
|
||||
<div class="status-row">
|
||||
<span>Consent-Capture</span>
|
||||
<label class="toggle">
|
||||
<input id="consent-capture-toggle" type="checkbox">
|
||||
<span id="consent-capture-status">aktiv</span>
|
||||
<span class="toggle-slider" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span>Request-Monitoring</span>
|
||||
<label class="toggle">
|
||||
<input id="request-monitoring-toggle" type="checkbox">
|
||||
<span id="request-monitoring-status">aus</span>
|
||||
<span class="toggle-slider" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="evidence-retention" aria-label="Evidenzdaten">
|
||||
<h2>Evidenzdaten</h2>
|
||||
<div id="maintenance-warning" class="maintenance-warning" hidden>
|
||||
Aufzeichnung pausiert: Dashboard geöffnet
|
||||
</div>
|
||||
<dl class="evidence-counts">
|
||||
<div>
|
||||
<dt>Consent States</dt>
|
||||
<dd id="evidence-consent-states-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Consent Events</dt>
|
||||
<dd id="evidence-consent-events-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Recorded Observations</dt>
|
||||
<dd id="evidence-observed-requests-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>GVL Snapshots</dt>
|
||||
<dd id="evidence-gvl-snapshots-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>GVL Events</dt>
|
||||
<dd id="evidence-gvl-snapshot-events-count">-</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gesperrt (DSGVO)</dt>
|
||||
<dd id="evidence-locked-count">-</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<button id="evidence-dashboard-button" type="button">
|
||||
Evidence Dashboard öffnen
|
||||
</button>
|
||||
<div id="evidence-retention-status" class="retention-status" aria-live="polite">
|
||||
Status wird geladen
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="../background/settings.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren