216 lines
5.5 KiB
JavaScript
216 lines
5.5 KiB
JavaScript
/**
|
|
* Universal Cookie Consent
|
|
* jurisdiction/resolver.js
|
|
*
|
|
* Pure deterministic jurisdiction resolver.
|
|
*
|
|
* It consumes explicitly supplied signals. It never performs network calls,
|
|
* reads IP addresses, accesses browser location, or guesses silently.
|
|
*/
|
|
|
|
import {
|
|
JURISDICTION_MODES,
|
|
RESOLUTION_SOURCES,
|
|
CONFIDENCE_LEVELS,
|
|
DEFAULT_JURISDICTION_ID,
|
|
DEFAULT_POLICY_ID,
|
|
DEFAULT_MODE
|
|
} from "./constants.js";
|
|
import { getJurisdiction, hasJurisdiction } from "./jurisdiction.js";
|
|
import { getPolicy, hasPolicy } from "./policies.js";
|
|
|
|
const SOURCE_CONFIDENCE = Object.freeze({
|
|
"site-config": "explicit",
|
|
server: "high",
|
|
application: "medium",
|
|
browser: "low",
|
|
fallback: "default"
|
|
});
|
|
|
|
const SOURCE_PRIORITY = Object.freeze({
|
|
"site-config": 500,
|
|
server: 400,
|
|
application: 300,
|
|
browser: 200,
|
|
fallback: 100
|
|
});
|
|
|
|
function isPlainObject(value) {
|
|
return Boolean(
|
|
value &&
|
|
typeof value === "object" &&
|
|
!Array.isArray(value)
|
|
);
|
|
}
|
|
|
|
function normalizeString(value) {
|
|
return typeof value === "string" ? value.trim() : "";
|
|
}
|
|
|
|
function assertMode(mode) {
|
|
if (!JURISDICTION_MODES.includes(mode)) {
|
|
throw new TypeError(
|
|
`Invalid jurisdiction mode. Expected: ${JURISDICTION_MODES.join(", ")}.`
|
|
);
|
|
}
|
|
}
|
|
|
|
function resolveEntry(id, source, status = "resolved") {
|
|
const entry = getJurisdiction(id);
|
|
|
|
if (!entry) {
|
|
throw new Error(`Unknown jurisdiction: ${id}`);
|
|
}
|
|
|
|
const policy = getPolicy(entry.policy);
|
|
|
|
if (!policy) {
|
|
throw new Error(`Unknown policy: ${entry.policy}`);
|
|
}
|
|
|
|
return Object.freeze({
|
|
status,
|
|
jurisdiction: Object.freeze({
|
|
id: entry.id,
|
|
country: entry.country,
|
|
region: entry.region
|
|
}),
|
|
policy: Object.freeze({
|
|
id: policy.id,
|
|
family: policy.family,
|
|
version: policy.version
|
|
}),
|
|
source,
|
|
confidence: SOURCE_CONFIDENCE[source]
|
|
});
|
|
}
|
|
|
|
function normalizeSignal(signal, fallbackSource) {
|
|
if (!isPlainObject(signal)) {
|
|
return null;
|
|
}
|
|
|
|
const id = normalizeString(signal.jurisdiction || signal.id);
|
|
if (!id || !hasJurisdiction(id)) {
|
|
return null;
|
|
}
|
|
|
|
const source = normalizeString(signal.source) || fallbackSource;
|
|
if (!RESOLUTION_SOURCES.includes(source)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id,
|
|
source,
|
|
priority: SOURCE_PRIORITY[source]
|
|
};
|
|
}
|
|
|
|
function chooseBestSignal(signals) {
|
|
const candidates = [];
|
|
|
|
for (const signal of signals) {
|
|
const normalized = normalizeSignal(signal, "browser");
|
|
if (normalized) {
|
|
candidates.push(normalized);
|
|
}
|
|
}
|
|
|
|
if (!candidates.length) {
|
|
return null;
|
|
}
|
|
|
|
candidates.sort((a, b) => b.priority - a.priority);
|
|
return candidates[0];
|
|
}
|
|
|
|
function resolveAuto(input) {
|
|
const site = isPlainObject(input.site) ? input.site : {};
|
|
const request = isPlainObject(input.request) ? input.request : {};
|
|
const visitor = isPlainObject(input.visitor) ? input.visitor : {};
|
|
|
|
const signal = chooseBestSignal([
|
|
{ jurisdiction: request.jurisdiction, source: request.source || "server" },
|
|
{ jurisdiction: request.regionJurisdiction, source: request.regionSource || "server" },
|
|
{ jurisdiction: visitor.jurisdiction, source: visitor.source || "browser" },
|
|
{ jurisdiction: visitor.regionJurisdiction, source: visitor.regionSource || "browser" },
|
|
{ jurisdiction: site.jurisdiction, source: site.source || "application" }
|
|
]);
|
|
|
|
if (signal) {
|
|
return resolveEntry(signal.id, signal.source);
|
|
}
|
|
|
|
return resolveEntry(DEFAULT_JURISDICTION_ID, "fallback", "fallback");
|
|
}
|
|
|
|
export function resolveJurisdiction(input = {}) {
|
|
if (!isPlainObject(input)) {
|
|
throw new TypeError("Jurisdiction input must be an object.");
|
|
}
|
|
|
|
const site = isPlainObject(input.site) ? input.site : {};
|
|
const mode = normalizeString(input.mode || site.mode || DEFAULT_MODE) || DEFAULT_MODE;
|
|
|
|
assertMode(mode);
|
|
|
|
if (mode === "global") {
|
|
return resolveEntry(DEFAULT_JURISDICTION_ID, "site-config");
|
|
}
|
|
|
|
if (mode === "fixed") {
|
|
const id = normalizeString(input.jurisdiction || site.jurisdiction);
|
|
|
|
if (!id) {
|
|
throw new TypeError(
|
|
"A jurisdiction is required when mode is \"fixed\"."
|
|
);
|
|
}
|
|
|
|
if (!hasJurisdiction(id)) {
|
|
throw new RangeError(`Unknown jurisdiction: ${id}`);
|
|
}
|
|
|
|
return resolveEntry(id, "site-config");
|
|
}
|
|
|
|
return resolveAuto(input);
|
|
}
|
|
|
|
export function getResolutionPriority(source) {
|
|
return SOURCE_PRIORITY[source] || 0;
|
|
}
|
|
|
|
export function getSourceConfidence(source) {
|
|
return SOURCE_CONFIDENCE[source] || null;
|
|
}
|
|
|
|
export function isValidResolution(result) {
|
|
if (!isPlainObject(result)) {
|
|
return false;
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(result, "status")) {
|
|
return false;
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(result, "jurisdiction")) {
|
|
return false;
|
|
}
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(result, "policy")) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
(result.status === "resolved" || result.status === "fallback") &&
|
|
typeof result.jurisdiction.id === "string" &&
|
|
typeof result.policy.id === "string" &&
|
|
RESOLUTION_SOURCES.includes(result.source) &&
|
|
CONFIDENCE_LEVELS.includes(result.confidence) &&
|
|
hasJurisdiction(result.jurisdiction.id) &&
|
|
hasPolicy(result.policy.id)
|
|
);
|
|
}
|