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;