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..71efedf 100644 --- a/docs/development/reference/api.md +++ b/docs/development/reference/api.md @@ -60,14 +60,29 @@ 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 `@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/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/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/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/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), 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(); +});