abuse.ts375 lines · main
| 1 | import { newId, NotFoundError } from '@briven/shared'; |
| 2 | import { and, desc, eq } from 'drizzle-orm'; |
| 3 | |
| 4 | import { getDb } from '../db/client.js'; |
| 5 | import { abuseReports, projects } from '../db/schema.js'; |
| 6 | import { log } from '../lib/logger.js'; |
| 7 | import { audit } from './audit.js'; |
| 8 | import { 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 | |
| 27 | export const ABUSE_SEVERITY = [ |
| 28 | 'spam', |
| 29 | 'phishing', |
| 30 | 'malware', |
| 31 | 'csam', |
| 32 | 'tos', |
| 33 | 'other', |
| 34 | ] as const; |
| 35 | export type AbuseSeverity = (typeof ABUSE_SEVERITY)[number]; |
| 36 | |
| 37 | export const ABUSE_RESOLUTION = [ |
| 38 | 'no_action', |
| 39 | 'warned', |
| 40 | 'suspended', |
| 41 | 'banned', |
| 42 | ] as const; |
| 43 | export type AbuseResolution = (typeof ABUSE_RESOLUTION)[number]; |
| 44 | |
| 45 | export type AbuseStatus = 'open' | 'triaged' | 'resolved'; |
| 46 | |
| 47 | export 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 | |
| 56 | export 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 | |
| 68 | export 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 | */ |
| 107 | export 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 | |
| 137 | export interface TriageInput { |
| 138 | reportId: string; |
| 139 | triagerId: string; |
| 140 | ipHash: string | null; |
| 141 | userAgent: string | null; |
| 142 | notes?: string; |
| 143 | } |
| 144 | |
| 145 | export 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 | |
| 178 | export 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 | |
| 191 | export 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 | */ |
| 269 | export 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 | |
| 319 | export 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 | */ |
| 364 | export 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 | } |