@aglyn/aglyn 1.0.0-beta.176 → 1.0.0-beta.178
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/docs-index.generated.js +3 -3
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- 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/host-naming.d.ts +15 -0
- package/src/lib/app-utils/host-naming.js +27 -0
- package/src/lib/app-utils/host-naming.js.map +1 -1
- package/src/lib/app-utils/organizations.js +13 -2
- package/src/lib/app-utils/organizations.js.map +1 -1
- 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
|
@@ -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":"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,MAAM+C,MAAM/C,OAAOgD,YAAY,CAACC,OAAO,CAAClF,yBAAyBC;QACjE,IAAI,CAAC+E,KAAK,OAAO;QACjB,MAAMG,SAAS5B,KAAK6B,KAAK,CAACJ;QAC1B,IACEG,CAAAA,0BAAAA,OAAQE,CAAC,MAAK,KACd,OAAOF,OAAO9H,MAAM,KAAK,YACzB,CAACH,iBAAiBqC,GAAG,CAAC4F,OAAO9H,MAAM,GACnC;YACA,OAAO;QACT;QACA,MAAMA,SAAS8H,OAAO9H,MAAM;QAC5B,OAAO;YACLgI,GAAG;YACHC,IAAI,OAAOH,OAAOG,EAAE,KAAK,WAAWH,OAAOG,EAAE,GAAG;YAChDjI;YACA,qEAAqE;YACrE,kCAAkC;YAClCc,WAAWV,yBAAyBJ;YACpC,oEAAoE;YACpE,iEAAiE;YACjE,iEAAiE;YACjE4B,aACEvB,2BAA2BL,WAAW8H,OAAOlG,WAAW,KAAK;YAC/DK,SAAS,OAAO6F,OAAO7F,OAAO,KAAK,WAAW6F,OAAO7F,OAAO,GAAG;QACjE;IACF,EAAE,eAAM;QACN,oEAAoE;QACpE,wCAAwC;QACxC,OAAO;IACT;AACF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASiG,oBACdtF,MAAc,EACduF,KAQC;QAYUA;IAVX,MAAMjI,SAA+B;QACnC8H,GAAG;QACHC,IAAIG,KAAKC,GAAG;QACZrI,QAAQmI,MAAMnI,MAAM;QACpBc,WAAWV,yBAAyB+H,MAAMnI,MAAM;QAChD,sEAAsE;QACtE,wEAAwE;QACxE,6DAA6D;QAC7D4B,aACEvB,2BAA2B8H,MAAMnI,MAAM,KAAKmI,MAAMvG,WAAW,KAAK;QACpEK,OAAO,GAAEkG,iBAAAA,MAAMlG,OAAO,YAAbkG,iBAAiB;IAC5B;IACA,IAAI,OAAOvD,WAAW,aAAa;QACjC,IAAI;YACFA,OAAOgD,YAAY,CAACU,OAAO,CACzB3F,yBAAyBC,SACzBsD,KAAKC,SAAS,CAACjG;YAEjB,IAAI,CAACA,OAAOY,SAAS,EAAE;gBACrB8D,OAAOgD,YAAY,CAACW,UAAU,CAAC/F;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,OAAO4D,aAAa,CAAC,IAAIC,YAAYhG;QACvC,EAAE,eAAM;QACN,gEAAgE;QAClE;IACF;IACA,OAAOvC;AACT;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,SAASwI,mBACd/H,IAA2C,EAC3CT,MAAmC;IAEnC,IAAI,CAAC2B,oBAAoBlB,OAAO,OAAO;IACvC,OAAOT,CAAAA,0BAAAA,OAAQY,SAAS,MAAK;AAC/B;AAEA;;;;;;;;;CASC,GACD,OAAO,SAAS6H,4BACdhI,IAA2C,EAC3CT,MAAmC;IAEnC,OAAOwI,mBAAmB/H,MAAMT,UAAU,UAAU;AACtD"}
|
|
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":"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"}
|
|
@@ -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;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type {\n OrgCrmInbound,\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets and media when nobody chooses\n * (AGL-1048). `'org'` — the default and today's behavior — shares them\n * with every site. `'host'` starts them private to the site they were\n * created in, which is what an agency running client sites wants: safe\n * by default rather than safe by discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Media or Data page there is no host to scope to, so those stay `'org'`\n * either way.\n */\n defaultResourceScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The workspace's email capture address (AGL-2657) — see `OrgCrmInbound`. */\n crmInbound?: OrgCrmInbound\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA4DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/aglyn/src/lib/foundation/definitions/organization.types.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 * Multi-tenant organizations (AGL-233, docs/MULTI_TENANT_FIRESTORE.md):\n * the org is the tenant boundary — one subscription, one workspace\n * subdomain, one isolation subtree. Billing fields mirror `AglynOrgBilling`\n * so the plan/entitlement resolvers work on either doc during the\n * transition; org-keyed billing lands with AGL-237.\n */\n\nimport type { ITimestamp } from '@aglyn/shared-util-timestamp'\nimport type { AccountAcquisition } from '../../app-utils/account-acquisition'\nimport type {\n OrgCrmInbound,\n OrgCrmSettings,\n OrgEntitlements,\n OrgPlan,\n OrgSeatAddons,\n OrgSubscription,\n} from './org-billing.types'\nimport type {\n AglynDocument,\n HostUid,\n OrgUid,\n ScopeToken,\n UserUid,\n} from './platform.types'\n\nexport type { OrgUid } from './platform.types'\n/** Workspace subdomain label: `{slug}.aglyn.com` (Slack-style). */\nexport type OrgSlug = string\n\n/** Org-wide roles, strongest to weakest. */\nexport type OrgRole = 'owner' | 'admin' | 'editor' | 'viewer'\n\n/**\n * Per-host refinement for editor/viewer members (\"3 of 15 sites\").\n *\n * `author` (AGL-2334) is `editor` MINUS publish: it may create and edit\n * every content document on the site, and it may not make any of it live.\n * It exists because the agency guide sells exactly that role — \"a client who\n * may edit content but not publish\" — and until now the narrowest thing we\n * could offer was `viewer`, which cannot edit at all.\n *\n * It is a HOST role rather than a twelfth org permission key deliberately.\n * Publishing is a set of client-direct Firestore writes, so it is enforced\n * in the security rules, and the rules resolve a host request from the\n * `memberRoles` projection with the one `get()` they already do. An org\n * permission key is on the wrong axis: rules cannot evaluate a custom role\n * without a second denormalized projection, and every publish surface would\n * need a server route it does not have.\n *\n * Ordered between `editor` and `viewer` because that is its strength, but\n * NOTHING may treat this union as ordered — `ORG_ROLE_WEIGHT` exists for the\n * org axis and has no counterpart here on purpose. Host roles are compared by\n * membership in a set (`HOST_CONTENT_WRITE_ROLES`, `HOST_PUBLISH_ROLES`), and\n * an author is not \"a weaker editor\" in a way any single number can express.\n */\nexport type HostAccessRole = 'admin' | 'editor' | 'author' | 'viewer'\n\n/**\n * A per-site permission key a collaborator can carry (AGL-2927, AGL-2984).\n * The host role decides what a collaborator may do to the SITE; these keys\n * decide what else they may do on it. Each is a catalog key a plugin\n * declares with host-role defaults, the same dotted key the org catalog\n * names, so one label serves both rosters.\n */\nexport type HostPermissionKey = string\n\n/**\n * Every host role, as a value — for `where('memberRoles.{uid}', 'in', …)`.\n *\n * `/hosts/{hostId}` is gated per document on `memberRoles.{uid}`, and\n * Firestore refuses to run a LIST that could return a denied document. So\n * every client query over `hosts` must carry this filter, and it must name\n * ALL the roles: a role missing from the array is a site the member owns and\n * cannot see, with no error to explain it (AGL-1145).\n *\n * Built from a `Record` rather than written as an array so that adding a role\n * to the union above fails to COMPILE here. A plain `HostAccessRole[]` would\n * happily stay short, which is the silent half of the bug.\n */\nconst HOST_ACCESS_ROLE_KEYS: Record<HostAccessRole, true> = {\n admin: true,\n editor: true,\n author: true,\n viewer: true,\n}\nexport const HOST_ACCESS_ROLES = Object.keys(\n HOST_ACCESS_ROLE_KEYS,\n) as HostAccessRole[]\n\nexport interface AglynOrganization extends AglynDocument {\n $id: OrgUid\n name?: string\n slug?: OrgSlug\n /** Current owner; ownership can move without re-keying anything. */\n ownerUid?: UserUid\n /**\n * Who CREATED the workspace — stamped once inside `createOrganization`'s\n * transaction and mutated by nothing, `transferOrgOwnership` included\n * (AGL-2265).\n *\n * `ownerUid` moves; this does not, and the difference is the whole point.\n * The free-workspace ceiling counts the UNION of the two, so handing a\n * workspace to an alt account, creating a fourth and taking the first one\n * back is not a way past the limit. Denied to client writes in the rules\n * for the same reason — a client that could clear its own attribution could\n * mint free workspaces without limit.\n *\n * Absent on every org created before AGL-2265 shipped, and every reader\n * must tolerate that: those are counted by `ownerUid` exactly as they\n * always were.\n */\n createdByUid?: UserUid\n /**\n * Where the workspace came from (AGL-3289): its creator's acquisition\n * record, copied by `createOrganization` at birth and naming the account it\n * was copied from. Written by nothing else, and denied to client writes on\n * every rules branch, for the reason `createdByUid` is: it is the\n * platform's record about the workspace, not the workspace's about itself.\n */\n acquisition?: AccountAcquisition\n /** Directory of the org's hosts (mirrors AglynOrgBilling.hosts). */\n hosts?: Record<HostUid, true>\n\n // Billing (mirrors AglynOrgBilling; source of truth moves here with AGL-237)\n plan?: OrgPlan\n entitlements?: OrgEntitlements\n /**\n * Per-org plugin switchboard (AGL-416): ids of plugins the workspace\n * loads (see plugin-manager/enabled-plugins). Absent = all first-party\n * plugins; always-on ids (base components) and the ids on for every\n * workspace (AI) are unioned in regardless — a site switches those off for\n * itself instead.\n */\n enabledPlugins?: string[]\n seatAddons?: OrgSeatAddons\n stripeCustomerId?: string\n subscription?: OrgSubscription\n suspendedAt?: ITimestamp | null\n /** Staff-internal rationale (AGL-202). NEVER shown to the customer. */\n suspendedReason?: string\n /**\n * Lockdown extensions (AGL-1501) on the shipped AGL-202 carrier — the org\n * scope of the panic button. `suspendedReasonCode` is the enum the notice\n * copy switches on (`security`/`billing`/`maintenance`/`manual`; absent =\n * `manual`), `suspendedMessage` is the CUSTOMER-FACING notice body (unlike\n * `suspendedReason`), and `suspendedUntilMs` is an optional expiry — once\n * it passes, the suspension is inactive with no write needed (maintenance\n * windows end on their own). Plain epoch ms, not a Timestamp, so every\n * cache serialization reads it back unchanged. All written only by\n * /api/admin/lockdown; normalized by `app-utils/lockdown.ts`.\n */\n suspendedReasonCode?: string\n suspendedMessage?: string\n suspendedUntilMs?: number\n erasureRequestedAt?: ITimestamp | null\n /**\n * Scope applied to newly created datasets and media when nobody chooses\n * (AGL-1048). `'org'` — the default and today's behavior — shares them\n * with every site. `'host'` starts them private to the site they were\n * created in, which is what an agency running client sites wants: safe\n * by default rather than safe by discipline.\n *\n * Only meaningful when there IS a site in context. Created from the org\n * Media or Data page there is no host to scope to, so those stay `'org'`\n * either way.\n */\n defaultResourceScope?: 'org' | 'host'\n /** The CRM's organization-wide settings (AGL-2613) — see `OrgCrmSettings`. */\n crm?: OrgCrmSettings\n /** The workspace's email capture address (AGL-2657) — see `OrgCrmInbound`. */\n crmInbound?: OrgCrmInbound\n}\n\n/**\n * `orgs/{orgId}/members/{uid}` — THE authorization doc: rules resolve a\n * request with this single read. Owner/admin span every host; editor and\n * viewer see `hostAccess` (or `allHosts`).\n */\nexport interface AglynOrgMember extends AglynDocument {\n $id: UserUid\n role?: OrgRole\n /**\n * Custom role reference (AGL-243): id of an `orgs/{orgId}/roles` doc\n * whose permission map overrides the org role's defaults.\n */\n roleId?: string\n /** Per-member permission overrides (AGL-243); win over every layer. */\n permissions?: Record<string, boolean>\n /** Org-wide host access shortcut; otherwise `hostAccess` decides. */\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n /**\n * Per-site permission overrides for a COLLABORATOR (AGL-2927), keyed by\n * the host the grant is for. Absent keys resolve by the host role's\n * default, so a document written before this field existed reads the same\n * as one written after it. Org-wide members are never consulted here:\n * their org role, custom role and `permissions` map decide.\n */\n hostPermissions?: Record<HostUid, Partial<Record<HostPermissionKey, boolean>>>\n /**\n * Denormalized reach as scope tokens (AGL-1038), so rules can intersect\n * it with a resource's `visibleTo` — they cannot derive it from\n * `hostAccess` because the rules language has no `.map()`. Written only\n * by `syncOrgAuthProjections`; never edit it by hand.\n */\n scopeTokens?: ScopeToken[]\n /**\n * Denormalized THREE-LAYER permission verdict, so rules can honor a custom\n * role they cannot resolve.\n *\n * `roleId` points at another document and `permissions` is only the top\n * layer, so a rule reading either alone answers a different question from\n * the server — and reproducing the precedence in CEL needs a second\n * cross-document get() plus correct handling of a dangling id. This is\n * `resolveOrgPermissions(member, customRole)`, already applied.\n *\n * ⚠️ ABSENT IS NOT EMPTY, and the rules must never treat it as either\n * \"everything allowed\" or \"nothing allowed\". Documents predating this\n * field, and any written by a path that bypasses\n * `syncOrgAuthProjections`, carry no map at all; the rules fall back to\n * the layers they CAN read — the role and the per-member overrides — which\n * is exactly the verdict they gave before this field existed. Written only\n * by `syncOrgAuthProjections` and by `createOrganization`'s inline stamp;\n * never edit it by hand.\n */\n resolvedPermissions?: Record<string, boolean>\n /** Denormalized for member lists without N user lookups. */\n displayName?: string\n email?: string\n invitedBy?: UserUid\n joinedAt?: ITimestamp\n /**\n * Denormalized org suspension flag (AGL-210) so host writes stay a\n * single rules read; maintained by the staff suspension API.\n */\n orgSuspended?: boolean\n}\n\n/** `orgs/{orgId}/invites/{inviteId}` — pending email invites. */\nexport interface AglynOrgInvite extends AglynDocument {\n $id: string\n email?: string\n role?: OrgRole\n allHosts?: boolean\n hostAccess?: Record<HostUid, HostAccessRole>\n invitedBy?: UserUid\n createdAt?: ITimestamp\n acceptedAt?: ITimestamp | null\n acceptedBy?: UserUid\n}\n\n/**\n * `orgSlugs/{slug}` — transactional uniqueness reservation; created and\n * deleted only by the org APIs (Admin SDK), publicly readable so the\n * console can resolve a workspace subdomain client-side.\n */\nexport interface OrgSlugReservation {\n orgId: OrgUid\n /**\n * Set when the previous holder renamed away — the tombstone that keeps old\n * workspace URLs redirecting until somebody else wants the name.\n */\n movedTo?: string\n /**\n * Epoch millis at which a PENDING reservation lapses (AGL-2585).\n *\n * Present only on a workspace created by an owner whose email was not yet\n * verified, which is every password signup: the address is held for them,\n * not granted to them, and the hold ends unless they confirm the address.\n * Absent means granted, and a grant never expires — so every workspace made\n * by a verified owner, and every one that predates this field, is outside\n * the rule entirely.\n *\n * Cleared by `reap-unverified-orgs` once the owner verifies. Public, like\n * the rest of this document, which is why nothing identifying the owner is\n * written beside it.\n */\n reservedUntil?: number\n}\n\n/**\n * `hostIndex/{hostId}` — server-written host → org resolver so the tenant\n * renderer and middleware find a host's org without scanning.\n */\nexport interface HostIndexEntry {\n orgId: OrgUid\n subdomain?: string\n}\n\n/**\n * `users/{uid}/orgs/{orgId}` — reverse index for \"my organizations\",\n * maintained transactionally with the member doc by the membership API.\n */\nexport interface UserOrgMembership extends AglynDocument {\n $id: OrgUid\n role?: OrgRole\n orgName?: string\n slug?: OrgSlug\n /**\n * Mirror of `isOrgWideMember(orgs/{orgId}/members/{uid})` (AGL-1032): does\n * this membership reach the whole org, or only a list of sites?\n *\n * `role` alone cannot answer it — `grantHostAccess` writes `role: 'viewer'`\n * here for a site collaborator, exactly what a genuine org-wide viewer\n * carries, so the console could not tell them apart without a second read\n * of the member doc on every org route. Denormalized because the console\n * navigation guard has to be synchronous: an async answer flashes the org\n * chrome before hiding it.\n *\n * ABSENT means org-wide. Rows predating the mirror carry no flag, and a\n * missing field must never lock a real member out of their own workspace\n * (the same legacy shape `isOrgWideMember` handles). Only an explicit\n * `false` scopes the console — see `isOrgWideMembership`.\n *\n * Navigation only. The Firestore rules are the access boundary (AGL-1026);\n * a stale mirror at worst shows a page whose reads then come back empty.\n */\n orgWide?: boolean\n}\n\n/**\n * `users/{uid}/hostMemberships/{hostId}` — reverse index of the sites a user\n * can reach, mirroring `hosts/{hostId}.memberRoles` (AGL-844). Denormalizes the\n * host name so the site switcher and subdomain→id routing query a user's own\n * sites, ordered and name-prefix-searched, without scanning the `hosts`\n * collection. Admin-SDK-maintained beside `memberRoles`; a best-effort\n * convenience index, never an authorization source (the rules still gate host\n * reads on `memberRoles`). NOT named `hosts` — that would collide with the\n * top-level `hosts` collection for collection-group rules/indexes.\n */\nexport interface UserHostMembership extends AglynDocument {\n $id: HostUid\n orgId?: OrgUid\n subdomain?: string\n displayName?: string\n nameLower?: string\n /**\n * Mirror of the host's `seo.favicon` (AGL-1071). The site switcher renders\n * from this projection rather than the host doc, so without the mirror it\n * showed the generic glyph for every site — the favicon feature worked in\n * the sites list and nowhere else. Absent when the site has none; writers\n * must DELETE it on clear rather than omit it, since the rows are written\n * with `{ merge: true }` and an omitted field would keep the old icon.\n */\n favicon?: string\n role?: HostAccessRole\n updatedAt?: ITimestamp\n}\n"],"names":["HOST_ACCESS_ROLE_KEYS","admin","editor","author","viewer","HOST_ACCESS_ROLES","Object","keys"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;CAMC,GA6DD;;;;;;;;;;;;CAYC,GACD,MAAMA,wBAAsD;IAC1DC,OAAO;IACPC,QAAQ;IACRC,QAAQ;IACRC,QAAQ;AACV;AACA,OAAO,MAAMC,oBAAoBC,OAAOC,IAAI,CAC1CP,uBACmB"}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
*/
|
|
17
|
+
/** Who to look for, and where. */
|
|
18
|
+
export interface PluginPersonMatchRequest {
|
|
19
|
+
/** The workspace whose records are searched. */
|
|
20
|
+
orgId: string;
|
|
21
|
+
/** Its slug, so a matcher can link to what it found. */
|
|
22
|
+
orgSlug: string | null;
|
|
23
|
+
/** The account's address, normalized; null when it has none. */
|
|
24
|
+
email: string | null;
|
|
25
|
+
/** The account's name, for the name half; null when it has none. */
|
|
26
|
+
name: string | null;
|
|
27
|
+
/**
|
|
28
|
+
* When the account was created. A record the account's own sign-up made at
|
|
29
|
+
* or after this moment is the account, not somebody already known.
|
|
30
|
+
*/
|
|
31
|
+
accountCreatedAtMs: number | null;
|
|
32
|
+
}
|
|
33
|
+
/** One record a matcher found. */
|
|
34
|
+
export interface PluginPersonMatch {
|
|
35
|
+
/** The record's kind, in the plugin's own words. */
|
|
36
|
+
kind: string;
|
|
37
|
+
id: string;
|
|
38
|
+
/** What a reader recognizes the record by — a name, else its address. */
|
|
39
|
+
label: string;
|
|
40
|
+
email: string | null;
|
|
41
|
+
/** `email` is certain; `name` is a resemblance, shown as "possible". */
|
|
42
|
+
basis: 'email' | 'name';
|
|
43
|
+
/** When the workspace first knew this record, when it can say. */
|
|
44
|
+
firstSeenAtMs: number | null;
|
|
45
|
+
/** How the workspace came to know them, in the plugin's words. */
|
|
46
|
+
sources: string[];
|
|
47
|
+
/** A console path to the record, when the plugin publishes one. */
|
|
48
|
+
href: string | null;
|
|
49
|
+
}
|
|
50
|
+
export type PluginPersonMatcher = (request: PluginPersonMatchRequest) => Promise<PluginPersonMatch[]>;
|
|
51
|
+
/**
|
|
52
|
+
* Registers a plugin's matcher. Owner = the loader's marker inside a register
|
|
53
|
+
* fn, else `options.pluginId`; a matcher with neither throws. One matcher per
|
|
54
|
+
* plugin: registering again replaces the earlier one in place.
|
|
55
|
+
*/
|
|
56
|
+
export declare function registerPluginPersonMatcher(matcher: PluginPersonMatcher, options?: {
|
|
57
|
+
pluginId?: string;
|
|
58
|
+
}): void;
|
|
59
|
+
/** The plugins with a matcher registered, in registration order. */
|
|
60
|
+
export declare function listPluginPersonMatchers(): string[];
|
|
61
|
+
/** Test seam — forget every matcher. */
|
|
62
|
+
export declare function resetPluginPersonMatchers(): void;
|
|
63
|
+
/** What every matcher answered, together. */
|
|
64
|
+
export interface PluginPersonMatchReport {
|
|
65
|
+
/** Every match, tagged with the plugin that found it; certain ones first. */
|
|
66
|
+
matches: Array<PluginPersonMatch & {
|
|
67
|
+
pluginId: string;
|
|
68
|
+
}>;
|
|
69
|
+
/** Plugins whose matcher threw — their absence from `matches` measures nothing. */
|
|
70
|
+
failed: string[];
|
|
71
|
+
/** Plugins that were asked. None means nothing on this install can answer. */
|
|
72
|
+
asked: string[];
|
|
73
|
+
}
|
|
74
|
+
/** Runs every matcher. Never throws. */
|
|
75
|
+
export declare function runPluginPersonMatchers(request: PluginPersonMatchRequest): Promise<PluginPersonMatchReport>;
|