diff --git a/public/brief-requested.html b/public/brief-requested.html index a40ec132..63b5002c 100644 --- a/public/brief-requested.html +++ b/public/brief-requested.html @@ -12,13 +12,14 @@ - - diff --git a/public/brief-requested.js b/public/brief-requested.js index f0e1bc73..2bfc0d6a 100644 --- a/public/brief-requested.js +++ b/public/brief-requested.js @@ -1,7 +1,8 @@ -window.dataLayer = window.dataLayer || []; - function gtag(){dataLayer.push(arguments);} - gtag('js', new Date()); - gtag('config', 'AW-XXXXXXXXX'); - gtag('event', 'conversion', { - 'send_to': 'AW-XXXXXXXXX/YYYYYYYYYYYYYYYYYYY' - }); +// Google Ads conversion script. +// +// This file's real content is generated by the worker at request time from +// the GOOGLE_ADS_CONVERSION_ID and GOOGLE_ADS_CONVERSION_LABEL env values — +// see specs/003-wellness-clinic-launch/tracking-setup.md. A hardcoded tag +// with a placeholder id is dead by construction (it can never record a +// conversion), so without those env values this script stays a no-op and +// nothing fires. diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs index 7c05897d..ebb88856 100644 --- a/scripts/check-site.mjs +++ b/scripts/check-site.mjs @@ -607,6 +607,43 @@ if (renderBlockingScript) { } } +// ---- Google Ads conversion tag (funnel measurement) ------------------------ +// The funnel's only conversion measurement was dead by construction: the +// brief-requested page hardcoded a gtag loader with the AW-XXXXXXXXX +// placeholder, and the production CSP blocked googletagmanager.com entirely, +// so the event could never record. The tag is now generated by the worker at +// request time from GOOGLE_ADS_CONVERSION_ID / GOOGLE_ADS_CONVERSION_LABEL +// and only emitted on /brief-requested when both are configured (see +// specs/003-wellness-clinic-launch/tracking-setup.md). These STATIC SOURCE +// GUARDS make the dead-by-construction shape impossible again: no placeholder +// may exist in public/ or src/worker.js, no public file may hardcode the gtag +// loader, the static brief-requested.js may not fire anything, and the worker +// must keep the env-driven injection wired. +const adsHtml = read("public/brief-requested.html"); +const adsScript = read("public/brief-requested.js"); +for (const placeholder of ["AW-XXXXXXXXX", "YYYYYYYYYYYYYYYYYYY"]) { + for (const [label, content] of [ + ["public/brief-requested.html", adsHtml], + ["public/brief-requested.js", adsScript], + ["src/worker.js", worker] + ]) { + if (content.includes(placeholder)) { + failures.push(`Google Ads placeholder must never ship (dead conversion): ${placeholder} in ${label}.`); + } + } +} +if (adsHtml.includes("googletagmanager.com")) { + failures.push("public/brief-requested.html must not hardcode the Google Ads tag; the worker injects it from env at request time."); +} +if (adsScript.includes("gtag(") || adsScript.includes("dataLayer")) { + failures.push("public/brief-requested.js must not fire a conversion statically; the worker generates it from env when configured."); +} +for (const needle of ["GOOGLE_ADS_CONVERSION_ID", "GOOGLE_ADS_CONVERSION_LABEL", "gtag/js", "/brief-requested.js"]) { + if (!worker.includes(needle)) { + failures.push(`Worker must keep the env-driven Google Ads conversion wiring (${needle}).`); + } +} + if (existsSync(new URL("../public/pipeline-sprint/index.html", import.meta.url))) { failures.push("Pipeline Sprint page should not remain as a separate stale public asset."); } diff --git a/scripts/test-agent-worker.mjs b/scripts/test-agent-worker.mjs index 47656fcc..bf77a204 100644 --- a/scripts/test-agent-worker.mjs +++ b/scripts/test-agent-worker.mjs @@ -1045,3 +1045,77 @@ test("worker does not serve unlisted asset-like paths outside the public allow-l const res = await worker.fetch(new Request("https://tinystudio.io/not-listed.js"), env); assert.equal(res.status, 404); }); + +// ---- Google Ads conversion tag (funnel measurement) ------------------------ +// The funnel's only conversion measurement was dead by construction: the +// brief-requested page hardcoded the AW-XXXXXXXXX placeholder, and the +// production CSP blocked googletagmanager.com, so the event could never +// record. The tag must therefore be generated by the worker from env values +// and only emitted on /brief-requested when both are configured; with either +// missing or malformed, the page ships with no tag at all. +const BRIEF_REQUESTED_HTML = 'briefok'; + +function adsEnv(overrides = {}) { + return { + ASSETS: { + fetch: async () => + new Response(BRIEF_REQUESTED_HTML, { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" } + }) + }, + GOOGLE_ADS_CONVERSION_ID: "AW-1234567890", + GOOGLE_ADS_CONVERSION_LABEL: "AbCdEfGhIjKlMnOpQrSt", + ...overrides + }; +} + +test("brief-requested ships no Google Ads tag while the conversion env is not configured", async () => { + const env = adsEnv({ GOOGLE_ADS_CONVERSION_ID: "", GOOGLE_ADS_CONVERSION_LABEL: "" }); + const res = await worker.fetch(new Request("https://tinystudio.io/brief-requested"), env); + assert.equal(res.status, 200); + const body = await res.text(); + assert.ok(!body.includes("googletagmanager.com"), "no tag may ship while unconfigured"); + const csp = res.headers.get("Content-Security-Policy") || ""; + assert.ok(!csp.includes("googletagmanager.com"), "CSP must stay strict while unconfigured"); + const js = await (await worker.fetch(new Request("https://tinystudio.io/brief-requested.js"), env)).text(); + assert.ok(!js.includes("gtag("), "no conversion event may fire while unconfigured"); +}); + +test("worker injects the configured Google Ads conversion tag on /brief-requested only", async () => { + const env = adsEnv(); + const res = await worker.fetch(new Request("https://tinystudio.io/brief-requested"), env); + assert.equal(res.status, 200); + const body = await res.text(); + assert.ok( + body.includes('src="https://www.googletagmanager.com/gtag/js?id=AW-1234567890"'), + "gtag loader must be injected with the real conversion id" + ); + assert.ok(body.includes(""), "injected loader must sit inside the head"); + const csp = res.headers.get("Content-Security-Policy") || ""; + assert.ok(csp.includes("https://www.googletagmanager.com"), "brief-requested CSP must allow the gtag script origin"); + assert.ok(csp.includes("googleads.g.doubleclick.net"), "brief-requested CSP must allow the conversion beacon"); + + const js = await (await worker.fetch(new Request("https://tinystudio.io/brief-requested.js"), env)).text(); + assert.ok(js.includes("gtag('config', 'AW-1234567890')"), "generated script must configure the real conversion id"); + assert.ok(js.includes("AW-1234567890/AbCdEfGhIjKlMnOpQrSt"), "generated script must send the real conversion label"); + + // Every other page keeps the strict CSP even when the tag is configured. + const other = await worker.fetch(new Request("https://tinystudio.io/pricing.html"), env); + const otherCsp = other.headers.get("Content-Security-Policy") || ""; + assert.ok(!otherCsp.includes("googletagmanager.com"), "other pages must keep the strict CSP"); +}); + +test("worker refuses to emit a Google Ads tag for a partial or malformed conversion config", async () => { + const malformed = [ + adsEnv({ GOOGLE_ADS_CONVERSION_LABEL: "" }), + adsEnv({ GOOGLE_ADS_CONVERSION_ID: "AW-123" }), + adsEnv({ GOOGLE_ADS_CONVERSION_ID: "javascript:alert(1)" }) + ]; + for (const env of malformed) { + const res = await worker.fetch(new Request("https://tinystudio.io/brief-requested"), env); + const body = await res.text(); + assert.ok(!body.includes("googletagmanager.com"), "no tag may emit for a partial or malformed config"); + assert.equal(body, BRIEF_REQUESTED_HTML, "unconfigured page must pass through untouched"); + } +}); diff --git a/specs/003-wellness-clinic-launch/tracking-setup.md b/specs/003-wellness-clinic-launch/tracking-setup.md index cee09ad5..45d0d28a 100644 --- a/specs/003-wellness-clinic-launch/tracking-setup.md +++ b/specs/003-wellness-clinic-launch/tracking-setup.md @@ -66,8 +66,17 @@ I cannot create this without account access. Exact steps: - Click-through window: **30 days** - Attribution: **Data-driven**, or last-click if data-driven is unavailable 4. Tag setup → copy the **conversion ID** (`AW-…`) and the **conversion label** -5. Paste both into `brief-requested.html`, replacing `AW-XXXXXXXXX` and the - `send_to` label. **Four placeholder occurrences — replace all of them.** +5. Set both on the Worker so the tag is emitted at request time — no code + change, and no placeholder can ever ship as a dead conversion: + - `wrangler secret put GOOGLE_ADS_CONVERSION_ID` → the `AW-…` ID + - `wrangler secret put GOOGLE_ADS_CONVERSION_LABEL` → the conversion label + - (For `wrangler dev --remote`, put both in `.dev.vars` instead.) + The Worker validates both (`AW-` + digits; a 10+ character alphanumeric + label) and injects the gtag loader + conversion event into + `/brief-requested` **only** when both are set and well-formed. With either + missing or malformed, the page ships with no tag at all — a dead tag is + never served. The CSP allowances for gtag are scoped to that one noindex + page's response; every other page keeps the strict CSP. ## 4. GA4 + Ads link @@ -94,7 +103,7 @@ works is a day of data you cannot use. - [ ] `htmlRedirect()` in `src/worker.js` points at `/brief-requested` - [ ] JS success path redirects to `/brief-requested` - [ ] Conversion action created — Submit lead form, no value, count One, 30 days -- [ ] `AW-…` ID and label pasted into `brief-requested.html` (all four spots) +- [ ] `GOOGLE_ADS_CONVERSION_ID` and `GOOGLE_ADS_CONVERSION_LABEL` set on the Worker (`wrangler secret put`; `.dev.vars` for dev) - [ ] GA4 linked to Ads - [ ] Live test submission verified as a recorded conversion - [ ] Negatives loaded, ads approved diff --git a/src/worker.js b/src/worker.js index 7035e176..984db122 100644 --- a/src/worker.js +++ b/src/worker.js @@ -10,6 +10,17 @@ const SECURITY_HEADERS = { "default-src 'self'; img-src 'self' data:; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://static.cloudflareinsights.com; connect-src 'self' https://cloudflareinsights.com; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" }; +// Page-scoped CSP for /brief-requested ONLY when the Google Ads conversion +// tag is configured. gtag.js loads from googletagmanager.com and beacons to +// Google's measurement endpoints; the global CSP above blocks both, which +// made even a real conversion id dead on arrival. The allowances are scoped +// to this one noindex page's response so every other page keeps the strict +// CSP. Only reachable when GOOGLE_ADS_CONVERSION_ID / _LABEL are configured +// (see googleAdsConversion below); when they are not, the page ships with +// the strict CSP and no tag at all. +const GOOGLE_ADS_CSP = + "default-src 'self'; img-src 'self' data: https://www.googleadservices.com; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self' https://static.cloudflareinsights.com https://www.googletagmanager.com; connect-src 'self' https://cloudflareinsights.com https://www.googletagmanager.com https://googleads.g.doubleclick.net https://www.googleadservices.com https://www.google-analytics.com https://stats.g.doubleclick.net; base-uri 'self'; frame-ancestors 'none'; form-action 'self'"; + const PUBLIC_ASSET_PATHS = new Set([ "/", "/index.html", @@ -94,10 +105,10 @@ const WEEKLY_METRIC_LABELS = [ "Cash collected" ]; -function withSecurityHeaders(response) { +function withSecurityHeaders(response, contentSecurityPolicy) { const headers = new Headers(response.headers); for (const [key, value] of Object.entries(SECURITY_HEADERS)) { - headers.set(key, value); + headers.set(key, key === "Content-Security-Policy" && contentSecurityPolicy ? contentSecurityPolicy : value); } return new Response(response.body, { status: response.status, @@ -1316,6 +1327,41 @@ function isHtmlNavigation(request) { return (request.method === "GET" || request.method === "HEAD") && accept.includes("text/html"); } +// ---- Google Ads conversion tag (funnel measurement) ----------------------- +// The funnel's only conversion measurement used to be dead by construction: +// brief-requested.html shipped a hardcoded gtag loader with a placeholder +// conversion id, and the production CSP blocked googletagmanager.com +// entirely, so the event could never record. The tag is now generated at +// request time from env values and only emitted on /brief-requested when +// BOTH are configured and well-formed; a partial or malformed config emits +// nothing rather than a dead tag. The strict patterns also mean the values +// are safe to interpolate into the generated script. +const GOOGLE_ADS_ID_PATTERN = /^AW-\d{6,15}$/; +const GOOGLE_ADS_LABEL_PATTERN = /^[A-Za-z0-9_-]{10,50}$/; + +function googleAdsConversion(env) { + const id = String(env.GOOGLE_ADS_CONVERSION_ID || "").trim(); + const label = String(env.GOOGLE_ADS_CONVERSION_LABEL || "").trim(); + if (!GOOGLE_ADS_ID_PATTERN.test(id) || !GOOGLE_ADS_LABEL_PATTERN.test(label)) return null; + return { id, label }; +} + +function googleAdsLoader({ id }) { + return ` +`; +} + +function googleAdsScript({ id, label }) { + return `window.dataLayer = window.dataLayer || []; +function gtag(){dataLayer.push(arguments);} +gtag('js', new Date()); +gtag('config', '${id}'); +gtag('event', 'conversion', { + 'send_to': '${id}/${label}' +}); +`; +} + export default { async fetch(request, env) { const url = new URL(request.url); @@ -1342,6 +1388,36 @@ export default { } if (PUBLIC_ASSET_PATHS.has(url.pathname)) { + const ads = googleAdsConversion(env); + const isBriefRequestedPage = + url.pathname === "/brief-requested" || url.pathname === "/brief-requested.html"; + const isBriefRequestedScript = url.pathname === "/brief-requested.js"; + + if (ads && request.method === "GET" && (isBriefRequestedPage || isBriefRequestedScript)) { + if (isBriefRequestedScript) { + return withSecurityHeaders( + new Response(googleAdsScript(ads), { + headers: { "Content-Type": "text/javascript;charset=UTF-8" } + }) + ); + } + const assetResponse = await env.ASSETS.fetch(request); + if (assetResponse.ok) { + const html = await assetResponse.text(); + const rewritten = html.includes("") + ? html.replace("", `${googleAdsLoader(ads)}\n`) + : html; + return withSecurityHeaders( + new Response(rewritten, { + status: assetResponse.status, + statusText: assetResponse.statusText, + headers: { "Content-Type": "text/html; charset=utf-8" } + }), + GOOGLE_ADS_CSP + ); + } + } + const assetResponse = await env.ASSETS.fetch(request); return withSecurityHeaders(assetResponse); }