Skip to content
SlashClone

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.

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

Cookies that keep you signed in and remember these choices are always on. Everything else is off until you turn it on.