diff --git a/deploy/monitoring/prometheus/alert-rules.yaml b/deploy/monitoring/prometheus/alert-rules.yaml index 7f2cc6a..1e0441c 100644 --- a/deploy/monitoring/prometheus/alert-rules.yaml +++ b/deploy/monitoring/prometheus/alert-rules.yaml @@ -335,3 +335,42 @@ groups: annotations: summary: 'Sustained rate limiting across route groups' description: '{{ $value }} route groups currently reporting blocked requests. Distinguishes an ongoing event from a historical total.' + + - name: neurowealth_support + interval: 60s + rules: + - alert: SupportTicketSlaBreached + expr: sum(support_tickets_sla_breached) > 0 + for: 5m + labels: + severity: warning + annotations: + summary: 'Support tickets are past their response SLA' + description: '{{ $value }} open tickets have breached their response SLA. See docs/SUPPORT_TICKETS.md.' + + - alert: SupportUrgentTicketSlaBreached + expr: support_tickets_sla_breached{priority=~"URGENT|HIGH"} > 0 + for: 1m + labels: + severity: critical + annotations: + summary: 'High-priority support ticket past response SLA' + description: '{{ $value }} {{ $labels.priority }} tickets have breached their response SLA.' + + - alert: SupportTicketsStale + expr: support_tickets_stale > 0 + for: 15m + labels: + severity: warning + annotations: + summary: 'Support tickets untouched beyond the stale threshold' + description: '{{ $value }} open tickets have had no activity for SUPPORT_STALE_HOURS.' + + - alert: ProtocolRiskMetadataStale + expr: risk_metadata_stale_entries > 0 + for: 5m + labels: + severity: critical + annotations: + summary: 'Protocol risk metadata is past its review date' + description: '{{ $value }} protocol risk metadata entries are stale. See docs/PROTOCOL_RISK_SCORING.md.' diff --git a/docs/API_KEYS.md b/docs/API_KEYS.md index f25851d..6f6d90c 100644 --- a/docs/API_KEYS.md +++ b/docs/API_KEYS.md @@ -76,19 +76,19 @@ New keys are **read-only by default**. Write scopes must be explicitly requested | `metrics:read` | read | enforced | — | Read operational metrics and dashboards. | — | `GET /api/v1/admin/jobs/dashboard`, `GET /api/v1/admin/stellar/metrics` | | `outbox:read` | read | enforced | — | Read the durable outbox. | — | `GET /api/v1/admin/outbox`, `GET /api/v1/admin/outbox/:id`, `GET /api/v1/admin/outbox/stats` | | `read` | read | enforced | — | General read access to admin resources. | — | `GET /api/v1/admin/erasure/preview/:userId`, `GET /api/v1/admin/erasure/requests`, `GET /api/v1/admin/referrals/review` | -| `referrals:read` | read | enforced | — | Read flagged referral conversions. | — | `GET /api/v1/admin/referrals/flagged` | +| `referrals:read` | read | enforced | — | Read flagged referral conversions. | — | `GET /api/v1/admin/referrals/flagged`, `GET /api/v1/admin/referrals/fraud/calibration`, `GET /api/v1/admin/referrals/fraud/queue` | | `reserves:read` | read | enforced | — | Read reserve and sponsorship state. | — | `GET /api/v1/admin/reserves` | -| `risk-metadata:read` | read | declared-only | — | Read protocol risk metadata. | No route requires this scope today — the risk-metadata review workflow is administered from src/config/riskMetadataAdmin.ts, not through the API. Declared so a key can be provisioned ahead of a route that needs it. | — | +| `risk-metadata:read` | read | enforced | — | Read protocol risk metadata. | `GET /api/v1/admin/risk-metadata` lists entries with a stale flag. | `GET /api/v1/admin/risk-metadata` | | `sessions:read` | read | enforced | — | Read user sessions. | — | `GET /api/v1/admin/users/:id/sessions` | | `support:read` | read | enforced | — | Read support tickets. | — | `GET /api/v1/admin/support/tickets`, `GET /api/v1/admin/support/tickets/:id` | -| `treasury:read` | read | enforced | — | Read treasury sweep policies. | — | `GET /api/v1/admin/treasury/policies` | +| `treasury:read` | read | enforced | — | Read treasury sweep policies. | — | `GET /api/v1/admin/treasury/policies`, `POST /api/v1/admin/treasury/sweep/preview` | | `approvals:write` | write | enforced | — | Cancel pending approval requests. | — | `POST /api/v1/admin/approvals/:id/cancel` | | `sessions:write` | write | enforced | — | Revoke user sessions. | — | `POST /api/v1/admin/users/:id/sessions/revoke-all` | | `support:write` | write | enforced | — | Reply to and update support tickets. | — | `PATCH /api/v1/admin/support/tickets/:id`, `POST /api/v1/admin/support/tickets/:id/reply` | | `write` | write | enforced | — | General write access to admin resources. | — | `POST /api/v1/admin/erasure/requests`, `POST /api/v1/admin/referrals/review/:id/approve`, `POST /api/v1/admin/referrals/review/:id/reject` | | `outbox:write` | money | enforced | — | Force-retry and cancel queued outbox operations. | — | `POST /api/v1/admin/outbox/:id/cancel`, `POST /api/v1/admin/outbox/:id/retry` | -| `referrals:write` | money | enforced | — | Approve or reject flagged referral conversions. | — | `POST /api/v1/admin/referrals/:id/review` | -| `treasury:write` | money | enforced | — | Change treasury sweep policies and run a sweep. | Sweep policies and signer rotations are written to the admin audit log. | `POST /api/v1/admin/treasury/emergency-sweep`, `POST /api/v1/admin/treasury/policies`, `POST /api/v1/admin/treasury/signer-rotations`, `POST /api/v1/admin/treasury/signer-rotations/:id/finalize` | +| `referrals:write` | money | enforced | — | Approve or reject flagged referral conversions. | — | `POST /api/v1/admin/referrals/:id/review`, `POST /api/v1/admin/referrals/fraud/bulk-review` | +| `treasury:write` | money | enforced | — | Change treasury sweep policies and run a sweep. | Sweep policies and signer rotations are written to the admin audit log. | `POST /api/v1/admin/treasury/emergency-sweep`, `POST /api/v1/admin/treasury/policies`, `POST /api/v1/admin/treasury/policies/:id/enable`, `POST /api/v1/admin/treasury/signer-rotations`, `POST /api/v1/admin/treasury/signer-rotations/:id/finalize` | | `wallet` | money | declared-only | — | Custodial wallet operations. | No route requires this scope today — the wallet rotation-status route is gated by keys:read. Declared so a key can be provisioned ahead of a route that needs it. | — | | `erasure:write` | destructive | enforced | — | Create, approve, reject and execute erasure requests. | Execution requires super — approval and execution are deliberately separated so one operator cannot both authorise and carry out a deletion. | `POST /api/v1/admin/erasure` | | `keys:write` | destructive | enforced | — | Create, rotate and revoke admin API keys. | Minting an admin key is a takeover vector — every use is written to the admin audit log. | `DELETE /api/v1/admin/keys/:id`, `POST /api/v1/admin/keys`, `POST /api/v1/admin/keys/:id/rotate` | @@ -101,7 +101,7 @@ New keys are **read-only by default**. Write scopes must be explicitly requested | `fiat:write` | operational | enforced | — | Change fiat provider failover state. | — | `POST /api/v1/admin/fiat/providers/:name/failover` | | `flags:write` | operational | enforced | — | Manage feature flags, staged rollouts and emergency rollbacks. | — | `POST /api/v1/admin/feature-flags/:key/reset`, `POST /api/v1/admin/feature-flags/:key/rollback`, `POST /api/v1/admin/feature-flags/:key/staged-rollout`, `POST /api/v1/admin/feature-flags/emergency-disable-all`, `PUT /api/v1/admin/feature-flags/:key` | | `messages:write` | operational | enforced | — | Retry and recover failed message deliveries. | — | `DELETE /api/v1/admin/messages/:id`, `POST /api/v1/admin/messages/:id/retry`, `POST /api/v1/admin/messages/retry-dead-letters` | -| `risk-metadata:write` | operational | declared-only | — | Review and publish protocol risk metadata. | No route requires this scope today — the risk-metadata review workflow is administered from src/config/riskMetadataAdmin.ts, not through the API. Declared so a key can be provisioned ahead of a route that needs it. | — | +| `risk-metadata:write` | operational | enforced | — | Review and publish protocol risk metadata. | `PUT /api/v1/admin/risk-metadata/:protocolName` upserts an entry; each change writes a history row and an admin audit log row. | `PUT /api/v1/admin/risk-metadata/:protocolName` | ### Withdrawal guardrails diff --git a/docs/PROTOCOL_RISK_SCORING.md b/docs/PROTOCOL_RISK_SCORING.md index c6e5280..1aa7b7c 100644 --- a/docs/PROTOCOL_RISK_SCORING.md +++ b/docs/PROTOCOL_RISK_SCORING.md @@ -188,6 +188,26 @@ from it automatically. > placeholders, all marked `dataConfidence: UNVERIFIED` — honest about the > fact that they have never actually been reviewed against a source. +## Metadata refresh, staleness alerting and admin updates (#621) + +Sourcing expectation: every entry needs an `auditReference`, and `dataConfidence: VERIFIED` needs a +`sourceUrl`. `nextReviewDueAt` is the refresh deadline; re-review each protocol before it passes +(recommended cadence: every 90 days, or immediately after an audit or incident). + +- Admin API: `GET /api/v1/admin/risk-metadata` (scope `risk-metadata:read`) and + `PUT /api/v1/admin/risk-metadata/:protocolName` (scope `risk-metadata:write`). The upsert validates + input, requires a future `nextReviewDueAt`, and writes the entry, an append-only + `ProtocolRiskMetadataHistory` row and an `AdminAuditLog` row (`RISK_METADATA_UPSERT`, with + before/after) in one transaction. +- `src/jobs/riskMetadataStaleness.ts` runs every `PROTOCOL_RISK_STALENESS_INTERVAL_MS` (default 1h). + When any entry is past `nextReviewDueAt` it sets `risk_metadata_stale_entries` and emits a + `critical` alert through `src/services/alerting.ts` (PagerDuty when `PAGERDUTY_ROUTING_KEY` is set; + repeat alerts are rate-limited by the alerting cooldown). Prometheus rule + `ProtocolRiskMetadataStale` mirrors it. +- Stale protocols are degraded visibly: `GET /api/protocols/risk` returns `riskMetadataStale: true`, + a `score` reduced by `PROTOCOL_RISK_STALE_SCORE_PENALTY` (default 20, floor 0) and the unmodified + `rawScore`. Risk-ceiling checks in the rebalancing router use the degraded score. + ## Out of scope (v1) - Automated on-chain audit-status verification — `auditStatus` is curated. diff --git a/docs/REFERRAL_PROGRAM.md b/docs/REFERRAL_PROGRAM.md index 42ab330..a4abdb0 100644 --- a/docs/REFERRAL_PROGRAM.md +++ b/docs/REFERRAL_PROGRAM.md @@ -163,3 +163,28 @@ Signup attribution is **not** a route — it is the `referralCode` field on (deduplicated per conversion + leg). - Because payouts are agent-signed and platform-funded, ensure the agent account is funded for the configured reward asset. + +## Fraud review queue and calibration (#619) + +Admin endpoints (mounted at `/api/v1/admin/referrals/fraud`): + +- `GET /queue` (scope `referrals:read`) lists scored conversions. Filters: `state` + (`pending` default, `approved`, `rejected`, `all`), `scoreMin`/`scoreMax`, `flag`, `from`/`to`; + paginated with `page` and `pageSize` (max 100); highest score first. +- `POST /bulk-review` (scope `referrals:write`) body `{ ids, decision: approve|reject, reason }` + (up to 100 ids; `reason` required for reject). Each conversion is decided and written to + `AdminAuditLog` (`REFERRAL_BULK_APPROVE`/`REFERRAL_BULK_REJECT`) in its own transaction; ids that + are unknown or not pending come back in `skipped`. +- `GET /calibration?month=YYYY-MM` (scope `referrals:read`) returns the same report as + `npx ts-node scripts/referral-calibration.ts [YYYY-MM]`. + +Calibration report: score distribution in 20-point bands for conversions created in the month, +and, for conversions reviewed in the month, the false-positive rate +(`approved / reviewed`, since a flagged conversion that a human approves was a false positive) and a +suggested `REFERRAL_FRAUD_REVIEW_SCORE`: the lowest observed score at which rejections make up at +least `REFERRAL_FRAUD_TARGET_PRECISION` (default 0.7) of reviews at or above it. + +Thresholds and weights are environment-driven, not hard-coded: `REFERRAL_FRAUD_BLOCK_SCORE` (100), +`REFERRAL_FRAUD_REVIEW_SCORE` (80), `REFERRAL_FRAUD_WEIGHT_{DUPLICATE_WALLET,DUPLICATE_EMAIL, +DUPLICATE_PHONE,SAME_IP,VELOCITY,RAPID_ACTIVATION}`, `REFERRAL_FRAUD_VELOCITY_COUNT` (5) and +`REFERRAL_FRAUD_RAPID_ACTIVATION_COUNT` (3). Defaults reproduce the previous behaviour. diff --git a/docs/SUPPORT_TICKETS.md b/docs/SUPPORT_TICKETS.md index 0201ae9..765199f 100644 --- a/docs/SUPPORT_TICKETS.md +++ b/docs/SUPPORT_TICKETS.md @@ -29,3 +29,28 @@ The General-Purpose Support Ticket System provides users with a structured, trac - `POST /api/v1/admin/support/tickets/:id/reply` - Admin reply or internal note Admin reads require support:read; updates and replies require support:write. Status transitions are enforced and status/assignment/priority changes commit with an audit record. SLA defaults are LOW 72h, MEDIUM 24h, HIGH 8h and URGENT 2h. Override these with SUPPORT_SLA__HOURS. Breaches are computed from creation time and exclude resolved/closed tickets. Internal notes are filtered by the database on user reads. User replies to RESOLVED or AWAITING_USER move the ticket to IN_PROGRESS. + +## SLA, transitions and alerting (#618) + +Priorities map to the usual P-levels: `URGENT` = P0, `HIGH` = P1, `MEDIUM` = P2, `LOW` = P3. + +| Priority | Response SLA (default) | Override | +|---|---|---| +| URGENT (P0) | 2h | `SUPPORT_SLA_URGENT_HOURS` | +| HIGH (P1) | 8h | `SUPPORT_SLA_HIGH_HOURS` | +| MEDIUM (P2) | 24h | `SUPPORT_SLA_MEDIUM_HOURS` | +| LOW (P3) | 72h | `SUPPORT_SLA_LOW_HOURS` | + +- `firstResponseAt` is set once, on the first non-internal admin reply. The SLA is met when + `firstResponseAt <= createdAt + SLA`. A ticket with no response is breached once the deadline + passes and it is not yet `RESOLVED`/`CLOSED`. `resolvedAt` is set when the ticket reaches `RESOLVED`. +- Allowed transitions (anything else returns `409`): + `OPEN -> IN_PROGRESS | AWAITING_USER | RESOLVED`, `IN_PROGRESS -> AWAITING_USER | RESOLVED`, + `AWAITING_USER -> IN_PROGRESS | RESOLVED`, `RESOLVED -> IN_PROGRESS (reopen) | CLOSED`, `CLOSED` is terminal. +- `src/jobs/supportTicketSla.ts` runs every `SUPPORT_SLA_CHECK_INTERVAL_MS` (default 5 min). It sets + `slaBreached`, publishes `support_tickets_sla_breached{priority}` and `support_tickets_stale`, and + alerts through `alertingService` (critical for URGENT/HIGH breaches). +- A ticket is stale when it is open (not `AWAITING_USER`) and untouched for `SUPPORT_STALE_HOURS` + (default 24). +- Prometheus rules `SupportTicketSlaBreached`, `SupportUrgentTicketSlaBreached` and + `SupportTicketsStale` live in `deploy/monitoring/prometheus/alert-rules.yaml`. diff --git a/docs/TREASURY.md b/docs/TREASURY.md index 6eebc0f..a9b6d60 100644 --- a/docs/TREASURY.md +++ b/docs/TREASURY.md @@ -49,3 +49,23 @@ Emergency and cadence sweeps on the same signer account never race: op submission is claimed and serialized per `signerPublicKey` by the existing outbox dispatcher (`src/outbox/signerLock.ts`), the same mechanism every other money-moving outbox op already relies on. + +## Sweep preview and enablement runbook (#620) + +New policy versions start with `sweepsEnabled = false`: the cadence job ignores them and +`executeSweep` refuses to run. Creating a new version therefore pauses sweeps for that tier pair +until an operator re-enables it. + +1. `POST /api/admin/treasury/policies` creates the new policy version (scope `treasury:write`). +2. `POST /api/admin/treasury/sweep/preview` (scope `treasury:read`) returns every sweep the active + policies would plan, with `policyEnabled` and `requiresApproval` per item. It reads balances but + creates no `TreasurySweep`, `MultisigEnvelope` or outbox op, so nothing is sent to the chain. +3. Review the plans. Then `POST /api/admin/treasury/policies/:id/enable` with + `{ "confirmation": "SWEEP" }` (scope `treasury:write`). Any other text is rejected. The enable is + recorded with `enabledBy`/`enabledAt` and in the admin audit log. +4. Only after step 3 does the cadence job plan sweeps and enqueue `TREASURY_SWEEP` outbox ops. +5. A failed sweep emits a `critical` alert on component `treasury-sweep` (PagerDuty/Slack when + configured) and rethrows. Emergency sweeps are unaffected by `sweepsEnabled`. + +Existing active policies also default to disabled after migration `20261020090000`; enable each one +after previewing. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ceb3606..8e3e8d4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1927,6 +1927,199 @@ paths: description: Forbidden '404': description: Resource not found + /admin/referrals/fraud/queue: + get: + tags: + - Admin + operationId: adminReferralFraudQueue + summary: Paginated flagged referral conversions; filters score band, flag, date; requires referrals:read + security: + - adminAuth: [] + parameters: + - name: state + in: query + required: false + schema: + type: string + - name: scoreMin + in: query + required: false + schema: + type: integer + - name: scoreMax + in: query + required: false + schema: + type: integer + - name: flag + in: query + required: false + schema: + type: string + - name: from + in: query + required: false + schema: + type: string + - name: to + in: query + required: false + schema: + type: string + - name: page + in: query + required: false + schema: + type: integer + - name: pageSize + in: query + required: false + schema: + type: integer + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + /admin/referrals/fraud/bulk-review: + post: + tags: + - Admin + operationId: adminReferralFraudBulkReview + summary: Bulk approve or reject flagged conversions with per-item audit log; requires referrals:write + security: + - adminAuth: [] + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + /admin/referrals/fraud/calibration: + get: + tags: + - Admin + operationId: adminReferralFraudCalibration + summary: Monthly score distribution, false-positive rate and threshold suggestion; requires referrals:read + security: + - adminAuth: [] + parameters: + - name: month + in: query + required: false + schema: + type: string + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + /admin/risk-metadata: + get: + tags: + - Admin + operationId: adminListRiskMetadata + summary: List protocol risk metadata with stale flag; requires risk-metadata:read + security: + - adminAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + /admin/risk-metadata/{protocolName}: + put: + tags: + - Admin + operationId: adminUpsertRiskMetadata + summary: Create or update protocol risk metadata with history and audit log; requires risk-metadata:write + security: + - adminAuth: [] + parameters: + - name: protocolName + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + /admin/treasury/sweep/preview: + post: + tags: + - Admin + operationId: adminTreasurySweepPreview + summary: Dry run of treasury sweeps under current policies; writes nothing; requires treasury:read + security: + - adminAuth: [] + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden + /admin/treasury/policies/{id}/enable: + post: + tags: + - Admin + operationId: adminTreasuryPolicyEnable + summary: Enable sweeps for a policy; body confirmation must be SWEEP; requires treasury:write + security: + - adminAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + responses: + '200': + description: Successful operation + '400': + description: Invalid request + '401': + description: Authentication required or verification failed + '403': + description: Forbidden /admin/support/tickets: get: tags: diff --git a/prisma/migrations/20261020090000_ticket_sla_treasury_enable/migration.sql b/prisma/migrations/20261020090000_ticket_sla_treasury_enable/migration.sql new file mode 100644 index 0000000..6a5887f --- /dev/null +++ b/prisma/migrations/20261020090000_ticket_sla_treasury_enable/migration.sql @@ -0,0 +1,16 @@ +ALTER TABLE "support_tickets" ADD COLUMN "firstResponseAt" TIMESTAMP(3); + +UPDATE "support_tickets" t +SET "firstResponseAt" = m.first_reply +FROM ( + SELECT "ticketId", MIN("createdAt") AS first_reply + FROM "ticket_messages" + WHERE "authorRole" = 'ADMIN' AND "internal" = false + GROUP BY "ticketId" +) m +WHERE m."ticketId" = t."id"; + +ALTER TABLE "treasury_sweep_policies" + ADD COLUMN "sweepsEnabled" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN "enabledBy" TEXT, + ADD COLUMN "enabledAt" TIMESTAMP(3); diff --git a/prisma/migrations/20261020090000_ticket_sla_treasury_enable/rollback.sql b/prisma/migrations/20261020090000_ticket_sla_treasury_enable/rollback.sql new file mode 100644 index 0000000..2de3e81 --- /dev/null +++ b/prisma/migrations/20261020090000_ticket_sla_treasury_enable/rollback.sql @@ -0,0 +1,11 @@ +-- Rollback for 20261020090000_ticket_sla_treasury_enable +-- Drops the support ticket first-response timestamp and the treasury sweep +-- policy enablement columns. +-- WARNING: Destroys firstResponseAt history and which policies were enabled; +-- after rollback no sweep-enable state remains, so re-add it before sweeping. + +ALTER TABLE "treasury_sweep_policies" DROP COLUMN IF EXISTS "enabledAt"; +ALTER TABLE "treasury_sweep_policies" DROP COLUMN IF EXISTS "enabledBy"; +ALTER TABLE "treasury_sweep_policies" DROP COLUMN IF EXISTS "sweepsEnabled"; + +ALTER TABLE "support_tickets" DROP COLUMN IF EXISTS "firstResponseAt"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0ba6f28..0a3752c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2411,6 +2411,9 @@ model TreasurySweepPolicy { requiresApprovalAbove Decimal? @db.Decimal(36, 18) version Int @default(1) isActive Boolean @default(true) + sweepsEnabled Boolean @default(false) + enabledBy String? + enabledAt DateTime? createdBy String createdAt DateTime @default(now()) @@ -2653,6 +2656,7 @@ model SupportTicket { slaBreached Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + firstResponseAt DateTime? resolvedAt DateTime? user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/scripts/referral-calibration.ts b/scripts/referral-calibration.ts new file mode 100644 index 0000000..1996e31 --- /dev/null +++ b/scripts/referral-calibration.ts @@ -0,0 +1,32 @@ +#!/usr/bin/env ts-node +/** + * Monthly referral fraud calibration report (#619). + * + * Prints the score distribution, the false-positive rate from manual reviews + * and a suggested REFERRAL_FRAUD_REVIEW_SCORE for one month. + * + * Usage: + * npx ts-node scripts/referral-calibration.ts [YYYY-MM] + * + * Defaults to the previous calendar month. Requires DATABASE_URL. + */ +import { buildCalibrationReport } from '../src/referral/reviewQueue' +import db from '../src/db' + +function previousMonth(now = new Date()): string { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)) + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}` +} + +async function main(): Promise { + const month = process.argv[2] ?? previousMonth() + const report = await buildCalibrationReport(month) + console.log(JSON.stringify(report, null, 2)) +} + +main() + .catch((error) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) + .finally(() => db.$disconnect()) diff --git a/src/agent/router.ts b/src/agent/router.ts index c89ef6e..76140e4 100644 --- a/src/agent/router.ts +++ b/src/agent/router.ts @@ -2,6 +2,10 @@ * Router - Compares APYs and triggers rebalancing when conditions are met */ +import { + getStaleProtocolNames, + applyStalenessPenalty, +} from '../jobs/riskMetadataStaleness' import { logger } from '../utils/logger' import { getCorrelationId } from '../utils/correlation' import { hasWashSaleRisk, isSameAssetRebuyLikely } from '../tax/washSale' @@ -55,9 +59,13 @@ async function loadProtocolRiskScores(): Promise> { const rows = await db.protocolRiskScore.findMany({ select: { protocolName: true, score: true }, }) + const stale = await getStaleProtocolNames() const map: Record = {} for (const row of rows as Array<{ protocolName: string; score: number }>) { - map[row.protocolName] = row.score + map[row.protocolName] = applyStalenessPenalty( + row.score, + stale.has(row.protocolName) + ) } return map } diff --git a/src/config/env.ts b/src/config/env.ts index e163fca..41ef3b8 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -663,6 +663,14 @@ export const config = { // stale entries are surfaced but not silently altered. staleAutoDowngrade: process.env.PROTOCOL_RISK_STALE_AUTO_DOWNGRADE === 'true', + // #621 — points subtracted from a protocol's score while its metadata is + // past nextReviewDueAt, and how often the staleness check pages. + stalePenalty: parseInt( + process.env.PROTOCOL_RISK_STALE_SCORE_PENALTY || '20' + ), + stalenessIntervalMs: parseInt( + process.env.PROTOCOL_RISK_STALENESS_INTERVAL_MS || '3600000' + ), }, portfolioRisk: { intervalMs: parseInt(process.env.PORTFOLIO_RISK_INTERVAL_MS || '21600000'), @@ -708,6 +716,37 @@ export const config = { payoutIntervalMs: parseInt( process.env.REFERRAL_PAYOUT_INTERVAL_MS || '120000' ), + // #619 — fraud scoring is config-driven so thresholds can be tuned from + // the calibration report without a deploy. + fraud: { + blockScore: parseInt(process.env.REFERRAL_FRAUD_BLOCK_SCORE || '100'), + reviewScore: parseInt(process.env.REFERRAL_FRAUD_REVIEW_SCORE || '80'), + weights: { + duplicateWallet: parseInt( + process.env.REFERRAL_FRAUD_WEIGHT_DUPLICATE_WALLET || '100' + ), + duplicateEmail: parseInt( + process.env.REFERRAL_FRAUD_WEIGHT_DUPLICATE_EMAIL || '50' + ), + duplicatePhone: parseInt( + process.env.REFERRAL_FRAUD_WEIGHT_DUPLICATE_PHONE || '50' + ), + sameIp: parseInt(process.env.REFERRAL_FRAUD_WEIGHT_SAME_IP || '40'), + velocity: parseInt(process.env.REFERRAL_FRAUD_WEIGHT_VELOCITY || '30'), + rapidActivation: parseInt( + process.env.REFERRAL_FRAUD_WEIGHT_RAPID_ACTIVATION || '40' + ), + }, + velocityCount: parseInt(process.env.REFERRAL_FRAUD_VELOCITY_COUNT || '5'), + rapidActivationCount: parseInt( + process.env.REFERRAL_FRAUD_RAPID_ACTIVATION_COUNT || '3' + ), + // Calibration report: minimum share of flagged reviews that ended in a + // rejection before the suggested review threshold is considered tuned. + targetPrecision: parseFloat( + process.env.REFERRAL_FRAUD_TARGET_PRECISION || '0.7' + ), + }, }, recurringDeposits: { intervalMs: parseInt( diff --git a/src/config/riskMetadataAdmin.ts b/src/config/riskMetadataAdmin.ts index a8b64bb..3577c03 100644 --- a/src/config/riskMetadataAdmin.ts +++ b/src/config/riskMetadataAdmin.ts @@ -54,3 +54,89 @@ export async function updateRiskMetadata( return result } + +export interface UpsertRiskMetadataInput { + auditStatus: AuditStatus + auditReference: string + sourceUrl?: string + dataConfidence?: DataConfidence + inceptionDate: Date + nextReviewDueAt: Date +} + +export interface RiskMetadataAdmin { + id: string + name: string + role: string +} + +/** + * Create-or-update an entry (#621). Same VERIFIED/sourceUrl rule as + * updateRiskMetadata; the history row and the AdminAuditLog row (with + * before/after) commit in the same transaction as the change itself. + */ +export async function upsertRiskMetadata( + protocolName: string, + input: UpsertRiskMetadataInput, + admin: RiskMetadataAdmin +) { + if (input.dataConfidence === 'VERIFIED' && !input.sourceUrl) { + throw new RiskMetadataValidationError( + 'sourceUrl is required to set dataConfidence to VERIFIED' + ) + } + if (input.nextReviewDueAt <= new Date()) { + throw new RiskMetadataValidationError( + 'nextReviewDueAt must be in the future' + ) + } + + const result = await db.$transaction(async (tx) => { + const before = await tx.protocolRiskMetadataEntry.findUnique({ + where: { protocolName }, + }) + const data = { ...input, reviewedAt: new Date(), reviewedBy: admin.name } + const entry = await tx.protocolRiskMetadataEntry.upsert({ + where: { protocolName }, + create: { protocolName, ...data }, + update: data, + }) + await tx.protocolRiskMetadataHistory.create({ + data: { + entryId: entry.id, + auditStatus: entry.auditStatus, + dataConfidence: entry.dataConfidence, + sourceUrl: entry.sourceUrl, + changedBy: admin.name, + }, + }) + await tx.adminAuditLog.create({ + data: { + adminKeyId: admin.id, + adminName: admin.name, + adminRole: admin.role, + action: 'RISK_METADATA_UPSERT', + target: protocolName, + result: 'SUCCESS', + details: { + before: before && { + auditStatus: before.auditStatus, + dataConfidence: before.dataConfidence, + sourceUrl: before.sourceUrl, + nextReviewDueAt: before.nextReviewDueAt, + }, + after: { + auditStatus: entry.auditStatus, + dataConfidence: entry.dataConfidence, + sourceUrl: entry.sourceUrl, + nextReviewDueAt: entry.nextReviewDueAt, + }, + }, + }, + }) + return entry + }) + + await refreshMetadataCache() + return result +} diff --git a/src/index.ts b/src/index.ts index 4ec0819..2b988ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,8 @@ import { scheduleAttribution } from './jobs/attribution' import { scheduleOutboxDispatcher } from './outbox/dispatcher' import { startFeeOracle, stopFeeOracle } from './stellar/feeOracle' import { scheduleProtocolRiskScoring } from './jobs/protocolRiskScoring' +import { scheduleSupportTicketSla } from './jobs/supportTicketSla' +import { scheduleRiskMetadataStaleness } from './jobs/riskMetadataStaleness' import { schedulePortfolioRiskJob } from './jobs/portfolioRisk' import { scheduleApprovalExpiry } from './jobs/approvalExpiry' import { scheduleReserveReconciliation } from './jobs/reserveReconciliation' @@ -116,6 +118,8 @@ let alertRulesHandle: NodeJS.Timeout | null = null let strategyMetricsHandle: NodeJS.Timeout | null = null let allocationSuggestionsHandle: NodeJS.Timeout | null = null let protocolRiskScoringHandle: NodeJS.Timeout | null = null +let supportTicketSlaHandle: NodeJS.Timeout | null = null +let riskMetadataStalenessHandle: NodeJS.Timeout | null = null let attributionHandle: NodeJS.Timeout | null = null let outboxDispatcherHandle: NodeJS.Timeout | null = null let portfolioRiskJobHandle: NodeJS.Timeout | null = null @@ -407,6 +411,16 @@ async function gracefulShutdown(signal: string): Promise { logger.info('[Shutdown] Strategy metrics timer cleared') } + if (supportTicketSlaHandle) { + clearInterval(supportTicketSlaHandle) + supportTicketSlaHandle = null + } + + if (riskMetadataStalenessHandle) { + clearInterval(riskMetadataStalenessHandle) + riskMetadataStalenessHandle = null + } + if (protocolRiskScoringHandle) { clearInterval(protocolRiskScoringHandle) protocolRiskScoringHandle = null @@ -707,6 +721,8 @@ async function main(): Promise { // Ordered before the suggestion job so the first suggestion run sees freshly // scored protocols rather than whatever was last left in the table. protocolRiskScoringHandle = scheduleProtocolRiskScoring() + supportTicketSlaHandle = scheduleSupportTicketSla() + riskMetadataStalenessHandle = scheduleRiskMetadataStaleness() allocationSuggestionsHandle = scheduleAllocationSuggestions() attributionHandle = scheduleAttribution() portfolioRiskJobHandle = schedulePortfolioRiskJob() diff --git a/src/jobs/riskMetadataStaleness.ts b/src/jobs/riskMetadataStaleness.ts index a7c1bdb..c033de4 100644 --- a/src/jobs/riskMetadataStaleness.ts +++ b/src/jobs/riskMetadataStaleness.ts @@ -1,12 +1,17 @@ /** - * Staleness check for ProtocolRiskMetadataEntry (#529). Flags entries past - * their review-due date; optionally auto-downgrades dataConfidence toward - * UNVERIFIED when config.protocolRisk.staleAutoDowngrade is set (default - * off — flag-only, never silently alters a reviewed status). + * Staleness check for ProtocolRiskMetadataEntry (#529, #621). Flags entries + * past their review-due date and pages via the alerting service (PagerDuty + * when PAGERDUTY_ROUTING_KEY is set); optionally auto-downgrades + * dataConfidence toward UNVERIFIED when config.protocolRisk.staleAutoDowngrade + * is set (default off — flag-only, never silently alters a reviewed status). + * Scores of stale protocols are degraded visibly — see applyStalenessPenalty. */ import db from '../db' import { logger } from '../utils/logger' import { config } from '../config/env' +import { alertingService } from '../services/alerting' +import { riskMetadataStaleEntries } from '../utils/metrics' +import { scheduleResilientJob } from './resilientScheduler' export async function flagStaleEntries( now: Date = new Date() @@ -16,12 +21,28 @@ export async function flagStaleEntries( select: { protocolName: true, id: true }, }) + riskMetadataStaleEntries.set(stale.length) + if (stale.length === 0) return [] + const protocols = stale.map((s) => s.protocolName) + logger.warn('[RiskMetadataStaleness] Entries past review-due date', { - protocols: stale.map((s) => s.protocolName), + protocols, }) + await alertingService.emit( + { + title: 'Protocol risk metadata is stale', + description: `Risk metadata past its review-due date for: ${protocols.join(', ')}`, + severity: 'critical', + component: 'protocol-risk-metadata', + metadata: { protocols }, + dedupKey: 'risk-metadata-stale', + }, + 'risk-metadata-stale' + ) + if (config.protocolRisk.staleAutoDowngrade) { await db.protocolRiskMetadataEntry.updateMany({ where: { id: { in: stale.map((s) => s.id) } }, @@ -29,5 +50,30 @@ export async function flagStaleEntries( }) } - return stale.map((s) => s.protocolName) + return protocols +} + +export async function getStaleProtocolNames( + now: Date = new Date() +): Promise> { + const stale = await db.protocolRiskMetadataEntry.findMany({ + where: { nextReviewDueAt: { lt: now } }, + select: { protocolName: true }, + }) + return new Set(stale.map((s) => s.protocolName)) +} + +/** Lowers a score by the configured penalty (floor 0) when metadata is stale. */ +export function applyStalenessPenalty(score: number, stale: boolean): number { + if (!stale) return score + return Math.max(0, score - config.protocolRisk.stalePenalty) +} + +export function scheduleRiskMetadataStaleness(): NodeJS.Timeout { + return scheduleResilientJob({ + jobName: 'risk_metadata_staleness', + task: () => flagStaleEntries().then(() => undefined), + intervalMs: config.protocolRisk.stalenessIntervalMs, + unref: true, + }) } diff --git a/src/jobs/supportTicketSla.ts b/src/jobs/supportTicketSla.ts new file mode 100644 index 0000000..ef3c5d6 --- /dev/null +++ b/src/jobs/supportTicketSla.ts @@ -0,0 +1,117 @@ +/** + * Support ticket SLA monitor (#618). Marks tickets whose first response is + * overdue, flags tickets untouched for SUPPORT_STALE_HOURS, publishes gauges + * for Prometheus, and alerts on newly breached or stale tickets. + */ +import { TicketPriority } from '@prisma/client' +import db from '../db' +import { logger } from '../utils/logger' +import { alertingService } from '../services/alerting' +import { + supportTicketsSlaBreached, + supportTicketsStale, +} from '../utils/metrics' +import { scheduleResilientJob } from './resilientScheduler' +import { + supportSla, + supportStaleHours, +} from '../services/support-ticket.service' + +const INTERVAL_MS = parseInt( + process.env.SUPPORT_SLA_CHECK_INTERVAL_MS || '300000' +) + +export interface SupportSlaResult { + breached: number + newlyBreached: number + stale: number +} + +export async function checkSupportTicketSla( + now: Date = new Date() +): Promise { + const open = await db.supportTicket.findMany({ + where: { status: { notIn: ['RESOLVED', 'CLOSED'] } }, + select: { + id: true, + priority: true, + status: true, + createdAt: true, + updatedAt: true, + firstResponseAt: true, + slaBreached: true, + }, + }) + + const byPriority = new Map( + Object.values(TicketPriority).map((p) => [p, 0]) + ) + const newlyBreached: typeof open = [] + let breached = 0 + + for (const ticket of open) { + if (!supportSla(ticket, now).slaBreached) continue + breached++ + byPriority.set(ticket.priority, (byPriority.get(ticket.priority) ?? 0) + 1) + if (!ticket.slaBreached) newlyBreached.push(ticket) + } + + for (const [priority, count] of byPriority) { + supportTicketsSlaBreached.set({ priority }, count) + } + + if (newlyBreached.length > 0) { + await db.supportTicket.updateMany({ + where: { id: { in: newlyBreached.map((t) => t.id) } }, + data: { slaBreached: true }, + }) + const urgent = newlyBreached.some( + (t) => t.priority === 'URGENT' || t.priority === 'HIGH' + ) + await alertingService.emit( + { + title: 'Support ticket SLA breached', + description: `${newlyBreached.length} support ticket(s) passed their response SLA`, + severity: urgent ? 'critical' : 'warning', + component: 'support-tickets', + metadata: { ticketIds: newlyBreached.map((t) => t.id) }, + }, + 'support-sla-breach' + ) + } + + const staleCutoff = now.getTime() - supportStaleHours() * 3600000 + const stale = open.filter( + (t) => t.status !== 'AWAITING_USER' && t.updatedAt.getTime() < staleCutoff + ) + supportTicketsStale.set(stale.length) + + if (stale.length > 0) { + await alertingService.emit( + { + title: 'Stale support tickets', + description: `${stale.length} support ticket(s) untouched for over ${supportStaleHours()}h`, + severity: 'warning', + component: 'support-tickets', + metadata: { ticketIds: stale.map((t) => t.id) }, + }, + 'support-stale' + ) + } + + logger.info('[SupportSla] Check complete', { + breached, + newlyBreached: newlyBreached.length, + stale: stale.length, + }) + return { breached, newlyBreached: newlyBreached.length, stale: stale.length } +} + +export function scheduleSupportTicketSla(): NodeJS.Timeout { + return scheduleResilientJob({ + jobName: 'support_ticket_sla', + task: () => checkSupportTicketSla().then(() => undefined), + intervalMs: INTERVAL_MS, + unref: true, + }) +} diff --git a/src/jobs/treasurySweep.ts b/src/jobs/treasurySweep.ts index d9a3db4..8a2a839 100644 --- a/src/jobs/treasurySweep.ts +++ b/src/jobs/treasurySweep.ts @@ -18,6 +18,7 @@ import { logger } from '../utils/logger' import { getActivePolicy } from '../treasury/policy' import { enqueueOutboxOp } from '../outbox/service' import { getAccount } from '../stellar/client' +import { alertingService } from '../services/alerting' const TREASURY_SWEEP_INTERVAL_MS = 300_000 // 5 minutes const HYSTERESIS_FACTOR = 1.5 // targetHigh must be >= targetLow * 1.5 @@ -42,12 +43,22 @@ async function getNativeBalance(publicKey: string): Promise { return native ? parseFloat(native.balance) : 0 } -export async function evaluateTreasuryBalances(): Promise { - const plans: SweepPlan[] = [] +export interface SweepPreviewItem extends SweepPlan { + policyId: string + policyVersion: number + policyEnabled: boolean + requiresApproval: boolean +} + +async function planSweeps( + includeDisabled: boolean +): Promise { + const plans: SweepPreviewItem[] = [] for (const [fromTier, toTier] of TIER_PAIRS) { const policy = await getActivePolicy(fromTier, toTier) if (!policy) continue + if (!policy.sweepsEnabled && !includeDisabled) continue const accounts = await db.treasuryAccount.findMany({ where: { tier: fromTier, isActive: true }, @@ -64,10 +75,30 @@ export async function evaluateTreasuryBalances(): Promise { asset: 'XLM', amount: over.toFixed(7), reason: 'hot_over_high_band', + policyId: policy.id, + policyVersion: policy.version, + policyEnabled: policy.sweepsEnabled, + requiresApproval: + policy.requiresApprovalAbove != null && + over > Number(policy.requiresApprovalAbove), }) } } + return plans +} + +export async function evaluateTreasuryBalances(): Promise { + const plans = (await planSweeps(false)).map( + ({ fromTier, toTier, asset, amount, reason }) => ({ + fromTier, + toTier, + asset, + amount, + reason, + }) + ) + logger.info('[TreasurySweep] Evaluated treasury balances', { plansFound: plans.length, }) @@ -75,8 +106,47 @@ export async function evaluateTreasuryBalances(): Promise { return plans } +/** + * Dry run (#620): computes what would move under every active policy, + * enabled or not. Read-only — reads balances from the network but never + * creates a TreasurySweep, MultisigEnvelope or outbox op. + */ +export async function previewTreasurySweep(): Promise<{ + previewedAt: string + plans: SweepPreviewItem[] +}> { + return { + previewedAt: new Date().toISOString(), + plans: await planSweeps(true), + } +} + export async function executeSweep(plan: SweepPlan): Promise { + try { + await runSweep(plan) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + await alertingService.emit( + { + title: 'Treasury sweep failed', + description: `Sweep ${plan.fromTier}->${plan.toTier} of ${plan.amount} ${plan.asset} failed: ${message}`, + severity: 'critical', + component: 'treasury-sweep', + metadata: { ...plan, error: message }, + }, + `treasury-sweep-failed:${plan.fromTier}:${plan.toTier}` + ) + throw error + } +} + +async function runSweep(plan: SweepPlan): Promise { const policy = await getActivePolicy(plan.fromTier, plan.toTier) + if (!policy?.sweepsEnabled) { + throw new Error( + `Sweeps are not enabled for ${plan.fromTier}->${plan.toTier}; preview and enable the policy first` + ) + } const sweep = await db.treasurySweep.create({ data: { diff --git a/src/middleware/adminAuth.ts b/src/middleware/adminAuth.ts index d590a7c..584a651 100644 --- a/src/middleware/adminAuth.ts +++ b/src/middleware/adminAuth.ts @@ -307,7 +307,8 @@ export const ADMIN_SCOPE_CATALOG: Record = { 'flags:write': { scope: 'flags:write', enforcement: 'enforced', - description: 'Manage feature flags, staged rollouts and emergency rollbacks.', + description: + 'Manage feature flags, staged rollouts and emergency rollbacks.', risk: 'operational', grants: 'Update runtime overrides, advance staged rollouts, reset flags, and trigger emergency rollbacks.', @@ -344,27 +345,21 @@ export const ADMIN_SCOPE_CATALOG: Record = { }, 'risk-metadata:read': { scope: 'risk-metadata:read', - enforcement: 'declared-only', + enforcement: 'enforced', description: 'Read protocol risk metadata.', risk: 'read', grants: 'The protocol risk metadata and its review state.', requires: - 'No route requires this scope today — the risk-metadata review ' + - 'workflow is administered from src/config/riskMetadataAdmin.ts, not ' + - 'through the API. Declared so a key can be provisioned ahead of a ' + - 'route that needs it.', + '`GET /api/v1/admin/risk-metadata` lists entries with a stale flag.', }, 'risk-metadata:write': { scope: 'risk-metadata:write', - enforcement: 'declared-only', + enforcement: 'enforced', description: 'Review and publish protocol risk metadata.', risk: 'operational', grants: 'Changes the risk metadata other users are shown.', requires: - 'No route requires this scope today — the risk-metadata review ' + - 'workflow is administered from src/config/riskMetadataAdmin.ts, not ' + - 'through the API. Declared so a key can be provisioned ahead of a ' + - 'route that needs it.', + '`PUT /api/v1/admin/risk-metadata/:protocolName` upserts an entry; each change writes a history row and an admin audit log row.', }, 'admin:impersonate': { scope: 'admin:impersonate', diff --git a/src/referral/reviewQueue.ts b/src/referral/reviewQueue.ts new file mode 100644 index 0000000..ff5e1b9 --- /dev/null +++ b/src/referral/reviewQueue.ts @@ -0,0 +1,273 @@ +/** + * Admin review queue, bulk decisions and monthly calibration for flagged + * referral conversions (#619). + */ +import { Prisma } from '@prisma/client' +import db from '../db' +import { config } from '../config' +import { logger } from '../utils/logger' + +export type ReviewState = 'pending' | 'approved' | 'rejected' | 'all' + +export interface ReviewQueueFilters { + state: ReviewState + scoreMin?: number + scoreMax?: number + flag?: string + from?: Date + to?: Date + page: number + pageSize: number +} + +export function buildQueueWhere( + f: Omit +): Prisma.ReferralConversionWhereInput { + const and: Prisma.ReferralConversionWhereInput[] = [ + { fraudCheckScore: { not: null } }, + ] + if (f.state === 'pending') { + and.push({ manualReviewRequired: true, manualReviewRejected: false }) + } else if (f.state === 'approved') { + and.push({ + manualReviewRequired: false, + manualReviewRejected: false, + reviewedAt: { not: null }, + }) + } else if (f.state === 'rejected') { + and.push({ manualReviewRejected: true }) + } else { + and.push({ + OR: [{ manualReviewRequired: true }, { reviewedAt: { not: null } }], + }) + } + if (f.scoreMin !== undefined) { + and.push({ fraudCheckScore: { gte: f.scoreMin } }) + } + if (f.scoreMax !== undefined) { + and.push({ fraudCheckScore: { lte: f.scoreMax } }) + } + if (f.flag) and.push({ fraudCheckFlags: { has: f.flag } }) + if (f.from) and.push({ createdAt: { gte: f.from } }) + if (f.to) and.push({ createdAt: { lt: f.to } }) + return { AND: and } +} + +export async function listReviewQueue(filters: ReviewQueueFilters) { + const where = buildQueueWhere(filters) + const [total, items] = await Promise.all([ + db.referralConversion.count({ where }), + db.referralConversion.findMany({ + where, + orderBy: [{ fraudCheckScore: 'desc' }, { createdAt: 'asc' }], + skip: (filters.page - 1) * filters.pageSize, + take: filters.pageSize, + include: { + referralCode: { select: { ownerUserId: true, code: true } }, + referredUser: { select: { id: true, email: true, createdAt: true } }, + }, + }), + ]) + return { + items, + page: filters.page, + pageSize: filters.pageSize, + total, + totalPages: Math.ceil(total / filters.pageSize), + } +} + +export interface BulkReviewAdmin { + id: string + name: string + role: string +} + +export interface BulkReviewResult { + succeeded: string[] + skipped: Array<{ id: string; reason: string }> +} + +/** + * Applies one decision to many pending conversions. Each conversion is + * decided and audited in its own transaction, so one stale id cannot roll + * back the rest; non-pending or unknown ids are reported as skipped. + */ +export async function bulkReviewConversions( + ids: string[], + decision: 'approve' | 'reject', + admin: BulkReviewAdmin, + reason?: string +): Promise { + const result: BulkReviewResult = { succeeded: [], skipped: [] } + + for (const id of [...new Set(ids)]) { + const outcome = await db.$transaction(async (tx) => { + const conversion = await tx.referralConversion.findUnique({ + where: { id }, + }) + if (!conversion) return 'not found' + if (!conversion.manualReviewRequired || conversion.manualReviewRejected) { + return 'not pending review' + } + await tx.referralConversion.update({ + where: { id }, + data: { + manualReviewRequired: false, + manualReviewRejected: decision === 'reject', + reviewedBy: admin.name, + reviewedAt: new Date(), + reviewDecision: decision === 'approve' ? 'approved' : 'rejected', + ...(decision === 'reject' ? { rejectionReason: reason ?? null } : {}), + }, + }) + await tx.adminAuditLog.create({ + data: { + adminKeyId: admin.id, + adminName: admin.name, + adminRole: admin.role, + action: + decision === 'approve' + ? 'REFERRAL_BULK_APPROVE' + : 'REFERRAL_BULK_REJECT', + target: id, + result: 'SUCCESS', + details: { + fraudCheckScore: conversion.fraudCheckScore, + fraudCheckFlags: conversion.fraudCheckFlags, + reason: reason ?? null, + }, + }, + }) + return null + }) + if (outcome) result.skipped.push({ id, reason: outcome }) + else result.succeeded.push(id) + } + + logger.info('[Referral] Bulk review applied', { + decision, + reviewer: admin.name, + succeeded: result.succeeded.length, + skipped: result.skipped.length, + }) + return result +} + +export interface ScoredReview { + score: number + rejected: boolean +} + +export interface CalibrationReport { + month: string + scored: number + distribution: Array<{ band: string; count: number }> + reviewed: number + approvedCount: number + rejectedCount: number + falsePositiveRate: number | null + currentReviewScore: number + suggestedReviewScore: number | null + suggestion: string +} + +const BAND_WIDTH = 20 + +export function scoreDistribution(scores: number[]) { + const buckets = new Map() + for (const s of scores) { + const b = Math.floor(s / BAND_WIDTH) * BAND_WIDTH + buckets.set(b, (buckets.get(b) ?? 0) + 1) + } + return [...buckets.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([start, count]) => ({ + band: `${start}-${start + BAND_WIDTH - 1}`, + count, + })) +} + +/** + * Lowest threshold t among observed review scores where the share of + * rejections among reviews with score >= t reaches targetPrecision. A + * threshold higher than the current one means the queue is over-flagging. + */ +export function suggestReviewThreshold( + reviews: ScoredReview[], + targetPrecision: number +): number | null { + const candidates = [...new Set(reviews.map((r) => r.score))].sort( + (a, b) => a - b + ) + for (const t of candidates) { + const above = reviews.filter((r) => r.score >= t) + const precision = above.filter((r) => r.rejected).length / above.length + if (precision >= targetPrecision) return t + } + return null +} + +export async function buildCalibrationReport( + month: string +): Promise { + const match = /^(\d{4})-(\d{2})$/.exec(month) + if (!match) throw new Error('month must be YYYY-MM') + const start = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, 1)) + const end = new Date(Date.UTC(Number(match[1]), Number(match[2]), 1)) + + const [scoredRows, reviewedRows] = await Promise.all([ + db.referralConversion.findMany({ + where: { + fraudCheckScore: { not: null }, + createdAt: { gte: start, lt: end }, + }, + select: { fraudCheckScore: true }, + }), + db.referralConversion.findMany({ + where: { + fraudCheckScore: { not: null }, + reviewedAt: { gte: start, lt: end }, + }, + select: { fraudCheckScore: true, manualReviewRejected: true }, + }), + ]) + + const reviews: ScoredReview[] = reviewedRows.map((r) => ({ + score: r.fraudCheckScore as number, + rejected: r.manualReviewRejected, + })) + const rejectedCount = reviews.filter((r) => r.rejected).length + const approvedCount = reviews.length - rejectedCount + const falsePositiveRate = + reviews.length > 0 ? approvedCount / reviews.length : null + + const { reviewScore, targetPrecision } = config.referral.fraud + const suggested = suggestReviewThreshold(reviews, targetPrecision) + + let suggestion = 'Not enough manual reviews this month to suggest a change.' + if (reviews.length > 0) { + if (suggested === null) { + suggestion = `No threshold reaches ${targetPrecision} precision; review the fraud weights (REFERRAL_FRAUD_WEIGHT_*).` + } else if (suggested > reviewScore) { + suggestion = `Raise REFERRAL_FRAUD_REVIEW_SCORE from ${reviewScore} to ${suggested} to cut false positives.` + } else { + suggestion = `Current REFERRAL_FRAUD_REVIEW_SCORE (${reviewScore}) meets the ${targetPrecision} precision target.` + } + } + + return { + month, + scored: scoredRows.length, + distribution: scoreDistribution( + scoredRows.map((r) => r.fraudCheckScore as number) + ), + reviewed: reviews.length, + approvedCount, + rejectedCount, + falsePositiveRate, + currentReviewScore: reviewScore, + suggestedReviewScore: suggested, + suggestion, + } +} diff --git a/src/referral/service.ts b/src/referral/service.ts index fd74f61..83746c4 100644 --- a/src/referral/service.ts +++ b/src/referral/service.ts @@ -65,6 +65,7 @@ export async function checkReferralFraud( referralCodeId: string, database: Db = db ): Promise { + const fraudCfg = config.referral.fraud const flags: ReferralFraudFlag[] = [] let riskScore = 0 const details: Record = {} @@ -116,7 +117,7 @@ export async function checkReferralFraud( }) if (duplicateWallet > 0) { flags.push(ReferralFraudFlag.DUPLICATE_WALLET) - riskScore += 100 + riskScore += fraudCfg.weights.duplicateWallet details.duplicateWallet = true } @@ -130,7 +131,7 @@ export async function checkReferralFraud( }) if (duplicateEmail > 0) { flags.push(ReferralFraudFlag.DUPLICATE_EMAIL) - riskScore += 50 + riskScore += fraudCfg.weights.duplicateEmail details.duplicateEmail = true } } @@ -145,7 +146,7 @@ export async function checkReferralFraud( }) if (duplicatePhone > 0) { flags.push(ReferralFraudFlag.DUPLICATE_PHONE) - riskScore += 50 + riskScore += fraudCfg.weights.duplicatePhone details.duplicatePhone = true } } @@ -160,7 +161,7 @@ export async function checkReferralFraud( const sharedIps = [...referredIps].filter((ip) => referrerIps.has(ip)) if (sharedIps.length > 0) { flags.push(ReferralFraudFlag.SAME_IP_ADDRESS) - riskScore += 40 + riskScore += fraudCfg.weights.sameIp details.sharedIps = sharedIps } @@ -171,9 +172,9 @@ export async function checkReferralFraud( createdAt: { gt: new Date(Date.now() - 24 * 60 * 60 * 1000) }, // 24h }, }) - if (recentConversions >= 5) { + if (recentConversions >= fraudCfg.velocityCount) { flags.push(ReferralFraudFlag.SUSPICIOUS_VELOCITY) - riskScore += 30 + riskScore += fraudCfg.weights.velocity details.recentConversions = recentConversions } @@ -185,14 +186,14 @@ export async function checkReferralFraud( activatedAt: { gt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, // 7d }, }) - if (rapidActivations >= 3) { + if (rapidActivations >= fraudCfg.rapidActivationCount) { flags.push(ReferralFraudFlag.RAPID_ACTIVATION) - riskScore += 40 + riskScore += fraudCfg.weights.rapidActivation details.rapidActivations = rapidActivations } - const requiresManualReview = riskScore >= 80 - const passed = riskScore < 100 // Block only if score is 100+ + const requiresManualReview = riskScore >= fraudCfg.reviewScore + const passed = riskScore < fraudCfg.blockScore return { passed, diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 5b6be9a..faac804 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -2267,6 +2267,85 @@ router.get( } ) +/** + * POST /api/admin/treasury/sweep/preview + * Dry run: what would move under the current policies. Writes nothing and + * sends nothing to the chain. Required scope: treasury:read + */ +router.post( + '/treasury/sweep/preview', + requireAdminScope('treasury:read'), + async (req: Request, res: Response) => { + if (!getAdminAuth(res)) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } + try { + const { previewTreasurySweep } = await import('../jobs/treasurySweep') + const preview = await previewTreasurySweep() + auditLog(req, res, 'TREASURY_SWEEP_PREVIEW', 'success', { + plans: preview.plans.length, + }) + res.status(200).json({ + success: true, + data: preview, + timestamp: new Date().toISOString(), + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + auditLog(req, res, 'TREASURY_SWEEP_PREVIEW', 'failure', { + error: message, + }) + res.status(500).json({ success: false, error: message }) + } + } +) + +/** + * POST /api/admin/treasury/policies/:id/enable + * Turns sweeps on for a policy. Body: { "confirmation": "SWEEP" }. + * Required scope: treasury:write + */ +router.post( + '/treasury/policies/:id/enable', + requireAdminScope('treasury:write'), + async (req: Request, res: Response) => { + const adminAuth = getAdminAuth(res) + if (!adminAuth) { + return res + .status(401) + .json({ success: false, error: 'Admin authentication required' }) + } + try { + const { enablePolicy } = await import('../treasury/policy') + const policy = await enablePolicy( + req.params.id, + req.body?.confirmation, + adminAuth.name + ) + auditLog(req, res, 'TREASURY_POLICY_ENABLE', 'success', { + policyId: policy.id, + fromTier: policy.fromTier, + toTier: policy.toTier, + version: policy.version, + }) + res.status(200).json({ + success: true, + data: policy, + timestamp: new Date().toISOString(), + }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error' + auditLog(req, res, 'TREASURY_POLICY_ENABLE', 'failure', { + policyId: req.params.id, + error: message, + }) + res.status(400).json({ success: false, error: message }) + } + } +) + /** * POST /api/admin/treasury/signer-rotations * Initiates a signer rotation into DUAL_ACTIVE. Required scope: treasury:write @@ -3235,22 +3314,18 @@ router.put( } if (allowlist !== undefined && !Array.isArray(allowlist)) { - res - .status(400) - .json({ - success: false, - error: 'allowlist must be an array of strings', - }) + res.status(400).json({ + success: false, + error: 'allowlist must be an array of strings', + }) return } if (blocklist !== undefined && !Array.isArray(blocklist)) { - res - .status(400) - .json({ - success: false, - error: 'blocklist must be an array of strings', - }) + res.status(400).json({ + success: false, + error: 'blocklist must be an array of strings', + }) return } diff --git a/src/routes/admin/referral-review.ts b/src/routes/admin/referral-review.ts new file mode 100644 index 0000000..43fc68c --- /dev/null +++ b/src/routes/admin/referral-review.ts @@ -0,0 +1,91 @@ +import { + Router, + Request, + Response, + NextFunction, + RequestHandler, +} from 'express' +import { z } from 'zod' +import { + requireAdminAuth, + requireAdminScope, + getAdminAuth, +} from '../../middleware/adminAuth' +import { validate } from '../../middleware/validate' +import { + listReviewQueue, + bulkReviewConversions, + buildCalibrationReport, +} from '../../referral/reviewQueue' + +const router = Router() +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req: Request, res: Response, next: NextFunction) => { + void fn(req, res).catch(next) + } + +const querySchema = z.object({ + state: z.enum(['pending', 'approved', 'rejected', 'all']).default('pending'), + scoreMin: z.coerce.number().int().min(0).optional(), + scoreMax: z.coerce.number().int().min(0).optional(), + flag: z.string().min(1).max(64).optional(), + from: z.coerce.date().optional(), + to: z.coerce.date().optional(), + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(25), +}) + +const bulkSchema = z.object({ + ids: z.array(z.string().min(1)).min(1).max(100), + decision: z.enum(['approve', 'reject']), + reason: z.string().min(1).max(500).optional(), +}) + +const calibrationSchema = z.object({ + month: z.string().regex(/^\d{4}-(0[1-9]|1[0-2])$/), +}) + +router.use(requireAdminAuth) + +router.get( + '/queue', + requireAdminScope('referrals:read'), + validate({ query: querySchema }), + handle(async (req, res) => { + res.json(await listReviewQueue(querySchema.parse(req.query))) + }) +) + +router.post( + '/bulk-review', + requireAdminScope('referrals:write'), + validate({ body: bulkSchema }), + handle(async (req, res) => { + const admin = getAdminAuth(res)! + const { ids, decision, reason } = bulkSchema.parse(req.body) + if (decision === 'reject' && !reason) { + res.status(400).json({ error: 'reason is required when rejecting' }) + return + } + const result = await bulkReviewConversions( + ids, + decision, + { id: admin.id, name: admin.name, role: admin.role }, + reason + ) + res.json(result) + }) +) + +router.get( + '/calibration', + requireAdminScope('referrals:read'), + validate({ query: calibrationSchema }), + handle(async (req, res) => { + const { month } = calibrationSchema.parse(req.query) + res.json(await buildCalibrationReport(month)) + }) +) + +export default router diff --git a/src/routes/admin/risk-metadata.ts b/src/routes/admin/risk-metadata.ts new file mode 100644 index 0000000..8cc1b84 --- /dev/null +++ b/src/routes/admin/risk-metadata.ts @@ -0,0 +1,82 @@ +import { + Router, + Request, + Response, + NextFunction, + RequestHandler, +} from 'express' +import { z } from 'zod' +import { + requireAdminAuth, + requireAdminScope, + getAdminAuth, +} from '../../middleware/adminAuth' +import { validate } from '../../middleware/validate' +import db from '../../db' +import { + upsertRiskMetadata, + RiskMetadataValidationError, +} from '../../config/riskMetadataAdmin' + +const router = Router() +const handle = + (fn: (req: Request, res: Response) => Promise): RequestHandler => + (req: Request, res: Response, next: NextFunction) => { + void fn(req, res).catch(next) + } + +const protocolParamSchema = z.object({ + protocolName: z.string().min(1).max(100), +}) + +const upsertSchema = z.object({ + auditStatus: z.enum(['UNAUDITED', 'SELF_REPORTED', 'THIRD_PARTY_AUDITED']), + auditReference: z.string().min(1).max(500), + sourceUrl: z.string().url().max(500).optional(), + dataConfidence: z + .enum(['VERIFIED', 'SELF_REPORTED', 'UNVERIFIED']) + .optional(), + inceptionDate: z.coerce.date(), + nextReviewDueAt: z.coerce.date(), +}) + +router.use(requireAdminAuth) + +router.get( + '/', + requireAdminScope('risk-metadata:read'), + handle(async (_req, res) => { + const now = new Date() + const entries = await db.protocolRiskMetadataEntry.findMany({ + orderBy: { protocolName: 'asc' }, + }) + res.json({ + entries: entries.map((e) => ({ ...e, stale: e.nextReviewDueAt < now })), + }) + }) +) + +router.put( + '/:protocolName', + requireAdminScope('risk-metadata:write'), + validate({ params: protocolParamSchema, body: upsertSchema }), + handle(async (req, res) => { + const admin = getAdminAuth(res)! + try { + const entry = await upsertRiskMetadata( + req.params.protocolName, + upsertSchema.parse(req.body), + { id: admin.id, name: admin.name, role: admin.role } + ) + res.json({ entry }) + } catch (error) { + if (error instanceof RiskMetadataValidationError) { + res.status(400).json({ error: error.message }) + return + } + throw error + } + }) +) + +export default router diff --git a/src/routes/admin/support-tickets.ts b/src/routes/admin/support-tickets.ts index 23b05bb..853d2db 100644 --- a/src/routes/admin/support-tickets.ts +++ b/src/routes/admin/support-tickets.ts @@ -16,7 +16,7 @@ import db from '../../db' import { supportSla, supportSlaHours, - SUPPORT_TRANSITIONS, + canTransition, } from '../../services/support-ticket.service' import { adminUpdateSupportTicketSchema, @@ -113,11 +113,7 @@ router.patch( } const { status, priority, assignedTo } = adminUpdateSupportTicketSchema.parse(req.body) - if ( - status && - status !== existing.status && - !SUPPORT_TRANSITIONS[existing.status].includes(status) - ) { + if (status && !canTransition(existing.status, status)) { res.status(409).json({ error: `Invalid transition from ${existing.status} to ${status}`, }) @@ -208,6 +204,9 @@ router.post( where: { id: ticket.id, status: ticket.status }, data: { updatedAt: new Date(), + ...(!internal && !ticket.firstResponseAt + ? { firstResponseAt: new Date() } + : {}), ...(!internal && ['OPEN', 'IN_PROGRESS'].includes(ticket.status) ? { status: 'AWAITING_USER' } : {}), diff --git a/src/routes/protocols.ts b/src/routes/protocols.ts index ad81eda..655dd11 100644 --- a/src/routes/protocols.ts +++ b/src/routes/protocols.ts @@ -1,5 +1,9 @@ import { Router, Request, Response } from 'express' import db from '../db' +import { + getStaleProtocolNames, + applyStalenessPenalty, +} from '../jobs/riskMetadataStaleness' import { getAgentStatus } from '../agent/loop' import { formatAgentStatusReply, @@ -51,9 +55,13 @@ router.get('/risk', async (_req: Request, res: Response) => { orderBy: { score: 'desc' }, }) + const stale = await getStaleProtocolNames() + const items = scores.map((s: any) => ({ protocolName: s.protocolName, - score: s.score, + score: applyStalenessPenalty(s.score, stale.has(s.protocolName)), + rawScore: s.score, + riskMetadataStale: stale.has(s.protocolName), // Contributing factors — exposed so the score is transparent, not opaque. factors: { auditStatus: s.auditStatus, diff --git a/src/routes/registry.ts b/src/routes/registry.ts index ad5b92d..4fcbb5e 100644 --- a/src/routes/registry.ts +++ b/src/routes/registry.ts @@ -23,6 +23,8 @@ import agentRouter from './agent' import agentDecisionsRouter from './agent-decisions' import adminRouter from './admin' import adminSupportTicketsRouter from './admin/support-tickets' +import adminRiskMetadataRouter from './admin/risk-metadata' +import adminReferralReviewRouter from './admin/referral-review' import alertsRouter from './alerts' import analyticsRouter from './analytics' import approvalPoliciesRouter from './approval-policies' @@ -118,6 +120,14 @@ export const API_ROUTES: ApiRoute[] = [ path: 'admin/support/tickets', handlers: [adminRateLimiter, adminSupportTicketsRouter], }, + { + path: 'admin/referrals/fraud', + handlers: [adminRateLimiter, adminReferralReviewRouter], + }, + { + path: 'admin/risk-metadata', + handlers: [adminRateLimiter, adminRiskMetadataRouter], + }, { path: 'assistant', handlers: [assistantRouter] }, { path: 'approvals', handlers: [approvalsRouter] }, { path: 'approval-policies', handlers: [approvalPoliciesRouter] }, diff --git a/src/services/alerting.ts b/src/services/alerting.ts index 8b4ebe0..e5c4a29 100644 --- a/src/services/alerting.ts +++ b/src/services/alerting.ts @@ -372,7 +372,8 @@ class AlertingService { body: JSON.stringify({ routing_key: this.pagerdutyRoutingKey, event_action: 'trigger', - dedup_key: `dlq-alert-${Math.floor(Date.now() / 60000)}`, // Group by minute + dedup_key: + payload.dedupKey ?? `dlq-alert-${Math.floor(Date.now() / 60000)}`, // Group by minute payload: { summary: payload.title, severity, diff --git a/src/services/support-ticket.service.ts b/src/services/support-ticket.service.ts index 6fe71ea..f9dcfb7 100644 --- a/src/services/support-ticket.service.ts +++ b/src/services/support-ticket.service.ts @@ -14,20 +14,33 @@ export function supportSlaHours(priority: TicketPriority): number { : DEFAULT_SLA_HOURS[priority] } +export function supportStaleHours(): number { + const configured = Number(process.env.SUPPORT_STALE_HOURS) + return Number.isFinite(configured) && configured > 0 ? configured : 24 +} + export function supportSla( - ticket: { priority: TicketPriority; status: TicketStatus; createdAt: Date }, + ticket: { + priority: TicketPriority + status: TicketStatus + createdAt: Date + firstResponseAt?: Date | null + }, now = new Date() ) { const slaDueAt = new Date( ticket.createdAt.getTime() + supportSlaHours(ticket.priority) * 3600000 ) - return { - slaDueAt, - slaBreached: - ticket.status !== 'RESOLVED' && + const slaBreached = ticket.firstResponseAt + ? ticket.firstResponseAt > slaDueAt + : ticket.status !== 'RESOLVED' && ticket.status !== 'CLOSED' && - slaDueAt < now, - } + slaDueAt < now + return { slaDueAt, slaBreached } +} + +export function canTransition(from: TicketStatus, to: TicketStatus): boolean { + return from === to || SUPPORT_TRANSITIONS[from].includes(to) } export const SUPPORT_TRANSITIONS: Record = { diff --git a/src/treasury/policy.ts b/src/treasury/policy.ts index 75876b8..09f9fb1 100644 --- a/src/treasury/policy.ts +++ b/src/treasury/policy.ts @@ -64,6 +64,7 @@ export interface TreasurySweepPolicyRecord { requiresApprovalAbove: Prisma.Decimal | null version: number isActive: boolean + sweepsEnabled: boolean } export async function createPolicyVersion( @@ -120,3 +121,39 @@ export async function getActivePolicy( where: { fromTier, toTier, isActive: true }, }) } + +export const SWEEP_CONFIRMATION = 'SWEEP' + +/** + * Enables sweeps for a policy (#620). New policy versions start disabled; an + * operator must preview the sweep and type SWEEP to turn the policy on. + */ +export async function enablePolicy( + policyId: string, + confirmation: string, + enabledBy: string, + database: Db = db +): Promise { + if (confirmation !== SWEEP_CONFIRMATION) { + throw new TreasuryPolicyValidationError( + `confirmation must be the exact text ${SWEEP_CONFIRMATION}` + ) + } + const policy = await database.treasurySweepPolicy.findUnique({ + where: { id: policyId }, + }) + if (!policy) { + throw new TreasuryPolicyValidationError('policy not found') + } + if (!policy.isActive) { + throw new TreasuryPolicyValidationError( + 'only the active policy version can be enabled' + ) + } + const updated = await database.treasurySweepPolicy.update({ + where: { id: policyId }, + data: { sweepsEnabled: true, enabledBy, enabledAt: new Date() }, + }) + logger.info('[TreasuryPolicy] Sweeps enabled', { policyId, enabledBy }) + return updated +} diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts index 49b2472..4df045e 100644 --- a/src/utils/metrics.ts +++ b/src/utils/metrics.ts @@ -85,6 +85,25 @@ export const dlqAlertActive = new client.Gauge({ registers: [register], }) +export const supportTicketsSlaBreached = new client.Gauge({ + name: 'support_tickets_sla_breached', + help: 'Open support tickets currently past their response SLA, by priority', + labelNames: ['priority'], + registers: [register], +}) + +export const supportTicketsStale = new client.Gauge({ + name: 'support_tickets_stale', + help: 'Open support tickets untouched longer than the stale threshold', + registers: [register], +}) + +export const riskMetadataStaleEntries = new client.Gauge({ + name: 'risk_metadata_stale_entries', + help: 'Protocol risk metadata entries past their review-due date', + registers: [register], +}) + export const outboundNotificationAttempts = new client.Counter({ name: 'outbound_notification_attempts_total', help: 'Outbound notification delivery attempts by channel and outcome', diff --git a/tests/unit/config/riskMetadataAdmin.test.ts b/tests/unit/config/riskMetadataAdmin.test.ts index 7b894f3..8db105a 100644 --- a/tests/unit/config/riskMetadataAdmin.test.ts +++ b/tests/unit/config/riskMetadataAdmin.test.ts @@ -1,5 +1,6 @@ import { updateRiskMetadata, + upsertRiskMetadata, RiskMetadataValidationError, } from '../../../src/config/riskMetadataAdmin' import db from '../../../src/db' @@ -71,3 +72,64 @@ describe('updateRiskMetadata', () => { ).resolves.toMatchObject({ dataConfidence: 'SELF_REPORTED' }) }) }) + +describe('upsertRiskMetadata (#621)', () => { + const admin = { id: 'k1', name: 'ops', role: 'super' } + const input = { + auditStatus: 'THIRD_PARTY_AUDITED' as const, + auditReference: 'https://example.com/audit.pdf', + sourceUrl: 'https://example.com/audit.pdf', + dataConfidence: 'VERIFIED' as const, + inceptionDate: new Date('2024-02-01'), + nextReviewDueAt: new Date(Date.now() + 86400000), + } + + it('rejects VERIFIED without sourceUrl', async () => { + await expect( + upsertRiskMetadata('Blend', { ...input, sourceUrl: undefined }, admin) + ).rejects.toThrow(RiskMetadataValidationError) + expect(mockDb.$transaction).not.toHaveBeenCalled() + }) + + it('rejects a nextReviewDueAt in the past', async () => { + await expect( + upsertRiskMetadata( + 'Blend', + { ...input, nextReviewDueAt: new Date('2020-01-01') }, + admin + ) + ).rejects.toThrow('nextReviewDueAt') + }) + + it('writes the entry, history row and audit row with before/after', async () => { + const tx = { + protocolRiskMetadataEntry: { + findUnique: jest.fn().mockResolvedValue({ + auditStatus: 'UNAUDITED', + dataConfidence: 'UNVERIFIED', + sourceUrl: null, + nextReviewDueAt: new Date('2026-01-01'), + }), + upsert: jest.fn().mockResolvedValue({ id: 'e1', ...input }), + }, + protocolRiskMetadataHistory: { create: jest.fn() }, + adminAuditLog: { create: jest.fn() }, + } + mockDb.$transaction.mockImplementation((fn: any) => fn(tx)) + + await upsertRiskMetadata('Blend', input, admin) + + expect(tx.protocolRiskMetadataHistory.create).toHaveBeenCalledTimes(1) + expect(tx.adminAuditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + adminKeyId: 'k1', + action: 'RISK_METADATA_UPSERT', + target: 'Blend', + details: { + before: expect.objectContaining({ auditStatus: 'UNAUDITED' }), + after: expect.objectContaining({ dataConfidence: 'VERIFIED' }), + }, + }), + }) + }) +}) diff --git a/tests/unit/jobs/riskMetadataStaleness.test.ts b/tests/unit/jobs/riskMetadataStaleness.test.ts index b9e11dd..0b532b0 100644 --- a/tests/unit/jobs/riskMetadataStaleness.test.ts +++ b/tests/unit/jobs/riskMetadataStaleness.test.ts @@ -1,5 +1,9 @@ -import { flagStaleEntries } from '../../../src/jobs/riskMetadataStaleness' +import { + flagStaleEntries, + applyStalenessPenalty, +} from '../../../src/jobs/riskMetadataStaleness' import db from '../../../src/db' +import { alertingService } from '../../../src/services/alerting' jest.mock('../../../src/db', () => ({ __esModule: true, @@ -14,7 +18,16 @@ jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })) jest.mock('../../../src/config/env', () => ({ - config: { protocolRisk: { staleAutoDowngrade: false } }, + config: { + protocolRisk: { + staleAutoDowngrade: false, + stalePenalty: 20, + stalenessIntervalMs: 3600000, + }, + }, +})) +jest.mock('../../../src/services/alerting', () => ({ + alertingService: { emit: jest.fn().mockResolvedValue({ sent: true }) }, })) const mockDb = db as any @@ -36,6 +49,22 @@ describe('flagStaleEntries', () => { const stale = await flagStaleEntries(new Date('2026-01-01')) expect(stale).toEqual(['Blend']) + expect(alertingService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'critical', + component: 'protocol-risk-metadata', + metadata: { protocols: ['Blend'] }, + }), + 'risk-metadata-stale' + ) + expect(alertingService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'critical', + component: 'protocol-risk-metadata', + metadata: { protocols: ['Blend'] }, + }), + 'risk-metadata-stale' + ) expect(mockDb.protocolRiskMetadataEntry.updateMany).not.toHaveBeenCalled() }) @@ -45,5 +74,17 @@ describe('flagStaleEntries', () => { const stale = await flagStaleEntries(new Date('2026-01-01')) expect(stale).toEqual([]) + expect(alertingService.emit).not.toHaveBeenCalled() + }) +}) + +describe('applyStalenessPenalty', () => { + it('lowers a stale score by the configured penalty, floored at 0', () => { + expect(applyStalenessPenalty(70, true)).toBe(50) + expect(applyStalenessPenalty(10, true)).toBe(0) + }) + + it('leaves a fresh score untouched', () => { + expect(applyStalenessPenalty(70, false)).toBe(70) }) }) diff --git a/tests/unit/jobs/supportTicketSla.test.ts b/tests/unit/jobs/supportTicketSla.test.ts new file mode 100644 index 0000000..9e3fbc7 --- /dev/null +++ b/tests/unit/jobs/supportTicketSla.test.ts @@ -0,0 +1,111 @@ +import { checkSupportTicketSla } from '../../../src/jobs/supportTicketSla' +import db from '../../../src/db' +import { alertingService } from '../../../src/services/alerting' +import { register } from '../../../src/utils/metrics' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + supportTicket: { findMany: jest.fn(), updateMany: jest.fn() }, + }, +})) +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) +jest.mock('../../../src/services/alerting', () => ({ + alertingService: { emit: jest.fn().mockResolvedValue({ sent: true }) }, +})) + +const mockDb = db as any +const now = new Date('2026-10-10T12:00:00Z') +const hoursAgo = (h: number) => new Date(now.getTime() - h * 3600000) + +const ticket = (over: Record) => ({ + id: 't', + priority: 'MEDIUM', + status: 'OPEN', + createdAt: hoursAgo(1), + updatedAt: hoursAgo(1), + firstResponseAt: null, + slaBreached: false, + ...over, +}) + +beforeEach(() => { + jest.clearAllMocks() +}) + +describe('checkSupportTicketSla (#618)', () => { + it('marks newly breached tickets, alerts critical for urgent, and sets the metric', async () => { + mockDb.supportTicket.findMany.mockResolvedValue([ + ticket({ id: 'u1', priority: 'URGENT', createdAt: hoursAgo(3) }), + ticket({ id: 'm1', priority: 'MEDIUM', createdAt: hoursAgo(2) }), + ticket({ + id: 'old', + priority: 'URGENT', + createdAt: hoursAgo(10), + slaBreached: true, + }), + ]) + + const result = await checkSupportTicketSla(now) + + expect(result).toMatchObject({ breached: 2, newlyBreached: 1 }) + expect(mockDb.supportTicket.updateMany).toHaveBeenCalledWith({ + where: { id: { in: ['u1'] } }, + data: { slaBreached: true }, + }) + expect(alertingService.emit).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'critical', + metadata: { ticketIds: ['u1'] }, + }), + 'support-sla-breach' + ) + const metrics = await register.getMetricsAsJSON() + const gauge = metrics.find((m) => m.name === 'support_tickets_sla_breached') + const urgent = (gauge as any).values.find( + (v: any) => v.labels.priority === 'URGENT' + ) + expect(urgent.value).toBe(2) + }) + + it('does not treat a ticket answered within SLA as breached', async () => { + mockDb.supportTicket.findMany.mockResolvedValue([ + ticket({ + priority: 'URGENT', + status: 'IN_PROGRESS', + createdAt: hoursAgo(5), + firstResponseAt: hoursAgo(4), + }), + ]) + + const result = await checkSupportTicketSla(now) + + expect(result.breached).toBe(0) + expect(alertingService.emit).not.toHaveBeenCalledWith( + expect.anything(), + 'support-sla-breach' + ) + }) + + it('alerts on tickets untouched past the stale threshold, ignoring awaiting-user', async () => { + mockDb.supportTicket.findMany.mockResolvedValue([ + ticket({ id: 's1', priority: 'LOW', updatedAt: hoursAgo(30) }), + ticket({ + id: 's2', + priority: 'LOW', + status: 'AWAITING_USER', + updatedAt: hoursAgo(90), + }), + ]) + + const result = await checkSupportTicketSla(now) + + expect(result.stale).toBe(1) + expect(alertingService.emit).toHaveBeenCalledWith( + expect.objectContaining({ metadata: { ticketIds: ['s1'] } }), + 'support-stale' + ) + }) +}) diff --git a/tests/unit/jobs/treasurySweep.test.ts b/tests/unit/jobs/treasurySweep.test.ts index 5a74669..ba433f6 100644 --- a/tests/unit/jobs/treasurySweep.test.ts +++ b/tests/unit/jobs/treasurySweep.test.ts @@ -1,5 +1,6 @@ import { evaluateTreasuryBalances, + previewTreasurySweep, executeSweep, executeEmergencySweep, validateHysteresis, @@ -7,6 +8,7 @@ import { import db from '../../../src/db' import { getAccount } from '../../../src/stellar/client' import { enqueueOutboxOp } from '../../../src/outbox/service' +import { alertingService } from '../../../src/services/alerting' jest.mock('../../../src/db', () => ({ __esModule: true, @@ -24,6 +26,9 @@ jest.mock('../../../src/stellar/client', () => ({ jest.mock('../../../src/outbox/service', () => ({ enqueueOutboxOp: jest.fn(), })) +jest.mock('../../../src/services/alerting', () => ({ + alertingService: { emit: jest.fn().mockResolvedValue({ sent: true }) }, +})) jest.mock('../../../src/utils/logger', () => ({ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, })) @@ -58,6 +63,7 @@ describe('evaluateTreasuryBalances', () => { minSweepAmount: '10', requiresApprovalAbove: null, isActive: true, + sweepsEnabled: true, }) } return Promise.resolve(null) @@ -101,6 +107,7 @@ describe('executeSweep', () => { fromTier: 'HOT', toTier: 'WARM', requiresApprovalAbove: null, + sweepsEnabled: true, }) mockDb.treasurySweep.create.mockResolvedValue({ id: 'sweep-1' }) mockDb.treasurySweep.update.mockResolvedValue({}) @@ -134,6 +141,7 @@ describe('executeSweep', () => { fromTier: 'HOT', toTier: 'WARM', requiresApprovalAbove: '500', + sweepsEnabled: true, }) mockDb.treasurySweep.create.mockResolvedValue({ id: 'sweep-2' }) mockDb.multisigEnvelope.create.mockResolvedValue({ id: 'env-1' }) @@ -201,3 +209,86 @@ describe('executeEmergencySweep', () => { ) }) }) + +describe('previewTreasurySweep (#620)', () => { + const policy = { + id: 'p1', + version: 3, + fromTier: 'HOT', + toTier: 'WARM', + maxHotBalance: '5000', + minSweepAmount: '10', + requiresApprovalAbove: '500', + isActive: true, + sweepsEnabled: false, + } + + beforeEach(() => { + mockDb.treasurySweepPolicy.findFirst.mockImplementation(({ where }: any) => + Promise.resolve(where.fromTier === 'HOT' ? policy : null) + ) + mockDb.treasuryAccount.findMany.mockResolvedValue([{ publicKey: 'GHOT' }]) + mockGetAccount.mockResolvedValue({ + balances: [{ asset_type: 'native', balance: '6000' }], + }) + }) + + it('reports plans for a disabled policy without writing anything', async () => { + const preview = await previewTreasurySweep() + + expect(preview.plans).toHaveLength(1) + expect(preview.plans[0]).toMatchObject({ + amount: '1000.0000000', + policyEnabled: false, + requiresApproval: true, + policyId: 'p1', + }) + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockDb.treasurySweep.create).not.toHaveBeenCalled() + expect(mockDb.treasurySweep.update).not.toHaveBeenCalled() + expect(mockDb.multisigEnvelope.create).not.toHaveBeenCalled() + }) + + it('evaluateTreasuryBalances ignores a policy that is not enabled', async () => { + expect(await evaluateTreasuryBalances()).toEqual([]) + }) + + it('executeSweep refuses a policy that is not enabled and alerts', async () => { + await expect( + executeSweep({ + fromTier: 'HOT', + toTier: 'WARM', + asset: 'XLM', + amount: '1000', + reason: 'hot_over_high_band', + }) + ).rejects.toThrow('not enabled') + expect(mockEnqueue).not.toHaveBeenCalled() + expect(mockDb.treasurySweep.create).not.toHaveBeenCalled() + expect(alertingService.emit).toHaveBeenCalledWith( + expect.objectContaining({ severity: 'critical' }), + expect.stringContaining('treasury-sweep-failed') + ) + }) + + it('executeSweep alerts when enqueueing fails', async () => { + mockDb.treasurySweepPolicy.findFirst.mockResolvedValue({ + ...policy, + sweepsEnabled: true, + requiresApprovalAbove: null, + }) + mockDb.treasurySweep.create.mockResolvedValue({ id: 'sweep-9' }) + mockEnqueue.mockRejectedValue(new Error('outbox down')) + + await expect( + executeSweep({ + fromTier: 'HOT', + toTier: 'WARM', + asset: 'XLM', + amount: '1000', + reason: 'hot_over_high_band', + }) + ).rejects.toThrow('outbox down') + expect(alertingService.emit).toHaveBeenCalledTimes(1) + }) +}) diff --git a/tests/unit/referral/reviewQueue.test.ts b/tests/unit/referral/reviewQueue.test.ts new file mode 100644 index 0000000..d54e8f4 --- /dev/null +++ b/tests/unit/referral/reviewQueue.test.ts @@ -0,0 +1,163 @@ +import { + buildQueueWhere, + bulkReviewConversions, + scoreDistribution, + suggestReviewThreshold, + buildCalibrationReport, +} from '../../../src/referral/reviewQueue' +import db from '../../../src/db' + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: { + $transaction: jest.fn(), + referralConversion: { findMany: jest.fn() }, + }, +})) +jest.mock('../../../src/utils/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})) +jest.mock('../../../src/config', () => ({ + config: { referral: { fraud: { reviewScore: 80, targetPrecision: 0.7 } } }, +})) + +const mockDb = db as any + +beforeEach(() => jest.clearAllMocks()) + +describe('buildQueueWhere', () => { + it('combines score band, flag and date filters for pending items', () => { + const from = new Date('2026-10-01') + const to = new Date('2026-11-01') + const where = buildQueueWhere({ + state: 'pending', + scoreMin: 80, + scoreMax: 99, + flag: 'SAME_IP_ADDRESS', + from, + to, + }) + expect(where.AND).toEqual( + expect.arrayContaining([ + { manualReviewRequired: true, manualReviewRejected: false }, + { fraudCheckScore: { gte: 80 } }, + { fraudCheckScore: { lte: 99 } }, + { fraudCheckFlags: { has: 'SAME_IP_ADDRESS' } }, + { createdAt: { gte: from } }, + { createdAt: { lt: to } }, + ]) + ) + }) +}) + +describe('bulkReviewConversions', () => { + const admin = { id: 'k1', name: 'ops', role: 'write' } + + it('decides each pending conversion with an audit row and skips the rest', async () => { + const rows: Record = { + a: { + id: 'a', + manualReviewRequired: true, + manualReviewRejected: false, + fraudCheckScore: 85, + fraudCheckFlags: ['SAME_IP_ADDRESS'], + }, + b: { id: 'b', manualReviewRequired: false, manualReviewRejected: false }, + } + const tx = { + referralConversion: { + findUnique: jest.fn(({ where }: any) => + Promise.resolve(rows[where.id] ?? null) + ), + update: jest.fn(), + }, + adminAuditLog: { create: jest.fn() }, + } + mockDb.$transaction.mockImplementation((fn: any) => fn(tx)) + + const result = await bulkReviewConversions( + ['a', 'b', 'zzz', 'a'], + 'reject', + admin, + 'ring' + ) + + expect(result.succeeded).toEqual(['a']) + expect(result.skipped).toEqual([ + { id: 'b', reason: 'not pending review' }, + { id: 'zzz', reason: 'not found' }, + ]) + expect(tx.referralConversion.update).toHaveBeenCalledTimes(1) + expect(tx.referralConversion.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + manualReviewRejected: true, + rejectionReason: 'ring', + reviewDecision: 'rejected', + }), + }) + ) + expect(tx.adminAuditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + action: 'REFERRAL_BULK_REJECT', + target: 'a', + adminKeyId: 'k1', + }), + }) + }) +}) + +describe('calibration', () => { + it('buckets scores into 20-point bands', () => { + expect(scoreDistribution([0, 10, 45, 85, 99, 100])).toEqual([ + { band: '0-19', count: 2 }, + { band: '40-59', count: 1 }, + { band: '80-99', count: 2 }, + { band: '100-119', count: 1 }, + ]) + }) + + it('suggests the lowest threshold that reaches the target precision', () => { + const reviews = [ + { score: 80, rejected: false }, + { score: 80, rejected: false }, + { score: 90, rejected: true }, + { score: 100, rejected: true }, + ] + expect(suggestReviewThreshold(reviews, 0.7)).toBe(90) + expect( + suggestReviewThreshold([{ score: 80, rejected: false }], 0.7) + ).toBeNull() + }) + + it('computes the false-positive rate and a config-named suggestion', async () => { + mockDb.referralConversion.findMany + .mockResolvedValueOnce([ + { fraudCheckScore: 85 }, + { fraudCheckScore: 90 }, + { fraudCheckScore: 100 }, + ]) + .mockResolvedValueOnce([ + { fraudCheckScore: 80, manualReviewRejected: false }, + { fraudCheckScore: 82, manualReviewRejected: false }, + { fraudCheckScore: 95, manualReviewRejected: true }, + { fraudCheckScore: 100, manualReviewRejected: true }, + ]) + + const report = await buildCalibrationReport('2026-10') + + expect(report).toMatchObject({ + scored: 3, + reviewed: 4, + approvedCount: 2, + rejectedCount: 2, + falsePositiveRate: 0.5, + suggestedReviewScore: 95, + }) + expect(report.suggestion).toContain('REFERRAL_FRAUD_REVIEW_SCORE') + }) + + it('rejects a malformed month', async () => { + await expect(buildCalibrationReport('2026-13x')).rejects.toThrow('YYYY-MM') + }) +}) diff --git a/tests/unit/services/support-ticket.service.test.ts b/tests/unit/services/support-ticket.service.test.ts index 59aee48..63407a6 100644 --- a/tests/unit/services/support-ticket.service.test.ts +++ b/tests/unit/services/support-ticket.service.test.ts @@ -1,4 +1,5 @@ import { + canTransition, supportSla, supportSlaHours, SUPPORT_TRANSITIONS, @@ -34,4 +35,32 @@ describe('support ticket SLA and transitions', () => { expect(SUPPORT_TRANSITIONS.RESOLVED).toContain('IN_PROGRESS') expect(SUPPORT_TRANSITIONS.CLOSED).toEqual([]) }) + it('judges a responded ticket by when it was first answered', () => { + const base = { + priority: 'URGENT' as const, + status: 'IN_PROGRESS' as const, + createdAt, + } + expect( + supportSla( + { ...base, firstResponseAt: new Date('2026-10-01T01:00:00Z') }, + now + ).slaBreached + ).toBe(false) + expect( + supportSla( + { ...base, firstResponseAt: new Date('2026-10-01T02:30:00Z') }, + now + ).slaBreached + ).toBe(true) + }) + + it('rejects invalid transitions and allows the reopen path', () => { + expect(canTransition('OPEN', 'CLOSED')).toBe(false) + expect(canTransition('CLOSED', 'IN_PROGRESS')).toBe(false) + expect(canTransition('AWAITING_USER', 'CLOSED')).toBe(false) + expect(canTransition('RESOLVED', 'IN_PROGRESS')).toBe(true) + expect(canTransition('RESOLVED', 'CLOSED')).toBe(true) + expect(canTransition('OPEN', 'OPEN')).toBe(true) + }) }) diff --git a/tests/unit/treasury/policy.test.ts b/tests/unit/treasury/policy.test.ts index 55a7b28..cbf5e78 100644 --- a/tests/unit/treasury/policy.test.ts +++ b/tests/unit/treasury/policy.test.ts @@ -1,4 +1,5 @@ import { + enablePolicy, validatePolicyInput, TreasuryPolicyValidationError, } from '../../../src/treasury/policy' @@ -41,3 +42,43 @@ describe('validatePolicyInput', () => { ).toThrow(TreasuryPolicyValidationError) }) }) + +describe('enablePolicy (#620)', () => { + const makeDb = (policy: any) => + ({ + treasurySweepPolicy: { + findUnique: jest.fn().mockResolvedValue(policy), + update: jest.fn().mockResolvedValue({ ...policy, sweepsEnabled: true }), + }, + }) as any + + it('rejects anything but the exact text SWEEP', async () => { + const database = makeDb({ id: 'p1', isActive: true }) + for (const bad of ['sweep', 'SWEEP ', '', undefined as any]) { + await expect(enablePolicy('p1', bad, 'admin', database)).rejects.toThrow( + TreasuryPolicyValidationError + ) + } + expect(database.treasurySweepPolicy.update).not.toHaveBeenCalled() + }) + + it('rejects an inactive (superseded) policy version', async () => { + const database = makeDb({ id: 'p1', isActive: false }) + await expect( + enablePolicy('p1', 'SWEEP', 'admin', database) + ).rejects.toThrow('active policy') + }) + + it('enables the policy and records who enabled it', async () => { + const database = makeDb({ id: 'p1', isActive: true }) + await enablePolicy('p1', 'SWEEP', 'admin', database) + expect(database.treasurySweepPolicy.update).toHaveBeenCalledWith({ + where: { id: 'p1' }, + data: { + sweepsEnabled: true, + enabledBy: 'admin', + enabledAt: expect.any(Date), + }, + }) + }) +})