@emplugins/emprivacy 3.0.0 → 3.2.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.
@@ -9,7 +9,9 @@ This guide is for **EmDash site managers** who want cookie consent on a live sit
9
9
  - A **banner** on first visit (or after you change **Policy / consent version**, or after an upgrade that adds the functional category).
10
10
  - Toggles for **Functional**, **Analytics**, and **Marketing**. Essential cookies stay on.
11
11
  - **Privacy and cookie policy links** you control (EmDash Page path or `https://`).
12
- - Analytics presets (Cloudflare, Plausible, Fathom, Umami, Simple Analytics, GA4, GTM under Marketing) plus Custom / None.
12
+ - Analytics presets (Cloudflare, Plausible, Fathom, Umami, Simple Analytics, GA4, GTM under Marketing, Microsoft Clarity under Analytics, Microsoft UET under Marketing) plus Custom / None.
13
+ - **Global Privacy Control** keeps Marketing off when the browser sends that signal.
14
+ - A signed-in admin download of the consent log when server logging is on.
13
15
  - Optional **gated embeds** for official EmDash YouTube / Vimeo / social / Gist blocks.
14
16
  - A **vendor list** generated from your settings.
15
17
 
@@ -92,7 +94,7 @@ Your base layout must include `<EmDashHead page={page} />`, `<EmDashBodyStart pa
92
94
  3. Choose an **Analytics platform** and fill the matching ID or token. Leave it on **None** if you do not use analytics.
93
95
  4. Optional: marketing script URLs (one `https://` `src` per line — never a `<script>` tag).
94
96
  5. Optional: gate embeds; pick Functional vs Marketing.
95
- 6. Optional: hex theme colors, locale JSON overrides, Google Consent Mode, server log.
97
+ 6. Optional: banner position (bottom or top), a banner theme profile or custom hex colors, locale JSON overrides, Google Consent Mode, server log. When the log is on, the same page links to the CSV download.
96
98
  7. **Save settings**.
97
99
 
98
100
  The **What this site uses** table on the same page is generated from the saved config. You can copy it into the cookie policy. Tokens are never listed.
@@ -136,7 +138,7 @@ Do not read `emprivacy_cc` yourself. The JSON shape can change with a major rele
136
138
  | YouTube still loads immediately | Embeds plugin is registered **after** EmPrivacy, so its renderers win. Move EmPrivacy last or pass `blockComponents` on `PortableText`. |
137
139
  | Embeds stay blocked after accept | Category is Marketing but you only enabled Functional (or the reverse). |
138
140
  | Save fails on policy URL | Must be `/path` or `https://…` |
139
- | Save fails on analytics ID | Plausible wants `example.com` (no scheme). GA4 wants `G-…`. GTM wants `GTM-…`. Umami needs an https script URL. |
141
+ | Save fails on analytics ID | Plausible wants `example.com` (no scheme). GA4 wants `G-…`. GTM wants `GTM-…`. Clarity wants a 7–20 character project ID. UET wants 6–12 digits. Umami needs an https script URL. |
140
142
  | Privacy link 404 | Path must match the live Page route |
141
143
 
142
144
  ---
@@ -76,11 +76,17 @@ On: Functional, Analytics, and Marketing start **unchecked**. Off: they start **
76
76
 
77
77
  On (default): visitors who open `/privacy` from the banner can read it without another overlay. The cookie button still appears so they can change choices.
78
78
 
79
+ ### Banner position
80
+
81
+ **Bottom of the viewport** (default) or **Top of the viewport**. The banner is `position: fixed`, so it stays on that edge while the page scrolls. EmDash still inserts the markup through `EmDashBodyEnd` (`body:end`). `body:start` would not move a fixed banner. Padding includes `env(safe-area-inset-top)` or `env(safe-area-inset-bottom)` so the bar clears a notch or home indicator. The reopen button stays at the bottom-left.
82
+
83
+ Saved settings without this field keep the bottom banner.
84
+
79
85
  ---
80
86
 
81
87
  ## Analytics platform
82
88
 
83
- Scripts for **Analytics** presets load only after **Analytics** consent — except **Google Tag Manager**, which loads only after **Marketing** consent (containers commonly fire ads and remarketing).
89
+ Scripts for **Analytics** presets load only after **Analytics** consent — except **Google Tag Manager** and **Microsoft UET**, which load only after **Marketing** consent (those tags commonly fire ads and remarketing).
84
90
 
85
91
  | Value | What you enter | What loads |
86
92
  |-------|----------------|------------|
@@ -91,31 +97,33 @@ Scripts for **Analytics** presets load only after **Analytics** consent — exce
91
97
  | Simple Analytics | Optional hostname | `https://scripts.simpleanalyticscdn.com/latest.js` |
92
98
  | Google Analytics 4 | `G-…` measurement ID | `https://www.googletagmanager.com/gtag/js?id=…` then `gtag('config', …)` |
93
99
  | Google Tag Manager | `GTM-…` | `https://www.googletagmanager.com/gtm.js?id=…` after **Marketing** consent. Tags inside the container are still your responsibility. |
100
+ | Microsoft Clarity | Project ID, 7–20 letters or digits | `https://www.clarity.ms/tag/…` after **Analytics** consent. A denied `consentv2` signal is queued in `<head>` first. `ad_Storage` stays denied until Marketing is also allowed. |
101
+ | Microsoft UET | Tag ID, 6–12 digits | `https://bat.bing.com/bat.js` after **Marketing** consent. `ad_storage` defaults to denied in `<head>`. The tag is not injected before that consent. Automatic SPA tracking is off. |
94
102
  | None | — | No analytics scripts |
95
- | Custom | One https script `src` per line (optional trailing SRI) | Those URLs only |
103
+ | Custom | One https script `src` per line, plus SRI (`sha256-`, `sha384-`, or `sha512-`) | Those URLs only, and only if the host is on the script host allowlist |
96
104
 
97
105
  IDs and URLs are validated on save and again in the browser before inject. Tokens are never shown in the public vendor list.
98
106
 
99
- ### Script host allowlist (optional)
107
+ ### Script host allowlist
100
108
 
101
- One hostname per line (e.g. `cdn.example.com`). When non-empty, **Custom** analytics, **Umami**, and **Marketing** script hosts must appear on the list. Built-in preset CDNs (Plausible, Fathom, GTM, etc.) stay allowed for their presets.
109
+ One hostname per line (e.g. `cdn.example.com`). **Custom** analytics, **Umami**, and **Marketing** script hosts must appear on the list. An empty list does not load those scripts. Built-in preset CDNs (Plausible, Fathom, GTM, Clarity, UET, and so on) stay allowed for their presets.
102
110
 
103
- ### Optional Subresource Integrity (SRI)
111
+ ### Subresource Integrity (SRI)
104
112
 
105
- Append a hash after the URL on Custom or Marketing lines:
113
+ Custom and marketing lines require a hash after the URL:
106
114
 
107
115
  ```
108
116
  https://cdn.example/a.js sha384-…
109
117
  https://cdn.example/b.js integrity=sha256-…
110
118
  ```
111
119
 
112
- The browser sets `integrity` + `crossorigin=anonymous` when the hash is valid. Prefer pinning Custom scripts; many preset CDNs do not publish stable hashes.
120
+ The browser sets `integrity` + `crossorigin=anonymous`. A line without a `sha256-`, `sha384-`, or `sha512-` hash is rejected on save and is not injected. Preset CDNs are not pinned this way; many do not publish stable hashes. Umami needs a hostname on the allowlist and does not require SRI.
113
121
 
114
122
  ---
115
123
 
116
124
  ## Marketing scripts
117
125
 
118
- One **https** URL per line, `src` only (optional SRI as above). Loaded after **Marketing** consent. Max 50 lines, 2048 characters each. No HTML.
126
+ One **https** URL per line, with SRI as above, and the host on the script host allowlist. Loaded after **Marketing** consent. Max 50 lines, 2048 characters each. No HTML.
119
127
 
120
128
  ---
121
129
 
@@ -133,7 +141,7 @@ Put EmPrivacy **first** in `plugins` if other plugins also inject Google tags.
133
141
 
134
142
  When on (default), EmPrivacy’s Portable Text components replace YouTube, Vimeo, tweet, Bluesky, Mastodon, Gist, and link-preview blocks with a placeholder until the selected category is allowed.
135
143
 
136
- YouTube / Vimeo become allowlisted iframes (`youtube-nocookie.com`, `player.vimeo.com`). Social posts, Gists, and link previews become **https links** — EmPrivacy will not iframe an arbitrary Mastodon or unknown host.
144
+ YouTube / Vimeo become allowlisted iframes (`youtube-nocookie.com`, `player.vimeo.com`). X, Bluesky, and GitHub Gist posts become **https links** when the URL matches those hosts. Mastodon and link previews become links only when the hostname is on **Embed link hosts**. An empty list leaves those placeholders in place. EmPrivacy will not iframe a Mastodon instance or an unknown host. A `data-emprivacy-embed` value in the page is not enough to allow an arbitrary URL.
137
145
 
138
146
  This does **not** rewrite raw HTML `<iframe>` tags you paste in the theme or in an HTML block.
139
147
 
@@ -157,13 +165,44 @@ Registration order: see [GETTING_STARTED](./GETTING_STARTED.md#registration-orde
157
165
 
158
166
  ## Theme
159
167
 
160
- Hex colors only (`#111`, `#111111`). Corner radius is an integer **0–24**. Values become CSS variables on `#emprivacy-root`. Other CSS is ignored (no `url()`, no `expression`).
168
+ **Banner theme** is one of five profiles, or **Custom colors**. A new install uses **Slate**, the first profile. A saved config with no theme choice also uses Slate. A profile writes that palette’s fixed background, text color, accent, and button radius. Color fields sent with a profile are ignored, so a profile cannot be combined with other CSS.
169
+
170
+ **Custom colors** opens with the colors already in effect. On Slate, those fields start as `#0f172a`, `#f8fafc`, `#3b82f6`, and 8 px. After a custom save, they show the saved hex values.
171
+
172
+ | Profile | Background | Text | Accent | Radius |
173
+ |---------|------------|------|--------|--------|
174
+ | Slate | `#0f172a` | `#f8fafc` | `#3b82f6` | 8 px |
175
+ | Paper | `#f7f6f3` | `#37352f` | `#1d4ed8` | 6 px |
176
+ | Ink | `#000000` | `#ededed` | `#0070f3` | 8 px |
177
+ | Indigo | `#ffffff` | `#0f172a` | `#4f46e5` | 12 px |
178
+ | Primer | `#f6f8fa` | `#1f2328` | `#0969da` | 6 px |
179
+
180
+ **Custom colors** accepts hex only (`#111`, `#111111`, or `#111111ff`). Corner radius is an integer **0–24**. Those values become CSS variables on `#emprivacy-root`. Other CSS is rejected on save (no `url()`, no `expression`). The full-width bar stays square. Radius applies to the buttons.
161
181
 
162
182
  ---
163
183
 
184
+ ## Global Privacy Control
185
+
186
+ There is no admin switch. If the browser sets `navigator.globalPrivacyControl`, EmPrivacy treats Marketing as denied for script loading, Google `ad_*` signals, Clarity `ad_Storage`, and UET. Accept all and Save cannot turn Marketing on while the signal is present. The Marketing checkbox is disabled and the banner explains why. Analytics and Functional are unchanged. Visitors can still open preferences.
187
+
188
+ The choice cookie is not rewritten until the visitor saves. While the signal is on, `window.emprivacy.get()` reports `marketing: false` and `gpc: true` even if an older cookie still has `m: 1`. After the signal is turned off, that saved Marketing choice applies again until they save a new one.
189
+
190
+ ## Known-cookie cleanup
191
+
192
+ When Analytics or Marketing is denied (including the first visit, before a choice), EmPrivacy expires a **fixed** list of first-party names:
193
+
194
+ - Analytics: `_ga`, `_gid`, `_gat`, and names starting with `_ga_` or `_gat_`
195
+ - Marketing: `_gcl_au`, `_gcl_aw`, `_gcl_dc`, names starting with `_gcl_`, plus Clarity `_clck`, `_clsk`, `CLID` and UET `_uetsid`, `_uetvid`
196
+
197
+ `emprivacy_cc` is never deleted. Settings cannot add cookie names. Page script cannot clear HttpOnly cookies or cookies set on another site. Expiration uses `Path=/` on the current host and its parent host label.
198
+
164
199
  ## Server-side logging
165
200
 
166
- When enabled, each save POSTs `{ policyVersion, functional, analytics, marketing }` to `/_emdash/api/plugins/emprivacy/record` (same origin, JSON). The handler requires a matching `Origin` **or** `Sec-Fetch-Site: same-origin`, fails closed if storage capacity cannot be checked, and rate-limits anonymous clients (~30/hour fingerprint). No IP is stored in the consent row. The page shows up to 15 recent rows. Logging stops at 500 rows.
201
+ When enabled, each save POSTs `{ policyVersion, functional, analytics, marketing, gpc }` to `/_emdash/api/plugins/emprivacy/record` (same origin, JSON). The handler requires a matching `Origin` **or** `Sec-Fetch-Site: same-origin`, fails closed if storage capacity cannot be checked, and rate-limits writes. The limit uses EmDash’s platform client address (`requestMeta.ip`), about 30 writes per address per UTC hour, plus about 60 writes per hour for the whole site. Requests with no platform address share one 30/hour bucket. Counters use compare-and-set. No IP is stored in the consent row. If `gpc` is true, the stored `marketing` flag is false. The page shows up to 15 recent rows. Logging stops at 500 rows.
202
+
203
+ Saving settings requires a signed-in admin. A settings request with no user is rejected.
204
+
205
+ Signed-in admins (the plugin settings permission) can download those rows as CSV from `/_emdash/api/plugins/emprivacy/consent-export`. The file is `Cache-Control: private, no-store`. It has no IP, user agent, or rate-limit fingerprint. A cell that starts with `=`, `+`, `-`, or `@` is prefixed with `'` so a spreadsheet does not run it as a formula. The route is not public. A request without a signed-in user is rejected.
167
206
 
168
207
  ---
169
208
 
@@ -172,9 +211,9 @@ When enabled, each save POSTs `{ policyVersion, functional, analytics, marketing
172
211
  EmPrivacy injects an **inline** bootstrap via EmDash `page:fragments`. Sites with a strict CSP must allow that script, typically:
173
212
 
174
213
  - `script-src 'unsafe-inline'` (or a hash/nonce of the emitted bootstrap if your host supports it), and
175
- - `script-src` / `connect-src` / `frame-src` entries for any presets and gated embeds you enable (e.g. `https://plausible.io`, `https://www.youtube-nocookie.com`, `https://player.vimeo.com`).
214
+ - `script-src` / `connect-src` / `frame-src` entries for any presets and gated embeds you enable (e.g. `https://plausible.io`, `https://www.clarity.ms`, `https://bat.bing.com`, `https://www.youtube-nocookie.com`, `https://player.vimeo.com`).
176
215
 
177
- YouTube/Vimeo placeholders use a tight iframe `sandbox` (`allow-scripts allow-same-origin allow-presentation`) after consent. Client hydration also re-checks allowlisted `data-src` values so a planted evil URL cannot become an iframe.
216
+ YouTube/Vimeo placeholders use a tight iframe `sandbox` (`allow-scripts allow-same-origin allow-presentation`) after consent. The iframe `allow` list does not include clipboard access. Client hydration re-checks `data-src` against the same YouTube, Vimeo, X, Bluesky, and Gist rules, and against **Embed link hosts** for every other link. A planted URL cannot become an iframe or an off-list link.
178
217
 
179
218
  A future hashed static bootstrap (no `'unsafe-inline'`) depends on EmDash fragment APIs exposing nonces or external script URLs.
180
219
 
package/docs/RELEASES.md CHANGED
@@ -46,13 +46,16 @@ update to latest emdash release
46
46
 
47
47
  Uses [`.cursor/skills/emdash-release/`](../.cursor/skills/emdash-release/SKILL.md) and [`.cursor/emdash-release.json`](../.cursor/emdash-release.json): conformance → compat PR → Version Packages merge → `pnpm release:publish` (npm + GitHub Release). Details: [maintainer-release.md](./maintainer-release.md).
48
48
 
49
- ### Automated feature release (Changesets)
49
+ ### Feature, minor, or patch release (agent-driven)
50
50
 
51
- 1. On a feature PR, run `pnpm changeset` and commit the generated file under `.changeset/`.
52
- 2. Merge to `main`. GitHub Actions opens a **Version Packages** PR when changesets are pending.
53
- 3. Merge the Version Packages PR. CI runs `pnpm release:publish` and creates a GitHub Release.
51
+ In Cursor, ask to publish a minor (or patch) release. The agent follows [`.cursor/skills/feature-release/`](../.cursor/skills/feature-release/SKILL.md):
54
52
 
55
- Requires **`NPM_TOKEN`** secret on the repo (publish rights for `@emplugins/emprivacy` on the [emplugins](https://www.npmjs.com/org/emplugins) org).
53
+ 1. Commit the work plus **one** `.changeset/*.md` file. Leave `package.json` `"version"`, `src/version.ts`, and `CHANGELOG.md` unchanged. Write the maintainer notes in [RELEASE_NOTES.md](./RELEASE_NOTES.md) for the version Changesets will create.
54
+ 2. Open a feature PR, squash-merge when its CI is green.
55
+ 3. Merge the **Version Packages** PR as soon as it opens. Its own CI often sits at `action_required` (the Release workflow opened it with `GITHUB_TOKEN`). `pnpm release:publish` on `main` is the publish-time test gate.
56
+ 4. Treat `+ @emplugins/emprivacy@<version>` in the Release log as a successful publish. The npm registry can 404 that version for several minutes while it scans the tarball. Poll `https://registry.npmjs.org/@emplugins/emprivacy/<version>` for up to 15 minutes. Do not publish again during that window.
57
+
58
+ Requires **`NPM_TOKEN`** secret on the repo (publish rights for `@emplugins/emprivacy` on the [emplugins](https://www.npmjs.com/org/emplugins) org). Details: [maintainer-release.md](./maintainer-release.md).
56
59
 
57
60
  ### Local (fallback)
58
61
 
@@ -4,7 +4,32 @@
4
4
 
5
5
  ## Changes
6
6
 
7
- _(none)_
7
+ No unreleased changes.
8
+
9
+ # emprivacy v3.2.0
10
+
11
+ ## Changes
12
+
13
+ - **Script host allowlist** — Custom, Umami, and marketing script hosts must be listed. An empty list does not load those scripts. Built-in presets (Cloudflare, Plausible, Fathom, Simple Analytics, GA4, GTM, Clarity, UET) are unchanged. Sites that already saved custom or marketing URLs need each hostname on the list before those scripts load again.
14
+ - **Subresource Integrity** — Custom and marketing lines require a `sha256-`, `sha384-`, or `sha512-` hash. A line without one is rejected on save and is not injected. Umami needs an allowlisted host and does not require a hash.
15
+ - **Embed link hosts** — Mastodon and link previews become links only when the hostname is listed. YouTube, Vimeo, X, Bluesky, and GitHub Gist keep their built-in checks. A `data-emprivacy-embed` attribute on the page is not enough to allow an arbitrary URL.
16
+ - **Non-public hosts** — Loopback, private, link-local, and other non-public addresses are rejected for script and embed URLs.
17
+ - **Settings** — Opening or saving EmPrivacy settings requires a signed-in admin.
18
+ - **Consent log** — Anonymous writes use EmDash’s platform client address: about 30 per address per UTC hour, and about 60 per hour for the whole site. Requests with no platform address share one 30/hour bucket. No IP is stored in the consent row.
19
+ - **Embed iframes** — YouTube and Vimeo `sandbox` is unchanged. The iframe `allow` list no longer includes clipboard access.
20
+ - **Publish** — npm credentials are written to a temporary user config outside the repo. A project `.npmrc` is refused at publish time.
21
+
22
+ # emprivacy v3.1.0
23
+
24
+ ## Changes
25
+
26
+ - **Banner position** — Admin setting places the fixed consent banner at the bottom (default) or top of the viewport. Markup still renders through `EmDashBodyEnd`.
27
+ - **Banner themes** — New installs use the Slate profile. Admin can choose Paper, Ink, Indigo, Primer, or Custom colors. Custom opens with the colors already in effect. A profile stores that palette’s fixed colors. Custom colors still accept hex only.
28
+ - **Global Privacy Control** — When the browser sets `navigator.globalPrivacyControl`, Marketing stays denied, including after Accept all. The optional server log stores `gpc: true` and `marketing: false`. Analytics is not forced off. No geolocation.
29
+ - **Cookie cleanup** — Denying Analytics or Marketing expires a fixed list of first-party cookie names used by the presets EmPrivacy injects. The consent cookie is not deleted. HttpOnly and third-party cookies are unchanged.
30
+ - **Keyboard** — Focus moves into the banner, Tab stays inside it, and Escape closes the reopen panel. The first visit stays until the visitor chooses.
31
+ - **Consent log download** — Signed-in admins can GET `/_emdash/api/plugins/emprivacy/consent-export` (CSV, no IP). Formula characters in cells are neutralized.
32
+ - **Microsoft presets** — Clarity loads only after Analytics consent (`www.clarity.ms`). UET loads only after Marketing consent (`bat.bing.com`). Both queue a denied consent signal before the tag is injected.
8
33
 
9
34
  # emprivacy v3.0.0
10
35
 
package/docs/TESTING.md CHANGED
@@ -10,7 +10,7 @@ Testing is split into **automated** checks (CI-friendly) and **manual** checks t
10
10
  |--------|-------------------|
11
11
  | `pnpm run typecheck` | TypeScript types against `emdash` APIs |
12
12
  | `pnpm run build` | Bundles ESM + declarations (`dist/`) |
13
- | `pnpm test` | Config validation, security helpers (embed/script allowlists, CSRF Origin/Sec-Fetch-Site, cookie TTL, SRI), i18n, theme hex, vendor list (no token leak; GTM=marketing), JSON escaping |
13
+ | `pnpm test` | Config validation, security helpers (embed/script allowlists, CSRF Origin/Sec-Fetch-Site, cookie TTL, SRI), i18n, theme hex, vendor list (no token leak; GTM and UET=marketing; Clarity ID not published), cookie-name purge list, CSV formula neutralization, JSON escaping |
14
14
 
15
15
  These scripts do **not** start EmDash or drive a browser.
16
16
 
@@ -20,19 +20,21 @@ Use a **staging** EmDash Astro app on **`emdash@^1.0.1`** with `emprivacyPlugin(
20
20
 
21
21
  ### Core flows
22
22
 
23
- 1. **Banner** — Incognito: banner appears with no `emprivacy_cc` cookie, when **Policy / consent version** does not match `v`, or when a legacy cookie lacks `f`.
23
+ 1. **Banner** — Incognito: banner appears with no `emprivacy_cc` cookie, when **Policy / consent version** does not match `v`, or when a legacy cookie lacks `f`. **Banner position** Bottom keeps it on the bottom edge; Top pins it to the top. The reopen button stays bottom-left.
24
24
  2. **Categories** — Customize shows Essential (locked on), Functional, Analytics, Marketing. Reject non-essential: no configured analytics/marketing scripts in Network; embeds stay placeholders.
25
- 3. **Re-open** — Cookie button (bottom-left). Save reloads. Turning a category off removes those scripts after reload.
26
- 4. **Presets** — For the platform you ship, confirm the expected host loads only after the correct category (Analytics for most presets; **Marketing** for GTM). Custom/marketing scripts must respect any host allowlist.
25
+ 3. **Re-open** — Cookie button (bottom-left). Save reloads. Turning a category off removes those scripts after reload and expires the known first-party cookie names for that category (`_ga` / `_ga_*` for Analytics; `_clck`, `_uetsid`, and the other fixed names for Marketing). `emprivacy_cc` remains.
26
+ 4. **Presets** — For the platform you ship, confirm the expected host loads only after the correct category (Analytics for most presets, including Clarity; **Marketing** for GTM and UET). Custom and marketing scripts load only with SRI and a script host allowlist entry. Umami loads only when its host is listed. Clarity and UET must not request their hosts before that consent. Mastodon and link previews stay placeholders until the host is on Embed link hosts.
27
27
  5. **Evil embed plant** — In DevTools, add a placeholder with `data-src="https://evil.example/x"` and grant marketing; assert no iframe is created.
28
28
  6. **Embed unlock** — “Allow embeds and load” opens preferences; it must not silently flip categories.
29
29
  7. **Embeds** — With `@emdash-cms/plugin-embeds` and EmPrivacy last (or site-level `blockComponents`): a YouTube block is a placeholder first; after allowing the embed category, the iframe `src` is `https://www.youtube-nocookie.com/embed/…`. A junk/javascript URL never becomes an iframe.
30
30
  8. **API** — After a choice, `window.emprivacy.get()` returns state; `window.emprivacy.has("analytics")` matches Network.
31
31
  9. **i18n** — A `de` (or `fr`) page shows translated buttons when that locale is active.
32
32
  10. **Policy pages** — `/privacy` does not show the first-visit overlay when that setting is on; the cookie button still works.
33
- 11. **Admin** — Save persists; **What this site uses** matches vendors; tokens do not appear there or at `/_emdash/api/plugins/emprivacy/vendors`.
34
- 12. **Optional server log** — POST requires matching Origin or `Sec-Fetch-Site: same-origin`; Origin mismatch is 403. Body includes `functional`.
33
+ 11. **Admin** — Save persists; **What this site uses** matches vendors; tokens do not appear there or at `/_emdash/api/plugins/emprivacy/vendors`. **Banner theme** Slate, Paper, Ink, Indigo, or Primer replaces the colors. Custom still rejects a non-hex value.
34
+ 12. **Optional server log** — POST requires matching Origin or `Sec-Fetch-Site: same-origin`; Origin mismatch is 403. Body includes `functional`. The CSV download works while signed in to admin and fails when signed out. A policy version that starts with `=` is stored as text in the file, not as a formula.
35
35
  13. **Optional Google Consent Mode** — Denied defaults in `<head>` before interaction, including `functionality_storage`.
36
+ 14. **Global Privacy Control** — With the signal enabled, Marketing stays unchecked and disabled, Accept all does not set `m` to 1, and marketing scripts do not load. Analytics can still be allowed. Turning the signal off leaves a previously saved Marketing choice in effect until the visitor saves again.
37
+ 15. **Keyboard** — Tab cycles inside the banner. Escape closes only the reopen panel and returns focus to the cookie button. The first visit does not dismiss on Escape. Buttons show a visible focus ring.
36
38
 
37
39
  ### Regression triggers
38
40
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "SPDX-License-Identifier: MIT",
3
3
  "name": "@emplugins/emprivacy",
4
- "version": "3.0.0",
4
+ "version": "3.2.0",
5
5
  "description": "Cookie consent, gated embeds, and privacy notices for EmDash (GDPR, CCPA/CIPA-oriented configuration)",
6
6
  "type": "module",
7
7
  "main": "dist/index.mjs",
@@ -1,5 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
 
3
+ import { parseStrictHttpsUrl } from "../security.js";
4
+
3
5
  /**
4
6
  * Validate official EmDash embed block payloads and produce a first-party
5
7
  * placeholder plan. Never returns a URL we did not construct or allowlist.
@@ -43,23 +45,9 @@ const BSKY_HOSTS = new Set(["bsky.app", "www.bsky.app"]);
43
45
  const GIST_HOSTS = new Set(["gist.github.com"]);
44
46
  const POSTER_HOSTS = new Set(["i.ytimg.com", "img.youtube.com", "i.vimeocdn.com"]);
45
47
 
46
- function hasUnsafeChars(s: string): boolean {
47
- return /[\u0000-\u001F\u007F\s]/.test(s);
48
- }
49
-
48
+ /** Same non-public host rules as script URLs. */
50
49
  export function parseHttpsUrl(raw: string): URL | null {
51
- const t = raw.trim();
52
- if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
53
- if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
54
- try {
55
- const u = new URL(t);
56
- if (u.protocol !== "https:") return null;
57
- if (u.username || u.password) return null;
58
- if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
59
- return u;
60
- } catch {
61
- return null;
62
- }
50
+ return parseStrictHttpsUrl(raw);
63
51
  }
64
52
 
65
53
  function hostOf(u: URL): string {