diff --git a/core/contract/__tests__/contract-map.test.ts b/core/contract/__tests__/contract-map.test.ts new file mode 100644 index 0000000..258d4e9 --- /dev/null +++ b/core/contract/__tests__/contract-map.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { + CROSS_SERVICE_CONTRACT_MAP, + getContractDefinition, + getContractsByConsumer, + getContractsByProducer, + listAllContracts, + validateEventContractPayload, + verifyCrossServiceContractMap +} from "@/core/contract/contract-map"; + +describe("Cross-Service Contract Map (#219)", () => { + it("verifies the integrity and completeness of all registered contracts", () => { + const verification = verifyCrossServiceContractMap(); + expect(verification.ok).toBe(true); + if (!verification.ok) return; + + expect(verification.value.valid).toBe(true); + expect(verification.value.totalContracts).toBeGreaterThanOrEqual(10); + expect(verification.value.producersCount).toBeGreaterThanOrEqual(5); + expect(verification.value.consumersCount).toBeGreaterThanOrEqual(5); + }); + + it("retrieves contract definition by eventId", () => { + const telemetryContract = getContractDefinition("telemetry.event"); + expect(telemetryContract).toBeDefined(); + expect(telemetryContract?.producer).toBe("core/telemetry"); + expect(telemetryContract?.versionRules.currentVersion).toBe("1.0"); + expect(telemetryContract?.failurePolicy.allowedFailureBehavior).toBe("fail_silent"); + + const nonExistent = getContractDefinition("non.existent.event"); + expect(nonExistent).toBeUndefined(); + }); + + it("filters contracts by producer and consumer", () => { + const telemetryContracts = getContractsByProducer("core/telemetry"); + expect(telemetryContracts.some((c) => c.eventId === "telemetry.event")).toBe(true); + + const auditConsumers = getContractsByConsumer("core/audit"); + expect(auditConsumers.length).toBeGreaterThanOrEqual(2); + expect(auditConsumers.some((c) => c.eventId === "telemetry.event" || c.eventId === "export.envelope")).toBe(true); + }); + + describe("Payload Validation against Contract Schemas", () => { + it("validates compliant telemetry.event payload", () => { + const validPayload = { + op: "horizon.request", + actorType: "client", + result: "success", + latencyMs: 125, + correlationId: "corr-12345", + timestamp: "2026-10-19T10:00:00.000Z" + }; + + const result = validateEventContractPayload("telemetry.event", validPayload); + expect(result.ok).toBe(true); + }); + + it("rejects telemetry.event payload with missing required field", () => { + const invalidPayload = { + op: "horizon.request", + result: "success", + // missing actorType, latencyMs, correlationId, timestamp + }; + + const result = validateEventContractPayload("telemetry.event", invalidPayload); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("missing_required_field"); + }); + + it("rejects telemetry.event payload with wrong field type", () => { + const wrongTypePayload = { + op: "horizon.request", + actorType: "client", + result: "success", + latencyMs: "125ms", // should be number + correlationId: "corr-12345", + timestamp: "2026-10-19T10:00:00.000Z" + }; + + const result = validateEventContractPayload("telemetry.event", wrongTypePayload); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("wrong_type"); + }); + + it("validates compliant worker.job payload with retry and idempotency policies", () => { + const jobContract = getContractDefinition("worker.job"); + expect(jobContract?.idempotency.required).toBe(true); + expect(jobContract?.failurePolicy.allowedFailureBehavior).toBe("retry_and_dead_letter"); + expect(jobContract?.failurePolicy.deadLetterQueue).toBe(true); + + const validJob = { + id: "job-991", + operation: "maintenance.cleanup", + status: "queued", + attempts: 0, + enqueuedAt: "2026-10-19T10:00:00.000Z", + correlationId: "corr-991" + }; + + const result = validateEventContractPayload("worker.job", validJob); + expect(result.ok).toBe(true); + }); + + it("validates compliant export.envelope payload", () => { + const validEnvelope = { + schemaVersion: "1.0", + scope: "own", + generatedAt: "2026-10-19T10:00:00.000Z", + expiresAt: "2026-10-20T10:00:00.000Z", + generator: "utix-export@1", + correlationId: "corr-exp-1", + recordCount: 1, + page: 1, + totalRecords: 1, + records: [{ op: "wallet.detect", result: "success" }] + }; + + const result = validateEventContractPayload("export.envelope", validEnvelope); + expect(result.ok).toBe(true); + }); + + it("rejects unknown event contracts with contract_drift", () => { + const result = validateEventContractPayload("unknown.event", {}); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("contract_drift"); + }); + + it("handles version negotiation and rejects future unsupported versions", () => { + const validPayload = { + op: "horizon.request", + actorType: "client", + result: "success", + latencyMs: 125, + correlationId: "corr-12345", + timestamp: "2026-10-19T10:00:00.000Z" + }; + + const result = validateEventContractPayload("telemetry.event", validPayload, { + requestedVersion: "99.0" // future version + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe("schema_future"); + }); + }); + + describe("Failure Behavior and Policy Consistency", () => { + it("ensures every contract declares a defined failure behavior and default error code", () => { + for (const contract of listAllContracts()) { + expect(["fail_silent", "retry_and_dead_letter", "revert_state", "safe_fallback", "reject_with_code"]).toContain( + contract.failurePolicy.allowedFailureBehavior + ); + expect(contract.failurePolicy.defaultErrorCode).toBeTruthy(); + expect(["halt", "skip", "quarantine"]).toContain(contract.failurePolicy.poisonHandling); + } + }); + + it("ensures high-risk write operations enforce idempotency", () => { + const writeEvents = ["worker.job", "export.envelope", "idempotency.record", "ownership.transfer", "webhook.delivery"]; + for (const eventId of writeEvents) { + const contract = getContractDefinition(eventId); + expect(contract?.idempotency.required).toBe(true); + expect(contract?.idempotency.keyField).toBeDefined(); + } + }); + }); +}); diff --git a/core/contract/contract-map.ts b/core/contract/contract-map.ts new file mode 100644 index 0000000..a0eed38 --- /dev/null +++ b/core/contract/contract-map.ts @@ -0,0 +1,529 @@ +/** + * Cross-Service Contract Map for Domain Events and Integration Boundaries (#219). + * + * Provides a formal registry and runtime validation mechanism for every domain + * event, integration boundary, and cross-service payload in Utix. + * + * Each contract locks: + * 1. Producer service & consumer services + * 2. Required fields, types, and nested value paths + * 3. Version negotiation and upcast policies + * 4. Allowed failure behavior, poison-event policies, and DLQ handling + * 5. Idempotency guarantees and replay protection requirements + */ + +import { err, ok, type Result } from "@/core/result/result"; +import { + type ContractErrorCode, + type ContractSchema, + type SchemaVersionErrorCode, + negotiateSchemaVersion, + validateContract +} from "@/core/contract/contract"; + +export type AllowedFailureBehavior = + | "fail_silent" + | "retry_and_dead_letter" + | "revert_state" + | "safe_fallback" + | "reject_with_code"; + +export type PoisonEventPolicy = "halt" | "skip" | "quarantine"; + +export interface VersionRules { + readonly currentVersion: string; + readonly supportedVersions: readonly string[]; + readonly deprecatedVersions?: readonly string[]; + readonly upcastPolicy?: "automatic" | "manual" | "reject"; +} + +export interface FailurePolicy { + readonly allowedFailureBehavior: AllowedFailureBehavior; + readonly maxRetries?: number; + readonly deadLetterQueue?: boolean; + readonly poisonHandling: PoisonEventPolicy; + readonly defaultErrorCode: string; +} + +export interface IdempotencyPolicy { + readonly required: boolean; + readonly keyField?: string; + readonly ttlMs?: number; +} + +export interface EventContractDefinition { + readonly eventId: string; + readonly name: string; + readonly description: string; + readonly producer: string; + readonly consumers: readonly string[]; + readonly schema: ContractSchema; + readonly versionRules: VersionRules; + readonly failurePolicy: FailurePolicy; + readonly idempotency: IdempotencyPolicy; +} + +/** + * Single source of truth cross-service contract map. + */ +export const CROSS_SERVICE_CONTRACT_MAP: Readonly> = { + "telemetry.event": { + eventId: "telemetry.event", + name: "Telemetry Metric Record", + description: "Structured operational performance and telemetry record emitted by core/telemetry.", + producer: "core/telemetry", + consumers: ["core/audit", "core/health", "logging_subsystem"], + schema: { + version: "1.0", + fields: { + op: { type: "string", required: true }, + actorType: { type: "string", required: true }, + result: { type: "string", required: true }, + latencyMs: { type: "number", required: true }, + correlationId: { type: "string", required: true }, + timestamp: { type: "string", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "fail_silent", + poisonHandling: "skip", + defaultErrorCode: "telemetry_drop_unhandled" + }, + idempotency: { + required: false + } + }, + + "horizon.error": { + eventId: "horizon.error", + name: "Classified Horizon Error", + description: "Standardized Stellar Horizon transport and HTTP error classification payload.", + producer: "core/horizon", + consumers: ["features/*/hooks", "features/*/components", "core/ui"], + schema: { + version: "1.0", + fields: { + code: { type: "string", required: true }, + detail: { type: "object", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "manual" + }, + failurePolicy: { + allowedFailureBehavior: "safe_fallback", + poisonHandling: "quarantine", + defaultErrorCode: "request_failed" + }, + idempotency: { + required: false + } + }, + + "worker.job": { + eventId: "worker.job", + name: "Background Worker Job", + description: "Asynchronous task payload queued for background execution with retry and DLQ.", + producer: "features/*", + consumers: ["core/workers", "core/audit"], + schema: { + version: "1.0", + fields: { + id: { type: "string", required: true }, + operation: { type: "string", required: true }, + status: { type: "string", required: true }, + attempts: { type: "number", required: true }, + enqueuedAt: { type: "string", required: true }, + correlationId: { type: "string", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "retry_and_dead_letter", + maxRetries: 3, + deadLetterQueue: true, + poisonHandling: "quarantine", + defaultErrorCode: "worker_job_failed" + }, + idempotency: { + required: true, + keyField: "id", + ttlMs: 86_400_000 + } + }, + + "export.envelope": { + eventId: "export.envelope", + name: "Data Export Envelope", + description: "Privacy-audited, scoped, schema-versioned data export container.", + producer: "core/export", + consumers: ["browser_download", "core/audit"], + schema: { + version: "1.0", + fields: { + schemaVersion: { type: "string", required: true }, + scope: { type: "string", required: true }, + generatedAt: { type: "string", required: true }, + expiresAt: { type: "string", required: true }, + generator: { type: "string", required: true }, + correlationId: { type: "string", required: true }, + recordCount: { type: "number", required: true }, + page: { type: "number", required: true }, + totalRecords: { type: "number", required: true }, + records: { type: "array", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "reject" + }, + failurePolicy: { + allowedFailureBehavior: "reject_with_code", + poisonHandling: "halt", + defaultErrorCode: "export_denied" + }, + idempotency: { + required: true, + keyField: "correlationId", + ttlMs: 3_600_000 + } + }, + + "lifecycle.state": { + eventId: "lifecycle.state", + name: "Lifecycle State Transition", + description: "State machine transition payload for domain records (tickets, jobs, envelopes).", + producer: "core/lifecycle", + consumers: ["core/ui", "core/notifications", "core/reconciliation"], + schema: { + version: "1.0", + fields: { + id: { type: "string", required: true }, + kind: { type: "string", required: true }, + state: { type: "string", required: true }, + updatedAt: { type: "string", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "revert_state", + poisonHandling: "halt", + defaultErrorCode: "invalid_transition" + }, + idempotency: { + required: true, + keyField: "id", + ttlMs: 86_400_000 + } + }, + + "idempotency.record": { + eventId: "idempotency.record", + name: "Idempotency Record Replay", + description: "Persisted outcome and request hash for idempotent high-risk write operations.", + producer: "core/idempotency", + consumers: ["core/workers", "core/operations", "core/export"], + schema: { + version: "1.0", + fields: { + key: { type: "string", required: true }, + operation: { type: "string", required: true }, + requestHash: { type: "string", required: true }, + status: { type: "string", required: true }, + createdAt: { type: "string", required: true }, + expiresAt: { type: "string", required: true }, + replays: { type: "number", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "reject_with_code", + poisonHandling: "halt", + defaultErrorCode: "idempotency_key_conflict" + }, + idempotency: { + required: true, + keyField: "key", + ttlMs: 86_400_000 + } + }, + + "reconciliation.report": { + eventId: "reconciliation.report", + name: "Reconciliation Invariant Report", + description: "Dry-run report analyzing stored domain records against derived state.", + producer: "core/reconciliation", + consumers: ["core/health", "maintainer_dashboard"], + schema: { + version: "1.0", + fields: { + timestamp: { type: "string", required: true }, + dryRun: { type: "boolean", required: true }, + checkedCount: { type: "number", required: true }, + driftCount: { type: "number", required: true }, + drifts: { type: "array", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "safe_fallback", + poisonHandling: "quarantine", + defaultErrorCode: "reconciliation_failed" + }, + idempotency: { + required: false + } + }, + + "webhook.delivery": { + eventId: "webhook.delivery", + name: "Webhook Signed Delivery", + description: "Signed HMAC event delivery with replay window and timestamp verification.", + producer: "core/webhooks", + consumers: ["external_subscribers", "core/audit"], + schema: { + version: "1.0", + fields: { + id: { type: "string", required: true }, + timestamp: { type: "number", required: true }, + event: { type: "string", required: true }, + data: { type: "object", required: true }, + signature: { type: "object", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "reject" + }, + failurePolicy: { + allowedFailureBehavior: "retry_and_dead_letter", + maxRetries: 5, + deadLetterQueue: true, + poisonHandling: "quarantine", + defaultErrorCode: "webhook_delivery_failed" + }, + idempotency: { + required: true, + keyField: "id", + ttlMs: 300_000 + } + }, + + "replay.event": { + eventId: "replay.event", + name: "Event Log Replay Entry", + description: "Append-only versioned event log entry used to project read models deterministically.", + producer: "core/replay", + consumers: ["core/replay/projections", "core/reconciliation"], + schema: { + version: "1.0", + fields: { + id: { type: "string", required: true }, + sequence: { type: "number", required: true }, + type: { type: "string", required: true }, + version: { type: "number", required: true }, + occurredAt: { type: "string", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "safe_fallback", + poisonHandling: "halt", + defaultErrorCode: "replay_poison_event" + }, + idempotency: { + required: true, + keyField: "id" + } + }, + + "ownership.transfer": { + eventId: "ownership.transfer", + name: "Ownership Transfer Intent", + description: "Pending two-step ownership handover with expiry and self-transfer guard.", + producer: "core/ownership", + consumers: ["core/notifications", "core/audit", "core/lifecycle"], + schema: { + version: "1.0", + fields: { + id: { type: "string", required: true }, + resourceId: { type: "string", required: true }, + currentOwner: { type: "string", required: true }, + proposedOwner: { type: "string", required: true }, + status: { type: "string", required: true }, + expiresAt: { type: "number", required: true } + } + }, + versionRules: { + currentVersion: "1.0", + supportedVersions: ["1.0"], + deprecatedVersions: [], + upcastPolicy: "automatic" + }, + failurePolicy: { + allowedFailureBehavior: "reject_with_code", + poisonHandling: "halt", + defaultErrorCode: "ownership_transfer_invalid" + }, + idempotency: { + required: true, + keyField: "id", + ttlMs: 86_400_000 + } + } +}; + +/** + * Retrieves a contract definition by its unique eventId. + */ +export function getContractDefinition(eventId: string): EventContractDefinition | undefined { + return CROSS_SERVICE_CONTRACT_MAP[eventId]; +} + +/** + * Lists all registered event and integration boundary contracts. + */ +export function listAllContracts(): readonly EventContractDefinition[] { + return Object.values(CROSS_SERVICE_CONTRACT_MAP); +} + +/** + * Finds all contracts where the given service acts as producer. + */ +export function getContractsByProducer(producer: string): readonly EventContractDefinition[] { + return listAllContracts().filter( + (contract) => contract.producer === producer || (contract.producer === "features/*" && producer.startsWith("features/")) + ); +} + +/** + * Finds all contracts where the given service acts as consumer. + */ +export function getContractsByConsumer(consumer: string): readonly EventContractDefinition[] { + return listAllContracts().filter((contract) => + contract.consumers.some((c) => c === consumer || (c === "features/*/hooks" && consumer.startsWith("features/"))) + ); +} + +/** + * Validates an event payload against its locked contract and version rules. + */ +export function validateEventContractPayload( + eventId: string, + payload: unknown, + options?: { requestedVersion?: string } +): Result { + const contract = getContractDefinition(eventId); + if (!contract) { + return err("contract_drift"); + } + + // Negotiate version if requested or required + const requestedVersion = options?.requestedVersion ?? contract.versionRules.currentVersion; + const versionNegotiation = negotiateSchemaVersion({ + requested: requestedVersion, + current: contract.versionRules.currentVersion, + deprecated: contract.versionRules.deprecatedVersions + }); + + if (versionNegotiation.code !== undefined && versionNegotiation.code !== "schema_deprecated") { + return err(versionNegotiation.code); + } + + // Validate fields against schema + return validateContract(payload, contract.schema); +} + +/** + * Verifies consistency and completeness of the entire Cross-Service Contract Map. + * Used in automated test suites and CI verification gates. + */ +export function verifyCrossServiceContractMap(): Result< + { + totalContracts: number; + producersCount: number; + consumersCount: number; + valid: boolean; + }, + string +> { + const contracts = listAllContracts(); + if (contracts.length === 0) { + return err("No contracts registered in cross-service contract map"); + } + + const producers = new Set(); + const consumers = new Set(); + + for (const contract of contracts) { + if (!contract.eventId || !contract.name || !contract.producer) { + return err(`Invalid contract metadata for ${contract.eventId || "unnamed"}`); + } + + if (!contract.consumers || contract.consumers.length === 0) { + return err(`Contract ${contract.eventId} must declare at least one consumer`); + } + + if (!contract.schema || !contract.schema.fields || Object.keys(contract.schema.fields).length === 0) { + return err(`Contract ${contract.eventId} must declare non-empty schema fields`); + } + + if (!contract.versionRules || !contract.versionRules.currentVersion) { + return err(`Contract ${contract.eventId} missing version rules`); + } + + if (!contract.failurePolicy || !contract.failurePolicy.allowedFailureBehavior) { + return err(`Contract ${contract.eventId} missing failure policy`); + } + + producers.add(contract.producer); + for (const c of contract.consumers) { + consumers.add(c); + } + } + + return ok({ + totalContracts: contracts.length, + producersCount: producers.size, + consumersCount: consumers.size, + valid: true + }); +} diff --git a/core/contract/index.ts b/core/contract/index.ts new file mode 100644 index 0000000..d935e8f --- /dev/null +++ b/core/contract/index.ts @@ -0,0 +1,2 @@ +export * from "./contract"; +export * from "./contract-map"; diff --git a/core/feature-flags/index.ts b/core/feature-flags/index.ts new file mode 100644 index 0000000..81c5bba --- /dev/null +++ b/core/feature-flags/index.ts @@ -0,0 +1,2 @@ +export * from "./flags"; +export * from "./rollout-guard"; diff --git a/core/feature-flags/rollout-guard.ts b/core/feature-flags/rollout-guard.ts new file mode 100644 index 0000000..00aa88d --- /dev/null +++ b/core/feature-flags/rollout-guard.ts @@ -0,0 +1,285 @@ +/** + * Feature-Flag Rollout Guard with Automatic Rollback Verification (#223). + * + * Provides safe scoping, active health monitoring, automated kill-switch + * tripping, and post-rollback state verification for high-risk protocol changes + * and experimental feature slices. + * + * Guarantees: + * 1. Risky features are strictly scoped by percentage, network, and risk tier. + * 2. Anomaly/error thresholds automatically trigger instantaneous rollback. + * 3. Automatic rollback verification runs immediately to ensure no orphaned, + * inconsistent, or corrupt state is left behind. + * 4. Actionable audit trail and telemetry events emitted for every guard action. + */ + +import { recordAudit } from "@/core/audit/audit"; +import { + configureRollout, + isFeatureEnabled, + triggerEmergencyDisable, + type FeatureFlag, + type RolloutConfig, + type RolloutContext +} from "@/core/feature-flags/flags"; +import { emitTelemetry, newCorrelationId } from "@/core/telemetry/telemetry"; + +export type RiskTier = "low" | "medium" | "high" | "critical"; + +export interface RolloutGuardScope { + readonly flag: FeatureFlag; + readonly riskTier: RiskTier; + readonly percentage: number; + readonly allowedNetworks?: readonly ("testnet" | "mainnet")[]; + readonly allowlist?: readonly string[]; + readonly blocklist?: readonly string[]; + readonly errorThresholdPercent?: number; // default: 20% + readonly maxConsecutiveFailures?: number; // default: 3 for high/critical, 5 for medium + readonly minSamples?: number; // default: 5 +} + +export interface StateConsistencyCheck { + readonly name: string; + readonly description: string; + readonly check: (state: T) => { valid: boolean; violation?: string }; + readonly remediate?: (state: T) => { remediated: boolean; details?: string }; +} + +export interface RolloutGuardOptions { + readonly stateProvider?: () => T; + readonly consistencyChecks?: readonly StateConsistencyCheck[]; + readonly onRollback?: (report: RollbackVerificationReport) => void; + readonly now?: () => Date; +} + +export interface RollbackVerificationReport { + readonly guardId: string; + readonly flag: FeatureFlag; + readonly rollbackTriggered: boolean; + readonly triggerReason: string; + readonly triggeredBy: string; + readonly triggeredAt: string; + readonly postRollbackFlagDisabled: boolean; + readonly stateConsistent: boolean; + readonly violations: readonly string[]; + readonly remediationsApplied: readonly string[]; + readonly correlationId: string; +} + +export interface GuardMetrics { + readonly successes: number; + readonly failures: number; + readonly consecutiveFailures: number; + readonly total: number; + readonly errorRatePercent: number; +} + +export class RolloutGuard { + public readonly id: string; + public readonly scope: RolloutGuardScope; + private readonly options: RolloutGuardOptions; + private successes = 0; + private failures = 0; + private consecutiveFailures = 0; + private tripped = false; + private lastReport: RollbackVerificationReport | null = null; + + constructor(scope: RolloutGuardScope, options?: RolloutGuardOptions) { + this.id = `guard-${scope.flag}-${Date.now()}`; + this.scope = { + errorThresholdPercent: 20, + minSamples: 5, + maxConsecutiveFailures: scope.riskTier === "critical" || scope.riskTier === "high" ? 3 : 5, + ...scope + }; + this.options = options ?? {}; + + // Apply baseline rollout configuration + const rolloutCfg: RolloutConfig = { + percentage: this.scope.percentage, + allowedNetworks: this.scope.allowedNetworks, + allowlist: this.scope.allowlist, + blocklist: this.scope.blocklist, + errorThresholdPercent: this.scope.errorThresholdPercent, + minErrorSamples: this.scope.minSamples + }; + configureRollout(this.scope.flag, rolloutCfg); + } + + /** + * Evaluates if the guarded feature flag is active for the context. + */ + public isEnabled(context?: RolloutContext): boolean { + if (this.tripped) return false; + return isFeatureEnabled(this.scope.flag, context); + } + + /** + * Records a successful operation under this guarded rollout. + */ + public recordSuccess(): void { + if (this.tripped) return; + this.successes += 1; + this.consecutiveFailures = 0; + } + + /** + * Records an operational failure. If failure thresholds or consecutive + * failure limits are breached, automatically triggers rollback & verification. + */ + public recordFailure(reason = "Operational failure"): RollbackVerificationReport | null { + if (this.tripped) return this.lastReport; + + this.failures += 1; + this.consecutiveFailures += 1; + + const total = this.successes + this.failures; + const errorRate = (this.failures / total) * 100; + + const breachedConsecutive = this.consecutiveFailures >= (this.scope.maxConsecutiveFailures ?? 3); + const breachedRate = total >= (this.scope.minSamples ?? 5) && errorRate >= (this.scope.errorThresholdPercent ?? 20); + + if (breachedConsecutive || breachedRate) { + const triggerReason = breachedConsecutive + ? `Consecutive failures threshold (${this.consecutiveFailures}) breached: ${reason}` + : `Error rate threshold (${errorRate.toFixed(1)}%) breached: ${reason}`; + + return this.executeRollback(triggerReason, "automated_rollout_guard"); + } + + return null; + } + + /** + * Manually triggers immediate rollback and post-rollback verification. + */ + public manualRollback(reason: string, actor = "maintainer"): RollbackVerificationReport { + return this.executeRollback(reason, actor); + } + + /** + * Core rollback execution and state verification engine. + */ + private executeRollback(reason: string, actor: string): RollbackVerificationReport { + this.tripped = true; + const now = (this.options.now ? this.options.now() : new Date()).toISOString(); + const correlationId = newCorrelationId(); + + // 1. Immediately kill-switch the flag + triggerEmergencyDisable(this.scope.flag, { + reason, + actor, + now: new Date(now) + }); + + // 2. Run post-rollback state verification + const violations: string[] = []; + const remediations: string[] = []; + + const state = this.options.stateProvider ? this.options.stateProvider() : undefined; + if (state !== undefined && this.options.consistencyChecks) { + for (const check of this.options.consistencyChecks) { + const result = check.check(state); + if (!result.valid) { + violations.push(`${check.name}: ${result.violation ?? "Invariant violated"}`); + if (check.remediate) { + const remResult = check.remediate(state); + if (remResult.remediated) { + remediations.push(`${check.name}: ${remResult.details ?? "Remediated successfully"}`); + } + } + } + } + } + + // Flag must evaluate to false after rollback + const postRollbackDisabled = !isFeatureEnabled(this.scope.flag); + + const report: RollbackVerificationReport = { + guardId: this.id, + flag: this.scope.flag, + rollbackTriggered: true, + triggerReason: reason, + triggeredBy: actor, + triggeredAt: now, + postRollbackFlagDisabled: postRollbackDisabled, + stateConsistent: violations.length === remediations.length, + violations, + remediationsApplied: remediations, + correlationId + }; + + this.lastReport = report; + + // 3. Emit structured telemetry + emitTelemetry({ + op: "rollout_guard.rollback", + actorType: actor === "automated_rollout_guard" ? "system" : "maintainer", + result: report.stateConsistent ? "success" : "failure", + latencyMs: 1, + correlationId, + timestamp: now, + errorCode: report.stateConsistent ? undefined : "inconsistent_state_post_rollback" + }); + + // 4. Record audit trail + recordAudit({ + action: "emergency_disable", + actor: { kind: actor === "automated_rollout_guard" ? "system" : "maintainer", id: actor }, + resourceType: "feature_flag", + resourceId: this.scope.flag, + details: { + guardId: this.id, + reason, + violationsCount: violations.length, + remediationsCount: remediations.length, + stateConsistent: report.stateConsistent + } + }); + + if (this.options.onRollback) { + this.options.onRollback(report); + } + + return report; + } + + /** + * Returns current health metrics for this guarded rollout. + */ + public getMetrics(): GuardMetrics { + const total = this.successes + this.failures; + const errorRate = total > 0 ? (this.failures / total) * 100 : 0; + return { + successes: this.successes, + failures: this.failures, + consecutiveFailures: this.consecutiveFailures, + total, + errorRatePercent: errorRate + }; + } + + /** + * Returns whether this guard has been tripped. + */ + public isTripped(): boolean { + return this.tripped; + } + + /** + * Retrieves the most recent rollback verification report, if any. + */ + public getLastRollbackReport(): RollbackVerificationReport | null { + return this.lastReport; + } +} + +/** + * Factory helper for instantiating a RolloutGuard. + */ +export function createRolloutGuard( + scope: RolloutGuardScope, + options?: RolloutGuardOptions +): RolloutGuard { + return new RolloutGuard(scope, options); +} diff --git a/core/migration/index.ts b/core/migration/index.ts index 18cc0bb..9acffd6 100644 --- a/core/migration/index.ts +++ b/core/migration/index.ts @@ -2,3 +2,4 @@ export * from './types'; export * from './postChecks'; export * from './runner'; export * from './backfill'; +export * from './storage-schema'; diff --git a/core/migration/storage-schema.ts b/core/migration/storage-schema.ts new file mode 100644 index 0000000..0314e9c --- /dev/null +++ b/core/migration/storage-schema.ts @@ -0,0 +1,415 @@ +/** + * Storage Schema Compatibility Checks & Migration Framework (#240). + * + * Implements compatibility checks and safe bidirectional migration for the + * next storage schema version (v1 -> v2) in Utix. + * + * Full coverage across all 4 required execution paths: + * 1. Normal path: valid legacy v1 records upgraded to v2 with integrity checksums. + * 2. Failure path: corrupt or invalid records rejected with atomic rollback. + * 3. Retry path: idempotent resumption and re-execution without duplicate keys. + * 4. Authorization path: gates execution behind maintainer authorization. + */ + +import { recordAudit } from "@/core/audit/audit"; +import { sha256Hex } from "@/core/ledger/sha256"; +import { type MigrationDefinition } from "@/core/migration/types"; +import { err, ok, type Result } from "@/core/result/result"; +import { emitTelemetry, newCorrelationId } from "@/core/telemetry/telemetry"; + +export const STORAGE_SCHEMA_CURRENT_VERSION = 1; +export const STORAGE_SCHEMA_NEXT_VERSION = 2; + +export interface StorageRecordV1 { + readonly id: string; + readonly kind: string; + readonly state: string; + readonly correlationId: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly payload?: Record; +} + +export interface StorageRecordV2 { + readonly id: string; + readonly schemaVersion: 2; + readonly kind: string; + readonly state: string; + readonly correlationId: string; + readonly partition: string; + readonly actor: { kind: string; id: string }; + readonly checksum: string; + readonly createdAt: string; + readonly updatedAt: string; + readonly payload?: Record; + readonly migrationMeta?: { + readonly migratedFrom: number; + readonly migratedAt: string; + }; +} + +export type StorageMigrationErrorCode = + | "permission_denied" + | "schema_version_unsupported" + | "migration_validation_failed" + | "corrupt_record_detected" + | "rollback_failed"; + +export interface StorageCompatibilityCheckResult { + readonly compatible: boolean; + readonly totalChecked: number; + readonly v1Count: number; + readonly v2Count: number; + readonly invalidRecords: readonly string[]; + readonly reasons: readonly string[]; +} + +export interface StorageMigrationReport { + readonly migrationId: string; + readonly fromVersion: number; + readonly toVersion: number; + readonly totalRecords: number; + readonly affectedCount: number; + readonly unchangedCount: number; + readonly passed: boolean; + readonly dryRun: boolean; + readonly correlationId: string; + readonly durationMs: number; +} + +/** + * Computes deterministic SHA-256 integrity checksum for a V2 storage record. + */ +export function computeStorageRecordChecksum(record: { + id: string; + kind: string; + state: string; + correlationId: string; + createdAt: string; +}): string { + const payload = `${record.id}:${record.kind}:${record.state}:${record.correlationId}:${record.createdAt}`; + return sha256Hex(payload).slice(0, 32); +} + +/** + * Checks compatibility of an existing dataset with the next storage schema version. + */ +export function checkStorageCompatibility( + records: readonly unknown[] +): StorageCompatibilityCheckResult { + let v1Count = 0; + let v2Count = 0; + const invalidRecords: string[] = []; + const reasons: string[] = []; + + for (const raw of records) { + if (typeof raw !== "object" || raw === null) { + invalidRecords.push("unknown_null"); + reasons.push("Null or non-object record encountered in storage"); + continue; + } + + const r = raw as Record; + const id = typeof r.id === "string" ? r.id : undefined; + + if (!id) { + invalidRecords.push("missing_id"); + reasons.push("Record missing required 'id' attribute"); + continue; + } + + if (r.schemaVersion === 2) { + // Validate V2 invariants + if (typeof r.checksum !== "string" || !r.checksum) { + invalidRecords.push(id); + reasons.push(`Record ${id} declared schemaVersion: 2 but missing integrity checksum`); + } else { + v2Count += 1; + } + } else { + // Validate V1 candidate + if (typeof r.kind !== "string" || typeof r.state !== "string" || typeof r.createdAt !== "string") { + invalidRecords.push(id); + reasons.push(`Record ${id} does not satisfy basic V1 storage requirements`); + } else { + v1Count += 1; + } + } + } + + return { + compatible: invalidRecords.length === 0, + totalChecked: records.length, + v1Count, + v2Count, + invalidRecords, + reasons + }; +} + +/** + * Upgrades a single V1 record to V2 schema. + */ +export function upgradeV1ToV2( + record: StorageRecordV1, + defaultActor = { kind: "system", id: "migration_runner" } +): StorageRecordV2 { + const checksum = computeStorageRecordChecksum(record); + return { + id: record.id, + schemaVersion: 2, + kind: record.kind, + state: record.state, + correlationId: record.correlationId || `corr-${record.id}`, + partition: "default", + actor: defaultActor, + checksum, + createdAt: record.createdAt, + updatedAt: record.updatedAt || new Date().toISOString(), + payload: record.payload, + migrationMeta: { + migratedFrom: 1, + migratedAt: new Date().toISOString() + } + }; +} + +/** + * Downgrades a V2 record back to V1 during rollback. + */ +export function downgradeV2ToV1(record: StorageRecordV2): StorageRecordV1 { + return { + id: record.id, + kind: record.kind, + state: record.state, + correlationId: record.correlationId, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + payload: record.payload + }; +} + +/** + * Formal migration definition adhering to the core migration safety framework. + */ +export const StorageV1ToV2Migration: MigrationDefinition = { + id: "storage-v1-to-v2", + name: "Storage Schema Upgrade to V2", + description: "Upgrades storage records with schemaVersion: 2, partition keys, actor scoping, and checksums.", + fromVersion: STORAGE_SCHEMA_CURRENT_VERSION, + toVersion: STORAGE_SCHEMA_NEXT_VERSION, + riskLevel: "medium", + rollbackNotes: "Downgrades V2 records back to V1 format by stripping metadata and checksum fields.", + forwardFixNotes: "Ensure all records have valid id, kind, state, and ISO createdAt fields.", + + dryRun: (records) => { + let affectedCount = 0; + let unchangedCount = 0; + + for (const r of records) { + if ((r as { schemaVersion?: number }).schemaVersion === 2) { + unchangedCount += 1; + } else { + affectedCount += 1; + } + } + + return { + migrationId: "storage-v1-to-v2", + targetVersion: STORAGE_SCHEMA_NEXT_VERSION, + totalRecords: records.length, + affectedCount, + unchangedCount, + sampleDiffs: [], + riskLevel: "medium", + rollbackNotes: "Supported via downgradeV2ToV1", + forwardFixNotes: "Validate legacy records with checkStorageCompatibility", + estimatedDurationMs: affectedCount * 2 + }; + }, + + apply: (records) => { + let affectedCount = 0; + const transformed: StorageRecordV2[] = []; + + for (const r of records) { + // Idempotency: If record is already V2, preserve it as-is + if ((r as { schemaVersion?: number }).schemaVersion === 2) { + transformed.push(r as StorageRecordV2); + } else { + transformed.push(upgradeV1ToV2(r as StorageRecordV1)); + affectedCount += 1; + } + } + + return { transformed, affectedCount }; + }, + + rollback: (records) => { + let affectedCount = 0; + const transformed: (StorageRecordV1 | StorageRecordV2)[] = []; + + for (const r of records) { + if ((r as { schemaVersion?: number }).schemaVersion === 2) { + transformed.push(downgradeV2ToV1(r as StorageRecordV2)); + affectedCount += 1; + } else { + transformed.push(r); + } + } + + return { transformed, affectedCount }; + }, + + postChecks: [ + { + name: "all_records_schema_version_2", + description: "Asserts that every record has schemaVersion === 2", + validate: (records) => { + const violations = records.filter((r) => r.schemaVersion !== 2).map((r) => r.id); + return { + checkName: "all_records_schema_version_2", + passed: violations.length === 0, + violatingRecordIds: violations, + message: violations.length === 0 ? "All records are schemaVersion 2" : `${violations.length} records failed version check` + }; + } + }, + { + name: "valid_integrity_checksums", + description: "Asserts that every record has a non-empty checksum", + validate: (records) => { + const violations = records.filter((r) => !r.checksum || r.checksum.length < 16).map((r) => r.id); + return { + checkName: "valid_integrity_checksums", + passed: violations.length === 0, + violatingRecordIds: violations, + message: violations.length === 0 ? "All checksums verified" : `${violations.length} records have missing/short checksums` + }; + } + } + ] +}; + +/** + * Top-level executor executing the complete migration lifecycle with + * authorization checks, failure handling, retry safety, and audit emission. + */ +export async function executeStorageSchemaMigration( + records: readonly (StorageRecordV1 | StorageRecordV2)[], + options: { + actor: { kind: string; id: string }; + dryRun?: boolean; + autoRollbackOnFailure?: boolean; + simulateFailure?: boolean; + } +): Promise> { + const startTime = Date.now(); + const correlationId = newCorrelationId(); + + // 1. Authorization Path Verification + if (options.actor.kind !== "maintainer" && options.actor.kind !== "admin") { + recordAudit({ + action: "storage_migration_denied", + actor: options.actor, + resourceType: "storage_schema", + resourceId: "v1_to_v2", + details: { reason: "Actor lacks maintainer or admin privilege" } + }); + return err("permission_denied", { violations: ["Unauthorized actor attempt"] }); + } + + // 2. Compatibility & Pre-flight Inspection + const compatibility = checkStorageCompatibility(records); + if (!compatibility.compatible) { + recordAudit({ + action: "storage_migration_failed", + actor: options.actor, + resourceType: "storage_schema", + resourceId: "v1_to_v2", + details: { reasons: compatibility.reasons } + }); + return err("corrupt_record_detected", { violations: compatibility.reasons }); + } + + // 3. Dry-Run Path + if (options.dryRun) { + const preview = StorageV1ToV2Migration.dryRun(records); + return ok({ + migrationId: StorageV1ToV2Migration.id, + fromVersion: STORAGE_SCHEMA_CURRENT_VERSION, + toVersion: STORAGE_SCHEMA_NEXT_VERSION, + totalRecords: records.length, + affectedCount: preview.affectedCount, + unchangedCount: preview.unchangedCount, + passed: true, + dryRun: true, + correlationId, + durationMs: Date.now() - startTime + }); + } + + // 4. Application & Execution + const { transformed, affectedCount } = StorageV1ToV2Migration.apply(records); + + // Failure Injection Seam (to test failure & rollback path deterministically) + if (options.simulateFailure) { + if (options.autoRollbackOnFailure && StorageV1ToV2Migration.rollback) { + StorageV1ToV2Migration.rollback(transformed); + } + return err("migration_validation_failed", { + violations: ["Simulated post-migration check failure"] + }); + } + + // 5. Post-Migration Invariant Checks + for (const check of StorageV1ToV2Migration.postChecks) { + const result = await check.validate(transformed); + if (!result.passed) { + if (options.autoRollbackOnFailure && StorageV1ToV2Migration.rollback) { + StorageV1ToV2Migration.rollback(transformed); + } + return err("migration_validation_failed", { + violations: result.violatingRecordIds ?? [result.message ?? "Post-migration invariant failed"] + }); + } + } + + const durationMs = Date.now() - startTime; + + // 6. Audit & Telemetry + recordAudit({ + action: "storage_migration_applied", + actor: options.actor, + resourceType: "storage_schema", + resourceId: "v1_to_v2", + details: { + totalRecords: records.length, + affectedCount, + durationMs, + correlationId + } + }); + + emitTelemetry({ + op: "storage.migrate_v2", + actorType: "maintainer", + result: "success", + latencyMs: durationMs, + correlationId, + timestamp: new Date().toISOString() + }); + + return ok({ + migrationId: StorageV1ToV2Migration.id, + fromVersion: STORAGE_SCHEMA_CURRENT_VERSION, + toVersion: STORAGE_SCHEMA_NEXT_VERSION, + totalRecords: records.length, + affectedCount, + unchangedCount: records.length - affectedCount, + passed: true, + dryRun: false, + correlationId, + durationMs + }); +} diff --git a/core/security/abuse-detection.ts b/core/security/abuse-detection.ts new file mode 100644 index 0000000..ac03f03 --- /dev/null +++ b/core/security/abuse-detection.ts @@ -0,0 +1,348 @@ +/** + * Risk-Scored Abuse Detection for Suspicious High-Impact Operations (#226). + * + * Implements explainable risk scoring and progressive step-up friction for + * high-impact, potentially abusive, or destructive operations on Stellar accounts, + * assets, Soroban contracts, and Utix export services. + * + * Key guarantees: + * 1. Zero friction for legitimate low-risk operations (address checks, balance queries). + * 2. Transparent, explainable risk signals explaining precisely why a score was assigned. + * 3. Step-up friction (confirmation challenge, secondary approval) before hard blocking. + * 4. Auditable decisions with maintainer override support. + */ + +import { recordAudit } from "@/core/audit/audit"; +import { emitTelemetry, newCorrelationId } from "@/core/telemetry/telemetry"; + +export type AbuseAction = "allow" | "step_up" | "review" | "block"; + +export type StepUpRequirement = "none" | "confirm_dialog" | "maintainer_approval" | "rate_limit_backoff"; + +export interface ExplainableRiskSignal { + readonly id: string; + readonly name: string; + readonly points: number; + readonly reason: string; + readonly evidence?: Record; +} + +export interface AbuseEvaluationContext { + readonly operation: string; + readonly actor: { kind: string; id: string }; + readonly network?: "testnet" | "mainnet"; + readonly recentOperationCount?: number; + readonly payload?: Record; + readonly clientSessionId?: string; +} + +export interface AbuseAssessment { + readonly evaluationId: string; + readonly operation: string; + readonly score: number; // 0 to 100 + readonly action: AbuseAction; + readonly signals: readonly ExplainableRiskSignal[]; + readonly requiredStepUp: StepUpRequirement; + readonly timestamp: string; + readonly overridden?: { + readonly originalAction: AbuseAction; + readonly maintainerId: string; + readonly reason: string; + readonly at: string; + }; +} + +// In-memory audit & evaluation cache for review and overrides +const assessmentStore = new Map(); + +// Known low-risk operations that are always allowed with 0 risk score +const ZERO_RISK_OPERATIONS = new Set([ + "address.validate", + "balance.view", + "trustline.check", + "transaction.lookup", + "ledger.lookup", + "strkey.inspect", + "payment_qr.generate" +]); + +/** + * Evaluates an incoming operation for abuse signals and computes an explainable risk score. + */ +export function evaluateAbuseRisk(context: AbuseEvaluationContext): AbuseAssessment { + const evaluationId = `abuse-${newCorrelationId()}`; + const now = new Date().toISOString(); + const signals: ExplainableRiskSignal[] = []; + + // 1. Fast-path: Low-risk routine read operations have 0 risk score + if (ZERO_RISK_OPERATIONS.has(context.operation)) { + const assessment: AbuseAssessment = { + evaluationId, + operation: context.operation, + score: 0, + action: "allow", + signals: [], + requiredStepUp: "none", + timestamp: now + }; + assessmentStore.set(evaluationId, assessment); + return assessment; + } + + // 2. High-Impact Operation Baseline Signals + if (context.operation === "account.merge") { + signals.push({ + id: "destructive_account_merge", + name: "Destructive Account Merge", + points: 45, + reason: "Account merge permanently removes source account from ledger and transfers all XLM.", + evidence: { targetAccount: context.payload?.destination } + }); + + // Stellar protocol rule: destination cannot be source account + if (context.payload?.destination && context.payload.destination === context.actor.id) { + signals.push({ + id: "self_merge_forbidden", + name: "Forbidden Self-Merge Attempt", + points: 40, + reason: "Stellar protocol forbids merging an account into itself.", + evidence: { destination: context.payload.destination, actor: context.actor.id } + }); + } + + // Stellar protocol prerequisite: source account cannot have active subentries + if (context.payload?.subentryCount !== undefined && Number(context.payload.subentryCount) > 0) { + signals.push({ + id: "merge_subentry_liability", + name: "Active Subentries Preclude Merge", + points: 35, + reason: "Stellar Account Merge requires 0 subentries; active trustlines or open offers must be cleared first.", + evidence: { subentryCount: context.payload.subentryCount } + }); + } + } + + if (context.operation === "account.set_options") { + const masterWeight = context.payload?.masterWeight; + if (masterWeight === 0) { + signals.push({ + id: "disabled_master_key", + name: "Master Key Weight Zeroed", + points: 45, + reason: "Zeroing master key permanently disables direct signing by account master key (locks account if multisig is absent).", + evidence: { masterWeight: 0 } + }); + } + + // Stellar threshold ordering recommendation: low <= med <= high + const low = typeof context.payload?.lowThreshold === "number" ? context.payload.lowThreshold : undefined; + const med = typeof context.payload?.medThreshold === "number" ? context.payload.medThreshold : undefined; + const high = typeof context.payload?.highThreshold === "number" ? context.payload.highThreshold : undefined; + + if (low !== undefined && med !== undefined && high !== undefined) { + if (low > med || med > high) { + signals.push({ + id: "inverted_threshold_ordering", + name: "Inverted Threshold Configuration", + points: 30, + reason: "Threshold violation: Stellar documentation specifies Low <= Medium <= High threshold ordering.", + evidence: { low, med, high } + }); + } + } + } + + if (context.operation === "faucet.fund") { + const recent = context.recentOperationCount ?? 0; + if (recent >= 5) { + signals.push({ + id: "faucet_burst_drain", + name: "Faucet Rapid Request Burst", + points: 50, + reason: `Exceeded allowable testnet funding frequency (${recent} requests in window).`, + evidence: { recentCount: recent } + }); + } else if (recent >= 2) { + signals.push({ + id: "faucet_repeat_request", + name: "Faucet Rapid Successive Funding", + points: 25, + reason: "Successive faucet requests detected in short duration.", + evidence: { recentCount: recent } + }); + } + } + + if (context.operation === "export.generate") { + if (context.payload?.scope === "maintainer") { + signals.push({ + id: "sensitive_bulk_export", + name: "Maintainer Scope Data Export", + points: 40, + reason: "Exporting maintainer-scoped audit and operational datasets requires elevated review.", + evidence: { scope: "maintainer" } + }); + } + const requestedRecords = Number(context.payload?.recordCount ?? 0); + if (requestedRecords > 10_000) { + signals.push({ + id: "massive_export_payload", + name: "Excessive Export Record Count", + points: 30, + reason: `Requested export volume (${requestedRecords}) exceeds normal analytical limits.`, + evidence: { requestedRecords } + }); + } + } + + if (context.operation === "ownership.transfer") { + signals.push({ + id: "resource_ownership_transfer", + name: "Resource Ownership Handover", + points: 35, + reason: "Irreversible handover of administrative resource control.", + evidence: { resourceId: context.payload?.resourceId } + }); + } + + if (context.operation === "xdr.inspect") { + const payloadSize = Number(context.payload?.byteLength ?? 0); + if (payloadSize > 256 * 1024) { + signals.push({ + id: "oversized_xdr_payload", + name: "Oversized XDR Payload", + points: 45, + reason: "XDR payload exceeds safe 256KB threshold (potential XDR bomb).", + evidence: { byteLength: payloadSize } + }); + } + } + + // 3. Frequency & Velocity Anomaly Signals + const recentOps = context.recentOperationCount ?? 0; + if (recentOps >= 20) { + signals.push({ + id: "excessive_burst_velocity", + name: "Abnormal Request Velocity", + points: 35, + reason: `Client performed ${recentOps} operations in immediate observation window.`, + evidence: { recentOps } + }); + } + + // 4. Compute Aggregate Score (capped at 100) + const totalScore = Math.min( + 100, + signals.reduce((sum, s) => sum + s.points, 0) + ); + + // 5. Determine Action and Step-Up Requirements + let action: AbuseAction = "allow"; + let requiredStepUp: StepUpRequirement = "none"; + + if (totalScore >= 90) { + action = "block"; + requiredStepUp = "rate_limit_backoff"; + } else if (totalScore >= 70) { + action = "review"; + requiredStepUp = "maintainer_approval"; + } else if (totalScore >= 40) { + action = "step_up"; + requiredStepUp = "confirm_dialog"; + } + + const assessment: AbuseAssessment = { + evaluationId, + operation: context.operation, + score: totalScore, + action, + signals, + requiredStepUp, + timestamp: now + }; + + assessmentStore.set(evaluationId, assessment); + + // Record audit trail for any non-allow decision + if (action !== "allow") { + recordAudit({ + action: "abuse_evaluation", + actor: context.actor, + resourceType: "operation", + resourceId: context.operation, + details: { + evaluationId, + score: totalScore, + action, + signals: signals.map((s) => ({ id: s.id, points: s.points, reason: s.reason })) + } + }); + + emitTelemetry({ + op: "security.abuse_detected", + actorType: context.actor.kind === "maintainer" ? "maintainer" : "user", + result: "success", + latencyMs: 1, + correlationId: evaluationId, + timestamp: now, + errorCode: action === "block" ? "abuse_blocked" : undefined + }); + } + + return assessment; +} + +/** + * Retrieves a stored abuse assessment by ID. + */ +export function getAbuseAssessment(evaluationId: string): AbuseAssessment | undefined { + return assessmentStore.get(evaluationId); +} + +/** + * Allows a maintainer to override an abuse assessment decision (e.g. approving false positive). + */ +export function overrideAbuseAssessment( + evaluationId: string, + newAction: AbuseAction, + maintainerActor: { kind: string; id: string }, + reason: string +): AbuseAssessment | null { + const existing = assessmentStore.get(evaluationId); + if (!existing) return null; + + const overridden: AbuseAssessment = { + ...existing, + action: newAction, + requiredStepUp: newAction === "allow" ? "none" : existing.requiredStepUp, + overridden: { + originalAction: existing.action, + maintainerId: maintainerActor.id, + reason, + at: new Date().toISOString() + } + }; + + assessmentStore.set(evaluationId, overridden); + + recordAudit({ + action: "abuse_override", + actor: maintainerActor, + resourceType: "abuse_assessment", + resourceId: evaluationId, + details: { + previousAction: existing.action, + newAction, + reason + } + }); + + return overridden; +} + +/** + * Test seam: resets assessment store. + */ +export function resetAbuseAssessmentStore(): void { + assessmentStore.clear(); +} diff --git a/core/security/index.ts b/core/security/index.ts new file mode 100644 index 0000000..e8be0ea --- /dev/null +++ b/core/security/index.ts @@ -0,0 +1,2 @@ +export * from "./threat-model"; +export * from "./abuse-detection"; diff --git a/docs/ABUSE_DETECTION.md b/docs/ABUSE_DETECTION.md new file mode 100644 index 0000000..ea58ce5 --- /dev/null +++ b/docs/ABUSE_DETECTION.md @@ -0,0 +1,92 @@ +# Risk-Scored Abuse Detection for Suspicious High-Impact Operations (#226) + +## Overview + +Utix is designed as a read-only toolkit, but users and developer workflows execute high-impact diagnostic analyses, testnet funding, account option previews, ownership transfers, and large-scale data exports. + +The **Abuse Detection Engine** (`core/security/abuse-detection.ts`) computes transparent, explainable risk scores for potentially abusive or high-impact operations. Instead of indiscriminately blocking users, it implements a **progressive friction model** (Step-Up Challenge -> Maintainer Review -> Block). + +--- + +## Decision Matrix & Risk Tiers + +| Score Range | Action | Required Step-Up Friction | Intended Use Case | +|---|---|---|---| +| **0 – 39** | `allow` | None (`none`) | Routine read operations (address checks, balance queries, transaction inspection). Zero friction. | +| **40 – 69** | `step_up` | Confirmation Challenge (`confirm_dialog`) | High-impact user actions (account merge analysis, master key disabling, large exports). | +| **70 – 89** | `review` | Maintainer Approval (`maintainer_approval`) | Bulk exports of maintainer datasets, rapid burst testnet faucet usage. | +| **90 – 100** | `block` | Backoff / Rate Limit (`rate_limit_backoff`) | Extreme request velocity, massive XDR bombs, abusive repeated actions. | + +--- + +## Explainable Risk Signals + +Every evaluation produces explicit, human-readable risk signals: + +```typescript +const assessment = evaluateAbuseRisk({ + operation: "account.set_options", + actor: { kind: "user", id: "G..." }, + payload: { masterWeight: 0 } +}); + +// Returns: +// { +// score: 45, +// action: "step_up", +// requiredStepUp: "confirm_dialog", +// signals: [ +// { +// id: "disabled_master_key", +// name: "Master Key Weight Zeroed", +// points: 45, +// reason: "Zeroing master key permanently disables direct signing by account master key." +// } +// ] +// } +``` + +--- + +## Key Risk Signals + +1. **`destructive_account_merge` (+45 pts)**: + - Account merge permanently destroys the source account and reallocates all native reserves. +2. **`disabled_master_key` (+45 pts)**: + - Setting `masterWeight = 0` locks the account out unless alternate signers are configured. +3. **`faucet_burst_drain` (+50 pts)**: + - More than 5 Friendbot funding requests in a short time window. +4. **`sensitive_bulk_export` (+40 pts)**: + - Exporting maintainer-scoped audit logs or telemetry records. +5. **`oversized_xdr_payload` (+45 pts)**: + - XDR payload exceeds 256KB, risking client browser memory exhaustion. +6. **`excessive_burst_velocity` (+35 pts)**: + - Over 20 operations within the observation window. + +--- + +## Operational Edge Cases & Failure Modes + +1. **Legitimate Power User High-Volume Export**: + - *Risk*: A developer legitimately exporting historical testnet transactions triggers `step_up`. + - *Behavior*: The user confirms the action via `confirm_dialog`. The operation is allowed and audited without blocking. +2. **False Positive Review Hold**: + - *Recovery*: Maintainers review the assessment via `getAbuseAssessment(id)` and apply `overrideAbuseAssessment(id, "allow", actor, "Verified partner workflow")`. +3. **Network Partition During Friendbot Faucet**: + - *Risk*: Retries look like rapid repeat requests. + - *Behavior*: Idempotency keys prevent duplicate credit attempts and classify repeated submissions as safe replays. + +--- + +## Rollback & Recovery Steps + +1. **Maintainer Overrides**: + Maintainers can clear or override any active abuse restriction: + ```typescript + overrideAbuseAssessment(evaluationId, "allow", maintainerActor, "Approved after identity verification"); + ``` +2. **Audit Verification**: + Inspect all abuse evaluations in the append-only audit trail: + ```bash + node scripts/verify-audit.mjs + ``` diff --git a/docs/EVENT_CONTRACT_MAP.md b/docs/EVENT_CONTRACT_MAP.md new file mode 100644 index 0000000..d71f653 --- /dev/null +++ b/docs/EVENT_CONTRACT_MAP.md @@ -0,0 +1,160 @@ +# Cross-Service Contract Map for Domain Events and Integration Boundaries (#219) + +## Overview + +Utix is a browser-centric Stellar development and diagnostics toolkit with strict read-only security guarantees. While Utix does not expose public external HTTP API mutations, its services, core kernel, feature slices, background workers, and external integration points exchange structured payloads across well-defined boundaries. + +The **Cross-Service Contract Map** (`core/contract/contract-map.ts`) establishes an authoritative, locked registry and verification system for all domain events and integration payloads. + +--- + +## Architecture & Integration Boundaries + +``` +[Feature Slices / External Network] + │ + ▼ (Input Validation / Classification) +┌────────────────────────────────────────────────────────┐ +│ Utix Core │ +│ │ +│ telemetry.event horizon.error worker.job │ +│ export.envelope lifecycle.state replay.event│ +│ idempotency.record ownership.transfer webhook.del │ +│ reconciliation.report │ +└────────────────────────────────────────────────────────┘ + │ + ▼ (Strict Contract Enforcement & Versioning) +[Audit Subsystem / Storage Adapters / Read Projections / UI] +``` + +--- + +## Contract Inventory + +| Event / Payload ID | Name | Producer | Consumers | Version | Failure Policy | Idempotency | +|---|---|---|---|---|---|---| +| `telemetry.event` | Telemetry Metric Record | `core/telemetry` | `core/audit`, `core/health`, logging | `1.0` | `fail_silent` (skip poison) | Optional | +| `horizon.error` | Classified Horizon Error | `core/horizon` | `features/*/hooks`, `core/ui` | `1.0` | `safe_fallback` (`request_failed`) | Optional | +| `worker.job` | Background Worker Job | `features/*` | `core/workers`, `core/audit` | `1.0` | `retry_and_dead_letter` (max 3, DLQ) | Required (`id`) | +| `export.envelope` | Data Export Envelope | `core/export` | browser download, `core/audit` | `1.0` | `reject_with_code` (`export_denied`) | Required (`correlationId`) | +| `lifecycle.state` | Lifecycle State Transition | `core/lifecycle` | `core/ui`, `core/notifications` | `1.0` | `revert_state` (`invalid_transition`) | Required (`id`) | +| `idempotency.record`| Idempotency Record Replay | `core/idempotency` | `core/workers`, `core/operations` | `1.0` | `reject_with_code` (`idempotency_key_conflict`) | Required (`key`) | +| `reconciliation.report`| Reconciliation Invariant Report | `core/reconciliation` | `core/health`, maintainer dashboard | `1.0` | `safe_fallback` (read-only) | Optional | +| `webhook.delivery` | Webhook Signed Delivery | `core/webhooks` | external subscribers, `core/audit` | `1.0` | `retry_and_dead_letter` (max 5, DLQ) | Required (`id`) | +| `replay.event` | Event Log Replay Entry | `core/replay` | `core/replay/projections` | `1.0` | `safe_fallback` (poison halt) | Required (`id`) | +| `ownership.transfer` | Ownership Transfer Intent | `core/ownership` | `core/notifications`, `core/audit` | `1.0` | `reject_with_code` (two-step) | Required (`id`) | + +--- + +## Producer / Consumer Specifications & Required Fields + +### 1. `telemetry.event` +- **Producer**: `core/telemetry` via `measureSync` or `emitTelemetry`. +- **Consumers**: `core/audit`, `core/health`, log streams. +- **Required Fields**: + - `op` (`string`): operation identifier (e.g. `horizon.request`, `export.generate`). + - `actorType` (`string`): `client`, `user`, or `maintainer`. + - `result` (`string`): `success` or `failure`. + - `latencyMs` (`number`): execution duration in milliseconds. + - `correlationId` (`string`): tracing identifier. + - `timestamp` (`string`): ISO 8601 timestamp. +- **Allowed Failure Behavior**: Fail-silent (`fail_silent`). Telemetry drops or formatting issues must never crash user workflows. Poison events are skipped with warnings. + +### 2. `horizon.error` +- **Producer**: `core/horizon` via `classifyHorizonError`. +- **Consumers**: Feature slice hooks, components, and toast notifications. +- **Required Fields**: + - `code` (`string`): stable error code (`rate_limited`, `not_found`, `timeout`, `network_error`). + - `detail` (`object`): HTTP status and upstream detail object. +- **Allowed Failure Behavior**: Safe fallback (`safe_fallback`). Unknown errors map to `request_failed`. + +### 3. `worker.job` +- **Producer**: Feature slices initiating background work. +- **Consumers**: `core/workers` queue and executor. +- **Required Fields**: + - `id` (`string`): deterministic or unique job ID. + - `operation` (`string`): operation name. + - `status` (`string`): `queued`, `running`, `retrying`, `succeeded`, `dead_lettered`. + - `attempts` (`number`): current attempt count. + - `enqueuedAt` (`string`): ISO 8601 timestamp. + - `correlationId` (`string`): correlation identifier. +- **Allowed Failure Behavior**: Retried with exponential backoff up to `maxAttempts: 3`. On exhaustion, transitioned to `dead_lettered` and recorded in audit trail. + +### 4. `export.envelope` +- **Producer**: `core/export` via `generateExport`. +- **Consumers**: Browser download handler, audit logger. +- **Required Fields**: + - `schemaVersion` (`string`): version negotiation string (`"1.0"`). + - `scope` (`string`): `"own"` or `"maintainer"`. + - `generatedAt` (`string`), `expiresAt` (`string`): lifecycle bounds. + - `generator` (`string`): generator tag. + - `correlationId` (`string`): audit correlation ID. + - `recordCount` (`number`), `page` (`number`), `totalRecords` (`number`): paging metadata. + - `records` (`array`): sanitized, privacy-filtered data records. +- **Allowed Failure Behavior**: Refused with explicit error codes (`export_denied`, `schema_unsupported`). No partial or unauthenticated exports. + +### 5. `lifecycle.state` +- **Producer**: `core/lifecycle/records.ts`. +- **Consumers**: UI state badges, notification triggers, reconciliation sweeps. +- **Required Fields**: + - `id` (`string`): record key. + - `kind` (`string`): record machine name (e.g., `worker_job`, `notification`, `ticket`). + - `state` (`string`): legal state name. + - `updatedAt` (`string`): ISO 8601 timestamp. +- **Allowed Failure Behavior**: Illegal state transitions return `{ ok: false, code: "invalid_transition" }`. Machine reverts to previous state atomically. + +### 6. `idempotency.record` +- **Producer**: `core/idempotency`. +- **Consumers**: Retrying callers, background jobs, payment verifiers. +- **Required Fields**: + - `key` (`string`): idempotency key. + - `operation` (`string`): targeted operation. + - `requestHash` (`string`): SHA-256 canonical hash of payload. + - `status` (`string`): `in_flight`, `completed`, `failed`. + - `createdAt` (`string`), `expiresAt` (`string`). + - `replays` (`number`): count of replayed responses. +- **Allowed Failure Behavior**: Key collision produces `idempotency_key_conflict`. Expired keys produce `idempotency_key_expired`. In-flight duplicates produce `idempotency_in_flight`. + +--- + +## Versioning Rules & Upcast Policies + +Every domain event contract adheres to SemVer-compatible negotiation: +1. **Current Version**: Active schema version produced by the current build. +2. **Supported Versions**: Versions the consumer/boundary can accept without error. +3. **Deprecated Versions**: Supported older versions that trigger upgrade notices but remain operational. +4. **Upcast Policy**: + - `automatic`: Missing optional fields filled with safe defaults. + - `manual`: Specific upcaster function applied via `core/replay/upcast.ts`. + - `reject`: Payload strictly rejected with `schema_unsupported` or `schema_future` to avoid silent data truncation. + +--- + +## Operational Edge Cases & Failure Modes + +1. **Schema Drift During Rolling Deployment**: + - *Risk*: A newer client produces an event format an older background worker cannot parse. + - *Behavior*: Worker inspects `schemaVersion`. If newer than supported, the job is quarantined as `safe_fallback` or scheduled with backoff until all workers are updated. +2. **Poison Event in Replay Stream**: + - *Risk*: A corrupt event in the append-only log triggers unhandled exceptions in all projections. + - *Behavior*: Projection evaluator enforces poison event policy: `halt` (stop stream to prevent corrupted view promotion) or `skip` with metric emission depending on configuration. +3. **Idempotency Hash Collision on Concurrent Retries**: + - *Risk*: Two distinct payloads send the same key concurrently. + - *Behavior*: Atomic CAS on storage key locks `in_flight` status; canonical hash comparison detects conflicts and returns `idempotency_key_conflict`. + +--- + +## Rollback & Recovery Steps + +1. **Detecting Incompatible Payloads**: + Run automated contract verification: + ```bash + npm run verify:issues-219-240 + ``` +2. **Contract Reversion**: + If an incompatible contract was rolled out, rollback to the previous version pointer. Deprecated version support ensures old consumers continue operating during the transition. +3. **Quarantine Inspection**: + Inspect dead-lettered jobs in `core/workers` or failed deliveries in `core/webhooks` using audit logs: + ```bash + node scripts/verify-audit.mjs + ``` diff --git a/docs/ROLLOUT_GUARD_AND_ROLLBACK.md b/docs/ROLLOUT_GUARD_AND_ROLLBACK.md new file mode 100644 index 0000000..6b1c161 --- /dev/null +++ b/docs/ROLLOUT_GUARD_AND_ROLLBACK.md @@ -0,0 +1,120 @@ +# Feature-Flag Rollout Guard with Automatic Rollback Verification (#223) + +## Overview + +Deploying experimental feature slices, Soroban RPC protocol updates, or high-risk changes into production requires continuous operational monitoring and fast, safe rollback mechanisms. + +The **Feature-Flag Rollout Guard** (`core/feature-flags/rollout-guard.ts`) safeguards production by: +1. Scoping risky rollouts (canary percentage, network targeting, allowlists, risk tiers). +2. Continuously monitoring error rates and consecutive failure streaks. +3. Automatically tripping an emergency kill-switch when error thresholds are exceeded. +4. Immediately executing **automatic rollback verification** to ensure that partially processed records, orphaned background jobs, or inconsistent intermediate states are detected, cleaned up, and audited. + +--- + +## Architecture & Rollout Workflow + +``` +┌────────────────────────────────────────────────────────┐ +│ RolloutGuard Configuration │ +│ - flag: "sorobanFeeEstimator" │ +│ - riskTier: "critical" (max 3 consecutive failures) │ +│ - errorThresholdPercent: 20% │ +│ - allowedNetworks: ["testnet"] │ +└────────────────────────────────────────────────────────┘ + │ + ▼ + [Active Operation Flow] + ┌─────────────┴─────────────┐ + ▼ ▼ + recordSuccess() recordFailure() + │ │ + │ Threshold breached? + │ │ + │ Yes ────┼────────────────┐ + │ │ │ + │ ▼ ▼ + │ [Trip Emergency Disable] [Rollback Verification] + │ │ │ + │ ▼ ▼ + │ Disable feature flag Check state consistency + │ Remediate orphaned records + │ Emit Telemetry & Audit + ▼ │ + Healthy Rollout ◄───────────────────────────────────┘ +``` + +--- + +## State Consistency Verification & Remediation + +When a rollback is triggered (either automatically via anomaly detection or manually by a maintainer), `RolloutGuard` executes registered `StateConsistencyCheck` rules against the system state: + +```typescript +const guard = createRolloutGuard( + { + flag: "contractEventSubscription", + riskTier: "high", + percentage: 10, + allowedNetworks: ["testnet"], + errorThresholdPercent: 15, + maxConsecutiveFailures: 3 + }, + { + stateProvider: () => runtimeState, + consistencyChecks: [ + { + name: "no_stranded_jobs", + description: "Verify no worker jobs remain in intermediate running state", + check: (state) => ({ + valid: !state.workerJobs.some((j) => j.status === "running" && j.feature === "contractEventSubscription"), + violation: "Found stranded running worker jobs" + }), + remediate: (state) => { + state.workerJobs = state.workerJobs.filter((j) => j.feature !== "contractEventSubscription"); + return { remediated: true, details: "Cleaned up stranded worker jobs" }; + } + } + ] + } +); +``` + +--- + +## Operational Edge Cases & Failure Modes + +### 1. Cascade Failure During Partial Rollout +- **Scenario**: A 10% canary rollout encounters an unhandled RPC transport bug on mainnet. +- **Guardrail**: As soon as 3 consecutive failures occur, `recordFailure()` trips the guard, invokes `triggerEmergencyDisable(flag)`, and evaluates all consistency checks. +- **Outcome**: The feature is immediately disabled for 100% of users, protecting the remaining 90% from seeing the failure. + +### 2. Orphaned State Left in Browser Storage +- **Scenario**: A feature flag enabled a new storage format. After rollback, the application must not crash reading newer schema records. +- **Guardrail**: The consistency check inspects stored keys, falls back to legacy storage keys, and logs an audit trail. + +### 3. False Positive Trip on Network Spike +- **Scenario**: Transient network timeout trips the guard. +- **Recovery**: The guard records an audit entry with `triggerReason`. Maintainers verify network stability and reset the guard via `clearEmergencyDisable(flag)` after root-cause validation. + +--- + +## Rollback & Recovery Steps + +1. **Verify Guard Status**: + Inspect whether a flag is emergency disabled or tripped: + ```bash + node --loader ./scripts/loader.mjs scripts/contributor-diagnostics.mjs + ``` + +2. **Run Rollback Verification**: + Execute the automated verification script: + ```bash + npm run verify:issues-219-240 + ``` + +3. **Inspect Audit Records**: + Review details of the automated kill-switch event: + ```bash + node scripts/verify-audit.mjs + ``` diff --git a/docs/STORAGE_SCHEMA_MIGRATION.md b/docs/STORAGE_SCHEMA_MIGRATION.md new file mode 100644 index 0000000..bfc2363 --- /dev/null +++ b/docs/STORAGE_SCHEMA_MIGRATION.md @@ -0,0 +1,90 @@ +# Storage Schema Compatibility Checks & Migration Framework (#240) + +## Overview + +Utix persists browser state, operations history, idempotency records, and notification preferences client-side. As the application evolves to support multi-tenant isolation, structured actor attribution, and tamper-evident SHA-256 record checksums, storage schemas must transition safely from legacy (V1) to the next storage schema version (V2). + +The **Storage Schema Migration Framework** (`core/migration/storage-schema.ts`) provides pre-flight compatibility checks, dry-run previews, atomic execution, automated post-migration invariant validation, and reverse downgrade rollback. + +--- + +## The Four Required Paths + +``` + [Storage Records] + │ + ┌────────────┴────────────┐ + ▼ ▼ + [Authorization Check] [Compatibility Check] + - maintainer/admin only - detect malformed records + - unauthorized -> DENIED - corrupt -> REJECT + │ │ + └────────────┬────────────┘ + ▼ + ┌─────────────────────────┐ + │ Migration Execution │ + └────────────┬────────────┘ + │ + ┌───────────────────────────┼───────────────────────────┐ + ▼ ▼ ▼ + [Normal Path] [Failure Path] [Retry Path] + - Upgrade V1 -> V2 - Invariant fails - Idempotent resume + - Inject Checksums - Trigger rollback - Skip already V2 + - Post-checks PASS - Restore V1 records - Zero duplicate records +``` + +### 1. Normal Path +- Valid legacy V1 records are upgraded to V2 format. +- Injects `schemaVersion: 2`, `partition: "default"`, deterministic SHA-256 `checksum`, and `migrationMeta`. +- All post-migration invariant checks pass (`all_records_schema_version_2`, `valid_integrity_checksums`). +- Emits telemetry and an audit trail entry. + +### 2. Failure Path +- Corrupt or malformed records (missing IDs, invalid dates) are detected during pre-flight checks and fail fast before touching storage. +- If a post-migration check fails and `autoRollbackOnFailure: true`, the framework executes `downgradeV2ToV1()` to restore records to their exact pre-migration state. + +### 3. Retry Path +- Interrupted or retried migrations are fully idempotent. +- Records already carrying `schemaVersion: 2` are detected as noops and passed through unchanged without key collisions or duplicated records. + +### 4. Authorization Path +- Migration operations are gated behind maintainer and administrative roles. +- Plain user actors attempting migration are refused with `permission_denied` and audited. + +--- + +## V1 vs V2 Schema Differences + +| Field | V1 (Legacy) | V2 (Next Version) | Purpose | +|---|---|---|---| +| `schemaVersion` | Optional / missing | `2` (required) | Explicit schema negotiation | +| `checksum` | Not present | 32-char SHA-256 hex | Tamper-evident record integrity | +| `partition` | Not present | `"default"` | Multi-account storage isolation | +| `actor` | Not present | `{ kind, id }` | Attribution for audit trail | +| `migrationMeta` | Not present | `{ migratedFrom, migratedAt }` | Provenance tracking | + +--- + +## Operational Edge Cases & Failure Modes + +1. **Storage Quota Exceeded During Migration**: + - V2 records include checksum and metadata fields, slightly increasing storage footprint. + - *Mitigation*: Run pre-flight `checkStorageCompatibility()` with dry-run estimation. +2. **Concurrent Tab Writes During Upgrade**: + - *Mitigation*: Migrations use atomic batches and verify checksums before committing. +3. **Rollback Verification**: + - If a rollback occurs, `verifyRollback()` asserts that record count and ID parity match the original set. + +--- + +## Rollback & Recovery Steps + +1. **Pre-flight Check**: + ```bash + node --loader ./scripts/loader.mjs scripts/verify-issues-219-240.mjs + ``` +2. **Reverse Migration**: + If an issue is found in V2 behavior post-migration, execute downgrade: + ```typescript + StorageV1ToV2Migration.rollback(v2Records); + ``` diff --git a/package-lock.json b/package-lock.json index 8b98c18..d263ce1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -148,7 +148,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -479,7 +478,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -528,11 +526,31 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", @@ -1743,7 +1761,6 @@ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright": "1.62.1" }, @@ -2091,7 +2108,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2277,7 +2293,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2288,7 +2303,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2355,7 +2369,6 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -2920,7 +2933,6 @@ "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.1.11", @@ -3065,7 +3077,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3650,7 +3661,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -4514,7 +4524,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4655,7 +4664,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6258,7 +6266,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -7625,7 +7632,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -7919,7 +7925,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -7929,7 +7934,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9041,7 +9045,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9308,7 +9311,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9485,7 +9487,6 @@ "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -9577,7 +9578,6 @@ "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", @@ -10014,7 +10014,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 0334499..55bc45b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "diagnostics:contributor": "node --loader ./scripts/loader.mjs scripts/contributor-diagnostics.mjs", "doctor": "node --loader ./scripts/loader.mjs scripts/contributor-diagnostics.mjs", "verify:issues-264-275": "node --loader ./scripts/loader.mjs scripts/verify-issues-264-275.mjs", + "verify:issues-219-240": "node --loader ./scripts/loader.mjs scripts/verify-issues-219-240.mjs", "backfill": "node --loader ./scripts/loader.mjs scripts/backfill.mjs" }, "dependencies": { diff --git a/scripts/loader.mjs b/scripts/loader.mjs index 8935b40..b340a56 100644 --- a/scripts/loader.mjs +++ b/scripts/loader.mjs @@ -1,10 +1,21 @@ import fs from "node:fs"; import path from "node:path"; -import { pathToFileURL } from "node:url"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import ts from "typescript"; -const root = path.resolve(import.meta.dirname, ".."); +const __dirname = typeof import.meta.dirname !== "undefined" + ? import.meta.dirname + : path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, ".."); export async function resolve(specifier, context, nextResolve) { + if ((specifier === "node:util" || specifier === "util") && !context.parentURL?.includes("shims/util.mjs")) { + return { + shortCircuit: true, + url: pathToFileURL(path.join(__dirname, "shims", "util.mjs")).href + }; + } + if (specifier.startsWith("@/")) { const target = path.join(root, specifier.slice(2)); for (const ext of ["", ".ts", ".tsx", ".mjs", ".js", "/index.ts", "/index.js"]) { @@ -36,3 +47,25 @@ export async function resolve(specifier, context, nextResolve) { } return nextResolve(specifier, context); } + +export async function load(url, context, nextLoad) { + if (url.endsWith(".ts") || url.endsWith(".tsx")) { + const filePath = fileURLToPath(url); + const source = await fs.promises.readFile(filePath, "utf8"); + const { outputText } = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, + esModuleInterop: true + }, + fileName: filePath + }); + return { + format: "module", + shortCircuit: true, + source: outputText + }; + } + return nextLoad(url, context); +} diff --git a/scripts/shims/util.mjs b/scripts/shims/util.mjs new file mode 100644 index 0000000..db890a7 --- /dev/null +++ b/scripts/shims/util.mjs @@ -0,0 +1,70 @@ +import * as realUtil from "node:util"; + +export const { + _errnoException, + _exceptionWithHostPort, + _extend, + callbackify, + debug, + debuglog, + deprecate, + format, + formatWithOptions, + getSystemErrorMap, + getSystemErrorName, + inherits, + inspect, + isArray, + isBoolean, + isBuffer, + isDeepStrictEqual, + isNull, + isNullOrUndefined, + isNumber, + isObject, + isPrimitive, + isRegExp, + isString, + isSymbol, + isUndefined, + log, + promisify, + stripVTControlCharacters, + toUSVString, + types, + TextDecoder, + TextEncoder, + MIMEType, + MIMEParams +} = realUtil; + +export function styleText(format, text) { + return String(text); +} + +export function parseEnv(content) { + const result = {}; + if (typeof content !== "string") return result; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq !== -1) { + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) { + val = val.slice(1, -1); + } + result[key] = val; + } + } + return result; +} + +const utilShim = { + ...realUtil, + styleText, + parseEnv +}; + +export default utilShim; diff --git a/scripts/verify-issues-219-240.mjs b/scripts/verify-issues-219-240.mjs new file mode 100644 index 0000000..1d7459c --- /dev/null +++ b/scripts/verify-issues-219-240.mjs @@ -0,0 +1,367 @@ +#!/usr/bin/env node +/** + * Automated Verification Test Suite for Issues #219, #223, #226, #240 + * + * Verifies all 4 hard contributor assignments: + * - Issue #219: Cross-service contract map for domain events and integration boundaries + * - Issue #223: Feature-flag rollout guard with automatic rollback verification + * - Issue #226: Risk-scored abuse detection for suspicious high-impact operations + * - Issue #240: Storage schema compatibility checks for the next schema version + * + * Usage: + * node --loader ./scripts/loader.mjs scripts/verify-issues-219-240.mjs + */ + +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +console.log("============================================================"); +console.log(" AUTOMATED VERIFICATION: ISSUES #219, #223, #226, #240 "); +console.log("============================================================\n"); + +// ------------------------------------------------------------- +// TEST 1: ISSUE #219 - Cross-Service Contract Map +// ------------------------------------------------------------- +console.log("--> Testing Issue #219: Cross-Service Contract Map..."); +const { + verifyCrossServiceContractMap, + getContractDefinition, + getContractsByProducer, + getContractsByConsumer, + validateEventContractPayload, + listAllContracts +} = await import("../core/contract/contract-map.ts"); + +// 1. Verify contract map registry completeness +const mapVerification = verifyCrossServiceContractMap(); +assert.equal(mapVerification.ok, true, "Cross-service contract map must be complete and valid"); +assert.equal(mapVerification.value.valid, true); +assert.ok(mapVerification.value.totalContracts >= 10, "Must define at least 10 core event/boundary contracts"); +assert.ok(mapVerification.value.producersCount >= 5, "Must declare at least 5 distinct producers"); +assert.ok(mapVerification.value.consumersCount >= 5, "Must declare at least 5 distinct consumers"); + +// 2. Validate specific contract definitions +const telemetryContract = getContractDefinition("telemetry.event"); +assert.ok(telemetryContract, "telemetry.event contract must exist"); +assert.equal(telemetryContract.producer, "core/telemetry"); +assert.equal(telemetryContract.failurePolicy.allowedFailureBehavior, "fail_silent"); + +const workerContract = getContractDefinition("worker.job"); +assert.ok(workerContract, "worker.job contract must exist"); +assert.equal(workerContract.idempotency.required, true); +assert.equal(workerContract.failurePolicy.allowedFailureBehavior, "retry_and_dead_letter"); +assert.equal(workerContract.failurePolicy.deadLetterQueue, true); + +// 3. Payload validation: success path +const validTelemetry = { + op: "horizon.request", + actorType: "client", + result: "success", + latencyMs: 142, + correlationId: "corr-test-1", + timestamp: "2026-10-19T10:00:00.000Z" +}; +const telRes = validateEventContractPayload("telemetry.event", validTelemetry); +assert.equal(telRes.ok, true, "Compliant telemetry payload must pass contract validation"); + +// 4. Payload validation: missing required field +const invalidTelemetry = { + op: "horizon.request", + result: "success" +}; +const invTelRes = validateEventContractPayload("telemetry.event", invalidTelemetry); +assert.equal(invTelRes.ok, false); +assert.equal(invTelRes.code, "missing_required_field"); + +// 5. Payload validation: wrong type +const wrongTypeTelemetry = { + op: "horizon.request", + actorType: "client", + result: "success", + latencyMs: "not-a-number", + correlationId: "corr-test-1", + timestamp: "2026-10-19T10:00:00.000Z" +}; +const wrongTypeRes = validateEventContractPayload("telemetry.event", wrongTypeTelemetry); +assert.equal(wrongTypeRes.ok, false); +assert.equal(wrongTypeRes.code, "wrong_type"); + +// 6. Version negotiation & future version rejection +const futureVerRes = validateEventContractPayload("telemetry.event", validTelemetry, { + requestedVersion: "99.0" +}); +assert.equal(futureVerRes.ok, false); +assert.equal(futureVerRes.code, "schema_future"); + +console.log(" [PASS] Cross-Service Contract Map verified (definitions, schemas, version rules, failure policies)\n"); + +// ------------------------------------------------------------- +// TEST 2: ISSUE #223 - Feature-Flag Rollout Guard & Rollback +// ------------------------------------------------------------- +console.log("--> Testing Issue #223: Feature-Flag Rollout Guard & Rollback Verification..."); +const { + createRolloutGuard +} = await import("../core/feature-flags/rollout-guard.ts"); +const { + isFeatureEnabled, + clearEmergencyDisable, + resetRolloutEngine +} = await import("../core/feature-flags/flags.ts"); + +resetRolloutEngine(); + +const mockState = { + workerJobs: [ + { id: "job-1", status: "completed", flag: "sorobanDiagnosticEngine" }, + { id: "job-2", status: "running", flag: "sorobanDiagnosticEngine" } // stranded job! + ] +}; + +const guard = createRolloutGuard( + { + flag: "sorobanDiagnosticEngine", + riskTier: "critical", + percentage: 10, + allowedNetworks: ["testnet"], + errorThresholdPercent: 50, + maxConsecutiveFailures: 3, + minSamples: 10 + }, + { + stateProvider: () => mockState, + consistencyChecks: [ + { + name: "no_stranded_jobs", + description: "Verify no worker jobs remain in 'running' state after rollback", + check: (state) => { + const stranded = state.workerJobs.filter((j) => j.status === "running" && j.flag === "sorobanDiagnosticEngine"); + return { + valid: stranded.length === 0, + violation: `${stranded.length} stranded jobs detected in running state` + }; + }, + remediate: (state) => { + state.workerJobs = state.workerJobs.filter((j) => !(j.status === "running" && j.flag === "sorobanDiagnosticEngine")); + return { + remediated: true, + details: "Purged stranded running worker jobs" + }; + } + } + ] + } +); + +// 1. Initial State: Flag is enabled for testnet context +assert.equal(guard.isTripped(), false, "Guard must start untripped"); +guard.recordSuccess(); +guard.recordSuccess(); +const initialMetrics = guard.getMetrics(); +assert.equal(initialMetrics.successes, 2); +assert.equal(initialMetrics.failures, 0); + +// 2. Anomaly Breach: 3 consecutive failures trigger automatic rollback +const f1 = guard.recordFailure("Transient RPC timeout"); +assert.equal(f1, null, "1st failure should not trip"); +const f2 = guard.recordFailure("Transient RPC timeout"); +assert.equal(f2, null, "2nd failure should not trip"); + +const rollbackReport = guard.recordFailure("Fatal Soroban VM panic"); +assert.ok(rollbackReport, "3rd consecutive failure must trigger automatic rollback"); +assert.equal(rollbackReport.rollbackTriggered, true); +assert.equal(guard.isTripped(), true, "Guard must be tripped"); + +// 3. Post-Rollback Feature Status: Must be disabled +assert.equal(isFeatureEnabled("sorobanDiagnosticEngine"), false, "Feature flag must be disabled post-rollback"); +assert.equal(guard.isEnabled(), false, "Guard must return false post-rollback"); + +// 4. Post-Rollback State Verification & Remediation +assert.equal(rollbackReport.postRollbackFlagDisabled, true); +assert.equal(rollbackReport.violations.length, 1, "Must detect the stranded running job"); +assert.equal(rollbackReport.remediationsApplied.length, 1, "Must remediate the stranded running job"); +assert.equal(rollbackReport.stateConsistent, true, "State must be verified consistent after remediation"); +assert.equal(mockState.workerJobs.length, 1, "Stranded job must have been cleaned up"); + +console.log(" [PASS] Rollout Guard & Automatic Rollback Verification verified (scoping, monitoring, tripping, remediation)\n"); + +// ------------------------------------------------------------- +// TEST 3: ISSUE #226 - Risk-Scored Abuse Detection +// ------------------------------------------------------------- +console.log("--> Testing Issue #226: Risk-Scored Abuse Detection..."); +const { + evaluateAbuseRisk, + getAbuseAssessment, + overrideAbuseAssessment, + resetAbuseAssessmentStore +} = await import("../core/security/abuse-detection.ts"); + +resetAbuseAssessmentStore(); + +const USER_ALICE = { kind: "user", id: "G_ALICE" }; +const MAINTAINER_BOB = { kind: "maintainer", id: "G_MAINTAINER_BOB" }; + +// 1. Zero friction for legitimate low-risk operations +const lowRisk1 = evaluateAbuseRisk({ + operation: "address.validate", + actor: USER_ALICE +}); +assert.equal(lowRisk1.score, 0, "Address validation must have 0 risk score"); +assert.equal(lowRisk1.action, "allow"); +assert.equal(lowRisk1.requiredStepUp, "none"); + +const lowRisk2 = evaluateAbuseRisk({ + operation: "balance.view", + actor: USER_ALICE +}); +assert.equal(lowRisk2.score, 0, "Balance query must have 0 risk score"); +assert.equal(lowRisk2.action, "allow"); + +// 2. High-Impact: Disabling master key weight triggers step-up +const stepUp1 = evaluateAbuseRisk({ + operation: "account.set_options", + actor: USER_ALICE, + payload: { masterWeight: 0 } +}); +assert.ok(stepUp1.score >= 40 && stepUp1.score < 70, "Zeroing master key should score in step_up tier"); +assert.equal(stepUp1.action, "step_up"); +assert.equal(stepUp1.requiredStepUp, "confirm_dialog"); +assert.equal(stepUp1.signals.length, 1); +assert.equal(stepUp1.signals[0].id, "disabled_master_key"); + +// 3. High-Impact: Destructive account merge +const mergeAssessment = evaluateAbuseRisk({ + operation: "account.merge", + actor: USER_ALICE, + payload: { destination: "G_DESTINATION" } +}); +assert.equal(mergeAssessment.action, "step_up"); +assert.equal(mergeAssessment.signals[0].id, "destructive_account_merge"); + +// 4. Critical: Velocity burst + suspicious action triggers review or block +const criticalAbuse = evaluateAbuseRisk({ + operation: "faucet.fund", + actor: USER_ALICE, + recentOperationCount: 25 // extreme burst +}); +assert.ok(criticalAbuse.score >= 80, "Extreme burst on faucet must score high"); +assert.ok(criticalAbuse.action === "review" || criticalAbuse.action === "block"); +assert.ok(criticalAbuse.signals.length >= 2, "Must accumulate multiple explainable risk signals"); + +// 5. Maintainer Override of False Positive +const storedAssessment = getAbuseAssessment(criticalAbuse.evaluationId); +assert.ok(storedAssessment); +const overridden = overrideAbuseAssessment( + criticalAbuse.evaluationId, + "allow", + MAINTAINER_BOB, + "Approved load-test run by verified contributor" +); +assert.ok(overridden); +assert.equal(overridden.action, "allow"); +assert.equal(overridden.overridden?.maintainerId, MAINTAINER_BOB.id); + +console.log(" [PASS] Risk-Scored Abuse Detection verified (signals, progressive step-up, explainability, maintainer overrides)\n"); + +// ------------------------------------------------------------- +// TEST 4: ISSUE #240 - Storage Schema Compatibility & Migration +// ------------------------------------------------------------- +console.log("--> Testing Issue #240: Storage Schema Migration & Compatibility..."); +const { + checkStorageCompatibility, + executeStorageSchemaMigration, + StorageV1ToV2Migration, + STORAGE_SCHEMA_CURRENT_VERSION, + STORAGE_SCHEMA_NEXT_VERSION +} = await import("../core/migration/storage-schema.ts"); + +const validV1Records = [ + { + id: "op-101", + kind: "horizon_request", + state: "completed", + correlationId: "corr-101", + createdAt: "2026-10-19T08:00:00.000Z", + updatedAt: "2026-10-19T08:00:01.000Z", + payload: { endpoint: "/accounts/G1" } + }, + { + id: "op-102", + kind: "export_generate", + state: "completed", + correlationId: "corr-102", + createdAt: "2026-10-19T08:05:00.000Z", + updatedAt: "2026-10-19T08:05:02.000Z", + payload: { scope: "own" } + } +]; + +// 4.1 Authorization Path: Unauthorized user rejected +const unauthRes = await executeStorageSchemaMigration(validV1Records, { + actor: { kind: "user", id: "G_UNAUTH" } +}); +assert.equal(unauthRes.ok, false, "Unauthorized non-maintainer must be refused"); +assert.equal(unauthRes.code, "permission_denied"); + +// 4.2 Pre-flight Compatibility Check +const compat = checkStorageCompatibility(validV1Records); +assert.equal(compat.compatible, true, "Valid V1 records must pass compatibility check"); +assert.equal(compat.v1Count, 2); +assert.equal(compat.v2Count, 0); + +// 4.3 Dry-Run Path +const dryRunRes = await executeStorageSchemaMigration(validV1Records, { + actor: { kind: "maintainer", id: "G_MAINTAINER" }, + dryRun: true +}); +assert.equal(dryRunRes.ok, true, "Dry-run must succeed"); +assert.equal(dryRunRes.value.dryRun, true); +assert.equal(dryRunRes.value.affectedCount, 2); + +// 4.4 Normal Path: Full migration V1 -> V2 +const normalRes = await executeStorageSchemaMigration(validV1Records, { + actor: { kind: "maintainer", id: "G_MAINTAINER" }, + dryRun: false +}); +assert.equal(normalRes.ok, true, "Normal migration must succeed"); +assert.equal(normalRes.value.fromVersion, STORAGE_SCHEMA_CURRENT_VERSION); +assert.equal(normalRes.value.toVersion, STORAGE_SCHEMA_NEXT_VERSION); +assert.equal(normalRes.value.affectedCount, 2); +assert.equal(normalRes.value.passed, true); + +// 4.5 Retry Path: Idempotent re-run on already upgraded V2 records +const { transformed: v2Records } = StorageV1ToV2Migration.apply(validV1Records); +assert.equal(v2Records.length, 2); +assert.equal(v2Records[0].schemaVersion, 2); +assert.ok(v2Records[0].checksum.length >= 16); + +const retryRes = await executeStorageSchemaMigration(v2Records, { + actor: { kind: "maintainer", id: "G_MAINTAINER" }, + dryRun: false +}); +assert.equal(retryRes.ok, true, "Retry must succeed idempotently"); +assert.equal(retryRes.value.affectedCount, 0, "Already-migrated V2 records should not be re-transformed"); +assert.equal(retryRes.value.unchangedCount, 2); + +// 4.6 Failure & Rollback Path: Corrupt records trigger rejection +const corruptRecords = [ + { id: "corrupt-1", state: "completed" }, // missing required kind & createdAt + null +]; +const corruptCompat = checkStorageCompatibility(corruptRecords); +assert.equal(corruptCompat.compatible, false, "Corrupted records must fail pre-flight check"); +assert.ok(corruptCompat.invalidRecords.length >= 2); + +const corruptRes = await executeStorageSchemaMigration(corruptRecords, { + actor: { kind: "maintainer", id: "G_MAINTAINER" } +}); +assert.equal(corruptRes.ok, false); +assert.equal(corruptRes.code, "corrupt_record_detected"); + +console.log(" [PASS] Storage Schema Migration verified (normal, failure, retry, authorization paths)\n"); + +console.log("============================================================"); +console.log(" ALL 4 ISSUES (#219, #223, #226, #240) VERIFIED [PASS] "); +console.log("============================================================\n");