diff --git a/package-lock.json b/package-lock.json index bb2e04b..55532b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@noble/curves": "^2.2.0", "@stellar/freighter-api": "^3.1.0", "@stellar/stellar-sdk": "^13.3.0", - "@walletconnect/sign-client": "^2.23.9" + "@walletconnect/sign-client": "^2.23.9", + "ajv": "^8.20.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", @@ -2788,6 +2789,22 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3537,6 +3554,28 @@ "node": ">=12.17.0" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "dev": true, @@ -4728,7 +4767,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" diff --git a/package.json b/package.json index 8ff9c66..8de17b9 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,6 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/property-roundtrip.test.ts test/property-address.test.ts test/property-deadline.test.ts test/property-invoice-params.test.ts test/property-client-invariants.test.ts", - "test": "vitest run test/client.test.ts test/retryPolicy.test.ts test/resilience.test.ts", - "test": "vitest run test/client.test.ts test/multiTenant.test.ts", - "test": "vitest run test/client.test.ts test/profiler.test.ts", "test": "vitest run test/client.test.ts test/retryPolicy.test.ts", "test:ui": "vitest run test/ui/", "test:all": "vitest run", @@ -57,7 +53,8 @@ "@noble/curves": "^2.2.0", "@stellar/freighter-api": "^3.1.0", "@stellar/stellar-sdk": "^13.3.0", - "@walletconnect/sign-client": "^2.23.9" + "@walletconnect/sign-client": "^2.23.9", + "ajv": "^8.20.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/src/accountStateDetector.ts b/src/accountStateDetector.ts new file mode 100644 index 0000000..6870e3d --- /dev/null +++ b/src/accountStateDetector.ts @@ -0,0 +1,113 @@ +/** + * Account freeze / lock state detector. + * + * An issuer can revoke authorization on a trustline (freezing it) or an + * account can be left in a state where authorization can never be granted + * again (`AUTH_IMMUTABLE`). Sending payments to or from such accounts fails + * on submission in ways that are hard to distinguish from other errors. + * `detectLockState` inspects the recipient's trustline and the issuer's + * account flags up front so callers can surface a clear error before + * building a transaction. + */ + +import { Asset, Horizon } from "@stellar/stellar-sdk"; +import { AccountFrozenError, AccountLockedError } from "./errors.js"; +import type { AccountLockState } from "./types.js"; + +/** + * Inspect an account's trustline for `asset` and the issuer's account flags + * to determine whether the account is frozen or permanently locked out of + * authorization. + * + * @param server - Horizon server instance. + * @param accountId - Stellar address to inspect. + * @param asset - Asset whose trustline is being checked. Native XLM is + * never frozen or locked, so it always returns a fully + * authorized state. + * @returns The account's lock state. + */ +export async function detectLockState( + server: Horizon.Server, + accountId: string, + asset: Asset, +): Promise { + if (asset.isNative()) { + return { + isFrozen: false, + isLocked: false, + trustlineAuthorized: true, + revocableByIssuer: false, + }; + } + + const account = await server.loadAccount(accountId); + const balances = account.balances as Array< + Horizon.HorizonApi.BalanceLineAsset | Horizon.HorizonApi.BalanceLineNative | Horizon.HorizonApi.BalanceLineLiquidityPool + >; + + const trustline = balances.find( + (b): b is Horizon.HorizonApi.BalanceLineAsset => + (b.asset_type === "credit_alphanum4" || b.asset_type === "credit_alphanum12") && + b.asset_code === asset.getCode() && + b.asset_issuer === asset.getIssuer(), + ); + + if (!trustline) { + return { + isFrozen: false, + isLocked: false, + trustlineAuthorized: false, + reason: "no_trustline", + revocableByIssuer: false, + }; + } + + const trustlineAuthorized = trustline.is_authorized; + const isFrozen = !trustline.is_authorized && !trustline.is_authorized_to_maintain_liabilities; + + let issuerFlags: Horizon.HorizonApi.Flags | undefined; + try { + const issuerAccount = await server.loadAccount(asset.getIssuer()); + issuerFlags = issuerAccount.flags; + } catch { + issuerFlags = undefined; + } + + const revocableByIssuer = issuerFlags?.auth_revocable ?? false; + const isLocked = !trustlineAuthorized && issuerFlags?.auth_immutable === true; + + const result: AccountLockState = { + isFrozen, + isLocked, + trustlineAuthorized, + revocableByIssuer, + }; + if (isFrozen) { + result.reason = "frozen"; + } else if (isLocked) { + result.reason = "immutable"; + } + return result; +} + +/** + * Convenience wrapper around {@link detectLockState} that throws when the + * account is frozen or locked, instead of returning a report. + * + * @throws {AccountFrozenError} When the trustline has been frozen by the issuer. + * @throws {AccountLockedError} When the account can never be authorized again. + */ +export async function assertAccountUnlocked( + server: Horizon.Server, + accountId: string, + asset: Asset, +): Promise { + const state = await detectLockState(server, accountId, asset); + if (state.isFrozen) { + throw new AccountFrozenError(accountId, asset.getCode()); + } + if (state.isLocked) { + throw new AccountLockedError(accountId, asset.getCode()); + } + return state; +} diff --git a/src/client.ts b/src/client.ts index 9af508b..c7788c4 100644 --- a/src/client.ts +++ b/src/client.ts @@ -237,6 +237,7 @@ import { IdempotencyManager } from "./idempotency.js"; import type { IdempotencyConfig } from "./idempotency.js"; import { RollbackCoordinator } from "./splitRollbackCoordinator.js"; import { validateInvoicePayload } from "./payloadGuard.js"; +import { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator.js"; import { validateSplitRatiosOrThrow } from "./validators/splitRatioValidator.js"; import type { SplitConfig } from "./types.js"; import { checkTrustlines } from "./trustlineChecker.js"; @@ -620,6 +621,7 @@ export class StellarSplitClient extends TypedEventEmitter { private _plugins = new Set(); private _pluginInstances: StellarSplitPlugin[] = []; private _pluginRegistry = new PluginRegistry(); + private _metadataValidator: InvoiceMetadataValidator; private _dedup = new Deduplicator(); private _cache: SimpleCache | ICacheStore | null = null; private _auditLogger: AuditLogger | null = null; @@ -768,6 +770,10 @@ export class StellarSplitClient extends TypedEventEmitter { super(); validateOrThrow(config); this.config = config; + this._metadataValidator = new InvoiceMetadataValidator( + config.metadataSchema, + config.metadataThrowOnInvalid ?? true, + ); const primaryUrl = Array.isArray(config.rpcUrl) ? config.rpcUrl[0]! : config.rpcUrl; @@ -1834,6 +1840,8 @@ export class StellarSplitClient extends TypedEventEmitter { validateInvoicePayload(params, this.config.payloadGuard); } + this._metadataValidator.validate(params.metadata); + // Pre-submission split ratio validation: catch malformed ratio arrays // early (ratio-sum violations, negative shares, duplicates, zeros). if (params.recipients.length > 1) { diff --git a/src/index.ts b/src/index.ts index fa411ac..5028cbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -206,6 +206,10 @@ export { isApprovalTimeoutError, } from "./errors.js"; +// Invoice metadata JSON Schema validator (issue #533) +export { InvoiceMetadataValidator } from "./validators/invoiceMetadataValidator.js"; +export type { MetadataValidationResult } from "./validators/invoiceMetadataValidator.js"; + // --------------------------------------------------------------------------- // Lifecycle management (graceful shutdown) // --------------------------------------------------------------------------- @@ -763,12 +767,14 @@ export type { FilterCriteria, CompiledFilter } from "./invoiceFilter.js"; export { diffInvoices, hasDiff } from "./diff.js"; export type { InvoiceDiff, InvoiceDiffEntry } from "./diff.js"; -export { diffSimulations } from "./simulationDiff.js"; +export { diffSimulations, compareSimulations, formatDiffSummary } from "./simulationDiff.js"; export type { SimulationDiff, SimulationDiffSuccess, SimulationDiffNotComparable, ResourceDelta, + SimulationComparison, + SimulationComparisonNotComparable, } from "./simulationDiff.js"; // Payment velocity tracking diff --git a/src/preflightChecker.ts b/src/preflightChecker.ts index 3b71cd6..34c597e 100644 --- a/src/preflightChecker.ts +++ b/src/preflightChecker.ts @@ -259,3 +259,49 @@ export async function checkPayerReadiness( return { ready: true }; } + +// --------------------------------------------------------------------------- +// Account Freeze / Lock Preflight Check +// --------------------------------------------------------------------------- + +/** Per-recipient lock state report, keyed by recipient address. */ +export interface RecipientLockCheckResult { + allUnlocked: boolean; + states: Record; +} + +/** + * Pre-submission check: verify none of the payment recipients are frozen or + * permanently locked out of authorization for `asset`. + * + * Calls {@link detectLockState} for each recipient. Throws on the first + * frozen or locked account found so callers fail fast before building a + * transaction that would otherwise be rejected on-chain. + * + * @param server - Horizon server instance. + * @param recipients - Recipient addresses to check. + * @param asset - Asset being sent. + * + * @throws {AccountFrozenError} When a recipient's trustline has been frozen by the issuer. + * @throws {AccountLockedError} When a recipient can never be authorized again for the asset. + */ +export async function checkRecipientsUnlocked( + server: Horizon.Server, + recipients: string[], + asset: Asset, +): Promise { + const states: Record = {}; + + for (const recipient of recipients) { + const state = await detectLockState(server, recipient, asset); + states[recipient] = state; + if (state.isFrozen) { + throw new AccountFrozenError(recipient, asset.getCode()); + } + if (state.isLocked) { + throw new AccountLockedError(recipient, asset.getCode()); + } + } + + return { allUnlocked: true, states }; +} diff --git a/src/reconciliationEngine.ts b/src/reconciliationEngine.ts new file mode 100644 index 0000000..6fa169b --- /dev/null +++ b/src/reconciliationEngine.ts @@ -0,0 +1,151 @@ +/** + * Asset balance reconciliation engine. + * + * After batch invoice payments, the SDK's internal accounting of sent + * amounts can drift from on-chain reality due to failed operations, fee + * deductions, or claimable balance unclaims. This engine compares the + * SDK's internal payment ledger (an invoice's recorded {@link Payment} + * entries) against the actual outgoing payments fetched from Horizon and + * surfaces any discrepancies as typed findings. + */ + +import { Horizon } from "@stellar/stellar-sdk"; +import type { Payment, ReconciliationFinding, ReconciliationReport } from "./types.js"; + +/** Inclusive Unix-timestamp (seconds) date range used to scope reconciliation. */ +export interface ReconciliationDateRange { + from: number; + to: number; +} + +export interface ReconciliationEngineOptions { + /** + * Discrepancy magnitude (in stroops) attributable to fees alone that is + * still tagged `severity: 'info'`. Defaults to 100 stroops. + */ + feeToleranceStroops?: bigint; + /** + * Discrepancy magnitude (in stroops) above `feeToleranceStroops` that is + * tagged `severity: 'warning'` rather than `'critical'`. Defaults to + * 10x the fee tolerance. + */ + warningThresholdStroops?: bigint; +} + +const DEFAULT_FEE_TOLERANCE_STROOPS = 100n; + +/** + * Compares the SDK's internal payment records against actual on-chain + * payments fetched from Horizon. + */ +export class ReconciliationEngine { + private readonly horizonServer: Horizon.Server; + private readonly payments: Payment[]; + private readonly feeToleranceStroops: bigint; + private readonly warningThresholdStroops: bigint; + + /** + * @param horizonUrl - Horizon API base URL used to fetch actual on-chain payments. + * @param payments - The SDK's internal payment ledger to reconcile against Horizon. + * @param options - Optional severity-classification thresholds. + */ + constructor(horizonUrl: string, payments: Payment[], options: ReconciliationEngineOptions = {}) { + this.horizonServer = new Horizon.Server(horizonUrl); + this.payments = payments; + this.feeToleranceStroops = options.feeToleranceStroops ?? DEFAULT_FEE_TOLERANCE_STROOPS; + this.warningThresholdStroops = options.warningThresholdStroops ?? this.feeToleranceStroops * 10n; + } + + /** + * Reconcile the internal payment ledger for `accountId` (as payer) and + * `assetCode` against actual outgoing Horizon payments within `dateRange`. + * + * Produces one finding per internal payment record: a payment with no + * matching on-chain counterpart surfaces as a `'critical'` finding. + */ + async reconcile( + accountId: string, + assetCode: string, + dateRange: ReconciliationDateRange, + ): Promise { + const expected = this.payments.filter( + (p) => p.payer === accountId && this.withinRange(p.timestamp, dateRange), + ); + + const actualAmounts = await this.fetchActualOutgoingAmounts(accountId, assetCode, dateRange); + + const findings: ReconciliationFinding[] = expected.map((payment) => { + const matchedAmount = this.consumeMatch(actualAmounts, payment.amount); + const discrepancy = payment.amount - matchedAmount; + return { + accountId, + assetCode, + expectedDelta: payment.amount, + actualDelta: matchedAmount, + discrepancy, + severity: this.classify(discrepancy), + reason: matchedAmount === 0n ? "No matching on-chain payment found" : undefined, + }; + }); + + return { + accountId, + assetCode, + dateRange, + findings, + isBalanced: findings.every((f) => f.discrepancy === 0n), + }; + } + + private withinRange(timestamp: number | undefined, range: ReconciliationDateRange): boolean { + return timestamp !== undefined && timestamp >= range.from && timestamp <= range.to; + } + + private classify(discrepancy: bigint): ReconciliationFinding["severity"] { + const abs = discrepancy < 0n ? -discrepancy : discrepancy; + if (abs === 0n || abs <= this.feeToleranceStroops) return "info"; + if (abs <= this.warningThresholdStroops) return "warning"; + return "critical"; + } + + /** + * Removes and returns the first matching amount from `pool` (exact match), + * or `0n` when no on-chain payment matches — used to avoid double-counting + * the same on-chain payment against multiple internal records. + */ + private consumeMatch(pool: bigint[], amount: bigint): bigint { + const index = pool.indexOf(amount); + if (index === -1) return 0n; + pool.splice(index, 1); + return amount; + } + + private async fetchActualOutgoingAmounts( + accountId: string, + assetCode: string, + dateRange: ReconciliationDateRange, + ): Promise { + const page = await this.horizonServer.payments().forAccount(accountId).limit(200).call(); + + return page.records + .filter((record): record is Horizon.ServerApi.PaymentOperationRecord => record.type === "payment") + .filter((record) => record.from === accountId) + .filter((record) => this.matchesAsset(record, assetCode)) + .filter((record) => { + const createdAtSeconds = Math.floor(new Date(record.created_at).getTime() / 1000); + return this.withinRange(createdAtSeconds, dateRange); + }) + .map((record) => xlmStringToStroops(record.amount)); + } + + private matchesAsset(record: Horizon.ServerApi.PaymentOperationRecord, assetCode: string): boolean { + if (assetCode === "native") return record.asset_type === "native"; + return record.asset_type !== "native" && record.asset_code === assetCode; + } +} + +/** Convert a Horizon balance/amount string ("1.0000000") to stroops (bigint). */ +function xlmStringToStroops(value: string): bigint { + const [whole = "0", frac = ""] = value.split("."); + return BigInt(whole) * 10_000_000n + BigInt(frac.padEnd(7, "0").slice(0, 7)); +} diff --git a/src/simulationDiff.ts b/src/simulationDiff.ts index d5afc9e..d040299 100644 --- a/src/simulationDiff.ts +++ b/src/simulationDiff.ts @@ -102,3 +102,124 @@ export function diffSimulations( }, }; } + +// --------------------------------------------------------------------------- +// compareSimulations — structured baseline-vs-revised comparator +// --------------------------------------------------------------------------- + +/** + * Structured comparison between a baseline and a revised simulation run. + * Positive deltas mean the revised simulation uses more resources. + */ +export interface SimulationComparison { + comparable: true; + /** Difference in CPU instructions (revised − baseline). */ + cpuDelta: bigint; + /** Difference in combined read+write bytes (revised − baseline). */ + memDelta: bigint; + /** Difference in minResourceFee, in stroops (revised − baseline). */ + feeDelta: bigint; + /** LedgerKey XDR (base64) present in the revised footprint but not the baseline. */ + footprintAdded: string[]; + /** LedgerKey XDR (base64) present in the baseline footprint but not the revised. */ + footprintRemoved: string[]; + /** True when the set of Soroban authorization entries differs between runs. */ + authChanged: boolean; +} + +/** Returned when either simulation cannot be compared (error or restore response). */ +export interface SimulationComparisonNotComparable { + comparable: false; + reason: string; +} + +function footprintKeys(response: SorobanRpc.Api.SimulateTransactionSuccessResponse): { + readOnly: string[]; + readWrite: string[]; +} { + try { + return { + readOnly: response.transactionData.getReadOnly().map((key) => key.toXDR("base64")), + readWrite: response.transactionData.getReadWrite().map((key) => key.toXDR("base64")), + }; + } catch { + return { readOnly: [], readWrite: [] }; + } +} + +function authFingerprint(response: SorobanRpc.Api.SimulateTransactionSuccessResponse): string[] { + const entries = response.result?.auth ?? []; + return entries.map((entry) => entry.toXDR("base64")).sort(); +} + +/** + * Compare two simulation runs (e.g. a baseline vs. a modified transaction) + * and report exactly what changed in resource consumption, footprint, and + * authorization entries. + * + * If either response is an error or a restore response, returns + * `{ comparable: false }` instead of throwing. + */ +export function compareSimulations( + baseline: SorobanRpc.Api.SimulateTransactionResponse, + revised: SorobanRpc.Api.SimulateTransactionResponse, +): SimulationComparison | SimulationComparisonNotComparable { + if (SorobanRpc.Api.isSimulationError(baseline)) { + return { comparable: false, reason: "baseline simulation returned an error" }; + } + if (SorobanRpc.Api.isSimulationError(revised)) { + return { comparable: false, reason: "revised simulation returned an error" }; + } + if (SorobanRpc.Api.isSimulationRestore(baseline)) { + return { comparable: false, reason: "baseline simulation requires state restore" }; + } + if (SorobanRpc.Api.isSimulationRestore(revised)) { + return { comparable: false, reason: "revised simulation requires state restore" }; + } + + const baselineRes = resourceStats(baseline); + const revisedRes = resourceStats(revised); + + const baselineFootprint = footprintKeys(baseline); + const revisedFootprint = footprintKeys(revised); + const baselineKeys = new Set([...baselineFootprint.readOnly, ...baselineFootprint.readWrite]); + const revisedKeys = new Set([...revisedFootprint.readOnly, ...revisedFootprint.readWrite]); + + const footprintAdded = [...revisedKeys].filter((key) => !baselineKeys.has(key)); + const footprintRemoved = [...baselineKeys].filter((key) => !revisedKeys.has(key)); + + const baselineAuth = authFingerprint(baseline); + const revisedAuth = authFingerprint(revised); + const authChanged = JSON.stringify(baselineAuth) !== JSON.stringify(revisedAuth); + + return { + comparable: true, + cpuDelta: revisedRes.cpuInstructions - baselineRes.cpuInstructions, + memDelta: + revisedRes.readBytes + revisedRes.writeBytes - (baselineRes.readBytes + baselineRes.writeBytes), + feeDelta: bigintFee(revised) - bigintFee(baseline), + footprintAdded, + footprintRemoved, + authChanged, + }; +} + +/** + * Produce a human-readable, multi-line summary of a {@link SimulationComparison} + * suitable for logging. + */ +export function formatDiffSummary(diff: SimulationComparison | SimulationComparisonNotComparable): string { + if (!diff.comparable) { + return `Simulations not comparable: ${diff.reason}`; + } + + const lines = [ + `CPU instructions: ${diff.cpuDelta >= 0n ? "+" : ""}${diff.cpuDelta}`, + `Memory (read+write bytes): ${diff.memDelta >= 0n ? "+" : ""}${diff.memDelta}`, + `Resource fee: ${diff.feeDelta >= 0n ? "+" : ""}${diff.feeDelta} stroops`, + `Footprint added: ${diff.footprintAdded.length}`, + `Footprint removed: ${diff.footprintRemoved.length}`, + `Auth entries changed: ${diff.authChanged}`, + ]; + return lines.join("\n"); +} diff --git a/src/validators/invoiceMetadataValidator.ts b/src/validators/invoiceMetadataValidator.ts new file mode 100644 index 0000000..706f622 --- /dev/null +++ b/src/validators/invoiceMetadataValidator.ts @@ -0,0 +1,62 @@ +/** + * Invoice metadata JSON Schema validator. + * + * Invoices carry an open-ended `metadata` object for integrator-defined + * fields (PO numbers, project codes, tax IDs, etc). Without validation, + * malformed metadata silently propagates through storage, rendering, and + * webhook delivery. This validator compiles an integrator-supplied JSON + * Schema once and reuses it for every create/update call. + */ + +import Ajv from "ajv"; +import type { ErrorObject, ValidateFunction } from "ajv"; +import { MetadataValidationError } from "../errors.js"; + +/** Result of validating a metadata payload against the registered schema. */ +export interface MetadataValidationResult { + valid: boolean; + errors?: ErrorObject[]; +} + +/** + * Validates invoice metadata against a JSON Schema registered at SDK + * construction time. When no schema is configured, `validate()` is a no-op + * that always reports success. + */ +export class InvoiceMetadataValidator { + private readonly validateFn: ValidateFunction | null; + private readonly throwOnInvalid: boolean; + + /** + * @param schema - JSON Schema object to validate metadata against. When + * omitted, validation is a no-op. + * @param throwOnInvalid - Whether {@link validate} throws + * {@link MetadataValidationError} on failure. Defaults to true. + */ + constructor(schema?: object, throwOnInvalid: boolean = true) { + this.validateFn = schema ? new Ajv({ allErrors: true }).compile(schema) : null; + this.throwOnInvalid = throwOnInvalid; + } + + /** + * Validate `metadata` against the registered schema. + * + * @throws {MetadataValidationError} When invalid and `throwOnInvalid` is true. + */ + validate(metadata: unknown): MetadataValidationResult { + if (!this.validateFn) { + return { valid: true }; + } + + const valid = this.validateFn(metadata); + if (valid) { + return { valid: true }; + } + + const errors = this.validateFn.errors ?? []; + if (this.throwOnInvalid) { + throw new MetadataValidationError(errors); + } + return { valid: false, errors }; + } +}