@emplugins/emprivacy 2.0.0 → 3.0.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.
package/docs/TESTING.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Testing EmPrivacy before deploy
4
4
 
5
- Testing is split into **automated** checks (CI-friendly) and **manual** checks that require a running EmDash site, because the plugin integrates with EmDash’s runtime, KV, storage, admin Block Kit, and public page pipeline.
5
+ Testing is split into **automated** checks (CI-friendly) and **manual** checks that require a running EmDash site.
6
6
 
7
7
  ## Automated (in this repo)
8
8
 
@@ -10,30 +10,34 @@ 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` | Unit tests for **pure** helpers in `src/config.ts` (validation, normalization, URL list parsing, 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=marketing), JSON escaping |
14
14
 
15
- These scripts do **not** start EmDash or exercise `page:fragments`, admin routes, or the consent cookie in a browser. They catch regressions in config/save validation and packaging early.
15
+ These scripts do **not** start EmDash or drive a browser.
16
16
 
17
17
  ## Manual integration QA (required before production)
18
18
 
19
- Use a **staging** EmDash Astro app on **`emdash@^0.38.0`** with `emprivacyPlugin()` in **`plugins: []`** (native, not `sandboxed`), layout wires (`EmDashHead` / body components with page context), and the same EmDash version you plan to ship.
20
-
21
- Work through the checklist from the main [README](../README.md) (also copied below). Record pass/fail and browser + EmDash versions.
19
+ Use a **staging** EmDash Astro app on **`emdash@^1.0.1`** with `emprivacyPlugin()` in **`plugins: []`**, layout wires (`EmDashHead` / body components with page context), and the same EmDash version you plan to ship. EmDash 0.x is not supported.
22
20
 
23
21
  ### Core flows
24
22
 
25
- 1. **Banner** — Incognito window: banner appears when there is no `emprivacy_cc` cookie or when **Policy / consent version** in admin does not match the cookie’s `v` field.
26
- 2. **Re-open consent** — After dismissing the banner, confirm a **cookie button** appears (bottom-left). Click it: the same options appear; **Save choices** (or Accept/Reject) reloads the page. After reload, **Network** should list analytics scripts only if analytics is on; turn analytics off, save, reload, and those scripts should not run. If you use **Cloudflare Web Analytics** in admin, after consent confirm `beacon.min.js` loads and the tag includes `data-cf-beacon` with your token.
27
- 3. **Strict defaults** — With strict mode on, deny non-essential and confirm configured analytics/marketing URLs do **not** appear in Network until the user accepts (or enables those categories in Customize).
28
- 4. **Persistence** — After accept, reload: banner stays closed; scripts load according to saved choices (Network tab).
29
- 5. **Admin** — EmPrivacy settings page loads, **Save** persists; public banner text and links match what you saved (after refresh / new request).
30
- 6. **Optional server log** — Enable “Log consent…”; submit consent; confirm a row appears under recent records and `POST /_emdash/api/plugins/emprivacy/record` succeeds (200, `{ ok: true }`) in Network.
31
- 7. **Optional Google Consent Mode** — Enable in admin; verify a denied-default snippet exists in `<head>` before interaction, and updates after choice (see [Google’s Consent Mode docs](https://support.google.com/tagmanager/answer/13695607)).
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`.
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.
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
+ 6. **Embed unlock** — “Allow embeds and load” opens preferences; it must not silently flip categories.
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
+ 8. **API** — After a choice, `window.emprivacy.get()` returns state; `window.emprivacy.has("analytics")` matches Network.
31
+ 9. **i18n** — A `de` (or `fr`) page shows translated buttons when that locale is active.
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`.
35
+ 13. **Optional Google Consent Mode** — Denied defaults in `<head>` before interaction, including `functionality_storage`.
32
36
 
33
37
  ### Regression triggers
34
38
 
35
- - Bump EmDash minor: re-run manual QA (plugin APIs and admin routing can change).
36
- - Bump this plugin’s **npm semver** after changing hooks or cookie shape: re-check banner and cookie JSON. Release numbering policy: [RELEASES.md](./RELEASES.md).
39
+ - Bump EmDash minor: re-run manual QA.
40
+ - Cookie JSON or embed allowlist changes: re-check banner, `window.emprivacy`, and a YouTube block.
37
41
 
38
42
  ## CI suggestion
39
43
 
@@ -44,10 +48,4 @@ pnpm run build
44
48
  pnpm test
45
49
  ```
46
50
 
47
- ## Future improvements (not in v1)
48
-
49
- - **Integration tests** against EmDash’s native plugin loader + mock `PluginContext` (heavy; depends on EmDash test utilities if exposed).
50
- - **E2E** (Playwright) against a minimal EmDash fixture app in CI (slower, higher maintenance).
51
-
52
51
  For most teams, **automated unit tests + staging manual QA** is the right balance.
53
-
package/package.json CHANGED
@@ -1,18 +1,20 @@
1
1
  {
2
2
  "//": "SPDX-License-Identifier: MIT",
3
3
  "name": "@emplugins/emprivacy",
4
- "version": "2.0.0",
5
- "description": "Cookie consent and privacy notices for EmDash (GDPR, CCPA/CIPA-oriented configuration)",
4
+ "version": "3.0.0",
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",
8
8
  "exports": {
9
9
  ".": {
10
10
  "import": "./dist/index.mjs",
11
11
  "types": "./dist/index.d.mts"
12
- }
12
+ },
13
+ "./astro": "./src/astro/index.ts"
13
14
  },
14
15
  "files": [
15
16
  "dist",
17
+ "src/astro",
16
18
  "README.md",
17
19
  "LICENSE",
18
20
  "docs/DEVELOPMENT.md",
@@ -37,17 +39,17 @@
37
39
  "license": "MIT",
38
40
  "packageManager": "pnpm@9.15.4",
39
41
  "peerDependencies": {
40
- "emdash": "^0.38.0"
42
+ "emdash": "^1.0.1"
41
43
  },
42
44
  "dependencies": {
43
- "zod": "4.5.4"
45
+ "zod": "4.6.5"
44
46
  },
45
47
  "devDependencies": {
46
- "@changesets/cli": "^2.27.11",
47
- "emdash": "0.38.0",
48
- "tsdown": "^0.20.3",
48
+ "@changesets/cli": "^3.0.3",
49
+ "emdash": "^1.0.1",
50
+ "tsdown": "^0.23.0",
49
51
  "typescript": "^5.9.3",
50
- "vitest": "^3.2.4"
52
+ "vitest": "^5.0.1"
51
53
  },
52
54
  "repository": {
53
55
  "type": "git",
@@ -64,7 +66,7 @@
64
66
  "pnpm": {
65
67
  "overrides": {
66
68
  "kysely": "0.29.6",
67
- "zod": "4.5.4"
69
+ "zod": "4.6.5"
68
70
  }
69
71
  },
70
72
  "scripts": {
@@ -0,0 +1,70 @@
1
+ ---
2
+ // SPDX-License-Identifier: MIT
3
+ import { resolveEmbed } from "./embed-resolve.js";
4
+
5
+ interface Props {
6
+ node: {
7
+ _type?: string;
8
+ id?: string;
9
+ poster?: string;
10
+ title?: string;
11
+ };
12
+ }
13
+
14
+ const { node } = Astro.props;
15
+ const resolved = resolveEmbed(node ?? {});
16
+ ---
17
+
18
+ {
19
+ resolved ? (
20
+ <div
21
+ class="emprivacy-embed"
22
+ data-emprivacy-embed={resolved.kind}
23
+ data-src={resolved.src}
24
+ data-mode={resolved.mode}
25
+ data-label={resolved.label}
26
+ >
27
+ {resolved.poster ? (
28
+ <img class="emprivacy-embed-poster" src={resolved.poster} alt="" width="480" height="270" />
29
+ ) : null}
30
+ <p class="emprivacy-embed-copy">{resolved.label} is blocked until you allow the matching cookie category.</p>
31
+ <button type="button" class="emprivacy-embed-btn" data-emprivacy-embed-load>
32
+ Allow embeds and load
33
+ </button>
34
+ </div>
35
+ ) : (
36
+ <p class="emprivacy-embed-invalid">This embed could not be displayed.</p>
37
+ )
38
+ }
39
+
40
+ <style>
41
+ .emprivacy-embed {
42
+ display: grid;
43
+ gap: 0.75rem;
44
+ place-items: center;
45
+ padding: 1.25rem;
46
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
47
+ border-radius: 8px;
48
+ background: color-mix(in srgb, currentColor 6%, transparent);
49
+ text-align: center;
50
+ aspect-ratio: 16 / 9;
51
+ }
52
+ .emprivacy-embed-poster {
53
+ max-width: 100%;
54
+ height: auto;
55
+ border-radius: 6px;
56
+ }
57
+ .emprivacy-embed-copy,
58
+ .emprivacy-embed-invalid {
59
+ margin: 0;
60
+ max-width: 36rem;
61
+ }
62
+ .emprivacy-embed-btn {
63
+ border-radius: 6px;
64
+ border: 1px solid currentColor;
65
+ background: transparent;
66
+ color: inherit;
67
+ padding: 0.5rem 0.85rem;
68
+ cursor: pointer;
69
+ }
70
+ </style>
@@ -0,0 +1,227 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ /**
4
+ * Validate official EmDash embed block payloads and produce a first-party
5
+ * placeholder plan. Never returns a URL we did not construct or allowlist.
6
+ */
7
+
8
+ export const EMBED_BLOCK_TYPES = [
9
+ "youtube",
10
+ "vimeo",
11
+ "tweet",
12
+ "bluesky",
13
+ "mastodon",
14
+ "linkPreview",
15
+ "gist",
16
+ ] as const;
17
+
18
+ export type EmbedBlockType = (typeof EMBED_BLOCK_TYPES)[number];
19
+
20
+ export type EmbedRenderMode = "iframe" | "link";
21
+
22
+ export interface ResolvedEmbed {
23
+ kind: EmbedBlockType;
24
+ mode: EmbedRenderMode;
25
+ /** https iframe src or link href — always produced by this module */
26
+ src: string;
27
+ label: string;
28
+ poster: string | null;
29
+ }
30
+
31
+ const YT_ID = /^[A-Za-z0-9_-]{11}$/;
32
+ const VIMEO_ID = /^\d{6,12}$/;
33
+ const TWEET_ID = /^\d{5,20}$/;
34
+ const BSKY_RKEY = /^[a-z0-9]{1,32}$/i;
35
+ const BSKY_HANDLE = /^[a-z0-9.-]{1,253}$/i;
36
+ const GIST_ID = /^[a-f0-9]{8,64}$/i;
37
+ const GIST_USER = /^[A-Za-z0-9-]{1,39}$/;
38
+
39
+ const YT_HOSTS = new Set(["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtube-nocookie.com", "youtube-nocookie.com"]);
40
+ const VIMEO_HOSTS = new Set(["vimeo.com", "www.vimeo.com", "player.vimeo.com"]);
41
+ const TWEET_HOSTS = new Set(["twitter.com", "www.twitter.com", "x.com", "www.x.com", "mobile.twitter.com"]);
42
+ const BSKY_HOSTS = new Set(["bsky.app", "www.bsky.app"]);
43
+ const GIST_HOSTS = new Set(["gist.github.com"]);
44
+ const POSTER_HOSTS = new Set(["i.ytimg.com", "img.youtube.com", "i.vimeocdn.com"]);
45
+
46
+ function hasUnsafeChars(s: string): boolean {
47
+ return /[\u0000-\u001F\u007F\s]/.test(s);
48
+ }
49
+
50
+ 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
+ }
63
+ }
64
+
65
+ function hostOf(u: URL): string {
66
+ return u.hostname.toLowerCase();
67
+ }
68
+
69
+ function youtubeId(raw: string): string | null {
70
+ const t = raw.trim();
71
+ if (YT_ID.test(t)) return t;
72
+ const u = parseHttpsUrl(t);
73
+ if (!u || !YT_HOSTS.has(hostOf(u))) return null;
74
+ if (hostOf(u) === "youtu.be") {
75
+ const id = u.pathname.replace(/^\//, "").split("/")[0] ?? "";
76
+ return YT_ID.test(id) ? id : null;
77
+ }
78
+ const v = u.searchParams.get("v");
79
+ if (v && YT_ID.test(v)) return v;
80
+ const parts = u.pathname.split("/").filter(Boolean);
81
+ const embedIdx = parts.indexOf("embed");
82
+ if (embedIdx >= 0) {
83
+ const id = parts[embedIdx + 1] ?? "";
84
+ return YT_ID.test(id) ? id : null;
85
+ }
86
+ const shortsIdx = parts.indexOf("shorts");
87
+ if (shortsIdx >= 0) {
88
+ const id = parts[shortsIdx + 1] ?? "";
89
+ return YT_ID.test(id) ? id : null;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function vimeoId(raw: string): string | null {
95
+ const t = raw.trim();
96
+ if (VIMEO_ID.test(t)) return t;
97
+ const u = parseHttpsUrl(t);
98
+ if (!u || !VIMEO_HOSTS.has(hostOf(u))) return null;
99
+ const parts = u.pathname.split("/").filter(Boolean);
100
+ const last = parts[parts.length - 1] ?? "";
101
+ const videoIdx = parts.indexOf("video");
102
+ if (videoIdx >= 0) {
103
+ const id = parts[videoIdx + 1] ?? "";
104
+ return VIMEO_ID.test(id) ? id : null;
105
+ }
106
+ return VIMEO_ID.test(last) ? last : null;
107
+ }
108
+
109
+ function tweetId(raw: string): string | null {
110
+ const t = raw.trim();
111
+ if (TWEET_ID.test(t)) return t;
112
+ const u = parseHttpsUrl(t);
113
+ if (!u || !TWEET_HOSTS.has(hostOf(u))) return null;
114
+ const m = /\/status(?:es)?\/(\d{5,20})(?:\/|$)/i.exec(u.pathname);
115
+ return m?.[1] ?? null;
116
+ }
117
+
118
+ function blueskyHref(raw: string): string | null {
119
+ const u = parseHttpsUrl(raw);
120
+ if (!u || !BSKY_HOSTS.has(hostOf(u))) return null;
121
+ const m = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/.exec(u.pathname);
122
+ if (!m) return null;
123
+ const handle = m[1] ?? "";
124
+ const rkey = m[2] ?? "";
125
+ if (!BSKY_HANDLE.test(handle) || !BSKY_RKEY.test(rkey)) return null;
126
+ return `https://bsky.app/profile/${handle}/post/${rkey}`;
127
+ }
128
+
129
+ function gistHref(raw: string): string | null {
130
+ const u = parseHttpsUrl(raw);
131
+ if (!u || !GIST_HOSTS.has(hostOf(u))) return null;
132
+ const parts = u.pathname.split("/").filter(Boolean);
133
+ if (parts.length < 2) return null;
134
+ const user = parts[0] ?? "";
135
+ const id = (parts[1] ?? "").replace(/\.js$/i, "");
136
+ if (!GIST_USER.test(user) || !GIST_ID.test(id)) return null;
137
+ return `https://gist.github.com/${user}/${id}`;
138
+ }
139
+
140
+ function mastodonHref(raw: string): string | null {
141
+ const u = parseHttpsUrl(raw);
142
+ if (!u) return null;
143
+ // Any Mastodon instance is an arbitrary host — never iframe it.
144
+ if (!/^\/@[^/]+\/\d+\/?$/.test(u.pathname) && !/^\/users\/[^/]+\/statuses\/\d+\/?$/.test(u.pathname)) {
145
+ return null;
146
+ }
147
+ return `https://${hostOf(u)}${u.pathname.replace(/\/$/, "")}`;
148
+ }
149
+
150
+ function posterUrl(raw: unknown, kind: EmbedBlockType, mediaId: string | null): string | null {
151
+ if (kind === "youtube" && mediaId && YT_ID.test(mediaId)) {
152
+ return `https://i.ytimg.com/vi/${mediaId}/hqdefault.jpg`;
153
+ }
154
+ if (typeof raw !== "string") return null;
155
+ const u = parseHttpsUrl(raw);
156
+ if (!u || !POSTER_HOSTS.has(hostOf(u))) return null;
157
+ return u.href;
158
+ }
159
+
160
+ export function isEmbedBlockType(s: string): s is EmbedBlockType {
161
+ return (EMBED_BLOCK_TYPES as readonly string[]).includes(s);
162
+ }
163
+
164
+ export function resolveEmbed(node: {
165
+ _type?: unknown;
166
+ id?: unknown;
167
+ poster?: unknown;
168
+ title?: unknown;
169
+ }): ResolvedEmbed | null {
170
+ if (!node || typeof node._type !== "string" || !isEmbedBlockType(node._type)) return null;
171
+ if (typeof node.id !== "string") return null;
172
+ const kind = node._type;
173
+ const rawId = node.id.trim();
174
+ if (!rawId || rawId.length > 2048) return null;
175
+
176
+ if (kind === "youtube") {
177
+ const id = youtubeId(rawId);
178
+ if (!id) return null;
179
+ return {
180
+ kind,
181
+ mode: "iframe",
182
+ src: `https://www.youtube-nocookie.com/embed/${id}`,
183
+ label: "YouTube",
184
+ poster: posterUrl(node.poster, kind, id),
185
+ };
186
+ }
187
+ if (kind === "vimeo") {
188
+ const id = vimeoId(rawId);
189
+ if (!id) return null;
190
+ return {
191
+ kind,
192
+ mode: "iframe",
193
+ src: `https://player.vimeo.com/video/${id}`,
194
+ label: "Vimeo",
195
+ poster: posterUrl(node.poster, kind, id),
196
+ };
197
+ }
198
+ if (kind === "tweet") {
199
+ const id = tweetId(rawId);
200
+ if (!id) return null;
201
+ return {
202
+ kind,
203
+ mode: "link",
204
+ src: `https://x.com/i/web/status/${id}`,
205
+ label: "Post on X",
206
+ poster: null,
207
+ };
208
+ }
209
+ if (kind === "bluesky") {
210
+ const href = blueskyHref(rawId);
211
+ if (!href) return null;
212
+ return { kind, mode: "link", src: href, label: "Bluesky post", poster: null };
213
+ }
214
+ if (kind === "mastodon") {
215
+ const href = mastodonHref(rawId);
216
+ if (!href) return null;
217
+ return { kind, mode: "link", src: href, label: "Mastodon post", poster: null };
218
+ }
219
+ if (kind === "gist") {
220
+ const href = gistHref(rawId);
221
+ if (!href) return null;
222
+ return { kind, mode: "link", src: href, label: "GitHub Gist", poster: null };
223
+ }
224
+ const href = parseHttpsUrl(rawId);
225
+ if (!href) return null;
226
+ return { kind: "linkPreview", mode: "link", src: href.href, label: href.hostname, poster: null };
227
+ }
@@ -0,0 +1,24 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ /**
4
+ * Portable Text renderers that replace official EmDash embed blocks with
5
+ * consent-gated placeholders. Import from `@emplugins/emprivacy/astro`.
6
+ *
7
+ * Later plugins in `plugins: []` win. To force these wrappers regardless of
8
+ * registration order, pass `blockComponents` to `<PortableText />`.
9
+ */
10
+
11
+ import GatedEmbed from "./GatedEmbed.astro";
12
+
13
+ export const blockComponents = {
14
+ youtube: GatedEmbed,
15
+ vimeo: GatedEmbed,
16
+ tweet: GatedEmbed,
17
+ bluesky: GatedEmbed,
18
+ mastodon: GatedEmbed,
19
+ linkPreview: GatedEmbed,
20
+ gist: GatedEmbed,
21
+ };
22
+
23
+ export { GatedEmbed };
24
+ export { blockComponents as emprivacyEmbedComponents };