Cookie consent banner
@slashclone/cookie-consent-banner
Add an honest cookie banner to a web app: analytics, ad pixels, and video embeds load only after a yes, and saying no is one click. One registry of every cookie and service drives the banner, the loaders, the CSP, and the privacy page's cookie table. Includes a registry checker and React templates.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Checks a cookie consent registry and turns it into the CSP additions and the
- 3// privacy page's cookie table. Node standard library only; reads and writes local files.
- 4//
- 5// node scripts/consent-plan.mjs --services examples/services.json --out out/consent-plan.json
- 6// [--report-only] exit 0 even when the registry has problems (they are still reported)
- 8import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
- 9import { dirname } from "node:path";
- 11const CATEGORIES = ["analytics", "advertising", "media", "support"];
- 12const DIRECTIVES = ["script", "connect", "img", "frame"];
- 14function args(argv) {
- 15 const out = {
- 16 services: "examples/services.json",
- 17 out: "out/consent-plan.json",
- 18 reportOnly: false,
- 19 };
- 20 for (let i = 2; i < argv.length; i++) {
- 21 const a = argv[i];
- 22 if (a === "--report-only") out.reportOnly = true;
- 23 else if (a === "--services") out.services = argv[++i];
- 24 else if (a === "--out") out.out = argv[++i];
- 25 else throw new Error(`Unknown argument: ${a}`);
- 26 }
- 27 return out;
- 28}
- 30function checkItem(item, where, problems) {
- 31 for (const key of ["name", "purpose", "lifetime"])
- 32 if (typeof item?.[key] !== "string" || item[key].trim() === "")
- 33 problems.push(`${where}: missing ${key}`);
- 34}
- 36export function plan(registry) {
- 37 const problems = [];
- 38 const necessary = Array.isArray(registry.necessary) ? registry.necessary : [];
- 39 const services = Array.isArray(registry.services) ? registry.services : [];
- 40 if (!registry.consentCookie)
- 41 problems.push("consentCookie: missing (the cookie that stores the choice)");
- 42 if (!necessary.some((n) => n.name === registry.consentCookie))
- 43 problems.push("necessary: the consent cookie itself must be listed");
- 44 necessary.forEach((n, i) => checkItem(n, `necessary[${i}]`, problems));
- 46 const csp = Object.fromEntries(DIRECTIVES.map((d) => [d, new Set()]));
- 47 const ids = new Set();
- 48 for (const [i, s] of services.entries()) {
- 49 const where = `services[${i}]${s?.id ? ` (${s.id})` : ""}`;
- 50 if (!s?.id || !/^[a-z0-9-]{2,30}$/.test(s.id))
- 51 problems.push(`${where}: id must be lowercase letters, digits, or dashes`);
- 52 else if (ids.has(s.id)) problems.push(`${where}: duplicate id`);
- 53 else ids.add(s.id);
- 54 if (!CATEGORIES.includes(s?.category))
- 55 problems.push(`${where}: category must be one of ${CATEGORIES.join(", ")}`);
- 56 if (!s?.name || !s?.provider) problems.push(`${where}: needs a name and a provider`);
- 57 if (!Array.isArray(s?.stores) || s.stores.length === 0)
- 58 problems.push(`${where}: list what it stores, even "Set by the provider"`);
- 59 else s.stores.forEach((item, j) => checkItem(item, `${where}.stores[${j}]`, problems));
- 60 if (s?.privacyUrl && !String(s.privacyUrl).startsWith("https://"))
- 61 problems.push(`${where}: privacyUrl must be https`);
- 62 for (const [directive, hosts] of Object.entries(s?.csp ?? {})) {
- 63 if (!DIRECTIVES.includes(directive)) {
- 64 problems.push(`${where}: unknown CSP directive "${directive}"`);
- 65 continue;
- 66 }
- 67 for (const h of hosts) {
- 68 if (!/^https:\/\/(\*\.)?[a-z0-9.-]+$/.test(h))
- 69 problems.push(`${where}: CSP host "${h}" must be https and a bare origin`);
- 70 else csp[directive].add(h);
- 71 }
- 72 }
- 73 if (s?.clear) {
- 74 let pattern;
- 75 try {
- 76 pattern = new RegExp(s.clear);
- 77 } catch {
- 78 problems.push(`${where}: clear is not a valid pattern`);
- 79 }
- 80 // Revoking a category must never sign people out or forget their choice.
- 81 for (const n of necessary)
- 82 if (pattern && pattern.test(n.name))
- 83 problems.push(`${where}: clear pattern would delete necessary "${n.name}"`);
- 84 }
- 85 }
- 87 const row = (i) => `| \`${i.name}\` | ${i.purpose} | ${i.lifetime} |`;
- 88 const head = "| Name | Why | Kept for |\n| --- | --- | --- |";
- 89 const sections = [`### Always on\n\n${head}\n${necessary.map(row).join("\n")}`];
- 90 for (const category of CATEGORIES) {
- 91 const inCategory = services.filter((s) => s.category === category);
- 92 if (inCategory.length === 0) continue;
- 93 const label = category[0].toUpperCase() + category.slice(1);
- 94 const blocks = inCategory.map(
- 95 (s) =>
- 96 `**${s.name}**, from ${s.provider}.${s.privacyUrl ? ` [Privacy policy](${s.privacyUrl})` : ""}\n\n${head}\n${(s.stores ?? []).map(row).join("\n")}`,
- 97 );
- 98 sections.push(`### ${label} (only if you allow it)\n\n${blocks.join("\n\n")}`);
- 99 }
- 101 return {
- 102 problems,
- 103 categories: CATEGORIES.filter((c) => services.some((s) => s.category === c)),
- 104 services: services.length,
- 105 banner: services.length > 0,
- 106 csp: Object.fromEntries(DIRECTIVES.map((d) => [d, [...csp[d]].sort()])),
- 107 cookieTable: sections.join("\n\n"),
- 108 };
- 109}
- 111const opts = args(process.argv);
- 112const result = plan(JSON.parse(readFileSync(opts.services, "utf8")));
- 113mkdirSync(dirname(opts.out), { recursive: true });
- 114writeFileSync(opts.out, `${JSON.stringify(result, null, 2)}\n`);
- 115console.log(
- 116 result.banner
- 117 ? `Banner needed for: ${result.categories.join(", ")}`
- 118 : "Nothing optional: no banner needed.",
- 119);
- 120for (const d of DIRECTIVES)
- 121 if (result.csp[d].length) console.log(`CSP ${d}-src add: ${result.csp[d].join(" ")}`);
- 122if (result.problems.length) {
- 123 console.log(`\n${result.problems.length} problem(s):`);
- 124 for (const p of result.problems) console.log(`- ${p}`);
- 125 if (!opts.reportOnly) process.exit(1);
- 126} else console.log("Registry OK.");
