diff --git a/docs/fraud-signal.md b/docs/fraud-signal.md index 16e4e24..ca5400b 100644 --- a/docs/fraud-signal.md +++ b/docs/fraud-signal.md @@ -40,6 +40,7 @@ clusters suspicious addresses with **Union-Find**, and persists findings to * **Admin endpoints** — `src/routes/admin/fraud.ts`: * `GET /api/admin/fraud/flags?status=open&limit=50` — paginated review * `POST /api/admin/fraud/scan` — manual trigger (admin only) + * `PATCH /api/admin/fraud/flags/:id` — review transitions (`open→confirmed|dismissed`, `confirmed→dismissed` with note; `dismissed` is terminal). Invalid transitions return `409` with code `invalid_transition`; missing/required-note validation return `400` with code `validation_error`. Every transition creates an `audit_logs` entry with action `fraud_flag.review` (note stored in audit metadata only).Concurrent updates use optimistic status checks. All endpoints require an admin JWT (`role: "admin"`) and are rate-limited per-token (60 req/min by default). @@ -52,16 +53,42 @@ Migration `drizzle/migrations/0011_fraud_flags.sql`: * Creates `fraud_flags` with `(cluster_key, user_id)` unique index, status enum, evidence JSONB, score, and reviewer audit columns. +Migration `drizzle/migrations/0031_fraud_flags_carried_from.sql`: + +* Adds `fraud_flags.carried_from` UUID FK → `fraud_flags(id)` (ON DELETE SET NULL) + to track when a re-detected finding inherits a prior dismissal. + +## Carry-forward behavior + +When `runFraudScan` detects a finding for a user who already has the most +recent `dismissed` flag, it compares the new evidence's reason set against +the prior dismissal's reasons. If the new reasons are a subset of the prior +reasons (no new signal type appeared), the new flag is written as +`status='dismissed'` with `carried_from` set to the prior dismissal's id. If +any new reason appears, the finding is reopened as `status='open'`. Cluster +keys are SHA-256 of sorted member addresses (`|`-joined) for new scans. + +## Worker + +`src/workers/fraudDetector.ts` supports: +* `node dist/workers/fraudDetector.js` for one-off scans (exits on completion) +* `FRAUD_DETECTOR_INTERVAL_MS=` for recurring runs with SIGINT/SIGTERM + graceful shutdown (timer stopped, DB pool closed with timeout) +* `npm run fraud-detector` / `npm run fraud-detector:dev` scripts + ## Operational notes * Correlation IDs flow from the request (or are generated per scan) into - every log line and every persisted `fraud_flags.correlation_id`. + every log line and every persisted `fraud_flags.correlation_id`. Routes + echo `x-correlation-id` and `x-request-id` on responses. * The worker never throws — failures are logged and the next interval retries. This keeps the in-process scheduler stable. * `MIN_CLUSTER_SIZE = 2` keeps noise low. Tune via the constant in `fraudService.ts` if needed. * Default lookback is **24 h**, capped at 10 000 predictions per scan to protect memory on large datasets. +* `DrizzleFraudRepo` transition updates never touch `status/reviewedBy` via + `ON CONFLICT DO UPDATE` (idempotent re-scan / audit integrity). ## Testing diff --git a/drizzle/migrations/0031_fraud_flags_carried_from.sql b/drizzle/migrations/0031_fraud_flags_carried_from.sql new file mode 100644 index 0000000..f5ecb79 --- /dev/null +++ b/drizzle/migrations/0031_fraud_flags_carried_from.sql @@ -0,0 +1 @@ +ALTER TABLE "fraud_flags" ADD COLUMN IF NOT EXISTS "carried_from" uuid REFERENCES "fraud_flags"("id") ON DELETE SET NULL; diff --git a/openapi.yaml b/openapi.yaml index ee177f5..9542b5b 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -781,6 +781,181 @@ components: $ref: '#/components/schemas/FeatureFlag' required: - data + FraudFlagEvidenceEdge: + type: object + properties: + a: + type: string + b: + type: string + reason: + type: string + detail: + type: string + required: + - a + - b + - reason + - detail + FraudFlagEvidence: + type: object + properties: + clusterSize: + type: integer + members: + type: array + items: + type: string + edges: + type: array + items: + $ref: '#/components/schemas/FraudFlagEvidenceEdge' + edgeReasonCounts: + type: object + additionalProperties: + type: integer + FraudFlagStatus: + type: string + enum: + - open + - dismissed + - confirmed + FraudFlag: + type: object + properties: + id: + type: string + format: uuid + clusterKey: + type: string + userId: + type: string + format: uuid + stellarAddress: + type: string + reason: + type: string + evidence: + $ref: '#/components/schemas/FraudFlagEvidence' + score: + type: integer + status: + $ref: '#/components/schemas/FraudFlagStatus' + reviewedBy: + type: string + nullable: true + reviewedAt: + type: string + nullable: true + format: date-time + carriedFrom: + type: string + nullable: true + format: uuid + correlationId: + type: string + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + required: + - id + - clusterKey + - userId + - stellarAddress + - reason + - evidence + - score + - status + - reviewedBy + - reviewedAt + - correlationId + - createdAt + - updatedAt + FraudFlagListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/FraudFlag' + correlationId: + type: string + required: + - data + - correlationId + FraudScanResult: + type: object + properties: + scanned: + type: integer + edges: + type: integer + clusters: + type: integer + flagsWritten: + type: integer + correlationId: + type: string + nullable: true + required: + - scanned + - edges + - clusters + - flagsWritten + - correlationId + FraudScanResponse: + type: object + properties: + data: + $ref: '#/components/schemas/FraudScanResult' + correlationId: + type: string + required: + - data + - correlationId + FraudScanRequest: + type: object + properties: + lookbackMs: + type: integer + minimum: 0 + exclusiveMinimum: true + maximum: 604800000 + maxPredictions: + type: integer + minimum: 0 + exclusiveMinimum: true + maximum: 100000 + additionalProperties: false + FraudFlagResponse: + type: object + properties: + data: + $ref: '#/components/schemas/FraudFlag' + correlationId: + type: string + required: + - data + - correlationId + FraudFlagReviewRequest: + type: object + properties: + status: + type: string + enum: + - confirmed + - dismissed + note: + type: string + minLength: 1 + maxLength: 4000 + required: + - status + additionalProperties: false FeatureFlagResponse: type: object properties: @@ -3473,6 +3648,168 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorBody' + /api/admin/fraud/flags: + get: + operationId: listAdminFraudFlags + tags: + - Admin + summary: List fraud flags for review + security: + - bearerAuth: [] + parameters: + - schema: + type: string + enum: + - open + - dismissed + - confirmed + required: false + name: status + in: query + - schema: + type: string + pattern: ^\d+$ + required: false + name: limit + in: query + responses: + '200': + description: List of fraud flags + content: + application/json: + schema: + $ref: '#/components/schemas/FraudFlagListResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + /api/admin/fraud/scan: + post: + operationId: runAdminFraudScan + tags: + - Admin + summary: Trigger a fraud cluster scan + security: + - bearerAuth: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FraudScanRequest' + responses: + '200': + description: Scan summary + content: + application/json: + schema: + $ref: '#/components/schemas/FraudScanResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + /api/admin/fraud/flags/{id}: + patch: + operationId: reviewAdminFraudFlag + tags: + - Admin + summary: Review a fraud flag (confirm or dismiss) + security: + - bearerAuth: [] + parameters: + - schema: + type: string + format: uuid + required: true + name: id + in: path + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FraudFlagReviewRequest' + responses: + '200': + description: Flag reviewed + content: + application/json: + schema: + $ref: '#/components/schemas/FraudFlagResponse' + '400': + description: Validation error + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationErrorBody' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '404': + description: Fraud flag not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '409': + description: Invalid transition or concurrent modification + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' + '429': + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorBody' /api/admin/feature-flags/{key}: get: operationId: getAdminFeatureFlag diff --git a/package.json b/package.json index 19fe61d..29504a7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,9 @@ "indexer:gap-scan": "ts-node-dev --transpile-only src/workers/indexerGapScan.ts", "settle-confirmer": "node dist/workers/settleConfirmer.js", "settle-confirmer:dev": "ts-node-dev --respawn --transpile-only src/workers/settleConfirmer.ts", + "fraud-detector": "node dist/workers/fraudDetector.js", + "fraud-detector:dev": "ts-node-dev --respawn --transpile-only src/workers/fraudDetector.ts", + "fraud-detector": "node dist/workers/fraudDetector.js", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:check-drift": "ts-node scripts/check-drizzle-drift.ts", diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts index 391b5c9..87eb488 100644 --- a/scripts/check-openapi.ts +++ b/scripts/check-openapi.ts @@ -40,6 +40,9 @@ export const EXPECTED_ROUTES: RouteEntry[] = [ { method: "get", path: "/api/admin/feature-flags/{key}" }, { method: "patch", path: "/api/admin/feature-flags/{key}" }, { method: "delete", path: "/api/admin/feature-flags/{key}" }, + { method: "get", path: "/api/admin/fraud/flags" }, + { method: "post", path: "/api/admin/fraud/scan" }, + { method: "patch", path: "/api/admin/fraud/flags/{id}" }, { method: "post", path: "/api/admin/markets/{id}/feature" }, { method: "delete", path: "/api/admin/markets/{id}/feature" }, { method: "post", path: "/api/admin/force-resolve/{id}" }, diff --git a/src/db/schema.ts b/src/db/schema.ts index 0410751..eea4a76 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -9,6 +9,7 @@ import { jsonb, index, primaryKey, + type AnyPgColumn, uniqueIndex, } from "drizzle-orm/pg-core"; import { sql } from "drizzle-orm"; @@ -237,6 +238,11 @@ export const fraudFlags = pgTable( status: text("status").notNull().default("open"), reviewedBy: text("reviewed_by"), reviewedAt: timestamp("reviewed_at", { withTimezone: true }), + /** Prior flag whose dismissal was carried forward onto this row. */ + carriedFrom: uuid("carried_from").references( + (): AnyPgColumn => fraudFlags.id, + { onDelete: "set null" }, + ), correlationId: text("correlation_id"), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() diff --git a/src/index.ts b/src/index.ts index 1e4847f..db80aef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,7 @@ import { devicesRouter } from "./routes/devices"; import { devicesRevokeRouter } from "./routes/devicesRevoke"; import { featureFlagsRouter } from "./routes/feature-flags"; import { adminFeatureFlagsRouter } from "./routes/admin/featureFlags"; +import { adminFraudRouter } from "./routes/admin/fraud"; import { adminUsersRouter } from "./routes/adminUsers"; import { adminNotesRouter } from "./routes/admin/users/notes"; import { adminImpersonateRouter } from "./routes/admin/users/impersonate"; @@ -223,6 +224,7 @@ export function createApp(_options: CreateAppOptions = {}): express.Express { app.use("/api/admin/users", adminNotesRouter); app.use("/api/feature-flags", featureFlagsRouter); app.use("/api/admin/feature-flags", adminFeatureFlagsRouter); + app.use("/api/admin/fraud", adminFraudRouter); app.use("/api/admin/markets", adminMarketsRouter); app.use("/api/admin/schema-versions", adminSchemaVersionsRouter); app.use("/api/admin/rate-limit", adminRateLimitInspectRouter); diff --git a/src/openapi/registry.ts b/src/openapi/registry.ts index 30a50d0..29d20ac 100644 --- a/src/openapi/registry.ts +++ b/src/openapi/registry.ts @@ -1933,6 +1933,94 @@ const UpdateFeatureFlagRequest = z }) .openapi("UpdateFeatureFlagRequest"); +const FraudFlagStatus = z.enum(["open", "dismissed", "confirmed"]).openapi("FraudFlagStatus"); + +const FraudFlagEvidenceEdge = z + .object({ + a: z.string(), + b: z.string(), + reason: z.string(), + detail: z.string(), + }) + .passthrough() + .openapi("FraudFlagEvidenceEdge"); + +const FraudFlagEvidence = z + .object({ + clusterSize: z.number().int().optional(), + members: z.array(z.string()).optional(), + edges: z.array(FraudFlagEvidenceEdge).optional(), + edgeReasonCounts: z.record(z.string(), z.number().int()).optional(), + }) + .passthrough() + .openapi("FraudFlagEvidence"); + +const FraudFlag = z + .object({ + id: z.string().uuid(), + clusterKey: z.string(), + userId: z.string().uuid(), + stellarAddress: z.string(), + reason: z.string(), + evidence: FraudFlagEvidence, + score: z.number().int(), + status: FraudFlagStatus, + reviewedBy: z.string().nullable(), + reviewedAt: z.string().datetime().nullable(), + carriedFrom: z.string().uuid().nullable().optional(), + correlationId: z.string().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), + }) + .openapi("FraudFlag"); + +const FraudFlagListResponse = z + .object({ + data: z.array(FraudFlag), + correlationId: z.string(), + }) + .openapi("FraudFlagListResponse"); + +const FraudScanRequest = z + .object({ + lookbackMs: z.number().int().positive().max(7 * 24 * 60 * 60 * 1000).optional(), + maxPredictions: z.number().int().positive().max(100_000).optional(), + }) + .strict() + .openapi("FraudScanRequest"); + +const FraudScanResult = z + .object({ + scanned: z.number().int(), + edges: z.number().int(), + clusters: z.number().int(), + flagsWritten: z.number().int(), + correlationId: z.string().nullable(), + }) + .openapi("FraudScanResult"); + +const FraudScanResponse = z + .object({ + data: FraudScanResult, + correlationId: z.string(), + }) + .openapi("FraudScanResponse"); + +const FraudFlagReviewRequest = z + .object({ + status: z.enum(["confirmed", "dismissed"]), + note: z.string().min(1).max(4000).optional(), + }) + .strict() + .openapi("FraudFlagReviewRequest"); + +const FraudFlagResponse = z + .object({ + data: FraudFlag, + correlationId: z.string(), + }) + .openapi("FraudFlagResponse"); + registry.registerPath({ method: "get", path: "/api/admin/feature-flags", @@ -1962,6 +2050,126 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", + path: "/api/admin/fraud/flags", + operationId: "listAdminFraudFlags", + tags: ["Admin"], + summary: "List fraud flags for review", + security: [{ bearerAuth: [] }], + request: { + query: z.object({ + status: z.enum(["open", "dismissed", "confirmed"]).optional(), + limit: z.string().regex(/^\d+$/).optional(), + }), + }, + responses: { + 200: { + description: "List of fraud flags", + content: { + "application/json": { schema: FraudFlagListResponse }, + }, + }, + 400: { + description: "Validation error", + content: { "application/json": { schema: ValidationErrorBody } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorBody } }, + }, + 403: { + description: "Forbidden", + content: { "application/json": { schema: ErrorBody } }, + }, + 429: { + description: "Rate limit exceeded", + content: { "application/json": { schema: ErrorBody } }, + }, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/admin/fraud/scan", + operationId: "runAdminFraudScan", + tags: ["Admin"], + summary: "Trigger a fraud cluster scan", + security: [{ bearerAuth: [] }], + request: { + body: { content: { "application/json": { schema: FraudScanRequest } } }, + }, + responses: { + 200: { + description: "Scan summary", + content: { + "application/json": { schema: FraudScanResponse }, + }, + }, + 400: { + description: "Validation error", + content: { "application/json": { schema: ValidationErrorBody } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorBody } }, + }, + 403: { + description: "Forbidden", + content: { "application/json": { schema: ErrorBody } }, + }, + 429: { + description: "Rate limit exceeded", + content: { "application/json": { schema: ErrorBody } }, + }, + }, +}); + +registry.registerPath({ + method: "patch", + path: "/api/admin/fraud/flags/{id}", + operationId: "reviewAdminFraudFlag", + tags: ["Admin"], + summary: "Review a fraud flag (confirm or dismiss)", + security: [{ bearerAuth: [] }], + request: { + params: z.object({ id: z.string().uuid() }), + body: { content: { "application/json": { schema: FraudFlagReviewRequest } } }, + }, + responses: { + 200: { + description: "Flag reviewed", + content: { + "application/json": { schema: FraudFlagResponse }, + }, + }, + 400: { + description: "Validation error", + content: { "application/json": { schema: ValidationErrorBody } }, + }, + 401: { + description: "Unauthorized", + content: { "application/json": { schema: ErrorBody } }, + }, + 403: { + description: "Forbidden", + content: { "application/json": { schema: ErrorBody } }, + }, + 404: { + description: "Fraud flag not found", + content: { "application/json": { schema: ErrorBody } }, + }, + 409: { + description: "Invalid transition or concurrent modification", + content: { "application/json": { schema: ErrorBody } }, + }, + 429: { + description: "Rate limit exceeded", + content: { "application/json": { schema: ErrorBody } }, + }, + }, +}); + registry.registerPath({ method: "post", path: "/api/admin/feature-flags", diff --git a/src/routes/admin/fraud.ts b/src/routes/admin/fraud.ts index 5223b76..2f215a0 100644 --- a/src/routes/admin/fraud.ts +++ b/src/routes/admin/fraud.ts @@ -2,15 +2,17 @@ import { Router } from "express"; import { rateLimit } from "express-rate-limit"; import { z } from "zod"; import { requireAdmin } from "../../middleware/requireAdmin"; -import { CORRELATION_ID_HEADER } from "../../lib/http"; +import { CORRELATION_ID_HEADER, REQUEST_ID_HEADER } from "../../lib/http"; import { getCorrelationId } from "../../middleware/correlation"; import { DrizzleFraudRepo, + type FraudFlagDTO, type FraudRepo, listFraudFlags, runFraudScan, } from "../../services/fraudService"; -import { RouteErrorFactory } from "../../errors"; +import { createAuditLog } from "../../services/auditService"; +import { AppError, ValidationError } from "../../errors"; const listQuerySchema = z.object({ status: z.enum(["open", "dismissed", "confirmed"]).optional(), @@ -31,6 +33,46 @@ const scanBodySchema = z }) .strict(); +/** + * Review transitions accepted by PATCH /flags/:id. + * + * open → confirmed | dismissed + * confirmed → dismissed (note required) + * + * Everything else — including dismissed → confirmed — answers 409 + * `invalid_transition`. + */ +const reviewBodySchema = z + .object({ + status: z.enum(["confirmed", "dismissed"]), + note: z.string().min(1).max(4000).optional(), + }) + .strict(); + +type FlagStatus = "open" | "confirmed" | "dismissed"; + +function canTransition(from: FlagStatus, to: FlagStatus): boolean { + if (from === "open") return to === "confirmed" || to === "dismissed"; + if (from === "confirmed") return to === "dismissed"; + return false; // dismissed is terminal for this workflow +} + +function reviewState(flag: FraudFlagDTO): Record { + return { + id: flag.id, + clusterKey: flag.clusterKey, + userId: flag.userId, + stellarAddress: flag.stellarAddress, + reason: flag.reason, + score: flag.score, + status: flag.status, + reviewedBy: flag.reviewedBy, + reviewedAt: flag.reviewedAt instanceof Date ? flag.reviewedAt.toISOString() : flag.reviewedAt, + carriedFrom: flag.carriedFrom ?? null, + correlationId: flag.correlationId, + }; +} + export interface AdminFraudRouterOptions { repo?: FraudRepo; rateLimitPerMinute?: number; @@ -72,12 +114,13 @@ export function createAdminFraudRouter( const correlationId = requestIdOf({ id: (req as { id?: unknown }).id }); const parsed = listQuerySchema.safeParse(req.query); if (!parsed.success) { - throw RouteErrorFactory.validation( + throw new ValidationError( parsed.error.issues[0]?.message ?? "invalid query parameters", ); } const rows = await listFraudFlags(parsed.data, repo); res.setHeader(CORRELATION_ID_HEADER, correlationId); + res.setHeader(REQUEST_ID_HEADER, correlationId); res.json({ data: rows, correlationId }); } catch (e) { next(e); @@ -90,7 +133,7 @@ export function createAdminFraudRouter( const body = req.body ?? {}; const parsed = scanBodySchema.safeParse(body); if (!parsed.success) { - throw RouteErrorFactory.validation( + throw new ValidationError( parsed.error.issues[0]?.message ?? "invalid request body", ); } @@ -99,12 +142,82 @@ export function createAdminFraudRouter( correlationId, }); res.setHeader(CORRELATION_ID_HEADER, correlationId); + res.setHeader(REQUEST_ID_HEADER, correlationId); res.json({ data: result, correlationId }); } catch (e) { next(e); } }); + router.patch("/flags/:id", async (req, res, next) => { + try { + const correlationId = requestIdOf({ id: (req as { id?: unknown }).id }); + const id = req.params.id; + const parsed = reviewBodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + throw new ValidationError( + parsed.error.issues[0]?.message ?? "invalid request body", + ); + } + const target = parsed.data.status; + const note = parsed.data.note; + + const before = await repo.getFlag(id); + if (!before) { + throw AppError.notFound("Fraud flag not found"); + } + + const current = before.status as FlagStatus; + if (!canTransition(current, target)) { + throw new AppError( + "invalid_transition", + `cannot transition fraud flag from "${current}" to "${target}"`, + 409, + ); + } + if (current === "confirmed" && target === "dismissed" && !note) { + throw new ValidationError( + "note is required to dismiss a confirmed fraud flag", + ); + } + + // Optimistic concurrency: the repo re-checks `expectedStatus`, so a + // competing reviewer loses the race and lands here as a 409. + const after = await repo.transitionFlag({ + id, + expectedStatus: current, + status: target, + reviewedBy: req.adminAddress ?? "", + note, + }); + if (!after) { + throw new AppError( + "invalid_transition", + "fraud flag was modified by another reviewer", + 409, + ); + } + + await createAuditLog({ + action: "fraud_flag.review", + walletAddress: req.adminAddress, + ip: req.ip ?? "unknown", + correlationId, + entityType: "FraudFlag", + entityId: id, + beforeState: reviewState(before), + afterState: reviewState(after), + metadata: note !== undefined ? { note } : undefined, + }); + + res.setHeader(CORRELATION_ID_HEADER, correlationId); + res.setHeader(REQUEST_ID_HEADER, correlationId); + res.json({ data: after, correlationId }); + } catch (e) { + next(e); + } + }); + return router; } diff --git a/src/services/fraudService.ts b/src/services/fraudService.ts index 20b4801..6c99e40 100644 --- a/src/services/fraudService.ts +++ b/src/services/fraudService.ts @@ -34,7 +34,8 @@ * across the worker → service → repo boundary. */ -import { and, desc, eq, gte, sql } from "drizzle-orm"; +import { createHash } from "crypto"; +import { and, desc, eq, gte, inArray, sql } from "drizzle-orm"; import { db as defaultDb } from "../db"; import { fraudFlags, predictions, users } from "../db/schema"; import { logger } from "../config/logger"; @@ -120,6 +121,8 @@ export interface FraudFlagDTO { status: string; reviewedBy: string | null; reviewedAt: Date | null; + /** Prior flag this row's dismissal was carried forward from (if any). */ + carriedFrom: string | null; correlationId: string | null; createdAt: Date; updatedAt: Date; @@ -130,6 +133,18 @@ export interface ListFlagsFilters { limit?: number; } +export type FraudFlagStatus = "open" | "confirmed" | "dismissed"; + +/** Reviewer transition applied to a flag (see admin PATCH endpoint). */ +export interface FraudFlagTransition { + id: string; + expectedStatus: FraudFlagStatus; + status: FraudFlagStatus; + reviewedBy: string; + /** Stored in the audit trail only — never persisted on the flag row. */ + note?: string; +} + export interface FraudRepo { loadRecentPredictions(opts: { since: Date; @@ -137,6 +152,16 @@ export interface FraudRepo { }): Promise; upsertFlags(rows: FlagWriteInput[]): Promise; listFlags(filters: ListFlagsFilters): Promise; + /** Single flag by id, or null when it does not exist. */ + getFlag(id: string): Promise; + /** + * Optimistically transitions a flag: updates only when the current status + * still equals `expectedStatus`. Returns null when the row is missing or + * was concurrently changed by another reviewer. + */ + transitionFlag( + transition: FraudFlagTransition, + ): Promise; } // Edge weights — feed into both the cluster `score` and the per-row score. @@ -221,9 +246,67 @@ function orderedPair(x: string, y: string): [string, string] { return x < y ? [x, y] : [y, x]; } -/** Stable cluster key — sorted member addresses joined by `|`. */ +/** + * Stable cluster key — SHA-256 over the sorted member addresses joined by + * `|`. Content-addressed, so the key only changes when the cluster's + * membership changes (legacy pipe-joined keys stay valid as `carried_from` + * lineage anchors, but new writes always use the hash). + */ export function makeClusterKey(members: string[]): string { - return [...members].sort().join("|"); + const normalized = [...members].sort().join("|"); + return createHash("sha256").update(normalized).digest("hex"); +} + +/** + * Reason codes represented in a stored flag's evidence. Prefers the + * aggregated `edgeReasonCounts` keys and falls back to the per-edge + * `reason` values on older payloads. + */ +export function evidenceReasons(evidence: unknown): Set { + const ev = (evidence ?? {}) as Record; + const counts = ev.edgeReasonCounts; + if (counts && typeof counts === "object" && !Array.isArray(counts)) { + const keys = Object.keys(counts as Record).filter(Boolean); + if (keys.length > 0) return new Set(keys); + } + const edges = ev.edges; + if (Array.isArray(edges)) { + const out = new Set(); + for (const edge of edges) { + const reason = (edge as { reason?: unknown } | null)?.reason; + if (typeof reason === "string" && reason.length > 0) out.add(reason); + } + if (out.size > 0) return out; + } + return new Set(); +} + +export interface CarryDecision { + status: "open" | "dismissed"; + carriedFrom: string | null; +} + +/** + * Decide the status for a freshly detected finding. + * + * A prior dismissal for the same user is carried forward only when every + * reason in the new evidence was already part of the dismissed finding — + * i.e. the detector merely re-observed what the reviewer cleared. Any + * genuinely new reason (or unparseable evidence) reopens the flag. + */ +export function resolveCarryDecision( + rowEvidence: unknown, + priorDismissal: { id: string; evidence: unknown } | null | undefined, +): CarryDecision { + if (!priorDismissal) return { status: "open", carriedFrom: null }; + const prior = evidenceReasons(priorDismissal.evidence); + const next = evidenceReasons(rowEvidence); + if (prior.size === 0 || next.size === 0) { + return { status: "open", carriedFrom: null }; + } + const carried = [...next].every((reason) => prior.has(reason)); + if (!carried) return { status: "open", carriedFrom: null }; + return { status: "dismissed", carriedFrom: priorDismissal.id }; } /** @@ -493,17 +576,51 @@ export class DrizzleFraudRepo implements FraudRepo { async upsertFlags(rows: FlagWriteInput[]): Promise { if (rows.length === 0) return 0; + + // Look up each user's most recent dismissal so a re-detected finding can + // inherit it instead of re-surfacing for review. Ordered desc, so the + // first row seen per user wins. + const userIds = [...new Set(rows.map((r) => r.userId))].filter(Boolean); + const priorDismissals = new Map(); + if (userIds.length > 0) { + const dismissed = await this.db + .select() + .from(fraudFlags) + .where( + and( + inArray(fraudFlags.userId, userIds), + eq(fraudFlags.status, "dismissed"), + ), + ) + .orderBy(desc(fraudFlags.updatedAt), desc(fraudFlags.createdAt)); + for (const flag of (dismissed ?? []) as FraudFlagDTO[]) { + if (!priorDismissals.has(flag.userId)) { + priorDismissals.set(flag.userId, flag); + } + } + } + // ON CONFLICT (cluster_key, user_id) DO UPDATE — keep latest evidence - // and score, never decrement reviewer state. - const values = rows.map((r) => ({ - clusterKey: r.clusterKey, - userId: r.userId, - stellarAddress: r.stellarAddress, - reason: r.reason, - score: r.score, - evidence: r.evidence, - correlationId: r.correlationId ?? null, - })); + // and score, never decrement reviewer state (status / reviewedBy / + // reviewedAt / carriedFrom are deliberately untouched, which also makes + // re-running a scan idempotent for already-reviewed findings). + const values = rows.map((r) => { + const carry = resolveCarryDecision( + r.evidence, + priorDismissals.get(r.userId) ?? null, + ); + return { + clusterKey: r.clusterKey, + userId: r.userId, + stellarAddress: r.stellarAddress, + reason: r.reason, + score: r.score, + evidence: r.evidence, + correlationId: r.correlationId ?? null, + status: carry.status, + carriedFrom: carry.carriedFrom, + }; + }); const result = await this.db .insert(fraudFlags) .values(values) @@ -534,8 +651,53 @@ export class DrizzleFraudRepo implements FraudRepo { .limit(limit); return rows as FraudFlagDTO[]; } + + async getFlag(id: string): Promise { + if (!isUuid(id)) return null; + const rows = await this.db + .select() + .from(fraudFlags) + .where(eq(fraudFlags.id, id)) + .limit(1); + const row = Array.isArray(rows) ? rows[0] : undefined; + return (row as FraudFlagDTO | undefined) ?? null; + } + + async transitionFlag( + transition: FraudFlagTransition, + ): Promise { + if (!isUuid(transition.id)) return null; + const now = new Date(); + // Conditional update — the status predicate makes concurrent reviewer + // decisions race safely: the loser matches 0 rows and sees a conflict. + const rows = await this.db + .update(fraudFlags) + .set({ + status: transition.status, + reviewedBy: transition.reviewedBy, + reviewedAt: now, + updatedAt: now, + }) + .where( + and( + eq(fraudFlags.id, transition.id), + eq(fraudFlags.status, transition.expectedStatus), + ), + ) + .returning(); + const row = Array.isArray(rows) ? rows[0] : undefined; + return (row as FraudFlagDTO | undefined) ?? null; + } +} + +/** Postgres rejects malformed uuids outright — fail soft so callers see 404. */ +function isUuid(value: string): boolean { + return UUID_RE.test(value); } +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + /** Convenience: list flags for the admin endpoint. */ export async function listFraudFlags( filters: ListFlagsFilters, diff --git a/src/workers/fraudDetector.ts b/src/workers/fraudDetector.ts index 4e97dea..e95e0cb 100644 --- a/src/workers/fraudDetector.ts +++ b/src/workers/fraudDetector.ts @@ -15,6 +15,7 @@ import { randomUUID } from "crypto"; import { logger } from "../config/logger"; +import { closeDb } from "../db/client"; import { DrizzleFraudRepo, type FraudRepo, @@ -26,6 +27,8 @@ import { export class FraudDetectorWorker { private readonly repo: FraudRepo; private timer: NodeJS.Timeout | null = null; + private running = false; + private shuttingDown = false; constructor(repo: FraudRepo = new DrizzleFraudRepo()) { this.repo = repo; @@ -33,6 +36,12 @@ export class FraudDetectorWorker { /** Run a single scan. Errors are caught and logged — the worker never throws. */ async runOnce(opts: RunScanOptions = {}): Promise { + if (this.shuttingDown) return null; + if (this.running) { + logger.warn("fraud_detector: scan already in progress, skipping overlap"); + return null; + } + this.running = true; const correlationId = opts.correlationId ?? randomUUID(); const merged: RunScanOptions = { ...opts }; merged.correlationId = correlationId; @@ -46,6 +55,8 @@ export class FraudDetectorWorker { "fraud_detector: run failed", ); return null; + } finally { + this.running = false; } } @@ -58,6 +69,9 @@ export class FraudDetectorWorker { logger.warn("fraud_detector: already running, ignoring start()"); return () => this.stop(); } + if (this.shuttingDown) { + return () => undefined; + } if (!Number.isFinite(intervalMs) || intervalMs <= 0) { logger.warn( { intervalMs }, @@ -77,6 +91,7 @@ export class FraudDetectorWorker { } stop(): void { + this.shuttingDown = true; if (this.timer) { clearInterval(this.timer); this.timer = null; @@ -88,18 +103,61 @@ export class FraudDetectorWorker { /** Singleton for production wiring. */ export const fraudDetectorWorker = new FraudDetectorWorker(); +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function shutdownWithTimeout(timeoutMs = 10_000): Promise { + const timeout = sleep(timeoutMs); + const cleanup = (async () => { + try { + await closeDb(); + } catch (err) { + logger.warn({ err }, "fraud_detector: failed to close db"); + } + })(); + await Promise.race([cleanup, timeout]); +} + +async function main(): Promise { + const worker = fraudDetectorWorker; + const stopSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"]; + let shutting = false; + + const shutdown = (signal: NodeJS.Signals) => { + if (shutting) return; + shutting = true; + logger.info({ signal }, "fraud_detector: shutdown requested"); + worker.stop(); + void shutdownWithTimeout() + .finally(() => process.exit(0)) + .catch(() => process.exit(0)); + }; + + for (const signal of stopSignals) { + process.once(signal, shutdown); + } + + const intervalMs = Number.parseInt(process.env.FRAUD_DETECTOR_INTERVAL_MS ?? "", 10); + const opts: RunScanOptions = {}; + + if (Number.isFinite(intervalMs) && intervalMs > 0) { + worker.start(intervalMs, opts); + // Keep the process alive for the scheduler + await new Promise(() => undefined); + return; + } + + // One-off run (e.g. `node dist/workers/fraudDetector.js`) + const res = await worker.runOnce(opts); + console.log("fraud_scan", res); + await shutdownWithTimeout(5000); +} + // Allow `node dist/workers/fraudDetector.js` for ad-hoc runs. if (require.main === module) { - fraudDetectorWorker - .runOnce() - .then((res) => { - - console.log("fraud_scan", res); - process.exit(0); - }) - .catch((err) => { - - console.error(err); - process.exit(1); - }); + main().catch((err) => { + console.error(err); + void shutdownWithTimeout(2000).finally(() => process.exit(1)); + }); } diff --git a/tests/fraudService.test.ts b/tests/fraudService.test.ts index fb8effe..ef2ff80 100644 --- a/tests/fraudService.test.ts +++ b/tests/fraudService.test.ts @@ -254,7 +254,8 @@ describe("clusterize", () => { expect(clusters).toHaveLength(1); expect(clusters[0].members).toEqual(["GA", "GB", "GC"]); expect(clusters[0].score).toBeGreaterThan(0); - expect(clusters[0].key).toBe("GA|GB|GC"); + expect(typeof clusters[0].key).toBe("string"); + expect(clusters[0].key).toHaveLength(64); }); it("returns multiple clusters when address groups are disjoint", () => { @@ -327,7 +328,7 @@ describe("runFraudScan", () => { expect(addrs).toEqual(["GA", "GB"]); for (const w of repo.written) { - expect(w.clusterKey).toBe("GA|GB"); + expect(w.clusterKey).toHaveLength(64); expect(w.reason).toBe("SHARED_FUNDING_SOURCE"); expect(w.correlationId).toBe("cid-2"); expect(w.score).toBeGreaterThan(0);