abuse.ts375 lines · main
1import { newId, NotFoundError } from '@briven/shared';
2import { and, desc, eq } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { abuseReports, projects } from '../db/schema.js';
6import { log } from '../lib/logger.js';
7import { audit } from './audit.js';
8import { publishEvent } from './outbound-webhooks.js';
9
10/**
11 * Phase 3 abuse-report pipeline.
12 *
13 * Reports persist in the dedicated `abuse_reports` table (the §cross-
14 * cutting cleanup that was previously gated on drizzle-kit unblock has
15 * shipped — see migration 0023). audit_logs still receives one row per
16 * state transition for the security-log perspective: a single source of
17 * truth for "who did what when" across the platform, where abuse-row
18 * UPDATEs would otherwise hide intent.
19 *
20 * Lifecycle: open → triaged → resolved (resolution ∈ {no_action,
21 * warned, suspended, banned}). Suspended/banned resolutions flip
22 * projects.suspended_at on the named project_id when provided; the
23 * outbound webhook fan-out (project.suspended) follows in the
24 * suspendProject path below.
25 */
26
27export const ABUSE_SEVERITY = [
28 'spam',
29 'phishing',
30 'malware',
31 'csam',
32 'tos',
33 'other',
34] as const;
35export type AbuseSeverity = (typeof ABUSE_SEVERITY)[number];
36
37export const ABUSE_RESOLUTION = [
38 'no_action',
39 'warned',
40 'suspended',
41 'banned',
42] as const;
43export type AbuseResolution = (typeof ABUSE_RESOLUTION)[number];
44
45export type AbuseStatus = 'open' | 'triaged' | 'resolved';
46
47export interface CreateAbuseReportInput {
48 targetUrl: string;
49 reason: string;
50 severity: AbuseSeverity;
51 reporterContact: string | null;
52 ipHash: string | null;
53 userAgent: string | null;
54}
55
56export interface AbuseReportSummary {
57 reportId: string;
58 targetUrl: string;
59 reason: string;
60 severity: AbuseSeverity;
61 status: AbuseStatus;
62 reporterContact: string | null;
63 createdAt: Date;
64 lastActionAt: Date;
65 resolution: AbuseResolution | null;
66}
67
68export async function createAbuseReport(input: CreateAbuseReportInput): Promise<{
69 reportId: string;
70}> {
71 const reportId = newId('ar');
72 const db = getDb();
73 await db.insert(abuseReports).values({
74 id: reportId,
75 targetUrl: input.targetUrl,
76 reason: input.reason,
77 severity: input.severity,
78 reporterContact: input.reporterContact,
79 sourceIpHash: input.ipHash,
80 sourceUserAgent: input.userAgent,
81 // status defaults to 'open' at the DB layer.
82 });
83 // Audit-trail companion. Lets a security review see the lifecycle in
84 // the same place as every other platform action — abuse_reports is
85 // the source of truth, audit_logs is the cross-system narrative.
86 await audit({
87 actorId: null,
88 projectId: null,
89 action: 'abuse.report.created',
90 ipHash: input.ipHash,
91 userAgent: input.userAgent,
92 metadata: {
93 reportId,
94 targetUrl: input.targetUrl,
95 reason: input.reason,
96 severity: input.severity,
97 reporterContact: input.reporterContact,
98 },
99 });
100 return { reportId };
101}
102
103/**
104 * List abuse reports filtered by status. Single indexed read from the
105 * dedicated table, ordered by created_at desc.
106 */
107export async function listAbuseReports(
108 filter: { status?: AbuseStatus; limit?: number } = {},
109): Promise<AbuseReportSummary[]> {
110 const db = getDb();
111 const rows = filter.status
112 ? await db
113 .select()
114 .from(abuseReports)
115 .where(eq(abuseReports.status, filter.status))
116 .orderBy(desc(abuseReports.createdAt))
117 .limit(filter.limit ?? 100)
118 : await db
119 .select()
120 .from(abuseReports)
121 .orderBy(desc(abuseReports.createdAt))
122 .limit(filter.limit ?? 100);
123
124 return rows.map((r) => ({
125 reportId: r.id,
126 targetUrl: r.targetUrl,
127 reason: r.reason,
128 severity: r.severity,
129 status: r.status,
130 reporterContact: r.reporterContact,
131 createdAt: r.createdAt,
132 lastActionAt: r.updatedAt,
133 resolution: r.resolution,
134 }));
135}
136
137export interface TriageInput {
138 reportId: string;
139 triagerId: string;
140 ipHash: string | null;
141 userAgent: string | null;
142 notes?: string;
143}
144
145export async function triageAbuseReport(input: TriageInput): Promise<void> {
146 const db = getDb();
147 const now = new Date();
148 // Only transition `open` → `triaged`; an already-triaged or resolved
149 // report shouldn't bounce back. The WHERE guards the state machine.
150 const updated = await db
151 .update(abuseReports)
152 .set({
153 status: 'triaged',
154 triagedAt: now,
155 triagedBy: input.triagerId,
156 triageNotes: input.notes ?? null,
157 updatedAt: now,
158 })
159 .where(and(eq(abuseReports.id, input.reportId), eq(abuseReports.status, 'open')))
160 .returning({ id: abuseReports.id });
161 if (!updated[0]) {
162 throw new NotFoundError('abuse_report (open)', input.reportId);
163 }
164 await audit({
165 actorId: input.triagerId,
166 projectId: null,
167 action: 'abuse.report.triaged',
168 ipHash: input.ipHash,
169 userAgent: input.userAgent,
170 metadata: {
171 reportId: input.reportId,
172 triagerId: input.triagerId,
173 notes: input.notes ?? null,
174 },
175 });
176}
177
178export interface ResolveInput {
179 reportId: string;
180 resolverId: string;
181 resolution: AbuseResolution;
182 ipHash: string | null;
183 userAgent: string | null;
184 notes?: string;
185 // Optional — the project being suspended/banned. When provided AND
186 // the resolution is 'suspended' or 'banned', we flip projects.suspended_at.
187 // For 'no_action' / 'warned' resolutions this is ignored.
188 projectId?: string;
189}
190
191export async function resolveAbuseReport(input: ResolveInput): Promise<void> {
192 const shouldSuspend = input.resolution === 'suspended' || input.resolution === 'banned';
193 let suspended = false;
194 const db = getDb();
195 const now = new Date();
196
197 if (shouldSuspend && input.projectId) {
198 try {
199 const result = await db
200 .update(projects)
201 .set({
202 suspendedAt: now,
203 suspendReason: `abuse_report:${input.reportId}:${input.resolution}`,
204 updatedAt: now,
205 })
206 .where(eq(projects.id, input.projectId))
207 .returning({ id: projects.id });
208 suspended = result.length > 0;
209 if (!suspended) {
210 log.warn('abuse_resolve_project_not_found', {
211 reportId: input.reportId,
212 projectId: input.projectId,
213 });
214 }
215 } catch (err) {
216 // Don't block the report transition on a DB hiccup — the operator
217 // can suspend manually from the admin UI as a recovery path.
218 log.error('abuse_resolve_suspend_failed', {
219 reportId: input.reportId,
220 projectId: input.projectId,
221 message: err instanceof Error ? err.message : String(err),
222 });
223 }
224 }
225
226 // Transition the report row. Accept any prior non-resolved state so
227 // the operator can resolve directly from `open` without a separate
228 // triage click — common for quick-decision spam.
229 const updated = await db
230 .update(abuseReports)
231 .set({
232 status: 'resolved',
233 resolution: input.resolution,
234 resolvedAt: now,
235 resolvedBy: input.resolverId,
236 resolveNotes: input.notes ?? null,
237 projectId: input.projectId ?? null,
238 updatedAt: now,
239 })
240 .where(eq(abuseReports.id, input.reportId))
241 .returning({ id: abuseReports.id });
242 if (!updated[0]) {
243 throw new NotFoundError('abuse_report', input.reportId);
244 }
245
246 await audit({
247 actorId: input.resolverId,
248 projectId: input.projectId ?? null,
249 action: 'abuse.report.resolved',
250 ipHash: input.ipHash,
251 userAgent: input.userAgent,
252 metadata: {
253 reportId: input.reportId,
254 resolverId: input.resolverId,
255 resolution: input.resolution,
256 notes: input.notes ?? null,
257 projectId: input.projectId ?? null,
258 projectSuspended: suspended,
259 },
260 });
261}
262
263/**
264 * Manual suspension — for admin actions outside the abuse pipeline
265 * (e.g. operator notices something during a routine sweep). Mirrors the
266 * resolve path but doesn't need a report id. Returns true when the
267 * UPDATE matched a row.
268 */
269export async function suspendProject(args: {
270 projectId: string;
271 actorId: string;
272 reason: string;
273 ipHash: string | null;
274 userAgent: string | null;
275}): Promise<boolean> {
276 const db = getDb();
277 const result = await db
278 .update(projects)
279 .set({
280 suspendedAt: new Date(),
281 suspendReason: `manual:${args.reason}`,
282 updatedAt: new Date(),
283 })
284 .where(eq(projects.id, args.projectId))
285 .returning({ id: projects.id });
286 const ok = result.length > 0;
287 await audit({
288 actorId: args.actorId,
289 projectId: args.projectId,
290 action: 'admin.project.suspend',
291 ipHash: args.ipHash,
292 userAgent: args.userAgent,
293 metadata: { reason: args.reason, matched: ok },
294 });
295 if (ok) {
296 // Fan-out to any outbound webhook subscriber listening for
297 // `project.suspended`. Failure here doesn't unwind the suspension —
298 // the customer's ability to react quickly to a suspension is a
299 // nice-to-have, the suspension itself is the load-bearing action.
300 await publishEvent({
301 projectId: args.projectId,
302 eventType: 'project.suspended',
303 payload: {
304 projectId: args.projectId,
305 reason: args.reason,
306 suspendedAt: new Date().toISOString(),
307 },
308 }).catch((err: unknown) => {
309 log.warn('outbound_publish_failed', {
310 event: 'project.suspended',
311 projectId: args.projectId,
312 message: err instanceof Error ? err.message : String(err),
313 });
314 });
315 }
316 return ok;
317}
318
319export async function unsuspendProject(args: {
320 projectId: string;
321 actorId: string;
322 ipHash: string | null;
323 userAgent: string | null;
324}): Promise<boolean> {
325 const db = getDb();
326 const result = await db
327 .update(projects)
328 .set({ suspendedAt: null, suspendReason: null, updatedAt: new Date() })
329 .where(eq(projects.id, args.projectId))
330 .returning({ id: projects.id });
331 const ok = result.length > 0;
332 await audit({
333 actorId: args.actorId,
334 projectId: args.projectId,
335 action: 'admin.project.unsuspend',
336 ipHash: args.ipHash,
337 userAgent: args.userAgent,
338 metadata: { matched: ok },
339 });
340 if (ok) {
341 await publishEvent({
342 projectId: args.projectId,
343 eventType: 'project.resumed',
344 payload: {
345 projectId: args.projectId,
346 resumedAt: new Date().toISOString(),
347 },
348 }).catch((err: unknown) => {
349 log.warn('outbound_publish_failed', {
350 event: 'project.resumed',
351 projectId: args.projectId,
352 message: err instanceof Error ? err.message : String(err),
353 });
354 });
355 }
356 return ok;
357}
358
359/**
360 * Returns the suspension state for a project, or null when the project
361 * isn't suspended. Used by the project-suspended middleware to gate
362 * state-changing routes (invokes, deploys, env writes).
363 */
364export async function getProjectSuspension(
365 projectId: string,
366): Promise<{ suspendedAt: Date; reason: string | null } | null> {
367 const db = getDb();
368 const [row] = await db
369 .select({ suspendedAt: projects.suspendedAt, suspendReason: projects.suspendReason })
370 .from(projects)
371 .where(eq(projects.id, projectId))
372 .limit(1);
373 if (!row || !row.suspendedAt) return null;
374 return { suspendedAt: row.suspendedAt, reason: row.suspendReason };
375}