diff --git a/src/app/(app)/maintainer/page.tsx b/src/app/(app)/maintainer/page.tsx
index 4e5987c..0610ec8 100644
--- a/src/app/(app)/maintainer/page.tsx
+++ b/src/app/(app)/maintainer/page.tsx
@@ -7,7 +7,9 @@ import {
getMaintainerPrQueue,
getRepoHealthOverview,
getStaleIssues,
+ getFlaggedAccounts,
getTopContributors,
+ type FlaggedAccountRow,
type MaintainerInstall,
type MaintainerPrRow,
type RepoHealthRow,
@@ -82,6 +84,10 @@ export default async function MaintainerPage({
const contributorsRes = await getTopContributors();
const topContributors: ContributorRow[] = isOk(contributorsRes) ? contributorsRes.data : [];
+ const flaggedAccountsRes = await getFlaggedAccounts();
+ const flaggedAccounts: FlaggedAccountRow[] = isOk(flaggedAccountsRes)
+ ? flaggedAccountsRes.data
+ : [];
return (
@@ -159,6 +165,50 @@ export default async function MaintainerPage({
{activeInstall.accountLogin} ({activeInstall.permissionLevel.replace('_', ' ')})
+ {flaggedAccounts.length > 0 && (
+
+
+
+
Suspicious XP Signals
+
+ Daily detector output for maintainer review.
+
+
+
+ {flaggedAccounts.length} open
+
+
+
+
+ {flaggedAccounts.map((flag) => (
+
+
+
+
@{flag.githubHandle}
+
+ Level {flag.level} · {flag.xp} XP
+
+
+
+ {flag.severity}
+
+
+
{formatFlagReason(flag.reason)}
+
{flag.summary}
+
+ Detected {relativeTime(flag.detectedAt)}
+
+
+ ))}
+
+
+ )}
Repository Health
@@ -373,6 +423,16 @@ function NoInstalls() {
);
}
+function formatFlagReason(reason: string) {
+ const labels: Record = {
+ daily_xp_event_spike: 'Daily XP event spike',
+ rapid_merge_spike: 'Rapid merge spike',
+ reviewer_approval_concentration: 'Reviewer approval concentration',
+ };
+
+ return labels[reason] ?? 'Suspicious activity';
+}
+
function NotConfigured() {
return (
diff --git a/src/app/actions/maintainer.ts b/src/app/actions/maintainer.ts
index de0204c..ebd9035 100644
--- a/src/app/actions/maintainer.ts
+++ b/src/app/actions/maintainer.ts
@@ -65,6 +65,18 @@ export type ContributorRow = {
level: number;
};
+export type FlaggedAccountRow = {
+ id: number;
+ githubHandle: string;
+ xp: number;
+ level: number;
+ reason: string;
+ severity: 'medium' | 'high';
+ detectedAt: string;
+ summary: string;
+ count: number;
+};
+
const ISSUE_BUCKETS = new Set
([
'needs-triage',
'in-progress',
@@ -821,3 +833,100 @@ export async function getTopContributors(): Promise> {
})),
);
}
+
+export async function getFlaggedAccounts(): 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');
+ }
+
+ await rateLimit({
+ namespace: 'maintainer',
+ key: user.id,
+ limit: 30,
+ windowSec: 60,
+ });
+
+ if (!(await isUserMaintainer(user.id))) {
+ return err('not_authorised', 'not a maintainer');
+ }
+
+ const { data: flags, error } = await service
+ .from('flagged_accounts')
+ .select('id, user_id, reason, severity, evidence, detected_at')
+ .eq('status', 'open')
+ .order('detected_at', { ascending: false })
+ .limit(10);
+
+ if (error) {
+ return err('query_failed', error.message);
+ }
+
+ const userIds = Array.from(new Set((flags ?? []).map((flag) => flag.user_id).filter(Boolean)));
+ const { data: profiles, error: profilesError } =
+ userIds.length > 0
+ ? await service.from('profiles').select('id, github_handle, xp, level').in('id', userIds)
+ : { data: [], error: null };
+
+ if (profilesError) {
+ return err('query_failed', profilesError.message);
+ }
+
+ const profilesById = new Map(
+ (profiles ?? []).map((profile) => [
+ profile.id,
+ {
+ githubHandle: profile.github_handle ?? 'unknown',
+ xp: profile.xp ?? 0,
+ level: profile.level ?? 0,
+ },
+ ]),
+ );
+
+ return ok(
+ (flags ?? []).map((flag) => {
+ const profile = profilesById.get(flag.user_id ?? '');
+ const evidence = readFlagEvidence(flag.evidence);
+
+ return {
+ id: flag.id,
+ githubHandle: profile?.githubHandle ?? 'unknown',
+ xp: profile?.xp ?? 0,
+ level: profile?.level ?? 0,
+ reason: flag.reason,
+ severity: flag.severity === 'high' ? 'high' : 'medium',
+ detectedAt: flag.detected_at,
+ summary: evidence.summary,
+ count: evidence.count,
+ };
+ }),
+ );
+}
+
+function readFlagEvidence(evidence: unknown) {
+ if (!evidence || typeof evidence !== 'object') {
+ return { summary: 'Suspicious activity pattern detected.', count: 0 };
+ }
+
+ const record = evidence as Record;
+ return {
+ summary:
+ typeof record.summary === 'string' ? record.summary : 'Suspicious activity pattern detected.',
+ count: typeof record.count === 'number' ? record.count : 0,
+ };
+}
diff --git a/src/app/api/inngest/route.ts b/src/app/api/inngest/route.ts
index 29260f6..05cc95c 100644
--- a/src/app/api/inngest/route.ts
+++ b/src/app/api/inngest/route.ts
@@ -20,6 +20,7 @@ import {
streakDetect,
recsExpire,
activityLogCleanup,
+ flagSuspiciousXpAccounts,
autoUnclaimStale,
} from '@/inngest/functions/maintenance';
import { githubStatsSync } from '@/inngest/functions/github-stats-sync';
@@ -45,6 +46,7 @@ export const { GET, POST, PUT } = serve({
streakDetect,
recsExpire,
activityLogCleanup,
+ flagSuspiciousXpAccounts,
autoUnclaimStale,
githubStatsSync,
mentorPostComment,
diff --git a/src/inngest/functions/maintenance.ts b/src/inngest/functions/maintenance.ts
index 00d1b83..a576327 100644
--- a/src/inngest/functions/maintenance.ts
+++ b/src/inngest/functions/maintenance.ts
@@ -2,6 +2,53 @@ import { inngest } from '../client';
import { getServiceSupabase } from '@/lib/supabase/service';
import { insertXpEvent } from '@/lib/xp/events';
import { XP_REWARDS, XP_SOURCE, refIds } from '@/lib/xp/sources';
+import {
+ detectSuspiciousPatterns,
+ type SuspiciousMergedPr,
+ type SuspiciousReview,
+ type SuspiciousXpEvent,
+} from '@/lib/xp/suspicious-patterns';
+
+const AUDIT_PAGE_SIZE = 1000;
+const AUDIT_FILTER_CHUNK_SIZE = 500;
+
+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 ?? 'Supabase audit query failed');
+
+ const page = data ?? [];
+ rows.push(...page);
+
+ if (page.length < AUDIT_PAGE_SIZE) {
+ return rows;
+ }
+ }
+}
+
+async function fetchChunkedAuditRows(
+ filters: TFilter[],
+ buildQuery: (chunk: TFilter[], from: number, to: number) => PromiseLike>,
+): Promise {
+ const rows: T[] = [];
+
+ for (let start = 0; start < filters.length; start += AUDIT_FILTER_CHUNK_SIZE) {
+ const chunk = filters.slice(start, start + AUDIT_FILTER_CHUNK_SIZE);
+ rows.push(...(await fetchAllAuditRows((from, to) => buildQuery(chunk, from, to))));
+ }
+
+ return rows;
+}
/**
* Daily streak detection — gives +10 XP/day to users who had any qualifying
@@ -84,6 +131,151 @@ export const activityLogCleanup = inngest.createFunction(
},
);
+/**
+ * Daily conservative fraud signal detection. This only flags accounts for
+ * maintainer review; it never changes XP, labels, or profile state.
+ */
+export const flagSuspiciousXpAccounts = inngest.createFunction(
+ { id: 'flag-suspicious-xp-accounts' },
+ { cron: '30 0 * * *' }, // 00:30 UTC daily, after streaks and cleanup
+ async ({ step }) => {
+ return await step.run('detect-and-store-flags', async () => {
+ const sb = getServiceSupabase();
+ if (!sb) throw new Error('service role missing');
+ const service = sb;
+
+ const dayEndDate = startOfUtcDay(new Date());
+ const dayStartDate = new Date(dayEndDate.getTime() - 24 * 60 * 60 * 1000);
+ const weekStartDate = new Date(dayEndDate.getTime() - 7 * 24 * 60 * 60 * 1000);
+
+ const dayStart = dayStartDate.toISOString();
+ const dayEnd = dayEndDate.toISOString();
+ const weekStart = weekStartDate.toISOString();
+ const weekEnd = dayEnd;
+
+ const [xpRows, mergedRows, reviewRows] = await Promise.all([
+ fetchAllAuditRows(
+ (from, to) =>
+ service
+ .from('xp_events')
+ .select('id, user_id, source, ref_id, repo, xp_delta, created_at')
+ .gte('created_at', dayStart)
+ .lt('created_at', dayEnd)
+ .order('created_at', { ascending: true })
+ .order('id', { ascending: true })
+ .range(from, to) as unknown as PromiseLike>,
+ ),
+ fetchAllAuditRows(
+ (from, to) =>
+ service
+ .from('pull_requests')
+ .select('id, repo_full_name, number, title, author_login, author_user_id, merged_at')
+ .eq('state', 'merged')
+ .gte('merged_at', dayStart)
+ .lt('merged_at', dayEnd)
+ .order('merged_at', { ascending: true })
+ .order('id', { ascending: true })
+ .range(from, to) as unknown as PromiseLike>,
+ ),
+ fetchAllAuditRows(
+ (from, to) =>
+ service
+ .from('pull_request_reviews')
+ .select('id, pr_id, reviewer_login, reviewer_user_id, state, submitted_at')
+ .eq('state', 'approved')
+ .gte('submitted_at', weekStart)
+ .lt('submitted_at', weekEnd)
+ .order('submitted_at', { ascending: true })
+ .order('id', { ascending: true })
+ .range(from, to) as unknown as PromiseLike>,
+ ),
+ ]);
+
+ const reviewPrIds = Array.from(
+ new Set(reviewRows.map((row) => Number(row.pr_id)).filter(Number.isFinite)),
+ );
+ const reviewPrRows = await fetchPullRequestsById(reviewPrIds);
+ const mergedPullRequests = mergedRows.map(mapPullRequestRow);
+ const pullRequestsById = new Map();
+ for (const pr of [...mergedPullRequests, ...reviewPrRows]) {
+ pullRequestsById.set(pr.id, pr);
+ }
+
+ const candidates = detectSuspiciousPatterns({
+ xpEvents: xpRows.map(mapXpEventRow),
+ mergedPullRequests,
+ reviews: reviewRows.map(mapReviewRow),
+ pullRequestsById,
+ window: { dayStart, dayEnd, weekStart, weekEnd },
+ });
+
+ if (candidates.length === 0) {
+ return { scanned: true, inserted: 0, candidates: 0 };
+ }
+
+ const candidateUserIds = Array.from(new Set(candidates.map((candidate) => candidate.userId)));
+ const existingRows = await fetchChunkedAuditRows(
+ candidateUserIds,
+ (chunk, from, to) =>
+ service
+ .from('flagged_accounts')
+ .select('user_id, reason')
+ .eq('status', 'open')
+ .in('user_id', chunk)
+ .order('user_id', { ascending: true })
+ .order('reason', { ascending: true })
+ .range(from, to) as unknown as PromiseLike>,
+ );
+
+ const existing = new Set(existingRows.map((row) => `${row.user_id}:${row.reason}`));
+ const rowsToInsert = candidates
+ .filter((candidate) => !existing.has(`${candidate.userId}:${candidate.reason}`))
+ .map((candidate) => ({
+ user_id: candidate.userId,
+ reason: candidate.reason,
+ severity: candidate.severity,
+ status: 'open',
+ evidence: candidate.evidence,
+ }));
+
+ if (rowsToInsert.length === 0) {
+ return { scanned: true, inserted: 0, candidates: candidates.length };
+ }
+
+ const { data: insertedRows, error: insertError } = await service
+ .from('flagged_accounts')
+ .insert(rowsToInsert)
+ .select('id');
+ if (insertError) throw insertError;
+
+ return {
+ scanned: true,
+ inserted: insertedRows?.length ?? 0,
+ candidates: candidates.length,
+ };
+
+ async function fetchPullRequestsById(ids: number[]) {
+ if (ids.length === 0) return [];
+
+ return (
+ await fetchChunkedAuditRows(
+ ids,
+ (chunk, from, to) =>
+ service
+ .from('pull_requests')
+ .select(
+ 'id, repo_full_name, number, title, author_login, author_user_id, merged_at',
+ )
+ .in('id', chunk)
+ .order('id', { ascending: true })
+ .range(from, to) as unknown as PromiseLike>,
+ )
+ ).map(mapPullRequestRow);
+ }
+ });
+ },
+);
+
const CLAIM_STALE_THRESHOLD_DAYS = 14;
const CLAIM_WARNING_THRESHOLD_DAYS = 10;
@@ -161,3 +353,75 @@ export const autoUnclaimStale = inngest.createFunction(
return { ...unclaimResult, ...warnResult };
},
);
+
+function startOfUtcDay(date: Date) {
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
+}
+
+type XpEventAuditRow = {
+ id: number;
+ user_id: string | null;
+ source: string | null;
+ ref_id: string | null;
+ repo: string | null;
+ xp_delta: number | null;
+ created_at: string;
+};
+
+type PullRequestAuditRow = {
+ id: number;
+ repo_full_name: string;
+ number: number;
+ title: string;
+ author_login: string;
+ author_user_id: string | null;
+ merged_at: string | null;
+};
+
+type ReviewAuditRow = {
+ id: number;
+ pr_id: number;
+ reviewer_login: string;
+ reviewer_user_id: string | null;
+ state: string;
+ submitted_at: string;
+};
+
+type FlaggedAccountAuditRow = {
+ user_id: string;
+ reason: string;
+};
+
+function mapXpEventRow(row: XpEventAuditRow): SuspiciousXpEvent {
+ return {
+ userId: row.user_id,
+ source: row.source,
+ refId: row.ref_id,
+ repo: row.repo,
+ xpDelta: row.xp_delta,
+ createdAt: row.created_at,
+ };
+}
+
+function mapPullRequestRow(row: PullRequestAuditRow): SuspiciousMergedPr {
+ return {
+ id: row.id,
+ repoFullName: row.repo_full_name,
+ number: row.number,
+ title: row.title,
+ authorLogin: row.author_login,
+ authorUserId: row.author_user_id,
+ mergedAt: row.merged_at,
+ };
+}
+
+function mapReviewRow(row: ReviewAuditRow): SuspiciousReview {
+ return {
+ id: row.id,
+ prId: row.pr_id,
+ reviewerLogin: row.reviewer_login,
+ reviewerUserId: row.reviewer_user_id,
+ state: row.state,
+ submittedAt: row.submitted_at,
+ };
+}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index b84646b..6005e1e 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -325,6 +325,35 @@ export const activityLog = pgTable(
}),
);
+export const flaggedAccounts = pgTable(
+ 'flagged_accounts',
+ {
+ id: bigserial('id', { mode: 'number' }).primaryKey(),
+ userId: uuid('user_id').references(() => profiles.id, { onDelete: 'cascade' }),
+ reason: text('reason', {
+ enum: ['daily_xp_event_spike', 'rapid_merge_spike', 'reviewer_approval_concentration'],
+ }).notNull(),
+ severity: text('severity', { enum: ['medium', 'high'] })
+ .notNull()
+ .default('medium'),
+ status: text('status', { enum: ['open', 'reviewed', 'dismissed'] })
+ .notNull()
+ .default('open'),
+ evidence: jsonb('evidence').notNull().default({}),
+ detectedAt: timestamp('detected_at', { withTimezone: true }).notNull().defaultNow(),
+ resolvedAt: timestamp('resolved_at', { withTimezone: true }),
+ },
+ (t) => ({
+ uniqUserReasonStatus: uniqueIndex('flagged_accounts_user_reason_status_unique').on(
+ t.userId,
+ t.reason,
+ t.status,
+ ),
+ statusDetectedIdx: index('flagged_accounts_status_detected_idx').on(t.status, t.detectedAt),
+ userIdx: index('flagged_accounts_user_idx').on(t.userId),
+ }),
+);
+
// ========================================================================
// Maintainer-side tables (migration 0005)
// ========================================================================
diff --git a/src/lib/xp/suspicious-patterns.test.ts b/src/lib/xp/suspicious-patterns.test.ts
new file mode 100644
index 0000000..61c967e
--- /dev/null
+++ b/src/lib/xp/suspicious-patterns.test.ts
@@ -0,0 +1,109 @@
+import { describe, expect, it } from 'vitest';
+import {
+ detectDailyXpEventSpikes,
+ detectRapidMergeSpikes,
+ detectReviewerApprovalConcentration,
+ type SuspiciousMergedPr,
+ type SuspiciousReview,
+ type SuspiciousXpEvent,
+} from './suspicious-patterns';
+
+const userId = '00000000-0000-0000-0000-000000000001';
+const reviewerId = '00000000-0000-0000-0000-000000000002';
+
+describe('detectDailyXpEventSpikes', () => {
+ it('flags users with more than five XP events in a UTC day', () => {
+ const events: SuspiciousXpEvent[] = Array.from({ length: 6 }, (_, index) => ({
+ userId,
+ source: 'merge',
+ refId: `pr:${index}`,
+ repo: 'org/repo',
+ xpDelta: 10,
+ createdAt: `2026-05-19T0${index}:00:00Z`,
+ }));
+
+ const flags = detectDailyXpEventSpikes(events, {
+ dayStart: '2026-05-19T00:00:00.000Z',
+ dayEnd: '2026-05-20T00:00:00.000Z',
+ });
+
+ expect(flags).toHaveLength(1);
+ expect(flags[0]?.reason).toBe('daily_xp_event_spike');
+ expect(flags[0]?.evidence.count).toBe(6);
+ });
+
+ it('does not flag exactly five events', () => {
+ const events: SuspiciousXpEvent[] = Array.from({ length: 5 }, (_, index) => ({
+ userId,
+ source: 'merge',
+ refId: `pr:${index}`,
+ repo: 'org/repo',
+ xpDelta: 10,
+ createdAt: `2026-05-19T0${index}:00:00Z`,
+ }));
+
+ expect(
+ detectDailyXpEventSpikes(events, {
+ dayStart: '2026-05-19T00:00:00.000Z',
+ dayEnd: '2026-05-20T00:00:00.000Z',
+ }),
+ ).toHaveLength(0);
+ });
+});
+
+describe('detectRapidMergeSpikes', () => {
+ it('flags more than three merged PRs inside one hour', () => {
+ const prs: SuspiciousMergedPr[] = Array.from({ length: 4 }, (_, index) => ({
+ id: index + 1,
+ repoFullName: 'org/repo',
+ number: index + 10,
+ title: `PR ${index}`,
+ authorLogin: 'contributor',
+ authorUserId: userId,
+ mergedAt: `2026-05-19T10:${String(index * 10).padStart(2, '0')}:00Z`,
+ }));
+
+ const flags = detectRapidMergeSpikes(prs, {
+ dayStart: '2026-05-19T00:00:00.000Z',
+ dayEnd: '2026-05-20T00:00:00.000Z',
+ });
+
+ expect(flags).toHaveLength(1);
+ expect(flags[0]?.reason).toBe('rapid_merge_spike');
+ });
+});
+
+describe('detectReviewerApprovalConcentration', () => {
+ it('flags more than four approvals from one reviewer to one contributor in a week', () => {
+ const prs = new Map(
+ Array.from({ length: 5 }, (_, index) => [
+ index + 1,
+ {
+ id: index + 1,
+ repoFullName: 'org/repo',
+ number: index + 20,
+ title: `PR ${index}`,
+ authorLogin: 'contributor',
+ authorUserId: userId,
+ mergedAt: null,
+ },
+ ]),
+ );
+ const reviews: SuspiciousReview[] = Array.from({ length: 5 }, (_, index) => ({
+ id: index + 1,
+ prId: index + 1,
+ reviewerLogin: 'mentor',
+ reviewerUserId: reviewerId,
+ state: 'approved',
+ submittedAt: `2026-05-19T1${index}:00:00Z`,
+ }));
+
+ const flags = detectReviewerApprovalConcentration(reviews, prs, {
+ weekStart: '2026-05-13T00:00:00.000Z',
+ weekEnd: '2026-05-20T00:00:00.000Z',
+ });
+
+ expect(flags).toHaveLength(1);
+ expect(flags[0]?.reason).toBe('reviewer_approval_concentration');
+ });
+});
diff --git a/src/lib/xp/suspicious-patterns.ts b/src/lib/xp/suspicious-patterns.ts
new file mode 100644
index 0000000..46614b6
--- /dev/null
+++ b/src/lib/xp/suspicious-patterns.ts
@@ -0,0 +1,217 @@
+export const SUSPICIOUS_XP_THRESHOLDS = {
+ dailyXpEvents: 5,
+ hourlyMerges: 3,
+ weeklyReviewerApprovals: 4,
+} as const;
+
+export type SuspiciousXpEvent = {
+ userId: string | null;
+ source: string | null;
+ refId: string | null;
+ repo: string | null;
+ xpDelta: number | null;
+ createdAt: string;
+};
+
+export type SuspiciousMergedPr = {
+ id: number;
+ repoFullName: string;
+ number: number;
+ title: string;
+ authorLogin: string;
+ authorUserId: string | null;
+ mergedAt: string | null;
+};
+
+export type SuspiciousReview = {
+ id: number;
+ prId: number;
+ reviewerLogin: string;
+ reviewerUserId: string | null;
+ state: string;
+ submittedAt: string;
+};
+
+export type SuspiciousFlagCandidate = {
+ userId: string;
+ reason: 'daily_xp_event_spike' | 'rapid_merge_spike' | 'reviewer_approval_concentration';
+ severity: 'medium' | 'high';
+ evidence: {
+ summary: string;
+ windowStart: string;
+ windowEnd: string;
+ count: number;
+ items: Array>;
+ };
+};
+
+type DetectionWindow = {
+ dayStart: string;
+ dayEnd: string;
+ weekStart: string;
+ weekEnd: string;
+};
+
+export function detectSuspiciousPatterns(args: {
+ xpEvents: SuspiciousXpEvent[];
+ mergedPullRequests: SuspiciousMergedPr[];
+ reviews: SuspiciousReview[];
+ pullRequestsById: Map;
+ window: DetectionWindow;
+}): SuspiciousFlagCandidate[] {
+ return [
+ ...detectDailyXpEventSpikes(args.xpEvents, args.window),
+ ...detectRapidMergeSpikes(args.mergedPullRequests, args.window),
+ ...detectReviewerApprovalConcentration(args.reviews, args.pullRequestsById, args.window),
+ ];
+}
+
+export function detectDailyXpEventSpikes(
+ xpEvents: SuspiciousXpEvent[],
+ window: Pick,
+): SuspiciousFlagCandidate[] {
+ const byUser = new Map();
+
+ for (const event of xpEvents) {
+ if (!event.userId) continue;
+ const bucket = byUser.get(event.userId) ?? [];
+ bucket.push(event);
+ byUser.set(event.userId, bucket);
+ }
+
+ const candidates: SuspiciousFlagCandidate[] = [];
+ for (const [userId, events] of byUser) {
+ if (events.length <= SUSPICIOUS_XP_THRESHOLDS.dailyXpEvents) continue;
+
+ const totalXp = events.reduce((sum, event) => sum + (event.xpDelta ?? 0), 0);
+ candidates.push({
+ userId,
+ reason: 'daily_xp_event_spike',
+ severity: events.length >= SUSPICIOUS_XP_THRESHOLDS.dailyXpEvents * 2 ? 'high' : 'medium',
+ evidence: {
+ summary: `${events.length} XP events in one UTC day (${totalXp} XP total).`,
+ windowStart: window.dayStart,
+ windowEnd: window.dayEnd,
+ count: events.length,
+ items: events.slice(0, 20).map((event) => ({
+ source: event.source,
+ refId: event.refId,
+ repo: event.repo,
+ xpDelta: event.xpDelta,
+ createdAt: event.createdAt,
+ })),
+ },
+ });
+ }
+
+ return candidates;
+}
+
+export function detectRapidMergeSpikes(
+ mergedPullRequests: SuspiciousMergedPr[],
+ window: Pick,
+): SuspiciousFlagCandidate[] {
+ const byUser = new Map();
+
+ for (const pr of mergedPullRequests) {
+ if (!pr.authorUserId || !pr.mergedAt) continue;
+ const bucket = byUser.get(pr.authorUserId) ?? [];
+ bucket.push(pr);
+ byUser.set(pr.authorUserId, bucket);
+ }
+
+ const candidates: SuspiciousFlagCandidate[] = [];
+ for (const [userId, prs] of byUser) {
+ const sorted = prs
+ .slice()
+ .sort((a, b) => Date.parse(a.mergedAt ?? '') - Date.parse(b.mergedAt ?? ''));
+
+ for (let start = 0; start < sorted.length; start += 1) {
+ const startPr = sorted[start];
+ if (!startPr?.mergedAt) continue;
+
+ const startMs = Date.parse(startPr.mergedAt);
+ const oneHourLater = startMs + 60 * 60 * 1000;
+ const burst = sorted.filter((pr) => {
+ if (!pr.mergedAt) return false;
+ const mergedMs = Date.parse(pr.mergedAt);
+ return mergedMs >= startMs && mergedMs <= oneHourLater;
+ });
+
+ if (burst.length <= SUSPICIOUS_XP_THRESHOLDS.hourlyMerges) continue;
+
+ candidates.push({
+ userId,
+ reason: 'rapid_merge_spike',
+ severity: burst.length >= SUSPICIOUS_XP_THRESHOLDS.hourlyMerges + 3 ? 'high' : 'medium',
+ evidence: {
+ summary: `${burst.length} merged PRs landed inside one hour.`,
+ windowStart: startPr.mergedAt,
+ windowEnd: new Date(oneHourLater).toISOString(),
+ count: burst.length,
+ items: burst.slice(0, 20).map((pr) => ({
+ repoFullName: pr.repoFullName,
+ number: pr.number,
+ title: pr.title,
+ authorLogin: pr.authorLogin,
+ mergedAt: pr.mergedAt,
+ })),
+ },
+ });
+ break;
+ }
+ }
+
+ return candidates;
+}
+
+export function detectReviewerApprovalConcentration(
+ reviews: SuspiciousReview[],
+ pullRequestsById: Map,
+ window: Pick,
+): SuspiciousFlagCandidate[] {
+ const byPair = new Map();
+
+ for (const review of reviews) {
+ if (review.state !== 'approved' || !review.reviewerUserId) continue;
+ const pr = pullRequestsById.get(review.prId);
+ if (!pr?.authorUserId) continue;
+
+ const key = `${pr.authorUserId}:${review.reviewerUserId}`;
+ const bucket = byPair.get(key) ?? { contributorId: pr.authorUserId, reviews: [] };
+ bucket.reviews.push(review);
+ byPair.set(key, bucket);
+ }
+
+ const candidates: SuspiciousFlagCandidate[] = [];
+ for (const { contributorId, reviews: pairReviews } of byPair.values()) {
+ if (pairReviews.length <= SUSPICIOUS_XP_THRESHOLDS.weeklyReviewerApprovals) continue;
+
+ candidates.push({
+ userId: contributorId,
+ reason: 'reviewer_approval_concentration',
+ severity:
+ pairReviews.length >= SUSPICIOUS_XP_THRESHOLDS.weeklyReviewerApprovals + 3
+ ? 'high'
+ : 'medium',
+ evidence: {
+ summary: `${pairReviews.length} approvals from the same reviewer in one week.`,
+ windowStart: window.weekStart,
+ windowEnd: window.weekEnd,
+ count: pairReviews.length,
+ items: pairReviews.slice(0, 20).map((review) => {
+ const pr = pullRequestsById.get(review.prId);
+ return {
+ reviewerLogin: review.reviewerLogin,
+ repoFullName: pr?.repoFullName ?? null,
+ number: pr?.number ?? null,
+ title: pr?.title ?? null,
+ submittedAt: review.submittedAt,
+ };
+ }),
+ },
+ });
+ }
+
+ return candidates;
+}
diff --git a/supabase/migrations/0012_flagged_accounts.sql b/supabase/migrations/0012_flagged_accounts.sql
new file mode 100644
index 0000000..9ebdd4e
--- /dev/null
+++ b/supabase/migrations/0012_flagged_accounts.sql
@@ -0,0 +1,29 @@
+-- Suspicious XP / review pattern flags for maintainer review.
+-- Detection rows are written by scheduled service-role jobs and read by
+-- authenticated maintainers through server actions.
+
+create table if not exists flagged_accounts (
+ id bigserial primary key,
+ user_id uuid references profiles(id) on delete cascade,
+ reason text not null check (
+ reason in (
+ 'daily_xp_event_spike',
+ 'rapid_merge_spike',
+ 'reviewer_approval_concentration'
+ )
+ ),
+ severity text not null default 'medium' check (severity in ('medium', 'high')),
+ status text not null default 'open' check (status in ('open', 'reviewed', 'dismissed')),
+ evidence jsonb not null default '{}'::jsonb,
+ detected_at timestamptz not null default now(),
+ resolved_at timestamptz,
+ unique (user_id, reason, status)
+);
+
+create index if not exists flagged_accounts_status_detected_idx
+ on flagged_accounts (status, detected_at desc);
+create index if not exists flagged_accounts_user_idx
+ on flagged_accounts (user_id);
+
+alter table flagged_accounts enable row level security;
+-- service-role only: no public policies by design.