@cookiecrumbs-eu/mcp 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +134 -0
  2. package/README.md +186 -0
  3. package/dist/cli/src/api.js +192 -0
  4. package/dist/cli/src/auth.js +106 -0
  5. package/dist/cli/src/commands/_shared.js +78 -0
  6. package/dist/cli/src/commands/alerts.js +85 -0
  7. package/dist/cli/src/commands/auth.js +92 -0
  8. package/dist/cli/src/commands/declaration.js +45 -0
  9. package/dist/cli/src/commands/diff.js +26 -0
  10. package/dist/cli/src/commands/domains.js +44 -0
  11. package/dist/cli/src/commands/export.js +136 -0
  12. package/dist/cli/src/commands/init.js +134 -0
  13. package/dist/cli/src/commands/install.js +77 -0
  14. package/dist/cli/src/commands/issues.js +61 -0
  15. package/dist/cli/src/commands/link.js +41 -0
  16. package/dist/cli/src/commands/logs.js +98 -0
  17. package/dist/cli/src/commands/open.js +45 -0
  18. package/dist/cli/src/commands/pull.js +89 -0
  19. package/dist/cli/src/commands/push.js +110 -0
  20. package/dist/cli/src/commands/scan.js +94 -0
  21. package/dist/cli/src/commands/schedule.js +97 -0
  22. package/dist/cli/src/commands/services.js +143 -0
  23. package/dist/cli/src/commands/sites.js +111 -0
  24. package/dist/cli/src/commands/status.js +90 -0
  25. package/dist/cli/src/commands/templates.js +133 -0
  26. package/dist/cli/src/commands/tokens.js +50 -0
  27. package/dist/cli/src/commands/usage.js +41 -0
  28. package/dist/cli/src/commands/versions.js +95 -0
  29. package/dist/cli/src/commands/webhooks.js +164 -0
  30. package/dist/cli/src/configpkg.js +10 -0
  31. package/dist/cli/src/diff.js +63 -0
  32. package/dist/cli/src/errors.js +20 -0
  33. package/dist/cli/src/frameworks.js +141 -0
  34. package/dist/cli/src/index.js +100 -0
  35. package/dist/cli/src/jobs.js +59 -0
  36. package/dist/cli/src/merge.js +38 -0
  37. package/dist/cli/src/output.js +112 -0
  38. package/dist/cli/src/project.js +269 -0
  39. package/dist/cli/src/util.js +122 -0
  40. package/dist/config/rules_reference.json +569 -0
  41. package/dist/config/src/canon.js +36 -0
  42. package/dist/config/src/declaration.js +38 -0
  43. package/dist/config/src/defaults.js +804 -0
  44. package/dist/config/src/export.js +130 -0
  45. package/dist/config/src/index.js +16 -0
  46. package/dist/config/src/lint.js +139 -0
  47. package/dist/config/src/regimes.js +62 -0
  48. package/dist/config/src/rules.js +90 -0
  49. package/dist/config/src/schema.js +323 -0
  50. package/dist/config/src/theme.js +147 -0
  51. package/dist/config/src/verify.js +51 -0
  52. package/dist/config/src/webhooks.js +309 -0
  53. package/dist/mcp/src/auth.js +40 -0
  54. package/dist/mcp/src/client.js +44 -0
  55. package/dist/mcp/src/diff.js +134 -0
  56. package/dist/mcp/src/index.js +25 -0
  57. package/dist/mcp/src/matrix.js +106 -0
  58. package/dist/mcp/src/server.js +171 -0
  59. package/dist/mcp/src/shared.js +147 -0
  60. package/dist/mcp/src/tools-config.js +943 -0
  61. package/dist/mcp/src/tools.js +650 -0
  62. package/package.json +66 -0
@@ -0,0 +1,323 @@
1
+ import { z } from 'zod';
2
+ // ------------------------------------------------------------------
3
+ // BannerConfig v1 — see docs/platform/contracts.md ("Phase 4 contracts"
4
+ // → "BannerConfig v1 additions"). This is the one schema every writer
5
+ // (dashboard, CLI, REST, MCP) validates against; the same shape is
6
+ // enforced in Postgres through schema_registry('banner_config', 1).
7
+ // Phase 4 only ADDS optional fields, so every stored draft stays valid.
8
+ // ------------------------------------------------------------------
9
+ export const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
10
+ export const CATEGORY_KEY = /^[a-z][a-z0-9_]{1,31}$/;
11
+ export const LANG_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/;
12
+ export const COUNTRY_CODE = /^[A-Z]{2}$/;
13
+ /** Footer links point at real pages on the operator's own site, so nothing but http(s) is accepted. */
14
+ export const URL_HTTP = /^https?:\/\/\S+$/i;
15
+ export const GCM_SIGNALS = [
16
+ 'ad_storage',
17
+ 'analytics_storage',
18
+ 'ad_user_data',
19
+ 'ad_personalization',
20
+ 'functionality_storage',
21
+ 'personalization_storage',
22
+ 'security_storage',
23
+ ];
24
+ /**
25
+ * `ribbon`, `sheet` and `inline` are additive (2026-08-23). An older runtime that meets one of them
26
+ * falls back to its nearest shipped shape, so a published config never renders nothing:
27
+ * ribbon → bar, sheet → modal, inline → corner (see LAYOUT_FALLBACK).
28
+ */
29
+ export const LAYOUTS = ['bar', 'modal', 'corner', 'headless', 'ribbon', 'sheet', 'inline'];
30
+ export const LAYOUT_FALLBACK = {
31
+ bar: 'bar',
32
+ modal: 'modal',
33
+ corner: 'corner',
34
+ headless: 'headless',
35
+ ribbon: 'bar',
36
+ sheet: 'modal',
37
+ inline: 'corner',
38
+ };
39
+ /**
40
+ * Local faces only. The banner is a blocking first script on someone else's page and may make no
41
+ * third-party request, so a webfont is not available to it at any price — the character has to come
42
+ * from scale, weight and tracking instead. `inherit` adopts the host page's own family.
43
+ */
44
+ export const TYPE_STACKS = ['system', 'inherit', 'grotesk', 'serif', 'mono', 'rounded'];
45
+ /** The six regimes of region_rule_sets v3 (`ca_qc` added in phase 5). */
46
+ export const REGIMES = ['eu_optin', 'uk_pecr', 'us_optout', 'ch_fadp', 'br_lgpd', 'ca_qc'];
47
+ /** Required in every enabled language (lint `missing_text`). */
48
+ export const TEXT_KEYS = [
49
+ 'title',
50
+ 'body',
51
+ 'accept_all',
52
+ 'reject_all',
53
+ 'settings',
54
+ 'save',
55
+ 'declaration_link',
56
+ 'reopen_label',
57
+ 'consent_id_label',
58
+ ];
59
+ /** Optional per language; some become required by lint depending on regions/behaviour. */
60
+ export const OPTIONAL_TEXT_KEYS = [
61
+ 'do_not_sell',
62
+ 'gpc_honoured',
63
+ 'object',
64
+ 'close_refuses',
65
+ 'saved',
66
+ 'confidentiality_settings',
67
+ /** Footer of every banner, when the matching URL is set in `links`. */
68
+ 'imprint_label',
69
+ 'privacy_label',
70
+ /** Accessible label for the language switcher. */
71
+ 'language_label',
72
+ ];
73
+ export const ALL_TEXT_KEYS = [...TEXT_KEYS, ...OPTIONAL_TEXT_KEYS];
74
+ const colour = z.string().regex(HEX_COLOUR, 'expected #rrggbb');
75
+ export const ThemeSchema = z.object({
76
+ // ---- v1 core. Required, and in `two_ink` these carry the DERIVED values so an older runtime
77
+ // (or an export, or a screenshot service) still renders the same banner without knowing the system.
78
+ background: colour,
79
+ text: colour,
80
+ muted: colour,
81
+ button: colour,
82
+ button_text: colour,
83
+ border: colour,
84
+ radius: z.number().int().min(0).max(32),
85
+ font: z.enum(['system', 'inherit']),
86
+ dark: z.enum(['light', 'dark', 'system']),
87
+ // ---- Two Inks (2026-08-23). All optional; absent = `classic` = today's rendering exactly.
88
+ /** Which rendering system draws the banner. */
89
+ system: z.enum(['classic', 'two_ink']).optional(),
90
+ /** The decisive plate: body text, buttons, rules, the switch. */
91
+ ink: colour.optional(),
92
+ /**
93
+ * Optional button plate, when the buttons should not be the same colour as the body text — the
94
+ * ordinary "dark text, coloured buttons" look. The runtime picks the button label from stock or ink,
95
+ * whichever is legible on it, so an accent can never produce an unreadable button.
96
+ */
97
+ accent: colour.optional(),
98
+ /** The second plate. Where it overprints `ink` the system derives a third colour for free. */
99
+ ink2: colour.optional(),
100
+ /** The ground the two inks are printed on. */
101
+ stock: colour.optional(),
102
+ /** Surface tooth, 0-3. Uncoated stock, not a texture image — it is two CSS gradients. */
103
+ tooth: z.number().int().min(0).max(3).optional(),
104
+ /** Off-registration in px, 0-4: how far the second plate sits from the first. 0 = perfect register. */
105
+ registration: z.number().int().min(0).max(4).optional(),
106
+ /** The one spacing division. Every measurement on the banner is a multiple of it. */
107
+ unit: z.number().int().min(4).max(12).optional(),
108
+ /** Local face for the banner. Never a webfont — see TYPE_STACKS. */
109
+ type_stack: z.enum(TYPE_STACKS).optional(),
110
+ /** Type scale. `loud` is display-scale; `compact` suits a ribbon. */
111
+ type_scale: z.enum(['compact', 'regular', 'loud']).optional(),
112
+ /**
113
+ * Button language. Accept and Reject ALWAYS share it — equal prominence is a legal requirement
114
+ * under eu_optin / uk_pecr / br_lgpd / ca_qc, so the shape is a property of the theme, never of a
115
+ * single control, and there is deliberately no way to style one decision differently from the other.
116
+ */
117
+ button_shape: z.enum(['sharp', 'soft', 'pill', 'plate']).optional(),
118
+ button_fill: z.enum(['solid', 'outline', 'plate']).optional(),
119
+ /** How the banner meets the page, and what that meeting-line says. */
120
+ edge: z.enum(['none', 'hairline', 'rule', 'plate']).optional(),
121
+ /** The backdrop behind a dialog layout. */
122
+ backdrop: z.enum(['none', 'dim', 'deep', 'ink']).optional(),
123
+ /** Elevation off the page. */
124
+ lift: z.enum(['none', 'soft', 'hard']).optional(),
125
+ /** The perforated receipt strip along the bottom edge — the consent record, on the banner. */
126
+ receipt: z.boolean().optional(),
127
+ /**
128
+ * What sits inside the floating reopen roundel. Absent/`brand` = the CookieCrumbs mark (the Free
129
+ * plan's attribution, like the footer line); `cookie` = the plain cookie badge; `custom` = the
130
+ * operator's own image (`reopen_image`). Anything but `brand` needs the remove_branding feature —
131
+ * the publish gate enforces it.
132
+ */
133
+ reopen_icon: z.enum(['brand', 'cookie', 'custom']).optional(),
134
+ /** The custom icon: an SVG/PNG/WebP data URL (kept small) or an https address on the operator's own site. */
135
+ reopen_image: z
136
+ .string()
137
+ .max(24000)
138
+ .regex(/^(data:image\/(svg\+xml|png|jpeg|webp);base64,[A-Za-z0-9+/=]+|https:\/\/\S+)$/, 'an image data URL or an https address')
139
+ .optional(),
140
+ /** Custom colours for the reopen roundel; absent = derived from the theme (dark-mode aware). */
141
+ reopen_bg: colour.optional(),
142
+ reopen_fg: colour.optional(),
143
+ /** Cap for a modal/sheet's height on small screens, in viewport-height percent. */
144
+ max_height_mobile: z.number().int().min(40).max(95).optional(),
145
+ /** Where a bar / ribbon / corner / inline banner sits. Ignored by modal and sheet. */
146
+ placement: z.enum(['bottom', 'top', 'bottom_left', 'bottom_right', 'top_right']).optional(),
147
+ /** The one authored moment. `tear` tears the notice along its perforation into layer 2. */
148
+ motion: z.enum(['none', 'fade', 'rise', 'tear']).optional(),
149
+ /**
150
+ * A small cookie after the title ("We use cookies and other technologies. 🍪"), drawn as inline SVG,
151
+ * never an emoji and never an image request. `cookie` is a glyph in the title's own colour;
152
+ * `cookie_colour` is a baked, golden-brown cookie with chocolate chips. Absent = none.
153
+ */
154
+ title_icon: z.enum(['none', 'cookie', 'cookie_colour']).optional(),
155
+ /**
156
+ * The operator's own CSS, appended to the banner's stylesheet inside the shadow root. `@import` and
157
+ * external `url()` are removed by `sanitiseCss` before it is served: a consent banner that fetches
158
+ * something from another origin before the visitor has chosen is the exact thing this product exists
159
+ * to prevent. 8 kB is a generous ceiling for overriding a banner and a hard cap on what rides on the
160
+ * critical path of someone else's page.
161
+ */
162
+ custom_css: z.string().max(8000).optional(),
163
+ });
164
+ export const TextsSchema = z
165
+ .object({
166
+ ...Object.fromEntries(TEXT_KEYS.map((k) => [k, z.string().min(1).max(2000)])),
167
+ ...Object.fromEntries(OPTIONAL_TEXT_KEYS.map((k) => [k, z.string().max(2000).optional()])),
168
+ })
169
+ .strict();
170
+ export const CategorySchema = z.object({
171
+ key: z.string().regex(CATEGORY_KEY, 'lowercase key, 2-32 chars'),
172
+ locked: z.boolean(),
173
+ default: z.boolean(),
174
+ gcm: z.array(z.enum(GCM_SIGNALS)).max(7),
175
+ /** On by default with an "Object" control under uk_pecr statistical mode. */
176
+ uk_statistical: z.boolean().optional(),
177
+ });
178
+ export const CategoryTextSchema = z.object({
179
+ name: z.string().min(1).max(80),
180
+ description: z.string().min(1).max(1000),
181
+ });
182
+ export const RegimeSettingSchema = z
183
+ .object({
184
+ enabled: z.boolean(),
185
+ expiry_months: z.number().int().min(1).max(13),
186
+ })
187
+ .strict();
188
+ export const UkPecrSettingSchema = RegimeSettingSchema.extend({ statistical_mode: z.boolean().optional() }).strict();
189
+ export const UsOptoutSettingSchema = RegimeSettingSchema.extend({
190
+ expiry_months: z.number().int().min(1).max(12),
191
+ /** Honour Global Privacy Control; default true. */
192
+ gpc: z.boolean().optional(),
193
+ }).strict();
194
+ export const ChFadpSettingSchema = RegimeSettingSchema.extend({
195
+ /** strict = opt-in like the EU; fmg = functional + measurement granted by default, marketing opt-in. */
196
+ mode: z.enum(['strict', 'fmg']).optional(),
197
+ }).strict();
198
+ /**
199
+ * Quebec Law 25: opt-in for every non-necessary category, a refuse-all control of equal prominence
200
+ * (the runtime renders Accept all / Reject all with the same style) and the "confidentiality settings"
201
+ * wording on the settings control (`texts.<lang>.confidentiality_settings`). `settings_label: 'default'`
202
+ * deliberately keeps the ordinary "Settings" label and silences the lint warning.
203
+ */
204
+ export const CaQcSettingSchema = RegimeSettingSchema.extend({
205
+ settings_label: z.enum(['confidentiality', 'default']).optional(),
206
+ }).strict();
207
+ export const RegimesSchema = z
208
+ .object({
209
+ eu_optin: RegimeSettingSchema.optional(),
210
+ uk_pecr: UkPecrSettingSchema.optional(),
211
+ us_optout: UsOptoutSettingSchema.optional(),
212
+ ch_fadp: ChFadpSettingSchema.optional(),
213
+ br_lgpd: RegimeSettingSchema.optional(),
214
+ ca_qc: CaQcSettingSchema.optional(),
215
+ })
216
+ .strict();
217
+ export const RegionsSchema = z
218
+ .object({
219
+ rules_version: z.number().int().min(1),
220
+ regimes: RegimesSchema,
221
+ fallback: z.enum(REGIMES),
222
+ force_regime: z.enum(REGIMES).nullable().optional(),
223
+ overrides: z.record(z.string().regex(COUNTRY_CODE, 'ISO 3166-1 alpha-2, upper case'), z.enum(REGIMES)).optional(),
224
+ })
225
+ .strict();
226
+ export const BannerConfigSchema = z
227
+ .object({
228
+ schema_version: z.literal(1),
229
+ layout: z.enum(LAYOUTS),
230
+ theme: ThemeSchema,
231
+ default_lang: z.string().regex(LANG_TAG),
232
+ languages: z.array(z.string().regex(LANG_TAG)).min(1).max(40),
233
+ texts: z.record(z.string().regex(LANG_TAG), TextsSchema),
234
+ categories: z.array(CategorySchema).min(1).max(12),
235
+ category_texts: z.record(z.string().regex(LANG_TAG), z.record(z.string().regex(CATEGORY_KEY), CategoryTextSchema)),
236
+ block: z.record(z.string().min(1).max(253), z.string().regex(CATEGORY_KEY)),
237
+ regions: RegionsSchema,
238
+ consent_mode: z
239
+ .object({
240
+ enabled: z.boolean(),
241
+ mode: z.enum(['basic', 'advanced']),
242
+ wait_for_update: z.number().int().min(0).max(5000),
243
+ /** gtag('set','ads_data_redaction', true) before the defaults; default true. */
244
+ ads_data_redaction: z.boolean().optional(),
245
+ /** gtag('set','url_passthrough', true); default false. */
246
+ url_passthrough: z.boolean().optional(),
247
+ /**
248
+ * What happens to the Google Tag Manager container script (`googletagmanager.com/gtm.js`) before
249
+ * a choice. `block` (the default): it waits for its category like any other script, so Tag
250
+ * Assistant sees nothing until a visitor decides. `load`: the container loads at once (it sets
251
+ * no cookies of its own) and the tags inside it are gated by the Consent Mode signals cc.js sets
252
+ * before it; the "Consent Initialization" trigger and the CookieCrumbs GTM template then work.
253
+ */
254
+ gtm_container: z.enum(['block', 'load']).optional(),
255
+ })
256
+ .strict(),
257
+ /**
258
+ * Links to the site's OWN pages, shown in the banner's footer. Optional, because not every site has
259
+ * both; each is rendered only when it is set. Labels come from `texts.<lang>.imprint_label` /
260
+ * `privacy_label` so they translate with everything else.
261
+ */
262
+ links: z
263
+ .object({
264
+ imprint: z.string().regex(URL_HTTP, 'expected a full http(s) address').max(2000).optional(),
265
+ privacy: z.string().regex(URL_HTTP, 'expected a full http(s) address').max(2000).optional(),
266
+ })
267
+ .strict()
268
+ .optional(),
269
+ /**
270
+ * The "Made & Hosted in Europe by CookieCrumbs" line in the banner footer. Omitted = shown.
271
+ * Turning it off is a paid entitlement: the publish gate requires the `remove_branding` plan
272
+ * feature, and `publish_version` forces `show: true` back in for plans without it.
273
+ */
274
+ branding: z.object({ show: z.boolean() }).strict().optional(),
275
+ /**
276
+ * Embed code injected when its category is granted, compiled at publish from managed services
277
+ * with an active `embed_code` (the CCM-style "source code of the embed"). Site-owner-authored
278
+ * markup, executed on the site owner's own page at their instruction; never edited by hand here.
279
+ */
280
+ inject: z
281
+ .array(z.object({ category: z.string().regex(CATEGORY_KEY), code: z.string().min(1).max(20000) }).strict())
282
+ .max(24)
283
+ .optional(),
284
+ behaviour: z
285
+ .object({
286
+ reask_on_material: z.boolean(),
287
+ refusal_months: z.number().int().min(1).max(13),
288
+ show_consent_id: z.boolean(),
289
+ reopen_control: z.enum(['floating', 'link']),
290
+ /** Closing layer 1 records `implied_dismiss` (all non-locked off); default false = no decision. */
291
+ implied_dismiss: z.boolean().optional(),
292
+ /** auto: data-cc-lang → <html lang> → navigator.languages → default_lang; default: default_lang only. */
293
+ lang_detect: z.enum(['auto', 'default']).optional(),
294
+ /**
295
+ * Show a language picker in the banner. `auto` (the default) shows it only when more than one
296
+ * language is configured, which is the only time it would do anything.
297
+ */
298
+ language_switcher: z.enum(['auto', 'always', 'never']).optional(),
299
+ /** What the reopen control (and [data-cc-open]) opens: the settings layer (default) or the first layer. */
300
+ reopen_opens: z.enum(['settings', 'banner']).optional(),
301
+ /**
302
+ * Render the category switches on the FIRST layer for quick selection. A Save button joins
303
+ * Accept all / Reject all, and Settings becomes a plain text link beneath the buttons.
304
+ */
305
+ categories_on_first: z.boolean().optional(),
306
+ })
307
+ .strict(),
308
+ })
309
+ .strict();
310
+ /** The JSON the runtime fetches: the draft plus publication metadata. */
311
+ export const PublishedConfigSchema = BannerConfigSchema.extend({
312
+ version_id: z.string().uuid(),
313
+ number: z.number().int().min(1),
314
+ site_key: z.string(),
315
+ environment: z.enum(['production', 'preview']),
316
+ runtime_min: z.string(),
317
+ published_at: z.string(),
318
+ hashes: z.object({
319
+ config: z.string().length(64),
320
+ purposes: z.string().length(64),
321
+ texts: z.record(z.string(), z.string().length(64)),
322
+ }),
323
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * The Two Inks derivation — ONE implementation, shared by the runtime (which paints the banner) and the
3
+ * dashboard (which has to show the customer what their two choices produce before they publish).
4
+ *
5
+ * Duplicating this maths would guarantee drift between the preview and the real thing, which on a
6
+ * consent banner means a preview that passes contrast while the live banner fails it.
7
+ *
8
+ * See DESIGN.md → "The model". The rule that falls out of it: `ink` is the body-text colour, so it must
9
+ * pass 4.5:1 against `stock`; a bright colour can only ever be `ink2`.
10
+ */
11
+ /** Relative luminance, WCAG 2.1. Inlined so this module imports nothing. */
12
+ function luminance(hex) {
13
+ const n = parseInt(hex.slice(1), 16);
14
+ const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
15
+ const s = v / 255;
16
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
17
+ });
18
+ return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
19
+ }
20
+ /** Contrast ratio between two #rrggbb colours. */
21
+ export function contrast(a, b) {
22
+ const la = luminance(a);
23
+ const lb = luminance(b);
24
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
25
+ return (hi + 0.05) / (lo + 0.05);
26
+ }
27
+ const rgb = (hex) => {
28
+ const n = parseInt(hex.slice(1), 16);
29
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
30
+ };
31
+ const toHex = (c) => '#' + c.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join('');
32
+ /** `t` = 0 returns a, 1 returns b. */
33
+ export const mix = (a, b, t) => {
34
+ const [r1, g1, b1] = rgb(a);
35
+ const [r2, g2, b2] = rgb(b);
36
+ return toHex([r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t]);
37
+ };
38
+ /** What two plates actually do where they cross: multiply. This is where the third colour comes from. */
39
+ export const overprint = (a, b) => {
40
+ const [r1, g1, b1] = rgb(a);
41
+ const [r2, g2, b2] = rgb(b);
42
+ return toHex([(r1 * r2) / 255, (g1 * g2) / 255, (b1 * b2) / 255]);
43
+ };
44
+ /**
45
+ * Secondary text, in the ink's own hue rather than grey — but never lighter than AA allows.
46
+ *
47
+ * A fixed blend fails: mixing a mid-tone ink like #25408F a third of the way toward its stock lands at
48
+ * 3.77:1. So the blend walks back toward the ink until it clears 4.6:1, which means secondary text is as
49
+ * light as this particular pair can afford and no lighter.
50
+ *
51
+ * Muted text is also printed on the wash (the receipt strip, a hovered category row), which sits a few
52
+ * percent closer to the ink than the stock does — so the walk checks the wash too, or a pair like
53
+ * Press's federal blue passes on the stock and quietly lands at 4.1:1 on its own receipt.
54
+ */
55
+ export function mutedOf(ink, stock) {
56
+ const wash = mix(ink, stock, 0.93);
57
+ for (let t = 0.34; t > 0.02; t -= 0.04) {
58
+ const c = mix(ink, stock, t);
59
+ if (contrast(c, stock) >= 4.6 && contrast(c, wash) >= 4.5)
60
+ return c;
61
+ }
62
+ return ink;
63
+ }
64
+ /** Two chosen values in, a whole palette out. The customer never sets the derived ones. */
65
+ export function plates(t) {
66
+ const stock = t.stock || t.background;
67
+ const ink = t.ink || t.text;
68
+ const ink2 = t.ink2 || ink;
69
+ return {
70
+ stock,
71
+ ink,
72
+ ink2,
73
+ over: ink2 === ink ? mix(ink, '#000000', 0.18) : overprint(ink, ink2),
74
+ muted: mutedOf(ink, stock),
75
+ line: mix(ink, stock, 0.82),
76
+ wash: mix(ink, stock, 0.93),
77
+ onInk: stock,
78
+ };
79
+ }
80
+ /**
81
+ * The v1 six, computed from the pair. A `two_ink` config must always carry these so an older runtime,
82
+ * a config export, or a screenshot service renders the same banner without knowing the system exists.
83
+ */
84
+ export function derivedCore(t) {
85
+ const p = plates(t);
86
+ return { background: p.stock, text: p.ink, muted: p.muted, button: p.ink, button_text: p.stock, border: p.line };
87
+ }
88
+ /** What the builder shows beside the two pickers, so the derivation is visible rather than magic. */
89
+ export const DERIVED_ROLES = [
90
+ { key: 'muted', label: 'Secondary text', note: 'kept above 4.5:1 automatically' },
91
+ { key: 'over', label: 'Overprint', note: 'hover, focus ring, selection' },
92
+ { key: 'line', label: 'Hairlines', note: 'rules and dividers' },
93
+ { key: 'wash', label: 'Wash', note: 'row tint and the receipt strip' },
94
+ ];
95
+ /**
96
+ * Strip everything from operator CSS that would reach off-origin.
97
+ *
98
+ * The banner runs before consent exists, so it may make no third-party request at any price — that rules
99
+ * out `@import` and any `url()` pointing at a scheme or a protocol-relative host. `data:` is kept: it is
100
+ * inline by definition and issues no request. Returns the cleaned CSS and what was removed, because a
101
+ * silent strip is how someone spends an afternoon wondering why their background never loads.
102
+ */
103
+ export function sanitiseCss(css) {
104
+ const removed = [];
105
+ let out = css;
106
+ out = out.replace(/@import[^;]*;?/gi, (m) => {
107
+ removed.push(m.trim().slice(0, 80));
108
+ return '';
109
+ });
110
+ out = out.replace(/url\(\s*(['"]?)([^)'"]*)\1\s*\)/gi, (m, _q, ref) => {
111
+ const v = ref.trim();
112
+ if (/^data:/i.test(v) || v === '' || v.startsWith('#'))
113
+ return m;
114
+ removed.push(m.trim().slice(0, 80));
115
+ return 'none';
116
+ });
117
+ // </style> would end the banner's own stylesheet element early.
118
+ out = out.replace(/<\/style/gi, () => {
119
+ removed.push('</style');
120
+ return '';
121
+ });
122
+ return { css: out, removed };
123
+ }
124
+ /** The class names a banner is built from, so overriding it is not reverse engineering. */
125
+ export const CSS_HOOKS = [
126
+ { sel: '.cc-p', what: 'the notice itself (both layers)' },
127
+ { sel: '.cc-t', what: 'the title' },
128
+ { sel: '.cc-b', what: 'the body text' },
129
+ { sel: '.cc-l', what: 'the declaration link' },
130
+ { sel: '.cc-a', what: 'the row of decisions' },
131
+ { sel: '.cc-btn', what: 'every button; .cc-s is the subordinate Settings one' },
132
+ { sel: '.cc-ft', what: 'the footer with your imprint and privacy links' },
133
+ { sel: '.cc-rc', what: 'the consent receipt strip' },
134
+ { sel: '.cc-rows', what: 'the category list on layer 2' },
135
+ { sel: '.cc-row', what: 'one category row' },
136
+ { sel: '.cc-sw', what: 'a category switch' },
137
+ { sel: '.cc-bd', what: 'the backdrop behind a modal or sheet' },
138
+ { sel: '.cc-r', what: 'the floating reopen button' },
139
+ ];
140
+ /**
141
+ * Is this pair usable? `ink` carries the body text, so it must clear AA against `stock`. Returned rather
142
+ * than thrown: the builder shows it live while the customer is still dragging a colour picker.
143
+ */
144
+ export function inkCheck(ink, stock) {
145
+ const r = contrast(ink, stock);
146
+ return { ratio: r, ok: r >= 4.5 };
147
+ }
@@ -0,0 +1,51 @@
1
+ import { canonJson, sha256Hex } from "./canon.js";
2
+ const US = '';
3
+ export function canonicalEvent(r) {
4
+ const ms = Date.parse(r.occurred_at);
5
+ return [
6
+ r.site_id,
7
+ r.environment,
8
+ r.id,
9
+ r.subject_id,
10
+ r.event_type,
11
+ String(ms),
12
+ r.version_id,
13
+ r.texts_hash,
14
+ r.language ?? '',
15
+ r.country ?? '',
16
+ r.regime ?? '',
17
+ canonJson(r.categories),
18
+ canonJson(r.services ?? {}),
19
+ r.tc_string ?? '',
20
+ canonJson(r.gcm_state ?? {}),
21
+ canonJson(r.collection ?? {}),
22
+ r.client_nonce,
23
+ ].join(US);
24
+ }
25
+ export async function recordHash(r) {
26
+ return sha256Hex((r.prev_hash ?? '') + canonicalEvent(r));
27
+ }
28
+ /**
29
+ * The hash the *next* record links to. A redacted (tombstoned) record keeps its original hash in
30
+ * `redaction_prev_hash` while `record_hash` was recomputed over the redacted values; this mirrors
31
+ * public.verify_chain and is the only special case in the chain.
32
+ */
33
+ export function linkHash(r) {
34
+ return r.redacted ? (r.redaction_prev_hash ?? null) : r.record_hash;
35
+ }
36
+ /** Verifies an ordered list of records (oldest first) for one site + environment. */
37
+ export async function verifyChain(records) {
38
+ let prev = records.length ? records[0].prev_hash : null;
39
+ for (let i = 0; i < records.length; i++) {
40
+ const r = records[i];
41
+ if ((r.prev_hash ?? null) !== (prev ?? null))
42
+ return { ok: false, checked: i, firstBadId: r.id };
43
+ if (r.redacted && !r.redaction_prev_hash)
44
+ return { ok: false, checked: i, firstBadId: r.id };
45
+ const h = await recordHash(r);
46
+ if (h !== r.record_hash)
47
+ return { ok: false, checked: i, firstBadId: r.id };
48
+ prev = linkHash(r);
49
+ }
50
+ return { ok: true, checked: records.length, firstBadId: null };
51
+ }