From 952d65710a7c5c83cea84a0df1f7bd574100d8db Mon Sep 17 00:00:00 2001 From: ety001 Date: Sun, 30 Aug 2026 22:50:31 +0800 Subject: [PATCH 1/6] feat(analytics): restore Google Analytics and overseer frontend tracking Match wallet-legacy gtag injection and overseer.collect route/user_action/login events, relaying overseer through the server so browsers do not hit api.steemit.com. --- .env.example | 10 + docker/docker-compose.yml | 5 + src/app/[locale]/layout.tsx | 6 + src/app/api/analytics/overseer/route.ts | 130 ++++++++ src/app/api/auth/login/route.ts | 5 + src/components/analytics/google-analytics.tsx | 59 ++++ .../analytics/overseer-page-tracker.tsx | 28 ++ src/components/layout/app-layout.tsx | 2 + .../wallet/cancel-power-down-handler.tsx | 2 + .../wallet/change-password-section.tsx | 2 + src/components/wallet/delegate-form.tsx | 8 + src/components/wallet/power-down-form.tsx | 6 + src/components/wallet/power-up-form.tsx | 8 + .../recover-account-confirmation-page.tsx | 2 + .../wallet/savings-withdraw-history.tsx | 2 + src/components/wallet/transfer-form.tsx | 18 ++ src/components/wallet/witness-vote-form.tsx | 4 + src/lib/analytics/ga-id.ts | 32 ++ src/lib/analytics/overseer-payload.ts | 298 ++++++++++++++++++ src/lib/analytics/overseer.ts | 75 +++++ src/lib/steem/server.ts | 30 ++ src/proxy.ts | 7 +- tests/unit/ga-id.test.ts | 41 +++ tests/unit/google-analytics.test.tsx | 48 +++ tests/unit/overseer-client.test.ts | 73 +++++ tests/unit/overseer-page-tracker.test.tsx | 65 ++++ tests/unit/overseer-payload.test.ts | 261 +++++++++++++++ tests/unit/overseer-route.test.ts | 135 ++++++++ tests/unit/proxy-csp.test.ts | 4 + tests/unit/steem-server.test.ts | 30 ++ 30 files changed, 1394 insertions(+), 2 deletions(-) create mode 100644 src/app/api/analytics/overseer/route.ts create mode 100644 src/components/analytics/google-analytics.tsx create mode 100644 src/components/analytics/overseer-page-tracker.tsx create mode 100644 src/lib/analytics/ga-id.ts create mode 100644 src/lib/analytics/overseer-payload.ts create mode 100644 src/lib/analytics/overseer.ts create mode 100644 tests/unit/ga-id.test.ts create mode 100644 tests/unit/google-analytics.test.tsx create mode 100644 tests/unit/overseer-client.test.ts create mode 100644 tests/unit/overseer-page-tracker.test.tsx create mode 100644 tests/unit/overseer-payload.test.ts create mode 100644 tests/unit/overseer-route.test.ts diff --git a/.env.example b/.env.example index d7873d01..e15d8143 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,16 @@ STEEM_RPC_URL=https://api.steemit.com # Optional: Mixpanel analytics (client-side token) # NEXT_PUBLIC_MIXPANEL_TOKEN=your-mixpanel-token +# Optional: Google Analytics measurement ID (gtag). Read at runtime by the +# server layout (no rebuild required). Legacy: SDC_GOOGLE_ANALYTICS_ID. +# GOOGLE_ANALYTICS_ID=G-XXXXXXXX +# SDC_GOOGLE_ANALYTICS_ID=G-XXXXXXXX + +# Optional: overseer whale thresholds (legacy config steem_whale / sbd_whale). +# Used when relaying user_action events. Defaults: 10000 STEEM / 500 SBD. +# STEEM_WHALE=10000 +# SBD_WHALE=500 + # Optional: Rate limiting (Redis-backed when REDIS_URL is set; per-process # in-memory fallback otherwise — not shared across instances). # RATE_LIMIT_ALLOW_MEMORY_FALLBACK=false # reject (503) instead of falling back diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1f25ddd4..146e0401 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -37,6 +37,11 @@ services: # Analytics - MIXPANEL_TOKEN=${MIXPANEL_TOKEN:-} - ANALYTICS_ENABLED=${ANALYTICS_ENABLED:-false} + # Runtime gtag ID (legacy SDC_GOOGLE_ANALYTICS_ID). Empty = script not injected. + - GOOGLE_ANALYTICS_ID=${GOOGLE_ANALYTICS_ID:-} + - SDC_GOOGLE_ANALYTICS_ID=${SDC_GOOGLE_ANALYTICS_ID:-} + - STEEM_WHALE=${STEEM_WHALE:-10000} + - SBD_WHALE=${SBD_WHALE:-500} # Rate Limiting - RATE_LIMIT_ENABLED=${RATE_LIMIT_ENABLED:-true} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index e06664a1..a595a27e 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -2,10 +2,13 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; +import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import { routing } from '@/i18n/routing'; import { Providers } from '../providers'; import { AppLayout } from '@/components/layout/app-layout'; +import { GoogleAnalytics } from '@/components/analytics/google-analytics'; +import { getGaMeasurementId } from '@/lib/analytics/ga-id'; import '../globals.css'; const geistSans = Geist({ @@ -55,12 +58,15 @@ export default async function LocaleLayout({ // Providing all messages to the client // side is the easiest way to get started const messages = await getMessages(); + const gaId = getGaMeasurementId(); + const nonce = (await headers()).get('x-nonce') ?? undefined; return ( + {gaId ? : null} {children} diff --git a/src/app/api/analytics/overseer/route.ts b/src/app/api/analytics/overseer/route.ts new file mode 100644 index 00000000..b1a58ade --- /dev/null +++ b/src/app/api/analytics/overseer/route.ts @@ -0,0 +1,130 @@ +// POST /api/analytics/overseer +// Relay frontend overseer.collect events (route tags + user actions). +// Shape/allowlist only — the chain-side overseer service is the sink. +import { NextRequest, NextResponse } from 'next/server'; +import { verifyCSRF, rateLimit } from '@/lib/middleware'; +import { SteemService } from '@/lib/steem/server'; +import { + buildRoutePayload, + buildUserActionPayload, + isOverseerRouteTag, + isOverseerUserAction, + isSteemAccountName, + isTrackingId, + whaleThresholdsFromEnv, + type UserActionParams, +} from '@/lib/analytics/overseer-payload'; + +const MAX_STRING = 64; +const MAX_AMOUNT = 1e15; + +function asOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_STRING) return undefined; + return trimmed; +} + +function asOptionalAccount(value: unknown): string | undefined { + const s = asOptionalString(value); + if (!s) return undefined; + const name = s.replace(/^@/, '').toLowerCase(); + return isSteemAccountName(name) ? name : undefined; +} + +function asOptionalAmount(value: unknown): string | number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= MAX_AMOUNT) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed || trimmed.length > MAX_STRING) return undefined; + const n = parseFloat(trimmed.split(' ')[0] ?? ''); + if (!Number.isFinite(n) || n < 0 || n > MAX_AMOUNT) return undefined; + return trimmed; + } + return undefined; +} + +function sanitizeActionParams(raw: unknown): UserActionParams { + if (!raw || typeof raw !== 'object') return {}; + const o = raw as Record; + const params: UserActionParams = {}; + const username = asOptionalAccount(o.username); + const from = asOptionalAccount(o.from); + const to = asOptionalAccount(o.to); + const witness = asOptionalAccount(o.witness); + // Proxy may be empty string (clear proxy). Allow '' or a valid account. + let proxy: string | undefined; + if (typeof o.proxy === 'string') { + const p = o.proxy.trim().replace(/^@/, '').toLowerCase(); + if (p === '') proxy = ''; + else if (isSteemAccountName(p)) proxy = p; + } + const transferCoin = asOptionalString(o.transferCoin); + const amount = asOptionalAmount(o.amount); + if (username !== undefined) params.username = username; + if (from !== undefined) params.from = from; + if (to !== undefined) params.to = to; + if (witness !== undefined) params.witness = witness; + if (proxy !== undefined) params.proxy = proxy; + if (transferCoin !== undefined) params.transferCoin = transferCoin; + if (amount !== undefined) params.amount = amount; + return params; +} + +export async function POST(request: NextRequest) { + try { + const csrfError = await verifyCSRF(request); + if (csrfError) return csrfError; + + const rateLimitError = await rateLimit(request, 'analytics', { + maxRequests: 100, + windowSeconds: 60, + }); + if (rateLimitError) return rateLimitError; + + const body = (await request.json()) as Record; + const kind = body.kind; + + if (kind === 'route') { + const tag = typeof body.tag === 'string' ? body.tag : ''; + const trackingId = typeof body.trackingId === 'string' ? body.trackingId : ''; + if (!isOverseerRouteTag(tag) || !isTrackingId(trackingId)) { + return NextResponse.json({ error: 'Invalid route event' }, { status: 400 }); + } + const accountname = asOptionalAccount( + body.params && typeof body.params === 'object' + ? (body.params as Record).accountname + : undefined + ); + const payload = buildRoutePayload( + trackingId, + tag, + accountname ? { accountname } : undefined, + body.isLogin === true + ); + await SteemService.collectOverseer(payload); + return NextResponse.json({ success: true }); + } + + if (kind === 'action') { + const action = typeof body.action === 'string' ? body.action : ''; + if (!isOverseerUserAction(action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + const payload = buildUserActionPayload( + action, + sanitizeActionParams(body.params), + whaleThresholdsFromEnv() + ); + await SteemService.collectOverseer(payload); + return NextResponse.json({ success: true }); + } + + return NextResponse.json({ error: 'Invalid event kind' }, { status: 400 }); + } catch (error) { + console.error('Overseer analytics error:', error); + return NextResponse.json({ success: true }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 35d3c805..78632c85 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { SteemService } from '@/lib/steem/server'; import { verifyCSRF, rateLimit } from '@/lib/middleware'; import { getRedis, redisKey } from '@/lib/cache/redis'; +import { buildUserLoginPayload } from '@/lib/analytics/overseer-payload'; export async function POST(request: NextRequest) { try { @@ -102,6 +103,10 @@ export async function POST(request: NextRequest) { ); } + // Legacy `/login_account` checkpoint: overseer measurement `user_login`. + // Do not await — a slow/missing overseer must not delay the session response. + void SteemService.collectOverseer(buildUserLoginPayload(account.name)); + // Return success with account info return NextResponse.json({ success: true, diff --git a/src/components/analytics/google-analytics.tsx b/src/components/analytics/google-analytics.tsx new file mode 100644 index 00000000..17635fdd --- /dev/null +++ b/src/components/analytics/google-analytics.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useEffect } from 'react'; +import Script from 'next/script'; +import { usePathname } from '@/i18n/routing'; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + } +} + +/** + * gtag loader + SPA pageviews. Matches wallet-legacy: + * - `server-html.jsx` injects gtag/js + config + * - `JsPlugins.js` also sets cookie_domain: auto and sample_rate: 5 + * + * Initial config uses send_page_view: false so the pathname effect is the + * single source of pageviews (avoids double-counting the first load). + */ +export function GoogleAnalytics({ + measurementId, + nonce, +}: { + measurementId: string; + nonce?: string; +}) { + const pathname = usePathname(); + + useEffect(() => { + if (typeof window.gtag !== 'function') return; + window.gtag('config', measurementId, { page_path: pathname }); + }, [pathname, measurementId]); + + const scriptProps = nonce ? { nonce } : {}; + + return ( + <> + + + ); +} diff --git a/src/components/analytics/overseer-page-tracker.tsx b/src/components/analytics/overseer-page-tracker.tsx new file mode 100644 index 00000000..75026823 --- /dev/null +++ b/src/components/analytics/overseer-page-tracker.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import { usePathname } from '@/i18n/routing'; +import type { RootState } from '@/lib/store'; +import { recordRouteTag } from '@/lib/analytics/overseer'; +import { routeTagFromPathname } from '@/lib/analytics/overseer-payload'; + +/** + * Legacy parity: each page dispatched `setRouteTag`, which saga'd into + * `overseer.collect` measurement `route`. One listener covers App Router navigations. + */ +export function OverseerPageTracker() { + const pathname = usePathname(); + const isLogin = useSelector((state: RootState) => state.auth.isAuthenticated); + const last = useRef(null); + + useEffect(() => { + const key = `${pathname}|${isLogin ? '1' : '0'}`; + if (last.current === key) return; + last.current = key; + const mapped = routeTagFromPathname(pathname); + recordRouteTag(mapped.tag, mapped.params, isLogin); + }, [pathname, isLogin]); + + return null; +} diff --git a/src/components/layout/app-layout.tsx b/src/components/layout/app-layout.tsx index 4cd30ec5..4c7e0900 100644 --- a/src/components/layout/app-layout.tsx +++ b/src/components/layout/app-layout.tsx @@ -4,6 +4,7 @@ import { Header } from './header'; import { SidePanel } from './side-panel'; import { TooltipProvider } from '@/components/ui/tooltip'; import { DegradationBanner } from './degradation-banner'; +import { OverseerPageTracker } from '@/components/analytics/overseer-page-tracker'; import { useState } from 'react'; import { Toaster } from 'sonner'; @@ -13,6 +14,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) { return (
+
setSidePanelOpen(true)} /> diff --git a/src/components/wallet/cancel-power-down-handler.tsx b/src/components/wallet/cancel-power-down-handler.tsx index 68029752..e7c55e7e 100644 --- a/src/components/wallet/cancel-power-down-handler.tsx +++ b/src/components/wallet/cancel-power-down-handler.tsx @@ -6,6 +6,7 @@ import { useSelector } from 'react-redux'; import type { RootState } from '@/lib/store'; import { useActiveSigningKey } from '@/hooks/use-auth'; import { SteemSigner, apiClient } from '@/lib/steem/client'; +import { userActionRecord } from '@/lib/analytics/overseer'; export interface CancelPowerDownHandlerProps { onSuccess: () => void; @@ -33,6 +34,7 @@ export function CancelPowerDownHandler({ onSuccess, onCancel }: CancelPowerDownH setError(response.error || t('cancelError')); return; } + userActionRecord('cancel_withdraw_vesting', { username }); onSuccess(); } catch (err) { console.error('Cancel power down error:', err); diff --git a/src/components/wallet/change-password-section.tsx b/src/components/wallet/change-password-section.tsx index 73078d8d..18ae31b9 100644 --- a/src/components/wallet/change-password-section.tsx +++ b/src/components/wallet/change-password-section.tsx @@ -20,6 +20,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Skeleton } from '@/components/ui/skeleton'; +import { userActionRecord } from '@/lib/analytics/overseer'; export interface ChangePasswordSectionProps { username: string; @@ -116,6 +117,7 @@ export function ChangePasswordSection({ clearRememberedPostingKey(); setChangeSuccess(true); + userActionRecord('change_password', { username }); await logout(); } catch (err) { const message = err instanceof Error ? err.message : t('broadcastFailed'); diff --git a/src/components/wallet/delegate-form.tsx b/src/components/wallet/delegate-form.tsx index 49664ee1..d887d3e1 100644 --- a/src/components/wallet/delegate-form.tsx +++ b/src/components/wallet/delegate-form.tsx @@ -18,6 +18,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params'; +import { userActionRecord } from '@/lib/analytics/overseer'; export type DelegateFormVariant = 'page' | 'dialog'; @@ -87,6 +88,13 @@ export function DelegateForm({ return; } + userActionRecord('delegate_vesting_shares', { + transferCoin: 'VESTS', + amount: shareValue, + from: username, + to: delegatee.trim().replace(/^@/, '').toLowerCase(), + }); + setIsLoading(false); startTransition(() => { if (onSuccess) onSuccess(); diff --git a/src/components/wallet/power-down-form.tsx b/src/components/wallet/power-down-form.tsx index bd6bbf24..718f63f1 100644 --- a/src/components/wallet/power-down-form.tsx +++ b/src/components/wallet/power-down-form.tsx @@ -33,6 +33,7 @@ import { import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params'; import { cn } from '@/lib/utils'; +import { userActionRecord } from '@/lib/analytics/overseer'; export type PowerDownFormVariant = 'page' | 'dialog'; @@ -139,6 +140,11 @@ export function PowerDownForm({ variant = 'page', onSuccess }: PowerDownFormProp return; } + userActionRecord('withdraw_vesting', { + username, + amount: steemPowerFromVests(withdraw, globalProps), + }); + finishSuccess(); } catch (err) { console.error('Power down error:', err); diff --git a/src/components/wallet/power-up-form.tsx b/src/components/wallet/power-up-form.tsx index c21993c5..b98dbc4d 100644 --- a/src/components/wallet/power-up-form.tsx +++ b/src/components/wallet/power-up-form.tsx @@ -17,6 +17,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { transfersPathForUsername } from '@/lib/wallet/wallet-modal-search-params'; +import { userActionRecord } from '@/lib/analytics/overseer'; export type PowerUpFormVariant = 'page' | 'dialog'; @@ -152,6 +153,13 @@ export function PowerUpForm({ return; } + userActionRecord('transfer_to_vesting', { + transferCoin: 'STEEM', + amount: amountValue, + from: username, + to: recipientName, + }); + finishSuccess(); } catch (err) { console.error('Power up error:', err); diff --git a/src/components/wallet/recover-account-confirmation-page.tsx b/src/components/wallet/recover-account-confirmation-page.tsx index bff9802f..7c80d66c 100644 --- a/src/components/wallet/recover-account-confirmation-page.tsx +++ b/src/components/wallet/recover-account-confirmation-page.tsx @@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { apiClient, SteemSigner } from '@/lib/steem/client'; +import { userActionRecord } from '@/lib/analytics/overseer'; function passwordToOwnerPubKey(username: string, password: string): string { const raw = password.trim(); @@ -128,6 +129,7 @@ export function RecoverAccountConfirmationPage({ code }: { code: string }) { } setSuccess(true); + userActionRecord('recovery_account', { username: name }); } catch (err) { setSubmitError(err instanceof Error ? err.message : t('unknownError')); } finally { diff --git a/src/components/wallet/savings-withdraw-history.tsx b/src/components/wallet/savings-withdraw-history.tsx index ce67d031..db4cfc37 100644 --- a/src/components/wallet/savings-withdraw-history.tsx +++ b/src/components/wallet/savings-withdraw-history.tsx @@ -8,6 +8,7 @@ import { cachedFetch } from '@/lib/cache/client-fetch'; import { clientCache } from '@/lib/cache/client-cache'; import { formatTimeUntil } from '@/lib/wallet/format-time-ago'; import type { PendingSavingsWithdrawal } from '@/hooks/use-wallet-estimated-value'; +import { userActionRecord } from '@/lib/analytics/overseer'; import { AlertDialog, AlertDialogCancel, @@ -81,6 +82,7 @@ export function SavingsWithdrawHistory({ setCancelling(false); return; } + userActionRecord('cancel_transfer_from_savings', { username }); setCancelTarget(null); setCancelling(false); clientCache.invalidate(extrasUrl); diff --git a/src/components/wallet/transfer-form.tsx b/src/components/wallet/transfer-form.tsx index 3e5adbee..8c20089a 100644 --- a/src/components/wallet/transfer-form.tsx +++ b/src/components/wallet/transfer-form.tsx @@ -30,6 +30,7 @@ import { transfersPathForUsername, type WalletTransferType, } from '@/lib/wallet/wallet-modal-search-params'; +import { userActionRecord } from '@/lib/analytics/overseer'; export type TransferFormVariant = 'page' | 'dialog'; @@ -347,6 +348,23 @@ export function TransferForm({ return; } + const recipient = + transferType === 'transfer' + ? formData.to.trim().replace(/^@/, '').toLowerCase() + : username; + const overseerAction = + transferType === 'transfer' + ? 'transfer' + : transferType === 'savings' + ? 'transfer_to_savings' + : 'transfer_from_savings'; + userActionRecord(overseerAction, { + transferCoin: amountSuffix, + amount: amountValue, + from: username, + to: recipient, + }); + setIsLoading(false); startTransition(() => { if (onSuccess) { diff --git a/src/components/wallet/witness-vote-form.tsx b/src/components/wallet/witness-vote-form.tsx index ddc441be..b7530fc6 100644 --- a/src/components/wallet/witness-vote-form.tsx +++ b/src/components/wallet/witness-vote-form.tsx @@ -11,6 +11,7 @@ import type { Witness } from '@/lib/steem/types'; import { LoginForm } from '@/components/auth/login-form'; import { DISABLED_SIGNING_KEY } from '@/lib/steem/constants'; import { toast } from 'sonner'; +import { userActionRecord } from '@/lib/analytics/overseer'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -158,6 +159,7 @@ export function WitnessVoteForm() { (approve ? t('voteError') : t('unvoteError')) ); } + userActionRecord('account_witness_vote', { username, witness: witnessName }); }, [t, username] ); @@ -215,6 +217,7 @@ export function WitnessVoteForm() { const signedTx = await SteemSigner.signWitnessProxy(username, nextProxy, key); const resp = await apiClient.broadcastWitnessProxy(signedTx, username); if (!resp.success) throw new Error(resp.error || tCommon('error')); + userActionRecord('account_witness_proxy', { username, proxy: nextProxy }); setProxyInput(''); toast.success(nextProxy ? t('proxySetSuccess') : t('proxyClearedSuccess')); await refetchAccount(); @@ -255,6 +258,7 @@ export function WitnessVoteForm() { const signedTx = await SteemSigner.signWitnessProxy(username, pendingAction.proxy, key); const resp = await apiClient.broadcastWitnessProxy(signedTx, username); if (!resp.success) throw new Error(resp.error || tCommon('error')); + userActionRecord('account_witness_proxy', { username, proxy: pendingAction.proxy }); toast.success(pendingAction.proxy ? t('proxySetSuccess') : t('proxyClearedSuccess')); } await refetchAccount(); diff --git a/src/lib/analytics/ga-id.ts b/src/lib/analytics/ga-id.ts new file mode 100644 index 00000000..3febd262 --- /dev/null +++ b/src/lib/analytics/ga-id.ts @@ -0,0 +1,32 @@ +/** + * Google Analytics measurement ID helpers. + * + * Legacy injected gtag from `google_analytics_id` / `SDC_GOOGLE_ANALYTICS_ID` + * at render time (not a build-time public env). Read the ID on the server so + * production can change it without rebuilding the Next.js client bundle. + */ + +const GA_ID_RE = /^(G|GT|AW)-[A-Z0-9]+$|^UA-\d+-\d+$/i; + +export function isValidGaMeasurementId(id: string): boolean { + return GA_ID_RE.test(id.trim()); +} + +/** + * Resolve the configured GA measurement ID, or null if unset/invalid. + * Checked in order: GOOGLE_ANALYTICS_ID, SDC_GOOGLE_ANALYTICS_ID (legacy ops), + * NEXT_PUBLIC_GOOGLE_ANALYTICS_ID (build-time fallback). + */ +export function getGaMeasurementId(): string | null { + const candidates = [ + process.env.GOOGLE_ANALYTICS_ID, + process.env.SDC_GOOGLE_ANALYTICS_ID, + process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID, + ]; + for (const raw of candidates) { + if (typeof raw !== 'string') continue; + const id = raw.trim(); + if (id && isValidGaMeasurementId(id)) return id; + } + return null; +} diff --git a/src/lib/analytics/overseer-payload.ts b/src/lib/analytics/overseer-payload.ts new file mode 100644 index 00000000..0a194a02 --- /dev/null +++ b/src/lib/analytics/overseer-payload.ts @@ -0,0 +1,298 @@ +/** + * Overseer payload builders matching wallet-legacy ServerApiClient.js. + * + * Legacy called `api.call('overseer.collect', ['custom', { measurement, fields, tags }])` + * from the browser (jussi routes `overseer.*` to the overseer service). The rewrite + * reconstructs the same payload on the server and relays it so browsers do not + * talk to api.steemit.com directly. + * + * Whale lookup lowercases `transferCoin` (`STEEM` → `steem`). Legacy compared + * against `whaleThreshold[params.transferCoin]` whose keys were lowercase, so + * STEEM/SBD transfers never tagged as whale. That was a bug; the config intent + * (`steem_whale` / `sbd_whale`) is preserved here. + */ + +export const OVERSEER_USER_ACTIONS = [ + 'transfer', + 'change_password', + 'recovery_account', + 'withdraw_vesting', + 'cancel_withdraw_vesting', + 'cancel_transfer_from_savings', + 'transfer_to_vesting', + 'transfer_to_savings', + 'transfer_from_savings', + 'delegate_vesting_shares', + 'account_witness_vote', + 'account_witness_proxy', +] as const; + +export type OverseerUserAction = (typeof OVERSEER_USER_ACTIONS)[number]; + +export const OVERSEER_ROUTE_TAGS = [ + 'index', + 'login', + 'market', + 'proposals', + 'vote_to_witness', + 'privacy', + 'tos', + 'faq', + 'about', + 'support', + 'recover_account_step1', + 'recover_account_step2', + 'user_index', + 'change_password', + 'not_found', +] as const; + +export type OverseerRouteTag = (typeof OVERSEER_ROUTE_TAGS)[number]; + +export type OverseerTags = Record; +export type OverseerFields = Record; + +export interface OverseerCustomPayload { + measurement: string; + tags: OverseerTags; + fields: OverseerFields; +} + +export interface WhaleThresholds { + steem: number; + sbd: number; +} + +export const DEFAULT_WHALE_THRESHOLDS: WhaleThresholds = { + steem: 10000, + sbd: 500, +}; + +export interface UserActionParams { + username?: string; + from?: string; + to?: string; + amount?: string | number; + transferCoin?: string; + witness?: string; + proxy?: string; +} + +export interface RouteTagParams { + accountname?: string; +} + +const ACTION_SET: ReadonlySet = new Set(OVERSEER_USER_ACTIONS); +const TAG_SET: ReadonlySet = new Set(OVERSEER_ROUTE_TAGS); + +export function isOverseerUserAction(value: string): value is OverseerUserAction { + return ACTION_SET.has(value); +} + +export function isOverseerRouteTag(value: string): value is OverseerRouteTag { + return TAG_SET.has(value); +} + +/** Steem account names are 3–16 chars: leading letter, then [a-z0-9.-]. */ +export const STEEM_ACCOUNT_RE = /^[a-z][a-z0-9.-]{2,15}$/; + +export function isSteemAccountName(value: string): boolean { + return STEEM_ACCOUNT_RE.test(value); +} + +/** Legacy session uid: 13 random bytes as hex (26 chars). Allow 8–32 hex. */ +export const TRACKING_ID_RE = /^[a-f0-9]{8,32}$/; + +export function isTrackingId(value: string): boolean { + return TRACKING_ID_RE.test(value); +} + +export function amountNumber(amount: string | number | undefined): number { + if (typeof amount === 'number') return Number.isFinite(amount) ? amount : 0; + if (typeof amount !== 'string') return 0; + const raw = parseFloat(amount.trim().split(' ')[0] ?? ''); + return Number.isFinite(raw) ? raw : 0; +} + +function whaleFlag(amount: string | number | undefined, coin: string | undefined, thresholds: WhaleThresholds): string { + if (!coin) return 'false'; + const key = coin.toLowerCase(); + const threshold = key === 'steem' ? thresholds.steem : key === 'sbd' ? thresholds.sbd : undefined; + if (threshold === undefined) return 'false'; + return (amountNumber(amount) > threshold).toString(); +} + +/** + * Static routes first (several of these strings are also valid Steem account + * names, e.g. `market`). Remaining `/` paths map to `user_index`. + */ +const EXACT_ROUTE_TAGS: Record = { + '/': 'index', + '/login': 'login', + '/market': 'market', + '/proposals': 'proposals', + '/witnesses': 'vote_to_witness', + '/privacy': 'privacy', + '/tos': 'tos', + '/faq': 'faq', + '/about': 'about', + '/support': 'support', + '/recover_account_step_1': 'recover_account_step1', +}; + +export function routeTagFromPathname(pathname: string): { + tag: OverseerRouteTag; + params?: RouteTagParams; +} { + const path = (pathname.replace(/\/+$/, '') || '/') as string; + const exact = EXACT_ROUTE_TAGS[path]; + if (exact) return { tag: exact }; + + if (/^\/account_recovery_confirmation\/[^/]+$/.test(path)) { + return { tag: 'recover_account_step2' }; + } + + const userMatch = /^\/([^/]+)(?:\/(.*))?$/.exec(path); + const account = userMatch?.[1]; + if (userMatch && account && isSteemAccountName(account)) { + const rest = userMatch[2]; + if (rest === 'settings') { + return { tag: 'change_password', params: { accountname: account } }; + } + return { tag: 'user_index', params: { accountname: account } }; + } + + return { tag: 'not_found' }; +} + +export function buildRoutePayload( + trackingId: string, + tag: OverseerRouteTag, + params: RouteTagParams | undefined, + isLogin: boolean +): OverseerCustomPayload { + const tags: OverseerTags = { + app: 'wallet', + tag, + is_login: isLogin, + }; + const fields: OverseerFields = + tag === 'user_index' && params?.accountname + ? { trackingId, permlink: params.accountname } + : { trackingId }; + return { measurement: 'route', tags, fields }; +} + +export function buildUserActionPayload( + action: OverseerUserAction, + params: UserActionParams, + thresholds: WhaleThresholds = DEFAULT_WHALE_THRESHOLDS +): OverseerCustomPayload { + let tags: OverseerTags = { + app: 'wallet', + action_type: action, + }; + let fields: OverseerFields = {}; + + switch (action) { + case 'transfer': + tags = { + app: 'wallet', + action_type: action, + transfer_coin: String(params.transferCoin ?? ''), + whale: whaleFlag(params.amount, params.transferCoin, thresholds), + }; + fields = { + from_username: String(params.from ?? ''), + to_username: String(params.to ?? ''), + amount: amountNumber(params.amount), + }; + break; + case 'change_password': + fields = { username: String(params.username ?? '') }; + break; + case 'recovery_account': + fields = { username: String(params.username ?? '') }; + break; + case 'withdraw_vesting': + tags = { + app: 'wallet', + action_type: action, + whale: whaleFlag(params.amount, 'steem', thresholds), + }; + fields = { + username: String(params.username ?? ''), + amount: amountNumber(params.amount), + }; + break; + case 'cancel_withdraw_vesting': + tags = { app: 'wallet', action_type: action }; + fields = { username: String(params.username ?? '') }; + break; + case 'cancel_transfer_from_savings': + fields = { username: String(params.username ?? '') }; + break; + case 'transfer_to_vesting': + tags = { + app: 'wallet', + action_type: action, + whale: whaleFlag(params.amount, 'steem', thresholds), + }; + fields = { + from_username: String(params.from ?? ''), + to_username: String(params.to ?? ''), + amount: amountNumber(params.amount), + }; + break; + case 'transfer_to_savings': + case 'transfer_from_savings': + case 'delegate_vesting_shares': + tags = { + app: 'wallet', + action_type: action, + transfer_coin: String(params.transferCoin ?? ''), + whale: whaleFlag(params.amount, params.transferCoin, thresholds), + }; + fields = { + from_username: String(params.from ?? ''), + to_username: String(params.to ?? ''), + amount: amountNumber(params.amount), + }; + break; + case 'account_witness_vote': + fields = { + username: String(params.username ?? ''), + witness: String(params.witness ?? ''), + }; + break; + case 'account_witness_proxy': + fields = { + username: String(params.username ?? ''), + proxy: String(params.proxy ?? ''), + }; + break; + } + + return { measurement: 'user_action', tags, fields }; +} + +export function buildUserLoginPayload(username: string): OverseerCustomPayload { + return { + measurement: 'user_login', + tags: { entry: 'wallet' }, + fields: { username }, + }; +} + +export function whaleThresholdsFromEnv(): WhaleThresholds { + return { + steem: parsePositiveNumber(process.env.STEEM_WHALE, DEFAULT_WHALE_THRESHOLDS.steem), + sbd: parsePositiveNumber(process.env.SBD_WHALE, DEFAULT_WHALE_THRESHOLDS.sbd), + }; +} + +function parsePositiveNumber(raw: string | undefined, fallback: number): number { + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : fallback; +} diff --git a/src/lib/analytics/overseer.ts b/src/lib/analytics/overseer.ts new file mode 100644 index 00000000..e4025559 --- /dev/null +++ b/src/lib/analytics/overseer.ts @@ -0,0 +1,75 @@ +'use client'; + +/** + * Client helpers for overseer frontend tracking (legacy ServerApiClient). + * + * Events POST to /api/analytics/overseer (CSRF + rate limit); the server + * reconstructs the legacy payload and relays `overseer.collect` to jussi. + * Failures are swallowed — analytics must never break wallet actions. + */ + +import type { OverseerRouteTag, OverseerUserAction, RouteTagParams, UserActionParams } from './overseer-payload'; + +const TRACKING_ID_KEY = 'wallet_tracking_id'; + +function csrfHeader(): Record { + if (typeof document === 'undefined') return {}; + const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]*)/); + const token = match?.[1] ? decodeURIComponent(match[1]) : null; + return token ? { 'X-CSRF-Token': token } : {}; +} + +/** + * Anonymous visitor id (legacy `session.uid`: 13 random bytes as hex). + * Persisted in localStorage because this app has no server session cookie. + */ +export function getTrackingId(): string { + if (typeof window === 'undefined') return ''; + try { + const existing = localStorage.getItem(TRACKING_ID_KEY); + if (existing && /^[a-f0-9]{8,32}$/.test(existing)) return existing; + const bytes = new Uint8Array(13); + crypto.getRandomValues(bytes); + let hex = ''; + for (const b of bytes) hex += b.toString(16).padStart(2, '0'); + localStorage.setItem(TRACKING_ID_KEY, hex); + return hex; + } catch { + return ''; + } +} + +async function postOverseer(body: unknown): Promise { + try { + await fetch('/api/analytics/overseer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...csrfHeader(), + }, + body: JSON.stringify(body), + }); + } catch { + // Swallow — never surface analytics errors to the user. + } +} + +export function recordRouteTag( + tag: OverseerRouteTag, + params: RouteTagParams | undefined, + isLogin = false +): void { + const trackingId = getTrackingId(); + if (!trackingId) return; + void postOverseer({ + kind: 'route', + tag, + trackingId, + isLogin, + ...(params ? { params } : {}), + }); +} + +export function userActionRecord(action: OverseerUserAction, params: UserActionParams): void { + void postOverseer({ kind: 'action', action, params }); +} diff --git a/src/lib/steem/server.ts b/src/lib/steem/server.ts index 9cd58318..35ada017 100644 --- a/src/lib/steem/server.ts +++ b/src/lib/steem/server.ts @@ -6,6 +6,7 @@ import { steem } from '@steemit/steem-js'; import { formatSteemIsoTimestamp } from '@/lib/steem/chain-time'; +import type { OverseerCustomPayload } from '@/lib/analytics/overseer-payload'; import { ORDERBOOK_LIMIT, RECENT_TRADES_LIMIT, @@ -44,6 +45,7 @@ const STEEM_RPC_URLS = (process.env.STEEM_RPC_URL || 'https://api.steemit.com') .filter(Boolean); let currentUrlIndex = 0; +let overseerWarned = false; function getCurrentRpcUrl(): string { return STEEM_RPC_URLS[currentUrlIndex % STEEM_RPC_URLS.length] ?? STEEM_RPC_URLS[0]!; } @@ -998,6 +1000,34 @@ export class SteemService { }); } + /** + * Relay an overseer.collect custom event through the Steem RPC (jussi). + * Fire-and-forget from callers: never throws, logs at most once per process + * so a missing overseer upstream cannot flood logs or fail user flows. + */ + static async collectOverseer(payload: OverseerCustomPayload): Promise { + try { + await withFailover(async () => { + ensureConfigured(); + const api = steem.api as unknown as { + callAsync?: (method: string, params: unknown) => Promise; + }; + if (typeof api.callAsync !== 'function') { + throw new Error('Steem API callAsync is not available'); + } + await api.callAsync('overseer.collect', ['custom', payload]); + }); + } catch (err) { + if (!overseerWarned) { + overseerWarned = true; + console.warn( + 'overseer.collect failed; further errors suppressed:', + err instanceof Error ? err.message : err + ); + } + } + } + static async listProposalVotesByVoter(voter: string): Promise< { voter: string; diff --git a/src/proxy.ts b/src/proxy.ts index c5ed52d9..7613a8f2 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -25,7 +25,10 @@ function buildCsp(nonce: string): string { const devExtras = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''; return [ "default-src 'self'", - `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${devExtras}`, + // Host allowlists after 'strict-dynamic' are ignored by supporting browsers + // (the nonce'd gtag loader can fetch further scripts). They remain as a + // fallback for older browsers, matching wallet-legacy helmet scriptSrc. + `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https://www.googletagmanager.com https://www.google-analytics.com${devExtras}`, "style-src 'self' 'unsafe-inline'", // profile_image/cover_image are arbitrary URLs from on-chain metadata, so image hosts // cannot be allowlisted by name; legacy allowed `imgSrc: *` for the same reason. @@ -33,7 +36,7 @@ function buildCsp(nonce: string): string { // by upgrade-insecure-requests below. "img-src 'self' blob: data: https:", "font-src 'self'", - "connect-src 'self'", + "connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://www.googletagmanager.com https://analytics.google.com https://*.analytics.google.com", "object-src 'none'", "base-uri 'self'", "form-action 'self'", diff --git a/tests/unit/ga-id.test.ts b/tests/unit/ga-id.test.ts new file mode 100644 index 00000000..e3c64168 --- /dev/null +++ b/tests/unit/ga-id.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { getGaMeasurementId, isValidGaMeasurementId } from '@/lib/analytics/ga-id'; + +describe('getGaMeasurementId', () => { + afterEach(() => { + delete process.env.GOOGLE_ANALYTICS_ID; + delete process.env.SDC_GOOGLE_ANALYTICS_ID; + delete process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID; + }); + + it('returns null when unset', () => { + delete process.env.GOOGLE_ANALYTICS_ID; + delete process.env.SDC_GOOGLE_ANALYTICS_ID; + delete process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID; + expect(getGaMeasurementId()).toBeNull(); + }); + + it('prefers GOOGLE_ANALYTICS_ID over the legacy and NEXT_PUBLIC names', () => { + process.env.GOOGLE_ANALYTICS_ID = 'G-PRIMARY'; + process.env.SDC_GOOGLE_ANALYTICS_ID = 'G-LEGACY'; + process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID = 'G-PUBLIC'; + expect(getGaMeasurementId()).toBe('G-PRIMARY'); + }); + + it('falls back to SDC_GOOGLE_ANALYTICS_ID', () => { + process.env.SDC_GOOGLE_ANALYTICS_ID = 'UA-1-1'; + expect(getGaMeasurementId()).toBe('UA-1-1'); + }); + + it('ignores an invalid value and tries the next candidate', () => { + process.env.GOOGLE_ANALYTICS_ID = 'not-valid'; + process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID = 'G-OKAY'; + expect(getGaMeasurementId()).toBe('G-OKAY'); + }); + + it('trims whitespace', () => { + process.env.GOOGLE_ANALYTICS_ID = ' G-TRIMMED '; + expect(isValidGaMeasurementId(' G-TRIMMED ')).toBe(true); + expect(getGaMeasurementId()).toBe('G-TRIMMED'); + }); +}); diff --git a/tests/unit/google-analytics.test.tsx b/tests/unit/google-analytics.test.tsx new file mode 100644 index 00000000..3daf4c25 --- /dev/null +++ b/tests/unit/google-analytics.test.tsx @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; + +vi.mock('next/script', () => ({ + default: function Script({ + children, + src, + id, + }: { + children?: string; + src?: string; + id?: string; + }) { + return ( + + ); + }, +})); + +const mockPathname = vi.fn(() => '/market'); +vi.mock('@/i18n/routing', () => ({ + usePathname: () => mockPathname(), +})); + +import { GoogleAnalytics } from '@/components/analytics/google-analytics'; + +describe('GoogleAnalytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + window.dataLayer = []; + window.gtag = vi.fn(); + mockPathname.mockReturnValue('/market'); + }); + + it('loads gtag.js for the measurement id', () => { + render(); + expect( + document.querySelector('script[src="https://www.googletagmanager.com/gtag/js?id=G-TESTID"]') + ).toBeTruthy(); + }); + + it('sends a virtual pageview on pathname', () => { + render(); + expect(window.gtag).toHaveBeenCalledWith('config', 'G-TESTID', { page_path: '/market' }); + }); +}); diff --git a/tests/unit/overseer-client.test.ts b/tests/unit/overseer-client.test.ts new file mode 100644 index 00000000..979fd177 --- /dev/null +++ b/tests/unit/overseer-client.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getTrackingId, recordRouteTag, userActionRecord } from '@/lib/analytics/overseer'; + +describe('overseer client helpers', () => { + beforeEach(() => { + localStorage.clear(); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ({ success: true }) }) + ); + document.cookie = 'csrf_token=test-csrf'; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + localStorage.clear(); + }); + + it('generates a 26-char hex tracking id and reuses it', () => { + const first = getTrackingId(); + expect(first).toMatch(/^[a-f0-9]{26}$/); + expect(getTrackingId()).toBe(first); + expect(localStorage.getItem('wallet_tracking_id')).toBe(first); + }); + + it('POSTs a route event with CSRF', () => { + const id = getTrackingId(); + recordRouteTag('market', undefined, true); + expect(fetch).toHaveBeenCalledWith( + '/api/analytics/overseer', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'X-CSRF-Token': 'test-csrf', + }), + }) + ); + const body = JSON.parse( + (vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit).body as string + ) as Record; + expect(body).toEqual({ + kind: 'route', + tag: 'market', + trackingId: id, + isLogin: true, + }); + }); + + it('POSTs a user action event', () => { + userActionRecord('transfer', { + transferCoin: 'STEEM', + amount: 1, + from: 'alice', + to: 'bob', + }); + const body = JSON.parse( + (vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit).body as string + ) as Record; + expect(body).toEqual({ + kind: 'action', + action: 'transfer', + params: { transferCoin: 'STEEM', amount: 1, from: 'alice', to: 'bob' }, + }); + }); + + it('swallows fetch errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('offline')); + expect(() => userActionRecord('change_password', { username: 'alice' })).not.toThrow(); + await Promise.resolve(); + }); +}); diff --git a/tests/unit/overseer-page-tracker.test.tsx b/tests/unit/overseer-page-tracker.test.tsx new file mode 100644 index 00000000..6ae2d48b --- /dev/null +++ b/tests/unit/overseer-page-tracker.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { configureStore } from '@reduxjs/toolkit'; +import authReducer, { setCredentials } from '@/lib/store/slices/auth'; +import walletReducer from '@/lib/store/slices/wallet'; +import uiReducer from '@/lib/store/slices/ui'; + +const recordRouteTag = vi.fn(); +vi.mock('@/lib/analytics/overseer', () => ({ + recordRouteTag: (...args: unknown[]) => recordRouteTag(...args), +})); + +vi.mock('@/i18n/routing', () => ({ + usePathname: () => mockPathname(), +})); + +const mockPathname = vi.fn(() => '/market'); + +import { OverseerPageTracker } from '@/components/analytics/overseer-page-tracker'; + +function makeStore(loggedIn: boolean) { + const store = configureStore({ + reducer: { auth: authReducer, wallet: walletReducer, ui: uiReducer }, + }); + if (loggedIn) { + store.dispatch( + setCredentials({ + username: 'alice', + postingKey: '5J', + }) + ); + } + return store; +} + +describe('OverseerPageTracker', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPathname.mockReturnValue('/market'); + }); + + it('records the mapped route tag on mount', () => { + render( + + + + ); + expect(recordRouteTag).toHaveBeenCalledWith('market', undefined, false); + }); + + it('passes isLogin when authenticated', () => { + mockPathname.mockReturnValue('/alice/transfers'); + render( + + + + ); + expect(recordRouteTag).toHaveBeenCalledWith( + 'user_index', + { accountname: 'alice' }, + true + ); + }); +}); diff --git a/tests/unit/overseer-payload.test.ts b/tests/unit/overseer-payload.test.ts new file mode 100644 index 00000000..990a87cd --- /dev/null +++ b/tests/unit/overseer-payload.test.ts @@ -0,0 +1,261 @@ +import { describe, it, expect } from 'vitest'; +import { isValidGaMeasurementId } from '@/lib/analytics/ga-id'; +import { + amountNumber, + buildRoutePayload, + buildUserActionPayload, + buildUserLoginPayload, + isOverseerRouteTag, + isOverseerUserAction, + isSteemAccountName, + isTrackingId, + routeTagFromPathname, + whaleThresholdsFromEnv, + DEFAULT_WHALE_THRESHOLDS, +} from '@/lib/analytics/overseer-payload'; + +describe('isValidGaMeasurementId', () => { + it.each(['G-ABCDEF1234', 'UA-123456-1', 'GT-XXXX', 'AW-999'])( + 'accepts %s', + (id) => { + expect(isValidGaMeasurementId(id)).toBe(true); + } + ); + + it.each(['', 'G-', "G-ABC';alert(1)", 'not-an-id', 'https://evil'])( + 'rejects %s', + (id) => { + expect(isValidGaMeasurementId(id)).toBe(false); + } + ); +}); + +describe('routeTagFromPathname', () => { + it('maps static pages to legacy route tags', () => { + expect(routeTagFromPathname('/')).toEqual({ tag: 'index' }); + expect(routeTagFromPathname('/login')).toEqual({ tag: 'login' }); + expect(routeTagFromPathname('/market')).toEqual({ tag: 'market' }); + expect(routeTagFromPathname('/proposals')).toEqual({ tag: 'proposals' }); + expect(routeTagFromPathname('/witnesses')).toEqual({ tag: 'vote_to_witness' }); + expect(routeTagFromPathname('/privacy')).toEqual({ tag: 'privacy' }); + expect(routeTagFromPathname('/tos')).toEqual({ tag: 'tos' }); + expect(routeTagFromPathname('/faq')).toEqual({ tag: 'faq' }); + expect(routeTagFromPathname('/about')).toEqual({ tag: 'about' }); + expect(routeTagFromPathname('/support')).toEqual({ tag: 'support' }); + expect(routeTagFromPathname('/recover_account_step_1')).toEqual({ + tag: 'recover_account_step1', + }); + }); + + it('does not treat static first segments as accounts (market is a valid name)', () => { + expect(routeTagFromPathname('/market')).toEqual({ tag: 'market' }); + }); + + it('maps account paths to user_index with permlink', () => { + expect(routeTagFromPathname('/alice')).toEqual({ + tag: 'user_index', + params: { accountname: 'alice' }, + }); + expect(routeTagFromPathname('/alice/transfers')).toEqual({ + tag: 'user_index', + params: { accountname: 'alice' }, + }); + expect(routeTagFromPathname('/alice.sub/delegations')).toEqual({ + tag: 'user_index', + params: { accountname: 'alice.sub' }, + }); + }); + + it('maps settings to change_password (legacy ChangePassword mount tag)', () => { + expect(routeTagFromPathname('/alice/settings')).toEqual({ + tag: 'change_password', + params: { accountname: 'alice' }, + }); + }); + + it('maps recovery confirmation to recover_account_step2', () => { + expect(routeTagFromPathname('/account_recovery_confirmation/abc')).toEqual({ + tag: 'recover_account_step2', + }); + }); + + it('maps unknown paths to not_found', () => { + expect(routeTagFromPathname('/this-is-not-valid!!')).toEqual({ tag: 'not_found' }); + }); +}); + +describe('buildRoutePayload', () => { + it('includes trackingId and is_login like legacy recordRouteTag', () => { + expect(buildRoutePayload('abc123def456', 'about', undefined, false)).toEqual({ + measurement: 'route', + tags: { app: 'wallet', tag: 'about', is_login: false }, + fields: { trackingId: 'abc123def456' }, + }); + }); + + it('adds permlink for user_index', () => { + const payload = buildRoutePayload( + 'aabbccddeeff001122334455', + 'user_index', + { accountname: 'alice' }, + true + ); + expect(payload.fields).toEqual({ + trackingId: 'aabbccddeeff001122334455', + permlink: 'alice', + }); + expect(payload.tags.is_login).toBe(true); + }); +}); + +describe('buildUserActionPayload', () => { + const t = DEFAULT_WHALE_THRESHOLDS; + + it('tags STEEM transfers as whale when amount exceeds the steem threshold', () => { + const payload = buildUserActionPayload( + 'transfer', + { transferCoin: 'STEEM', amount: 10001, from: 'alice', to: 'bob' }, + t + ); + expect(payload.measurement).toBe('user_action'); + expect(payload.tags).toMatchObject({ + app: 'wallet', + action_type: 'transfer', + transfer_coin: 'STEEM', + whale: 'true', + }); + expect(payload.fields).toEqual({ + from_username: 'alice', + to_username: 'bob', + amount: 10001, + }); + }); + + it('does not tag a sub-threshold SBD transfer as whale', () => { + const payload = buildUserActionPayload( + 'transfer', + { transferCoin: 'SBD', amount: 10, from: 'alice', to: 'bob' }, + t + ); + expect(payload.tags.whale).toBe('false'); + }); + + it('parses asset strings for transfer_to_vesting amount + whale', () => { + const payload = buildUserActionPayload( + 'transfer_to_vesting', + { amount: '10001.000 STEEM', from: 'alice', to: 'alice' }, + t + ); + expect(payload.tags.whale).toBe('true'); + expect(payload.fields.amount).toBe(10001); + }); + + it('never whales VESTS delegations (no vests threshold, same as legacy)', () => { + const payload = buildUserActionPayload( + 'delegate_vesting_shares', + { transferCoin: 'VESTS', amount: 1e12, from: 'alice', to: 'bob' }, + t + ); + expect(payload.tags.whale).toBe('false'); + }); + + it('records witness vote/proxy fields', () => { + expect( + buildUserActionPayload('account_witness_vote', { + username: 'alice', + witness: 'good-witness', + }).fields + ).toEqual({ username: 'alice', witness: 'good-witness' }); + expect( + buildUserActionPayload('account_witness_proxy', { + username: 'alice', + proxy: '', + }).fields + ).toEqual({ username: 'alice', proxy: '' }); + }); + + it('records password / recovery / cancel / savings / power-down fields', () => { + expect(buildUserActionPayload('change_password', { username: 'alice' }).fields).toEqual({ + username: 'alice', + }); + expect(buildUserActionPayload('recovery_account', { username: 'alice' }).fields).toEqual({ + username: 'alice', + }); + expect( + buildUserActionPayload('cancel_withdraw_vesting', { username: 'alice' }).tags.action_type + ).toBe('cancel_withdraw_vesting'); + expect( + buildUserActionPayload('cancel_transfer_from_savings', { username: 'alice' }).fields + ).toEqual({ username: 'alice' }); + expect( + buildUserActionPayload('withdraw_vesting', { username: 'alice', amount: 12.5 }, t).fields + ).toEqual({ username: 'alice', amount: 12.5 }); + expect( + buildUserActionPayload( + 'transfer_to_savings', + { transferCoin: 'SBD', amount: 2, from: 'alice', to: 'alice' }, + t + ).tags.transfer_coin + ).toBe('SBD'); + expect( + buildUserActionPayload( + 'transfer_from_savings', + { transferCoin: 'STEEM', amount: 3, from: 'alice', to: 'alice' }, + t + ).tags.action_type + ).toBe('transfer_from_savings'); + }); +}); + +describe('buildUserLoginPayload', () => { + it('matches legacy login_account overseer checkpoint', () => { + expect(buildUserLoginPayload('alice')).toEqual({ + measurement: 'user_login', + tags: { entry: 'wallet' }, + fields: { username: 'alice' }, + }); + }); +}); + +describe('guards', () => { + it('accepts a 26-char hex tracking id (legacy 13-byte uid)', () => { + expect(isTrackingId('aabbccddeeff00112233445566')).toBe(true); + expect(isTrackingId('nope')).toBe(false); + }); + + it('validates steem account names', () => { + expect(isSteemAccountName('alice')).toBe(true); + expect(isSteemAccountName('alice.sub')).toBe(true); + expect(isSteemAccountName('ab')).toBe(false); + expect(isSteemAccountName('Alice')).toBe(false); + }); + + it('allowlists actions and route tags', () => { + expect(isOverseerUserAction('transfer')).toBe(true); + expect(isOverseerUserAction('nope')).toBe(false); + expect(isOverseerRouteTag('market')).toBe(true); + expect(isOverseerRouteTag('hack')).toBe(false); + }); + + it('amountNumber parses numbers and asset strings', () => { + expect(amountNumber(1.5)).toBe(1.5); + expect(amountNumber('2.000 STEEM')).toBe(2); + expect(amountNumber('nope')).toBe(0); + }); +}); + +describe('whaleThresholdsFromEnv', () => { + it('uses defaults when unset and ignores non-positive values', () => { + delete process.env.STEEM_WHALE; + delete process.env.SBD_WHALE; + expect(whaleThresholdsFromEnv()).toEqual(DEFAULT_WHALE_THRESHOLDS); + process.env.STEEM_WHALE = '-1'; + process.env.SBD_WHALE = '0'; + expect(whaleThresholdsFromEnv()).toEqual(DEFAULT_WHALE_THRESHOLDS); + process.env.STEEM_WHALE = '50'; + process.env.SBD_WHALE = '3'; + expect(whaleThresholdsFromEnv()).toEqual({ steem: 50, sbd: 3 }); + delete process.env.STEEM_WHALE; + delete process.env.SBD_WHALE; + }); +}); diff --git a/tests/unit/overseer-route.test.ts b/tests/unit/overseer-route.test.ts new file mode 100644 index 00000000..45292793 --- /dev/null +++ b/tests/unit/overseer-route.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/middleware', () => ({ + verifyCSRF: vi.fn().mockResolvedValue(null), + rateLimit: vi.fn().mockResolvedValue(null), +})); + +const collectOverseer = vi.fn().mockResolvedValue(undefined); +vi.mock('@/lib/steem/server', () => ({ + SteemService: { + collectOverseer: (...args: unknown[]) => collectOverseer(...args), + }, +})); + +import { POST } from '@/app/api/analytics/overseer/route'; +import { verifyCSRF, rateLimit } from '@/lib/middleware'; + +function makeRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/analytics/overseer', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': 't' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/analytics/overseer', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(verifyCSRF).mockResolvedValue(null); + vi.mocked(rateLimit).mockResolvedValue(null); + collectOverseer.mockResolvedValue(undefined); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('relays a route tag with the legacy overseer shape', async () => { + const res = await POST( + makeRequest({ + kind: 'route', + tag: 'market', + trackingId: 'aabbccddeeff00112233445566', + isLogin: true, + }) + ); + expect(res.status).toBe(200); + expect(collectOverseer).toHaveBeenCalledWith({ + measurement: 'route', + tags: { app: 'wallet', tag: 'market', is_login: true }, + fields: { trackingId: 'aabbccddeeff00112233445566' }, + }); + }); + + it('relays a transfer user action', async () => { + const res = await POST( + makeRequest({ + kind: 'action', + action: 'transfer', + params: { transferCoin: 'STEEM', amount: 1, from: 'alice', to: 'bob' }, + }) + ); + expect(res.status).toBe(200); + expect(collectOverseer).toHaveBeenCalledWith( + expect.objectContaining({ + measurement: 'user_action', + tags: expect.objectContaining({ action_type: 'transfer', transfer_coin: 'STEEM' }), + fields: { from_username: 'alice', to_username: 'bob', amount: 1 }, + }) + ); + }); + + it('rejects an unknown action (allowlist)', async () => { + const res = await POST(makeRequest({ kind: 'action', action: 'drop_table' })); + expect(res.status).toBe(400); + expect(collectOverseer).not.toHaveBeenCalled(); + }); + + it('rejects a malformed tracking id', async () => { + const res = await POST( + makeRequest({ kind: 'route', tag: 'market', trackingId: 'not-hex' }) + ); + expect(res.status).toBe(400); + expect(collectOverseer).not.toHaveBeenCalled(); + }); + + it('rejects an unknown kind', async () => { + const res = await POST(makeRequest({ kind: 'ad' })); + expect(res.status).toBe(400); + expect(collectOverseer).not.toHaveBeenCalled(); + }); + + it('accepts a string amount and an empty proxy (clear proxy)', async () => { + const res = await POST( + makeRequest({ + kind: 'action', + action: 'account_witness_proxy', + params: { username: 'alice', proxy: '' }, + }) + ); + expect(res.status).toBe(200); + expect(collectOverseer).toHaveBeenCalledWith( + expect.objectContaining({ + fields: { username: 'alice', proxy: '' }, + }) + ); + }); + + it('drops invalid account params instead of relaying them', async () => { + const res = await POST( + makeRequest({ + kind: 'action', + action: 'transfer', + params: { from: 'NOT VALID', to: 'bob', amount: 1, transferCoin: 'STEEM' }, + }) + ); + expect(res.status).toBe(200); + const payload = collectOverseer.mock.calls[0]?.[0] as { + fields: { from_username: string; to_username: string }; + }; + expect(payload.fields.from_username).toBe(''); + expect(payload.fields.to_username).toBe('bob'); + }); + + it('returns success when overseer relay throws (do not break the client)', async () => { + collectOverseer.mockRejectedValueOnce(new Error('boom')); + const res = await POST( + makeRequest({ + kind: 'route', + tag: 'index', + trackingId: 'aabbccddeeff00112233445566', + }) + ); + expect(res.status).toBe(200); + expect((await res.json()).success).toBe(true); + }); +}); diff --git a/tests/unit/proxy-csp.test.ts b/tests/unit/proxy-csp.test.ts index d6ad4f56..f33a75e3 100644 --- a/tests/unit/proxy-csp.test.ts +++ b/tests/unit/proxy-csp.test.ts @@ -29,6 +29,10 @@ describe('proxy CSP nonce', () => { expect(csp).toContain("frame-ancestors 'none'"); // Arbitrary on-chain profile/cover image URLs must load (legacy parity: imgSrc '*'). expect(csp).toContain("img-src 'self' blob: data: https:"); + // gtag (legacy google_analytics_id) needs connect + a script-src host fallback. + expect(csp).toContain('https://www.googletagmanager.com'); + expect(csp).toContain('https://www.google-analytics.com'); + expect(csp).toContain('https://*.google-analytics.com'); }); it('generates a fresh nonce per request', () => { diff --git a/tests/unit/steem-server.test.ts b/tests/unit/steem-server.test.ts index 9f1b8acd..2bb92685 100644 --- a/tests/unit/steem-server.test.ts +++ b/tests/unit/steem-server.test.ts @@ -200,6 +200,36 @@ describe('SteemService.broadcastTransaction', () => { }); }); +describe('SteemService.collectOverseer', () => { + it('calls overseer.collect with the custom payload tuple', async () => { + api.callAsync.mockResolvedValueOnce(null); + await SteemService.collectOverseer({ + measurement: 'route', + tags: { app: 'wallet', tag: 'market' }, + fields: { trackingId: 'aa' }, + }); + expect(api.callAsync).toHaveBeenCalledWith('overseer.collect', [ + 'custom', + { + measurement: 'route', + tags: { app: 'wallet', tag: 'market' }, + fields: { trackingId: 'aa' }, + }, + ]); + }); + + it('does not throw when the RPC rejects (analytics must never fail the caller)', async () => { + api.callAsync.mockRejectedValueOnce(new Error('unknown method')); + await expect( + SteemService.collectOverseer({ + measurement: 'user_login', + tags: { entry: 'wallet' }, + fields: { username: 'alice' }, + }) + ).resolves.toBeUndefined(); + }); +}); + describe('SteemService.getCurrentMedianHistoryPrice', () => { it('returns base/quote verbatim when the node provides strings', async () => { api.callAsync.mockResolvedValueOnce({ base: '1.234 SBD', quote: '5.000 STEEM' }); From d6f59f065889463ba41f342e0f8dd9eee71376a7 Mon Sep 17 00:00:00 2001 From: ety001 Date: Mon, 31 Aug 2026 01:41:47 +0800 Subject: [PATCH 2/6] fix(test): add async to next/script mock to satisfy no-sync-scripts --- tests/unit/google-analytics.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/unit/google-analytics.test.tsx b/tests/unit/google-analytics.test.tsx index 3daf4c25..167a46a6 100644 --- a/tests/unit/google-analytics.test.tsx +++ b/tests/unit/google-analytics.test.tsx @@ -12,7 +12,9 @@ vi.mock('next/script', () => ({ id?: string; }) { return ( - ); From c809edf2eddec9022d8aac836d9cd7a7300862a6 Mon Sep 17 00:00:00 2001 From: ety001 Date: Tue, 1 Sep 2026 15:50:05 +0800 Subject: [PATCH 3/6] fix(analytics): mint csrf cookie in proxy so anonymous tracking passes CSRF The csrf_token cookie was only issued by /api/auth/challenge (login flow), so anonymous visitors' overseer route events were silently rejected 403. The proxy now mints a rolling stateless HMAC token (Web Crypto, byte-identical to the Node generator) on every document response; shared CSRF_SECRET keeps it valid across instances and self-heals after secret rotation. --- src/lib/analytics/overseer.ts | 2 + src/proxy.ts | 84 ++++++++++++++++++++- tests/unit/proxy-csp.test.ts | 22 +++--- tests/unit/proxy-csrf-cookie.test.ts | 108 +++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 tests/unit/proxy-csrf-cookie.test.ts diff --git a/src/lib/analytics/overseer.ts b/src/lib/analytics/overseer.ts index e4025559..fde9c58d 100644 --- a/src/lib/analytics/overseer.ts +++ b/src/lib/analytics/overseer.ts @@ -5,6 +5,8 @@ * * Events POST to /api/analytics/overseer (CSRF + rate limit); the server * reconstructs the legacy payload and relays `overseer.collect` to jussi. + * The csrf_token cookie is issued by the proxy middleware on every document + * response, so it is available to anonymous visitors from the first page load. * Failures are swallowed — analytics must never break wallet actions. */ diff --git a/src/proxy.ts b/src/proxy.ts index 7613a8f2..f95abd0c 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -20,6 +20,73 @@ function generateNonce(): string { return btoa(binary); } +// --------------------------------------------------------------------------- +// CSRF cookie issuance. +// +// The double-submit CSRF token (see src/lib/middleware/csrf.ts) is stateless: +// `base64url(timestamp).base64url(HMAC-SHA256(CSRF_SECRET, timestamp))`. Minting +// it here on every document response means the cookie exists from the very +// first page load, so anonymous visitors (who never call /api/auth/challenge — +// previously the only issuance point) can POST analytics events. Any instance +// can verify tokens minted by any other because they share CSRF_SECRET. +// +// The middleware runs in the edge runtime, where node:crypto is unavailable; +// Web Crypto (crypto.subtle) produces the exact same HMAC-SHA256 bytes. A unit +// test asserts parity with the Node-side generator. Without a configured +// CSRF_SECRET nothing is minted — mirroring the fail-closed posture of the +// Node verifier (a per-runtime random secret would mint tokens that never +// validate anywhere). +// --------------------------------------------------------------------------- + +const CSRF_COOKIE_NAME = 'csrf_token'; +const CSRF_COOKIE_MAX_AGE = 24 * 60 * 60; // 24 hours, matches csrf.ts + +const encoder = new TextEncoder(); +let hmacKey: { secret: string; key: CryptoKey } | null = null; +let warnedMissingCsrfSecret = false; + +function getCsrfSecret(): string | null { + const envSecret = process.env.CSRF_SECRET; + if (envSecret && envSecret.trim()) return envSecret.trim(); + if (process.env.NODE_ENV === 'production' && !warnedMissingCsrfSecret) { + warnedMissingCsrfSecret = true; + console.error('CSRF_SECRET is not set — proxy mints no CSRF cookie; mutations will be rejected'); + } + return null; +} + +function toBase64url(binary: string): string { + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +async function hmacBase64url(secret: string, message: string): Promise { + if (!hmacKey || hmacKey.secret !== secret) { + hmacKey = { + secret, + key: await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign'] + ), + }; + } + const signature = await crypto.subtle.sign('HMAC', hmacKey.key, encoder.encode(message)); + let binary = ''; + for (const b of new Uint8Array(signature)) binary += String.fromCharCode(b); + return toBase64url(binary); +} + +/** Token format identical to generateCSRFToken() in src/lib/middleware/csrf.ts. */ +async function generateCsrfToken(secret: string): Promise { + const timestamp = Date.now().toString(); + // Timestamps are ASCII digits, so btoa == Buffer.from(ts, 'utf8').toString('base64'). + const tsB64 = toBase64url(timestamp); + const mac = await hmacBase64url(secret, timestamp); + return `${tsB64}.${mac}`; +} + function buildCsp(nonce: string): string { // Development needs 'unsafe-eval' for React DevTools' error stack reconstruction. const devExtras = process.env.NODE_ENV === 'development' ? " 'unsafe-eval'" : ''; @@ -61,7 +128,7 @@ function accountPathWithoutAtPrefix(pathname: string): string | null { return `/${account}${suffix}`; } -export default function proxy(request: NextRequest) { +export default async function proxy(request: NextRequest) { // Intercept health check endpoint used by ELB and OpenResty. // Must return before i18n middleware to avoid locale redirect issues. // Only expose the status; version info (docker_tag/source_commit) is omitted @@ -91,6 +158,21 @@ export default function proxy(request: NextRequest) { } const response = intlMiddleware(forIntl); response.headers.set('Content-Security-Policy', csp); + + // Rolling CSRF cookie on every document response: refreshes the 24h window + // while the user navigates and self-heals after a CSRF_SECRET rotation (one + // rejected POST at most before the next navigation re-mints). Cookie attrs + // must stay in sync with setCSRFToken() in src/lib/middleware/csrf.ts. + const csrfSecret = getCsrfSecret(); + if (csrfSecret) { + response.cookies.set(CSRF_COOKIE_NAME, await generateCsrfToken(csrfSecret), { + httpOnly: false, // readable by JS: the client mirrors it into X-CSRF-Token + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: CSRF_COOKIE_MAX_AGE, + path: '/', + }); + } return response; } diff --git a/tests/unit/proxy-csp.test.ts b/tests/unit/proxy-csp.test.ts index f33a75e3..b58ebcd5 100644 --- a/tests/unit/proxy-csp.test.ts +++ b/tests/unit/proxy-csp.test.ts @@ -18,8 +18,8 @@ function req(path: string): NextRequest { } describe('proxy CSP nonce', () => { - it('sets a CSP response header with a nonce and strict-dynamic on page responses', () => { - const res = proxy(req('/market')); + it('sets a CSP response header with a nonce and strict-dynamic on page responses', async () => { + const res = await proxy(req('/market')); const csp = res.headers.get('content-security-policy'); expect(csp).toBeTruthy(); expect(csp).toContain("script-src 'self' 'nonce-"); @@ -35,30 +35,30 @@ describe('proxy CSP nonce', () => { expect(csp).toContain('https://*.google-analytics.com'); }); - it('generates a fresh nonce per request', () => { - const csp1 = proxy(req('/market')).headers.get('content-security-policy'); - const csp2 = proxy(req('/market')).headers.get('content-security-policy'); + it('generates a fresh nonce per request', async () => { + const csp1 = (await proxy(req('/market'))).headers.get('content-security-policy'); + const csp2 = (await proxy(req('/market'))).headers.get('content-security-policy'); expect(csp1).toBeTruthy(); expect(csp1).not.toBe(csp2); }); - it('mirrors the same nonce into the request headers seen by the renderer', () => { + it('mirrors the same nonce into the request headers seen by the renderer', async () => { const request = req('/market'); - proxy(request); + await proxy(request); const nonce = request.headers.get('x-nonce'); const cspReq = request.headers.get('content-security-policy'); expect(nonce).toBeTruthy(); expect(cspReq).toContain(`'nonce-${nonce}'`); }); - it('keeps the /@account normalization working with the nonce applied', () => { + it('keeps the /@account normalization working with the nonce applied', async () => { const request = req('/@alice/transfers'); - const res = proxy(request); + const res = await proxy(request); expect(res.headers.get('content-security-policy')).toContain("'nonce-"); }); - it('skips CSP on the healthcheck short-circuit', () => { - const res = proxy(req('/.well-known/healthcheck.json')); + it('skips CSP on the healthcheck short-circuit', async () => { + const res = await proxy(req('/.well-known/healthcheck.json')); expect(res.headers.get('content-security-policy')).toBeNull(); }); }); diff --git a/tests/unit/proxy-csrf-cookie.test.ts b/tests/unit/proxy-csrf-cookie.test.ts new file mode 100644 index 00000000..cfffa80b --- /dev/null +++ b/tests/unit/proxy-csrf-cookie.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node +// Node environment: the middleware's Web Crypto HMAC (crypto.subtle) is a +// global here but not under jsdom; NextRequest/NextResponse are server classes. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest, NextResponse } from 'next/server'; + +// next-intl's middleware module cannot be resolved in the vitest environment; +// the CSRF cookie behavior under test does not depend on intl routing. +vi.mock('next-intl/middleware', () => ({ + default: () => (request: NextRequest) => + NextResponse.next({ request: { headers: request.headers } }), +})); +vi.mock('@/i18n/routing', () => ({ + routing: { locales: ['en'], defaultLocale: 'en' }, +})); + +import proxy from '@/proxy'; +import { generateCSRFToken, isValidCSRFToken } from '@/lib/middleware/csrf'; + +function req(path: string): NextRequest { + return new NextRequest(new URL(`http://localhost${path}`)); +} + +function csrfSetCookie(res: Awaited>): string | undefined { + return res.headers.getSetCookie().find((c) => c.startsWith('csrf_token=')); +} + +function csrfCookieValue(res: Awaited>): string | null { + const setCookie = csrfSetCookie(res); + if (!setCookie) return null; + return setCookie.split(';')[0]!.split('=').slice(1).join('='); +} + +describe('proxy CSRF cookie issuance', () => { + const originalSecret = process.env.CSRF_SECRET; + + beforeEach(() => { + process.env.CSRF_SECRET = 'proxy-csrf-test-secret'; + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + if (originalSecret !== undefined) process.env.CSRF_SECRET = originalSecret; + else delete process.env.CSRF_SECRET; + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('sets a csrf_token cookie on document responses', async () => { + const res = await proxy(req('/market')); + expect(csrfCookieValue(res)).toBeTruthy(); + }); + + it('mints tokens the Node-side verifier accepts (cross-runtime HMAC parity)', async () => { + const res = await proxy(req('/market')); + expect(isValidCSRFToken(csrfCookieValue(res)!)).toBe(true); + }); + + it('is byte-identical to the Node generator for the same secret and timestamp', async () => { + vi.spyOn(Date, 'now').mockImplementation(() => 1_760_000_000_000); + const edgeToken = csrfCookieValue(await proxy(req('/market')))!; + // Same mocked clock, same secret: the edge (Web Crypto) and Node + // (node:crypto) HMAC outputs must match byte for byte. + expect(generateCSRFToken()).toBe(edgeToken); + }); + + it('re-mints a fresh token on every response (rolling refresh)', async () => { + // Base on the real clock: isValidCSRFToken rejects tokens older than 24h. + let now = Date.now(); + const spy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const first = csrfCookieValue(await proxy(req('/market')))!; + now += 60_000; + const second = csrfCookieValue(await proxy(req('/market')))!; + spy.mockRestore(); + expect(first).not.toBe(second); + expect(isValidCSRFToken(first)).toBe(true); + expect(isValidCSRFToken(second)).toBe(true); + }); + + it('serializes cookie attributes in sync with setCSRFToken', async () => { + vi.stubEnv('NODE_ENV', 'production'); + const res = await proxy(req('/market')); + const setCookie = csrfSetCookie(res)!; + // httpOnly:false -> NO HttpOnly attribute (the client must mirror the + // cookie into X-CSRF-Token, so it has to stay readable by JS). + expect(setCookie).not.toContain('HttpOnly'); + // Next serializes sameSite values lowercase; the attribute value is + // case-insensitive per the Set-Cookie grammar. + expect(setCookie).toContain('SameSite=strict'); + expect(setCookie).toContain('Secure'); + expect(setCookie).toContain('Max-Age=86400'); + expect(setCookie).toContain('Path=/'); + }); + + it('mints nothing when CSRF_SECRET is unset (fail-closed, mirrors the verifier)', async () => { + delete process.env.CSRF_SECRET; + vi.stubEnv('NODE_ENV', 'production'); + const res = await proxy(req('/market')); + expect(csrfSetCookie(res)).toBeUndefined(); + // CSP must still be applied — the two concerns are independent. + expect(res.headers.get('content-security-policy')).toContain("'nonce-"); + }); + + it('does not set the cookie on the healthcheck short-circuit', async () => { + const res = await proxy(req('/.well-known/healthcheck.json')); + expect(csrfSetCookie(res)).toBeUndefined(); + }); +}); From e0878a570bfabaab65172dd5273022b15e01f4b0 Mon Sep 17 00:00:00 2001 From: ety001 Date: Tue, 1 Sep 2026 15:50:05 +0800 Subject: [PATCH 4/6] refactor(analytics): emit gtag scripts in SSR HTML, not after hydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match wallet-legacy server-html.jsx (and condenser PR #4010): plain script tags rendered by the server layout so gtag loads at parse time with no hydration dependency. Both scripts carry the per-request CSP nonce. SPA pageviews stay in a small client component using next/navigation's usePathname — next-intl's throws outside NextIntlClientProvider during SSR, and the resulting error recovery silently swallowed the sibling scripts. --- src/app/[locale]/layout.tsx | 6 ++ .../analytics/google-analytics-pageviews.tsx | 33 +++++++ src/components/analytics/google-analytics.tsx | 68 ++++++------- tests/unit/google-analytics.test.tsx | 96 ++++++++++++------- 4 files changed, 130 insertions(+), 73 deletions(-) create mode 100644 src/components/analytics/google-analytics-pageviews.tsx diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index a595a27e..2552783f 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -8,6 +8,7 @@ import { routing } from '@/i18n/routing'; import { Providers } from '../providers'; import { AppLayout } from '@/components/layout/app-layout'; import { GoogleAnalytics } from '@/components/analytics/google-analytics'; +import { GoogleAnalyticsPageviews } from '@/components/analytics/google-analytics-pageviews'; import { getGaMeasurementId } from '@/lib/analytics/ga-id'; import '../globals.css'; @@ -66,7 +67,12 @@ export default async function LocaleLayout({ + {/* GA scripts must stay in a fragment free of 'use client' elements — + a client sibling inside the SAME fragment makes React defer the + scripts to hydration instead of emitting them in the SSR HTML. + Hence two separate conditionals, not one shared fragment. */} {gaId ? : null} + {gaId ? : null} {children} diff --git a/src/components/analytics/google-analytics-pageviews.tsx b/src/components/analytics/google-analytics-pageviews.tsx new file mode 100644 index 00000000..c5925b0e --- /dev/null +++ b/src/components/analytics/google-analytics-pageviews.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { useEffect } from 'react'; +// next/navigation's usePathname, NOT @/i18n/routing's: this component renders +// in the layout OUTSIDE NextIntlClientProvider, where next-intl's usePathname +// throws during SSR (React's error recovery then swallows sibling server +// content). With localePrefix 'never' both hooks return the same path. +import { usePathname } from 'next/navigation'; + +declare global { + interface Window { + dataLayer?: unknown[]; + gtag?: (...args: unknown[]) => void; + } +} + +/** + * SPA virtual pageviews for gtag. The loader + init scripts render in the SSR + * HTML (see google-analytics.tsx) and execute at parse time, so window.gtag + * exists before hydration and the first effect run already reports the + * initial pageview — the init config sets send_page_view:false so this effect + * is the single source of pageviews (no double-count on first load). + */ +export function GoogleAnalyticsPageviews({ measurementId }: { measurementId: string }) { + const pathname = usePathname(); + + useEffect(() => { + if (typeof window.gtag !== 'function') return; + window.gtag('config', measurementId, { page_path: pathname }); + }, [pathname, measurementId]); + + return null; +} diff --git a/src/components/analytics/google-analytics.tsx b/src/components/analytics/google-analytics.tsx index 17635fdd..12df7f32 100644 --- a/src/components/analytics/google-analytics.tsx +++ b/src/components/analytics/google-analytics.tsx @@ -1,23 +1,19 @@ -'use client'; - -import { useEffect } from 'react'; -import Script from 'next/script'; -import { usePathname } from '@/i18n/routing'; - -declare global { - interface Window { - dataLayer?: unknown[]; - gtag?: (...args: unknown[]) => void; - } -} - /** - * gtag loader + SPA pageviews. Matches wallet-legacy: - * - `server-html.jsx` injects gtag/js + config - * - `JsPlugins.js` also sets cookie_domain: auto and sample_rate: 5 + * gtag loader rendered straight into the SSR HTML so the browser loads it at + * parse time — no client-side injection, no hydration dependency (wallet-legacy + * `server-html.jsx` parity; also what condenser settled on in its #4010 fix). + * Init config matches legacy `JsPlugins.js`: cookie_domain auto, sample_rate 5, + * send_page_view:false (SPA pageviews are reported by GoogleAnalyticsPageviews + * — render it as a SEPARATE sibling conditional in the layout, never inside + * the same fragment: any 'use client' element sharing a fragment with these + * scripts makes React defer them to hydration instead of emitting them in the + * SSR HTML). + * + * measurementId must arrive validated (getGaMeasurementId — strict GA id + * regex) since it is interpolated into an inline script. * - * Initial config uses send_page_view: false so the pathname effect is the - * single source of pageviews (avoids double-counting the first load). + * Both scripts carry the CSP nonce: with 'strict-dynamic' the host allowlist + * only applies to legacy browsers, so an un-nonced script would be blocked. */ export function GoogleAnalytics({ measurementId, @@ -26,34 +22,28 @@ export function GoogleAnalytics({ measurementId: string; nonce?: string; }) { - const pathname = usePathname(); - - useEffect(() => { - if (typeof window.gtag !== 'function') return; - window.gtag('config', measurementId, { page_path: pathname }); - }, [pathname, measurementId]); - const scriptProps = nonce ? { nonce } : {}; return ( <> - + - ); - }, -})); +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup } from '@testing-library/react'; const mockPathname = vi.fn(() => '/market'); -vi.mock('@/i18n/routing', () => ({ +vi.mock('next/navigation', () => ({ usePathname: () => mockPathname(), })); +// RTL cleanup only unmounts the body container; React Float hoists async +//