From f3a247dfe248f5d3c8fb4c5885a849bf5ee21966 Mon Sep 17 00:00:00 2001 From: MacBook Date: Fri, 28 Aug 2026 10:32:09 +0100 Subject: [PATCH 1/2] feat: gate recommendation telemetry on consent Add an analytics-consent store (Zustand + localStorage, optimistic default off) and a consent-gated telemetry primitive for recommendations. The RecommendationsStrip now emits impression and click events only when the user has explicitly opted in via the privacy settings, with strict input sanitization, bounded session de-duplication, failure-tolerant sinks, and no sensitive data in payloads. Wire the privacy/account settings toggles to the consent store as a single source of truth. Closes #911 --- app/settings/account/page.tsx | 8 +- .../privacy/__tests__/privacy.a11y.test.tsx | 36 +- app/settings/privacy/page.tsx | 10 +- app/state/__tests__/consent.test.ts | 127 +++++++ app/state/consent.ts | 99 ++++++ components/dashboard/RecommendationsStrip.tsx | 48 ++- .../__tests__/RecommendationsStrip.test.tsx | 92 +++++- lib/__tests__/telemetry.test.ts | 309 ++++++++++++++++++ lib/telemetry.ts | 206 ++++++++++++ 9 files changed, 921 insertions(+), 14 deletions(-) create mode 100644 app/state/__tests__/consent.test.ts create mode 100644 app/state/consent.ts create mode 100644 lib/__tests__/telemetry.test.ts create mode 100644 lib/telemetry.ts diff --git a/app/settings/account/page.tsx b/app/settings/account/page.tsx index 7b7cefec..dff3ba9e 100644 --- a/app/settings/account/page.tsx +++ b/app/settings/account/page.tsx @@ -27,6 +27,7 @@ import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { useConsentStore } from "@/app/state/consent" // ───────────────────────────────────────────────────────────── // Types @@ -47,7 +48,8 @@ export default function AccountSettingsPage() { // Privacy controls const [publicProfile, setPublicProfile] = useState(true) const [showActivity, setShowActivity] = useState(true) - const [allowAnalytics, setAllowAnalytics] = useState(false) + const analyticsConsent = useConsentStore((s) => s.analyticsConsent) + const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent) const [showLeaderboard, setShowLeaderboard] = useState(true) // Save state for live-region announcement @@ -288,8 +290,8 @@ export default function AccountSettingsPage() { id="allow-analytics" label="Allow usage analytics" description="Help improve Predictify by sharing anonymised interaction data. No wallet data is included." - checked={allowAnalytics} - onCheckedChange={setAllowAnalytics} + checked={analyticsConsent} + onCheckedChange={setAnalyticsConsent} /> diff --git a/app/settings/privacy/__tests__/privacy.a11y.test.tsx b/app/settings/privacy/__tests__/privacy.a11y.test.tsx index e586950b..32184fe1 100644 --- a/app/settings/privacy/__tests__/privacy.a11y.test.tsx +++ b/app/settings/privacy/__tests__/privacy.a11y.test.tsx @@ -1,9 +1,21 @@ import React from "react" -import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react" import userEvent from "@testing-library/user-event" import PrivacySettingsPage from "../page" +import { useConsentStore } from "@/app/state/consent" describe("Settings → Privacy page", () => { + beforeEach(() => { + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + it("renders the page heading and description", () => { render() @@ -114,8 +126,10 @@ describe("Settings → Privacy page", () => { it("each switch row displays an icon and a badge in the summary card", () => { render() - const summaryBadges = screen.getAllByText(/visible|hidden/i) - expect(summaryBadges.length).toBe(5) + // Anchored to the exact badge values so surrounding copy (e.g. "...information is + // visible to other users...") can never inflate the count. + const summaryBadges = screen.getAllByText(/^(visible|hidden)$/i) + expect(summaryBadges).toHaveLength(5) }) it("all switches have unique ids matching their labels", () => { @@ -129,4 +143,20 @@ describe("Settings → Privacy page", () => { expect(label).toBeInTheDocument() }) }) + + it("the analytics toggle is backed by the consent store (single source of truth)", () => { + expect(useConsentStore.getState().analyticsConsent).toBe(false) + + render() + + const analytics = screen.getByRole("switch", { name: /allow usage analytics/i }) + fireEvent.click(analytics) + + expect(useConsentStore.getState().analyticsConsent).toBe(true) + expect(analytics).toBeChecked() + + fireEvent.click(analytics) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + expect(analytics).not.toBeChecked() + }) }) \ No newline at end of file diff --git a/app/settings/privacy/page.tsx b/app/settings/privacy/page.tsx index bb6d3d20..3694873d 100644 --- a/app/settings/privacy/page.tsx +++ b/app/settings/privacy/page.tsx @@ -16,6 +16,7 @@ import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { Switch } from "@/components/ui/switch" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { useConsentStore } from "@/app/state/consent" type SaveState = "idle" | "saving" | "saved" | "error" @@ -24,7 +25,8 @@ export default function PrivacySettingsPage() { const [showActivity, setShowActivity] = useState(true) const [showLeaderboard, setShowLeaderboard] = useState(true) const [showBalance, setShowBalance] = useState(true) - const [allowAnalytics, setAllowAnalytics] = useState(false) + const analyticsConsent = useConsentStore((s) => s.analyticsConsent) + const setAnalyticsConsent = useConsentStore((s) => s.setAnalyticsConsent) const [saveState, setSaveState] = useState("idle") @@ -75,8 +77,8 @@ export default function PrivacySettingsPage() { icon: Eye, label: "Allow usage analytics", description: "Help improve Predictify by sharing anonymised interaction data. No wallet data is included.", - checked: allowAnalytics, - onChange: setAllowAnalytics, + checked: analyticsConsent, + onChange: setAnalyticsConsent, }, ] @@ -211,7 +213,7 @@ export default function PrivacySettingsPage() { setShowActivity(true) setShowLeaderboard(true) setShowBalance(true) - setAllowAnalytics(false) + setAnalyticsConsent(false) }} > Reset defaults diff --git a/app/state/__tests__/consent.test.ts b/app/state/__tests__/consent.test.ts new file mode 100644 index 00000000..c8207287 --- /dev/null +++ b/app/state/__tests__/consent.test.ts @@ -0,0 +1,127 @@ +/** + * consent.test.ts + * + * Unit tests for the useConsentStore (app/state/consent.ts). + * Covers: default state, explicit opt-in/opt-out, strict boolean coercion, + * persistence, and boundary/invalid inputs. + */ + +import { act } from "@testing-library/react" +import { + useConsentStore, + getAnalyticsConsent, + parseConsent, + CONSENT_STORAGE_KEY, +} from "../consent" + +/** Reset store to the optimistic default between tests. */ +function resetStore() { + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) +} + +describe("useConsentStore", () => { + beforeEach(() => { + resetStore() + localStorage.clear() + }) + + afterEach(() => { + localStorage.clear() + }) + + // ── Initial state ────────────────────────────────────────────────────────── + + it("starts with telemetry consent disabled (optimistic default)", () => { + expect(useConsentStore.getState().analyticsConsent).toBe(false) + expect(getAnalyticsConsent()).toBe(false) + }) + + // ── setAnalyticsConsent ──────────────────────────────────────────────────── + + it("setAnalyticsConsent(true) opt-in is persisted", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + expect(useConsentStore.getState().analyticsConsent).toBe(true) + expect(getAnalyticsConsent()).toBe(true) + }) + + it("setAnalyticsConsent(false) opt-out is respected", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + act(() => useConsentStore.getState().setAnalyticsConsent(false)) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + }) + + it("coerces non-boolean opt-in values to false (no telemetry without explicit consent)", () => { + // @ts-expect-error – intentionally passing invalid runtime input + act(() => useConsentStore.getState().setAnalyticsConsent("yes")) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + + // @ts-expect-error – intentionally passing invalid runtime input + act(() => useConsentStore.getState().setAnalyticsConsent(1)) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + + // @ts-expect-error – intentionally passing invalid runtime input + act(() => useConsentStore.getState().setAnalyticsConsent(null)) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + }) + + // ── toggleAnalyticsConsent ───────────────────────────────────────────────── + + it("toggleAnalyticsConsent flips state and returns the new value", () => { + let result1: boolean + act(() => { + result1 = useConsentStore.getState().toggleAnalyticsConsent() + }) + expect(result1!).toBe(true) + expect(useConsentStore.getState().analyticsConsent).toBe(true) + + let result2: boolean + act(() => { + result2 = useConsentStore.getState().toggleAnalyticsConsent() + }) + expect(result2!).toBe(false) + expect(useConsentStore.getState().analyticsConsent).toBe(false) + }) + + // ── persistence ──────────────────────────────────────────────────────────── + + it("persists opt-in consent to localStorage under the consent key", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + + const stored = localStorage.getItem(CONSENT_STORAGE_KEY) + expect(stored).toBeTruthy() + const parsed = JSON.parse(stored!) + expect(parsed.state.analyticsConsent).toBe(true) + }) + + it("persists opt-out so telemetry stays disabled across reloads", () => { + act(() => useConsentStore.getState().setAnalyticsConsent(true)) + act(() => useConsentStore.getState().setAnalyticsConsent(false)) + + const stored = localStorage.getItem(CONSENT_STORAGE_KEY) + const parsed = JSON.parse(stored!) + expect(parsed.state.analyticsConsent).toBe(false) + }) + + // ── parseConsent boundary inputs ─────────────────────────────────────────── + + it("parseConsent accepts only the literal boolean true", () => { + expect(parseConsent(true)).toBe(true) + expect(parseConsent(false)).toBe(false) + }) + + it("parseConsent rejects every coercion-flavoured value", () => { + expect(parseConsent("true")).toBe(false) + expect(parseConsent("1")).toBe(false) + expect(parseConsent(1)).toBe(false) + expect(parseConsent(0)).toBe(false) + expect(parseConsent("")).toBe(false) + expect(parseConsent(null)).toBe(false) + expect(parseConsent(undefined)).toBe(false) + expect(parseConsent([])).toBe(false) + expect(parseConsent({})).toBe(false) + expect(parseConsent("TRUE")).toBe(false) + expect(parseConsent(1 as unknown)).toBe(false) + }) +}) \ No newline at end of file diff --git a/app/state/consent.ts b/app/state/consent.ts new file mode 100644 index 00000000..146bab9f --- /dev/null +++ b/app/state/consent.ts @@ -0,0 +1,99 @@ +/** + * consent.ts + * + * Client-side store for the user's usage-analytics consent preference. + * Gated in optimistic-default style: consent is `false` until the user + * explicitly opts in from the privacy settings page. All recommendation + * telemetry is suppressed while consent is `false`. + * + * Invariants + * ---------- + * - `analyticsConsent` is a strict boolean. Any invalid value written to + * localStorage (e.g. a corrupted payload) is coerced via `parseConsent` + * so it can never put the store into an inconsistent state. + * - Consent defaults to `false` ("no telemetry") and can only become `true` + * through an explicit user action (`setAnalyticsConsent`). + * - Reads and writes are failure-tolerant: a blocked or throwing + * localStorage never crashes the app — telemetry simply stays disabled. + */ + +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; + +export const CONSENT_STORAGE_KEY = "predictify-consent"; + +interface ConsentState { + /** Whether the user has opted in to usage analytics. */ + analyticsConsent: boolean; + /** Set the consent value. Call from an explicit opt-in/opt-out UI action. */ + setAnalyticsConsent: (consent: boolean) => void; + /** Toggle consent; returns the new value. */ + toggleAnalyticsConsent: () => boolean; +} + +/** + * Coerce an unknown persisted value into a strict boolean. Anything that is + * not exactly `true` maps to `false` so the optimistic-default invariant + * always holds, even against corrupted or malicious localStorage payloads. + */ +export function parseConsent(value: unknown): boolean { + return value === true; +} + +/** + * A no-op storage used on the server (or when localStorage is unavailable) + * so persistence never throws during SSR or privacy-restricted sessions. + */ +const noopStorage = { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, +}; + +function createSafeStorage(): ReturnType { + if (typeof window === "undefined") { + return createJSONStorage(() => noopStorage); + } + try { + return createJSONStorage(() => window.localStorage); + } catch { + return createJSONStorage(() => noopStorage); + } +} + +export const useConsentStore = create()( + persist( + (set, get) => ({ + analyticsConsent: false, + + setAnalyticsConsent: (consent) => + set({ analyticsConsent: parseConsent(consent) }), + + toggleAnalyticsConsent: () => { + const next = !get().analyticsConsent; + set({ analyticsConsent: next }); + return next; + }, + }), + { + name: CONSENT_STORAGE_KEY, + storage: createSafeStorage(), + merge: (persisted, current) => ({ + ...current, + ...(persisted as Partial), + // Enforce the strict-boolean invariant on any persisted payload so a + // stale or corrupted value can never turn telemetry on without an + // explicit opt-in. + analyticsConsent: parseConsent( + (persisted as Partial | undefined)?.analyticsConsent + ), + }), + partialize: (state) => ({ analyticsConsent: state.analyticsConsent }), + } + ) +); + +/** Non-hook accessor for reads outside React (e.g. before render). */ +export function getAnalyticsConsent(): boolean { + return parseConsent(useConsentStore.getState().analyticsConsent); +} \ No newline at end of file diff --git a/components/dashboard/RecommendationsStrip.tsx b/components/dashboard/RecommendationsStrip.tsx index d5ad41d0..6a05b575 100644 --- a/components/dashboard/RecommendationsStrip.tsx +++ b/components/dashboard/RecommendationsStrip.tsx @@ -13,6 +13,11 @@ import { getTopCategories, type RecommendedMarket, } from "@/lib/recommendations" +import { + trackRecommendation, + trackRecommendationImpression, +} from "@/lib/telemetry" +import { useConsentStore } from "@/app/state/consent" import { mockActiveBets } from "@/lib/mock-data" import type { Bet, CategoryColor } from "@/lib/types" @@ -28,10 +33,19 @@ interface RecommendationsStripProps { * category chip — this is a lower-commitment discovery surface, not a bet * the user is tracking. */ -function RecommendationCard({ market }: { market: RecommendedMarket }) { +function RecommendationCard({ + market, + position, + onSelect, +}: { + market: RecommendedMarket + position: number + onSelect: (market: RecommendedMarket, position: number) => void +}) { return ( onSelect(market, position)} className={cn( "flex-shrink-0 snap-start rounded-xl border border-border/30 bg-card/20 p-4", "w-[calc(100vw-3rem)] max-w-[260px] sm:w-[260px]", @@ -108,10 +122,33 @@ export function RecommendationsStrip({ bets = mockActiveBets, className }: Recom const scrollContainerRef = useRef(null) const [canScrollLeft, setCanScrollLeft] = useState(false) const [canScrollRight, setCanScrollRight] = useState(false) + const analyticsConsent = useConsentStore((s) => s.analyticsConsent) const categories = manualCategories ?? getTopCategories(bets) const markets = useMemo(() => getRecommendedMarkets(categories), [categories]) + // Consent-gated impression telemetry: fires once per rendered market set. + // When consent is revoked, no further events are emitted. + useEffect(() => { + if (!analyticsConsent) return + markets.forEach((market, index) => { + trackRecommendationImpression({ + marketId: market.id, + category: market.category, + position: index, + }) + }) + }, [markets, analyticsConsent]) + + const handleSelectMarket = (market: RecommendedMarket, position: number) => { + trackRecommendation({ + type: "recommendation_click", + marketId: market.id, + category: market.category, + position, + }) + } + const updateScrollState = () => { const el = scrollContainerRef.current if (!el) return @@ -184,8 +221,13 @@ export function RecommendationsStrip({ bets = mockActiveBets, className }: Recom role="region" aria-label="Markets you might like carousel" > - {markets.map((market) => ( - + {markets.map((market, index) => ( + ))} diff --git a/components/dashboard/__tests__/RecommendationsStrip.test.tsx b/components/dashboard/__tests__/RecommendationsStrip.test.tsx index fa9d4085..8c10fd2a 100644 --- a/components/dashboard/__tests__/RecommendationsStrip.test.tsx +++ b/components/dashboard/__tests__/RecommendationsStrip.test.tsx @@ -1,6 +1,8 @@ import React from "react" -import { render, screen, fireEvent } from "@testing-library/react" +import { render, screen, fireEvent, act } from "@testing-library/react" import { RecommendationsStrip } from "../RecommendationsStrip" +import { setTelemetrySink } from "@/lib/telemetry" +import { useConsentStore } from "@/app/state/consent" import type { Bet } from "@/lib/types" function betIn(color: Bet["category"]["color"]): Pick { @@ -94,4 +96,92 @@ describe("RecommendationsStrip", () => { expect(screen.getByRole("region", { name: /carousel/i })).toHaveAttribute("tabIndex", "0") }) }) + + describe("consent-gated telemetry", () => { + const sink = jest.fn() + + beforeEach(() => { + sink.mockClear() + setTelemetrySink(sink) + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + }) + + afterEach(() => { + setTelemetrySink(null) + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + }) + + it("emits no telemetry while analytics consent is disabled", () => { + render() + expect(screen.getByRole("region", { name: /carousel/i })).toBeInTheDocument() + expect(sink).not.toHaveBeenCalled() + }) + + it("emits one impression per recommended market after consent is granted", () => { + act(() => { + useConsentStore.setState({ analyticsConsent: true }) + }) + render() + + const impressions = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_impression" + ) + expect(impressions.length).toBeGreaterThan(0) + impressions.forEach(([event]) => { + expect(event).toMatchObject({ + type: "recommendation_impression", + category: "crypto", + }) + expect(typeof event.marketId).toBe("string") + expect(event.marketId.length).toBeGreaterThan(0) + expect(event.sessionId).toBeTruthy() + }) + }) + + it("emits a click event with position when a recommendation card is selected", () => { + act(() => { + useConsentStore.setState({ analyticsConsent: true }) + }) + render() + + const card = screen.getAllByRole("link")[0] + fireEvent.click(card) + + const clickEvents = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_click" + ) + expect(clickEvents.length).toBe(1) + expect(clickEvents[0][0]).toMatchObject({ + type: "recommendation_click", + category: "crypto", + position: 0, + }) + expect(clickEvents[0][0].marketId).toBeTruthy() + }) + + it("stops emitting impressions once consent is revoked", () => { + act(() => { + useConsentStore.setState({ analyticsConsent: true }) + }) + const { unmount } = render() + const impressionsBefore = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_impression" + ) + expect(impressionsBefore.length).toBeGreaterThan(0) + + act(() => { + useConsentStore.setState({ analyticsConsent: false }) + }) + const impressionsAfter = sink.mock.calls.filter( + ([event]) => event.type === "recommendation_impression" + ) + // Revoking consent never adds events; the count is unchanged. + expect(impressionsAfter.length).toBe(impressionsBefore.length) + unmount() + }) + }) }) diff --git a/lib/__tests__/telemetry.test.ts b/lib/__tests__/telemetry.test.ts new file mode 100644 index 00000000..5e51a370 --- /dev/null +++ b/lib/__tests__/telemetry.test.ts @@ -0,0 +1,309 @@ +/** + * telemetry.test.ts + * + * Unit tests for lib/telemetry.ts — the consent-gated recommendation + * telemetry primitive. Covers consent gating, strict input validation, + * boundary clamping, duplicate suppression, sink failure recovery, and + * determinism of the emitted event shape. + */ + +import { act } from "@testing-library/react" +import { useConsentStore } from "@/app/state/consent" +import { + trackRecommendation, + trackRecommendationImpression, + sanitizeTelemetryEvent, + setTelemetrySink, + resetTelemetrySession, + MAX_MARKET_ID_LENGTH, + MAX_CATEGORY_LENGTH, +} from "../telemetry" + +const sink = jest.fn() + +function grantConsent(enabled: boolean) { + act(() => { + useConsentStore.setState({ analyticsConsent: enabled }) + }) +} + +describe("sanitizeTelemetryEvent", () => { + beforeEach(() => { + grantConsent(true) + resetTelemetrySession() + }) + + it("builds a well-formed event for valid input", () => { + const event = sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "rec-crypto-1", + category: "crypto", + position: 2, + }) + expect(event).not.toBeNull() + expect(event!.type).toBe("recommendation_click") + expect(event!.marketId).toBe("rec-crypto-1") + expect(event!.category).toBe("crypto") + expect(event!.position).toBe(2) + expect(typeof event!.timestamp).toBe("number") + expect(typeof event!.sessionId).toBe("string") + expect(event!.sessionId.length).toBeGreaterThan(0) + }) + + it("rejects non-object input", () => { + expect(sanitizeTelemetryEvent(null)).toBeNull() + expect(sanitizeTelemetryEvent(undefined)).toBeNull() + expect(sanitizeTelemetryEvent("event")).toBeNull() + expect(sanitizeTelemetryEvent(42)).toBeNull() + }) + + it("rejects unknown event types", () => { + expect( + sanitizeTelemetryEvent({ type: "page_view", marketId: "x" }) + ).toBeNull() + }) + + it("rejects missing, empty, or whitespace-only marketId", () => { + expect(sanitizeTelemetryEvent({ type: "recommendation_click" })).toBeNull() + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "" }) + ).toBeNull() + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: " " }) + ).toBeNull() + }) + + it("trims and bounds the marketId and category lengths", () => { + const event = sanitizeTelemetryEvent({ + type: "recommendation_impression", + marketId: ` ${"a".repeat(MAX_MARKET_ID_LENGTH + 50)} `, + category: ` ${"b".repeat(MAX_CATEGORY_LENGTH + 50)} `, + }) + expect(event!.marketId).toBe("a".repeat(MAX_MARKET_ID_LENGTH)) + expect(event!.category).toBe("b".repeat(MAX_CATEGORY_LENGTH)) + }) + + it("drops the category when it is missing or collapses to empty", () => { + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x" }) + ?.category + ).toBeUndefined() + expect( + sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "x", + category: " ", + })?.category + ).toBeUndefined() + expect( + sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "x", + category: 7, + })?.category + ).toBeUndefined() + }) + + it("clamps position into [0, MAX_POSITION] deterministically", () => { + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 0 })! + .position + ).toBe(0) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 3.9 })! + .position + ).toBe(3) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: -5 })! + .position + ).toBe(0) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: 100000 })! + .position + ).toBe(999) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: NaN })! + .position + ).toBe(0) + expect( + sanitizeTelemetryEvent({ type: "recommendation_click", marketId: "x", position: Infinity })! + .position + ).toBe(0) + }) + + it("falls back to the current timestamp for invalid timestamps", () => { + const before = Date.now() + const event = sanitizeTelemetryEvent({ + type: "recommendation_click", + marketId: "x", + timestamp: -1, + }) + expect(event!.timestamp).toBeGreaterThanOrEqual(before) + }) +}) + +describe("trackRecommendation (consent gate)", () => { + beforeEach(() => { + sink.mockClear() + setTelemetrySink(sink) + resetTelemetrySession() + grantConsent(false) + }) + + afterEach(() => { + setTelemetrySink(null) + grantConsent(false) + }) + + it("emits nothing and returns false when consent is disabled", () => { + const result = trackRecommendation({ + type: "recommendation_click", + marketId: "rec-crypto-1", + }) + expect(result).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("emits a valid event and returns true when consent is granted", () => { + grantConsent(true) + const result = trackRecommendation({ + type: "recommendation_click", + marketId: "rec-crypto-1", + category: "crypto", + position: 4, + }) + expect(result).toBe(true) + expect(sink).toHaveBeenCalledTimes(1) + const dispatched = sink.mock.calls[0][0] + expect(dispatched.marketId).toBe("rec-crypto-1") + expect(dispatched.category).toBe("crypto") + expect(dispatched.position).toBe(4) + // No sensitive data is ever forwarded (there is no identity field). + expect(dispatched).not.toHaveProperty("wallet") + expect(dispatched).not.toHaveProperty("userId") + expect(dispatched).not.toHaveProperty("address") + }) + + it("does not dispatch invalid input even when consent is granted", () => { + grantConsent(true) + expect(trackRecommendation({ type: "recommendation_click" })).toBe(false) + expect(trackRecommendation({ type: "recommendation_click", marketId: "" })).toBe(false) + expect(trackRecommendation("garbage")).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("is deterministic for duplicate click input (clicks are not de-duplicated)", () => { + grantConsent(true) + const input = { type: "recommendation_click" as const, marketId: "rec-crypto-1" } + expect(trackRecommendation(input)).toBe(true) + expect(trackRecommendation(input)).toBe(true) + expect(sink).toHaveBeenCalledTimes(2) + }) + + it("swallows sink failures so callers are never broken", () => { + grantConsent(true) + const error = new Error("sink is down") + setTelemetrySink(() => { + throw error + }) + const spy = jest.spyOn(console, "error").mockImplementation(() => {}) + let result: boolean + expect(() => { + result = trackRecommendation({ type: "recommendation_click", marketId: "x" }) + }).not.toThrow() + expect(result!).toBe(true) + expect(spy).toHaveBeenCalled() + spy.mockRestore() + }) + + it("recovers on the next call after a transient sink failure", () => { + grantConsent(true) + let shouldThrow = true + setTelemetrySink(() => { + if (shouldThrow) throw new Error("boom") + }) + const spy = jest.spyOn(console, "error").mockImplementation(() => {}) + const input = { type: "recommendation_click" as const, marketId: "x" } + expect(trackRecommendation(input)).toBe(true) + shouldThrow = false + expect(trackRecommendation(input)).toBe(true) + spy.mockRestore() + }) +}) + +describe("trackRecommendationImpression (dedup)", () => { + beforeEach(() => { + sink.mockClear() + setTelemetrySink(sink) + resetTelemetrySession() + }) + + afterEach(() => { + setTelemetrySink(null) + grantConsent(false) + }) + + it("dispatches an impression for each distinct market in the session", () => { + grantConsent(true) + expect( + trackRecommendationImpression({ marketId: "rec-crypto-1", category: "crypto", position: 0 }) + ).toBe(true) + expect( + trackRecommendationImpression({ marketId: "rec-football-1", category: "football", position: 1 }) + ).toBe(true) + expect(sink).toHaveBeenCalledTimes(2) + }) + + it("suppresses a duplicate impression of the same market in the same session", () => { + grantConsent(true) + const input = { marketId: "rec-crypto-1", category: "crypto", position: 0 } + expect(trackRecommendationImpression(input)).toBe(true) + expect(trackRecommendationImpression(input)).toBe(false) + expect(sink).toHaveBeenCalledTimes(1) + }) + + it("is suppressed entirely without consent and never reaches the sink", () => { + grantConsent(false) + expect( + trackRecommendationImpression({ marketId: "rec-crypto-1" }) + ).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("does not dedup invalid impressions (they are simply rejected)", () => { + grantConsent(true) + expect(trackRecommendationImpression({ marketId: "" })).toBe(false) + expect(trackRecommendationImpression({ marketId: " " })).toBe(false) + expect(sink).not.toHaveBeenCalled() + }) + + it("a fresh session re-enables impressions for the same market", () => { + grantConsent(true) + const input = { marketId: "rec-crypto-1", category: "crypto", position: 0 } + expect(trackRecommendationImpression(input)).toBe(true) + resetTelemetrySession() + expect(trackRecommendationImpression(input)).toBe(true) + expect(sink).toHaveBeenCalledTimes(2) + }) + + it("keeps the dedup set size-bounded (oldest evicted past the cap)", () => { + grantConsent(true) + const marketIds = Array.from({ length: 300 }, (_, i) => `market-${i}`) + marketIds.forEach((marketId, i) => { + trackRecommendationImpression({ marketId, position: i % 6 }) + }) + // Cap is 256 → the first 300-256=44 keys were evicted. + expect(trackRecommendationImpression({ marketId: "market-0" })).toBe(true) + expect(trackRecommendationImpression({ marketId: "market-299" })).toBe(false) + }) + + it("does not corrupt the store or sink state after a batch of impressions", () => { + grantConsent(true) + for (let i = 0; i < 50; i++) { + trackRecommendationImpression({ marketId: `m-${i % 5}`, position: i }) + } + // Only 5 unique markets are seen even after 50 calls. + expect(sink).toHaveBeenCalledTimes(5) + expect(useConsentStore.getState().analyticsConsent).toBe(true) + }) +}) \ No newline at end of file diff --git a/lib/telemetry.ts b/lib/telemetry.ts new file mode 100644 index 00000000..2d0577fe --- /dev/null +++ b/lib/telemetry.ts @@ -0,0 +1,206 @@ +/** + * telemetry.ts + * + * Recommendation telemetry, gated on the user's usage-analytics consent. + * + * Every event emitted from here is (1) validated and sanitized against a + * strict schema, (2) suppressed entirely while consent is `false`, and + * (3) forwarded to a pluggable sink that never receives sensitive data + * (no wallet addresses, no user identity — only market/category metadata). + * + * Invariants + * ---------- + * - No telemetry is emitted unless `getAnalyticsConsent()` is true. + * - Invalid input is rejected (returns `false`) and never reaches the sink. + * - Boundary inputs are clamped deterministically (see sanitize* helpers). + * - Sink failures are swallowed: a throwing sink never crashes the caller, + * and telemetry silently degrades to a no-op instead of failing the UI. + * - Impression duplicates within the same session are de-duplicated and the + * dedup set is size-bounded so memory cannot grow without limit. + */ + +import { getAnalyticsConsent } from "@/app/state/consent" + +export type RecommendationEventType = + | "recommendation_impression" + | "recommendation_click" + +export interface RecommendationTelemetryInput { + type: RecommendationEventType + marketId: string + category?: string + position?: number + timestamp?: number +} + +export interface RecommendationTelemetryEvent { + type: RecommendationEventType + marketId: string + category?: string + position: number + timestamp: number + /** Auto-generated, stable for the lifetime of the tab (page session). */ + sessionId: string +} + +export const MAX_MARKET_ID_LENGTH = 128 +export const MAX_CATEGORY_LENGTH = 32 +export const MAX_POSITION = 999 +export const IMPRESSION_DEDUP_CAP = 256 + +type TelemetrySink = (event: RecommendationTelemetryEvent) => void + +/** Default: dev-only console output; production emits nothing until wired to a real sink. */ +const defaultSink: TelemetrySink = (event) => { + if (process.env.NODE_ENV !== "production") { + console.debug("[telemetry]", event) + } +} + +let sink: TelemetrySink = defaultSink + +/** Replace the sink (used to point telemetry at a real backend or a test spy). */ +export function setTelemetrySink(next: TelemetrySink | null): void { + sink = next ?? defaultSink +} + +export function getTelemetrySink(): TelemetrySink { + return sink +} + +/** Non-sensitive module-scoped page-session id, stable per module lifetime. */ +let sessionId = createSessionId() + +function createSessionId(): string { + return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}` +} + +/** Reset module-scoped state (used by tests between cases). */ +export function resetTelemetrySession(): void { + sessionId = createSessionId() + seenImpressions.clear() + impressionFifo = [] +} + +// ── sanitization ───────────────────────────────────────────────────────────── + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function isEventType(value: unknown): value is RecommendationEventType { + return value === "recommendation_impression" || value === "recommendation_click" +} + +function sanitizeString(value: unknown, maxLength: number): string | undefined { + if (typeof value !== "string") return undefined + const trimmed = value.trim().slice(0, maxLength) + return trimmed.length > 0 ? trimmed : undefined +} + +/** Position is clamped into [0, MAX_POSITION]; invalid input defaults to 0. */ +function sanitizePosition(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return 0 + } + return Math.min(Math.floor(value), MAX_POSITION) +} + +function sanitizeTimestamp(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return Date.now() + } + return Math.floor(value) +} + +/** + * Validate and coerce arbitrary input into a well-formed telemetry event. + * Returns `null` for invalid input so callers can deterministically treat + * malformed events as "not emitted". Never throws. + */ +export function sanitizeTelemetryEvent( + input: unknown +): RecommendationTelemetryEvent | null { + if (!isRecord(input)) return null + + const type = input.type + if (!isEventType(type)) return null + + const marketId = sanitizeString(input.marketId, MAX_MARKET_ID_LENGTH) + if (!marketId) return null + + const category = sanitizeString(input.category, MAX_CATEGORY_LENGTH) + + return { + type, + marketId, + category, + position: sanitizePosition(input.position), + timestamp: sanitizeTimestamp(input.timestamp), + sessionId, + } +} + +// ── impression de-duplication (bounded, FIFO) ─────────────────────────────── + +const seenImpressions = new Set() +let impressionFifo: string[] = [] + +function rememberImpression(key: string): void { + if (seenImpressions.size >= IMPRESSION_DEDUP_CAP) { + const oldest = impressionFifo.shift() + if (oldest !== undefined) seenImpressions.delete(oldest) + } + seenImpressions.add(key) + impressionFifo.push(key) +} + +/** + * Emit a recommendation telemetry event. + * + * - Returns `true` when the event was dispatched to the sink. + * - Returns `false` when consent is missing or the input is invalid — the + * caller can rely on this for deterministic behaviour in tests/retries. + * + * Sink errors are swallowed so telemetry can never break the caller. + */ +export function trackRecommendation( + input: RecommendationTelemetryInput | unknown +): boolean { + if (!getAnalyticsConsent()) return false + + const event = sanitizeTelemetryEvent(input) + if (!event) return false + + try { + sink(event) + } catch (error) { + // Failure-tolerant by design: an unreachable/slow/erroring sink must not + // corrupt UI state or crash the client. Log locally without payload data. + console.error("[telemetry] sink error", error instanceof Error ? error.message : "unknown") + } + return true +} + +/** + * Emit a recommendation *impression*, de-duplicated per market per session. + * A second impression of the same market in the same tab is suppressed so + * re-renders / retries cannot double-count. + */ +export function trackRecommendationImpression( + input: Omit | unknown +): boolean { + if (!getAnalyticsConsent()) return false + + const event = sanitizeTelemetryEvent({ + ...(isRecord(input) ? input : {}), + type: "recommendation_impression", + }) + if (!event) return false + + const key = `${event.sessionId}:${event.marketId}` + if (seenImpressions.has(key)) return false + + rememberImpression(key) + return trackRecommendation(event) +} \ No newline at end of file From 2b163203c7af2d5964cb020de3f491ddcd579290 Mon Sep 17 00:00:00 2001 From: blizzj Date: Mon, 31 Aug 2026 12:32:00 +0100 Subject: [PATCH 2/2] chore: set up SSH-linked GitHub account --- package.json | 8 ++++---- pnpm-lock.yaml | 22 +++++++++++----------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index a2f2a476..a7337d68 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "dependencies": { "@creit.tech/stellar-wallets-kit": "^1.7.3", "@hookform/resolvers": "^3.9.1", - "canvas-confetti": "^1.9.3", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-alert-dialog": "^1.1.4", "@radix-ui/react-aspect-ratio": "^1.1.1", @@ -54,6 +53,7 @@ "@radix-ui/react-tooltip": "^1.1.6", "@tanstack/react-virtual": "^3.10.8", "autoprefixer": "^10.4.20", + "canvas-confetti": "^1.9.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -82,10 +82,10 @@ }, "devDependencies": { "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^6.1.4", + "@testing-library/jest-dom": "^6", + "@testing-library/react": "^16", + "@testing-library/user-event": "^14", "@types/canvas-confetti": "^1.6.4", - "@testing-library/react": "^16.0.0", - "@testing-library/user-event": "^14.5.1", "@types/jest": "^29.5.8", "@types/node": "^22", "@types/qrcode": "^1.5.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32654fda..3fa37b02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,13 +184,13 @@ importers: specifier: ^10.4.1 version: 10.4.1 '@testing-library/jest-dom': - specifier: ^6.1.4 + specifier: ^6 version: 6.9.1 '@testing-library/react': - specifier: ^16.0.0 + specifier: ^16 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@testing-library/user-event': - specifier: ^14.5.1 + specifier: ^14 version: 14.6.1(@testing-library/dom@10.4.1) '@types/canvas-confetti': specifier: ^1.6.4 @@ -9926,8 +9926,8 @@ snapshots: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1) @@ -9946,7 +9946,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 @@ -9957,22 +9957,22 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.62.0(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9983,7 +9983,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.62.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3