diff --git a/packages/script/src/runtime/registry/_gcm-consent.ts b/packages/script/src/runtime/registry/_gcm-consent.ts index 15c74b53..9a88e1b9 100644 --- a/packages/script/src/runtime/registry/_gcm-consent.ts +++ b/packages/script/src/runtime/registry/_gcm-consent.ts @@ -1,14 +1,48 @@ import type { ConsentState, GcmConsentApi, UseScriptContext } from '../types' -import { safeParse, strictObject } from 'valibot' import { logger } from '../logger' -import { gcmConsentState } from './schemas' export type { GcmConsentApi } -// Strict variant rebuilt from the lenient schema's entries — same shape, but -// unknown keys produce issues so we can warn on typos without breaking the -// lenient `defaultConsent` schema parse used at build time. -const gcmConsentStateStrict = strictObject(gcmConsentState.entries) +// GCMv2 consent categories. Every entry takes `granted` or `denied`. +const CONSENT_CATEGORIES = [ + 'ad_storage', + 'ad_user_data', + 'ad_personalization', + 'analytics_storage', + 'functionality_storage', + 'personalization_storage', + 'security_storage', +] as const satisfies readonly (keyof ConsentState)[] + +const CONSENT_CATEGORY_KEYS: ReadonlySet = new Set(CONSENT_CATEGORIES) + +// Fails to compile if `ConsentState` grows a key this validator does not know about. +type AssertNever = T +type _ConsentKeysChecked = AssertNever> + +/** + * Describe what is wrong with one consent entry, or return `null` when it is valid. + * The canonical schema stays lenient so unknown keys pass a schema parse; here we + * reject them, because an unknown key is almost always a typo the user wants to see. + */ +function describeConsentIssue(key: string, value: unknown): string | null { + if (CONSENT_CATEGORY_KEYS.has(key)) { + return value === 'granted' || value === 'denied' + ? null + : `"${key}" must be "granted" or "denied", received ${JSON.stringify(value)}` + } + if (key === 'wait_for_update') { + return typeof value === 'number' && Number.isFinite(value) + ? null + : `"wait_for_update" must be a number, received ${JSON.stringify(value)}` + } + if (key === 'region') { + return Array.isArray(value) && value.every(entry => typeof entry === 'string') + ? null + : `"region" must be an array of strings, received ${JSON.stringify(value)}` + } + return `unknown key "${key}"` +} /** * GCMv2 consent contract returned by registry scripts (GA, GTM, future Google Ads, …). @@ -21,11 +55,15 @@ export interface GcmConsentContract { /** Validate a partial GCMv2 consent state. Logs each issue via the registry-scoped logger. */ export function validateConsentState(log: typeof logger, state: ConsentState, source: string) { - const result = safeParse(gcmConsentStateStrict, state) - if (result.success) + if (state === null || typeof state !== 'object') { + log.warn(`${source}: consent state must be an object, received ${JSON.stringify(state)}`) return - for (const issue of result.issues) - log.warn(`${source}: ${issue.message} (path: ${issue.path?.map(p => p.key).join('.') || ''})`) + } + for (const key in state) { + const issue = describeConsentIssue(key, (state as Record)[key]) + if (issue) + log.warn(`${source}: ${issue}`) + } } export function attachGcmConsent( diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts index 70537788..46704eb1 100644 --- a/packages/script/src/runtime/registry/speedcurve.ts +++ b/packages/script/src/runtime/registry/speedcurve.ts @@ -1,4 +1,5 @@ import type { LuxGlobal, UserConfig } from '@speedcurve/lux' +import type { InferInput } from 'valibot' import type { RouteLocationNormalized } from 'vue-router' import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types' import { useHead, useNuxtApp, useRouter } from 'nuxt/app' @@ -25,10 +26,28 @@ export type SpeedCurveInput = Omit label?: string | ((to: RouteLocationNormalized) => string | false) | false } -// Derived from the schema: all schema keys except the composable-only ones. -const LUX_USER_CONFIG_KEYS = Object.keys(SpeedCurveOptions.entries).filter( - k => k !== 'id' && k !== 'autoTrackSpaNavigations' && k !== 'spaMode', -) as (keyof UserConfig)[] +/** Schema keys consumed by `useScriptSpeedCurve` itself, never forwarded to LUX. */ +type ComposableOnlyKey = 'id' | 'spaMode' | 'autoTrackSpaNavigations' +type ForwardedKey = Exclude, ComposableOnlyKey> + +// Listed rather than derived from `SpeedCurveOptions.entries`, so a production build +// drops the schema and valibot with it. The type guards below fail to compile if this +// list and the schema drift apart. +const LUX_USER_CONFIG_KEYS = [ + 'label', + 'samplerate', + 'sendBeaconOnPageHidden', + 'trackErrors', + 'maxErrors', + 'minMeasureTime', + 'maxMeasureTime', + 'newBeaconOnPageShow', + 'trackHiddenPages', + 'cookieDomain', +] as const satisfies readonly (ForwardedKey & keyof UserConfig)[] + +type AssertNever = T +type _AllForwardedKeysListed = AssertNever> let luxWired = false let teardownAutoTracker = () => {} diff --git a/test/unit/gcm-consent.test.ts b/test/unit/gcm-consent.test.ts new file mode 100644 index 00000000..e4cc7384 --- /dev/null +++ b/test/unit/gcm-consent.test.ts @@ -0,0 +1,53 @@ +import type { ConsentState } from '../../packages/script/src/runtime/types' +import { describe, expect, it, vi } from 'vitest' +import { validateConsentState } from '../../packages/script/src/runtime/registry/_gcm-consent' + +function collectWarnings(state: unknown): string[] { + const warn = vi.fn() + validateConsentState({ warn } as any, state as ConsentState, 'consent.update()') + return warn.mock.calls.map(([message]) => message as string) +} + +describe('validateConsentState', () => { + it('accepts a valid partial GCMv2 state', () => { + expect(collectWarnings({ + ad_storage: 'granted', + analytics_storage: 'denied', + wait_for_update: 500, + region: ['AU', 'NZ'], + })).toEqual([]) + }) + + it('warns once per unknown key', () => { + const warnings = collectWarnings({ analytics_storages: 'granted', ad_storag: 'denied' }) + expect(warnings).toHaveLength(2) + expect(warnings[0]).toContain('analytics_storages') + expect(warnings[1]).toContain('ad_storag') + }) + + it('warns on a consent value outside granted/denied', () => { + const warnings = collectWarnings({ ad_storage: 'allow' }) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toContain('ad_storage') + expect(warnings[0]).toContain('"allow"') + }) + + it('warns when wait_for_update is not a number', () => { + expect(collectWarnings({ wait_for_update: '500' })[0]).toContain('wait_for_update') + expect(collectWarnings({ wait_for_update: Number.NaN })[0]).toContain('wait_for_update') + }) + + it('warns when region is not an array of strings', () => { + expect(collectWarnings({ region: 'AU' })[0]).toContain('region') + expect(collectWarnings({ region: ['AU', 2] })[0]).toContain('region') + }) + + it('warns when the state is not an object', () => { + expect(collectWarnings(null)[0]).toContain('must be an object') + expect(collectWarnings('granted')[0]).toContain('must be an object') + }) + + it('reports the calling source in each warning', () => { + expect(collectWarnings({ nope: 'granted' })[0]).toContain('consent.update()') + }) +})