The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.
1. The problem
Prerendered/ISR/CDN-cached HTML is shared across visitors, so it can carry nothing region-specific: baking "US visitor, opt-out" into cached markup would grant analytics by default to every visitor, EU included. Therefore:
- Shared HTML bakes the strictest initial consent (spec-consent §8) — all denied, opt-in.
- The region-correct default comes only from a per-visitor server endpoint, resolved after load.
2. Server resolution
resolveInitialConsent(countryCode, requestedCategories, hasGpcSignal):
- Missing country code → the strictest initial consent. A missing geo header means "unknown visitor" (a prerender
crawl, a host without geo), never "known non-EU visitor" — conflating the two would default-grant analytics to
everyone on a geo-less host. This check MUST run before region resolution (whose own fallback for unrecognized codes
is
other). - Otherwise: region from the country code (spec-consent §2), mode from the region, default decision from mode + requested categories + GPC (spec-consent §3).
- Return
{ defaultConsent, mode, region }.
Request adapter requirements (however the endpoint is wired):
- Country comes from the platform's geo header (e.g.
x-vercel-ip-country, ISO 3166-1 alpha-2); GPC from thesec-gpcrequest header equal to"1". - The response is per-visitor by definition, so the endpoint MUST answer with
cache-control: private, no-store— no shared cache may ever store it. - The endpoint MUST be server-only: unreachable from client bundles (build-time import protection, package layout, or separate deployables).
3. Client store
The client holds the resolved value in a subscribable store with snapshots:
{ "initialConsent": { "defaultConsent": {…}, "mode": "…", "region": "…" }, "isResolved": false }States and transitions:
- Initial: the strictest initial consent,
isResolved: false. This is also the permanent server-render snapshot (hydration-safe: it matches what shared HTML could know). ensureResolved()— idempotent kick-off; callers SHOULD invoke it early so the round trip overlaps app startup:- Already resolved successfully, or a request in flight → no-op (single-flight).
- Session cache hit (optional): a cached value that passes the initial-consent guard (spec-consent §8) publishes immediately, no request. Cache entries MUST be re-validated before use, never trusted.
- Otherwise call the endpoint. Success → write the session cache, publish
{ value, isResolved: true }; success is sticky for the store's lifetime. - Failure (including a synchronous throw from the resolver, which MUST be folded into the same path) → publish
fail-closed: the strictest value with
isResolved: true— consent UI can render, under the strictest default — but the failure is not sticky: resolution stays retryable.
- Retry: after a failure, re-attempt when the tab becomes visible again, the page is restored from cache, or connectivity returns — a visitor who never leaves the tab must not stay fail-closed for the whole session over one network blip. Retries coalesce through the same single-flight guard, and retry listeners are torn down once a resolve succeeds.
- Session-cache read/write failures (private mode, quota) are swallowed — resolve again next page load.
Consumption. The live consent mode used by the effective-consent rule (spec-consent §7) and the prompt rule MUST be re-read from the store per evaluation, so a post-hydration region resolve wins over the baked strictest default without recreating any gate or tracker.
4. Composition (the consent runtime)
One factory composes the client half from the single consent configuration, so every surface shares the same instances by construction:
- one consent storage (from
storageKey), - one initial-consent store (over the app's endpoint, with an optional session-cache key),
- one
isAnalyticsAllowedgate wired to live GPC and the store's current mode.
The UI surface, the tracker gate, and the tag bootstrap MUST all consume these shared instances — two storages or two configs is a spec violation (drifting constants are exactly what the single config exists to prevent).
Conformance vectors
V1 — fail-closed on missing geo. (countryCode: none, requested ["analytics"], gpc false) → the strictest initial
consent {defaultConsent:{ads:false,analytics:false}, mode:"opt-in", region:"other"} — not other's opt-out.
V2 — resolution. ("DE", ["analytics"], false) →
{mode:"opt-in", region:"eu", defaultConsent:{ads:false,analytics:false}}. ("US", ["ads","analytics"], true) →
{mode:"opt-out", region:"us", defaultConsent:{ads:false,analytics:true}}.
V3 — response caching. Every endpoint response carries cache-control: private, no-store.
V4 — single-flight. Three concurrent ensureResolved() calls → exactly one request.
V5 — sticky success. After a successful resolve, further ensureResolved() calls make no request and the snapshot
keeps the resolved value.
V6 — retryable failure. Endpoint fails → snapshot is the strictest value with isResolved:true; a subsequent
tab-visible/online signal triggers exactly one new request; when it succeeds the resolved value is published and retry
listeners stop.
V7 — cache validation. A session-cache entry failing the initial-consent guard (e.g.
{mode:"opt-out", region:"eu", …}) is ignored and the endpoint is called.
V8 — mode re-read. Gate evaluated before resolution uses opt-in (all denied ⇒ analytics false); after a resolve to
us/opt-out with analytics requested and no stored decision, the same gate instance evaluates to true.
License
Released under the MIT License.