@aglyn/aglyn 1.0.0-beta.176 → 1.0.0-beta.177
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -10
- package/src/lib/app-utils/account-acquisition.d.ts +164 -0
- package/src/lib/app-utils/account-acquisition.js +300 -0
- package/src/lib/app-utils/account-acquisition.js.map +1 -0
- package/src/lib/app-utils/first-party-hosts.d.ts +86 -0
- package/src/lib/app-utils/first-party-hosts.js +143 -0
- package/src/lib/app-utils/first-party-hosts.js.map +1 -0
- package/src/lib/app-utils/visitor-consent.d.ts +9 -0
- package/src/lib/app-utils/visitor-consent.js +19 -4
- package/src/lib/app-utils/visitor-consent.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +9 -0
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/plugin-manager/plugin-person-matches.d.ts +75 -0
- package/src/lib/plugin-manager/plugin-person-matches.js +103 -0
- package/src/lib/plugin-manager/plugin-person-matches.js.map +1 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { normalizeFirstTouchHost } from "@aglyn/shared-util-first-touch";
|
|
17
|
+
import { DOCS_BASE_URL } from "./docs-help.js";
|
|
18
|
+
import { PLATFORM_HOME_URL } from "./platform-brand.js";
|
|
19
|
+
/**
|
|
20
|
+
* The hosts this install serves ITSELF — the first-party host registry the
|
|
21
|
+
* first-touch capture reads (AGL-3289).
|
|
22
|
+
*
|
|
23
|
+
* A referrer on one of these is internal and never a first touch, and the
|
|
24
|
+
* capture only runs on a page served from one. Two layers:
|
|
25
|
+
*
|
|
26
|
+
* 1. **Built in**, derived from configuration every install already has: the
|
|
27
|
+
* workspace domain and every subdomain of it (the console, its auth host,
|
|
28
|
+
* each workspace, the docs host under it), plus the configured console,
|
|
29
|
+
* docs and home origins wherever they live. A self-hosted install gets its
|
|
30
|
+
* own list from its own configuration, with nothing new to set.
|
|
31
|
+
* 2. **Configured**, a list staff edit on the Platform settings page and the
|
|
32
|
+
* platform keeps in `platformSettings/firstPartyHosts` — for a surface the
|
|
33
|
+
* configuration cannot name: a forum, a status page, another apex.
|
|
34
|
+
*
|
|
35
|
+
* Adding a surface is therefore: register its host (unless the workspace
|
|
36
|
+
* domain already covers it), and include the capture. Nothing else.
|
|
37
|
+
*
|
|
38
|
+
* ## What is deliberately NOT first-party
|
|
39
|
+
*
|
|
40
|
+
* Published customer sites. A visitor who follows a customer's "made with"
|
|
41
|
+
* badge to the platform arrived from somebody else's site, which is exactly
|
|
42
|
+
* the kind of referral the record is for. The tenant domain lives outside the
|
|
43
|
+
* workspace domain on the platform's own deployment; where an install nests it
|
|
44
|
+
* inside, the registry EXCLUDES it explicitly rather than trusting the
|
|
45
|
+
* wildcard.
|
|
46
|
+
*/ /** The `platformSettings` document the configured list lives in. */ export const FIRST_PARTY_HOSTS_SETTINGS_DOC = 'firstPartyHosts';
|
|
47
|
+
/** How many entries staff may configure; the built-in list is not counted. */ export const FIRST_PARTY_HOSTS_MAX = 50;
|
|
48
|
+
function hostOf(value) {
|
|
49
|
+
if (!value) return '';
|
|
50
|
+
try {
|
|
51
|
+
return normalizeFirstTouchHost(new URL(value).hostname);
|
|
52
|
+
} catch (unused) {
|
|
53
|
+
return normalizeFirstTouchHost(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* A registry entry as staff typed it, or '' when it is not one: a host, a
|
|
58
|
+
* `*.` wildcard, either with a leading `!` to exclude, or a URL whose host is
|
|
59
|
+
* taken. Every host but `localhost` needs a dot, so nobody can make a whole
|
|
60
|
+
* top-level domain internal by typing `*.com`.
|
|
61
|
+
*/ export function normalizeFirstPartyHostEntry(value) {
|
|
62
|
+
if (typeof value !== 'string') return '';
|
|
63
|
+
let raw = value.trim().toLowerCase();
|
|
64
|
+
const negated = raw.startsWith('!');
|
|
65
|
+
if (negated) raw = raw.slice(1).trim();
|
|
66
|
+
const wildcard = raw.startsWith('*.');
|
|
67
|
+
if (wildcard) raw = raw.slice(2);
|
|
68
|
+
const host = raw.includes('/') ? hostOf(raw.includes('://') ? raw : `https://${raw}`) : hostOf(raw);
|
|
69
|
+
if (!host) return '';
|
|
70
|
+
if (host !== 'localhost' && !host.includes('.')) return '';
|
|
71
|
+
return `${negated ? '!' : ''}${wildcard ? '*.' : ''}${host}`;
|
|
72
|
+
}
|
|
73
|
+
/** The built-in list for a set of configuration values. */ export function builtInFirstPartyHosts(inputs) {
|
|
74
|
+
const hosts = [];
|
|
75
|
+
const add = (entry)=>{
|
|
76
|
+
const normalized = normalizeFirstPartyHostEntry(entry);
|
|
77
|
+
if (normalized && !hosts.includes(normalized)) hosts.push(normalized);
|
|
78
|
+
};
|
|
79
|
+
const workspace = hostOf(inputs.workspaceDomain);
|
|
80
|
+
if (workspace) {
|
|
81
|
+
add(workspace);
|
|
82
|
+
add(`*.${workspace}`);
|
|
83
|
+
}
|
|
84
|
+
for (const url of [
|
|
85
|
+
inputs.consoleUrl,
|
|
86
|
+
inputs.docsUrl
|
|
87
|
+
]){
|
|
88
|
+
const host = hostOf(url);
|
|
89
|
+
if (host) add(host);
|
|
90
|
+
}
|
|
91
|
+
const home = hostOf(inputs.homeUrl);
|
|
92
|
+
if (home) {
|
|
93
|
+
add(home);
|
|
94
|
+
if (!home.startsWith('www.')) add(`www.${home}`);
|
|
95
|
+
}
|
|
96
|
+
const tenant = hostOf(inputs.tenantDomain);
|
|
97
|
+
if (tenant && workspace && tenant.endsWith(`.${workspace}`)) {
|
|
98
|
+
add(`!${tenant}`);
|
|
99
|
+
add(`!*.${tenant}`);
|
|
100
|
+
}
|
|
101
|
+
if (inputs.development) {
|
|
102
|
+
add('localhost');
|
|
103
|
+
add('*.localhost');
|
|
104
|
+
}
|
|
105
|
+
return hosts;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* This deployment's configuration, read the way the rest of the platform
|
|
109
|
+
* reads it.
|
|
110
|
+
*
|
|
111
|
+
* The workspace domain falls back to the home URL's host when its variable is
|
|
112
|
+
* unset — which it is on the platform's own production console, where the
|
|
113
|
+
* code-level default and the home URL name the same apex. Dot notation
|
|
114
|
+
* throughout, so a browser bundle gets the build-time values.
|
|
115
|
+
*/ export function platformHostInputs() {
|
|
116
|
+
const home = hostOf(PLATFORM_HOME_URL);
|
|
117
|
+
return {
|
|
118
|
+
workspaceDomain: process.env.NEXT_PUBLIC_WORKSPACE_DOMAIN || (home.startsWith('www.') ? home.slice(4) : home) || null,
|
|
119
|
+
consoleUrl: process.env.NEXT_PUBLIC_CONSOLE_URL || null,
|
|
120
|
+
docsUrl: DOCS_BASE_URL || null,
|
|
121
|
+
homeUrl: PLATFORM_HOME_URL,
|
|
122
|
+
tenantDomain: process.env.NEXT_PUBLIC_TENANT_DOMAIN || null,
|
|
123
|
+
development: process.env.NODE_ENV !== 'production'
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The whole registry: the built-in list, then whatever staff configured,
|
|
128
|
+
* cleaned and de-duplicated. An unusable configured entry is dropped rather
|
|
129
|
+
* than failing the list, because a registry that refused to load would switch
|
|
130
|
+
* the capture off everywhere at once.
|
|
131
|
+
*/ export function mergeFirstPartyHosts(builtIn, configured) {
|
|
132
|
+
const hosts = [
|
|
133
|
+
...builtIn
|
|
134
|
+
];
|
|
135
|
+
const entries = Array.isArray(configured) ? configured.slice(0, FIRST_PARTY_HOSTS_MAX) : [];
|
|
136
|
+
for (const entry of entries){
|
|
137
|
+
const normalized = normalizeFirstPartyHostEntry(entry);
|
|
138
|
+
if (normalized && !hosts.includes(normalized)) hosts.push(normalized);
|
|
139
|
+
}
|
|
140
|
+
return hosts;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
//# sourceMappingURL=first-party-hosts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/first-party-hosts.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeFirstTouchHost } from '@aglyn/shared-util-first-touch'\nimport { DOCS_BASE_URL } from './docs-help'\nimport { PLATFORM_HOME_URL } from './platform-brand'\n\n/**\n * The hosts this install serves ITSELF — the first-party host registry the\n * first-touch capture reads (AGL-3289).\n *\n * A referrer on one of these is internal and never a first touch, and the\n * capture only runs on a page served from one. Two layers:\n *\n * 1. **Built in**, derived from configuration every install already has: the\n * workspace domain and every subdomain of it (the console, its auth host,\n * each workspace, the docs host under it), plus the configured console,\n * docs and home origins wherever they live. A self-hosted install gets its\n * own list from its own configuration, with nothing new to set.\n * 2. **Configured**, a list staff edit on the Platform settings page and the\n * platform keeps in `platformSettings/firstPartyHosts` — for a surface the\n * configuration cannot name: a forum, a status page, another apex.\n *\n * Adding a surface is therefore: register its host (unless the workspace\n * domain already covers it), and include the capture. Nothing else.\n *\n * ## What is deliberately NOT first-party\n *\n * Published customer sites. A visitor who follows a customer's \"made with\"\n * badge to the platform arrived from somebody else's site, which is exactly\n * the kind of referral the record is for. The tenant domain lives outside the\n * workspace domain on the platform's own deployment; where an install nests it\n * inside, the registry EXCLUDES it explicitly rather than trusting the\n * wildcard.\n */\n\n/** The `platformSettings` document the configured list lives in. */\nexport const FIRST_PARTY_HOSTS_SETTINGS_DOC = 'firstPartyHosts'\n\n/** How many entries staff may configure; the built-in list is not counted. */\nexport const FIRST_PARTY_HOSTS_MAX = 50\n\n/** The configuration the built-in list is derived from. */\nexport interface PlatformHostInputs {\n /** The domain workspaces are served under; its subdomains are all ours. */\n workspaceDomain: string | null\n consoleUrl: string | null\n docsUrl: string | null\n homeUrl: string | null\n /** The domain customer sites are served under. */\n tenantDomain: string | null\n /** Local development, where the console and the sites run on localhost. */\n development: boolean\n}\n\nfunction hostOf(value: string | null | undefined): string {\n if (!value) return ''\n try {\n return normalizeFirstTouchHost(new URL(value).hostname)\n } catch {\n return normalizeFirstTouchHost(value)\n }\n}\n\n/**\n * A registry entry as staff typed it, or '' when it is not one: a host, a\n * `*.` wildcard, either with a leading `!` to exclude, or a URL whose host is\n * taken. Every host but `localhost` needs a dot, so nobody can make a whole\n * top-level domain internal by typing `*.com`.\n */\nexport function normalizeFirstPartyHostEntry(value: unknown): string {\n if (typeof value !== 'string') return ''\n let raw = value.trim().toLowerCase()\n const negated = raw.startsWith('!')\n if (negated) raw = raw.slice(1).trim()\n const wildcard = raw.startsWith('*.')\n if (wildcard) raw = raw.slice(2)\n const host = raw.includes('/') ? hostOf(raw.includes('://') ? raw : `https://${raw}`) : hostOf(raw)\n if (!host) return ''\n if (host !== 'localhost' && !host.includes('.')) return ''\n return `${negated ? '!' : ''}${wildcard ? '*.' : ''}${host}`\n}\n\n/** The built-in list for a set of configuration values. */\nexport function builtInFirstPartyHosts(inputs: PlatformHostInputs): string[] {\n const hosts: string[] = []\n const add = (entry: string) => {\n const normalized = normalizeFirstPartyHostEntry(entry)\n if (normalized && !hosts.includes(normalized)) hosts.push(normalized)\n }\n const workspace = hostOf(inputs.workspaceDomain)\n if (workspace) {\n add(workspace)\n add(`*.${workspace}`)\n }\n for (const url of [inputs.consoleUrl, inputs.docsUrl]) {\n const host = hostOf(url)\n if (host) add(host)\n }\n const home = hostOf(inputs.homeUrl)\n if (home) {\n add(home)\n if (!home.startsWith('www.')) add(`www.${home}`)\n }\n const tenant = hostOf(inputs.tenantDomain)\n if (tenant && workspace && tenant.endsWith(`.${workspace}`)) {\n add(`!${tenant}`)\n add(`!*.${tenant}`)\n }\n if (inputs.development) {\n add('localhost')\n add('*.localhost')\n }\n return hosts\n}\n\n/**\n * This deployment's configuration, read the way the rest of the platform\n * reads it.\n *\n * The workspace domain falls back to the home URL's host when its variable is\n * unset — which it is on the platform's own production console, where the\n * code-level default and the home URL name the same apex. Dot notation\n * throughout, so a browser bundle gets the build-time values.\n */\nexport function platformHostInputs(): PlatformHostInputs {\n const home = hostOf(PLATFORM_HOME_URL)\n return {\n workspaceDomain:\n process.env.NEXT_PUBLIC_WORKSPACE_DOMAIN ||\n (home.startsWith('www.') ? home.slice(4) : home) ||\n null,\n consoleUrl: process.env.NEXT_PUBLIC_CONSOLE_URL || null,\n docsUrl: DOCS_BASE_URL || null,\n homeUrl: PLATFORM_HOME_URL,\n tenantDomain: process.env.NEXT_PUBLIC_TENANT_DOMAIN || null,\n development: process.env.NODE_ENV !== 'production',\n }\n}\n\n/**\n * The whole registry: the built-in list, then whatever staff configured,\n * cleaned and de-duplicated. An unusable configured entry is dropped rather\n * than failing the list, because a registry that refused to load would switch\n * the capture off everywhere at once.\n */\nexport function mergeFirstPartyHosts(builtIn: readonly string[], configured: unknown): string[] {\n const hosts = [...builtIn]\n const entries = Array.isArray(configured) ? configured.slice(0, FIRST_PARTY_HOSTS_MAX) : []\n for (const entry of entries) {\n const normalized = normalizeFirstPartyHostEntry(entry)\n if (normalized && !hosts.includes(normalized)) hosts.push(normalized)\n }\n return hosts\n}\n"],"names":["normalizeFirstTouchHost","DOCS_BASE_URL","PLATFORM_HOME_URL","FIRST_PARTY_HOSTS_SETTINGS_DOC","FIRST_PARTY_HOSTS_MAX","hostOf","value","URL","hostname","normalizeFirstPartyHostEntry","raw","trim","toLowerCase","negated","startsWith","slice","wildcard","host","includes","builtInFirstPartyHosts","inputs","hosts","add","entry","normalized","push","workspace","workspaceDomain","url","consoleUrl","docsUrl","home","homeUrl","tenant","tenantDomain","endsWith","development","platformHostInputs","process","env","NEXT_PUBLIC_WORKSPACE_DOMAIN","NEXT_PUBLIC_CONSOLE_URL","NEXT_PUBLIC_TENANT_DOMAIN","NODE_ENV","mergeFirstPartyHosts","builtIn","configured","entries","Array","isArray"],"mappings":"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"}
|
|
@@ -703,6 +703,15 @@ export declare function setResidentAnalyticsTags(granted: boolean, advertising?:
|
|
|
703
703
|
* unavailable, or an unrecognized shape — treated alike: not yet decided).
|
|
704
704
|
*/
|
|
705
705
|
export declare function readStoredVisitorConsent(hostId: string | null | undefined): StoredVisitorConsent | null;
|
|
706
|
+
/**
|
|
707
|
+
* A stored record's text as the record it may claim to be, or null.
|
|
708
|
+
*
|
|
709
|
+
* The one validation every copy of a record passes through — the per-origin
|
|
710
|
+
* store above, and the `aglyn_consent` mirror a server reads off a request
|
|
711
|
+
* (AGL-3289) — so a copy read anywhere grants exactly what its status
|
|
712
|
+
* supports and nothing a hand edit added.
|
|
713
|
+
*/
|
|
714
|
+
export declare function parseStoredVisitorConsent(raw: string | null | undefined): StoredVisitorConsent | null;
|
|
706
715
|
/**
|
|
707
716
|
* Record a consent state and make the page agree with it immediately.
|
|
708
717
|
*
|
|
@@ -850,8 +850,23 @@ function normalizeCookieHostname(hostname) {
|
|
|
850
850
|
*/ export function readStoredVisitorConsent(hostId) {
|
|
851
851
|
if (!hostId || typeof window === 'undefined') return null;
|
|
852
852
|
try {
|
|
853
|
-
|
|
854
|
-
|
|
853
|
+
return parseStoredVisitorConsent(window.localStorage.getItem(visitorConsentStorageKey(hostId)));
|
|
854
|
+
} catch (unused) {
|
|
855
|
+
// No storage (privacy mode) — the visitor is simply undecided, which is
|
|
856
|
+
// the safe reading.
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* A stored record's text as the record it may claim to be, or null.
|
|
862
|
+
*
|
|
863
|
+
* The one validation every copy of a record passes through — the per-origin
|
|
864
|
+
* store above, and the `aglyn_consent` mirror a server reads off a request
|
|
865
|
+
* (AGL-3289) — so a copy read anywhere grants exactly what its status
|
|
866
|
+
* supports and nothing a hand edit added.
|
|
867
|
+
*/ export function parseStoredVisitorConsent(raw) {
|
|
868
|
+
if (!raw) return null;
|
|
869
|
+
try {
|
|
855
870
|
const parsed = JSON.parse(raw);
|
|
856
871
|
if ((parsed == null ? void 0 : parsed.v) !== 1 || typeof parsed.status !== 'string' || !CONSENT_STATUSES.has(parsed.status)) {
|
|
857
872
|
return null;
|
|
@@ -871,8 +886,8 @@ function normalizeCookieHostname(hostname) {
|
|
|
871
886
|
country: typeof parsed.country === 'string' ? parsed.country : null
|
|
872
887
|
};
|
|
873
888
|
} catch (unused) {
|
|
874
|
-
//
|
|
875
|
-
//
|
|
889
|
+
// Corrupt JSON — the visitor is simply undecided, which is the safe
|
|
890
|
+
// reading.
|
|
876
891
|
return null;
|
|
877
892
|
}
|
|
878
893
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/visitor-consent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Visitor tracking consent (AGL-1498): the single answer to \"may this\n * tracking feature run for this visitor?\", shared by every surface that has\n * to agree about it — the tenant's GA injection, the consent banner, the\n * marketing runtime's visitor id, the console card that configures it, and\n * the console preview's region simulator.\n *\n * The model, and the honesty bar it was built against:\n *\n * - **Strictly necessary — no consent asked, none required.** The cart cookie\n * (`aglyn_cart_{hostId}`, commerce checkout), the member session\n * (`aglyn_member_{hostId}`, memberships sign-in), the `aglyn-tenant-host`\n * preview-host override, popup/announcement dismissal stamps (local-only,\n * never transmitted, and blocking them would show MORE popups), the\n * first-party pageview beacon (cookieless, stores no visitor identifier —\n * AGL-82), and the stored consent choice itself.\n * - **Consent-gated: `advertising`** (AGL-1649) — GA4's `ad_storage`,\n * `ad_user_data` and `ad_personalization`. OFF for every site unless the\n * host turns the question on, and granted only by an explicit visitor yes\n * to that specific category: never by the implied default, never by a\n * record written before the category existed, never by any refusal status.\n * A host who needs Ads storage previously had one workaround — switch the\n * whole tool off and run their own CMP — which is why this exists.\n * - **Consent-gated: `analytics`.** Today that is the customer-configured\n * Google Analytics tag — a third-party identifier-setting script — plus\n * the CROSS-VISIT persistence of the `aglyn:visitor` experiment id (the id\n * never leaves the browser, but an indefinite-lifetime identifier is\n * disproportionate without a basis; without one it degrades to\n * sessionStorage, so variants stay stable within the visit).\n *\n * **Two postures, resolved per visitor** (the Squarespace shape, host-chosen):\n *\n * - **`opt-in`** — prior consent: nothing gated loads until an explicit\n * accept. Applied in regions whose law requires prior consent (EU/EEA/UK),\n * whenever the visitor's region is UNKNOWN, and everywhere when the host\n * picks `strict` mode. Unknown-geo falls to opt-in deliberately: the\n * asymmetry is a few lost analytics events on rare headerless visits\n * versus pre-consent tracking of an EU visitor.\n * - **`opt-out`** — implied consent: gated features are live from first\n * paint, no banner and no notice; the visitor's recorded state is\n * `implied`, and the always-present \"Your Privacy Choices\" control is the\n * opt-out surface. Applied outside prior-consent regions when the host\n * mode is `geo`.\n *\n * **GPC (Global Privacy Control)** is honored as an automatic opt-out in\n * both postures: a visitor whose browser sends it is recorded `gpc-opt-out`\n * and never tracked, unless they later explicitly accept (the signal is a\n * default; a specific, informed choice overrides it).\n *\n * Enforcement is AT THE SOURCE and MODE-INDEPENDENT: whenever the verdict is\n * \"no\", the GA script never loads — not load-then-suppress — and the gate\n * does not care WHY consent is absent. Because tenant pages are ISR-cached,\n * both the posture and the verdict are evaluated client-side only — the\n * cached HTML never varies by region or consent state.\n */\n\n/** The host fields this module reads; keeps callers free of the full doc. */\nexport interface VisitorConsentHost {\n analytics?: {\n gaMeasurementId?: string\n /** GTM container (AGL-2486) — gated exactly as the GA id is. */\n gtmContainerId?: string\n } | null\n consent?: {\n /**\n * Host opt-out of the whole tool (AGL-1498). PERSISTED NAME. `true`\n * means the host runs their own consent solution (or accepts the\n * exposure): no banner, no privacy-choices control, and gated features\n * load for every visitor. Absent means the tool is active.\n */\n disabled?: boolean\n /**\n * The host's consent posture (AGL-1498). PERSISTED NAME.\n * - `'geo'` — geo-conditional: implied consent (tracking on, opt-out\n * offered) outside prior-consent regions; a prior-consent banner in\n * EU/EEA/UK and wherever the region is unknown.\n * - `'strict'` — opt-in everywhere: every visitor gets the banner.\n * Absent behaves as `'geo'` — the field practice (Squarespace) and the\n * posture the console preselects; recorded here so nobody discovers it\n * by surprise.\n */\n mode?: 'geo' | 'strict'\n /**\n * Host opt-in to ASKING about advertising storage (AGL-1649). PERSISTED\n * NAME. Absent or `false` — the default for every site that exists — means\n * the visitor is asked about analytics only and the three advertising\n * signals stay denied, which is where AGL-1622 put them.\n *\n * `true` adds a SECOND question to the banner and the privacy-choices\n * panel. It does not grant anything by itself: it creates the opportunity\n * for a visitor to say yes, which is the thing a host previously could not\n * obtain without switching the whole tool off and running their own CMP.\n *\n * Strictly `=== true`, like {@link isConsentToolDisabled}: a truthy string\n * out of a hand-edited host document must not turn a consent category on.\n */\n advertising?: boolean\n } | null\n}\n\n/** The host's configured posture; absent means `'geo'`. */\nexport type HostConsentMode = 'geo' | 'strict'\n\n/** The per-visitor resolved posture. */\nexport type VisitorConsentPosture = 'opt-in' | 'opt-out'\n\n/**\n * Consent-gated feature categories.\n *\n * `advertising` (AGL-1649) is gated ONLY where the host opted in — see\n * {@link hostAsksAboutAdvertising}. The stored shape is keyed by category, so\n * this extended rather than migrated, and a record written before it existed\n * reads as \"never asked\", never as a grant.\n */\nexport type VisitorConsentCategory = 'analytics' | 'advertising'\n\n/**\n * A visitor's recorded consent state. The five are DISTINCT on purpose:\n * \"how many visitors are tracked\" (and any future regulator question) needs\n * to distinguish a visitor who clicked Accept from one who was defaulted in.\n *\n * - `implied` — opt-out posture, no action taken: tracking is on.\n * - `accepted` — explicit yes (either posture): tracking is on.\n * - `declined` — explicit no to a prior-consent banner: tracking is off.\n * - `opted-out` — explicit opt-out from the implied default: tracking off.\n * - `gpc-opt-out` — the browser's GPC signal, honored: tracking off.\n */\nexport type VisitorConsentStatus =\n | 'implied'\n | 'accepted'\n | 'declined'\n | 'opted-out'\n | 'gpc-opt-out'\n\nconst CONSENT_STATUSES: ReadonlySet<string> = new Set([\n 'implied',\n 'accepted',\n 'declined',\n 'opted-out',\n 'gpc-opt-out',\n])\n\n/** The statuses that are a visitor's own click, not a default or a signal. */\nexport function isExplicitConsentStatus(\n status: VisitorConsentStatus | null | undefined,\n): boolean {\n return (\n status === 'accepted' || status === 'declined' || status === 'opted-out'\n )\n}\n\n/**\n * Which refusal a \"no\" is, for a visitor in this state.\n *\n * `opted-out` when they were defaulted in, `declined` when they were asked\n * first. The GATE does not care — both refuse — but the RECORD does: \"how many\n * visitors were tracked before they said no\" is a question only these two\n * answers separate, and it is a question a regulator can ask.\n *\n * Lives here rather than in either surface that needs it. Both the consent\n * overlay and the console's own module were deriving it, and a rule with two\n * derivations is a rule that eventually means two different things.\n */\nexport function refusalStatusFor(\n stored: { status?: VisitorConsentStatus } | null | undefined,\n posture: VisitorConsentPosture | null | undefined,\n): VisitorConsentStatus {\n return stored?.status === 'implied' || posture === 'opt-out'\n ? 'opted-out'\n : 'declined'\n}\n\n/** Whether a status grants the analytics category. */\nexport function analyticsGrantedByStatus(\n status: VisitorConsentStatus,\n): boolean {\n return status === 'accepted' || status === 'implied'\n}\n\n/**\n * Whether a status can carry an advertising grant at all (AGL-1649; widened by\n * AGL-2402, narrowed back on 2026-08-24, widened again on 2026-08-25).\n *\n * The SAME status set as {@link analyticsGrantedByStatus}, and the agreement is\n * deliberate: `implied` grants both. Being defaulted in where the law permits a\n * default is one posture, applied to both categories, and the visitor's opt-out\n * surface is the same one.\n *\n * ## Why it moved three times, because the next person will move it again\n *\n * AGL-2402 (`a410d8785`) first made `implied` qualify, on the reasoning that\n * the rule is safe by GEOGRAPHY: an `implied` record can only ever be written\n * in the OPT-OUT posture, because {@link resolveConsentPosture} puts the EU 27,\n * the EEA/EFTA three, the UK, Gibraltar, the EU outermost regions AND any\n * visitor whose region cannot be determined into opt-in, where\n * `decideVisitorConsent` records no default at all. That half was always sound\n * and is still asserted in `visitor-consent-advertising.spec.ts`.\n *\n * The half that failed was DISCLOSURE. That commit stated the published Cookie\n * Policy had been updated first; read against the live page on 2026-08-24 it\n * said two different things — its \"Marketing / advertising\" paragraph described\n * the opt-out posture, while the per-cookie table said `_gac`, `_gcl_au`,\n * `_fbp` and `_fbc` were \"set only where you have allowed advertising cookies\"\n * and \"Your choices\" repeated it. A policy that says both cannot authorise the\n * wider behaviour, so the code was narrowed to the strictest thing it said.\n *\n * On 2026-08-25 the master moved for real, in the right order: those five\n * opt-in-only sentences were rewritten in the Cookie Policy gdoc, and only then\n * did this function follow. The Privacy Policy needed nothing — it already said\n * *\"With your consent, we do 'share' … for cross-context behavioral\n * advertising\"*, named Google and Meta, and described the EU/UK-ask-first vs.\n * elsewhere-from-first-visit split. The flat \"we do not sell or share\" denial\n * that the narrowing reasoned from was a paraphrase in `docs/ANALYTICS.md`, not\n * a sentence in the document.\n *\n * ⛔ Re-narrowing is a POLICY act, not a code change. Move the published\n * masters first and let this follow — never the other way round. Nine surfaces\n * describe this rule to a human and `consent-advertising-copy-drift.spec.ts`\n * fails on all of them the moment the two disagree.\n *\n * A `true` here is necessary, never sufficient: the host must ALSO have opted\n * into asking (see {@link hostAsksAboutAdvertising}), and a refusal — declined,\n * opted out, or a GPC signal — outranks the default everywhere.\n */\nexport function advertisingGrantedByStatus(\n status: VisitorConsentStatus,\n): boolean {\n // Fails CLOSED: an exact match against the granting statuses, so any\n // unknown, absent or future status answers `false`. `strictNullChecks` is\n // off repo-wide, which means `status` can be `null`/`undefined` at runtime\n // despite the type — an equality test denies those, an exclusion list\n // (`status !== 'declined' && …`) would have granted them.\n //\n // `implied` GRANTS from 2026-08-25, and the policy went first (the call,\n // executed in that order). `decideVisitorConsent` only ever writes `implied`\n // in the OPT-OUT posture — `resolveConsentPosture` keeps the EU 27, the\n // EEA/EFTA three, the UK, Gibraltar, the EU outermost regions AND any visitor\n // whose region cannot be determined in opt-in, where no default is recorded at\n // all — so this widens advertising to the rest of the world only, and the\n // geographic guarantee `visitor-consent-advertising.spec.ts` asserts is\n // untouched. `gpc-opt-out` and `declined` still answer `false`.\n //\n // The disclosure this rests on is now consistent in BOTH masters, which is\n // what the 2026-08-24 narrowing was waiting for: the Privacy Policy already\n // said *\"With your consent, we do 'share' … Where the law requires us to ask\n // first — the EU, the UK, and anywhere we cannot determine your region — it\n // does not load until you accept. Elsewhere it runs from your first visit\"*,\n // and the Cookie Policy's two remaining opt-in-only sentences (the ×4\n // per-cookie note and the \"Your choices\" bullet) were rewritten to match it\n // the same day. Re-narrow this ONLY by moving the policies back first.\n return status === 'accepted' || status === 'implied'\n}\n\n/**\n * Strict GA measurement-id check (AGL-138) — the id lands inside an inline\n * script, so the format gate is load-bearing, not cosmetic.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{4,16}$/\n\n/**\n * The shape a Meta pixel id has, shared by the LOADER and the console field.\n *\n * Declared HERE and not in `advertising-tags.ts`, which the console may not\n * import: `advertising-tag-gate.spec` case (f) fails any console file that\n * reaches for that module, because it is what mounts a vendor's script. A\n * format string is not a vendor, so it lives with the other id patterns and\n * the loader imports it from here.\n *\n * One pattern, because two would be a form that accepts an id the loader then\n * refuses — the tag never mounts and the operator is left looking at a saved\n * field and no pixel.\n */\nexport const META_PIXEL_ID_PATTERN = /^[0-9]{8,20}$/\n\n/**\n * A Google Ads conversion id — `AW-` and digits.\n *\n * Distinct from `GA_MEASUREMENT_ID_PATTERN` (`G-…`) on purpose: they are\n * different products with different ids, and a form that accepted either in\n * either field would load an analytics id into an ads tag and report nothing.\n */\nexport const GOOGLE_ADS_ID_PATTERN = /^AW-[0-9]{6,16}$/\n\n/**\n * A LinkedIn Insight partner id — numeric, and short.\n *\n * Bounded well under the Meta band rather than sharing it: a partner id is six\n * or seven digits today, and a pattern loose enough to accept a Meta pixel id\n * would let a mis-paste into the wrong field validate and then load a tag that\n * matches nothing.\n */\nexport const LINKEDIN_PARTNER_ID_PATTERN = /^[0-9]{4,10}$/\n\n/** The configured GA id when it is well-formed, else null. */\nexport function resolveGaMeasurementId(\n host: VisitorConsentHost | null | undefined,\n): string | null {\n const candidate = String(host?.analytics?.gaMeasurementId ?? '')\n return GA_MEASUREMENT_ID_PATTERN.test(candidate) ? candidate : null\n}\n\n/**\n * A Google Tag Manager container id — `GTM-` and 5–10 upper-case\n * alphanumerics.\n *\n * Format-checked for the same reason the GA id is: it lands inside an INLINE\n * SCRIPT on a published page, so anything that is not this shape must never\n * reach it (AGL-138).\n */\nexport const GTM_CONTAINER_ID_PATTERN = /^GTM-[A-Z0-9]{5,10}$/\n\n/** The configured GTM container when it is well-formed, else null. */\nexport function resolveGtmContainerId(\n host: VisitorConsentHost | null | undefined,\n): string | null {\n const candidate = String(host?.analytics?.gtmContainerId ?? '')\n return GTM_CONTAINER_ID_PATTERN.test(candidate) ? candidate : null\n}\n\n/**\n * Whether the site loads ANY Google tag — a GA property, a GTM container, or\n * both (AGL-2486).\n *\n * The one predicate both consent questions are asked through, because the\n * alternative is two places that each remember only one of the two. A site\n * with a container and no GA id is a site that tracks: it was the shape that\n * would have had `consentGatedCategories` return `[]`, no banner rendered, and\n * the container loading ungated — which for GTM is worse than for GA, since a\n * container is exactly the thing that can carry advertising tags.\n */\nexport function hostHasGoogleTag(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n return !!resolveGaMeasurementId(host) || !!resolveGtmContainerId(host)\n}\n\n/** Host opt-out of the tool. Absent means ACTIVE — the safe default. */\nexport function isConsentToolDisabled(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n return host?.consent?.disabled === true\n}\n\n/** The host's posture choice; anything but `'strict'` reads as `'geo'`. */\nexport function resolveHostConsentMode(\n host: VisitorConsentHost | null | undefined,\n): HostConsentMode {\n return host?.consent?.mode === 'strict' ? 'strict' : 'geo'\n}\n\n/**\n * The gated categories this site actually uses. Empty means there is\n * nothing to consent to and no consent UI renders — a banner with no\n * question behind it is decoration that trains visitors to click banners.\n */\nexport function consentGatedCategories(\n host: VisitorConsentHost | null | undefined,\n): VisitorConsentCategory[] {\n // A GTM CONTAINER counts (AGL-2486). Read as `resolveGaMeasurementId` alone,\n // a site running only a container had no gated category, so no banner\n // rendered and the container loaded ungated — see `hostHasGoogleTag`.\n if (!hostHasGoogleTag(host)) return []\n return hostAsksAboutAdvertising(host)\n ? ['analytics', 'advertising']\n : ['analytics']\n}\n\n/**\n * Whether this site asks its visitors about advertising storage (AGL-1649).\n *\n * Two conditions, both required. The host has to have opted in — the flag is\n * off for every site that exists and stays off unless someone turns it on —\n * and the site has to actually have an analytics tag, because advertising\n * storage here is GA4's `ad_storage` and there is nothing to ask about\n * without one. A banner with no question behind it is decoration (AGL-1498).\n */\nexport function hostAsksAboutAdvertising(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n // Either tag qualifies (AGL-2486): `ad_storage` is what a GA property reads\n // and what a GTM container's advertising tags read, and a container is the\n // likelier of the two to carry them.\n return host?.consent?.advertising === true && hostHasGoogleTag(host)\n}\n\n/**\n * Whether the published site runs the consent machinery at all: the tool is\n * active AND the site uses at least one gated feature.\n */\nexport function hostConsentRequired(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n return !isConsentToolDisabled(host) && consentGatedCategories(host).length > 0\n}\n\n/**\n * Regions whose law requires PRIOR consent for non-essential\n * cookies/identifiers (ePrivacy and its UK retention): the EU 27, the three\n * EEA/EFTA states, the UK, Gibraltar (UK GDPR extends there), and the EU\n * outermost regions that carry their own ISO codes. ISO 3166-1 alpha-2,\n * matching `x-vercel-ip-country`.\n *\n * Deliberately NOT a \"privacy-law countries\" list: plenty of laws (CCPA,\n * LGPD, PIPEDA) regulate tracking on an opt-out basis, which is exactly what\n * the opt-out posture plus GPC provides. This set is only the prior-consent\n * boundary.\n */\nexport const PRIOR_CONSENT_COUNTRY_CODES: ReadonlySet<string> = new Set([\n // EU 27\n 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR',\n 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK',\n 'SI', 'ES', 'SE',\n // EEA / EFTA\n 'IS', 'LI', 'NO',\n // UK and Gibraltar\n 'GB', 'GI',\n // EU outermost regions with their own ISO codes (legally EU territory)\n 'AX', 'GF', 'GP', 'MQ', 'RE', 'YT',\n])\n\n/**\n * The per-visitor posture. Client-evaluated (ISR forbids varying the HTML),\n * from the country the region endpoint reports.\n *\n * Unknown region (`null`) falls to `opt-in` — the ONE place this feature\n * does not maximize tracking, because the asymmetry is lopsided: a few lost\n * analytics events on rare headerless visits, versus pre-consent tracking\n * of an EU visitor, which \"track as soon as we legally can\" excludes by its\n * own terms.\n */\nexport function resolveConsentPosture(\n host: VisitorConsentHost | null | undefined,\n country: string | null | undefined,\n): VisitorConsentPosture {\n if (resolveHostConsentMode(host) === 'strict') return 'opt-in'\n if (!country) return 'opt-in'\n return PRIOR_CONSENT_COUNTRY_CODES.has(country.toUpperCase())\n ? 'opt-in'\n : 'opt-out'\n}\n\n/**\n * Whether this browser sends Global Privacy Control. Read live at decision\n * time — the signal is the user agent's, not ours to cache.\n */\nexport function hasGlobalPrivacyControl(): boolean {\n return (\n typeof navigator !== 'undefined' &&\n (navigator as { globalPrivacyControl?: unknown }).globalPrivacyControl ===\n true\n )\n}\n\n/**\n * A visitor's stored consent record. Versioned so a future category\n * addition can distinguish \"declined analytics\" from \"was never asked about\n * marketing\".\n */\nexport interface StoredVisitorConsent {\n v: 1\n /** Epoch ms of the decision (or of the implied default's recording). */\n at: number\n status: VisitorConsentStatus\n /** The category grant, derived from `status` at store time. */\n analytics: boolean\n /**\n * The advertising grant (AGL-1649). ABSENT on every record written before\n * the category existed, and absent reads as `false` — \"was never asked\"\n * is not \"said yes\". Reading it the other way would hand Google a basis\n * for every visitor who ever clicked Allow on an analytics-only banner.\n *\n * Carried by an explicit accept, and — since 2026-08-25 — by the opt-out\n * posture's implied default, on a site whose host turned the question on.\n * Never by a refusal: declined, opted out and GPC all deny it everywhere.\n * See {@link advertisingGrantedByStatus} for the order the policy and the\n * code move in.\n */\n advertising?: boolean\n /** ISO country at decision time, when known — the `implied,us` shape. */\n country?: string | null\n}\n\n/**\n * Keyed by hostId, not a bare name: preview deployments and localhost serve\n * many hosts from one origin, and one site's \"yes\" must never leak to\n * another's.\n */\nexport const VISITOR_CONSENT_STORAGE_PREFIX = 'aglyn:consent:'\n\n/** The marketing runtime's cross-visit experiment id (`site-runtime.tsx`). */\nexport const VISITOR_ID_STORAGE_KEY = 'aglyn:visitor'\n\n/** Fired on `window` whenever {@link storeVisitorConsent} records a state. */\nexport const VISITOR_CONSENT_CHANGED_EVENT = 'aglyn:consent:changed'\n\n/**\n * Fired on `window` to open the privacy-choices panel — the change-your-mind\n * path, in BOTH directions. Sites can also link any element to\n * `#aglyn-consent`; the banner listens for those clicks.\n */\nexport const VISITOR_CONSENT_OPEN_EVENT = 'aglyn:consent:open'\n\nexport function visitorConsentStorageKey(hostId: string): string {\n return `${VISITOR_CONSENT_STORAGE_PREFIX}${hostId}`\n}\n\n/**\n * Cookie-name PREFIXES the analytics grant is answerable for (AGL-1606).\n *\n * A prefix sweep rather than a name list, because the names are derived and\n * configuration-dependent: `_ga` is joined by `_ga_<measurement id without the\n * `G-`>` per property, by `_gac_<id>` when Google Ads linking is on, and by\n * `_gid` where the legacy daily id is still issued. Hardcoding the two names a\n * given site happens to carry today is precisely how a withdrawal leaves an\n * identifier behind on the next site. `_ga` subsumes `_ga`, `_ga_*` and\n * `_gac_*`; `_gid` is the only Google analytics cookie that does not start\n * with it.\n */\nexport const ANALYTICS_COOKIE_PREFIXES: readonly string[] = ['_ga', '_gid']\n\n/**\n * Cookie-name PREFIXES the ADVERTISING grant is answerable for (AGL-2486).\n *\n * These are Google's, and they are separate from the analytics list above for\n * a reason that is not tidiness: an advertising-only withdrawal must sweep\n * them while analytics keeps running. Folding them into\n * {@link ANALYTICS_COOKIE_PREFIXES} would have swept them on ANALYTICS\n * withdrawal and still not on the case that motivates this — leaving the same\n * hole, narrower — besides filing a conversion-linker cookie under a category\n * it does not belong to.\n *\n * `_gcl_*` is the conversion linker, written by gtag once `ad_storage` is\n * granted, and it is the one that started this: it does not begin with `_ga`,\n * so nothing swept it, on any path.\n *\n * ONE PREFIX, deliberately. `_gac_*` (Google Ads linking) was in this list\n * briefly and is not, because it is written under the ANALYTICS grant and is\n * already swept by the `_ga` prefix. Listing it here made every\n * analytics-only GRANT delete it — on a site that never asks the advertising\n * question `stored.advertising` is always false, so the sweep ran on the way\n * IN — which broke \"cleanup is the withdrawal path only\" (AGL-1606). Sweeping\n * wider is not free when a category nobody was asked about owns the trigger.\n *\n * PRECISION MATTERS, because {@link clearCookiesWithPrefixes} is `startsWith`.\n * `_gcl` cannot reach `_ga`, `_ga_<id>`, `_gac_<id>` or `_gid` — those are\n * analytics and MUST survive an advertising-only withdrawal.\n */\nexport const ADVERTISING_COOKIE_PREFIXES: readonly string[] = ['_gcl']\n\n/**\n * Expire the advertising identifiers, on the same domain ladder as the\n * analytics sweep (AGL-2486).\n *\n * Deleting a cookie that was never set is a no-op; failing to delete one that\n * was is the defect — so this runs on every non-granting store rather than\n * only on a transition observed to be a withdrawal, which the record cannot\n * reliably tell us anyway.\n */\nexport function clearAdvertisingCookies(hostname?: string | null): string[] {\n return clearCookiesWithPrefixes(ADVERTISING_COOKIE_PREFIXES, hostname)\n}\n\n/**\n * Multi-label public suffixes: names under which registrations happen, so the\n * label to their left is the registrable one. A short list rather than the\n * Public Suffix List — the same trade `console-domains.ts` makes, and the\n * failure mode is mild in both directions: an unlisted suffix costs one\n * over-broad `domain=` write that the browser silently refuses (cookies cannot\n * be set on a public suffix), never a deletion on someone else's domain.\n */\nconst MULTI_LABEL_PUBLIC_SUFFIXES: ReadonlySet<string> = new Set([\n 'co.uk', 'org.uk', 'me.uk', 'ac.uk', 'gov.uk',\n 'com.au', 'net.au', 'org.au',\n 'co.nz', 'co.za',\n 'co.jp', 'ne.jp', 'or.jp',\n 'com.br', 'com.mx', 'com.cn', 'com.tr',\n 'co.in', 'co.kr', 'com.sg', 'com.hk',\n])\n\nfunction normalizeCookieHostname(hostname: string | null | undefined): string {\n return String(hostname ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.+$/, '')\n}\n\n/**\n * The registrable domain of a hostname — the highest name a cookie may be\n * written at — or `null` when there is none.\n *\n * This is the fiddly half of the cleanup. GA's `cookie_domain: 'auto'` walks\n * UP from the hostname and writes at the highest domain the browser accepts,\n * which is the registrable domain with a leading dot: a visit to\n * `www.shop.acme.co.uk` leaves `_ga` on `.acme.co.uk`, not on the exact host.\n * A deletion aimed at the exact hostname therefore silently no-ops — it writes\n * a second, host-scoped, already-expired cookie and leaves the real one alone.\n *\n * `null` for a single-label host (`localhost`) and for IP literals: those take\n * host-only cookies, which the undecorated deletion already covers.\n */\nexport function registrableCookieDomain(\n hostname: string | null | undefined,\n): string | null {\n // Named `name`, not `host`: `host.foo` inside app-utils is what the\n // AGL-1361 write-deny sweep reads as a host-document field, and a local\n // string's `.split`/`.includes` would land in it as phantom fields.\n const name = normalizeCookieHostname(hostname)\n if (!name) return null\n // IPv6 literal (bracketed or bare) and IPv4 literal: host-only cookies.\n if (name.startsWith('[') || name.includes(':')) return null\n if (/^\\d+(\\.\\d+){3}$/.test(name)) return null\n const labels = name.split('.')\n if (labels.length < 2 || labels.some((label) => !label)) return null\n const lastTwo = labels.slice(-2).join('.')\n if (MULTI_LABEL_PUBLIC_SUFFIXES.has(lastTwo)) {\n // `acme.co.uk` is registrable; `co.uk` on its own is not a domain at all.\n return labels.length > 2 ? labels.slice(-3).join('.') : null\n }\n return lastTwo\n}\n\n/**\n * Every `domain=` value a cookie deletion has to try, most specific first and\n * stopping at the registrable domain.\n *\n * The whole ladder rather than the registrable domain alone because\n * `cookie_domain` is configurable: a host that pins it to `shop.acme.com`\n * leaves the cookie one rung below where `auto` would. Walking the rungs costs\n * a handful of no-op writes and removes the guess.\n *\n * Empty when there is no registrable domain — the host-only deletion that\n * {@link clearAnalyticsCookies} always performs is the complete answer there.\n */\nexport function analyticsCookieDomains(\n hostname: string | null | undefined,\n): string[] {\n const name = normalizeCookieHostname(hostname)\n const registrable = registrableCookieDomain(name)\n if (!registrable) return []\n const domains: string[] = []\n let current = name\n for (;;) {\n domains.push(`.${current}`)\n if (current === registrable) break\n const cut = current.indexOf('.')\n if (cut < 0) break\n current = current.slice(cut + 1)\n }\n return domains\n}\n\n/**\n * Expire every analytics cookie this browser is carrying, at the host itself\n * and at every domain up to the registrable one. Returns the names it acted\n * on, which is what makes the cleanup assertable.\n *\n * Best-effort by construction: a cookie written at a path other than `/` is\n * not reachable this way, and `document.cookie` cannot report which domain a\n * cookie came from — hence the ladder. Neither limit applies to the cookies GA\n * actually writes (path `/`, registrable domain).\n */\nexport function clearAnalyticsCookies(\n hostname?: string | null,\n): string[] {\n return clearCookiesWithPrefixes(ANALYTICS_COOKIE_PREFIXES, hostname)\n}\n\n/**\n * The same sweep for an ARBITRARY prefix set — the generalisation\n * {@link clearAnalyticsCookies} now delegates to.\n *\n * Extracted rather than copied because the hard part is not the prefix match,\n * it is {@link analyticsCookieDomains}: the ladder from the exact hostname up\n * to the registrable domain, which exists because a tag written with an `auto`\n * cookie domain leaves its identifier one or more rungs ABOVE the host. A\n * second vendor's sweep re-derived by hand would get that wrong the first time\n * and pass its own test, because a deletion aimed at the wrong domain silently\n * no-ops — it writes an already-expired host-scoped cookie and reports success.\n * One implementation, one ladder, one place to fix.\n *\n * Meta's `_fbp`/`_fbc` are written exactly like `_ga`: path `/`, registrable\n * domain, first-party. Nothing here is Google-specific and nothing here is\n * Meta-specific.\n */\nexport function clearCookiesWithPrefixes(\n prefixes: readonly string[],\n hostname?: string | null,\n): string[] {\n if (typeof document === 'undefined') return []\n // An empty prefix set would match EVERY cookie via `''.startsWith`, which\n // would delete the cart, the member session and the consent record itself.\n // A caller with nothing to sweep gets nothing swept.\n if (!prefixes || prefixes.length === 0) return []\n try {\n const names = new Set<string>()\n for (const pair of String(document.cookie ?? '').split(';')) {\n const name = pair.split('=')[0].trim()\n if (name && prefixes.some((p) => !!p && name.startsWith(p))) {\n names.add(name)\n }\n }\n if (names.size === 0) return []\n const domains = analyticsCookieDomains(\n hostname ??\n (typeof window !== 'undefined' ? window.location?.hostname : ''),\n )\n for (const name of names) {\n const expired = `${name}=; Max-Age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`\n document.cookie = expired\n for (const domain of domains) {\n document.cookie = `${expired}; domain=${domain}`\n }\n }\n return [...names]\n } catch {\n // Cookies disabled or a hostile `document.cookie`: the gate still keeps\n // the script out, which is the load-bearing half.\n return []\n }\n}\n\n/**\n * The `window` flag `gtag.js` consults before every hit: with\n * `window['ga-disable-G-XXXX'] === true` the tag for that property sends\n * nothing and writes nothing. Google's own documented opt-out mechanism, and\n * the one that works on a tag which never received a consent-mode default\n * (AGL-1608) — a case that still exists after AGL-1622, because a tag arriving\n * through GTM or a host's own CMP is not one this module declared for.\n */\nexport const GA_DISABLE_FLAG_PREFIX = 'ga-disable-'\n\n/** A single consent-mode signal value. */\nexport type AnalyticsConsentSignal = 'granted' | 'denied'\n\n/**\n * The consent-mode signal set this tool is answerable for.\n *\n * `ad_storage` and friends are typed as the literal `'denied'`, not as\n * {@link AnalyticsConsentSignal}: the tool asks a visitor about analytics and\n * nothing else, so there is no advertising basis on file to grant, in either\n * direction. Widening them is a change the type has to be edited to allow.\n */\nexport interface AnalyticsConsentSignals {\n analytics_storage: AnalyticsConsentSignal\n ad_storage: 'denied'\n ad_user_data: 'denied'\n ad_personalization: 'denied'\n}\n\n/**\n * The consent-mode payload for a given analytics grant — ONE source, so the\n * `default` declared before the tag loads (AGL-1622) and the `update` sent on a\n * mid-pageview withdrawal (AGL-1608) can never drift apart. Two hand-written\n * literals that disagree is how a tag ends up believing a state the visitor\n * never chose.\n */\nexport function analyticsConsentSignals(\n granted: boolean,\n): AnalyticsConsentSignals {\n return {\n analytics_storage: granted ? 'granted' : 'denied',\n ad_storage: 'denied',\n ad_user_data: 'denied',\n ad_personalization: 'denied',\n }\n}\n\n/** The four signals when advertising is in play (AGL-1649). */\nexport interface VisitorConsentSignals {\n analytics_storage: AnalyticsConsentSignal\n ad_storage: AnalyticsConsentSignal\n ad_user_data: AnalyticsConsentSignal\n ad_personalization: AnalyticsConsentSignal\n}\n\n/**\n * The consent-mode payload for a pair of category grants (AGL-1649).\n *\n * A SEPARATE function from {@link analyticsConsentSignals} rather than a\n * widening of it, deliberately. AGL-1649 says not to widen those three\n * literals, and the reason survives this change: a caller that only knows\n * about analytics should be unable to express a grant it has no answer for.\n * Reaching an advertising grant now requires calling something else and\n * passing it, which is a thing a reviewer can see in a diff.\n *\n * Advertising is clamped to analytics. `ad_storage: 'granted'` with\n * `analytics_storage: 'denied'` is not a state this tool can reach honestly —\n * every refusal path (declined, opted out, GPC) withdraws both together — so\n * the clamp makes the impossible state unrepresentable rather than merely\n * unreached.\n */\nexport function consentModeSignals(grants: {\n analytics: boolean\n advertising: boolean\n}): VisitorConsentSignals {\n const analytics = grants.analytics === true\n const advertising = analytics && grants.advertising === true\n const ads: AnalyticsConsentSignal = advertising ? 'granted' : 'denied'\n return {\n analytics_storage: analytics ? 'granted' : 'denied',\n ad_storage: ads,\n ad_user_data: ads,\n ad_personalization: ads,\n }\n}\n\n/**\n * Whether advertising storage may run for this visitor on this site.\n *\n * THREE independent conditions, and all of them are load-bearing:\n *\n * 1. The host asks about advertising at all. A record that says yes is\n * ignored on a site that never turned the category on — which covers a\n * host switching it back off, and covers a stale record generally.\n * 2. The record's status can carry a grant — an explicit accept, and nothing\n * else (see {@link advertisingGrantedByStatus}). An `implied` default\n * cannot, and every refusal status refuses.\n * 3. The record actually says yes to this category.\n *\n * Absent, unreadable or legacy records all answer `false`, which is the same\n * answer they gave before the category existed.\n */\nexport function advertisingGrantedByRecord(\n host: VisitorConsentHost | null | undefined,\n stored: StoredVisitorConsent | null | undefined,\n): boolean {\n if (!hostAsksAboutAdvertising(host)) return false\n if (!stored) return false\n if (!advertisingGrantedByStatus(stored.status)) return false\n return stored.advertising === true\n}\n\n/**\n * The consent-mode `default` declaration that goes in front of the tag\n * (AGL-1622), for injection into the same inline block that creates\n * `dataLayer` and calls `gtag('config', …)`.\n *\n * ## What this is, and — load-bearing — what it is NOT\n *\n * It is NOT a licence to load the tag earlier. It is emitted INSIDE the block\n * that only renders when the AGL-1498 gate has already said yes, so it is\n * reached only on a pageview where analytics is granted. On every gated\n * pageview — an EU/UK/EEA visitor with no explicit accept, an unknown region, a\n * declined or opted-out visitor, a GPC browser — nothing here renders, no\n * request goes to `googletagmanager.com`, and this constant is never evaluated\n * into the page. \"The script never LOADS, rather than load-then-suppress\" is a\n * stated AGL-1498 property and it is intact everywhere the gate applies;\n * `consent-mode-default.spec.tsx` fails if that ever stops being true.\n *\n * the decision, 2026-08-14: load-then-restrict is approved for the UNITED\n * STATES, where the implied-consent posture already permits the load and the\n * restriction signals act on a tag that is legitimately resident. EU and UK\n * keep the prior-consent gate exactly as it is, because loading an analytics\n * tag before consent is the specific thing prior-consent law prohibits. So the\n * consent-mode mechanism is strictly ADDITIVE to the gate, never a replacement\n * for it.\n *\n * What declaring it actually buys, given the tag is granted by the time it is\n * read:\n *\n * - The advertising signals are denied FROM THE FIRST HIT. Without a default,\n * a freshly loaded tag runs with `ad_storage` unrestricted and only becomes\n * denied if the visitor happens to withdraw and change their mind again\n * (AGL-1608's `update`). The load-time state now matches the tool's actual\n * scope instead of being wider than it.\n * - A later `update` is a transition from a declared state rather than a tag's\n * first-ever consent signal, which is the shape GA4's consent-mode reporting\n * and any third-party CMP alongside ours both expect.\n *\n * The value is a literal, built from {@link analyticsConsentSignals} — no\n * interpolated input reaches it, which matters because it lands inside an\n * inline script (the AGL-138 concern).\n */\nexport const GA_CONSENT_DEFAULT_SNIPPET = `gtag('consent', 'default', ${JSON.stringify(\n analyticsConsentSignals(true),\n)});`\n\n/**\n * The same declaration for a visitor who granted ADVERTISING too (AGL-1649).\n *\n * A second CONSTANT rather than a parameterised builder, for the reason the\n * first one is a constant: it lands inside an inline script (the AGL-138\n * concern), and a constant cannot interpolate anything. Both values are\n * literals fixed at module load; the only thing the caller chooses is which\n * of the two to emit.\n *\n * Reached only where {@link advertisingGrantedByRecord} says yes — host\n * opted in, explicit accept, explicit yes to this category — which is\n * evaluated client-side after hydration, like every other part of this gate.\n * The ISR property is unaffected: the cached HTML contains neither snippet,\n * because the whole block is inside the client-side `analyticsAllowed`\n * condition.\n *\n * Declaring the grant as the DEFAULT rather than sending a later `update` is\n * what keeps the tag's first hit correct. An update would arrive after\n * `config`, so the session's first pageview — usually the entire session for\n * a marketing visit — would carry the denied state the visitor did not\n * choose.\n */\nexport const GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET = `gtag('consent', 'default', ${JSON.stringify(\n consentModeSignals({ analytics: true, advertising: true }),\n)});`\n\n/**\n * Consent-mode URL passthrough for the platform's own tag (AGL-2548).\n *\n * The advertising signals in {@link GA_CONSENT_DEFAULT_SNIPPET} are denied, so\n * on `aglyn.com` gtag writes no `_gcl_aw` cookie and the `gclid` an ad click\n * arrives with lives only in that first URL. The conversions it should be\n * credited for fire one hop later, on the console, where the posture grants\n * advertising storage outside the prior-consent regions — and without this\n * declaration the click id never makes that hop. The console reports a\n * conversion with no click behind it, and Google Ads counts nothing.\n *\n * `url_passthrough` is Google's mechanism for exactly this state: while\n * `ad_storage` is denied, gtag appends the click identifiers to links into the\n * cross-domain-linked hosts as URL parameters instead of setting a cookie. The\n * receiving tag reads them under its own consent state. Nothing is stored on\n * the denying surface, so the marketing site's opt-in advertising posture is\n * unchanged; the click id simply survives the link it was always meant to\n * survive.\n *\n * `ads_data_redaction` is the companion Google documents alongside it: with\n * `ad_storage` denied, the ad click identifiers are stripped from the hits this\n * tag sends and its network requests go to a cookieless endpoint. Declared\n * together so a denied visitor is measured with less, not merely differently.\n *\n * Both are `set` calls, so they must precede `gtag('config', …)` in the same\n * inline block — a `set` applies to hits processed after it, and the session's\n * first pageview is the one carrying the click.\n *\n * PLATFORM ID ONLY. A customer's tag is configured with their id and their\n * consent posture; how their ad click ids travel is theirs to decide, and a\n * passthrough declared on their behalf would rewrite links on their site.\n */\nexport const GA_CLICK_ID_PASSTHROUGH_SNIPPET =\n \"gtag('set', 'url_passthrough', true);\" +\n \"gtag('set', 'ads_data_redaction', true);\"\n\n/**\n * The measurement ids whose tag is actually RESIDENT in this page.\n *\n * Discovered from the page rather than taken from the host document on\n * purpose: what has to be silenced is whatever gtag.js is currently loaded and\n * configured for, which is not always what the host record configures — a tag\n * can arrive through GTM, and a stale `<script>` from earlier in the pageview\n * is exactly the thing this function exists to find.\n *\n * Ids are format-checked before they are used, because each one becomes a\n * `window` property name.\n */\nexport function residentGaMeasurementIds(): string[] {\n if (typeof document === 'undefined') return []\n const ids = new Set<string>()\n try {\n const loaded = document.querySelectorAll(\n 'script[src*=\"googletagmanager.com/gtag/js\"]',\n )\n for (const element of Array.from(loaded)) {\n const src = String((element as HTMLScriptElement).src ?? '')\n const match = /[?&]id=([^&]*)/.exec(src)\n const id = match ? decodeURIComponent(match[1]) : ''\n if (GA_MEASUREMENT_ID_PATTERN.test(id)) ids.add(id)\n }\n } catch {\n // A hostile or absent DOM: the dataLayer pass below still applies.\n }\n try {\n const layer = (window as unknown as Record<string, unknown>)?.dataLayer\n for (const entry of Array.from((layer ?? []) as ArrayLike<unknown>)) {\n // `arguments` objects, not arrays — gtag pushes its own call sites.\n const args = Array.from((entry ?? []) as ArrayLike<unknown>)\n if (args[0] !== 'config') continue\n const id = String(args[1] ?? '')\n if (GA_MEASUREMENT_ID_PATTERN.test(id)) ids.add(id)\n }\n } catch {\n // No dataLayer, or one that is not iterable: the script pass stands.\n }\n return [...ids]\n}\n\n/**\n * Make every resident GA tag agree with the consent state, and return the ids\n * it acted on.\n *\n * ## Why deleting the cookies is not enough (AGL-1608)\n *\n * The AGL-1498 gate stops the script from LOADING, which is the right\n * enforcement for a fresh pageview and the wrong tool for a visitor who\n * withdraws mid-pageview: `gtag.js` has already executed, and React unmounting\n * the `<script>` element does not unload it. `window.gtag` stays live, GA4\n * enhanced measurement fires on its own (scroll depth, outbound click, file\n * download), and the tag re-writes `_ga_<id>` AFTER\n * {@link clearAnalyticsCookies} has deleted it. Reproduced on aglyn.com: an\n * opt-out, a hand-run sweep to zero cookies, then one scroll to the footer\n * brought `_ga_YW5PG16YTM` back.\n *\n * Two signals, because they fail differently and neither is guaranteed:\n * the `ga-disable-<id>` window flag silences a tag that never received a\n * consent-mode default, and the `consent`/`update` call reaches a tag that\n * arrived through GTM with its own id this function never saw.\n *\n * ## What this deliberately does NOT do\n *\n * It never touches the gate. Both signals act only on a tag that is already\n * resident — which, by construction, only exists after a grant. AGL-1622 later\n * added a consent-mode DEFAULT ({@link GA_CONSENT_DEFAULT_SNIPPET}), but inside\n * the gated block and therefore only on a pageview the gate already allowed:\n * load-then-restrict is additive to \"the script never LOADS\", not a\n * replacement for it, and the prior-consent regions are unaffected.\n *\n * Symmetric on purpose: a visitor who opts out and changes their mind in the\n * same pageview would otherwise stay silently unmeasured until they navigated,\n * because the re-rendered `<Script>` cannot re-execute an already-loaded tag.\n * The re-grant restores whatever the CALLER passes — analytics alone on a site\n * that never asks the advertising question (`advertising` defaults to false),\n * and both categories where the visitor's record grants both (AGL-1649). The\n * clamp in {@link consentModeSignals} still makes ads-without-analytics\n * unrepresentable, so a withdrawal can never restore advertising alone.\n */\nexport function setResidentAnalyticsTags(\n granted: boolean,\n advertising = false,\n): string[] {\n if (typeof window === 'undefined') return []\n const ids = residentGaMeasurementIds()\n const scope = window as unknown as Record<string, unknown>\n for (const id of ids) {\n scope[`${GA_DISABLE_FLAG_PREFIX}${id}`] = !granted\n }\n try {\n const send = scope.gtag\n if (typeof send === 'function') {\n // The same payload builder the load-time `default` is made from\n // (AGL-1622), so the two declarations cannot drift.\n ;(send as (...args: unknown[]) => void)(\n 'consent',\n 'update',\n consentModeSignals({ analytics: granted, advertising }),\n )\n }\n } catch {\n // A tag that throws on its own consent call is one we cannot silence;\n // the flag above and the cookie sweep still stand.\n }\n return ids\n}\n\n/**\n * The stored record, or null when there is none (never resolved, storage\n * unavailable, or an unrecognized shape — treated alike: not yet decided).\n */\nexport function readStoredVisitorConsent(\n hostId: string | null | undefined,\n): StoredVisitorConsent | null {\n if (!hostId || typeof window === 'undefined') return null\n try {\n const raw = window.localStorage.getItem(visitorConsentStorageKey(hostId))\n if (!raw) return null\n const parsed = JSON.parse(raw)\n if (\n parsed?.v !== 1 ||\n typeof parsed.status !== 'string' ||\n !CONSENT_STATUSES.has(parsed.status)\n ) {\n return null\n }\n const status = parsed.status as VisitorConsentStatus\n return {\n v: 1,\n at: typeof parsed.at === 'number' ? parsed.at : 0,\n status,\n // Derived, never trusted from storage: a hand-edited record must not\n // grant what its status does not.\n analytics: analyticsGrantedByStatus(status),\n // Same discipline for advertising (AGL-1649), plus one more clause:\n // the key has to be PRESENT and true. Absent is a record written\n // before the category existed — never asked, which is not a yes.\n advertising:\n advertisingGrantedByStatus(status) && parsed.advertising === true,\n country: typeof parsed.country === 'string' ? parsed.country : null,\n }\n } catch {\n // No storage (privacy mode) or corrupt JSON — the visitor is simply\n // undecided, which is the safe reading.\n return null\n }\n}\n\n/**\n * Record a consent state and make the page agree with it immediately.\n *\n * Storing the record is itself strictly-necessary storage (it is how a \"no\"\n * is remembered), so it is exempt from the consent it records. Any\n * non-granting state also REMOVES the identifiers the grant allowed —\n * the persistent `aglyn:visitor` id AND the GA cookies\n * ({@link clearAnalyticsCookies}): opting out cleans up, it does not merely\n * stop adding (AGL-1606) — and SILENCES the tag that is already running\n * ({@link setResidentAnalyticsTags}), without which the cleanup un-does itself\n * within the same pageview (AGL-1608). Listeners hear about it via\n * {@link VISITOR_CONSENT_CHANGED_EVENT} so the GA gate and the runtime react\n * without a reload.\n *\n * The two cleanups are deliberately in SEPARATE try blocks: private mode\n * throws on `localStorage` while cookies still work, and a withdrawal must\n * not lose the cookie sweep to that.\n */\nexport function storeVisitorConsent(\n hostId: string,\n state: {\n status: VisitorConsentStatus\n country?: string | null\n /**\n * The visitor's answer to the advertising question (AGL-1649). Omitted\n * by every caller that does not ask one, and omitted means NO.\n */\n advertising?: boolean\n },\n): StoredVisitorConsent {\n const stored: StoredVisitorConsent = {\n v: 1,\n at: Date.now(),\n status: state.status,\n analytics: analyticsGrantedByStatus(state.status),\n // Derived at store time exactly as `analytics` is, so a caller cannot\n // persist a grant the status does not support — a refusal always writes\n // `false` here even if the panel's checkbox was left ticked.\n advertising:\n advertisingGrantedByStatus(state.status) && state.advertising === true,\n country: state.country ?? null,\n }\n if (typeof window !== 'undefined') {\n try {\n window.localStorage.setItem(\n visitorConsentStorageKey(hostId),\n JSON.stringify(stored),\n )\n if (!stored.analytics) {\n window.localStorage.removeItem(VISITOR_ID_STORAGE_KEY)\n }\n } catch {\n // Private mode: the state holds for this page via the event below,\n // and is re-derived next visit — the safe failure.\n }\n // Silence the RESIDENT tag before sweeping, never after (AGL-1608): the\n // gate only keeps the script from LOADING, so a mid-pageview withdrawal\n // leaves gtag.js live and it re-writes `_ga_<id>` on its next enhanced\n // measurement event. Sweeping first would delete three cookies and\n // immediately get one back.\n setResidentAnalyticsTags(stored.analytics, stored.advertising === true)\n if (!stored.analytics) {\n clearAnalyticsCookies()\n }\n // The advertising half, and it belongs HERE rather than only in\n // `revokeAdvertisingTags` (AGL-2486). That function sweeps a vendor whose\n // marked script elements are in the document, and it is installed only on\n // our own marketing host — so on a CUSTOMER site, where the advertising\n // question is equally available, withdrawing advertising ran no sweep at\n // all. `_gcl_au` is written by the GA tag `site-analytics.tsx` mounts, not\n // by a script this module marked, so no element-scoped teardown was ever\n // going to reach it on any surface.\n //\n // This path is the one every site shares: it is where a decision becomes a\n // record. Unconditional on the grant being absent, for the reason in\n // `clearAdvertisingCookies` — a delete of something never set costs\n // nothing, a missed delete is the defect.\n if (!stored.advertising) {\n clearAdvertisingCookies()\n }\n try {\n window.dispatchEvent(new CustomEvent(VISITOR_CONSENT_CHANGED_EVENT))\n } catch {\n // A dispatch failure only delays consumers until the next read.\n }\n }\n return stored\n}\n\n/**\n * The GA gate's whole verdict: may the analytics category run for this\n * visitor, on this site, right now?\n *\n * - Consent machinery not required (no gated features, or host opted out of\n * the tool) → yes.\n * - Required and the recorded state grants analytics (`accepted` or\n * `implied`) → yes.\n * - Required and anything else — declined, opted out, GPC, or NOT YET\n * RESOLVED → no. Undecided is not a maybe: the script stays out until a\n * state that grants it exists. The gate never asks WHY consent is absent,\n * which is what keeps it posture-independent.\n */\nexport function isAnalyticsAllowed(\n host: VisitorConsentHost | null | undefined,\n stored: StoredVisitorConsent | null,\n): boolean {\n if (!hostConsentRequired(host)) return true\n return stored?.analytics === true\n}\n\n/**\n * Where the marketing runtime may keep its experiment visitor id.\n *\n * `local` — the id persists across visits. `session` — the id lives in\n * sessionStorage: variants stay stable within the visit and the identifier\n * dies with the tab. The downgrade applies while the consent machinery is\n * active for this site and analytics is not granted; the id itself never\n * leaves the browser either way (the experiment beacons are aggregate — no\n * visitor id on the wire).\n */\nexport function resolveVisitorIdPersistence(\n host: VisitorConsentHost | null | undefined,\n stored: StoredVisitorConsent | null,\n): 'local' | 'session' {\n return isAnalyticsAllowed(host, stored) ? 'local' : 'session'\n}\n"],"names":["CONSENT_STATUSES","Set","isExplicitConsentStatus","status","refusalStatusFor","stored","posture","analyticsGrantedByStatus","advertisingGrantedByStatus","GA_MEASUREMENT_ID_PATTERN","META_PIXEL_ID_PATTERN","GOOGLE_ADS_ID_PATTERN","LINKEDIN_PARTNER_ID_PATTERN","resolveGaMeasurementId","host","candidate","String","analytics","gaMeasurementId","test","GTM_CONTAINER_ID_PATTERN","resolveGtmContainerId","gtmContainerId","hostHasGoogleTag","isConsentToolDisabled","consent","disabled","resolveHostConsentMode","mode","consentGatedCategories","hostAsksAboutAdvertising","advertising","hostConsentRequired","length","PRIOR_CONSENT_COUNTRY_CODES","resolveConsentPosture","country","has","toUpperCase","hasGlobalPrivacyControl","navigator","globalPrivacyControl","VISITOR_CONSENT_STORAGE_PREFIX","VISITOR_ID_STORAGE_KEY","VISITOR_CONSENT_CHANGED_EVENT","VISITOR_CONSENT_OPEN_EVENT","visitorConsentStorageKey","hostId","ANALYTICS_COOKIE_PREFIXES","ADVERTISING_COOKIE_PREFIXES","clearAdvertisingCookies","hostname","clearCookiesWithPrefixes","MULTI_LABEL_PUBLIC_SUFFIXES","normalizeCookieHostname","trim","toLowerCase","replace","registrableCookieDomain","name","startsWith","includes","labels","split","some","label","lastTwo","slice","join","analyticsCookieDomains","registrable","domains","current","push","cut","indexOf","clearAnalyticsCookies","prefixes","document","window","names","pair","cookie","p","add","size","location","expired","domain","GA_DISABLE_FLAG_PREFIX","analyticsConsentSignals","granted","analytics_storage","ad_storage","ad_user_data","ad_personalization","consentModeSignals","grants","ads","advertisingGrantedByRecord","GA_CONSENT_DEFAULT_SNIPPET","JSON","stringify","GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET","GA_CLICK_ID_PASSTHROUGH_SNIPPET","residentGaMeasurementIds","ids","loaded","querySelectorAll","element","Array","from","src","match","exec","id","decodeURIComponent","layer","dataLayer","entry","args","setResidentAnalyticsTags","scope","send","gtag","readStoredVisitorConsent","raw","localStorage","getItem","parsed","parse","v","at","storeVisitorConsent","state","Date","now","setItem","removeItem","dispatchEvent","CustomEvent","isAnalyticsAllowed","resolveVisitorIdPersistence"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/visitor-consent.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Visitor tracking consent (AGL-1498): the single answer to \"may this\n * tracking feature run for this visitor?\", shared by every surface that has\n * to agree about it — the tenant's GA injection, the consent banner, the\n * marketing runtime's visitor id, the console card that configures it, and\n * the console preview's region simulator.\n *\n * The model, and the honesty bar it was built against:\n *\n * - **Strictly necessary — no consent asked, none required.** The cart cookie\n * (`aglyn_cart_{hostId}`, commerce checkout), the member session\n * (`aglyn_member_{hostId}`, memberships sign-in), the `aglyn-tenant-host`\n * preview-host override, popup/announcement dismissal stamps (local-only,\n * never transmitted, and blocking them would show MORE popups), the\n * first-party pageview beacon (cookieless, stores no visitor identifier —\n * AGL-82), and the stored consent choice itself.\n * - **Consent-gated: `advertising`** (AGL-1649) — GA4's `ad_storage`,\n * `ad_user_data` and `ad_personalization`. OFF for every site unless the\n * host turns the question on, and granted only by an explicit visitor yes\n * to that specific category: never by the implied default, never by a\n * record written before the category existed, never by any refusal status.\n * A host who needs Ads storage previously had one workaround — switch the\n * whole tool off and run their own CMP — which is why this exists.\n * - **Consent-gated: `analytics`.** Today that is the customer-configured\n * Google Analytics tag — a third-party identifier-setting script — plus\n * the CROSS-VISIT persistence of the `aglyn:visitor` experiment id (the id\n * never leaves the browser, but an indefinite-lifetime identifier is\n * disproportionate without a basis; without one it degrades to\n * sessionStorage, so variants stay stable within the visit).\n *\n * **Two postures, resolved per visitor** (the Squarespace shape, host-chosen):\n *\n * - **`opt-in`** — prior consent: nothing gated loads until an explicit\n * accept. Applied in regions whose law requires prior consent (EU/EEA/UK),\n * whenever the visitor's region is UNKNOWN, and everywhere when the host\n * picks `strict` mode. Unknown-geo falls to opt-in deliberately: the\n * asymmetry is a few lost analytics events on rare headerless visits\n * versus pre-consent tracking of an EU visitor.\n * - **`opt-out`** — implied consent: gated features are live from first\n * paint, no banner and no notice; the visitor's recorded state is\n * `implied`, and the always-present \"Your Privacy Choices\" control is the\n * opt-out surface. Applied outside prior-consent regions when the host\n * mode is `geo`.\n *\n * **GPC (Global Privacy Control)** is honored as an automatic opt-out in\n * both postures: a visitor whose browser sends it is recorded `gpc-opt-out`\n * and never tracked, unless they later explicitly accept (the signal is a\n * default; a specific, informed choice overrides it).\n *\n * Enforcement is AT THE SOURCE and MODE-INDEPENDENT: whenever the verdict is\n * \"no\", the GA script never loads — not load-then-suppress — and the gate\n * does not care WHY consent is absent. Because tenant pages are ISR-cached,\n * both the posture and the verdict are evaluated client-side only — the\n * cached HTML never varies by region or consent state.\n */\n\n/** The host fields this module reads; keeps callers free of the full doc. */\nexport interface VisitorConsentHost {\n analytics?: {\n gaMeasurementId?: string\n /** GTM container (AGL-2486) — gated exactly as the GA id is. */\n gtmContainerId?: string\n } | null\n consent?: {\n /**\n * Host opt-out of the whole tool (AGL-1498). PERSISTED NAME. `true`\n * means the host runs their own consent solution (or accepts the\n * exposure): no banner, no privacy-choices control, and gated features\n * load for every visitor. Absent means the tool is active.\n */\n disabled?: boolean\n /**\n * The host's consent posture (AGL-1498). PERSISTED NAME.\n * - `'geo'` — geo-conditional: implied consent (tracking on, opt-out\n * offered) outside prior-consent regions; a prior-consent banner in\n * EU/EEA/UK and wherever the region is unknown.\n * - `'strict'` — opt-in everywhere: every visitor gets the banner.\n * Absent behaves as `'geo'` — the field practice (Squarespace) and the\n * posture the console preselects; recorded here so nobody discovers it\n * by surprise.\n */\n mode?: 'geo' | 'strict'\n /**\n * Host opt-in to ASKING about advertising storage (AGL-1649). PERSISTED\n * NAME. Absent or `false` — the default for every site that exists — means\n * the visitor is asked about analytics only and the three advertising\n * signals stay denied, which is where AGL-1622 put them.\n *\n * `true` adds a SECOND question to the banner and the privacy-choices\n * panel. It does not grant anything by itself: it creates the opportunity\n * for a visitor to say yes, which is the thing a host previously could not\n * obtain without switching the whole tool off and running their own CMP.\n *\n * Strictly `=== true`, like {@link isConsentToolDisabled}: a truthy string\n * out of a hand-edited host document must not turn a consent category on.\n */\n advertising?: boolean\n } | null\n}\n\n/** The host's configured posture; absent means `'geo'`. */\nexport type HostConsentMode = 'geo' | 'strict'\n\n/** The per-visitor resolved posture. */\nexport type VisitorConsentPosture = 'opt-in' | 'opt-out'\n\n/**\n * Consent-gated feature categories.\n *\n * `advertising` (AGL-1649) is gated ONLY where the host opted in — see\n * {@link hostAsksAboutAdvertising}. The stored shape is keyed by category, so\n * this extended rather than migrated, and a record written before it existed\n * reads as \"never asked\", never as a grant.\n */\nexport type VisitorConsentCategory = 'analytics' | 'advertising'\n\n/**\n * A visitor's recorded consent state. The five are DISTINCT on purpose:\n * \"how many visitors are tracked\" (and any future regulator question) needs\n * to distinguish a visitor who clicked Accept from one who was defaulted in.\n *\n * - `implied` — opt-out posture, no action taken: tracking is on.\n * - `accepted` — explicit yes (either posture): tracking is on.\n * - `declined` — explicit no to a prior-consent banner: tracking is off.\n * - `opted-out` — explicit opt-out from the implied default: tracking off.\n * - `gpc-opt-out` — the browser's GPC signal, honored: tracking off.\n */\nexport type VisitorConsentStatus =\n | 'implied'\n | 'accepted'\n | 'declined'\n | 'opted-out'\n | 'gpc-opt-out'\n\nconst CONSENT_STATUSES: ReadonlySet<string> = new Set([\n 'implied',\n 'accepted',\n 'declined',\n 'opted-out',\n 'gpc-opt-out',\n])\n\n/** The statuses that are a visitor's own click, not a default or a signal. */\nexport function isExplicitConsentStatus(\n status: VisitorConsentStatus | null | undefined,\n): boolean {\n return (\n status === 'accepted' || status === 'declined' || status === 'opted-out'\n )\n}\n\n/**\n * Which refusal a \"no\" is, for a visitor in this state.\n *\n * `opted-out` when they were defaulted in, `declined` when they were asked\n * first. The GATE does not care — both refuse — but the RECORD does: \"how many\n * visitors were tracked before they said no\" is a question only these two\n * answers separate, and it is a question a regulator can ask.\n *\n * Lives here rather than in either surface that needs it. Both the consent\n * overlay and the console's own module were deriving it, and a rule with two\n * derivations is a rule that eventually means two different things.\n */\nexport function refusalStatusFor(\n stored: { status?: VisitorConsentStatus } | null | undefined,\n posture: VisitorConsentPosture | null | undefined,\n): VisitorConsentStatus {\n return stored?.status === 'implied' || posture === 'opt-out'\n ? 'opted-out'\n : 'declined'\n}\n\n/** Whether a status grants the analytics category. */\nexport function analyticsGrantedByStatus(\n status: VisitorConsentStatus,\n): boolean {\n return status === 'accepted' || status === 'implied'\n}\n\n/**\n * Whether a status can carry an advertising grant at all (AGL-1649; widened by\n * AGL-2402, narrowed back on 2026-08-24, widened again on 2026-08-25).\n *\n * The SAME status set as {@link analyticsGrantedByStatus}, and the agreement is\n * deliberate: `implied` grants both. Being defaulted in where the law permits a\n * default is one posture, applied to both categories, and the visitor's opt-out\n * surface is the same one.\n *\n * ## Why it moved three times, because the next person will move it again\n *\n * AGL-2402 (`a410d8785`) first made `implied` qualify, on the reasoning that\n * the rule is safe by GEOGRAPHY: an `implied` record can only ever be written\n * in the OPT-OUT posture, because {@link resolveConsentPosture} puts the EU 27,\n * the EEA/EFTA three, the UK, Gibraltar, the EU outermost regions AND any\n * visitor whose region cannot be determined into opt-in, where\n * `decideVisitorConsent` records no default at all. That half was always sound\n * and is still asserted in `visitor-consent-advertising.spec.ts`.\n *\n * The half that failed was DISCLOSURE. That commit stated the published Cookie\n * Policy had been updated first; read against the live page on 2026-08-24 it\n * said two different things — its \"Marketing / advertising\" paragraph described\n * the opt-out posture, while the per-cookie table said `_gac`, `_gcl_au`,\n * `_fbp` and `_fbc` were \"set only where you have allowed advertising cookies\"\n * and \"Your choices\" repeated it. A policy that says both cannot authorise the\n * wider behaviour, so the code was narrowed to the strictest thing it said.\n *\n * On 2026-08-25 the master moved for real, in the right order: those five\n * opt-in-only sentences were rewritten in the Cookie Policy gdoc, and only then\n * did this function follow. The Privacy Policy needed nothing — it already said\n * *\"With your consent, we do 'share' … for cross-context behavioral\n * advertising\"*, named Google and Meta, and described the EU/UK-ask-first vs.\n * elsewhere-from-first-visit split. The flat \"we do not sell or share\" denial\n * that the narrowing reasoned from was a paraphrase in `docs/ANALYTICS.md`, not\n * a sentence in the document.\n *\n * ⛔ Re-narrowing is a POLICY act, not a code change. Move the published\n * masters first and let this follow — never the other way round. Nine surfaces\n * describe this rule to a human and `consent-advertising-copy-drift.spec.ts`\n * fails on all of them the moment the two disagree.\n *\n * A `true` here is necessary, never sufficient: the host must ALSO have opted\n * into asking (see {@link hostAsksAboutAdvertising}), and a refusal — declined,\n * opted out, or a GPC signal — outranks the default everywhere.\n */\nexport function advertisingGrantedByStatus(\n status: VisitorConsentStatus,\n): boolean {\n // Fails CLOSED: an exact match against the granting statuses, so any\n // unknown, absent or future status answers `false`. `strictNullChecks` is\n // off repo-wide, which means `status` can be `null`/`undefined` at runtime\n // despite the type — an equality test denies those, an exclusion list\n // (`status !== 'declined' && …`) would have granted them.\n //\n // `implied` GRANTS from 2026-08-25, and the policy went first (the call,\n // executed in that order). `decideVisitorConsent` only ever writes `implied`\n // in the OPT-OUT posture — `resolveConsentPosture` keeps the EU 27, the\n // EEA/EFTA three, the UK, Gibraltar, the EU outermost regions AND any visitor\n // whose region cannot be determined in opt-in, where no default is recorded at\n // all — so this widens advertising to the rest of the world only, and the\n // geographic guarantee `visitor-consent-advertising.spec.ts` asserts is\n // untouched. `gpc-opt-out` and `declined` still answer `false`.\n //\n // The disclosure this rests on is now consistent in BOTH masters, which is\n // what the 2026-08-24 narrowing was waiting for: the Privacy Policy already\n // said *\"With your consent, we do 'share' … Where the law requires us to ask\n // first — the EU, the UK, and anywhere we cannot determine your region — it\n // does not load until you accept. Elsewhere it runs from your first visit\"*,\n // and the Cookie Policy's two remaining opt-in-only sentences (the ×4\n // per-cookie note and the \"Your choices\" bullet) were rewritten to match it\n // the same day. Re-narrow this ONLY by moving the policies back first.\n return status === 'accepted' || status === 'implied'\n}\n\n/**\n * Strict GA measurement-id check (AGL-138) — the id lands inside an inline\n * script, so the format gate is load-bearing, not cosmetic.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{4,16}$/\n\n/**\n * The shape a Meta pixel id has, shared by the LOADER and the console field.\n *\n * Declared HERE and not in `advertising-tags.ts`, which the console may not\n * import: `advertising-tag-gate.spec` case (f) fails any console file that\n * reaches for that module, because it is what mounts a vendor's script. A\n * format string is not a vendor, so it lives with the other id patterns and\n * the loader imports it from here.\n *\n * One pattern, because two would be a form that accepts an id the loader then\n * refuses — the tag never mounts and the operator is left looking at a saved\n * field and no pixel.\n */\nexport const META_PIXEL_ID_PATTERN = /^[0-9]{8,20}$/\n\n/**\n * A Google Ads conversion id — `AW-` and digits.\n *\n * Distinct from `GA_MEASUREMENT_ID_PATTERN` (`G-…`) on purpose: they are\n * different products with different ids, and a form that accepted either in\n * either field would load an analytics id into an ads tag and report nothing.\n */\nexport const GOOGLE_ADS_ID_PATTERN = /^AW-[0-9]{6,16}$/\n\n/**\n * A LinkedIn Insight partner id — numeric, and short.\n *\n * Bounded well under the Meta band rather than sharing it: a partner id is six\n * or seven digits today, and a pattern loose enough to accept a Meta pixel id\n * would let a mis-paste into the wrong field validate and then load a tag that\n * matches nothing.\n */\nexport const LINKEDIN_PARTNER_ID_PATTERN = /^[0-9]{4,10}$/\n\n/** The configured GA id when it is well-formed, else null. */\nexport function resolveGaMeasurementId(\n host: VisitorConsentHost | null | undefined,\n): string | null {\n const candidate = String(host?.analytics?.gaMeasurementId ?? '')\n return GA_MEASUREMENT_ID_PATTERN.test(candidate) ? candidate : null\n}\n\n/**\n * A Google Tag Manager container id — `GTM-` and 5–10 upper-case\n * alphanumerics.\n *\n * Format-checked for the same reason the GA id is: it lands inside an INLINE\n * SCRIPT on a published page, so anything that is not this shape must never\n * reach it (AGL-138).\n */\nexport const GTM_CONTAINER_ID_PATTERN = /^GTM-[A-Z0-9]{5,10}$/\n\n/** The configured GTM container when it is well-formed, else null. */\nexport function resolveGtmContainerId(\n host: VisitorConsentHost | null | undefined,\n): string | null {\n const candidate = String(host?.analytics?.gtmContainerId ?? '')\n return GTM_CONTAINER_ID_PATTERN.test(candidate) ? candidate : null\n}\n\n/**\n * Whether the site loads ANY Google tag — a GA property, a GTM container, or\n * both (AGL-2486).\n *\n * The one predicate both consent questions are asked through, because the\n * alternative is two places that each remember only one of the two. A site\n * with a container and no GA id is a site that tracks: it was the shape that\n * would have had `consentGatedCategories` return `[]`, no banner rendered, and\n * the container loading ungated — which for GTM is worse than for GA, since a\n * container is exactly the thing that can carry advertising tags.\n */\nexport function hostHasGoogleTag(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n return !!resolveGaMeasurementId(host) || !!resolveGtmContainerId(host)\n}\n\n/** Host opt-out of the tool. Absent means ACTIVE — the safe default. */\nexport function isConsentToolDisabled(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n return host?.consent?.disabled === true\n}\n\n/** The host's posture choice; anything but `'strict'` reads as `'geo'`. */\nexport function resolveHostConsentMode(\n host: VisitorConsentHost | null | undefined,\n): HostConsentMode {\n return host?.consent?.mode === 'strict' ? 'strict' : 'geo'\n}\n\n/**\n * The gated categories this site actually uses. Empty means there is\n * nothing to consent to and no consent UI renders — a banner with no\n * question behind it is decoration that trains visitors to click banners.\n */\nexport function consentGatedCategories(\n host: VisitorConsentHost | null | undefined,\n): VisitorConsentCategory[] {\n // A GTM CONTAINER counts (AGL-2486). Read as `resolveGaMeasurementId` alone,\n // a site running only a container had no gated category, so no banner\n // rendered and the container loaded ungated — see `hostHasGoogleTag`.\n if (!hostHasGoogleTag(host)) return []\n return hostAsksAboutAdvertising(host)\n ? ['analytics', 'advertising']\n : ['analytics']\n}\n\n/**\n * Whether this site asks its visitors about advertising storage (AGL-1649).\n *\n * Two conditions, both required. The host has to have opted in — the flag is\n * off for every site that exists and stays off unless someone turns it on —\n * and the site has to actually have an analytics tag, because advertising\n * storage here is GA4's `ad_storage` and there is nothing to ask about\n * without one. A banner with no question behind it is decoration (AGL-1498).\n */\nexport function hostAsksAboutAdvertising(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n // Either tag qualifies (AGL-2486): `ad_storage` is what a GA property reads\n // and what a GTM container's advertising tags read, and a container is the\n // likelier of the two to carry them.\n return host?.consent?.advertising === true && hostHasGoogleTag(host)\n}\n\n/**\n * Whether the published site runs the consent machinery at all: the tool is\n * active AND the site uses at least one gated feature.\n */\nexport function hostConsentRequired(\n host: VisitorConsentHost | null | undefined,\n): boolean {\n return !isConsentToolDisabled(host) && consentGatedCategories(host).length > 0\n}\n\n/**\n * Regions whose law requires PRIOR consent for non-essential\n * cookies/identifiers (ePrivacy and its UK retention): the EU 27, the three\n * EEA/EFTA states, the UK, Gibraltar (UK GDPR extends there), and the EU\n * outermost regions that carry their own ISO codes. ISO 3166-1 alpha-2,\n * matching `x-vercel-ip-country`.\n *\n * Deliberately NOT a \"privacy-law countries\" list: plenty of laws (CCPA,\n * LGPD, PIPEDA) regulate tracking on an opt-out basis, which is exactly what\n * the opt-out posture plus GPC provides. This set is only the prior-consent\n * boundary.\n */\nexport const PRIOR_CONSENT_COUNTRY_CODES: ReadonlySet<string> = new Set([\n // EU 27\n 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR',\n 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK',\n 'SI', 'ES', 'SE',\n // EEA / EFTA\n 'IS', 'LI', 'NO',\n // UK and Gibraltar\n 'GB', 'GI',\n // EU outermost regions with their own ISO codes (legally EU territory)\n 'AX', 'GF', 'GP', 'MQ', 'RE', 'YT',\n])\n\n/**\n * The per-visitor posture. Client-evaluated (ISR forbids varying the HTML),\n * from the country the region endpoint reports.\n *\n * Unknown region (`null`) falls to `opt-in` — the ONE place this feature\n * does not maximize tracking, because the asymmetry is lopsided: a few lost\n * analytics events on rare headerless visits, versus pre-consent tracking\n * of an EU visitor, which \"track as soon as we legally can\" excludes by its\n * own terms.\n */\nexport function resolveConsentPosture(\n host: VisitorConsentHost | null | undefined,\n country: string | null | undefined,\n): VisitorConsentPosture {\n if (resolveHostConsentMode(host) === 'strict') return 'opt-in'\n if (!country) return 'opt-in'\n return PRIOR_CONSENT_COUNTRY_CODES.has(country.toUpperCase())\n ? 'opt-in'\n : 'opt-out'\n}\n\n/**\n * Whether this browser sends Global Privacy Control. Read live at decision\n * time — the signal is the user agent's, not ours to cache.\n */\nexport function hasGlobalPrivacyControl(): boolean {\n return (\n typeof navigator !== 'undefined' &&\n (navigator as { globalPrivacyControl?: unknown }).globalPrivacyControl ===\n true\n )\n}\n\n/**\n * A visitor's stored consent record. Versioned so a future category\n * addition can distinguish \"declined analytics\" from \"was never asked about\n * marketing\".\n */\nexport interface StoredVisitorConsent {\n v: 1\n /** Epoch ms of the decision (or of the implied default's recording). */\n at: number\n status: VisitorConsentStatus\n /** The category grant, derived from `status` at store time. */\n analytics: boolean\n /**\n * The advertising grant (AGL-1649). ABSENT on every record written before\n * the category existed, and absent reads as `false` — \"was never asked\"\n * is not \"said yes\". Reading it the other way would hand Google a basis\n * for every visitor who ever clicked Allow on an analytics-only banner.\n *\n * Carried by an explicit accept, and — since 2026-08-25 — by the opt-out\n * posture's implied default, on a site whose host turned the question on.\n * Never by a refusal: declined, opted out and GPC all deny it everywhere.\n * See {@link advertisingGrantedByStatus} for the order the policy and the\n * code move in.\n */\n advertising?: boolean\n /** ISO country at decision time, when known — the `implied,us` shape. */\n country?: string | null\n}\n\n/**\n * Keyed by hostId, not a bare name: preview deployments and localhost serve\n * many hosts from one origin, and one site's \"yes\" must never leak to\n * another's.\n */\nexport const VISITOR_CONSENT_STORAGE_PREFIX = 'aglyn:consent:'\n\n/** The marketing runtime's cross-visit experiment id (`site-runtime.tsx`). */\nexport const VISITOR_ID_STORAGE_KEY = 'aglyn:visitor'\n\n/** Fired on `window` whenever {@link storeVisitorConsent} records a state. */\nexport const VISITOR_CONSENT_CHANGED_EVENT = 'aglyn:consent:changed'\n\n/**\n * Fired on `window` to open the privacy-choices panel — the change-your-mind\n * path, in BOTH directions. Sites can also link any element to\n * `#aglyn-consent`; the banner listens for those clicks.\n */\nexport const VISITOR_CONSENT_OPEN_EVENT = 'aglyn:consent:open'\n\nexport function visitorConsentStorageKey(hostId: string): string {\n return `${VISITOR_CONSENT_STORAGE_PREFIX}${hostId}`\n}\n\n/**\n * Cookie-name PREFIXES the analytics grant is answerable for (AGL-1606).\n *\n * A prefix sweep rather than a name list, because the names are derived and\n * configuration-dependent: `_ga` is joined by `_ga_<measurement id without the\n * `G-`>` per property, by `_gac_<id>` when Google Ads linking is on, and by\n * `_gid` where the legacy daily id is still issued. Hardcoding the two names a\n * given site happens to carry today is precisely how a withdrawal leaves an\n * identifier behind on the next site. `_ga` subsumes `_ga`, `_ga_*` and\n * `_gac_*`; `_gid` is the only Google analytics cookie that does not start\n * with it.\n */\nexport const ANALYTICS_COOKIE_PREFIXES: readonly string[] = ['_ga', '_gid']\n\n/**\n * Cookie-name PREFIXES the ADVERTISING grant is answerable for (AGL-2486).\n *\n * These are Google's, and they are separate from the analytics list above for\n * a reason that is not tidiness: an advertising-only withdrawal must sweep\n * them while analytics keeps running. Folding them into\n * {@link ANALYTICS_COOKIE_PREFIXES} would have swept them on ANALYTICS\n * withdrawal and still not on the case that motivates this — leaving the same\n * hole, narrower — besides filing a conversion-linker cookie under a category\n * it does not belong to.\n *\n * `_gcl_*` is the conversion linker, written by gtag once `ad_storage` is\n * granted, and it is the one that started this: it does not begin with `_ga`,\n * so nothing swept it, on any path.\n *\n * ONE PREFIX, deliberately. `_gac_*` (Google Ads linking) was in this list\n * briefly and is not, because it is written under the ANALYTICS grant and is\n * already swept by the `_ga` prefix. Listing it here made every\n * analytics-only GRANT delete it — on a site that never asks the advertising\n * question `stored.advertising` is always false, so the sweep ran on the way\n * IN — which broke \"cleanup is the withdrawal path only\" (AGL-1606). Sweeping\n * wider is not free when a category nobody was asked about owns the trigger.\n *\n * PRECISION MATTERS, because {@link clearCookiesWithPrefixes} is `startsWith`.\n * `_gcl` cannot reach `_ga`, `_ga_<id>`, `_gac_<id>` or `_gid` — those are\n * analytics and MUST survive an advertising-only withdrawal.\n */\nexport const ADVERTISING_COOKIE_PREFIXES: readonly string[] = ['_gcl']\n\n/**\n * Expire the advertising identifiers, on the same domain ladder as the\n * analytics sweep (AGL-2486).\n *\n * Deleting a cookie that was never set is a no-op; failing to delete one that\n * was is the defect — so this runs on every non-granting store rather than\n * only on a transition observed to be a withdrawal, which the record cannot\n * reliably tell us anyway.\n */\nexport function clearAdvertisingCookies(hostname?: string | null): string[] {\n return clearCookiesWithPrefixes(ADVERTISING_COOKIE_PREFIXES, hostname)\n}\n\n/**\n * Multi-label public suffixes: names under which registrations happen, so the\n * label to their left is the registrable one. A short list rather than the\n * Public Suffix List — the same trade `console-domains.ts` makes, and the\n * failure mode is mild in both directions: an unlisted suffix costs one\n * over-broad `domain=` write that the browser silently refuses (cookies cannot\n * be set on a public suffix), never a deletion on someone else's domain.\n */\nconst MULTI_LABEL_PUBLIC_SUFFIXES: ReadonlySet<string> = new Set([\n 'co.uk', 'org.uk', 'me.uk', 'ac.uk', 'gov.uk',\n 'com.au', 'net.au', 'org.au',\n 'co.nz', 'co.za',\n 'co.jp', 'ne.jp', 'or.jp',\n 'com.br', 'com.mx', 'com.cn', 'com.tr',\n 'co.in', 'co.kr', 'com.sg', 'com.hk',\n])\n\nfunction normalizeCookieHostname(hostname: string | null | undefined): string {\n return String(hostname ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\.+$/, '')\n}\n\n/**\n * The registrable domain of a hostname — the highest name a cookie may be\n * written at — or `null` when there is none.\n *\n * This is the fiddly half of the cleanup. GA's `cookie_domain: 'auto'` walks\n * UP from the hostname and writes at the highest domain the browser accepts,\n * which is the registrable domain with a leading dot: a visit to\n * `www.shop.acme.co.uk` leaves `_ga` on `.acme.co.uk`, not on the exact host.\n * A deletion aimed at the exact hostname therefore silently no-ops — it writes\n * a second, host-scoped, already-expired cookie and leaves the real one alone.\n *\n * `null` for a single-label host (`localhost`) and for IP literals: those take\n * host-only cookies, which the undecorated deletion already covers.\n */\nexport function registrableCookieDomain(\n hostname: string | null | undefined,\n): string | null {\n // Named `name`, not `host`: `host.foo` inside app-utils is what the\n // AGL-1361 write-deny sweep reads as a host-document field, and a local\n // string's `.split`/`.includes` would land in it as phantom fields.\n const name = normalizeCookieHostname(hostname)\n if (!name) return null\n // IPv6 literal (bracketed or bare) and IPv4 literal: host-only cookies.\n if (name.startsWith('[') || name.includes(':')) return null\n if (/^\\d+(\\.\\d+){3}$/.test(name)) return null\n const labels = name.split('.')\n if (labels.length < 2 || labels.some((label) => !label)) return null\n const lastTwo = labels.slice(-2).join('.')\n if (MULTI_LABEL_PUBLIC_SUFFIXES.has(lastTwo)) {\n // `acme.co.uk` is registrable; `co.uk` on its own is not a domain at all.\n return labels.length > 2 ? labels.slice(-3).join('.') : null\n }\n return lastTwo\n}\n\n/**\n * Every `domain=` value a cookie deletion has to try, most specific first and\n * stopping at the registrable domain.\n *\n * The whole ladder rather than the registrable domain alone because\n * `cookie_domain` is configurable: a host that pins it to `shop.acme.com`\n * leaves the cookie one rung below where `auto` would. Walking the rungs costs\n * a handful of no-op writes and removes the guess.\n *\n * Empty when there is no registrable domain — the host-only deletion that\n * {@link clearAnalyticsCookies} always performs is the complete answer there.\n */\nexport function analyticsCookieDomains(\n hostname: string | null | undefined,\n): string[] {\n const name = normalizeCookieHostname(hostname)\n const registrable = registrableCookieDomain(name)\n if (!registrable) return []\n const domains: string[] = []\n let current = name\n for (;;) {\n domains.push(`.${current}`)\n if (current === registrable) break\n const cut = current.indexOf('.')\n if (cut < 0) break\n current = current.slice(cut + 1)\n }\n return domains\n}\n\n/**\n * Expire every analytics cookie this browser is carrying, at the host itself\n * and at every domain up to the registrable one. Returns the names it acted\n * on, which is what makes the cleanup assertable.\n *\n * Best-effort by construction: a cookie written at a path other than `/` is\n * not reachable this way, and `document.cookie` cannot report which domain a\n * cookie came from — hence the ladder. Neither limit applies to the cookies GA\n * actually writes (path `/`, registrable domain).\n */\nexport function clearAnalyticsCookies(\n hostname?: string | null,\n): string[] {\n return clearCookiesWithPrefixes(ANALYTICS_COOKIE_PREFIXES, hostname)\n}\n\n/**\n * The same sweep for an ARBITRARY prefix set — the generalisation\n * {@link clearAnalyticsCookies} now delegates to.\n *\n * Extracted rather than copied because the hard part is not the prefix match,\n * it is {@link analyticsCookieDomains}: the ladder from the exact hostname up\n * to the registrable domain, which exists because a tag written with an `auto`\n * cookie domain leaves its identifier one or more rungs ABOVE the host. A\n * second vendor's sweep re-derived by hand would get that wrong the first time\n * and pass its own test, because a deletion aimed at the wrong domain silently\n * no-ops — it writes an already-expired host-scoped cookie and reports success.\n * One implementation, one ladder, one place to fix.\n *\n * Meta's `_fbp`/`_fbc` are written exactly like `_ga`: path `/`, registrable\n * domain, first-party. Nothing here is Google-specific and nothing here is\n * Meta-specific.\n */\nexport function clearCookiesWithPrefixes(\n prefixes: readonly string[],\n hostname?: string | null,\n): string[] {\n if (typeof document === 'undefined') return []\n // An empty prefix set would match EVERY cookie via `''.startsWith`, which\n // would delete the cart, the member session and the consent record itself.\n // A caller with nothing to sweep gets nothing swept.\n if (!prefixes || prefixes.length === 0) return []\n try {\n const names = new Set<string>()\n for (const pair of String(document.cookie ?? '').split(';')) {\n const name = pair.split('=')[0].trim()\n if (name && prefixes.some((p) => !!p && name.startsWith(p))) {\n names.add(name)\n }\n }\n if (names.size === 0) return []\n const domains = analyticsCookieDomains(\n hostname ??\n (typeof window !== 'undefined' ? window.location?.hostname : ''),\n )\n for (const name of names) {\n const expired = `${name}=; Max-Age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`\n document.cookie = expired\n for (const domain of domains) {\n document.cookie = `${expired}; domain=${domain}`\n }\n }\n return [...names]\n } catch {\n // Cookies disabled or a hostile `document.cookie`: the gate still keeps\n // the script out, which is the load-bearing half.\n return []\n }\n}\n\n/**\n * The `window` flag `gtag.js` consults before every hit: with\n * `window['ga-disable-G-XXXX'] === true` the tag for that property sends\n * nothing and writes nothing. Google's own documented opt-out mechanism, and\n * the one that works on a tag which never received a consent-mode default\n * (AGL-1608) — a case that still exists after AGL-1622, because a tag arriving\n * through GTM or a host's own CMP is not one this module declared for.\n */\nexport const GA_DISABLE_FLAG_PREFIX = 'ga-disable-'\n\n/** A single consent-mode signal value. */\nexport type AnalyticsConsentSignal = 'granted' | 'denied'\n\n/**\n * The consent-mode signal set this tool is answerable for.\n *\n * `ad_storage` and friends are typed as the literal `'denied'`, not as\n * {@link AnalyticsConsentSignal}: the tool asks a visitor about analytics and\n * nothing else, so there is no advertising basis on file to grant, in either\n * direction. Widening them is a change the type has to be edited to allow.\n */\nexport interface AnalyticsConsentSignals {\n analytics_storage: AnalyticsConsentSignal\n ad_storage: 'denied'\n ad_user_data: 'denied'\n ad_personalization: 'denied'\n}\n\n/**\n * The consent-mode payload for a given analytics grant — ONE source, so the\n * `default` declared before the tag loads (AGL-1622) and the `update` sent on a\n * mid-pageview withdrawal (AGL-1608) can never drift apart. Two hand-written\n * literals that disagree is how a tag ends up believing a state the visitor\n * never chose.\n */\nexport function analyticsConsentSignals(\n granted: boolean,\n): AnalyticsConsentSignals {\n return {\n analytics_storage: granted ? 'granted' : 'denied',\n ad_storage: 'denied',\n ad_user_data: 'denied',\n ad_personalization: 'denied',\n }\n}\n\n/** The four signals when advertising is in play (AGL-1649). */\nexport interface VisitorConsentSignals {\n analytics_storage: AnalyticsConsentSignal\n ad_storage: AnalyticsConsentSignal\n ad_user_data: AnalyticsConsentSignal\n ad_personalization: AnalyticsConsentSignal\n}\n\n/**\n * The consent-mode payload for a pair of category grants (AGL-1649).\n *\n * A SEPARATE function from {@link analyticsConsentSignals} rather than a\n * widening of it, deliberately. AGL-1649 says not to widen those three\n * literals, and the reason survives this change: a caller that only knows\n * about analytics should be unable to express a grant it has no answer for.\n * Reaching an advertising grant now requires calling something else and\n * passing it, which is a thing a reviewer can see in a diff.\n *\n * Advertising is clamped to analytics. `ad_storage: 'granted'` with\n * `analytics_storage: 'denied'` is not a state this tool can reach honestly —\n * every refusal path (declined, opted out, GPC) withdraws both together — so\n * the clamp makes the impossible state unrepresentable rather than merely\n * unreached.\n */\nexport function consentModeSignals(grants: {\n analytics: boolean\n advertising: boolean\n}): VisitorConsentSignals {\n const analytics = grants.analytics === true\n const advertising = analytics && grants.advertising === true\n const ads: AnalyticsConsentSignal = advertising ? 'granted' : 'denied'\n return {\n analytics_storage: analytics ? 'granted' : 'denied',\n ad_storage: ads,\n ad_user_data: ads,\n ad_personalization: ads,\n }\n}\n\n/**\n * Whether advertising storage may run for this visitor on this site.\n *\n * THREE independent conditions, and all of them are load-bearing:\n *\n * 1. The host asks about advertising at all. A record that says yes is\n * ignored on a site that never turned the category on — which covers a\n * host switching it back off, and covers a stale record generally.\n * 2. The record's status can carry a grant — an explicit accept, and nothing\n * else (see {@link advertisingGrantedByStatus}). An `implied` default\n * cannot, and every refusal status refuses.\n * 3. The record actually says yes to this category.\n *\n * Absent, unreadable or legacy records all answer `false`, which is the same\n * answer they gave before the category existed.\n */\nexport function advertisingGrantedByRecord(\n host: VisitorConsentHost | null | undefined,\n stored: StoredVisitorConsent | null | undefined,\n): boolean {\n if (!hostAsksAboutAdvertising(host)) return false\n if (!stored) return false\n if (!advertisingGrantedByStatus(stored.status)) return false\n return stored.advertising === true\n}\n\n/**\n * The consent-mode `default` declaration that goes in front of the tag\n * (AGL-1622), for injection into the same inline block that creates\n * `dataLayer` and calls `gtag('config', …)`.\n *\n * ## What this is, and — load-bearing — what it is NOT\n *\n * It is NOT a licence to load the tag earlier. It is emitted INSIDE the block\n * that only renders when the AGL-1498 gate has already said yes, so it is\n * reached only on a pageview where analytics is granted. On every gated\n * pageview — an EU/UK/EEA visitor with no explicit accept, an unknown region, a\n * declined or opted-out visitor, a GPC browser — nothing here renders, no\n * request goes to `googletagmanager.com`, and this constant is never evaluated\n * into the page. \"The script never LOADS, rather than load-then-suppress\" is a\n * stated AGL-1498 property and it is intact everywhere the gate applies;\n * `consent-mode-default.spec.tsx` fails if that ever stops being true.\n *\n * the decision, 2026-08-14: load-then-restrict is approved for the UNITED\n * STATES, where the implied-consent posture already permits the load and the\n * restriction signals act on a tag that is legitimately resident. EU and UK\n * keep the prior-consent gate exactly as it is, because loading an analytics\n * tag before consent is the specific thing prior-consent law prohibits. So the\n * consent-mode mechanism is strictly ADDITIVE to the gate, never a replacement\n * for it.\n *\n * What declaring it actually buys, given the tag is granted by the time it is\n * read:\n *\n * - The advertising signals are denied FROM THE FIRST HIT. Without a default,\n * a freshly loaded tag runs with `ad_storage` unrestricted and only becomes\n * denied if the visitor happens to withdraw and change their mind again\n * (AGL-1608's `update`). The load-time state now matches the tool's actual\n * scope instead of being wider than it.\n * - A later `update` is a transition from a declared state rather than a tag's\n * first-ever consent signal, which is the shape GA4's consent-mode reporting\n * and any third-party CMP alongside ours both expect.\n *\n * The value is a literal, built from {@link analyticsConsentSignals} — no\n * interpolated input reaches it, which matters because it lands inside an\n * inline script (the AGL-138 concern).\n */\nexport const GA_CONSENT_DEFAULT_SNIPPET = `gtag('consent', 'default', ${JSON.stringify(\n analyticsConsentSignals(true),\n)});`\n\n/**\n * The same declaration for a visitor who granted ADVERTISING too (AGL-1649).\n *\n * A second CONSTANT rather than a parameterised builder, for the reason the\n * first one is a constant: it lands inside an inline script (the AGL-138\n * concern), and a constant cannot interpolate anything. Both values are\n * literals fixed at module load; the only thing the caller chooses is which\n * of the two to emit.\n *\n * Reached only where {@link advertisingGrantedByRecord} says yes — host\n * opted in, explicit accept, explicit yes to this category — which is\n * evaluated client-side after hydration, like every other part of this gate.\n * The ISR property is unaffected: the cached HTML contains neither snippet,\n * because the whole block is inside the client-side `analyticsAllowed`\n * condition.\n *\n * Declaring the grant as the DEFAULT rather than sending a later `update` is\n * what keeps the tag's first hit correct. An update would arrive after\n * `config`, so the session's first pageview — usually the entire session for\n * a marketing visit — would carry the denied state the visitor did not\n * choose.\n */\nexport const GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET = `gtag('consent', 'default', ${JSON.stringify(\n consentModeSignals({ analytics: true, advertising: true }),\n)});`\n\n/**\n * Consent-mode URL passthrough for the platform's own tag (AGL-2548).\n *\n * The advertising signals in {@link GA_CONSENT_DEFAULT_SNIPPET} are denied, so\n * on `aglyn.com` gtag writes no `_gcl_aw` cookie and the `gclid` an ad click\n * arrives with lives only in that first URL. The conversions it should be\n * credited for fire one hop later, on the console, where the posture grants\n * advertising storage outside the prior-consent regions — and without this\n * declaration the click id never makes that hop. The console reports a\n * conversion with no click behind it, and Google Ads counts nothing.\n *\n * `url_passthrough` is Google's mechanism for exactly this state: while\n * `ad_storage` is denied, gtag appends the click identifiers to links into the\n * cross-domain-linked hosts as URL parameters instead of setting a cookie. The\n * receiving tag reads them under its own consent state. Nothing is stored on\n * the denying surface, so the marketing site's opt-in advertising posture is\n * unchanged; the click id simply survives the link it was always meant to\n * survive.\n *\n * `ads_data_redaction` is the companion Google documents alongside it: with\n * `ad_storage` denied, the ad click identifiers are stripped from the hits this\n * tag sends and its network requests go to a cookieless endpoint. Declared\n * together so a denied visitor is measured with less, not merely differently.\n *\n * Both are `set` calls, so they must precede `gtag('config', …)` in the same\n * inline block — a `set` applies to hits processed after it, and the session's\n * first pageview is the one carrying the click.\n *\n * PLATFORM ID ONLY. A customer's tag is configured with their id and their\n * consent posture; how their ad click ids travel is theirs to decide, and a\n * passthrough declared on their behalf would rewrite links on their site.\n */\nexport const GA_CLICK_ID_PASSTHROUGH_SNIPPET =\n \"gtag('set', 'url_passthrough', true);\" +\n \"gtag('set', 'ads_data_redaction', true);\"\n\n/**\n * The measurement ids whose tag is actually RESIDENT in this page.\n *\n * Discovered from the page rather than taken from the host document on\n * purpose: what has to be silenced is whatever gtag.js is currently loaded and\n * configured for, which is not always what the host record configures — a tag\n * can arrive through GTM, and a stale `<script>` from earlier in the pageview\n * is exactly the thing this function exists to find.\n *\n * Ids are format-checked before they are used, because each one becomes a\n * `window` property name.\n */\nexport function residentGaMeasurementIds(): string[] {\n if (typeof document === 'undefined') return []\n const ids = new Set<string>()\n try {\n const loaded = document.querySelectorAll(\n 'script[src*=\"googletagmanager.com/gtag/js\"]',\n )\n for (const element of Array.from(loaded)) {\n const src = String((element as HTMLScriptElement).src ?? '')\n const match = /[?&]id=([^&]*)/.exec(src)\n const id = match ? decodeURIComponent(match[1]) : ''\n if (GA_MEASUREMENT_ID_PATTERN.test(id)) ids.add(id)\n }\n } catch {\n // A hostile or absent DOM: the dataLayer pass below still applies.\n }\n try {\n const layer = (window as unknown as Record<string, unknown>)?.dataLayer\n for (const entry of Array.from((layer ?? []) as ArrayLike<unknown>)) {\n // `arguments` objects, not arrays — gtag pushes its own call sites.\n const args = Array.from((entry ?? []) as ArrayLike<unknown>)\n if (args[0] !== 'config') continue\n const id = String(args[1] ?? '')\n if (GA_MEASUREMENT_ID_PATTERN.test(id)) ids.add(id)\n }\n } catch {\n // No dataLayer, or one that is not iterable: the script pass stands.\n }\n return [...ids]\n}\n\n/**\n * Make every resident GA tag agree with the consent state, and return the ids\n * it acted on.\n *\n * ## Why deleting the cookies is not enough (AGL-1608)\n *\n * The AGL-1498 gate stops the script from LOADING, which is the right\n * enforcement for a fresh pageview and the wrong tool for a visitor who\n * withdraws mid-pageview: `gtag.js` has already executed, and React unmounting\n * the `<script>` element does not unload it. `window.gtag` stays live, GA4\n * enhanced measurement fires on its own (scroll depth, outbound click, file\n * download), and the tag re-writes `_ga_<id>` AFTER\n * {@link clearAnalyticsCookies} has deleted it. Reproduced on aglyn.com: an\n * opt-out, a hand-run sweep to zero cookies, then one scroll to the footer\n * brought `_ga_YW5PG16YTM` back.\n *\n * Two signals, because they fail differently and neither is guaranteed:\n * the `ga-disable-<id>` window flag silences a tag that never received a\n * consent-mode default, and the `consent`/`update` call reaches a tag that\n * arrived through GTM with its own id this function never saw.\n *\n * ## What this deliberately does NOT do\n *\n * It never touches the gate. Both signals act only on a tag that is already\n * resident — which, by construction, only exists after a grant. AGL-1622 later\n * added a consent-mode DEFAULT ({@link GA_CONSENT_DEFAULT_SNIPPET}), but inside\n * the gated block and therefore only on a pageview the gate already allowed:\n * load-then-restrict is additive to \"the script never LOADS\", not a\n * replacement for it, and the prior-consent regions are unaffected.\n *\n * Symmetric on purpose: a visitor who opts out and changes their mind in the\n * same pageview would otherwise stay silently unmeasured until they navigated,\n * because the re-rendered `<Script>` cannot re-execute an already-loaded tag.\n * The re-grant restores whatever the CALLER passes — analytics alone on a site\n * that never asks the advertising question (`advertising` defaults to false),\n * and both categories where the visitor's record grants both (AGL-1649). The\n * clamp in {@link consentModeSignals} still makes ads-without-analytics\n * unrepresentable, so a withdrawal can never restore advertising alone.\n */\nexport function setResidentAnalyticsTags(\n granted: boolean,\n advertising = false,\n): string[] {\n if (typeof window === 'undefined') return []\n const ids = residentGaMeasurementIds()\n const scope = window as unknown as Record<string, unknown>\n for (const id of ids) {\n scope[`${GA_DISABLE_FLAG_PREFIX}${id}`] = !granted\n }\n try {\n const send = scope.gtag\n if (typeof send === 'function') {\n // The same payload builder the load-time `default` is made from\n // (AGL-1622), so the two declarations cannot drift.\n ;(send as (...args: unknown[]) => void)(\n 'consent',\n 'update',\n consentModeSignals({ analytics: granted, advertising }),\n )\n }\n } catch {\n // A tag that throws on its own consent call is one we cannot silence;\n // the flag above and the cookie sweep still stand.\n }\n return ids\n}\n\n/**\n * The stored record, or null when there is none (never resolved, storage\n * unavailable, or an unrecognized shape — treated alike: not yet decided).\n */\nexport function readStoredVisitorConsent(\n hostId: string | null | undefined,\n): StoredVisitorConsent | null {\n if (!hostId || typeof window === 'undefined') return null\n try {\n return parseStoredVisitorConsent(\n window.localStorage.getItem(visitorConsentStorageKey(hostId)),\n )\n } catch {\n // No storage (privacy mode) — the visitor is simply undecided, which is\n // the safe reading.\n return null\n }\n}\n\n/**\n * A stored record's text as the record it may claim to be, or null.\n *\n * The one validation every copy of a record passes through — the per-origin\n * store above, and the `aglyn_consent` mirror a server reads off a request\n * (AGL-3289) — so a copy read anywhere grants exactly what its status\n * supports and nothing a hand edit added.\n */\nexport function parseStoredVisitorConsent(\n raw: string | null | undefined,\n): StoredVisitorConsent | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw)\n if (\n parsed?.v !== 1 ||\n typeof parsed.status !== 'string' ||\n !CONSENT_STATUSES.has(parsed.status)\n ) {\n return null\n }\n const status = parsed.status as VisitorConsentStatus\n return {\n v: 1,\n at: typeof parsed.at === 'number' ? parsed.at : 0,\n status,\n // Derived, never trusted from storage: a hand-edited record must not\n // grant what its status does not.\n analytics: analyticsGrantedByStatus(status),\n // Same discipline for advertising (AGL-1649), plus one more clause:\n // the key has to be PRESENT and true. Absent is a record written\n // before the category existed — never asked, which is not a yes.\n advertising:\n advertisingGrantedByStatus(status) && parsed.advertising === true,\n country: typeof parsed.country === 'string' ? parsed.country : null,\n }\n } catch {\n // Corrupt JSON — the visitor is simply undecided, which is the safe\n // reading.\n return null\n }\n}\n\n/**\n * Record a consent state and make the page agree with it immediately.\n *\n * Storing the record is itself strictly-necessary storage (it is how a \"no\"\n * is remembered), so it is exempt from the consent it records. Any\n * non-granting state also REMOVES the identifiers the grant allowed —\n * the persistent `aglyn:visitor` id AND the GA cookies\n * ({@link clearAnalyticsCookies}): opting out cleans up, it does not merely\n * stop adding (AGL-1606) — and SILENCES the tag that is already running\n * ({@link setResidentAnalyticsTags}), without which the cleanup un-does itself\n * within the same pageview (AGL-1608). Listeners hear about it via\n * {@link VISITOR_CONSENT_CHANGED_EVENT} so the GA gate and the runtime react\n * without a reload.\n *\n * The two cleanups are deliberately in SEPARATE try blocks: private mode\n * throws on `localStorage` while cookies still work, and a withdrawal must\n * not lose the cookie sweep to that.\n */\nexport function storeVisitorConsent(\n hostId: string,\n state: {\n status: VisitorConsentStatus\n country?: string | null\n /**\n * The visitor's answer to the advertising question (AGL-1649). Omitted\n * by every caller that does not ask one, and omitted means NO.\n */\n advertising?: boolean\n },\n): StoredVisitorConsent {\n const stored: StoredVisitorConsent = {\n v: 1,\n at: Date.now(),\n status: state.status,\n analytics: analyticsGrantedByStatus(state.status),\n // Derived at store time exactly as `analytics` is, so a caller cannot\n // persist a grant the status does not support — a refusal always writes\n // `false` here even if the panel's checkbox was left ticked.\n advertising:\n advertisingGrantedByStatus(state.status) && state.advertising === true,\n country: state.country ?? null,\n }\n if (typeof window !== 'undefined') {\n try {\n window.localStorage.setItem(\n visitorConsentStorageKey(hostId),\n JSON.stringify(stored),\n )\n if (!stored.analytics) {\n window.localStorage.removeItem(VISITOR_ID_STORAGE_KEY)\n }\n } catch {\n // Private mode: the state holds for this page via the event below,\n // and is re-derived next visit — the safe failure.\n }\n // Silence the RESIDENT tag before sweeping, never after (AGL-1608): the\n // gate only keeps the script from LOADING, so a mid-pageview withdrawal\n // leaves gtag.js live and it re-writes `_ga_<id>` on its next enhanced\n // measurement event. Sweeping first would delete three cookies and\n // immediately get one back.\n setResidentAnalyticsTags(stored.analytics, stored.advertising === true)\n if (!stored.analytics) {\n clearAnalyticsCookies()\n }\n // The advertising half, and it belongs HERE rather than only in\n // `revokeAdvertisingTags` (AGL-2486). That function sweeps a vendor whose\n // marked script elements are in the document, and it is installed only on\n // our own marketing host — so on a CUSTOMER site, where the advertising\n // question is equally available, withdrawing advertising ran no sweep at\n // all. `_gcl_au` is written by the GA tag `site-analytics.tsx` mounts, not\n // by a script this module marked, so no element-scoped teardown was ever\n // going to reach it on any surface.\n //\n // This path is the one every site shares: it is where a decision becomes a\n // record. Unconditional on the grant being absent, for the reason in\n // `clearAdvertisingCookies` — a delete of something never set costs\n // nothing, a missed delete is the defect.\n if (!stored.advertising) {\n clearAdvertisingCookies()\n }\n try {\n window.dispatchEvent(new CustomEvent(VISITOR_CONSENT_CHANGED_EVENT))\n } catch {\n // A dispatch failure only delays consumers until the next read.\n }\n }\n return stored\n}\n\n/**\n * The GA gate's whole verdict: may the analytics category run for this\n * visitor, on this site, right now?\n *\n * - Consent machinery not required (no gated features, or host opted out of\n * the tool) → yes.\n * - Required and the recorded state grants analytics (`accepted` or\n * `implied`) → yes.\n * - Required and anything else — declined, opted out, GPC, or NOT YET\n * RESOLVED → no. Undecided is not a maybe: the script stays out until a\n * state that grants it exists. The gate never asks WHY consent is absent,\n * which is what keeps it posture-independent.\n */\nexport function isAnalyticsAllowed(\n host: VisitorConsentHost | null | undefined,\n stored: StoredVisitorConsent | null,\n): boolean {\n if (!hostConsentRequired(host)) return true\n return stored?.analytics === true\n}\n\n/**\n * Where the marketing runtime may keep its experiment visitor id.\n *\n * `local` — the id persists across visits. `session` — the id lives in\n * sessionStorage: variants stay stable within the visit and the identifier\n * dies with the tab. The downgrade applies while the consent machinery is\n * active for this site and analytics is not granted; the id itself never\n * leaves the browser either way (the experiment beacons are aggregate — no\n * visitor id on the wire).\n */\nexport function resolveVisitorIdPersistence(\n host: VisitorConsentHost | null | undefined,\n stored: StoredVisitorConsent | null,\n): 'local' | 'session' {\n return isAnalyticsAllowed(host, stored) ? 'local' : 'session'\n}\n"],"names":["CONSENT_STATUSES","Set","isExplicitConsentStatus","status","refusalStatusFor","stored","posture","analyticsGrantedByStatus","advertisingGrantedByStatus","GA_MEASUREMENT_ID_PATTERN","META_PIXEL_ID_PATTERN","GOOGLE_ADS_ID_PATTERN","LINKEDIN_PARTNER_ID_PATTERN","resolveGaMeasurementId","host","candidate","String","analytics","gaMeasurementId","test","GTM_CONTAINER_ID_PATTERN","resolveGtmContainerId","gtmContainerId","hostHasGoogleTag","isConsentToolDisabled","consent","disabled","resolveHostConsentMode","mode","consentGatedCategories","hostAsksAboutAdvertising","advertising","hostConsentRequired","length","PRIOR_CONSENT_COUNTRY_CODES","resolveConsentPosture","country","has","toUpperCase","hasGlobalPrivacyControl","navigator","globalPrivacyControl","VISITOR_CONSENT_STORAGE_PREFIX","VISITOR_ID_STORAGE_KEY","VISITOR_CONSENT_CHANGED_EVENT","VISITOR_CONSENT_OPEN_EVENT","visitorConsentStorageKey","hostId","ANALYTICS_COOKIE_PREFIXES","ADVERTISING_COOKIE_PREFIXES","clearAdvertisingCookies","hostname","clearCookiesWithPrefixes","MULTI_LABEL_PUBLIC_SUFFIXES","normalizeCookieHostname","trim","toLowerCase","replace","registrableCookieDomain","name","startsWith","includes","labels","split","some","label","lastTwo","slice","join","analyticsCookieDomains","registrable","domains","current","push","cut","indexOf","clearAnalyticsCookies","prefixes","document","window","names","pair","cookie","p","add","size","location","expired","domain","GA_DISABLE_FLAG_PREFIX","analyticsConsentSignals","granted","analytics_storage","ad_storage","ad_user_data","ad_personalization","consentModeSignals","grants","ads","advertisingGrantedByRecord","GA_CONSENT_DEFAULT_SNIPPET","JSON","stringify","GA_CONSENT_DEFAULT_WITH_ADS_SNIPPET","GA_CLICK_ID_PASSTHROUGH_SNIPPET","residentGaMeasurementIds","ids","loaded","querySelectorAll","element","Array","from","src","match","exec","id","decodeURIComponent","layer","dataLayer","entry","args","setResidentAnalyticsTags","scope","send","gtag","readStoredVisitorConsent","parseStoredVisitorConsent","localStorage","getItem","raw","parsed","parse","v","at","storeVisitorConsent","state","Date","now","setItem","removeItem","dispatchEvent","CustomEvent","isAnalyticsAllowed","resolveVisitorIdPersistence"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsDC,GAED,2EAA2E,GA8E3E,MAAMA,mBAAwC,IAAIC,IAAI;IACpD;IACA;IACA;IACA;IACA;CACD;AAED,4EAA4E,GAC5E,OAAO,SAASC,wBACdC,MAA+C;IAE/C,OACEA,WAAW,cAAcA,WAAW,cAAcA,WAAW;AAEjE;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,iBACdC,MAA4D,EAC5DC,OAAiD;IAEjD,OAAOD,CAAAA,0BAAAA,OAAQF,MAAM,MAAK,aAAaG,YAAY,YAC/C,cACA;AACN;AAEA,oDAAoD,GACpD,OAAO,SAASC,yBACdJ,MAA4B;IAE5B,OAAOA,WAAW,cAAcA,WAAW;AAC7C;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4CC,GACD,OAAO,SAASK,2BACdL,MAA4B;IAE5B,qEAAqE;IACrE,0EAA0E;IAC1E,2EAA2E;IAC3E,sEAAsE;IACtE,0DAA0D;IAC1D,EAAE;IACF,yEAAyE;IACzE,6EAA6E;IAC7E,wEAAwE;IACxE,8EAA8E;IAC9E,+EAA+E;IAC/E,0EAA0E;IAC1E,wEAAwE;IACxE,gEAAgE;IAChE,EAAE;IACF,2EAA2E;IAC3E,4EAA4E;IAC5E,6EAA6E;IAC7E,4EAA4E;IAC5E,6EAA6E;IAC7E,sEAAsE;IACtE,4EAA4E;IAC5E,uEAAuE;IACvE,OAAOA,WAAW,cAAcA,WAAW;AAC7C;AAEA;;;CAGC,GACD,OAAO,MAAMM,4BAA4B,qBAAoB;AAE7D;;;;;;;;;;;;CAYC,GACD,OAAO,MAAMC,wBAAwB,gBAAe;AAEpD;;;;;;CAMC,GACD,OAAO,MAAMC,wBAAwB,mBAAkB;AAEvD;;;;;;;CAOC,GACD,OAAO,MAAMC,8BAA8B,gBAAe;AAE1D,4DAA4D,GAC5D,OAAO,SAASC,uBACdC,IAA2C;;QAElBA;IAAzB,MAAMC,YAAYC,eAAOF,yBAAAA,kBAAAA,KAAMG,SAAS,qBAAfH,gBAAiBI,eAAe,mBAAI;IAC7D,OAAOT,0BAA0BU,IAAI,CAACJ,aAAaA,YAAY;AACjE;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMK,2BAA2B,uBAAsB;AAE9D,oEAAoE,GACpE,OAAO,SAASC,sBACdP,IAA2C;;QAElBA;IAAzB,MAAMC,YAAYC,eAAOF,yBAAAA,kBAAAA,KAAMG,SAAS,qBAAfH,gBAAiBQ,cAAc,mBAAI;IAC5D,OAAOF,yBAAyBD,IAAI,CAACJ,aAAaA,YAAY;AAChE;AAEA;;;;;;;;;;CAUC,GACD,OAAO,SAASQ,iBACdT,IAA2C;IAE3C,OAAO,CAAC,CAACD,uBAAuBC,SAAS,CAAC,CAACO,sBAAsBP;AACnE;AAEA,sEAAsE,GACtE,OAAO,SAASU,sBACdV,IAA2C;QAEpCA;IAAP,OAAOA,CAAAA,yBAAAA,gBAAAA,KAAMW,OAAO,qBAAbX,cAAeY,QAAQ,MAAK;AACrC;AAEA,yEAAyE,GACzE,OAAO,SAASC,uBACdb,IAA2C;QAEpCA;IAAP,OAAOA,CAAAA,yBAAAA,gBAAAA,KAAMW,OAAO,qBAAbX,cAAec,IAAI,MAAK,WAAW,WAAW;AACvD;AAEA;;;;CAIC,GACD,OAAO,SAASC,uBACdf,IAA2C;IAE3C,6EAA6E;IAC7E,sEAAsE;IACtE,sEAAsE;IACtE,IAAI,CAACS,iBAAiBT,OAAO,OAAO,EAAE;IACtC,OAAOgB,yBAAyBhB,QAC5B;QAAC;QAAa;KAAc,GAC5B;QAAC;KAAY;AACnB;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASgB,yBACdhB,IAA2C;QAKpCA;IAHP,4EAA4E;IAC5E,2EAA2E;IAC3E,qCAAqC;IACrC,OAAOA,CAAAA,yBAAAA,gBAAAA,KAAMW,OAAO,qBAAbX,cAAeiB,WAAW,MAAK,QAAQR,iBAAiBT;AACjE;AAEA;;;CAGC,GACD,OAAO,SAASkB,oBACdlB,IAA2C;IAE3C,OAAO,CAACU,sBAAsBV,SAASe,uBAAuBf,MAAMmB,MAAM,GAAG;AAC/E;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,8BAAmD,IAAIjC,IAAI;IACtE,QAAQ;IACR;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAClE;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAAM;IAClE;IAAM;IAAM;IACZ,aAAa;IACb;IAAM;IAAM;IACZ,mBAAmB;IACnB;IAAM;IACN,uEAAuE;IACvE;IAAM;IAAM;IAAM;IAAM;IAAM;CAC/B,EAAC;AAEF;;;;;;;;;CASC,GACD,OAAO,SAASkC,sBACdrB,IAA2C,EAC3CsB,OAAkC;IAElC,IAAIT,uBAAuBb,UAAU,UAAU,OAAO;IACtD,IAAI,CAACsB,SAAS,OAAO;IACrB,OAAOF,4BAA4BG,GAAG,CAACD,QAAQE,WAAW,MACtD,WACA;AACN;AAEA;;;CAGC,GACD,OAAO,SAASC;IACd,OACE,OAAOC,cAAc,eACrB,AAACA,UAAiDC,oBAAoB,KACpE;AAEN;AA+BA;;;;CAIC,GACD,OAAO,MAAMC,iCAAiC,iBAAgB;AAE9D,4EAA4E,GAC5E,OAAO,MAAMC,yBAAyB,gBAAe;AAErD,4EAA4E,GAC5E,OAAO,MAAMC,gCAAgC,wBAAuB;AAEpE;;;;CAIC,GACD,OAAO,MAAMC,6BAA6B,qBAAoB;AAE9D,OAAO,SAASC,yBAAyBC,MAAc;IACrD,OAAO,GAAGL,iCAAiCK,QAAQ;AACrD;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,MAAMC,4BAA+C;IAAC;IAAO;CAAO,CAAA;AAE3E;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMC,8BAAiD;IAAC;CAAO,CAAA;AAEtE;;;;;;;;CAQC,GACD,OAAO,SAASC,wBAAwBC,QAAwB;IAC9D,OAAOC,yBAAyBH,6BAA6BE;AAC/D;AAEA;;;;;;;CAOC,GACD,MAAME,8BAAmD,IAAIpD,IAAI;IAC/D;IAAS;IAAU;IAAS;IAAS;IACrC;IAAU;IAAU;IACpB;IAAS;IACT;IAAS;IAAS;IAClB;IAAU;IAAU;IAAU;IAC9B;IAAS;IAAS;IAAU;CAC7B;AAED,SAASqD,wBAAwBH,QAAmC;IAClE,OAAOnC,OAAOmC,mBAAAA,WAAY,IACvBI,IAAI,GACJC,WAAW,GACXC,OAAO,CAAC,QAAQ;AACrB;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASC,wBACdP,QAAmC;IAEnC,oEAAoE;IACpE,wEAAwE;IACxE,oEAAoE;IACpE,MAAMQ,OAAOL,wBAAwBH;IACrC,IAAI,CAACQ,MAAM,OAAO;IAClB,wEAAwE;IACxE,IAAIA,KAAKC,UAAU,CAAC,QAAQD,KAAKE,QAAQ,CAAC,MAAM,OAAO;IACvD,IAAI,kBAAkB1C,IAAI,CAACwC,OAAO,OAAO;IACzC,MAAMG,SAASH,KAAKI,KAAK,CAAC;IAC1B,IAAID,OAAO7B,MAAM,GAAG,KAAK6B,OAAOE,IAAI,CAAC,CAACC,QAAU,CAACA,QAAQ,OAAO;IAChE,MAAMC,UAAUJ,OAAOK,KAAK,CAAC,CAAC,GAAGC,IAAI,CAAC;IACtC,IAAIf,4BAA4BhB,GAAG,CAAC6B,UAAU;QAC5C,0EAA0E;QAC1E,OAAOJ,OAAO7B,MAAM,GAAG,IAAI6B,OAAOK,KAAK,CAAC,CAAC,GAAGC,IAAI,CAAC,OAAO;IAC1D;IACA,OAAOF;AACT;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASG,uBACdlB,QAAmC;IAEnC,MAAMQ,OAAOL,wBAAwBH;IACrC,MAAMmB,cAAcZ,wBAAwBC;IAC5C,IAAI,CAACW,aAAa,OAAO,EAAE;IAC3B,MAAMC,UAAoB,EAAE;IAC5B,IAAIC,UAAUb;IACd,OAAS;QACPY,QAAQE,IAAI,CAAC,CAAC,CAAC,EAAED,SAAS;QAC1B,IAAIA,YAAYF,aAAa;QAC7B,MAAMI,MAAMF,QAAQG,OAAO,CAAC;QAC5B,IAAID,MAAM,GAAG;QACbF,UAAUA,QAAQL,KAAK,CAACO,MAAM;IAChC;IACA,OAAOH;AACT;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASK,sBACdzB,QAAwB;IAExB,OAAOC,yBAAyBJ,2BAA2BG;AAC7D;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,yBACdyB,QAA2B,EAC3B1B,QAAwB;IAExB,IAAI,OAAO2B,aAAa,aAAa,OAAO,EAAE;IAC9C,0EAA0E;IAC1E,2EAA2E;IAC3E,qDAAqD;IACrD,IAAI,CAACD,YAAYA,SAAS5C,MAAM,KAAK,GAAG,OAAO,EAAE;IACjD,IAAI;YAEwB6C;YASWC;QAVrC,MAAMC,QAAQ,IAAI/E;QAClB,KAAK,MAAMgF,QAAQjE,QAAO8D,mBAAAA,SAASI,MAAM,YAAfJ,mBAAmB,IAAIf,KAAK,CAAC,KAAM;YAC3D,MAAMJ,OAAOsB,KAAKlB,KAAK,CAAC,IAAI,CAAC,EAAE,CAACR,IAAI;YACpC,IAAII,QAAQkB,SAASb,IAAI,CAAC,CAACmB,IAAM,CAAC,CAACA,KAAKxB,KAAKC,UAAU,CAACuB,KAAK;gBAC3DH,MAAMI,GAAG,CAACzB;YACZ;QACF;QACA,IAAIqB,MAAMK,IAAI,KAAK,GAAG,OAAO,EAAE;QAC/B,MAAMd,UAAUF,uBACdlB,mBAAAA,WACG,OAAO4B,WAAW,eAAcA,mBAAAA,OAAOO,QAAQ,qBAAfP,iBAAiB5B,QAAQ,GAAG;QAEjE,KAAK,MAAMQ,QAAQqB,MAAO;YACxB,MAAMO,UAAU,GAAG5B,KAAK,2DAA2D,CAAC;YACpFmB,SAASI,MAAM,GAAGK;YAClB,KAAK,MAAMC,UAAUjB,QAAS;gBAC5BO,SAASI,MAAM,GAAG,GAAGK,QAAQ,SAAS,EAAEC,QAAQ;YAClD;QACF;QACA,OAAO;eAAIR;SAAM;IACnB,EAAE,eAAM;QACN,wEAAwE;QACxE,kDAAkD;QAClD,OAAO,EAAE;IACX;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,MAAMS,yBAAyB,cAAa;AAoBnD;;;;;;CAMC,GACD,OAAO,SAASC,wBACdC,OAAgB;IAEhB,OAAO;QACLC,mBAAmBD,UAAU,YAAY;QACzCE,YAAY;QACZC,cAAc;QACdC,oBAAoB;IACtB;AACF;AAUA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,mBAAmBC,MAGlC;IACC,MAAMhF,YAAYgF,OAAOhF,SAAS,KAAK;IACvC,MAAMc,cAAcd,aAAagF,OAAOlE,WAAW,KAAK;IACxD,MAAMmE,MAA8BnE,cAAc,YAAY;IAC9D,OAAO;QACL6D,mBAAmB3E,YAAY,YAAY;QAC3C4E,YAAYK;QACZJ,cAAcI;QACdH,oBAAoBG;IACtB;AACF;AAEA;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASC,2BACdrF,IAA2C,EAC3CT,MAA+C;IAE/C,IAAI,CAACyB,yBAAyBhB,OAAO,OAAO;IAC5C,IAAI,CAACT,QAAQ,OAAO;IACpB,IAAI,CAACG,2BAA2BH,OAAOF,MAAM,GAAG,OAAO;IACvD,OAAOE,OAAO0B,WAAW,KAAK;AAChC;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GACD,OAAO,MAAMqE,6BAA6B,CAAC,2BAA2B,EAAEC,KAAKC,SAAS,CACpFZ,wBAAwB,OACxB,EAAE,CAAC,CAAA;AAEL;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD,OAAO,MAAMa,sCAAsC,CAAC,2BAA2B,EAAEF,KAAKC,SAAS,CAC7FN,mBAAmB;IAAE/E,WAAW;IAAMc,aAAa;AAAK,IACxD,EAAE,CAAC,CAAA;AAEL;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BC,GACD,OAAO,MAAMyE,kCACX,0CACA,2CAA0C;AAE5C;;;;;;;;;;;CAWC,GACD,OAAO,SAASC;IACd,IAAI,OAAO3B,aAAa,aAAa,OAAO,EAAE;IAC9C,MAAM4B,MAAM,IAAIzG;IAChB,IAAI;QACF,MAAM0G,SAAS7B,SAAS8B,gBAAgB,CACtC;QAEF,KAAK,MAAMC,WAAWC,MAAMC,IAAI,CAACJ,QAAS;gBACrB;YAAnB,MAAMK,MAAMhG,QAAO,eAAA,AAAC6F,QAA8BG,GAAG,YAAlC,eAAsC;YACzD,MAAMC,QAAQ,iBAAiBC,IAAI,CAACF;YACpC,MAAMG,KAAKF,QAAQG,mBAAmBH,KAAK,CAAC,EAAE,IAAI;YAClD,IAAIxG,0BAA0BU,IAAI,CAACgG,KAAKT,IAAItB,GAAG,CAAC+B;QAClD;IACF,EAAE,eAAM;IACN,mEAAmE;IACrE;IACA,IAAI;YACapC;QAAf,MAAMsC,SAAStC,UAAAA,2BAAD,AAACA,QAA+CuC,SAAS;QACvE,KAAK,MAAMC,SAAST,MAAMC,IAAI,CAAEM,gBAAAA,QAAS,EAAE,EAA0B;gBAIjDG;YAHlB,oEAAoE;YACpE,MAAMA,OAAOV,MAAMC,IAAI,CAAEQ,gBAAAA,QAAS,EAAE;YACpC,IAAIC,IAAI,CAAC,EAAE,KAAK,UAAU;YAC1B,MAAML,KAAKnG,QAAOwG,SAAAA,IAAI,CAAC,EAAE,YAAPA,SAAW;YAC7B,IAAI/G,0BAA0BU,IAAI,CAACgG,KAAKT,IAAItB,GAAG,CAAC+B;QAClD;IACF,EAAE,eAAM;IACN,qEAAqE;IACvE;IACA,OAAO;WAAIT;KAAI;AACjB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsCC,GACD,OAAO,SAASe,yBACd9B,OAAgB,EAChB5D,cAAc,KAAK;IAEnB,IAAI,OAAOgD,WAAW,aAAa,OAAO,EAAE;IAC5C,MAAM2B,MAAMD;IACZ,MAAMiB,QAAQ3C;IACd,KAAK,MAAMoC,MAAMT,IAAK;QACpBgB,KAAK,CAAC,GAAGjC,yBAAyB0B,IAAI,CAAC,GAAG,CAACxB;IAC7C;IACA,IAAI;QACF,MAAMgC,OAAOD,MAAME,IAAI;QACvB,IAAI,OAAOD,SAAS,YAAY;YAC9B,gEAAgE;YAChE,oDAAoD;;YAClDA,KACA,WACA,UACA3B,mBAAmB;gBAAE/E,WAAW0E;gBAAS5D;YAAY;QAEzD;IACF,EAAE,eAAM;IACN,sEAAsE;IACtE,mDAAmD;IACrD;IACA,OAAO2E;AACT;AAEA;;;CAGC,GACD,OAAO,SAASmB,yBACd9E,MAAiC;IAEjC,IAAI,CAACA,UAAU,OAAOgC,WAAW,aAAa,OAAO;IACrD,IAAI;QACF,OAAO+C,0BACL/C,OAAOgD,YAAY,CAACC,OAAO,CAAClF,yBAAyBC;IAEzD,EAAE,eAAM;QACN,wEAAwE;QACxE,oBAAoB;QACpB,OAAO;IACT;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAAS+E,0BACdG,GAA8B;IAE9B,IAAI,CAACA,KAAK,OAAO;IACjB,IAAI;QACF,MAAMC,SAAS7B,KAAK8B,KAAK,CAACF;QAC1B,IACEC,CAAAA,0BAAAA,OAAQE,CAAC,MAAK,KACd,OAAOF,OAAO/H,MAAM,KAAK,YACzB,CAACH,iBAAiBqC,GAAG,CAAC6F,OAAO/H,MAAM,GACnC;YACA,OAAO;QACT;QACA,MAAMA,SAAS+H,OAAO/H,MAAM;QAC5B,OAAO;YACLiI,GAAG;YACHC,IAAI,OAAOH,OAAOG,EAAE,KAAK,WAAWH,OAAOG,EAAE,GAAG;YAChDlI;YACA,qEAAqE;YACrE,kCAAkC;YAClCc,WAAWV,yBAAyBJ;YACpC,oEAAoE;YACpE,iEAAiE;YACjE,iEAAiE;YACjE4B,aACEvB,2BAA2BL,WAAW+H,OAAOnG,WAAW,KAAK;YAC/DK,SAAS,OAAO8F,OAAO9F,OAAO,KAAK,WAAW8F,OAAO9F,OAAO,GAAG;QACjE;IACF,EAAE,eAAM;QACN,oEAAoE;QACpE,WAAW;QACX,OAAO;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASkG,oBACdvF,MAAc,EACdwF,KAQC;QAYUA;IAVX,MAAMlI,SAA+B;QACnC+H,GAAG;QACHC,IAAIG,KAAKC,GAAG;QACZtI,QAAQoI,MAAMpI,MAAM;QACpBc,WAAWV,yBAAyBgI,MAAMpI,MAAM;QAChD,sEAAsE;QACtE,wEAAwE;QACxE,6DAA6D;QAC7D4B,aACEvB,2BAA2B+H,MAAMpI,MAAM,KAAKoI,MAAMxG,WAAW,KAAK;QACpEK,OAAO,GAAEmG,iBAAAA,MAAMnG,OAAO,YAAbmG,iBAAiB;IAC5B;IACA,IAAI,OAAOxD,WAAW,aAAa;QACjC,IAAI;YACFA,OAAOgD,YAAY,CAACW,OAAO,CACzB5F,yBAAyBC,SACzBsD,KAAKC,SAAS,CAACjG;YAEjB,IAAI,CAACA,OAAOY,SAAS,EAAE;gBACrB8D,OAAOgD,YAAY,CAACY,UAAU,CAAChG;YACjC;QACF,EAAE,eAAM;QACN,mEAAmE;QACnE,mDAAmD;QACrD;QACA,wEAAwE;QACxE,wEAAwE;QACxE,uEAAuE;QACvE,mEAAmE;QACnE,4BAA4B;QAC5B8E,yBAAyBpH,OAAOY,SAAS,EAAEZ,OAAO0B,WAAW,KAAK;QAClE,IAAI,CAAC1B,OAAOY,SAAS,EAAE;YACrB2D;QACF;QACA,gEAAgE;QAChE,0EAA0E;QAC1E,0EAA0E;QAC1E,wEAAwE;QACxE,yEAAyE;QACzE,2EAA2E;QAC3E,yEAAyE;QACzE,oCAAoC;QACpC,EAAE;QACF,2EAA2E;QAC3E,qEAAqE;QACrE,oEAAoE;QACpE,0CAA0C;QAC1C,IAAI,CAACvE,OAAO0B,WAAW,EAAE;YACvBmB;QACF;QACA,IAAI;YACF6B,OAAO6D,aAAa,CAAC,IAAIC,YAAYjG;QACvC,EAAE,eAAM;QACN,gEAAgE;QAClE;IACF;IACA,OAAOvC;AACT;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASyI,mBACdhI,IAA2C,EAC3CT,MAAmC;IAEnC,IAAI,CAAC2B,oBAAoBlB,OAAO,OAAO;IACvC,OAAOT,CAAAA,0BAAAA,OAAQY,SAAS,MAAK;AAC/B;AAEA;;;;;;;;;CASC,GACD,OAAO,SAAS8H,4BACdjI,IAA2C,EAC3CT,MAAmC;IAEnC,OAAOyI,mBAAmBhI,MAAMT,UAAU,UAAU;AACtD"}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* transition; org-keyed billing lands with AGL-237.
|
|
23
23
|
*/
|
|
24
24
|
import type { ITimestamp } from '@aglyn/shared-util-timestamp';
|
|
25
|
+
import type { AccountAcquisition } from '../../app-utils/account-acquisition';
|
|
25
26
|
import type { OrgCrmInbound, OrgCrmSettings, OrgEntitlements, OrgPlan, OrgSeatAddons, OrgSubscription } from './org-billing.types';
|
|
26
27
|
import type { AglynDocument, HostUid, OrgUid, ScopeToken, UserUid } from './platform.types';
|
|
27
28
|
export type { OrgUid } from './platform.types';
|
|
@@ -85,6 +86,14 @@ export interface AglynOrganization extends AglynDocument {
|
|
|
85
86
|
* always were.
|
|
86
87
|
*/
|
|
87
88
|
createdByUid?: UserUid;
|
|
89
|
+
/**
|
|
90
|
+
* Where the workspace came from (AGL-3289): its creator's acquisition
|
|
91
|
+
* record, copied by `createOrganization` at birth and naming the account it
|
|
92
|
+
* was copied from. Written by nothing else, and denied to client writes on
|
|
93
|
+
* every rules branch, for the reason `createdByUid` is: it is the
|
|
94
|
+
* platform's record about the workspace, not the workspace's about itself.
|
|
95
|
+
*/
|
|
96
|
+
acquisition?: AccountAcquisition;
|
|
88
97
|
/** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */
|
|
89
98
|
hosts?: Record<HostUid, true>;
|
|
90
99
|
plan?: OrgPlan;
|