diff --git a/core/audit/__tests__/compliance-snapshot.test.ts b/core/audit/__tests__/compliance-snapshot.test.ts new file mode 100644 index 0000000..46db9da --- /dev/null +++ b/core/audit/__tests__/compliance-snapshot.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + COMPLIANCE_SNAPSHOT_SCHEMA_VERSION, + ComplianceSnapshotStore, + canonicalJson, + createComplianceSnapshot, + verifyComplianceSnapshot, + type ComplianceSnapshot, + type ComplianceSnapshotInput, + type SnapshotStorage +} from "../compliance-snapshot"; + +class MemoryStorage implements SnapshotStorage { + readonly values = new Map(); + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } +} + +const input: ComplianceSnapshotInput = { + eventId: "evt-1", + event: "authorization.revoked", + capturedAt: "2026-10-18T10:00:00.000Z", + activeRules: { maxFeeStroops: 500, requireSignature: true }, + state: { holders: 3 } +}; + +function created(): ComplianceSnapshot { + const result = createComplianceSnapshot(input); + if (!result.ok) throw new Error(`expected snapshot, got ${result.code}`); + return result.value; +} + +describe("canonicalJson", () => { + it("sorts keys at every depth so equal data serialises identically", () => { + expect(canonicalJson({ b: 1, a: { d: 2, c: 3 } })).toBe('{"a":{"c":3,"d":2},"b":1}'); + }); +}); + +describe("createComplianceSnapshot", () => { + it("stores a canonical payload with schema version and integrity hash", () => { + const snapshot = created(); + + expect(snapshot.schemaVersion).toBe(COMPLIANCE_SNAPSHOT_SCHEMA_VERSION); + expect(JSON.parse(snapshot.payload)).toMatchObject({ eventId: "evt-1", state: { holders: 3 } }); + expect(snapshot.integrityHash).toHaveLength(64); + }); + + it("produces the same hash for the same input", () => { + expect(created().integrityHash).toBe(created().integrityHash); + }); + + it("rejects an empty event id", () => { + const result = createComplianceSnapshot({ ...input, eventId: " " }); + + expect(result).toEqual({ ok: false, code: "invalid_snapshot_input" }); + }); +}); + +describe("verifyComplianceSnapshot", () => { + it("accepts an untouched snapshot", () => { + expect(verifyComplianceSnapshot(created())).toEqual({ ok: true, value: true }); + }); + + it("detects a payload that was altered after capture", () => { + const snapshot = created(); + const tampered = { ...snapshot, payload: snapshot.payload.replace("\"holders\":3", "\"holders\":4") }; + + expect(verifyComplianceSnapshot(tampered)).toEqual({ ok: false, code: "snapshot_tampered" }); + }); + + it("refuses an unknown schema version", () => { + const snapshot = { ...created(), schemaVersion: 99 }; + + expect(verifyComplianceSnapshot(snapshot)).toEqual({ ok: false, code: "unsupported_schema_version" }); + }); +}); + +describe("ComplianceSnapshotStore", () => { + it("captures once per event and returns the original on repeat capture", () => { + const storage = new MemoryStorage(); + const store = new ComplianceSnapshotStore(storage); + + const first = store.capture(input); + const second = store.capture({ ...input, state: { holders: 99 } }); + + expect(first.ok && second.ok).toBe(true); + if (!first.ok || !second.ok) return; + expect(second.value.payload).toBe(first.value.payload); + expect(storage.values.size).toBe(1); + }); + + it("returns the stored snapshot to a maintainer after verifying it", () => { + const store = new ComplianceSnapshotStore(new MemoryStorage()); + store.capture(input); + + const read = store.read("evt-1", "maintainer"); + + expect(read.ok).toBe(true); + }); + + it("denies snapshot reads to the own scope", () => { + const store = new ComplianceSnapshotStore(new MemoryStorage()); + store.capture(input); + + expect(store.read("evt-1", "own")).toEqual({ ok: false, code: "audit_denied" }); + }); + + it("reports tampering when the stored envelope was modified", () => { + const storage = new MemoryStorage(); + const store = new ComplianceSnapshotStore(storage); + store.capture(input); + const key = "utix:compliance-snapshot:v1:evt-1"; + const stored = JSON.parse(storage.getItem(key) ?? "{}") as ComplianceSnapshot; + storage.setItem(key, JSON.stringify({ ...stored, payload: stored.payload.replace("true", "false") })); + + expect(store.read("evt-1", "maintainer")).toEqual({ ok: false, code: "snapshot_tampered" }); + }); + + it("reports not found for an unknown event", () => { + const store = new ComplianceSnapshotStore(new MemoryStorage()); + + expect(store.read("missing", "maintainer")).toEqual({ ok: false, code: "snapshot_not_found" }); + }); +}); diff --git a/core/audit/audit.ts b/core/audit/audit.ts index d1f042a..aa53d40 100644 --- a/core/audit/audit.ts +++ b/core/audit/audit.ts @@ -87,7 +87,19 @@ export const SENSITIVE_ACTIONS = [ /** A delegated permission was revoked (or a revocation was refused). */ "authorization.revoked", /** A stale delegated permission was removed by cleanup (or cleanup was refused). */ - "authorization.cleaned_up" + "authorization.cleaned_up", + /** A recipient's notification preferences were changed (or the change was refused). */ + "notification_preferences.updated", + /** A recipient's notification preferences were reset to defaults. */ + "notification_preferences.reset", + /** An optional notification was suppressed by the recipient's preferences. */ + "notification.suppressed_by_preference", + /** A mandatory alert was delivered despite the recipient's preferences. */ + "notification.mandatory_override", + /** A freeze window was bypassed for an emergency action. */ + "freeze.bypassed", + /** An emergency bypass of a freeze window was refused. */ + "freeze.bypass_denied" ] as const; export type AuditAction = (typeof SENSITIVE_ACTIONS)[number]; diff --git a/core/audit/compliance-snapshot.ts b/core/audit/compliance-snapshot.ts new file mode 100644 index 0000000..4b5aed4 --- /dev/null +++ b/core/audit/compliance-snapshot.ts @@ -0,0 +1,188 @@ +/** + * Immutable compliance snapshots for critical lifecycle events. + * + * A snapshot freezes the rules and data that were active when a critical event + * happened. The payload is canonical JSON (sorted keys) and carries a SHA-256 + * integrity hash, so a later audit can prove the record was not altered. + */ +import { err, ok, type Result } from "@/core/result/result"; +import { sha256Hex } from "@/core/ledger/sha256"; +import type { AuditAction } from "./audit"; + +export const COMPLIANCE_SNAPSHOT_SCHEMA_VERSION = 1; + +/** Lifecycle events that must be captured. Each one is also a sensitive audit action. */ +export const CRITICAL_SNAPSHOT_EVENTS: readonly AuditAction[] = [ + "admin.config_changed", + "authorization.granted", + "authorization.revoked", + "projection.activated", + "reconciliation.repaired", + "quota.override_granted", + "freeze.bypassed" +]; + +export type CriticalSnapshotEvent = AuditAction; + +export type ComplianceSnapshotErrorCode = + | "invalid_snapshot_input" + | "snapshot_not_found" + | "snapshot_tampered" + | "unsupported_schema_version" + | "audit_denied"; + +export interface ComplianceSnapshotInput { + readonly eventId: string; + readonly event: CriticalSnapshotEvent; + readonly capturedAt: string; + readonly activeRules: Record; + readonly state: Record; +} + +export interface ComplianceSnapshot { + readonly eventId: string; + readonly schemaVersion: number; + readonly event: CriticalSnapshotEvent; + readonly capturedAt: string; + /** Canonical JSON of the full payload. This is the exact text that was hashed. */ + readonly payload: string; + readonly integrityHash: string; +} + +export interface SnapshotStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export const COMPLIANCE_SNAPSHOT_PREFIX = "utix:compliance-snapshot:v1:"; + +/** Stable JSON: object keys sorted at every depth so equal data always hashes equally. */ +export function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value !== null && typeof value === "object") { + const record = value as Record; + const members = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`); + return `{${members.join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function isNonEmpty(value: string): boolean { + return value.trim().length > 0 && value.length <= 128; +} + +export function createComplianceSnapshot( + input: ComplianceSnapshotInput +): Result { + if (!isNonEmpty(input.eventId) || !isNonEmpty(input.capturedAt)) { + return err("invalid_snapshot_input"); + } + + const payload = canonicalJson({ + schemaVersion: COMPLIANCE_SNAPSHOT_SCHEMA_VERSION, + eventId: input.eventId, + event: input.event, + capturedAt: input.capturedAt, + activeRules: input.activeRules, + state: input.state + }); + + return ok({ + eventId: input.eventId, + schemaVersion: COMPLIANCE_SNAPSHOT_SCHEMA_VERSION, + event: input.event, + capturedAt: input.capturedAt, + payload, + integrityHash: sha256Hex(payload) + }); +} + +/** Recomputes the hash and checks the payload matches the stored envelope. */ +export function verifyComplianceSnapshot( + snapshot: ComplianceSnapshot +): Result { + if (snapshot.schemaVersion !== COMPLIANCE_SNAPSHOT_SCHEMA_VERSION) { + return err("unsupported_schema_version"); + } + if (typeof snapshot.payload !== "string" || sha256Hex(snapshot.payload) !== snapshot.integrityHash) { + return err("snapshot_tampered"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(snapshot.payload); + } catch { + return err("snapshot_tampered"); + } + const envelope = parsed as Partial; + const matches = + envelope.eventId === snapshot.eventId && + envelope.event === snapshot.event && + envelope.capturedAt === snapshot.capturedAt && + envelope.schemaVersion === snapshot.schemaVersion; + + return matches ? ok(true) : err("snapshot_tampered"); +} + +/** + * Keeps one snapshot per event id. Capturing the same event again returns the + * original, so a retried emitter cannot overwrite the record. + */ +export class ComplianceSnapshotStore { + private readonly storage: SnapshotStorage; + + constructor(storage: SnapshotStorage) { + this.storage = storage; + } + + private key(eventId: string): string { + return `${COMPLIANCE_SNAPSHOT_PREFIX}${eventId}`; + } + + capture(input: ComplianceSnapshotInput): Result { + const existing = this.storage.getItem(this.key(input.eventId)); + if (existing) { + return this.parse(existing); + } + + const created = createComplianceSnapshot(input); + if (created.ok) { + this.storage.setItem(this.key(input.eventId), JSON.stringify(created.value)); + } + return created; + } + + /** + * Reads a snapshot for audit review. Only the maintainer scope may read them, and + * the integrity hash is verified before the snapshot is returned. + */ + read( + eventId: string, + scope: "own" | "maintainer" + ): Result { + if (scope !== "maintainer") return err("audit_denied"); + if (!isNonEmpty(eventId)) return err("invalid_snapshot_input"); + + const raw = this.storage.getItem(this.key(eventId)); + if (!raw) return err("snapshot_not_found"); + + return this.parse(raw); + } + + private parse(raw: string): Result { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return err("snapshot_tampered"); + } + if (typeof parsed !== "object" || parsed === null) { + return err("snapshot_tampered"); + } + const snapshot = parsed as ComplianceSnapshot; + const verified = verifyComplianceSnapshot(snapshot); + return verified.ok ? ok(snapshot) : err(verified.code); + } +} diff --git a/core/notifications/__tests__/mandatory-preferences.test.ts b/core/notifications/__tests__/mandatory-preferences.test.ts new file mode 100644 index 0000000..8a88ff2 --- /dev/null +++ b/core/notifications/__tests__/mandatory-preferences.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { NotificationStore } from "../store"; +import type { NotificationStorage } from "../types"; + +class TestStorage implements NotificationStorage { + private readonly values = new Map(); + + getItem(key: string) { + return this.values.get(key) ?? null; + } + + setItem(key: string, value: string) { + this.values.set(key, value); + } + + removeItem(key: string) { + this.values.delete(key); + } +} + +const account = `account:${`G${"B".repeat(55)}`}` as const; +const otherAccount = `account:${`G${"C".repeat(55)}`}` as const; + +describe("NotificationStore mandatory overrides and preference authorization", () => { + it("delivers a mandatory failure alert even when the recipient has muted everything", () => { + const store = new NotificationStore(new TestStorage()); + store.updatePreferences(account, { muted: true }); + + const alert = store.publish({ + recipient: account, + event: "failure", + tone: "error", + title: "Payout failed", + message: "Retry budget exhausted", + href: "/tools/operation-browser", + dedupeKey: "payout-failed-1" + }); + + expect(alert).not.toBeNull(); + expect(alert?.routedAs).toBe("mandatory_override"); + expect(alert?.urgency).toBe("mandatory"); + }); + + it("still suppresses an optional notification for a muted recipient", () => { + const store = new NotificationStore(new TestStorage()); + store.updatePreferences(account, { muted: true }); + + const optional = store.publish({ + recipient: account, + event: "completed", + tone: "success", + title: "Done", + message: "Finished", + href: "/tools/operation-browser", + dedupeKey: "done-1" + }); + + expect(optional).toBeNull(); + }); + + it("refuses a preference update made by another account and leaves the preferences unchanged", () => { + const store = new NotificationStore(new TestStorage()); + + expect(() => store.updatePreferences(account, { muted: true }, otherAccount)).toThrow( + /cannot be edited/ + ); + expect(store.getPreferences(account).muted).toBe(false); + }); + + it("refuses a workspace reset of an account's preferences", () => { + const store = new NotificationStore(new TestStorage()); + store.updatePreferences(account, { muted: true }); + + expect(() => store.resetPreferences(account, "workspace")).toThrow(/cannot be edited/); + expect(store.getPreferences(account).muted).toBe(true); + }); + + it("lets the recipient edit their own preferences", () => { + const store = new NotificationStore(new TestStorage()); + + const updated = store.updatePreferences(account, { muted: true }, account); + + expect(updated.muted).toBe(true); + }); +}); diff --git a/core/notifications/store.ts b/core/notifications/store.ts index 66cffce..fb9c6cf 100644 --- a/core/notifications/store.ts +++ b/core/notifications/store.ts @@ -259,6 +259,22 @@ export class NotificationStore { } } + /** + * Only the recipient may change their own preferences. Other edits are audited as + * denied and refused, so a workspace or another account cannot silence a recipient. + */ + private assertPreferenceEditor(recipient: NotificationRecipient, actor: NotificationRecipient): void { + if (actor === recipient) return; + recordAudit({ + action: "notification_preferences.updated", + actor: { kind: "user", id: actor }, + target: { kind: "notification", id: recipient }, + outcome: "denied", + reason: "unauthorized_preference_edit" + }); + throw new Error(`Preferences for ${recipient} cannot be edited by ${actor}`); + } + /** * Updates durable preferences for a recipient and records an audit log. * @@ -269,11 +285,12 @@ export class NotificationStore { updatePreferences( recipient: NotificationRecipient, patch: Partial>, - { expectedVersion }: { expectedVersion?: number } = {} + actor: NotificationRecipient = recipient ): NotificationPreferences { if (!isNotificationRecipient(recipient)) { throw new Error(`Invalid notification recipient: ${String(recipient)}`); } + this.assertPreferenceEditor(recipient, actor); const current = this.getPreferences(recipient); if (expectedVersion !== undefined && expectedVersion !== current.version) { throw new StalePreferencesVersionError(recipient, expectedVersion, current.version); @@ -316,10 +333,14 @@ export class NotificationStore { /** * Resets preferences to factory defaults. */ - resetPreferences(recipient: NotificationRecipient): NotificationPreferences { + resetPreferences( + recipient: NotificationRecipient, + actor: NotificationRecipient = recipient + ): NotificationPreferences { if (!isNotificationRecipient(recipient)) { throw new Error(`Invalid notification recipient: ${String(recipient)}`); } + this.assertPreferenceEditor(recipient, actor); const defaults = createDefaultPreferences(recipient); this.storage.removeItem(this.prefKey(recipient)); diff --git a/core/policy/__tests__/counterparty-risk.test.ts b/core/policy/__tests__/counterparty-risk.test.ts new file mode 100644 index 0000000..b0b169c --- /dev/null +++ b/core/policy/__tests__/counterparty-risk.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + evaluateCounterpartyRisk, + toPublicRiskSummary, + type CounterpartyProfile +} from "../counterparty-risk"; + +const established: CounterpartyProfile = { + accountAgeDays: 400, + disputeCount: 0, + flaggedByFraudControls: false, + bulkTransferCount: 0 +}; + +describe("evaluateCounterpartyRisk", () => { + it("reports low risk for an established counterparty with no signals", () => { + const report = evaluateCounterpartyRisk("GA-LOW", established); + + expect(report.state).toBe("low"); + expect(report.source).toBe("score"); + expect(report.score).toBe(0); + expect(report.signals).toEqual([]); + }); + + it("reports medium risk when a single moderate signal fires", () => { + const report = evaluateCounterpartyRisk("GA-MED", { ...established, disputeCount: 1 }); + + expect(report.state).toBe("medium"); + expect(report.score).toBe(40); + expect(report.signals.map((signal) => signal.id)).toEqual(["disputes"]); + }); + + it("reports high risk when signals combine past the high threshold", () => { + const report = evaluateCounterpartyRisk("GA-HIGH", { + accountAgeDays: 2, + disputeCount: 3, + flaggedByFraudControls: true, + bulkTransferCount: 25 + }); + + expect(report.state).toBe("high"); + expect(report.score).toBe(100); + }); + + it("reports unknown when there is no profile and no override", () => { + const report = evaluateCounterpartyRisk("GA-UNKNOWN", null); + + expect(report.state).toBe("unknown"); + expect(report.source).toBe("no_profile"); + expect(report.score).toBeNull(); + }); + + it("lets a maintainer override replace the scored state while keeping the score", () => { + const report = evaluateCounterpartyRisk( + "GA-OVERRIDE", + { ...established, flaggedByFraudControls: true }, + { state: "low", setBy: "maintainer-1", reason: "Cleared after manual review" } + ); + + expect(report.state).toBe("low"); + expect(report.source).toBe("override"); + expect(report.score).toBe(50); + expect(report.explanation).toContain("maintainer-1"); + }); + + it("applies an override even when no profile exists", () => { + const report = evaluateCounterpartyRisk("GA-NOPROFILE", null, { + state: "high", + setBy: "maintainer-2", + reason: "Known bad actor" + }); + + expect(report.state).toBe("high"); + expect(report.source).toBe("override"); + expect(report.score).toBeNull(); + }); +}); + +describe("toPublicRiskSummary", () => { + it("exposes only the counterparty and state, never score or signals", () => { + const report = evaluateCounterpartyRisk("GA-PUBLIC", { + ...established, + disputeCount: 2 + }); + + expect(toPublicRiskSummary(report)).toEqual({ counterpartyId: "GA-PUBLIC", state: "medium" }); + }); +}); diff --git a/core/policy/__tests__/freeze-window.test.ts b/core/policy/__tests__/freeze-window.test.ts new file mode 100644 index 0000000..54cbaeb --- /dev/null +++ b/core/policy/__tests__/freeze-window.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from "vitest"; +import { enforceFreeze, validateFreezeWindow, type FreezeWindow } from "../freeze-window"; + +const START = Date.parse("2026-10-20T00:00:00.000Z"); +const END = Date.parse("2026-10-21T00:00:00.000Z"); + +const settlement: FreezeWindow = { + id: "settlement-week", + actions: ["transfer", "redemption"], + startsAt: START, + endsAt: END, + reason: "Settlement window" +}; + +const during = START + 60_000; + +describe("validateFreezeWindow", () => { + it("accepts a window with known actions and an increasing schedule", () => { + expect(validateFreezeWindow(settlement).ok).toBe(true); + }); + + it("rejects an unknown action scope", () => { + const result = validateFreezeWindow({ ...settlement, actions: ["teleport" as never] }); + + expect(result).toEqual({ ok: false, code: "invalid_freeze_window" }); + }); + + it("rejects a window that ends before it starts", () => { + const result = validateFreezeWindow({ ...settlement, startsAt: END, endsAt: START }); + + expect(result).toEqual({ ok: false, code: "invalid_freeze_window" }); + }); +}); + +describe("enforceFreeze", () => { + it("rejects a frozen action during an active window and names the window", () => { + const result = enforceFreeze({ windows: [settlement], action: "transfer", now: during }); + + expect(result).toEqual({ + ok: false, + code: "action_frozen", + detail: { windowId: "settlement-week", endsAt: END } + }); + }); + + it("lets unrelated actions continue during an active window", () => { + const result = enforceFreeze({ windows: [settlement], action: "issuance", now: during }); + + expect(result).toEqual({ ok: true, value: true }); + }); + + it("allows a frozen action once the window has expired", () => { + const result = enforceFreeze({ windows: [settlement], action: "transfer", now: END + 1 }); + + expect(result.ok).toBe(true); + }); + + it("allows a frozen action before a future window starts", () => { + const result = enforceFreeze({ windows: [settlement], action: "transfer", now: START - 1 }); + + expect(result.ok).toBe(true); + }); + + it("treats the end instant as outside the window", () => { + const result = enforceFreeze({ windows: [settlement], action: "transfer", now: END }); + + expect(result.ok).toBe(true); + }); + + it("lets a maintainer bypass an active window for an emergency", () => { + const result = enforceFreeze({ + windows: [settlement], + action: "transfer", + now: during, + bypass: { windowId: "settlement-week", approvedBy: "maintainer-1", justification: "Incident 42" } + }); + + expect(result).toEqual({ ok: true, value: true }); + }); + + it("refuses a bypass that names a different window", () => { + const result = enforceFreeze({ + windows: [settlement], + action: "transfer", + now: during, + bypass: { windowId: "other-window", approvedBy: "maintainer-1", justification: "Incident 42" } + }); + + expect(result).toEqual({ ok: false, code: "invalid_bypass" }); + }); + + it("refuses a bypass without a named approver", () => { + const result = enforceFreeze({ + windows: [settlement], + action: "transfer", + now: during, + bypass: { windowId: "settlement-week", approvedBy: " ", justification: "Incident 42" } + }); + + expect(result).toEqual({ ok: false, code: "invalid_bypass" }); + }); +}); diff --git a/core/policy/counterparty-risk.ts b/core/policy/counterparty-risk.ts new file mode 100644 index 0000000..881bc6d --- /dev/null +++ b/core/policy/counterparty-risk.ts @@ -0,0 +1,139 @@ +/** + * Counterparty risk scoring for issuers, organizers and transfer participants. + * + * Profile signals are fed through the shared `assessRisk` engine so the score is + * reproducible from the documented inputs below. Users only ever see the coarse + * state; the score, thresholds and signal breakdown stay behind the maintainer view. + */ +import { assessRisk, type RiskLevel, type RiskSignal, type RiskSignalInput } from "./risk-scoring"; + +export type CounterpartyRiskState = "low" | "medium" | "high" | "unknown"; + +export interface CounterpartyProfile { + /** Account age in whole days, or `null` when the age could not be determined. */ + readonly accountAgeDays: number | null; + readonly disputeCount: number; + readonly flaggedByFraudControls: boolean; + readonly bulkTransferCount: number; +} + +/** A maintainer decision that replaces the scored state until it is removed. */ +export interface CounterpartyRiskOverride { + readonly state: Exclude; + readonly setBy: string; + readonly reason: string; +} + +export interface CounterpartyRiskReport { + readonly counterpartyId: string; + readonly state: CounterpartyRiskState; + readonly source: "score" | "override" | "no_profile"; + /** Maintainer-only detail: never returned by `toPublicRiskSummary`. */ + readonly score: number | null; + readonly signals: readonly RiskSignal[]; + readonly explanation: string; +} + +export interface PublicCounterpartyRisk { + readonly counterpartyId: string; + readonly state: CounterpartyRiskState; +} + +export const NEW_ACCOUNT_THRESHOLD_DAYS = 30; +export const BULK_TRANSFER_THRESHOLD = 10; + +const SIGNAL_WEIGHTS = { + newAccount: 15, + dispute: 40, + fraudFlag: 50, + bulkTransfers: 20 +} as const; + +function toState(level: RiskLevel): Exclude { + return level === "critical" ? "high" : level; +} + +function buildSignals(profile: CounterpartyProfile): RiskSignalInput[] { + const newAccount = profile.accountAgeDays !== null && profile.accountAgeDays < NEW_ACCOUNT_THRESHOLD_DAYS; + return [ + { + id: "new_account", + name: "New account", + fired: newAccount, + maxScore: SIGNAL_WEIGHTS.newAccount, + explanation: "Account age is only {ageDays} days", + context: { ageDays: profile.accountAgeDays ?? 0 } + }, + { + id: "disputes", + name: "Open disputes", + fired: profile.disputeCount > 0, + maxScore: SIGNAL_WEIGHTS.dispute, + explanation: "{disputeCount} disputes on record", + context: { disputeCount: profile.disputeCount } + }, + { + id: "fraud_flag", + name: "Fraud control flag", + fired: profile.flaggedByFraudControls, + maxScore: SIGNAL_WEIGHTS.fraudFlag, + explanation: "Flagged by fraud controls" + }, + { + id: "bulk_transfers", + name: "Bulk transfers", + fired: profile.bulkTransferCount >= BULK_TRANSFER_THRESHOLD, + maxScore: SIGNAL_WEIGHTS.bulkTransfers, + explanation: "{bulkTransferCount} bulk transfers in the review window", + context: { bulkTransferCount: profile.bulkTransferCount } + } + ]; +} + +/** + * Scores a counterparty. A missing profile is `unknown`, and a maintainer override + * wins over the score while still reporting the underlying score for review. + */ +export function evaluateCounterpartyRisk( + counterpartyId: string, + profile: CounterpartyProfile | null, + override?: CounterpartyRiskOverride +): CounterpartyRiskReport { + const assessment = profile ? assessRisk(buildSignals(profile)) : null; + + if (override) { + return { + counterpartyId, + state: override.state, + source: "override", + score: assessment?.score ?? null, + signals: assessment?.signals ?? [], + explanation: `Manually set to ${override.state} by ${override.setBy}: ${override.reason}` + }; + } + + if (!assessment) { + return { + counterpartyId, + state: "unknown", + source: "no_profile", + score: null, + signals: [], + explanation: "No risk profile is available for this counterparty." + }; + } + + return { + counterpartyId, + state: toState(assessment.level), + source: "score", + score: assessment.score, + signals: assessment.signals, + explanation: assessment.explanation + }; +} + +/** The only view a non-maintainer receives: no score, thresholds or signals. */ +export function toPublicRiskSummary(report: CounterpartyRiskReport): PublicCounterpartyRisk { + return { counterpartyId: report.counterpartyId, state: report.state }; +} diff --git a/core/policy/freeze-window.ts b/core/policy/freeze-window.ts new file mode 100644 index 0000000..3091260 --- /dev/null +++ b/core/policy/freeze-window.ts @@ -0,0 +1,110 @@ +/** + * Operational freeze windows for risky maintenance actions. + * + * A window blocks the listed action scopes between `startsAt` and `endsAt` + * (epoch milliseconds, end exclusive). Actions outside the scope are unaffected. + * An emergency bypass is allowed only for the window it names, and every bypass + * or refused bypass is written to the audit trail. + */ +import { recordAudit } from "@/core/audit/audit"; +import { err, ok, type Result } from "@/core/result/result"; + +export const FREEZE_ACTIONS = [ + "issuance", + "transfer", + "redemption", + "bulk_operation", + "maintenance" +] as const; + +export type FreezeAction = (typeof FREEZE_ACTIONS)[number]; + +export interface FreezeWindow { + readonly id: string; + readonly actions: readonly FreezeAction[]; + readonly startsAt: number; + readonly endsAt: number; + readonly reason: string; +} + +export interface EmergencyBypass { + readonly windowId: string; + readonly approvedBy: string; + readonly justification: string; +} + +export interface FrozenDetail { + readonly windowId: string; + readonly endsAt: number; +} + +export type FreezeErrorCode = "action_frozen" | "invalid_freeze_window" | "invalid_bypass"; + +const MAX_TEXT_LENGTH = 256; + +function isText(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0 && value.length <= MAX_TEXT_LENGTH; +} + +export function validateFreezeWindow(window: FreezeWindow): Result { + const valid = + isText(window.id) && + isText(window.reason) && + Number.isFinite(window.startsAt) && + Number.isFinite(window.endsAt) && + window.startsAt < window.endsAt && + window.actions.length > 0 && + window.actions.every((action) => (FREEZE_ACTIONS as readonly string[]).includes(action)); + + return valid ? ok(window) : err("invalid_freeze_window"); +} + +export function isFreezeActive(window: FreezeWindow, now: number): boolean { + return window.startsAt <= now && now < window.endsAt; +} + +/** + * Decides whether `action` may run at `now`. A frozen action is rejected with the + * window that blocks it. A valid emergency bypass for that exact window lets it run. + */ +export function enforceFreeze(input: { + readonly windows: readonly FreezeWindow[]; + readonly action: FreezeAction; + readonly now: number; + readonly bypass?: EmergencyBypass; +}): Result { + const blocking = input.windows.find( + (window) => window.actions.includes(input.action) && isFreezeActive(window, input.now) + ); + if (!blocking) return ok(true); + + if (!input.bypass) { + return err("action_frozen", { windowId: blocking.id, endsAt: blocking.endsAt }); + } + + const { bypass } = input; + const valid = + bypass.windowId === blocking.id && isText(bypass.approvedBy) && isText(bypass.justification); + + if (!valid) { + recordAudit({ + action: "freeze.bypass_denied", + actor: { kind: "user", id: isText(bypass.approvedBy) ? bypass.approvedBy : "unknown" }, + target: { kind: "freeze_window", id: blocking.id }, + outcome: "denied", + reason: "invalid_bypass", + after: { action: input.action } + }); + return err("invalid_bypass"); + } + + recordAudit({ + action: "freeze.bypassed", + actor: { kind: "user", id: bypass.approvedBy }, + target: { kind: "freeze_window", id: blocking.id }, + outcome: "allowed", + reason: "emergency_bypass", + after: { action: input.action, justification: bypass.justification } + }); + return ok(true); +}