From f5edf7dcc1582f98264589d340d92719af0e13b1 Mon Sep 17 00:00:00 2001 From: saurabhhhcodes <157192462+saurabhhhcodes@users.noreply.github.com> Date: Sun, 17 May 2026 11:09:17 +0530 Subject: [PATCH 1/4] Add suspicious XP flag audit --- src/app/(app)/maintainer/page.tsx | 79 +++++++ src/app/actions/maintainer.ts | 63 ++++++ src/app/api/inngest/route.ts | 2 + src/inngest/functions/suspicious-xp-audit.ts | 89 ++++++++ src/lib/db/schema.ts | 22 ++ src/lib/xp/suspicious-flags.test.ts | 98 +++++++++ src/lib/xp/suspicious-flags.ts | 203 ++++++++++++++++++ supabase/migrations/0011_flagged_accounts.sql | 19 ++ 8 files changed, 575 insertions(+) create mode 100644 src/inngest/functions/suspicious-xp-audit.ts create mode 100644 src/lib/xp/suspicious-flags.test.ts create mode 100644 src/lib/xp/suspicious-flags.ts create mode 100644 supabase/migrations/0011_flagged_accounts.sql diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index ee6c612..381c5cd 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -4,11 +4,13 @@ import { getServerSupabase } from '@/lib/supabase/server'; import { isUserMaintainer } from '@/lib/maintainer/detect'; import { getMaintainerInstalls, + getMaintainerFlags, getMaintainerPrQueue, getRepoHealthOverview, getStaleIssues, getTopContributors, type MaintainerInstall, + type MaintainerFlagRow, type MaintainerPrRow, type RepoHealthRow, type StaleIssueRow, @@ -82,6 +84,9 @@ export default async function MaintainerPage({ const contributorsRes = await getTopContributors(); const topContributors: ContributorRow[] = isOk(contributorsRes) ? contributorsRes.data : []; + const flagsRes = await getMaintainerFlags(8); + const flags: MaintainerFlagRow[] = isOk(flagsRes) ? flagsRes.data : []; + return (
@@ -229,6 +234,8 @@ export default async function MaintainerPage({
+ + {rows.length === 0 ? (
No PRs match your filters. Try widening state or running a refresh. @@ -289,6 +296,55 @@ export default async function MaintainerPage({ ); } +function FlaggedAccountsPanel({ flags }: { flags: MaintainerFlagRow[] }) { + return ( +
+
+
+

XP audit flags

+

+ Detection only. Review the evidence before taking any action. +

+
+ + {flags.length} open + +
+ + {flags.length === 0 ? ( +
+ No suspicious XP patterns flagged. +
+ ) : ( +
    + {flags.map((flag) => ( +
  • +
    + + {flag.severity} + + {reasonLabel(flag.reason)} + + {flag.githubHandle ? `@${flag.githubHandle}` : flag.userId ?? 'unknown user'} + + + {new Date(flag.createdAt).toLocaleString()} + +
    +

    {formatFlagEvidence(flag.evidence)}

    +
  • + ))} +
+ )} +
+ ); +} + function FilterPill({ label, href, active }: { label: string; href: string; active: boolean }) { return ( = { + daily_event_burst: 'Daily XP event burst', + hourly_merge_burst: 'Hourly merge burst', + review_pair_burst: 'Repeated reviewer pair', + }; + return labels[reason]; +} + +function formatFlagEvidence(evidence: Record): string { + const parts: string[] = []; + for (const key of ['eventCount', 'mergeCount', 'reviewCount', 'totalXp', 'day', 'week']) { + const value = evidence[key]; + if (value !== null && value !== undefined) parts.push(`${key}=${String(value)}`); + } + return parts.length > 0 ? parts.join(' ยท ') : 'Evidence payload captured for review.'; +} + function relativeTime(iso: string): string { const ms = Date.now() - new Date(iso).getTime(); const min = Math.floor(ms / 60000); diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts index 82246b2..95f355e 100644 --- a/src/app/actions/maintainer.ts +++ b/src/app/actions/maintainer.ts @@ -63,6 +63,17 @@ export type ContributorRow = { level: number; }; +export type MaintainerFlagRow = { + id: number; + userId: string | null; + githubHandle: string | null; + reason: 'daily_event_burst' | 'hourly_merge_burst' | 'review_pair_burst'; + severity: 'medium' | 'high'; + status: 'open' | 'resolved' | 'dismissed'; + evidence: Record; + createdAt: string; +}; + const ISSUE_BUCKETS = new Set([ 'needs-triage', 'in-progress', @@ -354,6 +365,58 @@ export async function getMaintainerIssueQueue(args: { return ok({ rows: pageRows, hasMore: filtered.length > PAGE_SIZE }); } +export async function getMaintainerFlags(limit = 10): Promise> { + const sb = getServerSupabase(); + if (!sb) return err('not_configured', 'auth not configured'); + const service = getServiceSupabase(); + if (!service) return err('not_configured', 'service role missing'); + + const { + data: { user }, + } = await sb.auth.getUser(); + if (!user) return err('not_authenticated', 'sign in first'); + + if (!(await isUserMaintainer(user.id))) { + return err('not_authorised', 'not a maintainer'); + } + + type RawFlag = { + id: number; + user_id: string | null; + reason: MaintainerFlagRow['reason']; + severity: MaintainerFlagRow['severity']; + status: MaintainerFlagRow['status']; + evidence: Record; + created_at: string; + profiles: { github_handle?: string | null } | { github_handle?: string | null }[] | null; + }; + + const { data, error } = await service + .from('flagged_accounts') + .select('id, user_id, reason, severity, status, evidence, created_at, profiles(github_handle)') + .eq('status', 'open') + .order('created_at', { ascending: false }) + .limit(Math.max(1, Math.min(50, limit))); + + if (error) return err('db_error', error.message); + + const rows = ((data ?? []) as RawFlag[]).map((row) => { + const profile = Array.isArray(row.profiles) ? row.profiles[0] : row.profiles; + return { + id: row.id, + userId: row.user_id, + githubHandle: profile?.github_handle ?? null, + reason: row.reason, + severity: row.severity, + status: row.status, + evidence: row.evidence ?? {}, + createdAt: row.created_at, + }; + }); + + return ok(rows); +} + export async function refreshMaintainerBackfill( installationId: number, ): Promise> { diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts index 5caeb7b..c582b4c 100644 --- a/src/app/api/inngest/route.ts +++ b/src/app/api/inngest/route.ts @@ -21,6 +21,7 @@ import { githubStatsSync } from '@/inngest/functions/github-stats-sync'; import { mentorPostComment } from '@/inngest/functions/mentor-post-comment'; import { processIssueEvent } from '@/inngest/functions/process-issue-event'; import { processIssueCommentEvent } from '@/inngest/functions/process-issue-comment-event'; +import { suspiciousXpAudit } from '@/inngest/functions/suspicious-xp-audit'; export const { GET, POST, PUT } = serve({ client: inngest, @@ -44,5 +45,6 @@ export const { GET, POST, PUT } = serve({ mentorPostComment, processIssueEvent, processIssueCommentEvent, + suspiciousXpAudit, ], }); diff --git a/src/inngest/functions/suspicious-xp-audit.ts b/src/inngest/functions/suspicious-xp-audit.ts new file mode 100644 index 0000000..014efad --- /dev/null +++ b/src/inngest/functions/suspicious-xp-audit.ts @@ -0,0 +1,89 @@ +import { inngest } from '../client'; +import { getServiceSupabase } from '@/lib/supabase/service'; +import { + detectSuspiciousXpPatterns, + type ReviewAuditEvent, + type XpAuditEvent, +} from '@/lib/xp/suspicious-flags'; + +const LOOKBACK_DAYS = 8; + +export const suspiciousXpAudit = inngest.createFunction( + { id: 'suspicious-xp-audit' }, + { cron: '30 2 * * *' }, + async ({ step }) => { + return await step.run('detect-and-upsert-flags', async () => { + const sb = getServiceSupabase(); + if (!sb) throw new Error('service role missing'); + + const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString(); + + const [xpRes, reviewsRes] = await Promise.all([ + sb + .from('xp_events') + .select('id, user_id, source, ref_id, repo, xp_delta, created_at') + .gte('created_at', since), + sb + .from('pull_request_reviews') + .select( + 'id, pr_id, reviewer_user_id, state, submitted_at, ' + + 'pull_requests!inner(author_user_id, repo_full_name, number)', + ) + .gte('submitted_at', since), + ]); + + if (xpRes.error) throw new Error(xpRes.error.message); + if (reviewsRes.error) throw new Error(reviewsRes.error.message); + + const xpEvents: XpAuditEvent[] = (xpRes.data ?? []).map((row) => ({ + id: row.id, + userId: row.user_id, + source: row.source, + refId: row.ref_id, + repo: row.repo, + xpDelta: row.xp_delta, + createdAt: row.created_at, + })); + + const reviewEvents: ReviewAuditEvent[] = (reviewsRes.data ?? []).map((row) => { + const pr = Array.isArray(row.pull_requests) + ? row.pull_requests[0] + : row.pull_requests; + return { + id: row.id, + prId: row.pr_id, + reviewerUserId: row.reviewer_user_id, + authorUserId: pr?.author_user_id ?? null, + state: row.state, + submittedAt: row.submitted_at, + repoFullName: pr?.repo_full_name ?? null, + prNumber: pr?.number ?? null, + }; + }); + + const flags = detectSuspiciousXpPatterns({ xpEvents, reviewEvents }); + if (flags.length === 0) { + return { scannedXpEvents: xpEvents.length, scannedReviews: reviewEvents.length, flags: 0 }; + } + + const { error } = await sb.from('flagged_accounts').upsert( + flags.map((flag) => ({ + user_id: flag.userId, + reason: flag.reason, + severity: flag.severity, + status: 'open', + dedupe_key: flag.dedupeKey, + evidence: flag.evidence, + })), + { onConflict: 'dedupe_key', ignoreDuplicates: true }, + ); + if (error) throw new Error(error.message); + + return { + scannedXpEvents: xpEvents.length, + scannedReviews: reviewEvents.length, + flags: flags.length, + }; + }); + }, +); diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index b84646b..9ef8c9c 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -325,6 +325,28 @@ export const activityLog = pgTable( }), ); +export const flaggedAccounts = pgTable( + 'flagged_accounts', + { + id: bigserial('id', { mode: 'number' }).primaryKey(), + userId: uuid('user_id').references(() => profiles.id, { onDelete: 'set null' }), + reason: text('reason', { + enum: ['daily_event_burst', 'hourly_merge_burst', 'review_pair_burst'], + }).notNull(), + severity: text('severity', { enum: ['medium', 'high'] }).notNull().default('medium'), + status: text('status', { enum: ['open', 'resolved', 'dismissed'] }).notNull().default('open'), + dedupeKey: text('dedupe_key').notNull(), + evidence: jsonb('evidence').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + resolvedAt: timestamp('resolved_at', { withTimezone: true }), + }, + (t) => ({ + dedupeKeyUnique: uniqueIndex('flagged_accounts_dedupe_key_unique').on(t.dedupeKey), + statusCreatedIdx: index('flagged_accounts_status_created_idx').on(t.status, t.createdAt), + userCreatedIdx: index('flagged_accounts_user_created_idx').on(t.userId, t.createdAt), + }), +); + // ======================================================================== // Maintainer-side tables (migration 0005) // ======================================================================== diff --git a/src/lib/xp/suspicious-flags.test.ts b/src/lib/xp/suspicious-flags.test.ts new file mode 100644 index 0000000..72e0b00 --- /dev/null +++ b/src/lib/xp/suspicious-flags.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { + detectSuspiciousXpPatterns, + XP_FLAG_THRESHOLDS, + type XpAuditEvent, +} from './suspicious-flags'; +import { XP_SOURCE } from './sources'; + +const xp = (overrides: Partial = {}): XpAuditEvent => ({ + id: overrides.id ?? 1, + userId: overrides.userId ?? 'user-1', + source: overrides.source ?? XP_SOURCE.COMMENT, + refId: overrides.refId ?? `ref-${overrides.id ?? 1}`, + repo: overrides.repo ?? 'org/repo', + xpDelta: overrides.xpDelta ?? 1, + createdAt: overrides.createdAt ?? '2026-05-17T00:00:00.000Z', +}); + +describe('detectSuspiciousXpPatterns', () => { + it('flags more than five positive XP events in a UTC day', () => { + const events = Array.from({ length: XP_FLAG_THRESHOLDS.dailyEventCount + 1 }, (_, index) => + xp({ id: index + 1, refId: `comment-${index + 1}` }), + ); + + const flags = detectSuspiciousXpPatterns({ xpEvents: events }); + + expect(flags).toHaveLength(1); + expect(flags[0]).toMatchObject({ + userId: 'user-1', + reason: 'daily_event_burst', + severity: 'medium', + }); + expect(flags[0]!.evidence).toMatchObject({ + day: '2026-05-17', + eventCount: 6, + threshold: XP_FLAG_THRESHOLDS.dailyEventCount, + }); + }); + + it('does not flag daily events at the threshold boundary or penalty events', () => { + const events = [ + ...Array.from({ length: XP_FLAG_THRESHOLDS.dailyEventCount }, (_, index) => + xp({ id: index + 1 }), + ), + xp({ id: 99, xpDelta: -50 }), + ]; + + expect(detectSuspiciousXpPatterns({ xpEvents: events })).toEqual([]); + }); + + it('flags more than three merge XP events inside a one-hour window', () => { + const events = Array.from({ length: XP_FLAG_THRESHOLDS.hourlyMergeCount + 1 }, (_, index) => + xp({ + id: index + 1, + source: XP_SOURCE.RECOMMENDED_MERGE, + refId: `pr:org/repo:${index + 1}`, + xpDelta: 150, + createdAt: `2026-05-17T00:${String(index * 10).padStart(2, '0')}:00.000Z`, + }), + ); + + const flags = detectSuspiciousXpPatterns({ xpEvents: events }); + + expect(flags.some((flag) => flag.reason === 'hourly_merge_burst')).toBe(true); + expect(flags.find((flag) => flag.reason === 'hourly_merge_burst')!.evidence).toMatchObject({ + mergeCount: 4, + threshold: XP_FLAG_THRESHOLDS.hourlyMergeCount, + }); + }); + + it('flags reviewer and contributor pairs with too many approvals in one UTC week', () => { + const reviewEvents = Array.from( + { length: XP_FLAG_THRESHOLDS.weeklyReviewerPairCount + 1 }, + (_, index) => ({ + id: index + 1, + prId: 100 + index, + reviewerUserId: 'reviewer-1', + authorUserId: 'author-1', + state: 'approved', + submittedAt: `2026-05-${String(11 + index).padStart(2, '0')}T12:00:00.000Z`, + repoFullName: 'org/repo', + prNumber: index + 1, + }), + ); + + const flags = detectSuspiciousXpPatterns({ xpEvents: [], reviewEvents }); + + expect(flags).toHaveLength(1); + expect(flags[0]).toMatchObject({ + userId: 'reviewer-1', + reason: 'review_pair_burst', + }); + expect(flags[0]!.evidence).toMatchObject({ + authorUserId: 'author-1', + reviewCount: 5, + }); + }); +}); diff --git a/src/lib/xp/suspicious-flags.ts b/src/lib/xp/suspicious-flags.ts new file mode 100644 index 0000000..663ac9d --- /dev/null +++ b/src/lib/xp/suspicious-flags.ts @@ -0,0 +1,203 @@ +import { XP_SOURCE } from './sources'; + +export const XP_FLAG_THRESHOLDS = { + dailyEventCount: 5, + hourlyMergeCount: 3, + weeklyReviewerPairCount: 4, + hourlyWindowMs: 60 * 60 * 1000, +} as const; + +export type XpAuditEvent = { + id: number; + userId: string; + source: string; + refId: string; + repo: string | null; + xpDelta: number; + createdAt: string; +}; + +export type ReviewAuditEvent = { + id: number; + prId: number; + reviewerUserId: string | null; + authorUserId: string | null; + state: string; + submittedAt: string; + repoFullName: string | null; + prNumber: number | null; +}; + +export type FlagReason = 'daily_event_burst' | 'hourly_merge_burst' | 'review_pair_burst'; +export type FlagSeverity = 'medium' | 'high'; + +export type FlagCandidate = { + userId: string; + reason: FlagReason; + severity: FlagSeverity; + dedupeKey: string; + evidence: Record; +}; + +const MERGE_SOURCES = new Set([ + XP_SOURCE.RECOMMENDED_MERGE, + XP_SOURCE.UNRECOMMENDED_MERGE, +]); + +export function detectSuspiciousXpPatterns(args: { + xpEvents: XpAuditEvent[]; + reviewEvents?: ReviewAuditEvent[]; + now?: Date; +}): FlagCandidate[] { + const flags: FlagCandidate[] = []; + flags.push(...detectDailyEventBursts(args.xpEvents)); + flags.push(...detectHourlyMergeBursts(args.xpEvents)); + flags.push(...detectReviewerPairBursts(args.reviewEvents ?? [])); + return dedupeFlags(flags); +} + +function detectDailyEventBursts(events: XpAuditEvent[]): FlagCandidate[] { + const groups = new Map(); + for (const event of events) { + if (event.xpDelta <= 0) continue; + const key = `${event.userId}:${utcDay(event.createdAt)}`; + groups.set(key, [...(groups.get(key) ?? []), event]); + } + + const flags: FlagCandidate[] = []; + for (const [key, group] of groups) { + if (group.length <= XP_FLAG_THRESHOLDS.dailyEventCount) continue; + const [userId, day] = key.split(':'); + if (!userId || !day) continue; + flags.push({ + userId, + reason: 'daily_event_burst', + severity: group.length >= XP_FLAG_THRESHOLDS.dailyEventCount * 2 ? 'high' : 'medium', + dedupeKey: `daily-event-burst:${userId}:${day}`, + evidence: { + day, + eventCount: group.length, + threshold: XP_FLAG_THRESHOLDS.dailyEventCount, + totalXp: group.reduce((sum, event) => sum + event.xpDelta, 0), + sources: countBy(group.map((event) => event.source)), + refs: group.slice(0, 10).map((event) => event.refId), + }, + }); + } + return flags; +} + +function detectHourlyMergeBursts(events: XpAuditEvent[]): FlagCandidate[] { + const byUser = new Map(); + for (const event of events) { + if (!MERGE_SOURCES.has(event.source) || event.xpDelta <= 0) continue; + byUser.set(event.userId, [...(byUser.get(event.userId) ?? []), event]); + } + + const flags: FlagCandidate[] = []; + for (const [userId, group] of byUser) { + const sorted = [...group].sort((a, b) => ts(a.createdAt) - ts(b.createdAt)); + for (let start = 0; start < sorted.length; start += 1) { + const windowStart = ts(sorted[start]!.createdAt); + const window = sorted.filter((event) => { + const eventTime = ts(event.createdAt); + return ( + eventTime >= windowStart && + eventTime - windowStart <= XP_FLAG_THRESHOLDS.hourlyWindowMs + ); + }); + if (window.length <= XP_FLAG_THRESHOLDS.hourlyMergeCount) continue; + const first = window[0]!; + const last = window[window.length - 1]!; + flags.push({ + userId, + reason: 'hourly_merge_burst', + severity: window.length >= XP_FLAG_THRESHOLDS.hourlyMergeCount + 3 ? 'high' : 'medium', + dedupeKey: `hourly-merge-burst:${userId}:${utcHour(first.createdAt)}`, + evidence: { + windowStart: first.createdAt, + windowEnd: last.createdAt, + mergeCount: window.length, + threshold: XP_FLAG_THRESHOLDS.hourlyMergeCount, + repos: [...new Set(window.map((event) => event.repo).filter(Boolean))], + refs: window.map((event) => event.refId), + }, + }); + break; + } + } + return flags; +} + +function detectReviewerPairBursts(events: ReviewAuditEvent[]): FlagCandidate[] { + const groups = new Map(); + for (const event of events) { + if (event.state !== 'approved') continue; + if (!event.reviewerUserId || !event.authorUserId) continue; + if (event.reviewerUserId === event.authorUserId) continue; + const key = `${event.reviewerUserId}:${event.authorUserId}:${utcWeek(event.submittedAt)}`; + groups.set(key, [...(groups.get(key) ?? []), event]); + } + + const flags: FlagCandidate[] = []; + for (const [key, group] of groups) { + if (group.length <= XP_FLAG_THRESHOLDS.weeklyReviewerPairCount) continue; + const [reviewerUserId, authorUserId, week] = key.split(':'); + if (!reviewerUserId || !authorUserId || !week) continue; + flags.push({ + userId: reviewerUserId, + reason: 'review_pair_burst', + severity: group.length >= XP_FLAG_THRESHOLDS.weeklyReviewerPairCount + 3 ? 'high' : 'medium', + dedupeKey: `review-pair-burst:${reviewerUserId}:${authorUserId}:${week}`, + evidence: { + week, + reviewerUserId, + authorUserId, + reviewCount: group.length, + threshold: XP_FLAG_THRESHOLDS.weeklyReviewerPairCount, + prs: group.slice(0, 12).map((event) => ({ + id: event.prId, + repo: event.repoFullName, + number: event.prNumber, + })), + }, + }); + } + return flags; +} + +function dedupeFlags(flags: FlagCandidate[]): FlagCandidate[] { + const seen = new Set(); + return flags.filter((flag) => { + if (seen.has(flag.dedupeKey)) return false; + seen.add(flag.dedupeKey); + return true; + }); +} + +function countBy(values: string[]): Record { + return values.reduce>((acc, value) => { + acc[value] = (acc[value] ?? 0) + 1; + return acc; + }, {}); +} + +function utcDay(iso: string): string { + return new Date(iso).toISOString().slice(0, 10); +} + +function utcHour(iso: string): string { + return new Date(iso).toISOString().slice(0, 13); +} + +function utcWeek(iso: string): string { + const date = new Date(iso); + const day = date.getUTCDay() || 7; + date.setUTCDate(date.getUTCDate() - day + 1); + date.setUTCHours(0, 0, 0, 0); + return date.toISOString().slice(0, 10); +} + +function ts(iso: string): number { + return new Date(iso).getTime(); +} diff --git a/supabase/migrations/0011_flagged_accounts.sql b/supabase/migrations/0011_flagged_accounts.sql new file mode 100644 index 0000000..e44b08e --- /dev/null +++ b/supabase/migrations/0011_flagged_accounts.sql @@ -0,0 +1,19 @@ +create table if not exists flagged_accounts ( + id bigserial primary key, + user_id uuid references profiles(id) on delete set null, + reason text not null check (reason in ('daily_event_burst', 'hourly_merge_burst', 'review_pair_burst')), + severity text not null default 'medium' check (severity in ('medium', 'high')), + status text not null default 'open' check (status in ('open', 'resolved', 'dismissed')), + dedupe_key text not null unique, + evidence jsonb not null, + created_at timestamptz not null default now(), + resolved_at timestamptz +); + +create index if not exists flagged_accounts_status_created_idx + on flagged_accounts(status, created_at desc); + +create index if not exists flagged_accounts_user_created_idx + on flagged_accounts(user_id, created_at desc); + +alter table flagged_accounts enable row level security; From f3ca8f37e8b6bba318cf9dc3b74759f4b48704cc Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Sun, 17 May 2026 15:56:57 +0530 Subject: [PATCH 2/4] Paginate suspicious XP audit reads --- src/inngest/functions/suspicious-xp-audit.ts | 66 +++++++++++++++----- src/lib/xp/suspicious-flags.test.ts | 15 +++++ 2 files changed, 64 insertions(+), 17 deletions(-) diff --git a/src/inngest/functions/suspicious-xp-audit.ts b/src/inngest/functions/suspicious-xp-audit.ts index 014efad..c41ae85 100644 --- a/src/inngest/functions/suspicious-xp-audit.ts +++ b/src/inngest/functions/suspicious-xp-audit.ts @@ -7,6 +7,31 @@ import { } from '@/lib/xp/suspicious-flags'; const LOOKBACK_DAYS = 8; +const AUDIT_PAGE_SIZE = 1000; + +type SupabasePage = { + data: T[] | null; + error: { message: string } | null; +}; + +async function fetchAllAuditRows( + buildQuery: (from: number, to: number) => PromiseLike>, +): Promise { + const rows: T[] = []; + + for (let from = 0; ; from += AUDIT_PAGE_SIZE) { + const to = from + AUDIT_PAGE_SIZE - 1; + const { data, error } = await buildQuery(from, to); + if (error) throw new Error(error.message); + + const page = data ?? []; + rows.push(...page); + + if (page.length < AUDIT_PAGE_SIZE) { + return rows; + } + } +} export const suspiciousXpAudit = inngest.createFunction( { id: 'suspicious-xp-audit' }, @@ -18,24 +43,31 @@ export const suspiciousXpAudit = inngest.createFunction( const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString(); - const [xpRes, reviewsRes] = await Promise.all([ - sb - .from('xp_events') - .select('id, user_id, source, ref_id, repo, xp_delta, created_at') - .gte('created_at', since), - sb - .from('pull_request_reviews') - .select( - 'id, pr_id, reviewer_user_id, state, submitted_at, ' + - 'pull_requests!inner(author_user_id, repo_full_name, number)', - ) - .gte('submitted_at', since), + const [xpRows, reviewRows] = await Promise.all([ + fetchAllAuditRows((from, to) => + sb + .from('xp_events') + .select('id, user_id, source, ref_id, repo, xp_delta, created_at') + .gte('created_at', since) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + ), + fetchAllAuditRows((from, to) => + sb + .from('pull_request_reviews') + .select( + 'id, pr_id, reviewer_user_id, state, submitted_at, ' + + 'pull_requests!inner(author_user_id, repo_full_name, number)', + ) + .gte('submitted_at', since) + .order('submitted_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to), + ), ]); - if (xpRes.error) throw new Error(xpRes.error.message); - if (reviewsRes.error) throw new Error(reviewsRes.error.message); - - const xpEvents: XpAuditEvent[] = (xpRes.data ?? []).map((row) => ({ + const xpEvents: XpAuditEvent[] = xpRows.map((row) => ({ id: row.id, userId: row.user_id, source: row.source, @@ -45,7 +77,7 @@ export const suspiciousXpAudit = inngest.createFunction( createdAt: row.created_at, })); - const reviewEvents: ReviewAuditEvent[] = (reviewsRes.data ?? []).map((row) => { + const reviewEvents: ReviewAuditEvent[] = reviewRows.map((row) => { const pr = Array.isArray(row.pull_requests) ? row.pull_requests[0] : row.pull_requests; diff --git a/src/lib/xp/suspicious-flags.test.ts b/src/lib/xp/suspicious-flags.test.ts index 72e0b00..49d2c4e 100644 --- a/src/lib/xp/suspicious-flags.test.ts +++ b/src/lib/xp/suspicious-flags.test.ts @@ -37,6 +37,21 @@ describe('detectSuspiciousXpPatterns', () => { }); }); + it('escalates daily XP bursts to high severity when volume doubles the threshold', () => { + const events = Array.from({ length: XP_FLAG_THRESHOLDS.dailyEventCount * 2 }, (_, index) => + xp({ id: index + 1, refId: `comment-${index + 1}` }), + ); + + const flags = detectSuspiciousXpPatterns({ xpEvents: events }); + + expect(flags).toHaveLength(1); + expect(flags[0]).toMatchObject({ + userId: 'user-1', + reason: 'daily_event_burst', + severity: 'high', + }); + }); + it('does not flag daily events at the threshold boundary or penalty events', () => { const events = [ ...Array.from({ length: XP_FLAG_THRESHOLDS.dailyEventCount }, (_, index) => From 69bdeef423c9371632aae53e653b92f1ffc5dcbd Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Sun, 17 May 2026 20:13:41 +0530 Subject: [PATCH 3/4] Fix suspicious XP audit Supabase row typing --- src/inngest/functions/suspicious-xp-audit.ts | 74 ++++++++++++++------ 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/src/inngest/functions/suspicious-xp-audit.ts b/src/inngest/functions/suspicious-xp-audit.ts index c41ae85..cf7c199 100644 --- a/src/inngest/functions/suspicious-xp-audit.ts +++ b/src/inngest/functions/suspicious-xp-audit.ts @@ -14,6 +14,36 @@ type SupabasePage = { error: { message: string } | null; }; +type XpAuditRow = { + id: number; + user_id: string; + source: string; + ref_id: string; + repo: string | null; + xp_delta: number; + created_at: string; +}; + +type ReviewAuditRow = { + id: number; + pr_id: number; + reviewer_user_id: string | null; + state: string; + submitted_at: string; + pull_requests: + | { + author_user_id: string | null; + repo_full_name: string | null; + number: number | null; + } + | { + author_user_id: string | null; + repo_full_name: string | null; + number: number | null; + }[] + | null; +}; + async function fetchAllAuditRows( buildQuery: (from: number, to: number) => PromiseLike>, ): Promise { @@ -44,26 +74,28 @@ export const suspiciousXpAudit = inngest.createFunction( const since = new Date(Date.now() - LOOKBACK_DAYS * 24 * 60 * 60 * 1000).toISOString(); const [xpRows, reviewRows] = await Promise.all([ - fetchAllAuditRows((from, to) => - sb - .from('xp_events') - .select('id, user_id, source, ref_id, repo, xp_delta, created_at') - .gte('created_at', since) - .order('created_at', { ascending: true }) - .order('id', { ascending: true }) - .range(from, to), + fetchAllAuditRows( + (from, to) => + sb + .from('xp_events') + .select('id, user_id, source, ref_id, repo, xp_delta, created_at') + .gte('created_at', since) + .order('created_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, ), - fetchAllAuditRows((from, to) => - sb - .from('pull_request_reviews') - .select( - 'id, pr_id, reviewer_user_id, state, submitted_at, ' + - 'pull_requests!inner(author_user_id, repo_full_name, number)', - ) - .gte('submitted_at', since) - .order('submitted_at', { ascending: true }) - .order('id', { ascending: true }) - .range(from, to), + fetchAllAuditRows( + (from, to) => + sb + .from('pull_request_reviews') + .select( + 'id, pr_id, reviewer_user_id, state, submitted_at, ' + + 'pull_requests!inner(author_user_id, repo_full_name, number)', + ) + .gte('submitted_at', since) + .order('submitted_at', { ascending: true }) + .order('id', { ascending: true }) + .range(from, to) as unknown as PromiseLike>, ), ]); @@ -78,9 +110,7 @@ export const suspiciousXpAudit = inngest.createFunction( })); const reviewEvents: ReviewAuditEvent[] = reviewRows.map((row) => { - const pr = Array.isArray(row.pull_requests) - ? row.pull_requests[0] - : row.pull_requests; + const pr = Array.isArray(row.pull_requests) ? row.pull_requests[0] : row.pull_requests; return { id: row.id, prId: row.pr_id, From 3acdb05a871c682cd2f109e0a64836d2a8a1c015 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar Bajpai Date: Mon, 18 May 2026 11:16:01 +0530 Subject: [PATCH 4/4] style: format suspicious XP audit changes --- src/app/(app)/maintainer/page.tsx | 2 +- src/lib/db/schema.ts | 8 ++++++-- src/lib/xp/suspicious-flags.ts | 8 ++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx index 381c5cd..080abac 100644 --- a/src/app/(app)/maintainer/page.tsx +++ b/src/app/(app)/maintainer/page.tsx @@ -330,7 +330,7 @@ function FlaggedAccountsPanel({ flags }: { flags: MaintainerFlagRow[] }) { {reasonLabel(flag.reason)} - {flag.githubHandle ? `@${flag.githubHandle}` : flag.userId ?? 'unknown user'} + {flag.githubHandle ? `@${flag.githubHandle}` : (flag.userId ?? 'unknown user')} {new Date(flag.createdAt).toLocaleString()} diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 9ef8c9c..c4d5bb4 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -333,8 +333,12 @@ export const flaggedAccounts = pgTable( reason: text('reason', { enum: ['daily_event_burst', 'hourly_merge_burst', 'review_pair_burst'], }).notNull(), - severity: text('severity', { enum: ['medium', 'high'] }).notNull().default('medium'), - status: text('status', { enum: ['open', 'resolved', 'dismissed'] }).notNull().default('open'), + severity: text('severity', { enum: ['medium', 'high'] }) + .notNull() + .default('medium'), + status: text('status', { enum: ['open', 'resolved', 'dismissed'] }) + .notNull() + .default('open'), dedupeKey: text('dedupe_key').notNull(), evidence: jsonb('evidence').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), diff --git a/src/lib/xp/suspicious-flags.ts b/src/lib/xp/suspicious-flags.ts index 663ac9d..057dba6 100644 --- a/src/lib/xp/suspicious-flags.ts +++ b/src/lib/xp/suspicious-flags.ts @@ -39,10 +39,7 @@ export type FlagCandidate = { evidence: Record; }; -const MERGE_SOURCES = new Set([ - XP_SOURCE.RECOMMENDED_MERGE, - XP_SOURCE.UNRECOMMENDED_MERGE, -]); +const MERGE_SOURCES = new Set([XP_SOURCE.RECOMMENDED_MERGE, XP_SOURCE.UNRECOMMENDED_MERGE]); export function detectSuspiciousXpPatterns(args: { xpEvents: XpAuditEvent[]; @@ -102,8 +99,7 @@ function detectHourlyMergeBursts(events: XpAuditEvent[]): FlagCandidate[] { const window = sorted.filter((event) => { const eventTime = ts(event.createdAt); return ( - eventTime >= windowStart && - eventTime - windowStart <= XP_FLAG_THRESHOLDS.hourlyWindowMs + eventTime >= windowStart && eventTime - windowStart <= XP_FLAG_THRESHOLDS.hourlyWindowMs ); }); if (window.length <= XP_FLAG_THRESHOLDS.hourlyMergeCount) continue;