@growth-labs/consent
v0.7.1
Published
Geography-aware consent management. Banner UI, cookie gating, and a utility other packages use to check consent state. Generic — not analytics-specific.
Readme
@growth-labs/consent
Geography-aware consent management. Banner UI, cookie gating, and a utility other packages use to check consent state. Generic — not analytics-specific.
Config
import consent from '@growth-labs/consent'
consent({
mode: 'geography', // 'required' | 'geography' | 'disabled'
consentCookieName: 'gl_consent',
autoInject: true, // default: package injects the banner script on every page
purposes: {
analytics: { label: 'Analytics', description: 'Helps us understand...', required: false },
marketing: { label: 'Marketing', description: 'Personalized content...', required: false },
},
banner: {
position: 'bottom', // 'bottom' | 'top' | 'center'
showManageLink: true,
defaults: 'auto', // 'auto' | 'unchecked' | 'checked'
privacyPolicyUrl: '/privacy-policy/',
privacyPolicyLabel: 'Privacy policy',
showCloseButton: false, // first-impression banner has no X (see below)
},
// requireConsentRegions defaults to EU/EEA + UK + Brazil
})GDPR-Compliant Defaults
banner.defaults controls the pre-tick behaviour of non-required purpose
checkboxes:
'auto'(default): unchecked when the visitor's country is inrequireConsentRegions(EU/UK/EEA/BR by default), checked otherwise. Per EDPB Guidelines 05/2020 and the CJEU Planet49 ruling, pre-ticked boxes do not constitute valid consent in the EU/UK.'unchecked': always unchecked. Strict opt-in everywhere.'checked': always pre-ticked. Legacy pre-0.5.x behaviour. Only legal in non-GDPR regions.
Required-purpose checkboxes (required: true) always stay checked and
disabled — they are operational, not consent-bearing.
Privacy Policy Link
Setting banner.privacyPolicyUrl renders a link below the action buttons.
Omit the option to suppress the link entirely. EDPB Guidelines 05/2020
§3.3.1 expects informed consent, which the link disclosure supports.
First-Impression Close Button
banner.showCloseButton defaults to false. Closing the first-impression
banner via an "X" dismisses without recording a decision, which both
re-triggers the banner on every subsequent pageview AND fails the EDPB
affirmative-act test. The close affordance is therefore gated to the
manage-preferences sub-view (opened from inside an active consent flow,
where closing is safe).
What It Injects
Middleware: Reads the configured decision cookie and populates
context.locals.consent with { managed, cookieName, required, granted:
{ analytics, marketing } }. The deterministic managed/cookieName fields
let other packages hydrate the correct browser cookie without serializing the
visitor's decision into public HTML.
Static descriptor: Managed HTML also carries deterministic
<meta name="growth-labs-consent" content="{cookieName}">. It contains no
visitor decision or geography. Analytics uses it only as a fail-closed fallback
when a static artifact has no analytics config data node. It is emitted even
when autoInject is false and on HTML error/fragment responses.
Banner: Auto-injected by the integration by default. With no decision cookie,
it opens whenever the configured helper is true, missing, or malformed; only
an explicit helper value of false keeps a first-visit banner closed. It
supports Accept all / Decline / Manage toggles and gl:consent_reopen.
The Worker response body is deterministic across country and cookie state: the
same hidden controller is present on every successful HTML response, and the
browser reads the helper cookie to decide whether it opens. The middleware
emits the helper cookie only when its value changes. Any response with
Set-Cookie or Vary: Cookie is forced to private, no-store on
Cache-Control, CDN-Cache-Control, and Cloudflare-CDN-Cache-Control.
Server-side consumers never trust the readable helper as authorization. An
explicit decision wins; otherwise isConsentGranted() recomputes
isConsentRequired(request) from trusted Worker request metadata and grants
only when that server decision is non-required. The helper remains browser UI
state. Sites with no consent integration, or mode: 'disabled', remain unconditional.
Disabled mode publishes managed: false and emits no helper cookie, banner, or
static descriptor. It also ignores stale decision/helper cookies when building
Zaraz consent state, so all configured purposes remain granted.
The integration also injects GET /api/consent/status. It returns only
{ managed, required } with private/no-store headers. Zaraz and direct browser
analytics use this route when there is no explicit decision, so a forged
${cookieName}_required=false cookie cannot activate third-party tracking.
With Astro's native security.csp enabled, the middleware registers the exact
SHA-256 hash of its injected banner controller before rendering. Do not add
unsafe-inline for consent; Astro combines this hash with its own generated
script and style hashes. If WebCrypto cannot produce that hash, rendering fails
closed with a contextual error and the cached computation is cleared so a
later request can retry; the package never injects an unhashed banner script.
Set autoInject: false only when a site intentionally renders the package component itself. The manual component must receive the same runtime values the injected banner uses:
---
import ConsentBanner from '@growth-labs/consent/components/ConsentBanner.astro'
const purposes = [
{ key: 'analytics', label: 'Analytics', description: 'Helps us understand traffic' },
{ key: 'marketing', label: 'Marketing', description: 'Personalized content' },
]
---
<ConsentBanner
consentRequired={false}
cookieName="gl_consent"
purposes={purposes}
position="bottom"
showManageLink={true}
/>Programmatic Reopen
For footer links or privacy pages that should reopen the in-page banner instead of sending users to a separate route:
document.dispatchEvent(
new CustomEvent('gl:consent_reopen', {
detail: { view: 'preferences' }, // or 'main'
}),
)The banner reuses any existing consent cookie to prefill the checkboxes before it opens.
How Geography Detection Works
Uses trusted request.cf.country on Cloudflare Workers. Missing or malformed
Workers metadata, including Cloudflare's unknown XX code, fails closed.
Client-settable CF-IPCountry is ignored. If the country is in
requireConsentRegions, consent is required.
Checking Consent in Other Packages
import { isConsentGranted } from '@growth-labs/consent/utils'
if (isConsentGranted(context, 'analytics')) {
// Set tracking cookies, fire analytics
}If @growth-labs/consent is NOT installed, isConsentGranted() always returns true (appropriate for US-only sites).
Integration Order
Consent middleware runs AFTER auth, BEFORE analytics. List order in astro.config.mjs: auth → consent → analytics.
Key Patterns
- Virtual module:
virtual:growth-labs/consent/config - Runtime state self-seeds from the virtual config in middleware, routes, and utils
- The configured decision cookie (default
gl_consent) storesanalytics:true,marketing:false; its${cookieName}_requiredhelper is presentation state and never grants server writes or direct browser tracking - Banner is minimal HTML — consumers style with CSS/Tailwind
- Client-side control uses
gl:*CustomEvent events ondocument, includinggl:consent_reopen - The current
zaraz.consentAPI gates GA4 + third-party scripts; unresolved state is denied and queued events are released only after an explicit grant or the private status route confirms consent is not required
