GDPR 쿠키 동의: 거부·동의가 동등한 배너, 카테고리, 동의 기록, ConsentGate, Consent Mode v2
설치
genpm add @core/consent포함 내용
- src/lib/consent/에 소스 코드, 파일 9개. (22.3kB)
- src/lib/consent/AGENTS.md에 AI 규칙, 그리고 IDE 규칙 파일.
- .env.example에 추가되는 환경 변수: CONSENT_POLICY_VERSION, SITE_URL, CONSENT_COOKIE_DOMAIN.
- @core/db을(를) 자동으로 해결합니다.
README
이 패키지에는 README가 없습니다.
이것이 AI가 src/lib/consent에서 작업할 때 읽는 내용 그대로입니다. 그 외에는 컨텍스트에 아무것도 추가되지 않습니다.
@core/consent — rules for AI agents
Purpose
Cookie consent that meets GDPR/ePrivacy: categories (necessary always on; analytics, marketing, preferences off by
default), a banner where "Reject all" is as prominent as "Accept all", a preferences center, proof of each decision
(consent_records: choices, policy version, date; no IP), server checks (hasConsent), a client ConsentGate and
Google Consent Mode v2 values. Changing CONSENT_POLICY_VERSION asks everyone again.
Map
index.ts— server API:getConsent,hasConsent,recordConsent,consentModeDefaultsScript,consentHistory,cookieDomain.react.ts— client:ConsentBanner,ConsentGate,CookieSettingsButton,openConsentPreferences,readClientConsent,currentPolicyVersion.adapters/hono.ts—consentRoutes().adapters/next.ts—consentRoute()→{ GET, POST }.
Integration
- Env:
CONSENT_POLICY_VERSION(bump it when the cookie policy changes),SITE_URL(its host gives the registrable domain where third-party tags set their cookies: the last two labels) and, when that guess is wrong (e.g..co.uk),CONSENT_COOKIE_DOMAIN=example.co.uk. Migrations as insrc/lib/db/AGENTS.md. - Mount
/api/consent(passcookiesByCategory, e.g.{ analytics: ['_ga'], marketing: ['_fbp', '_ttp'] }, so withdrawing deletes them, host-only and on the registrable domain). The endpoint only accepts same-origin JSON POSTs (CSRF): a cross-siteOrigin/Sec-Fetch-Sitegets 403 and other content types 415; the banner already posts that way. - Root layout:
<ConsentBanner policyVersion={policyVersion()} policyHref="/cookies" labels={translated} />and in the footer<CookieSettingsButton />(a button that callsopenConsentPreferences()). The banner also renders<meta name="consent-policy-version">: client checks treat a decision made under another version as undecided (everything off) until the visitor decides again. - Wrap every analytics/marketing script:
<ConsentGate category="marketing" policyVersion={policyVersion()}>…</ConsentGate>(policyVersionis optional when the banner is on the page); on the server usehasConsent(cookieHeader, 'analytics'). - With Google tags, render
consentModeDefaultsScript(getConsent(cookieHeader))inline before them. - Verify (browser devtools): before deciding, no request goes to third-party analytics or ad domains.
Conventions
- Labels must be translated (
labelsprop); keep both main buttons equal in style and size. - The consent cookie is first-party, not HttpOnly (scripts must read it), and holds no personal data.
- Keep the cookie policy page listing every cookie per category.
Don't
- Don't pre-check categories, use "scroll means consent" or hide "Reject all" behind extra clicks.
- Don't load analytics/marketing scripts outside
ConsentGate, or send hit data server-side without consent. - Don't block the site behind the banner (cookie walls).
# @core/consent — rules for AI agents
## Purpose
Cookie consent that meets GDPR/ePrivacy: categories (necessary always on; analytics, marketing, preferences off by
default), a banner where "Reject all" is as prominent as "Accept all", a preferences center, proof of each decision
(`consent_records`: choices, policy version, date; no IP), server checks (`hasConsent`), a client `ConsentGate` and
Google Consent Mode v2 values. Changing `CONSENT_POLICY_VERSION` asks everyone again.
## Map
- `index.ts` — server API: `getConsent`, `hasConsent`, `recordConsent`, `consentModeDefaultsScript`, `consentHistory`, `cookieDomain`.
- `react.ts` — client: `ConsentBanner`, `ConsentGate`, `CookieSettingsButton`, `openConsentPreferences`, `readClientConsent`, `currentPolicyVersion`.
- `adapters/hono.ts` — `consentRoutes()`. `adapters/next.ts` — `consentRoute()` → `{ GET, POST }`.
## Integration
1. Env: `CONSENT_POLICY_VERSION` (bump it when the cookie policy changes), `SITE_URL` (its host gives the registrable
domain where third-party tags set their cookies: the last two labels) and, when that guess is wrong (e.g. `.co.uk`),
`CONSENT_COOKIE_DOMAIN=example.co.uk`. Migrations as in `src/lib/db/AGENTS.md`.
2. Mount `/api/consent` (pass `cookiesByCategory`, e.g. `{ analytics: ['_ga'], marketing: ['_fbp', '_ttp'] }`, so withdrawing
deletes them, host-only and on the registrable domain). The endpoint only accepts same-origin JSON POSTs (CSRF):
a cross-site `Origin`/`Sec-Fetch-Site` gets 403 and other content types 415; the banner already posts that way.
3. Root layout: `<ConsentBanner policyVersion={policyVersion()} policyHref="/cookies" labels={translated} />` and in the footer
`<CookieSettingsButton />` (a button that calls `openConsentPreferences()`). The banner also renders `<meta name="consent-policy-version">`: client checks
treat a decision made under another version as undecided (everything off) until the visitor decides again.
4. Wrap every analytics/marketing script: `<ConsentGate category="marketing" policyVersion={policyVersion()}>…</ConsentGate>`
(`policyVersion` is optional when the banner is on the page); on the server use `hasConsent(cookieHeader, 'analytics')`.
5. With Google tags, render `consentModeDefaultsScript(getConsent(cookieHeader))` inline before them.
6. Verify (browser devtools): before deciding, no request goes to third-party analytics or ad domains.
## Conventions
- Labels must be translated (`labels` prop); keep both main buttons equal in style and size.
- The consent cookie is first-party, not HttpOnly (scripts must read it), and holds no personal data.
- Keep the cookie policy page listing every cookie per category.
## Don't
- Don't pre-check categories, use "scroll means consent" or hide "Reject all" behind extra clicks.
- Don't load analytics/marketing scripts outside `ConsentGate`, or send hit data server-side without consent.
- Don't block the site behind the banner (cookie walls).
.genpmignore 적용 후 주입될 정확한 트리입니다. 고정 대상:
// Consentimiento de cookies: estado en una cookie de primera parte, registro de prueba en BD, comprobación en servidor
// y valores para Google Consent Mode v2. "Rechazar" debe ser tan fácil como "Aceptar" (lo garantiza el banner).
import { asc, eq } from 'drizzle-orm';
import { z } from 'zod';
import { type Executor, getDb, newId } from '../db/index.ts';
import { consentRecords } from './schema.ts';
export const CATEGORIES = ['analytics', 'marketing', 'preferences'] as const;
export type Category = (typeof CATEGORIES)[number] | 'necessary';
export const CONSENT_COOKIE = 'consent';
const MAX_AGE_DAYS = 180;
export type ConsentState = {
/** Hay una decisión vigente (misma versión de la política). */
decided: boolean;
subjectId: string | null;
policyVersion: string | null;
choices: Record<(typeof CATEGORIES)[number], boolean>;
};
export const policyVersion = () => process.env.CONSENT_POLICY_VERSION ?? '1';
const none = () => Object.fromEntries(CATEGORIES.map((c) => [c, false])) as ConsentState['choices'];
const b64 = (s: string) => btoa(s).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
const unb64 = (s: string) => atob(s.replaceAll('-', '+').replaceAll('_', '/'));
const Stored = z.object({ s: z.string().regex(/^cns_[0-9A-Za-z]{26}$|^[0-9A-Za-z_]{8,40}$/), v: z.string().max(20), c: z.record(z.string(), z.boolean()) });
/** Estado actual desde la cabecera Cookie. Sin cookie o con otra versión de la política: no decidido (todo a false). */
export function getConsent(cookieHeader: string | null | undefined): ConsentState {
const raw = (cookieHeader ?? '').split(/;\s*/).find((c) => c.startsWith(`${CONSENT_COOKIE}=`))?.slice(CONSENT_COOKIE.length + 1);
if (raw) {
try {
const parsed = Stored.parse(JSON.parse(unb64(decodeURIComponent(raw))));
if (parsed.v === policyVersion()) {
const choices = none();
for (const c of CATEGORIES) choices[c] = parsed.c[c] === true;
return { decided: true, subjectId: parsed.s, policyVersion: parsed.v, choices };
}
return { decided: false, subjectId: parsed.s, policyVersion: parsed.v, choices: none() };
} catch {
// Cookie dañada o manipulada: como si no hubiera decisión.
}
}
return { decided: false, subjectId: null, policyVersion: null, choices: none() };
}
/** ¿Hay consentimiento para esta categoría? `necessary` siempre es true. */
export const hasConsent = (cookieHeader: string | null | undefined, category: Category) =>
category === 'necessary' || getConsent(cookieHeader).choices[category];
export const ChoicesInput = z.object({ analytics: z.boolean().default(false), marketing: z.boolean().default(false), preferences: z.boolean().default(false) });
/**
* Guarda una decisión: registro de prueba + cookie. Devuelve la cabecera Set-Cookie (y las que borran cookies de
* categorías retiradas, si se indican en `cookiesByCategory`).
*/
export async function recordConsent(
input: { choices: z.input<typeof ChoicesInput>; action?: 'banner' | 'preferences' | 'withdraw'; cookieHeader?: string | null },
opts: { secure?: boolean; cookiesByCategory?: Partial<Record<(typeof CATEGORIES)[number], string[]>> } = {},
db: Executor = getDb(),
): Promise<{ state: ConsentState; setCookies: string[] }> {
const choices = ChoicesInput.parse(input.choices);
const prev = getConsent(input.cookieHeader);
const subjectId = prev.subjectId ?? newId('cns');
await db.insert(consentRecords).values({ subjectId, choices, policyVersion: policyVersion(), action: input.action ?? 'banner' });
const value = encodeURIComponent(b64(JSON.stringify({ s: subjectId, v: policyVersion(), c: choices })));
const secure = opts.secure ?? process.env.NODE_ENV === 'production';
const setCookies = [`${CONSENT_COOKIE}=${value}; Path=/; SameSite=Lax; Max-Age=${MAX_AGE_DAYS * 86_400}${secure ? '; Secure' : ''}`];
// Al retirar una categoría, se borran sus cookies conocidas (p. ej. `_ga`, `_fbp`): sin dominio y con el dominio
// registrable, que es donde las fijan GA, Meta y TikTok (`Domain=.example.com`).
const domain = cookieDomain();
for (const c of CATEGORIES)
if (!choices[c])
for (const name of opts.cookiesByCategory?.[c] ?? [])
if (/^[\w.-]{1,64}$/.test(name)) {
setCookies.push(`${name}=; Path=/; Max-Age=0`);
if (domain) setCookies.push(`${name}=; Path=/; Domain=${domain}; Max-Age=0`);
}
return { state: { decided: true, subjectId, policyVersion: policyVersion(), choices }, setCookies };
}
/**
* Dominio con el que las etiquetas de terceros fijan sus cookies: `CONSENT_COOKIE_DOMAIN` (p. ej. `example.co.uk` cuando
* el sufijo tiene dos niveles) o, si no, las dos últimas etiquetas del host de `SITE_URL`. Null para IPs y `localhost`.
*/
export function cookieDomain(env: Record<string, string | undefined> = process.env): string | null {
const explicit = env.CONSENT_COOKIE_DOMAIN?.trim().replace(/^\./, '').toLowerCase();
if (explicit) return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(explicit) ? explicit : null;
let host: string;
try {
host = new URL(env.SITE_URL ?? '').hostname.toLowerCase();
} catch {
return null;
}
const labels = host.split('.');
if (labels.length < 2 || /^\d+$/.test(labels.at(-1)!) || host.includes(':')) return null;
return labels.slice(-2).join('.');
}
/** Historial de un navegador (para atender una solicitud de prueba o de acceso). */
export async function consentHistory(subjectId: string, db: Executor = getDb()) {
return db.select().from(consentRecords).where(eq(consentRecords.subjectId, subjectId)).orderBy(asc(consentRecords.createdAt));
}
/** Valores de Google Consent Mode v2 para `gtag('consent', 'default'|'update', …)`. */
export function consentModeValues(state: ConsentState) {
const g = (b: boolean) => (b ? 'granted' : 'denied');
return {
ad_storage: g(state.choices.marketing),
ad_user_data: g(state.choices.marketing),
ad_personalization: g(state.choices.marketing),
analytics_storage: g(state.choices.analytics),
functionality_storage: 'granted',
personalization_storage: g(state.choices.preferences),
security_storage: 'granted',
} as const;
}
/** Script en línea con los valores por defecto de Consent Mode (va antes de cualquier etiqueta de Google). */
export function consentModeDefaultsScript(state: ConsentState): string {
return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('consent','default',${JSON.stringify({ ...consentModeValues(state), wait_for_update: 500 }).replaceAll('<', '\\u003c')});`;
}
이 패키지는 MCP 서버를 선언하지 않습니다.
| 버전 | 커밋 | 게시일 | 검사 |
|---|---|---|---|
| 1.1.0 | 37d6a8f | 7시간 전 | 검사 통과 |
- genpm
- @core/db ^1.0.0
- 제안됨
- GenPM은 npm 명령을 제안하고, 동의한 경우에만 실행합니다.
- 검사
- 검사 통과 · 문제 0건
- 커밋
- v1.1.0 → 37d6a8faf85f764fdc34ddf9623da7b2b19bcd35 · 가져온 뒤 검증됨
- 스크립트
- 없음. GenPM은 패키지 코드를 절대 실행하지 않습니다.
- 라이선스
- MIT
- 품질
- 100/100
- 인정된 라이선스충족
- AGENTS.md에 목적 설명충족
- AGENTS.md에 통합 단계충족
- AGENTS.md에 규칙 또는 금지 사항충족
- 테스트 포함충족
- 보안 검사 통과충족
- 최근 6개월 내 게시충족
- 인증된 게시자충족
- 요약과 키워드충족
- 신고
- 문제가 있나요?