From a61dc46261a8fefb444786f94f025e65b748fe63 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 23 Sep 2026 13:48:03 -0700 Subject: [PATCH 1/2] feat: Count lvwwd.org campaign events Add lvwwd.org, the Week Without Driving Las Vegas campaign site, to the event allowlist. Its ten browser events cover the campaign's success measures: sign-ups, "Open my week" link requests, trip entries, trip pictures, bingo progress, Find a bus, installs, printed materials and the mail-in instructions. Every property is a campaign day, a running count or a fixed label, so no event carries a name, contact, link or place. The browser validators now read the shared allowlist instead of keeping their own copies of the placement and feature lists. Delegated data-lvbt attributes and the classic-script client therefore accept any declared event and property, and still refuse server-only events. The classic-script client grows to carry the larger allowlist, so its size budget rises from 1024 to 1792 bytes. Release the package as 0.2.0 and verify lvwwd.org in the weekly check. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/weekly.yml | 2 + apps/collector/tests/collector.test.ts | 48 ++++++++++++++++++ .../how-to/add-a-conversion-event.md | 4 +- docs/development/reference/api.md | 18 ++++++- docs/development/reference/configuration.md | 5 ++ docs/development/reference/event-allowlist.md | 43 +++++++++++++--- docs/public/privacy.md | 5 +- packages/analytics/package.json | 2 +- packages/analytics/scripts/check-size.mjs | 5 +- packages/analytics/src/client-events.ts | 49 ++++++------------ packages/analytics/src/events.ts | 45 +++++++++++++++++ packages/analytics/src/index.ts | 2 +- packages/analytics/src/standalone-runtime.ts | 35 ++----------- packages/analytics/tests/events.test.ts | 50 ++++++++++++++++++- packages/analytics/tests/init.test.ts | 18 +++++++ packages/analytics/tests/publishing.test.ts | 2 +- packages/analytics/tests/standalone.test.ts | 22 ++++++++ 17 files changed, 273 insertions(+), 82 deletions(-) diff --git a/.github/workflows/weekly.yml b/.github/workflows/weekly.yml index 7f6ca81..6760730 100644 --- a/.github/workflows/weekly.yml +++ b/.github/workflows/weekly.yml @@ -41,6 +41,8 @@ jobs: --site fund.lasvegasfortransit.org --expect present node packages/analytics/dist/cli/index.mjs verify https://map.lasvegasfortransit.org \ --site map.lasvegasfortransit.org --expect present + node packages/analytics/dist/cli/index.mjs verify https://lvwwd.org \ + --site lvwwd.org --expect present - name: Build reports run: | diff --git a/apps/collector/tests/collector.test.ts b/apps/collector/tests/collector.test.ts index e4962e6..d7dc1b7 100644 --- a/apps/collector/tests/collector.test.ts +++ b/apps/collector/tests/collector.test.ts @@ -45,6 +45,54 @@ describe('analytics collector', () => { ); }); + test('writes an lvwwd.org campaign event with its properties in declared order', async () => { + const env = testEnv(); + const response = await handle( + new Request(endpoint, { + body: JSON.stringify({ + site: 'lvwwd.org', + name: 'trip_entry_submitted', + props: { method: 'screenshot', day: '5' }, + }), + headers: { 'Content-Type': 'text/plain', Origin: 'https://lvwwd.org' }, + method: 'POST', + }), + env, + ); + + expect(response.status).toBe(204); + expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://lvwwd.org'); + expect(env.EVENTS.writeDataPoint).toHaveBeenCalledOnce(); + const point = env.EVENTS.writeDataPoint.mock.calls[0]?.[0]; + expect(point?.indexes).toEqual(['lvwwd.org']); + expect(point?.blobs?.slice(0, 2)).toEqual(['lvwwd.org', 'trip_entry_submitted']); + expect(point?.blobs?.slice(6)).toEqual(['5', 'screenshot']); + }); + + test('keeps LVBT events and campaign events on their own sites', async () => { + const campaignJoin = await workerFetch(endpoint, { + body: JSON.stringify({ + site: 'lvwwd.org', + name: 'join_click', + props: { placement: 'header' }, + }), + headers: { 'Content-Type': 'text/plain', Origin: 'https://lvwwd.org' }, + method: 'POST', + }); + const labsSignup = await workerFetch(endpoint, { + body: JSON.stringify({ + site: 'labs.lasvegasfortransit.org', + name: 'campaign_signup', + props: {}, + }), + headers: clientHeaders, + method: 'POST', + }); + + expect(campaignJoin.status).toBe(400); + expect(labsSignup.status).toBe(400); + }); + test.each(['Sec-GPC', 'DNT'])('honors %s before processing the event', async (header) => { const response = await workerFetch(endpoint, { body: 'not json', diff --git a/docs/development/how-to/add-a-conversion-event.md b/docs/development/how-to/add-a-conversion-event.md index e65dc2f..8d6ff31 100644 --- a/docs/development/how-to/add-a-conversion-event.md +++ b/docs/development/how-to/add-a-conversion-event.md @@ -4,7 +4,9 @@ A conversion event represents a small, durable product action that cannot be ans pageviews. Confirm that the question needs a custom event before changing the allowlist. 1. Add one entry to `packages/analytics/src/events.ts`. Choose `client` or `server`, list the exact - production sites, and use enum properties only. + production sites, and use enum properties only. Name the event in lowercase snake case, such as + `trip_entry_submitted`. Write each value as a short lowercase label or a small count, such as + `screenshot` or `3`; a unit test rejects anything else. 2. Add valid and invalid cases to `packages/analytics/tests/events.test.ts` and the collector workerd suite. 3. Run `pnpm exec lvbt-analytics events --markdown` and update the event reference with the exact diff --git a/docs/development/reference/api.md b/docs/development/reference/api.md index 6045605..c71127b 100644 --- a/docs/development/reference/api.md +++ b/docs/development/reference/api.md @@ -60,8 +60,22 @@ HTML elements use the same typed contract without application code: Join ``` -Unknown names, property keys, and enum values are ignored by delegated click tracking and rejected -by direct `track` calls. +Each declared property reads from the `data-lvbt-` attribute of the same name, so `material_printed` +reads `data-lvbt-item` and an event without properties needs only `data-lvbt-event`. Unknown names, +property keys, and enum values are ignored by delegated click tracking and rejected by direct +`track` calls. Server-only events are rejected in the browser. + +Classic scripts that cannot import the package call the same function through `window.lvbt`, which +exists only after the gate enables analytics. Wrap the call so an analytics mistake never stops the +page: + +```js +try { + window.lvbt?.track('bus_finder_used', { method: 'place' }); +} catch { + // An event outside the allowlist is dropped. +} +``` ## Framework entry points diff --git a/docs/development/reference/configuration.md b/docs/development/reference/configuration.md index 3b7eaa4..11af497 100644 --- a/docs/development/reference/configuration.md +++ b/docs/development/reference/configuration.md @@ -46,5 +46,10 @@ Site identifiers are production hostnames without a scheme or path: - `labs.lasvegasfortransit.org` - `fund.lasvegasfortransit.org` - `map.lasvegasfortransit.org` +- `lvwwd.org`, the Week Without Driving Las Vegas campaign site The client accepts the exact hostname and its `www.` form. Other hostnames fail the production gate. + +`lvwwd.org` is not a `lasvegasfortransit.org` subdomain, so the organization's Web Analytics site +does not cover it. It has its own Web Analytics site, and its token lives in the +week-without-driving repository's `production` environment rather than in the organization variable. diff --git a/docs/development/reference/event-allowlist.md b/docs/development/reference/event-allowlist.md index acc59ab..f65d8a9 100644 --- a/docs/development/reference/event-allowlist.md +++ b/docs/development/reference/event-allowlist.md @@ -5,15 +5,44 @@ terms, share identifiers, and arbitrary campaign values do not belong in an anal -| Event | Source | Sites | Properties | -| ------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `newsletter_signup` | server | `lasvegasfortransit.org` | `method`: `site_form`, `membership_form` | -| `membership_intake` | server | `lasvegasfortransit.org` | None | -| `join_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` | -| `donate_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` | -| `tool_feature_used` | client | `labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `feature`: `share_created`, `share_opened`, `export_png`, `export_svg`, `export_json`, `gtfs_import`, `sim_started`, `fuel_lever_moved`, `scenario_changed` | +| Event | Source | Sites | Properties | +| ---------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `newsletter_signup` | server | `lasvegasfortransit.org` | `method`: `site_form`, `membership_form` | +| `membership_intake` | server | `lasvegasfortransit.org` | None | +| `join_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` | +| `donate_click` | client | `lasvegasfortransit.org`
`labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `placement`: `header`, `footer`, `hero`, `inline`, `dialog` | +| `tool_feature_used` | client | `labs.lasvegasfortransit.org`
`fund.lasvegasfortransit.org`
`map.lasvegasfortransit.org` | `feature`: `share_created`, `share_opened`, `export_png`, `export_svg`, `export_json`, `gtfs_import`, `sim_started`, `fuel_lever_moved`, `scenario_changed` | +| `campaign_signup` | client | `lvwwd.org` | None | +| `week_link_requested` | client | `lvwwd.org` | `method`: `link_form`, `signup_form` | +| `trip_entry_submitted` | client | `lvwwd.org` | `day`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`
`method`: `link`, `screenshot`, `link_and_screenshot` | +| `trip_picture_shared` | client | `lvwwd.org` | `method`: `share_sheet`, `download` | +| `bingo_square_marked` | client | `lvwwd.org` | `marked`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12`, `13`, `14`, `15`, `16`, `17`, `18`, `19`, `20`, `21`, `22`, `23`, `24` | +| `bingo_completed` | client | `lvwwd.org` | `lines`: `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`, `9`, `10`, `11`, `12` | +| `bus_finder_used` | client | `lvwwd.org` | `method`: `my_location`, `place` | +| `app_installed` | client | `lvwwd.org` | `method`: `browser`, `home_screen` | +| `material_printed` | client | `lvwwd.org` | `item`: `partner_flyer`, `bingo_card` | +| `mail_in_viewed` | client | `lvwwd.org` | None | +## lvwwd.org campaign events + +lvwwd.org, the Week Without Driving Las Vegas campaign site, counts the steps of its campaign. Every +value is a day of the week, a running count, or a fixed label, so no event says who a person is, +what they wrote, or where they were. + +| Event | Sent when | Properties | +| ---------------------- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `campaign_signup` | A new sign-up is saved | None | +| `week_link_requested` | Someone asks for their "Open my week" link | `method`: the Get my link form, or a sign-up that already existed | +| `trip_entry_submitted` | A day's trip entry is saved | `day`: the campaign day, 1 to 8; `method`: a post link, a screenshot, or both. The link itself is never sent | +| `trip_picture_shared` | The trip picture goes to the phone's share sheet or is downloaded | `method`: `share_sheet` or `download` | +| `bingo_square_marked` | A bingo square is marked | `marked`: how many squares are marked now, 1 to 24 | +| `bingo_completed` | Marking a square finishes a line | `lines`: how many lines are complete now, 1 to 12; 12 is the whole card | +| `bus_finder_used` | Find a bus lists stops | `method`: the phone's location or a chosen place. The location itself is never sent | +| `app_installed` | The browser reports an install, or the site first opens from the home screen where browsers report none | `method`: `browser` or `home_screen` | +| `material_printed` | A print starts on the partner flyer or the paper bingo card | `item`: `partner_flyer` or `bingo_card` | +| `mail_in_viewed` | The enter-by-mail instructions come into view | None | + Cloudflare Web Analytics owns pageviews, referrers, UTM attribution, and Core Web Vitals. Duplicating those values as custom events creates a second, less reliable source of truth. diff --git a/docs/public/privacy.md b/docs/public/privacy.md index 660844d..ff398ca 100644 --- a/docs/public/privacy.md +++ b/docs/public/privacy.md @@ -4,8 +4,9 @@ Las Vegas for Better Transit uses privacy-preserving analytics to understand whi tools are useful and whether the sites perform well. Cloudflare Web Analytics records aggregate page use and performance without cookies. A small -LVBT-operated service records a limited set of actions such as selecting a join link or using an -export feature. Those actions contain predefined categories, not form contents or other free text. +LVBT-operated service records a limited set of actions such as selecting a join link, using an +export feature, or finishing a campaign step like signing up. Those actions contain predefined +categories, not form contents or other free text. The system does not create visitor or session identifiers, fingerprint browsers, or store IP addresses and user-agent strings with events. It does not collect names, email addresses, search diff --git a/packages/analytics/package.json b/packages/analytics/package.json index fc11466..41b1257 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -1,6 +1,6 @@ { "name": "@lasvegasfortransit/analytics", - "version": "0.1.0", + "version": "0.2.0", "description": "Privacy-preserving analytics for Las Vegas for Better Transit web properties.", "license": "MIT", "repository": { diff --git a/packages/analytics/scripts/check-size.mjs b/packages/analytics/scripts/check-size.mjs index b138947..9aa4fd5 100644 --- a/packages/analytics/scripts/check-size.mjs +++ b/packages/analytics/scripts/check-size.mjs @@ -1,8 +1,11 @@ import { readFile } from 'node:fs/promises'; import { gzipSync } from 'node:zlib'; +// The standalone client carries the client half of the event allowlist so a +// plain-HTML page rejects undeclared data before it leaves the browser. The +// allowlist grows with each site, so its budget covers five sites' events. const budgets = [ - ['standalone client', 'dist/standalone/client.iife.js', 1024], + ['standalone client', 'dist/standalone/client.iife.js', 1792], ['package entry', 'dist/index.mjs', 1536], ]; diff --git a/packages/analytics/src/client-events.ts b/packages/analytics/src/client-events.ts index b072b0b..75a2e9c 100644 --- a/packages/analytics/src/client-events.ts +++ b/packages/analytics/src/client-events.ts @@ -1,44 +1,23 @@ -import type { AnalyticsEvent, EventName, PropsFor } from './events.js'; - -const placements = ['header', 'footer', 'hero', 'inline', 'dialog'] as const; -const features = [ - 'share_created', - 'share_opened', - 'export_png', - 'export_svg', - 'export_json', - 'gtfs_import', - 'sim_started', - 'fuel_lever_moved', - 'scenario_changed', -] as const; - -function declaration(name: EventName) { - if (name === 'join_click' || name === 'donate_click') return ['placement', placements] as const; - if (name === 'tool_feature_used') return ['feature', features] as const; - return undefined; -} +import { eventPayload, EVENTS, type EventName, type PropsFor } from './events.js'; export function clientEvent(site: string, name: N, props: PropsFor) { - const declared = declaration(name); - if ( - !declared || - Object.keys(props).length !== 1 || - typeof props[declared[0] as keyof PropsFor] !== 'string' || - !(declared[1] as readonly string[]).includes(props[declared[0] as keyof PropsFor]) - ) - throw new Error('Analytics event is not declared.'); - return { site, name, props } as AnalyticsEvent; + const event = eventPayload({ site, name, props }); + if (EVENTS[event.name].source !== 'client') throw new Error('Analytics event is not declared.'); + return event; } +// data-lvbt-event names the event; data-lvbt- carries each declared property. export function delegatedEvent(target: HTMLElement) { - const name = target.dataset.lvbtEvent as EventName; - const key = name === 'tool_feature_used' ? 'feature' : 'placement'; - const value = key === 'feature' ? target.dataset.lvbtFeature : target.dataset.lvbtPlacement; + const name = target.dataset.lvbtEvent ?? ''; + if (!Object.prototype.hasOwnProperty.call(EVENTS, name)) return undefined; + const props = Object.fromEntries( + Object.keys(EVENTS[name as EventName].props).map((key) => [ + key, + target.dataset[`lvbt${key.charAt(0).toUpperCase()}${key.slice(1)}`], + ]), + ); try { - return clientEvent(target.dataset.lvbtSite ?? '', name, { - [key]: value, - } as PropsFor); + return clientEvent(target.dataset.lvbtSite ?? '', name as EventName, props as never); } catch { return undefined; } diff --git a/packages/analytics/src/events.ts b/packages/analytics/src/events.ts index 1dfe9d6..80a9a99 100644 --- a/packages/analytics/src/events.ts +++ b/packages/analytics/src/events.ts @@ -1,3 +1,14 @@ +// lvwwd.org, the Week Without Driving Las Vegas campaign site, counts its +// campaign steps with these values. Each is a day of the week, a running +// count, or a fixed label; none says who a person is or what they wrote. +const campaignDays = ['1', '2', '3', '4', '5', '6', '7', '8'] as const; +// prettier-ignore +const markedSquares = [ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', + '13', '14', '15', '16', '17', '18', '19', '20', '21', '22', '23', '24', +] as const; +const completedLines = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] as const; + export const EVENTS = { newsletter_signup: { source: 'server', @@ -46,6 +57,40 @@ export const EVENTS = { ], }, }, + campaign_signup: { source: 'client', sites: ['lvwwd.org'], props: {} }, + week_link_requested: { + source: 'client', + sites: ['lvwwd.org'], + props: { method: ['link_form', 'signup_form'] }, + }, + trip_entry_submitted: { + source: 'client', + sites: ['lvwwd.org'], + props: { day: campaignDays, method: ['link', 'screenshot', 'link_and_screenshot'] }, + }, + trip_picture_shared: { + source: 'client', + sites: ['lvwwd.org'], + props: { method: ['share_sheet', 'download'] }, + }, + bingo_square_marked: { source: 'client', sites: ['lvwwd.org'], props: { marked: markedSquares } }, + bingo_completed: { source: 'client', sites: ['lvwwd.org'], props: { lines: completedLines } }, + bus_finder_used: { + source: 'client', + sites: ['lvwwd.org'], + props: { method: ['my_location', 'place'] }, + }, + app_installed: { + source: 'client', + sites: ['lvwwd.org'], + props: { method: ['browser', 'home_screen'] }, + }, + material_printed: { + source: 'client', + sites: ['lvwwd.org'], + props: { item: ['partner_flyer', 'bingo_card'] }, + }, + mail_in_viewed: { source: 'client', sites: ['lvwwd.org'], props: {} }, } as const; export type EventName = keyof typeof EVENTS; diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index b4b66c0..8035dc5 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -9,4 +9,4 @@ export { export { shouldEnable, type GateInput, type GateReason, type GateResult } from './gate.js'; export { DEFAULT_COLLECTOR, init, type AnalyticsHandle, type InitOptions } from './init.js'; -export const VERSION = '0.1.0'; +export const VERSION = '0.2.0'; diff --git a/packages/analytics/src/standalone-runtime.ts b/packages/analytics/src/standalone-runtime.ts index a568488..5e81174 100644 --- a/packages/analytics/src/standalone-runtime.ts +++ b/packages/analytics/src/standalone-runtime.ts @@ -1,15 +1,4 @@ -const placements = ['header', 'footer', 'hero', 'inline', 'dialog']; -const features = [ - 'share_created', - 'share_opened', - 'export_png', - 'export_svg', - 'export_json', - 'gtfs_import', - 'sim_started', - 'fuel_lever_moved', - 'scenario_changed', -]; +import { clientEvent, delegatedEvent } from './client-events.js'; interface StandaloneEnvironment { hostname?: string; @@ -30,17 +19,7 @@ function framed() { } } -function valid(name: string, props: Record) { - const key = name === 'tool_feature_used' ? 'feature' : 'placement'; - const values = key === 'feature' ? features : placements; - return ( - (key === 'feature' || name === 'join_click' || name === 'donate_click') && - Object.keys(props).length === 1 && - values.includes(props[key] ?? '') - ); -} - -// eslint-disable-next-line complexity -- Inline checks keep the standalone runtime under its budget. +// eslint-disable-next-line complexity -- Inline gate checks keep the standalone runtime small. export function startStandalone( script: HTMLScriptElement | null, environment: StandaloneEnvironment = {}, @@ -69,8 +48,7 @@ export function startStandalone( const collector = script.dataset.lvbtCollector ?? 'https://events.lasvegasfortransit.org'; const sent = new Set(); const track = (name: string, props: Record) => { - if (!valid(name, props)) throw new Error('Analytics event is not declared.'); - const body = JSON.stringify({ site, name, props }); + const body = JSON.stringify(clientEvent(site, name as never, props as never)); if (sent.has(body)) return; sent.add(body); const blob = new Blob([body], { type: 'text/plain' }); @@ -95,10 +73,7 @@ export function startStandalone( event.target instanceof Element ? event.target.closest('[data-lvbt-event]') : null; - if (!target) return; - const name = target.dataset.lvbtEvent ?? ''; - const key = name === 'tool_feature_used' ? 'feature' : 'placement'; - const value = key === 'feature' ? target.dataset.lvbtFeature : target.dataset.lvbtPlacement; - if (value && valid(name, { [key]: value })) track(name, { [key]: value }); + const payload = target ? delegatedEvent(target) : undefined; + if (payload) track(payload.name, payload.props); }); } diff --git a/packages/analytics/tests/events.test.ts b/packages/analytics/tests/events.test.ts index 75a914f..ad7cd89 100644 --- a/packages/analytics/tests/events.test.ts +++ b/packages/analytics/tests/events.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { eventPayload } from '../src/events.js'; +import { clientEvent } from '../src/client-events.js'; +import { eventPayload, EVENTS } from '../src/events.js'; describe('event allowlist', () => { test('accepts a declared event and enum property', () => { @@ -27,4 +28,51 @@ describe('event allowlist', () => { ])('rejects undeclared event data', (payload) => { expect(() => eventPayload(payload)).toThrow(); }); + + test.each([ + { name: 'campaign_signup', props: {} }, + { name: 'week_link_requested', props: { method: 'link_form' } }, + { name: 'trip_entry_submitted', props: { day: '3', method: 'screenshot' } }, + { name: 'trip_picture_shared', props: { method: 'share_sheet' } }, + { name: 'bingo_square_marked', props: { marked: '24' } }, + { name: 'bingo_completed', props: { lines: '12' } }, + { name: 'bus_finder_used', props: { method: 'place' } }, + { name: 'app_installed', props: { method: 'home_screen' } }, + { name: 'material_printed', props: { item: 'partner_flyer' } }, + { name: 'mail_in_viewed', props: {} }, + ])('accepts the lvwwd.org campaign event $name', (event) => { + const payload = { site: 'lvwwd.org', ...event }; + expect(eventPayload(payload)).toEqual(payload); + }); + + test.each([ + { name: 'trip_entry_submitted', props: { day: '9', method: 'link' } }, + { name: 'trip_entry_submitted', props: { day: '2' } }, + { + name: 'trip_entry_submitted', + props: { day: '2', method: 'https://www.instagram.com/p/example' }, + }, + { name: 'campaign_signup', props: { contact: 'person@example.com' } }, + { name: 'bingo_square_marked', props: { marked: '25' } }, + ])('rejects campaign data outside the allowlist', (event) => { + expect(() => eventPayload({ site: 'lvwwd.org', ...event })).toThrow(); + }); + + test('declares lvwwd.org events for lvwwd.org alone', () => { + const campaignEvents = Object.values(EVENTS).filter((event) => + (event.sites as readonly string[]).includes('lvwwd.org'), + ); + expect(campaignEvents.length).toBeGreaterThan(0); + for (const event of campaignEvents) expect(event.sites).toEqual(['lvwwd.org']); + }); + + test('keeps every property value a short enum label', () => { + for (const event of Object.values(EVENTS)) + for (const values of Object.values(event.props) as Array) + for (const value of values) expect(value).toMatch(/^[a-z0-9_]{1,32}$/); + }); + + test('refuses to send a server-only event from the browser', () => { + expect(() => clientEvent('lasvegasfortransit.org', 'membership_intake', {} as never)).toThrow(); + }); }); diff --git a/packages/analytics/tests/init.test.ts b/packages/analytics/tests/init.test.ts index 7f01e47..02e9897 100644 --- a/packages/analytics/tests/init.test.ts +++ b/packages/analytics/tests/init.test.ts @@ -77,6 +77,24 @@ test('tracks a valid delegated event from a nested click target', () => { expect(sendBeacon).toHaveBeenCalledOnce(); }); +test('reads each declared property of a delegated event from its data attribute', () => { + vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + const sendBeacon = vi.fn(() => true); + Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: sendBeacon }); + init({ site: 'test.example', token: 'a'.repeat(32) }); + const print = document.createElement('button'); + print.dataset.lvbtEvent = 'material_printed'; + print.dataset.lvbtItem = 'bingo_card'; + const mail = document.createElement('a'); + mail.dataset.lvbtEvent = 'mail_in_viewed'; + document.body.append(print, mail); + + print.click(); + mail.click(); + + expect(sendBeacon).toHaveBeenCalledTimes(2); +}); + test('falls back to keepalive fetch when sendBeacon declines the event', () => { vi.spyOn(document.head, 'append').mockImplementation(() => undefined); Object.defineProperty(navigator, 'sendBeacon', { diff --git a/packages/analytics/tests/publishing.test.ts b/packages/analytics/tests/publishing.test.ts index 9376f59..96571c6 100644 --- a/packages/analytics/tests/publishing.test.ts +++ b/packages/analytics/tests/publishing.test.ts @@ -27,7 +27,7 @@ test('publishes from the canonical repository through GitHub Packages', async () expect(workflow).not.toMatch(/(^|\s)npm publish/); expect(workflow).toContain('packages: write'); expect(workflow).toContain('NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}'); - expect(packageJson.version).toBe('0.1.0'); + expect(packageJson.version).toBe('0.2.0'); expect(VERSION).toBe(packageJson.version); expect(packageJson.bin).toEqual({ 'lvbt-analytics': 'dist/cli/index.mjs' }); expect(packageJson.files).toContain('LICENSE'); diff --git a/packages/analytics/tests/standalone.test.ts b/packages/analytics/tests/standalone.test.ts index 6816c5e..36c380c 100644 --- a/packages/analytics/tests/standalone.test.ts +++ b/packages/analytics/tests/standalone.test.ts @@ -45,3 +45,25 @@ test('does not initialize when a privacy signal is enabled', () => { expect((window as Window & { lvbt?: unknown }).lvbt).toBeUndefined(); expect(document.querySelector('[data-lvbt-analytics]')).toBeNull(); }); + +test('checks classic-script events against the shared allowlist', () => { + vi.spyOn(document.head, 'append').mockImplementation(() => undefined); + const sendBeacon = vi.fn(() => true); + Object.defineProperty(navigator, 'sendBeacon', { configurable: true, value: sendBeacon }); + const script = document.createElement('script'); + script.dataset.lvbtSite = 'lvwwd.org'; + script.dataset.lvbtToken = 'a'.repeat(32); + + startStandalone(script, { hostname: 'lvwwd.org', gpc: false, dnt: false, framed: false }); + const analytics = (window as Window & { lvbt?: { track: (...args: unknown[]) => void } }).lvbt; + analytics?.track('trip_entry_submitted', { day: '4', method: 'link' }); + + expect(sendBeacon).toHaveBeenCalledOnce(); + expect(() => analytics?.track('trip_entry_submitted', { day: '4', method: 'free text' })).toThrow( + 'not allowed', + ); + expect(() => analytics?.track('newsletter_signup', { method: 'site_form' })).toThrow( + 'not declared', + ); + expect(sendBeacon).toHaveBeenCalledOnce(); +}); From d7fa204c5c02133fff4903924e3c5dde35550ff8 Mon Sep 17 00:00:00 2001 From: Willie Chalmers III Date: Wed, 23 Sep 2026 14:03:02 -0700 Subject: [PATCH 2/2] fix(client): Let Astro sites keep their small stylesheets inline The Astro integration set Vite's assetsInlineLimit to 0 so that Astro would never inline the analytics page script, which a script-src 'self' policy would block. Astro reads the same limit for stylesheets, so every page's small styles also became separate files. On lvwwd.org that added six stylesheet requests to its pages. The integration now passes a limit function that keeps script chunks external and hands every other asset to the site's own limit, or to Astro's default when the site sets none. Co-Authored-By: Claude Opus 5.5 --- docs/development/reference/api.md | 5 +-- packages/analytics/astro/index.ts | 15 ++++++++- packages/analytics/tests/astro.test.ts | 46 +++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/docs/development/reference/api.md b/docs/development/reference/api.md index c71127b..71efedf 100644 --- a/docs/development/reference/api.md +++ b/docs/development/reference/api.md @@ -80,8 +80,9 @@ try { ## Framework entry points `@lasvegasfortransit/analytics/astro` exports `lvbtAnalytics(options)`. It reads the standard -environment, injects an external page module only when a token exists, and disables JavaScript asset -inlining so the site CSP remains enforceable. +environment, injects an external page module only when a token exists, and keeps script chunks out +of the HTML so a `script-src 'self'` policy still runs them. Stylesheets and other assets keep the +site's own inlining rule, so small page styles stay inline. `@lasvegasfortransit/analytics/react` exports `Analytics`, `useAnalytics`, and `useTrack`. The provider initializes the shared client after mount. Hooks return the disabled no-op handle during diff --git a/packages/analytics/astro/index.ts b/packages/analytics/astro/index.ts index 80ed1a7..a1b8d7d 100644 --- a/packages/analytics/astro/index.ts +++ b/packages/analytics/astro/index.ts @@ -38,7 +38,20 @@ export function lvbtAnalytics(options: LvbtAnalyticsOptions): AstroIntegration { 'page', `import { init } from '@lasvegasfortransit/analytics'; const options = ${JSON.stringify({ ...serializable, collector, token })}; ${patternAssignments} init(options);`, ); - updateConfig({ vite: { build: { assetsInlineLimit: 0 } } }); + // Keep script chunks external so a `script-src 'self'` policy still runs them, and leave + // every other asset, such as small stylesheets, to the site's own inlining rule. + const siteLimit = config.vite.build?.assetsInlineLimit; + updateConfig({ + vite: { + build: { + assetsInlineLimit: (filePath: string, content: Buffer) => { + if (/\.m?js$/.test(filePath)) return false; + if (typeof siteLimit === 'function') return siteLimit(filePath, content); + return siteLimit === undefined ? undefined : content.byteLength < siteLimit; + }, + }, + }, + }); }, }, }; diff --git a/packages/analytics/tests/astro.test.ts b/packages/analytics/tests/astro.test.ts index dc406a9..43d3a8c 100644 --- a/packages/analytics/tests/astro.test.ts +++ b/packages/analytics/tests/astro.test.ts @@ -15,7 +15,7 @@ test('reads the standard collector override from the build environment', async ( await integration.hooks['astro:config:setup']?.({ command: 'build', - config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site' }, + config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site', vite: {} }, injectScript, updateConfig: vi.fn(), } as never); @@ -26,7 +26,7 @@ test('reads the standard collector override from the build environment', async ( ); }); -test('injects the production client and prevents JavaScript inlining', async () => { +test('injects the production client and keeps its script external', async () => { process.env.PUBLIC_LVBT_CWA_TOKEN = 'a'.repeat(32); const injectScript = vi.fn(); const updateConfig = vi.fn(); @@ -37,7 +37,7 @@ test('injects the production client and prevents JavaScript inlining', async () await integration.hooks['astro:config:setup']?.({ command: 'build', - config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site' }, + config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site', vite: {} }, injectScript, updateConfig, } as never); @@ -54,7 +54,43 @@ test('injects the production client and prevents JavaScript inlining', async () 'page', expect.stringContaining('new RegExp("^/archive/")'), ); - expect(updateConfig).toHaveBeenCalledWith({ vite: { build: { assetsInlineLimit: 0 } } }); + const limit = ( + updateConfig.mock.calls[0]?.[0] as { + vite: { + build: { assetsInlineLimit: (path: string, content: Buffer) => boolean | undefined }; + }; + } + ).vite.build.assetsInlineLimit; + expect(limit('_astro/page.abc123.js', Buffer.from('init()'))).toBe(false); + expect(limit('_astro/index.abc123.css', Buffer.from('a{}'))).toBeUndefined(); +}); + +test('keeps a site inlining limit for everything except scripts', async () => { + process.env.PUBLIC_LVBT_CWA_TOKEN = 'a'.repeat(32); + const updateConfig = vi.fn(); + const integration = lvbtAnalytics({ site: 'labs.lasvegasfortransit.org' }); + + await integration.hooks['astro:config:setup']?.({ + command: 'build', + config: { + root: new URL('file:///tmp/site/'), + envDir: '/tmp/site', + vite: { build: { assetsInlineLimit: 8 } }, + }, + injectScript: vi.fn(), + updateConfig, + } as never); + + const limit = ( + updateConfig.mock.calls[0]?.[0] as { + vite: { + build: { assetsInlineLimit: (path: string, content: Buffer) => boolean | undefined }; + }; + } + ).vite.build.assetsInlineLimit; + expect(limit('_astro/index.abc123.css', Buffer.from('a{}'))).toBe(true); + expect(limit('_astro/index.abc123.css', Buffer.from('a{color:red}'))).toBe(false); + expect(limit('_astro/page.abc123.js', Buffer.from('1'))).toBe(false); }); test('requires the token only for guarded production builds', () => { @@ -63,7 +99,7 @@ test('requires the token only for guarded production builds', () => { expect(() => integration.hooks['astro:config:setup']?.({ command: 'build', - config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site' }, + config: { root: new URL('file:///tmp/site/'), envDir: '/tmp/site', vite: {} }, injectScript: vi.fn(), updateConfig: vi.fn(), } as never),