Skip to content

Commit a8d17e4

Browse files
screwkclaude
andcommitted
fix: review fixes — migration split, XSS hardening, CI pipeline, monitoring
Security: - Complete XSS escape in ProfileJsonLd (< > & all escaped) - Guard window.matchMedia with typeof check in ClaimDialog - Truncate wallet addresses before sending to Vercel Analytics - Skip ProfileJsonLd structured data on noindex wallet pages - Add CoinGecko API key header to OG image fetch Infrastructure: - Split migration 019 into 4 files (1 per CONCURRENTLY index + enum fix) Supabase CLI has no --no-transaction pragma; each CREATE INDEX CONCURRENTLY must be in its own migration file - CI: replace cache with upload/download-artifact for reliable build sharing between jobs (no more double build) Monitoring: - Add lib/monitoring.ts with claim event tracking, performance metrics, and fee collection tracking - Instrument claim/bags and claim/confirm routes Code quality: - Extract isWalletAddress() to lib/utils.ts (shared by page.tsx) - Remove dead saveButtonStyle() from ShareButton, add saving state feedback with cursor-wait + opacity - Convert remaining hardcoded colors to design system tokens (ShareButton, loading.tsx, ProfileHero chain badges) - Fix HeroReveal comment (3s → 1.3s actual timeline) - Simplify SearchAction JSON-LD to string target format - Add vitest config + initial utils tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 36377e6 commit a8d17e4

27 files changed

Lines changed: 1619 additions & 179 deletions

.github/workflows/ci.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ jobs:
2626
- run: npx next lint
2727
- run: npm run build
2828

29+
- uses: actions/upload-artifact@v4
30+
with:
31+
name: nextjs-build
32+
path: .next
33+
retention-days: 1
34+
2935
e2e:
3036
runs-on: ubuntu-latest
3137
needs: build
@@ -39,7 +45,12 @@ jobs:
3945

4046
- run: npm ci
4147
- run: npx playwright install --with-deps chromium
42-
- run: npm run build
48+
49+
- uses: actions/download-artifact@v4
50+
with:
51+
name: nextjs-build
52+
path: .next
53+
4354
- run: npm run test:e2e
4455

4556
- uses: actions/upload-artifact@v4

app/[handle]/loading.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export default function Loading() {
2323

2424
{/* SCANNING label */}
2525
<div
26-
className="signal-fade-up signal-scan-label mb-6 font-mono text-[10px] font-medium uppercase tracking-[3px] text-black/28"
26+
className="signal-fade-up signal-scan-label mb-6 font-mono text-[10px] font-medium uppercase tracking-[3px] text-foreground/28"
2727
style={{ animationDelay: '0.1s' }}
2828
>
2929
Scanning
@@ -40,11 +40,11 @@ export default function Loading() {
4040
<svg className="signal-ring-outer signal-ring-outer-svg absolute h-[220px] w-[220px]" viewBox="0 0 220 220">
4141
<circle
4242
cx="110" cy="110" r="108"
43-
fill="none" stroke="rgba(0,0,0,0.07)" strokeWidth="1.5"
43+
fill="none" stroke="currentColor" strokeWidth="1.5" className="text-foreground/[0.07]"
4444
/>
4545
<circle
4646
cx="110" cy="110" r="108"
47-
fill="none" stroke="rgba(0,0,0,0.45)" strokeWidth="1.5"
47+
fill="none" stroke="currentColor" strokeWidth="1.5" className="text-foreground/45"
4848
strokeLinecap="round" strokeDasharray="80 614"
4949
transform="rotate(-90 110 110)"
5050
/>
@@ -62,7 +62,7 @@ export default function Loading() {
6262
<svg className="signal-reticle absolute inset-0 h-full w-full" viewBox="0 0 32 32">
6363
<circle
6464
cx="16" cy="16" r="14"
65-
stroke="rgba(0,0,0,0.18)" strokeWidth="1"
65+
stroke="currentColor" strokeWidth="1" className="text-foreground/[0.18]"
6666
fill="none" strokeDasharray="3 5"
6767
/>
6868
</svg>

app/[handle]/opengraph-image.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,13 @@ function computeUsd(amount: bigint, decimals: number, priceUsd: number): number
6060

6161
async function fetchPrices(): Promise<{ sol: number; eth: number }> {
6262
try {
63+
const headers: Record<string, string> = {};
64+
if (process.env.COINGECKO_API_KEY) {
65+
headers['x-cg-demo-api-key'] = process.env.COINGECKO_API_KEY;
66+
}
6367
const res = await fetch(
6468
'https://api.coingecko.com/api/v3/simple/price?ids=solana,ethereum&vs_currencies=usd',
65-
{ signal: AbortSignal.timeout(4000), next: { revalidate: 300 } },
69+
{ signal: AbortSignal.timeout(4000), next: { revalidate: 300 }, headers },
6670
);
6771
if (!res.ok) return { sol: 0, eth: 0 };
6872
const data = await res.json();

app/[handle]/page.tsx

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { notFound } from 'next/navigation';
33
import dynamic from 'next/dynamic';
44
import { resolveAndPersistCreator } from '@/lib/services/creator';
55
import { getNativeTokenPrices } from '@/lib/prices';
6-
import { computeFeeUsd } from '@/lib/utils';
6+
import { computeFeeUsd, isWalletAddress } from '@/lib/utils';
77
import { PLATFORM_CONFIG } from '@/lib/constants';
88
import { SearchBar } from '../components/SearchBar';
99
import { ProfileJsonLd } from '../components/ProfileJsonLd';
@@ -32,7 +32,7 @@ export async function generateMetadata({ params }: PageProps) {
3232
const decoded = decodeURIComponent(handle);
3333
const safeName = decoded.replace(/[^a-zA-Z0-9_\-\.]/g, '').slice(0, 64);
3434
// Don't prefix wallet addresses with @
35-
const isWallet = /^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/.test(safeName);
35+
const isWallet = isWalletAddress(safeName);
3636
const displayName = isWallet ? safeName : `@${safeName}`;
3737
return {
3838
title: `${displayName} Unclaimed Creator Fees`,
@@ -131,20 +131,22 @@ export default async function ProfilePage({ params }: PageProps) {
131131
);
132132

133133
// Build display name for structured data
134-
const isWallet = /^(0x[a-fA-F0-9]{40}|[1-9A-HJ-NP-Za-km-z]{32,44})$/.test(decoded);
134+
const isWallet = isWalletAddress(decoded);
135135
const displayName = isWallet ? decoded : `@${decoded}`;
136136

137137
return (
138138
<div className="space-y-8 sm:space-y-10">
139139
<SearchBar />
140-
<ProfileJsonLd
141-
handle={decoded}
142-
displayName={displayName}
143-
totalEarnedUsd={totalEarnedUsd}
144-
platformCount={platformCount}
145-
avatarUrl={creator.avatar_url ?? null}
146-
walletAddresses={wallets.map((w) => w.address)}
147-
/>
140+
{!isWallet && (
141+
<ProfileJsonLd
142+
handle={decoded}
143+
displayName={displayName}
144+
totalEarnedUsd={totalEarnedUsd}
145+
platformCount={platformCount}
146+
avatarUrl={creator.avatar_url ?? null}
147+
walletAddresses={wallets.map((w) => w.address)}
148+
/>
149+
)}
148150

149151
{/* ZONE 1: Profile Hero */}
150152
<div className="animate-fade-in-up">

app/api/claim/bags/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createServiceClient } from '@/lib/supabase/service';
44
import { generateBatchClaimTransactions } from '@/lib/platforms/bags-claim';
55
import { generateConfirmToken } from '@/lib/claim/hmac';
66
import { CLAIMSCAN_FEE_BPS, MIN_FEE_LAMPORTS } from '@/lib/constants';
7+
import { trackClaimEvent, trackPerformance, trackFeeCollection } from '@/lib/monitoring';
78

89
/** Vercel Hobby hard limit is 10s. Reduced batch size (10 mints) fits within this budget. */
910
export const maxDuration = 60;
@@ -184,7 +185,9 @@ export async function POST(request: Request) {
184185
}
185186

186187
// Generate claim transactions — wrapped in try/finally to clean up locks on failure
188+
trackClaimEvent('initiated', { wallet, platform: 'bags', mintCount: lockedMints.length });
187189
let results;
190+
const txGenStart = performance.now();
188191
try {
189192
results = await generateBatchClaimTransactions(wallet, lockedMints);
190193
} catch (err) {
@@ -201,8 +204,10 @@ export async function POST(request: Request) {
201204
.in('id', idsToClean);
202205
if (cleanupErr) console.error('[claim/bags] Lock cleanup after tx failure failed:', cleanupErr.message);
203206
}
207+
trackClaimEvent('failure', { reason: 'tx_generation_error', wallet, platform: 'bags', mintCount: lockedMints.length });
204208
return NextResponse.json({ error: 'Failed to generate claim transactions' }, { status: 500 });
205209
}
210+
trackPerformance('bags_batch_tx_generation', performance.now() - txGenStart, 8000);
206211

207212
const transactions: Array<{
208213
tokenMint: string;
@@ -292,6 +297,13 @@ export async function POST(request: Request) {
292297
}
293298
}
294299

300+
// Track fee collection decision
301+
const feeCollected = feeLamports !== '0';
302+
trackFeeCollection(feeCollected, feeLamports, 0);
303+
if (!feeCollected && transactions.length > 0) {
304+
trackClaimEvent('fee_skipped', { wallet, platform: 'bags', mintCount: transactions.length });
305+
}
306+
295307
return NextResponse.json({
296308
transactions,
297309
feeLamports,

app/api/claim/confirm/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { invalidatePositionsCache } from '@/lib/platforms/bags-api';
55
import { verifyConfirmToken } from '@/lib/claim/hmac';
66
import { CLAIMSCAN_FEE_WALLET } from '@/lib/constants';
77
import type { ClaimAttemptStatus } from '@/lib/supabase/types';
8+
import { trackClaimEvent, trackFeeCollection } from '@/lib/monitoring';
89

910
/**
1011
* Valid forward-only status transitions.
@@ -102,6 +103,7 @@ export async function POST(request: Request) {
102103
// Actual amount will be reconciled via cron once RPC is available.
103104
// Never trust client-supplied rawFeeLamports for unverified records.
104105
console.error(`[claim/confirm] FEE_VERIFICATION_FAILED: sig=${feeSig} wallet=${feeWallet} lamports=${rawFeeLamports} — inserting unverified record with amount=0`);
106+
trackClaimEvent('failure', { reason: 'fee_verification_rpc_error', wallet: feeWallet ?? '', feeLamports: rawFeeLamports ?? '0' });
105107
try {
106108
const svc = createServiceClient();
107109
await svc.from('claim_fees').insert({
@@ -130,6 +132,10 @@ export async function POST(request: Request) {
130132
});
131133
if (insertError && insertError.code !== '23505') {
132134
console.error('[claim/confirm] Fee log insert FAILED (revenue loss):', insertError.message, { sig: feeSig, wallet: feeWallet });
135+
trackClaimEvent('failure', { reason: 'fee_insert_error', wallet: feeWallet ?? '', feeLamports });
136+
} else {
137+
trackFeeCollection(true, feeLamports, 0);
138+
trackClaimEvent('fee_collected', { wallet: feeWallet ?? '', feeLamports });
133139
}
134140
return NextResponse.json({ ok: true });
135141
}
@@ -242,6 +248,7 @@ export async function POST(request: Request) {
242248
return NextResponse.json({ error: 'Another active claim exists for this token' }, { status: 409 });
243249
}
244250
console.error('[claim/confirm] Update error:', updateError.message);
251+
trackClaimEvent('failure', { reason: 'db_update_error', claimAttemptId, wallet: wallet ?? '', status: validatedStatus });
245252
return NextResponse.json({ error: 'Failed to update claim status' }, { status: 500 });
246253
}
247254
if (updateCount === 0) {
@@ -252,6 +259,12 @@ export async function POST(request: Request) {
252259
// On confirmed: invalidate positions cache
253260
if (validatedStatus === 'confirmed') {
254261
invalidatePositionsCache(attempt.wallet_address);
262+
trackClaimEvent('success', { claimAttemptId, wallet: wallet ?? '', platform: attempt.platform ?? '', chain: attempt.chain ?? '' });
263+
}
264+
265+
// Track claim failures reported by the client
266+
if (validatedStatus === 'failed') {
267+
trackClaimEvent('failure', { reason: errorReason ?? 'unknown', claimAttemptId, wallet: wallet ?? '', platform: attempt.platform ?? '' });
255268
}
256269

257270
return NextResponse.json({ ok: true, status: validatedStatus });

app/components/ClaimDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function ClaimDialog({
4949
&& connectedWallet !== bagsRegisteredWallet;
5050

5151
useEffect(() => {
52+
if (typeof window === 'undefined') return;
5253
const mql = window.matchMedia('(max-width: 767px)');
5354
setIsMobile(mql.matches);
5455
function onChange(e: MediaQueryListEvent) { setIsMobile(e.matches); }

app/components/HeroReveal.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@ import { useState, useEffect, useCallback, startTransition, type ReactNode } fro
55
/**
66
* Full-screen reveal animation for the homepage hero.
77
*
8-
* Timeline (3 s total):
9-
* 0.0 s - 0.7 s Logo scales in with subtle blur clear
10-
* 0.4 s - 0.8 s "ClaimScan" text fades in below logo
11-
* 0.8 s - 2.0 s Hold (logo + text visible)
12-
* 2.0 s - 3.0 s Overlay fades out, content animations begin
8+
* Timeline (1.3 s total):
9+
* 0.0 s - 0.4 s Logo scales in with subtle blur clear
10+
* 0.2 s - 0.5 s "ClaimScan" text fades in below logo
11+
* 0.0 s - 0.8 s Hold (logo + text visible, OVERLAY_HOLD_MS)
12+
* 0.8 s - 1.3 s Overlay fades out (OVERLAY_FADE_MS), content animations begin
1313
*
1414
* Only plays once per browser session (sessionStorage).
1515
* Skips entirely when prefers-reduced-motion is enabled.

app/components/JsonLd.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,7 @@ export function JsonLd() {
2626
publisher: { '@id': 'https://lwdesigns.art/#org' },
2727
potentialAction: {
2828
'@type': 'SearchAction',
29-
target: {
30-
'@type': 'EntryPoint',
31-
urlTemplate: 'https://claimscan.tech/{search_term_string}',
32-
},
29+
target: 'https://claimscan.tech/{search_term_string}',
3330
'query-input': 'required name=search_term_string',
3431
},
3532
},

app/components/PlatformBreakdown.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,7 @@ export function PlatformBreakdown({ fees, solPrice = 0, ethPrice = 0, wallets =
446446
{/* Claim Dialog — wrapped in error boundary for wallet/connection failures */}
447447
{connectedWallet && claimDialogOpen && (
448448
<ClaimErrorBoundary fallback={
449-
<p className="rounded-lg border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-400">
449+
<p className="rounded-lg border border-destructive/20 bg-destructive/10 px-3 py-2 text-xs text-destructive">
450450
Failed to load claim dialog. Please refresh the page and try again.
451451
</p>
452452
}>

0 commit comments

Comments
 (0)