From e74ac3aac6d125c1789de34e6d3108502079fa97 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Sat, 17 Oct 2026 12:15:58 +0100 Subject: [PATCH 1/4] fix(contracts): full strategy response bounds, authenticated multisig approvals - vault: validate_strategy_response rejects the zero strategy address, asset tokens with more than 30 decimals and values outside 0..=MAX_STRATEGY_VALUE; harvested Korean yield is bounded and rebalance checks the target strategy's deposit delta with StrategyValidator (#1530) - permissions: MultiSignerValidator returns VaultError, uses Map-backed sets for duplicate / membership checks and calls require_auth on every approver; require_governance_threshold picks the signer set without auth so each approver signs once (#1531) - fix pre-existing compile errors in rounding.rs, total_assets, the wasm zero-address check and vault lib tests so the tests run Closes #1530 Closes #1531 --- contracts/share-price-math/src/rounding.rs | 4 +- contracts/vault/src/fuzz_math.rs | 4 +- contracts/vault/src/lib.rs | 57 +++--- contracts/vault/src/permissions.rs | 197 +++++++++++++++---- contracts/vault/src/strategy_registration.rs | 2 +- contracts/vault/src/strategy_validation.rs | 15 +- contracts/vault/src/test.rs | 71 +++++++ contracts/vault/src/timelock.rs | 2 + contracts/vault/src/whitelist.rs | 2 +- 9 files changed, 272 insertions(+), 82 deletions(-) diff --git a/contracts/share-price-math/src/rounding.rs b/contracts/share-price-math/src/rounding.rs index ebe8f5750..f64ca1036 100644 --- a/contracts/share-price-math/src/rounding.rs +++ b/contracts/share-price-math/src/rounding.rs @@ -3,8 +3,6 @@ //! The vault must never round in the user's favour: rounding always leaves the //! residual dust with the vault, so `deposit -> withdraw` round trips can never //! mint value out of nothing. - -use crate::MathError; //! //! | Operation | Direction | Rounding | //! |------------|-----------|----------| @@ -18,6 +16,8 @@ use crate::MathError; //! TypeScript SDK and the backend quote service can assert byte-identical //! results against this implementation. +use crate::MathError; + /// Rounding direction applied to the residual of an integer division. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Rounding { diff --git a/contracts/vault/src/fuzz_math.rs b/contracts/vault/src/fuzz_math.rs index a4275e86c..4b2048ec5 100644 --- a/contracts/vault/src/fuzz_math.rs +++ b/contracts/vault/src/fuzz_math.rs @@ -43,7 +43,9 @@ fn mint(env: &Env, token_addr: &Address, _admin: &Address, recipient: &Address, // ── pure math helpers (delegates to centralized math module) ────────────────── -use crate::math::{try_assets_to_shares as shares_for, try_shares_to_assets as assets_for}; +use crate::math::{ + try_assets_to_shares as shares_for, try_shares_to_assets as assets_for, MathError, +}; // ── property strategies ─────────────────────────────────────────────────────── diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index c808f6cfa..6726d567f 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -126,6 +126,7 @@ pub use risk_limits::ProtocolRiskLimits; use crate::strategy::StrategyClient; use crate::strategy_registration::{STATE_ACTIVE, STATE_PENDING, STATE_RETIRED}; +use crate::strategy_validation::StrategyValidator; use crate::upgrade::{ get_admin, get_pending_admin, get_storage_version, is_initialized, set_admin, set_initialized, set_pending_admin, set_storage_version, @@ -891,16 +892,17 @@ impl YieldVault { strategy_addr: &Address, expected_asset: &Address, ) -> Result { + StrategyValidator::validate_strategy_address(env, strategy_addr)?; let strategy_client = StrategyClient::new(env, strategy_addr); let actual_asset = strategy_client.asset(); if actual_asset != *expected_asset { return Err(VaultError::UnauthorizedStrategy); } + StrategyValidator::validate_decimals(token::Client::new(env, &actual_asset).decimals())?; + // Non-negative and at most `MAX_STRATEGY_VALUE` (issue #1530). let value = strategy_client.total_value(); - if value < 0 { - return Err(VaultError::InvalidAmount); - } + StrategyValidator::validate_total_value(value)?; Ok(value) } @@ -1379,17 +1381,12 @@ impl YieldVault { let token = Self::token(env.clone()); let price_data = oracle_client.get_price(&token, &token); let max_age = Self::oracle_heartbeat(env.clone()); - let last: Option = env - .storage() - .instance() - .get(&DataKeyExt::Risk(RiskExtKey::LastPx)); let last_price = Self::last_oracle_price(&env); oracle::OracleValidator::validate_price_data( &env, &price_data, max_age, Some(oracle::MAX_PRICE_DEVIATION_BPS), - last.as_ref(), last_price.as_ref(), ) .map_err(|_| VaultError::OracleValidationFailed)?; @@ -1560,6 +1557,7 @@ impl YieldVault { if harvested <= 0 { return Err(VaultError::InvalidYieldAmount); } + StrategyValidator::validate_total_value(harvested)?; // Issue #1230: Performance fee — redirect portion of yield above watermark let perf_enabled: bool = env @@ -1712,10 +1710,11 @@ impl YieldVault { /// During migration, accepts signatures from either active or previous signer sets. /// /// ### Parameters - /// * `approvals` - Vector of addresses that have approved the operation + /// * `approvals` - Vector of addresses that have approved the operation; + /// every approver must authorize this invocation (`require_auth`) /// /// ### Returns - /// Ok if threshold is met, panics otherwise + /// Ok if threshold is met, otherwise the `MultiSignerValidator` error pub fn require_governance_threshold( env: Env, approvals: Vec
, @@ -1734,30 +1733,19 @@ impl YieldVault { // During migration, accept both old and new signer sets let is_migration = current_time < migration_deadline && !config.previous_signers.is_empty(); - if is_migration { - let old_signers = config.previous_signers; - - // Try new signer set first, then fall back to old set - if permissions::MultiSignerValidator::verify_threshold(&signers, threshold, &approvals) - .is_ok() - { - return Ok(()); - } - if permissions::MultiSignerValidator::verify_threshold( - &old_signers, - threshold, - &approvals, + // During migration either set may satisfy the threshold. The set is + // chosen without auth checks so each approver signs exactly once. + let active = if is_migration + && permissions::MultiSignerValidator::check_threshold( + &env, &signers, threshold, &approvals, ) - .is_ok() - { - return Ok(()); - } - return Err(VaultError::GovernanceThresholdNotMet); + .is_err() + { + config.previous_signers } else { - permissions::MultiSignerValidator::verify_threshold(&signers, threshold, &approvals) - .map_err(|_| VaultError::GovernanceThresholdNotMet)?; - } - Ok(()) + signers + }; + permissions::MultiSignerValidator::verify_threshold(&env, &active, threshold, &approvals) } /// Clear migration state. Called after old signer set is no longer needed. @@ -3276,6 +3264,11 @@ impl YieldVault { // Verify invest slippage let to_strategy_val_after = Self::validate_strategy_response(&env, &to_strategy, &token_addr)?; + StrategyValidator::validate_deposit_result( + withdrawn_assets, + to_strategy_val_before, + to_strategy_val_after, + )?; let invested_value = to_strategy_val_after .checked_sub(to_strategy_val_before) .unwrap_or(0); diff --git a/contracts/vault/src/permissions.rs b/contracts/vault/src/permissions.rs index 80cf77995..5e177304f 100644 --- a/contracts/vault/src/permissions.rs +++ b/contracts/vault/src/permissions.rs @@ -13,7 +13,8 @@ //! - Migration-safe updates: old and new signer sets coexist during transition //! - Operations require threshold signatures from the current active set -use soroban_sdk::{Address, Vec}; +use crate::VaultError; +use soroban_sdk::{Address, Env, Map, Vec}; /// Verifies that the caller is the admin /// @@ -60,51 +61,75 @@ pub fn require_pauser_or_admin_auth(caller: &Address, admin: &Address, pauser: O pub struct MultiSignerValidator; impl MultiSignerValidator { - /// Verify that threshold signatures are satisfied. + /// Verify that threshold signatures are satisfied and that every approver + /// actually authorized this invocation. + /// + /// Runs [`Self::check_threshold`] first, then calls `require_auth` on each + /// approver, so an address listed in `approvals` without its signature + /// cannot count toward the threshold. /// /// ### Parameters /// * `signers` - Set of authorized signers for this operation /// * `threshold` - Number of required signatures (M of N) - /// * `approvals` - Vector of addresses that have approved (deduplicated, sorted) + /// * `approvals` - Addresses that have approved; each must sign the call /// - /// ### Returns - /// Ok if number of approvals >= threshold, Err otherwise + /// ### Errors + /// See [`Self::check_threshold`]; a missing signature aborts the call. pub fn verify_threshold( + env: &Env, signers: &Vec
, threshold: u32, approvals: &Vec
, - ) -> Result<(), &'static str> { - if threshold == 0 { - return Err("threshold must be > 0"); + ) -> Result<(), VaultError> { + Self::check_threshold(env, signers, threshold, approvals)?; + for approver in approvals.iter() { + approver.require_auth(); } - if threshold > signers.len() { - return Err("threshold exceeds signer set size"); + Ok(()) + } + + /// Validates `approvals` against `signers` and `threshold` without + /// checking signatures. + /// + /// ### Errors + /// * `VaultError::InvalidGovernanceThreshold` - threshold is 0 or exceeds the signer set + /// * `VaultError::DuplicateVote` - the same approver appears more than once + /// * `VaultError::GovernanceThresholdNotMet` - an approver is not a signer, + /// or there are fewer approvals than `threshold` + pub fn check_threshold( + env: &Env, + signers: &Vec
, + threshold: u32, + approvals: &Vec
, + ) -> Result<(), VaultError> { + if threshold == 0 || threshold > signers.len() { + return Err(VaultError::InvalidGovernanceThreshold); + } + + // Map-backed sets give logarithmic membership / duplicate checks + // instead of the previous O(n^2) pairwise scans. + let mut signer_set: Map = Map::new(env); + for signer in signers.iter() { + signer_set.set(signer, ()); } // Reject duplicate approvals before counting toward the threshold. // This prevents a single signer from replaying the same authorization // multiple times to satisfy an M-of-N requirement. - for i in 0..approvals.len() { - let first = approvals.get(i).unwrap(); - for j in (i + 1)..approvals.len() { - let second = approvals.get(j).unwrap(); - if first == second { - return Err("duplicate signer approval"); - } + let mut seen: Map = Map::new(env); + for approver in approvals.iter() { + if seen.contains_key(approver.clone()) { + return Err(VaultError::DuplicateVote); + } + if !signer_set.contains_key(approver.clone()) { + return Err(VaultError::GovernanceThresholdNotMet); } + seen.set(approver, ()); } if approvals.len() < threshold { - return Err("insufficient approvals"); + return Err(VaultError::GovernanceThresholdNotMet); } - - // Verify all approvers are in the signer set. - for approver in approvals.iter() { - if !signers.iter().any(|s| s == approver) { - return Err("unauthorized signer"); - } - } - Ok(()) } @@ -132,27 +157,72 @@ mod tests { // Actual enforcement is tested in lib.rs via role gating tests } + fn signers(env: &soroban_sdk::Env, n: usize) -> Vec
{ + let mut v = Vec::new(env); + for _ in 0..n { + v.push_back(Address::generate(env)); + } + v + } + #[test] fn test_threshold_valid_approvals() { let env = soroban_sdk::Env::default(); - let signers = Vec::from_array( - &env, - [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ], - ); + let signers = signers(&env, 3); let approvals = Vec::from_array(&env, [signers.get(0).unwrap(), signers.get(1).unwrap()]); - assert!(MultiSignerValidator::verify_threshold(&signers, 2, &approvals).is_ok()); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Ok(()) + ); } #[test] fn test_threshold_insufficient_approvals() { let env = soroban_sdk::Env::default(); - let signers = Vec::from_array(&env, [Address::generate(&env), Address::generate(&env)]); + let signers = signers(&env, 2); let approvals = Vec::from_array(&env, [signers.get(0).unwrap()]); - assert!(MultiSignerValidator::verify_threshold(&signers, 2, &approvals).is_err()); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Err(VaultError::GovernanceThresholdNotMet) + ); + } + + #[test] + fn test_threshold_rejects_invalid_threshold() { + let env = soroban_sdk::Env::default(); + let signers = signers(&env, 2); + let approvals = signers.clone(); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 0, &approvals), + Err(VaultError::InvalidGovernanceThreshold) + ); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 3, &approvals), + Err(VaultError::InvalidGovernanceThreshold) + ); + } + + #[test] + fn test_threshold_rejects_duplicate_approval() { + let env = soroban_sdk::Env::default(); + let signers = signers(&env, 3); + let first = signers.get(0).unwrap(); + let approvals = Vec::from_array(&env, [first.clone(), first]); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Err(VaultError::DuplicateVote) + ); + } + + #[test] + fn test_threshold_rejects_non_signer_approval() { + let env = soroban_sdk::Env::default(); + let signers = signers(&env, 3); + let approvals = Vec::from_array(&env, [signers.get(0).unwrap(), Address::generate(&env)]); + assert_eq!( + MultiSignerValidator::check_threshold(&env, &signers, 2, &approvals), + Err(VaultError::GovernanceThresholdNotMet) + ); } #[test] @@ -166,4 +236,57 @@ mod tests { let result = MultiSignerValidator::is_migration_active(Some(1), Some(2), 1000, 1500); assert!(!result); } + + /// Contract-level spoof rejection: approvals only count for signers that + /// actually signed the `require_governance_threshold` invocation. + #[test] + fn test_require_governance_threshold_needs_each_approver_signature() { + use crate::{YieldVault, YieldVaultClient}; + use soroban_sdk::testutils::{MockAuth, MockAuthInvoke}; + use soroban_sdk::IntoVal; + + let env = soroban_sdk::Env::default(); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + let admin = Address::generate(&env); + let signers = signers(&env, 3); + + env.mock_all_auths(); + vault.initialize(&admin, &Address::generate(&env)); + vault.set_governance_signers(&signers, &2, &0); + + let approvals = Vec::from_array(&env, [signers.get(0).unwrap(), signers.get(1).unwrap()]); + let args = (approvals.clone(),).into_val(&env); + let invoke = MockAuthInvoke { + contract: &vault_id, + fn_name: "require_governance_threshold", + args, + sub_invokes: &[], + }; + let first = signers.get(0).unwrap(); + let second = signers.get(1).unwrap(); + + // Only the first approver signed: the second is a spoofed entry. + env.mock_auths(&[MockAuth { + address: &first, + invoke: &invoke, + }]); + assert!(vault.try_require_governance_threshold(&approvals).is_err()); + + // Both approvers signed. + env.mock_auths(&[ + MockAuth { + address: &first, + invoke: &invoke, + }, + MockAuth { + address: &second, + invoke: &invoke, + }, + ]); + assert_eq!( + vault.try_require_governance_threshold(&approvals), + Ok(Ok(())) + ); + } } diff --git a/contracts/vault/src/strategy_registration.rs b/contracts/vault/src/strategy_registration.rs index 5f21689f3..5f7685c32 100644 --- a/contracts/vault/src/strategy_registration.rs +++ b/contracts/vault/src/strategy_registration.rs @@ -152,7 +152,7 @@ mod tests { use super::*; use crate::upgrade::set_admin; use crate::DataKey; - use soroban_sdk::{testutils::Address as _, Address, Env}; + use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, Env}; #[test] fn test_allowed_transitions() { diff --git a/contracts/vault/src/strategy_validation.rs b/contracts/vault/src/strategy_validation.rs index ec05a8513..12b80c0cd 100644 --- a/contracts/vault/src/strategy_validation.rs +++ b/contracts/vault/src/strategy_validation.rs @@ -4,12 +4,15 @@ //! cannot break vault logic through adversarial or corrupted payloads. use crate::VaultError; -use soroban_sdk::{xdr::ScAddress, Address, Env, TryFromVal}; +use soroban_sdk::{Address, Env}; /// Maximum allowed strategy total value to prevent overflow and unreasonable responses. /// Set conservatively to catch malicious responses while allowing legitimate vaults. pub const MAX_STRATEGY_VALUE: i128 = i128::MAX / 2; +/// Strkey of the all-zero contract address (`ScAddress::Contract([0; 32])`). +pub const ZERO_CONTRACT_STRKEY: &str = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + /// Strategy response validator providing comprehensive payload validation. pub struct StrategyValidator; @@ -154,13 +157,9 @@ impl StrategyValidator { // - Does it have the strategy interface? // For now, basic non-zero check. // - // The previous `Address::from_string(&String::new(_env))` could not - // build the SDK `String` type at all. Building the all-zero contract - // address from its `ScAddress` avoids strkey decoding, which rejects - // an empty string outright. - let zero = - Address::try_from_val(env, &ScAddress::Contract(soroban_sdk::xdr::Hash([0u8; 32]))) - .map_err(|_| VaultError::InvalidAmount)?; + // `Address: TryFromVal` is host-only (not available on + // wasm), so the all-zero contract address is parsed from its strkey. + let zero = Address::from_str(env, ZERO_CONTRACT_STRKEY); if strategy == &zero { return Err(VaultError::InvalidAmount); } diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index b6cec1be6..fcb6e32cf 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -653,6 +653,77 @@ fn test_strategy_response_rejects_negative_total_value() { assert_eq!(result, Err(VaultError::InvalidAmount)); } +/// Registers a `MaliciousStrategy` reporting `value` in `asset`. +fn seeded_strategy(env: &Env, vault_id: &Address, asset: &Address, value: i128) -> Address { + let strategy_id = env.register(MaliciousStrategy, ()); + env.as_contract(&strategy_id, || { + let storage = env.storage().instance(); + storage.set(&StrategyTestKey::Vault, vault_id); + storage.set(&StrategyTestKey::Asset, asset); + storage.set(&StrategyTestKey::Value, &value); + }); + strategy_id +} + +/// Issue #1530: strategy reads enforce the full `StrategyValidator` bounds. +#[test] +fn test_strategy_response_value_bounds() { + use crate::strategy_validation::MAX_STRATEGY_VALUE; + + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + let usdc = create_token(&env, &Address::generate(&env)); + vault.initialize(&Address::generate(&env), &usdc.address); + + for (value, expected) in [ + (0, Ok(0)), + (MAX_STRATEGY_VALUE, Ok(MAX_STRATEGY_VALUE)), + (MAX_STRATEGY_VALUE + 1, Err(VaultError::MathOverflow)), + (i128::MAX, Err(VaultError::MathOverflow)), + (-1, Err(VaultError::InvalidAmount)), + ] { + let strategy = seeded_strategy(&env, &vault_id, &usdc.address, value); + assert_eq!( + YieldVault::validate_strategy_response(&env, &strategy, &usdc.address), + expected, + "value {value}" + ); + } +} + +#[test] +fn test_strategy_response_rejects_zero_strategy_address() { + use crate::strategy_validation::ZERO_CONTRACT_STRKEY; + + let env = Env::default(); + let usdc = create_token(&env, &Address::generate(&env)); + let zero = Address::from_str(&env, ZERO_CONTRACT_STRKEY); + assert_eq!( + YieldVault::validate_strategy_response(&env, &zero, &usdc.address), + Err(VaultError::InvalidAmount) + ); +} + +#[test] +fn test_total_assets_rejects_strategy_value_above_max() { + use crate::strategy_validation::MAX_STRATEGY_VALUE; + + let env = Env::default(); + env.mock_all_auths(); + let vault_id = env.register(YieldVault, ()); + let vault = YieldVaultClient::new(&env, &vault_id); + let usdc = create_token(&env, &Address::generate(&env)); + vault.initialize(&Address::generate(&env), &usdc.address); + + let strategy = seeded_strategy(&env, &vault_id, &usdc.address, MAX_STRATEGY_VALUE + 1); + vault.whitelist_strategy(&strategy, &true); + vault.set_strategy(&strategy); + + assert!(vault.try_total_assets().is_err()); +} + #[test] #[should_panic] fn test_report_benji_yield_before_strategy_configured_panics_on_missing_key() { diff --git a/contracts/vault/src/timelock.rs b/contracts/vault/src/timelock.rs index 248ced83a..17ba3e8d7 100644 --- a/contracts/vault/src/timelock.rs +++ b/contracts/vault/src/timelock.rs @@ -61,6 +61,8 @@ pub fn is_valid_delay(delay_secs: u64) -> bool { #[cfg(test)] mod tests { + extern crate std; + use super::*; use proptest::prelude::*; use soroban_sdk::testutils::{Address as _, Ledger}; diff --git a/contracts/vault/src/whitelist.rs b/contracts/vault/src/whitelist.rs index d1191da7f..bb8145f2e 100644 --- a/contracts/vault/src/whitelist.rs +++ b/contracts/vault/src/whitelist.rs @@ -86,7 +86,7 @@ mod tests { }; use crate::upgrade::set_admin; use crate::DataKey; - use soroban_sdk::{testutils::Address as _, Address, Env}; + use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, Env}; #[test] fn test_whitelist_documentation_exists() {} From 51e27d5896f47eb1ab4c67d7eb719306f795ff8a Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Sat, 17 Oct 2026 12:19:19 +0100 Subject: [PATCH 2/4] fix(backend): repair corrupted sources so the app and test setup load Same repairs as the OpenAPI branch: decode base64-encoded healthProbe.ts and apiKeyAuth.ts, drop duplicated merge blocks in eventOutbox, idempotency, apiError, pagination, tracing, outboxPoller, vaultEndpoints and routes/vaults, fix the @yieldvault/api-schemas dependency name, sync the lockfile and remove a trailing comma in the root package.json. --- backend/package-lock.json | 90 +++- backend/package.json | 2 +- backend/src/eventOutbox.ts | 14 +- backend/src/healthProbe.ts | 144 +++++- backend/src/idempotency.ts | 685 --------------------------- backend/src/jobs/outboxPoller.ts | 3 +- backend/src/middleware/apiError.ts | 15 - backend/src/middleware/apiKeyAuth.ts | 422 ++++++++++++++++- backend/src/pagination.ts | 13 +- backend/src/routes/vaults.ts | 74 --- backend/src/tracing.ts | 2 - backend/src/vaultEndpoints.ts | 23 - package.json | 3 +- 13 files changed, 659 insertions(+), 831 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index 5344f3ff3..14876de3a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,11 +1,11 @@ { - "name": "yieldvault-backend", + "name": "yieldviault-backend", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "yieldvault-backend", + "name": "yieldviault-backend", "version": "1.0.0", "license": "MIT", "dependencies": { @@ -21,8 +21,9 @@ "@stellar/stellar-base": "^13.1.0", "@stellar/stellar-sdk": "^13.0.0", "@yieldvault/api-schemas": "file:../packages/api-schemas", + "compression": "^1.7.4", "cors": "^2.8.6", - "decimal.js": "^10.4.3", + "decimal.js": "^10.6.0", "dotenv": "^16.3.1", "express": "^4.18.2", "express-rate-limit": "^7.0.0", @@ -45,13 +46,14 @@ "@opentelemetry/resources": "^1.23.0", "@opentelemetry/sdk-node": "^0.50.0", "@opentelemetry/semantic-conventions": "^1.23.0", + "@types/compression": "^1.7.4", "@types/cors": "^2.8.19", "@types/express": "^4.17.17", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@types/pg": "^8.20.0", "@types/supertest": "^2.0.12", - "@types/swagger-jsdoc": "^6.0.2", + "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", @@ -4078,6 +4080,17 @@ "@types/node": "*" } }, + "node_modules/@types/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -5419,6 +5432,65 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -6637,6 +6709,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8444,6 +8517,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/backend/package.json b/backend/package.json index 39511349e..e99ceb44d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -60,7 +60,7 @@ "@prisma/instrumentation": "^7.8.0", "@stellar/stellar-base": "^13.1.0", "@stellar/stellar-sdk": "^13.0.0", - "@yieldviault/api-schemas": "file:../packages/api-schemas", + "@yieldvault/api-schemas": "file:../packages/api-schemas", "compression": "^1.7.4", "cors": "^2.8.6", "decimal.js": "^10.6.0", diff --git a/backend/src/eventOutbox.ts b/backend/src/eventOutbox.ts index 7b7fe922a..6be86ab88 100644 --- a/backend/src/eventOutbox.ts +++ b/backend/src/eventOutbox.ts @@ -15,6 +15,7 @@ */ import crypto from 'crypto'; +import { prisma } from './prisma'; import { logger } from './middleware/structuredLogging'; import { emitTransactionEvent, @@ -170,14 +171,9 @@ class EventOutboxService { * Returns the created outbox record. */ async writeEvent(input: OutboxWriteInput): Promise { - if (!prisma.eventOutbox) { - throw new Error('prisma.eventOutbox is not initialized. Ensure Prisma schema includes eventOutbox model.'); - } - assertEventOutboxModelAvailable(); const now = new Date(); const vaultId = input.vaultId ?? input.aggregateId; - const record = await prisma.eventOutbox.create({ const record = await getEventOutboxDelegate().create({ data: { id: `obx-${crypto.randomUUID()}`, @@ -222,8 +218,6 @@ class EventOutboxService { // We select the head-of-line event per vault to enforce strict // per-vault ordering (a vault's next event is only processed after // its predecessor has been relayed or dead-lettered). - const candidates = await prisma.eventOutbox.findMany({ - // 1. Find eligible entries: pending or failed entries whose lock is expired const candidates = await getEventOutboxDelegate().findMany({ where: { status: { in: ['pending', 'failed'] }, @@ -265,8 +259,6 @@ class EventOutboxService { // 2. Lock the claimed entries by updating lockedAt/lockedBy in bulk const candidateIds = orderedCandidates.map((e) => e.id); - await prisma.eventOutbox.updateMany({ - const candidateIds = candidates.map((e) => e.id); await getEventOutboxDelegate().updateMany({ where: { id: { in: candidateIds }, @@ -318,12 +310,11 @@ class EventOutboxService { payload, ); - await getEventOutboxDelegate().update({ // Do not write the mark-as-sent update after abort; the event will be // re-relayed after restart (at-least-once delivery). if (signal?.aborted) break; - await prisma.eventOutbox.update({ + await getEventOutboxDelegate().update({ where: { id: entry.id }, data: { status: 'relayed', @@ -488,7 +479,6 @@ class EventOutboxService { */ async replayOnStartup(): Promise { assertEventOutboxModelAvailable(); - const pendingCount = await prisma.eventOutbox.count({ const pendingCount = await getEventOutboxDelegate().count({ where: { status: { in: ['pending', 'failed'] } }, }); diff --git a/backend/src/healthProbe.ts b/backend/src/healthProbe.ts index 0dd9cb6d8..537d21b4b 100644 --- a/backend/src/healthProbe.ts +++ b/backend/src/healthProbe.ts @@ -1 +1,143 @@ -aW1wb3J0IHsgbG9nZ2VyIH0gZnJvbSAnLi9taWRkbGV3YXJlL3N0cnVjdHVyZWRMb2dnaW5nJzsKaW1wb3J0IHsgb2JzZXJ2ZUV4dGVybmFsRGVwZW5kZW5jeSB9IGZyb20gJy4vbWV0cmljcyc7CgpleHBvcnQgaW50ZXJmYWNlIERlcGVuZGVuY3lQcm9iZVN0YXRlIHsKICBzdGF0dXM6ICd1cCcgfCAnZG93bicgfCAnZGVncmFkZWQnOwogIGxhdGVuY3lNczogbnVtYmVyIHwgbnVsbDsKICBsYXN0Q2hlY2tlZEF0OiBzdHJpbmcgfCBudWxsOwogIGxhc3RFcnJvcjogc3RyaW5nIHwgbnVsbDsKICBsYXN0RXJyb3JBdDogc3RyaW5nIHwgbnVsbDsKICBjb25zZWN1dGl2ZUZhaWx1cmVzOiBudW1iZXI7CiAgb3B0aW9uYWw/OiBib29sZWFuOwp9CgpleHBvcnQgdHlwZSBEZXBlbmRlbmN5TmFtZSA9ICdkYXRhYmFzZScgfCAnY2FjaGUnIHwgJ3N0ZWxsYXJS cGMnIHwgJ3ByaXNtYScgfCAncXVldWUnIHwgJ2luZGV4ZXInIHwgJ3JlZGlzJzsKCnR5cGUgUHJvYmVGdW5jdGlvbiA9ICgpID0+IFByb21pc2U8J3VwJyB8ICdkb3duJz47CgppbnRlcmZhY2UgUHJvYmVSZWdpc3RyYXRpb24gewogIG5hbWU6IERlcGVuZGVuY3lOYW1lOwogIHByb2JlOiBQcm9iZUZ1bmN0aW9uOwogIG9wdGlvbmFsPzogYm9vbGVhbjsKfQoKY2xhc3MgSGVhbHRoUHJvYmVTZXJ2aWNlIHsKICBwcml2YXRlIHByb2JlcyA9IG5ldyBNYXA8RGVwZW5kZW5jeU5hbWUsIFByb2JlUmVnaXN0cmF0aW9uPigpOwogIHByaXZhdGUgc3RhdGVzID0gbmV3IE1hcDxEZXBlbmRlbmN5TmFtZSwgRGVwZW5kZW5jeVByb2JlU3RhdGU+KCk7CgogIHJlZ2lzdGVyKG5hbWU6IERlcGVuZGVuY3lOYW1lLCBwcm9iZTogUHJvYmVGdW5jdGlvbiwgb3B0aW9ucz86IHsgb3B0aW9uYWw/OiBib29sZWFuIH0pOiB2b2lkIHsKICAgIHRoaXMucHJvYmVzLnNldChuYW1lLCB7IG5hbWUsIHByb2JlLCBvcHRpb25hbDogb3B0aW9ucz8ub3B0aW9uYWwgfSk7CiAgICBpZiAoIXRoaXMuc3RhdGVzLmhhcyhuYW1lKSkgewogICAgICB0aGlzLnN0YXRlcy5zZXQobmFtZSwgewogICAgICAgIHN0YXR1czogJ3VwJywKICAgICAgICBsYXRlbmN5TXM6IG51bGwsCiAgICAgICAgbGFzdENoZWNrZWRBdDogbnVsbCwKICAgICAgICBsYXN0RXJyb3I6IG51bGwsCiAgICAgICAgbGFzdEVycm9yQXQ6IG51bGwsCiAgICAgICAgY29uc2VjdXRpdmVGYWlsdXJlczogMCwKICAgICAgICBvcHRpb25hbDogb3B0aW9ucz8ub3B0aW9uYWwsCiAgICAgIH0pOwogICAgfQogIH0KCiAgYXN5bmMgY2hlY2tEZXBlbmRlbmN5KG5hbWU6IERlcGVuZGVuY3lOYW1lKTogUHJvbWlzZTxEZXBlbmRlbmN5UHJvYmVTdGF0ZT4gewogICAgY29uc3QgcmVnaXN0cmF0aW9uID0gdGhpcy5wcm9iZXMuZ2V0KG5hbWUpOwogICAgY29uc3Qgc3RhdGUgPSB0aGlzLnN0YXRlcy5nZXQobmFtZSkgPz8gdGhpcy5jcmVhdGVEZWZhdWx0U3RhdGUoKTsKCiAgICBpZiAoIXJlZ2lzdHJhdGlvbikgewogICAgICBzdGF0ZS5zdGF0dXMgPSAnZG93bic7CiAgICAgIHN0YXRlLmxhc3RFcnJvciA9ICdQcm9iZSBub3QgcmVnaXN0ZXJlZCc7CiAgICAgIHN0YXRlLmxhc3RDaGVja2VkQXQgPSBuZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCk7CiAgICAgIHJldHVybiBzdGF0ZTsKICAgIH0KCiAgICBjb25zdCBzdGFydE1zID0gRGF0ZS5ub3coKTsKCiAgICB0cnkgewogICAgICBjb25zdCByZXN1bHQgPSBhd2FpdCByZWdpc3RyYXRpb24ucHJvYmUoKTsKICAgICAgY29uc3QgbGF0ZW5jeU1zID0gRGF0ZS5ub3coKSAtIHN0YXJ0TXM7CiAgICAgIG9ic2VydmVFeHRlcm5hbERlcGVuZGVuY3kobmFtZSwgJ2hlYWx0aF9wcm9iZScsIGxhdGVuY3lNcywgcmVzdWx0ID09PSAndXAnID8gJ3N1Y2Nlc3MnIDogJ2ZhaWx1cmUnKTsKCiAgICAgIHN0YXRlLnN0YXR1cyA9IHJlc3VsdDsKICAgICAgc3RhdGUubGF0ZW5jeU1zID0gbGF0ZW5jeU1zOwogICAgICBzdGF0ZS5sYXN0Q2hlY2tlZEF0ID0gbmV3IERhdGUoKS50b0lTT1N0cmluZygpOwoKICAgICAgaWYgKHJlc3VsdCA9PT0gJ3VwJykgewogICAgICAgIHN0YXRlLmNvbnNlY3V0aXZlRmFpbHVyZXMgPSAwOwogICAgICB9IGVsc2UgewogICAgICAgIHN0YXRlLmNvbnNlY3V0aXZlRmFpbHVyZXMgKz0gMTsKICAgICAgICBzdGF0ZS5sYXN0RXJyb3IgPSAnUHJvYmUgcmV0dXJuZWQgZG93bic7CiAgICAgICAgc3RhdGUubGFzdEVycm9yQXQgPSBuZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCk7CiAgICAgIH0KICAgIH0gY2F0Y2ggKGVycm9yKSB7CiAgICAgIGNvbnN0IGxhdGVuY3lNcyA9IERhdGUubm93KCkgLSBzdGFydE1zOwogICAgICBvYnNlcnZlRXh0ZXJuYWxEZXBlbmRlbmN5KG5hbWUsICdoZWFsdGhfcHJvYmUnLCBsYXRlbmN5TXMsICdmYWlsdXJlJyk7CiAgICAgIHN0YXRlLnN0YXR1cyA9ICdkb3duJzsKICAgICAgc3RhdGUubGF0ZW5jeU1zID0gbGF0ZW5jeU1zOwogICAgICBzdGF0ZS5sYXN0Q2hlY2tlZEF0ID0gbmV3IERhdGUoKS50b0lTT1N0cmluZygpOwogICAgICBzdGF0ZS5sYXN0RXJyb3IgPSBlcnJvciBpbnN0YW5jZW9mIEVycm9yID8gZXJyb3IubWVzc2FnZSA6IFN0cmluZyhlcnJvcik7CiAgICAgIHN0YXRlLmxhc3RFcnJvckF0ID0gbmV3IERhdGUoKS50b0lTT1N0cmluZygpOwogICAgICBzdGF0ZS5jb25zZWN1dGl2ZUZhaWx1cmVzICs9IDE7CiAgICB9CgogICAgaWYgKHN0YXRlLmNvbnNlY3V0aXZlRmFpbHVyZXMgPiAwICYmIHN0YXRlLmNvbnNlY3V0aXZlRmFpbHVyZXMgPCAzICYmIHN0YXRlLnN0YXR1cyAhPT0gJ3VwJykgewogICAgICBzdGF0ZS5zdGF0dXMgPSAnZGVncmFkZWQnOwogICAgfQoKICAgIHRoaXMuc3RhdGVzLnNldChuYW1lLCBzdGF0ZSk7CiAgICByZXR1cm4geyAuLi5zdGF0ZSB9OwogIH0KCiAgYXN5bmMgY2hlY2tBbGwoKTogUHJvbWlzZTxSZWNvcmQ8RGVwZW5kZW5jeU5hbWUsIERlcGVuZGVuY3lQcm9iZVN0YXRlPj4gewogICAgY29uc3QgbmFtZXMgPSBBcnJheS5mcm9tKHRoaXMucHJvYmVzLmtleXMoKSk7CiAgICBjb25zdCByZXN1bHRzID0gYXdhaXQgUHJvbWlzZS5hbGwobmFtZXMubWFwKChuYW1lKSA9PiB0aGlzLmNoZWNrRGVwZW5kZW5jeShuYW1lKSkpOwoKICAgIGNvbnN0IHJlY29yZDogUGFydGlhbDxSZWNvcmQ8RGVwZW5kZW5jeU5hbWUsIERlcGVuZGVuY3lQcm9iZVN0YXRlPj4gPSB7fTsKICAgIGZvciAobGV0IGkgPSAwOyBpIDwgbmFtZXMubGVuZ3RoOyBpKyspIHsKICAgICAgcmVjb3JkW25hbWVzW2ldXSA9IHJlc3VsdHNbaV07CiAgICB9CgogICAgcmV0dXJuIHJlY29yZCBhcyBSZWNvcmQ8RGVwZW5kZW5jeU5hbWUsIERlcGVuZGVuY3lQcm9iZVN0YXRlPjsKICB9CgogIGdldExhc3RTdGF0ZShuYW1lOiBEZXBlbmRlbmN5TmFtZSk6IERlcGVuZGVuY3lQcm9iZVN0YXRlIHwgbnVsbCB7CiAgICByZXR1cm4gdGhpcy5zdGF0ZXMuZ2V0KG5hbWUpID8/IG51bGw7CiAgfQoKICBnZXRBbGxTdGF0ZXMoKTogUmVjb3JkPHN0cmluZywgRGVwZW5kZW5jeVByb2JlU3RhdGU+IHsKICAgIGNvbnN0IHJlc3VsdDogUmVjb3JkPHN0cmluZywgRGVwZW5kZW5jeVByb2JlU3RhdGU+ID0ge307CiAgICBmb3IgKGNvbnN0IFtuYW1lLCBzdGF0ZV0gb2YgdGhpcy5zdGF0ZXMpIHsKICAgICAgcmVzdWx0W25hbWVdID0geyAuLi5zdGF0ZSB9OwogICAgfQogICAgcmV0dXJuIHJlc3VsdDsKICB9CgogIGlzSGVhbHRoeSgpOiBib29sZWFuIHsKICAgIGZvciAoY29uc3QgW25hbWUsIHN0YXRlXSBvZiB0aGlzLnN0YXRlcykgewogICAgICBpZiAoc3RhdGUuc3RhdHVzID09PSAnZG93bicpIHsKICAgICAgICBjb25zdCByZWdpc3RyYXRpb24gPSB0aGlzLnByb2Jlcy5nZXQobmFtZSk7CiAgICAgICAgaWYgKHJlZ2lzdHJhdGlvbj8ub3B0aW9uYWwpIGNvbnRpbnVlOwogICAgICAgIHJldHVybiBmYWxzZTsKICAgICAgfQogICAgfQogICAgcmV0dXJuIHRydWU7CiAgfQoKICBwcml2YXRlIGNyZWF0ZURlZmF1bHRTdGF0ZSgpOiBEZXBlbmRlbmN5UHJvYmVTdGF0ZSB7CiAgICByZXR1cm4gewogICAgICBzdGF0dXM6ICd1cCcsCiAgICAgIGxhdGVuY3lNczogbnVsbCwKICAgICAgbGFzdENoZWNrZWRBdDogbnVsbCwKICAgICAgbGFzdEVycm9yOiBudWxsLAogICAgICBsYXN0RXJyb3JBdDogbnVsbCwKICAgICAgY29uc2VjdXRpdmVGYWlsdXJlczogMCwKICAgIH07CiAgfQoKICBjbGVhcigpOiB2b2lkIHsKICAgIHRoaXMucHJvYmVzLmNsZWFyKCk7CiAgICB0aGlzLnN0YXRlcy5jbGVhcigpOwogIH0KfQoKZXhwb3J0IGNvbnN0IGhlYWx0aFByb2JlU2VydmljZSA9IG5ldyBIZWFsdGhQcm9iZVNlcnZpY2UoKTsK \ No newline at end of file +import { logger } from './middleware/structuredLogging'; +import { observeExternalDependency } from './metrics'; + +export interface DependencyProbeState { + status: 'up' | 'down' | 'degraded'; + latencyMs: number | null; + lastCheckedAt: string | null; + lastError: string | null; + lastErrorAt: string | null; + consecutiveFailures: number; + optional?: boolean; +} + +export type DependencyName = 'database' | 'cache' | 'stellarRpc' | 'prisma' | 'queue' | 'indexer' | 'redis'; + +type ProbeFunction = () => Promise<'up' | 'down'>; + +interface ProbeRegistration { + name: DependencyName; + probe: ProbeFunction; + optional?: boolean; +} + +class HealthProbeService { + private probes = new Map(); + private states = new Map(); + + register(name: DependencyName, probe: ProbeFunction, options?: { optional?: boolean }): void { + this.probes.set(name, { name, probe, optional: options?.optional }); + if (!this.states.has(name)) { + this.states.set(name, { + status: 'up', + latencyMs: null, + lastCheckedAt: null, + lastError: null, + lastErrorAt: null, + consecutiveFailures: 0, + optional: options?.optional, + }); + } + } + + async checkDependency(name: DependencyName): Promise { + const registration = this.probes.get(name); + const state = this.states.get(name) ?? this.createDefaultState(); + + if (!registration) { + state.status = 'down'; + state.lastError = 'Probe not registered'; + state.lastCheckedAt = new Date().toISOString(); + return state; + } + + const startMs = Date.now(); + + try { + const result = await registration.probe(); + const latencyMs = Date.now() - startMs; + observeExternalDependency(name, 'health_probe', latencyMs, result === 'up' ? 'success' : 'failure'); + + state.status = result; + state.latencyMs = latencyMs; + state.lastCheckedAt = new Date().toISOString(); + + if (result === 'up') { + state.consecutiveFailures = 0; + } else { + state.consecutiveFailures += 1; + state.lastError = 'Probe returned down'; + state.lastErrorAt = new Date().toISOString(); + } + } catch (error) { + const latencyMs = Date.now() - startMs; + observeExternalDependency(name, 'health_probe', latencyMs, 'failure'); + state.status = 'down'; + state.latencyMs = latencyMs; + state.lastCheckedAt = new Date().toISOString(); + state.lastError = error instanceof Error ? error.message : String(error); + state.lastErrorAt = new Date().toISOString(); + state.consecutiveFailures += 1; + } + + if (state.consecutiveFailures > 0 && state.consecutiveFailures < 3 && state.status !== 'up') { + state.status = 'degraded'; + } + + this.states.set(name, state); + return { ...state }; + } + + async checkAll(): Promise> { + const names = Array.from(this.probes.keys()); + const results = await Promise.all(names.map((name) => this.checkDependency(name))); + + const record: Partial> = {}; + for (let i = 0; i < names.length; i++) { + record[names[i]] = results[i]; + } + + return record as Record; + } + + getLastState(name: DependencyName): DependencyProbeState | null { + return this.states.get(name) ?? null; + } + + getAllStates(): Record { + const result: Record = {}; + for (const [name, state] of this.states) { + result[name] = { ...state }; + } + return result; + } + + isHealthy(): boolean { + for (const [name, state] of this.states) { + if (state.status === 'down') { + const registration = this.probes.get(name); + if (registration?.optional) continue; + return false; + } + } + return true; + } + + private createDefaultState(): DependencyProbeState { + return { + status: 'up', + latencyMs: null, + lastCheckedAt: null, + lastError: null, + lastErrorAt: null, + consecutiveFailures: 0, + }; + } + + clear(): void { + this.probes.clear(); + this.states.clear(); + } +} + +export const healthProbeService = new HealthProbeService(); diff --git a/backend/src/idempotency.ts b/backend/src/idempotency.ts index cf111e0d6..fcc20199c 100644 --- a/backend/src/idempotency.ts +++ b/backend/src/idempotency.ts @@ -22,13 +22,9 @@ */ import crypto from 'crypto'; -import NodeCache from 'node-cache'; import { prisma } from './prisma'; -import { redisClientManager } from './rateLimiter'; import { logger } from './middleware/structuredLogging'; import type { Request, Response, NextFunction } from 'express'; -import NodeCache from 'node-cache'; -import { redisClientManager } from './rateLimiter'; /** * The replay cache used by money-moving operations (deposits, withdrawals, @@ -396,306 +392,6 @@ declare global { } } -// ─── Error Classes ────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -// ─── Internal Types ───────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyKeyMetadata; -} - -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -// ─── Redis key prefix ─────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Fingerprint helper ───────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - -// ─── IdempotencyStore ─────────────────────────────────────────────────── - -export class IdempotencyStore { - private readonly completedResponses: NodeCache; - private readonly pendingResponses = new Map>(); - - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.completedResponses = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.completedResponses.on('expired', (_key: string, _value: unknown) => { - this._evictions++; - }); - } - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - let completed = await this.redisGet(key); - if (!completed) { - completed = this.completedResponses.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - await this.redisSet(key, completed); - this.completedResponses.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - const metadata: IdempotencyKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - await this.redisSet(key, stored); - this.completedResponses.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.completedResponses.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.completedResponses.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - async deleteKey(key: string): Promise { - const deletedLocal = this.completedResponses.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - clear(): void { - const count = this.completedResponses.keys().length + this.pendingResponses.size; - this._evictions += count; - this.completedResponses.flushAll(); - this.pendingResponses.clear(); - } - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.completedResponses.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.completedResponses.keys()) { - const entry = this.completedResponses.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.completedResponses.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - } -} - -// ─── Singleton ────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - // ─── Cleanup ──────────────────────────────────────────────────────────────── /** @@ -739,384 +435,3 @@ export function startIdempotencyCleanupTask(intervalMs = 3600000): NodeJS.Timer }); }, intervalMs); } - -// ─── In-flight store (Redis + NodeCache) ───────────────────────────────────── -// -// Issue #811: multi-instance deployments lost idempotency guarantees on pod -// recycle because responses were stored only in-process. This revision -// persists completed responses to Redis using SET … EX so all replicas share -// the same store, falling back to NodeCache when Redis is unavailable -// (fail-open, with a warning). -// -// The Prisma helpers above (enforceIdempotency / *IdempotencyRecord) are the -// newer, DB-backed design but are not yet wired into any route. The mutation -// endpoints (deposit/withdrawal/transfer), the admin key routes and the -// retention sweeper all still call the store below, so both surfaces live here -// until those call sites are migrated. Restoring this store is what keeps -// `idempotencyStore`, `IdempotencyStore` and `IdempotencyConflictError` -// resolvable for those consumers. -// ─── Response Store (Redis + NodeCache) ───────────────────────────────────── -// -// Shared response store used by vault endpoints and the transfer orchestrator. -// Completed responses are persisted to Redis (when available) with a NodeCache -// in-process fallback. Retention sweeps live in idempotencyRetention.ts. - -// ─── Public Types ───────────────────────────────────────────────────────────── - -export interface IdempotentOperationResult { - statusCode: number; - body: T; -} - -/** Metadata attached to every idempotency key entry. */ -export interface IdempotencyEntryMetadata { -export interface IdempotencyStoreKeyMetadata { - /** ISO-8601 timestamp when the key was first stored. */ - createdAt: string; - /** ISO-8601 timestamp of the most recent access (read or write). */ - lastAccessedAt: string; - /** Number of times this key has been replayed (returned cached result). */ - replayCount: number; - /** Current state of the entry. */ - status: 'pending' | 'completed'; -} - -/** Summary returned by GET /admin/idempotency/keys. */ -export interface IdempotencyKeyInfo { - key: string; - metadata: IdempotencyEntryMetadata; - metadata: IdempotencyStoreKeyMetadata; -} - -/** Snapshot of store-wide observability counters. */ -export interface IdempotencyMetrics { - hits: number; - conflicts: number; - evictions: number; - activeKeys: number; - pendingKeys: number; -} - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyEntryMetadata; -// ─── Internal Types ─────────────────────────────────────────────────────────── - -interface StoredResponse extends IdempotentOperationResult { - fingerprint: string; - metadata: IdempotencyStoreKeyMetadata; -} - -interface PendingOperation { - fingerprint: string; - promise: Promise>; - metadata: IdempotencyEntryMetadata; -} - - metadata: IdempotencyStoreKeyMetadata; -} - -// ─── Errors ─────────────────────────────────────────────────────────────────── - -export class IdempotencyConflictError extends Error { - constructor(message = 'Idempotency key already used for a different request body') { - super(message); - this.name = 'IdempotencyConflictError'; - } -} - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Redis key prefix ───────────────────────────────────────────────────────── - -const REDIS_PREFIX = 'idempotency:'; - -// ─── Store ──────────────────────────────────────────────────────────────────── - -export class IdempotencyStore { - /** Fallback in-process store used when Redis is unavailable. */ - private readonly localCache: NodeCache; - private readonly pendingResponses = new Map>(); - - // Observability counters - private _hits = 0; - private _conflicts = 0; - private _evictions = 0; - - constructor(private readonly ttlMs = 24 * 60 * 60 * 1000) { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - this.localCache = new NodeCache({ stdTTL: ttlSeconds, checkperiod: ttlSeconds }); - this.localCache.on('expired', () => { this._evictions++; }); - } - - // ─── Redis helpers ───────────────────────────────────────────────────────── - - private redisKey(key: string): string { - return `${REDIS_PREFIX}${key}`; - } - - private get redis() { - const client = redisClientManager.getClient(); - return redisClientManager.isReady() && client ? client : null; - } - - private async redisGet(key: string): Promise | null> { - const r = this.redis; - if (!r) return null; - try { - const raw = await r.get(this.redisKey(key)); - return raw ? (JSON.parse(raw) as StoredResponse) : null; - } catch { - return null; - } - } - - private async redisSet(key: string, value: StoredResponse): Promise { - const r = this.redis; - if (!r) return; - try { - const ttlSeconds = Math.max(1, Math.ceil(this.ttlMs / 1000)); - await r.set(this.redisKey(key), JSON.stringify(value), 'EX', ttlSeconds); - } catch (err) { - console.log(JSON.stringify({ level: 'warn', event: 'idempotency_redis_write_fail', key, reason: (err as Error).message })); - } - } - - private async redisDel(key: string): Promise { - const r = this.redis; - if (!r) return false; - try { - return (await r.del(this.redisKey(key))) > 0; - } catch { - return false; - } - } - - // ─── Core execute ────────────────────────────────────────────────────────── - - async execute( - key: string, - fingerprint: string, - operation: () => Promise> - ): Promise<{ result: IdempotentOperationResult; replayed: boolean }> { - const now = new Date().toISOString(); - - // 1. Check Redis first, then local cache - let completed = await this.redisGet(key); - if (!completed) { - completed = this.localCache.get>(key) ?? null; - } - - if (completed) { - if (completed.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - completed.metadata.lastAccessedAt = now; - completed.metadata.replayCount++; - // Refresh in both backends; errors are non-fatal - await this.redisSet(key, completed); - this.localCache.set(key, completed); - return { result: { statusCode: completed.statusCode, body: completed.body }, replayed: true }; - } - - // 2. Currently in-flight - const pendingOperation = this.pendingResponses.get(key) as PendingOperation | undefined; - if (pendingOperation) { - if (pendingOperation.fingerprint !== fingerprint) { - this._conflicts++; - throw new IdempotencyConflictError(); - } - this._hits++; - pendingOperation.metadata.lastAccessedAt = now; - pendingOperation.metadata.replayCount++; - const replayed = await pendingOperation.promise; - return { result: { statusCode: replayed.statusCode, body: replayed.body }, replayed: true }; - } - - // 3. First execution - const metadata: IdempotencyEntryMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - const metadata: IdempotencyStoreKeyMetadata = { createdAt: now, lastAccessedAt: now, replayCount: 0, status: 'pending' }; - - const operationPromise = (async () => { - const result = await operation(); - const stored: StoredResponse = { - ...result, - fingerprint, - metadata: { ...metadata, status: 'completed', lastAccessedAt: new Date().toISOString() }, - }; - // Persist to Redis (primary) and local cache (fallback/fast-path) - await this.redisSet(key, stored); - this.localCache.set(key, stored, this.ttlMs / 1000); - return stored; - })(); - - this.pendingResponses.set(key, { fingerprint, promise: operationPromise, metadata }); - - try { - const stored = await operationPromise; - return { result: { statusCode: stored.statusCode, body: stored.body }, replayed: false }; - } finally { - this.pendingResponses.delete(key); - } - } - - // ─── Inspection ──────────────────────────────────────────────────────────── - - inspectKeys(prefix?: string): IdempotencyKeyInfo[] { - const results: IdempotencyKeyInfo[] = []; - for (const key of this.localCache.keys()) { - if (prefix && !key.startsWith(prefix)) continue; - const entry = this.localCache.get>(key); - if (entry) results.push({ key, metadata: { ...entry.metadata } }); - } - for (const [key, pending] of this.pendingResponses.entries()) { - if (prefix && !key.startsWith(prefix)) continue; - if (!results.some((r) => r.key === key)) { - results.push({ key, metadata: { ...pending.metadata } }); - } - } - return results; - } - - // ─── Targeted deletion ───────────────────────────────────────────────────── - - async deleteKey(key: string): Promise { - const deletedLocal = this.localCache.del(key) > 0; - const deletedPending = this.pendingResponses.delete(key); - const deletedRedis = await this.redisDel(key); - if (deletedLocal || deletedPending || deletedRedis) { - this._evictions++; - return true; - } - return false; - } - - // ─── Global clear (admin only) ───────────────────────────────────────────── - - clear(): void { - const count = this.localCache.keys().length + this.pendingResponses.size; - this._evictions += count; - this.localCache.flushAll(); - this.pendingResponses.clear(); - // Note: Redis keys are prefixed with REDIS_PREFIX; a full Redis FLUSHDB is intentionally - // not issued here to avoid clearing unrelated keys. Use deleteKey() per-key when needed. - } - - // ─── Observability ───────────────────────────────────────────────────────── - - getMetrics(): IdempotencyMetrics { - return { - hits: this._hits, - conflicts: this._conflicts, - evictions: this._evictions, - activeKeys: this.localCache.keys().length, - pendingKeys: this.pendingResponses.size, - }; - } - - // ─── Retention cleanup ───────────────────────────────────────────────────── - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number }> { - /** - * Removes entries older than `retentionMs` from the local cache and Redis. - * With `dryRun`, only counts the entries that would be removed: nothing is - * deleted and the eviction counter is left untouched (Issue #1375). - */ - - async pruneStaleKeys( - retentionMs: number, - dryRun = false, - ): Promise<{ pruned: number; localPruned: number; redisPruned: number; dryRun: boolean }> { - const cutoff = Date.now() - retentionMs; - let localPruned = 0; - let redisPruned = 0; - - for (const key of this.localCache.keys()) { - const entry = this.localCache.get>(key); - if (!entry) continue; - const createdAt = Date.parse(entry.metadata.createdAt); - if (Number.isNaN(createdAt) || createdAt >= cutoff) continue; - if (!dryRun) { - this.localCache.del(key); - this._evictions++; - } - localPruned++; - } - - const r = this.redis; - if (r) { - let cursor = '0'; - do { - const [nextCursor, keys] = await r.scan(cursor, 'MATCH', `${REDIS_PREFIX}*`, 'COUNT', 100); - cursor = nextCursor; - for (const redisKey of keys) { - try { - const raw = await r.get(redisKey); - if (!raw) continue; - const entry = JSON.parse(raw) as StoredResponse; - const createdAt = Date.parse(entry.metadata?.createdAt ?? ''); - const ttl = await r.ttl(redisKey); - const isStale = (!Number.isNaN(createdAt) && createdAt < cutoff) || ttl === 0; - if (!isStale) continue; - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } catch { - if (!dryRun) { - await r.del(redisKey); - this._evictions++; - } - redisPruned++; - } - } - } while (cursor !== '0'); - } - - return { pruned: localPruned + redisPruned, localPruned, redisPruned }; - return { pruned: localPruned + redisPruned, localPruned, redisPruned, dryRun }; - } -} - -// ─── Singleton ──────────────────────────────────────────────────────────────── - -export const idempotencyStore = new IdempotencyStore( - parseInt(process.env.IDEMPOTENCY_KEY_TTL_MS || '86400000', 10) -); - -// ─── Fingerprint helper ─────────────────────────────────────────────────────── - -export function getIdempotencyHashThreshold(): number { - return parseInt(process.env.IDEMPOTENCY_HASH_THRESHOLD_BYTES || '4096', 10); -} - -export function buildIdempotencyFingerprint(payload: unknown): string { - const stable = stableStringify(payload); - const byteLength = Buffer.byteLength(stable, 'utf-8'); - if (byteLength > getIdempotencyHashThreshold()) { - return `hashv1:${crypto.createHash('sha256').update(stable).digest('hex')}`; - } - return stable; -} - -function stableStringify(value: unknown): string { - if (value === null) return 'null'; - if (value instanceof Date) return JSON.stringify(value.toISOString()); - if (typeof value !== 'object') return JSON.stringify(value); - - if (Array.isArray(value)) { - return `[${value.map((item) => stableStringify(item)).join(',')}]`; - } - - const record = value as Record; - const keys = Object.keys(record).sort(); - const serialized = keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); - return `{${serialized.join(',')}}`; -} - diff --git a/backend/src/jobs/outboxPoller.ts b/backend/src/jobs/outboxPoller.ts index d89aeaaef..67b332d14 100644 --- a/backend/src/jobs/outboxPoller.ts +++ b/backend/src/jobs/outboxPoller.ts @@ -50,7 +50,8 @@ export function resolveVaultId(event: EventOutbox): string { // Malformed payloads: fall back to the aggregate id below. } - return event.aggregateId || GLOBAL_VAULT_KEY:} + return event.aggregateId || GLOBAL_VAULT_KEY; +} /** * Poller that delivers outbox events while preserving per-vault ordering. diff --git a/backend/src/middleware/apiError.ts b/backend/src/middleware/apiError.ts index 8c397b3b0..8ef150e60 100644 --- a/backend/src/middleware/apiError.ts +++ b/backend/src/middleware/apiError.ts @@ -79,21 +79,6 @@ export function buildApiErrorBody(options: BuildApiErrorBodyOptions): ApiErrorBo ...(options.correlationId ? { correlationId: options.correlationId } : {}), ...(options.traceId ? { traceId: options.traceId } : {}), }; - /** - * Short, stable headline for the failure. Part of the published error - * contract (see `ErrorEnvelope` in openapi.json) alongside `message`. - */ - summary?: string; - /** - * Field-level failures, surfaced at the top level for clients that read - * `errors` directly. `details` carries the same payload for the generic - * envelope. - */ - errors?: unknown[]; - /** Request path, echoed on routing failures so clients can log it directly. */ - path?: string; - summary?: string; - errors?: unknown; } export function sendApiError( diff --git a/backend/src/middleware/apiKeyAuth.ts b/backend/src/middleware/apiKeyAuth.ts index cdb3d5cd9..656da9796 100644 --- a/backend/src/middleware/apiKeyAuth.ts +++ b/backend/src/middleware/apiKeyAuth.ts @@ -1 +1,421 @@ -aW1wb3J0IHR5cGUgeyBSZXF1ZXN0LCBSZXNwb25zZSwgTmV4dEZ1bmN0aW9uIH0gZnJvbSAnZXhwcmVzcyc7CmltcG9ydCBjcnlwdG8gZnJvbSAnY3J5cHRvJzsKaW1wb3J0IHsgcHJpc21hIH0gZnJvbSAnLi4vcHJpc21hJzsKaW1wb3J0IHsgc2VuZEFwaUVycm9yIH0gZnJvbSAnLi9hcGlFcnJvcic7CgpleHBvcnQgdHlwZSBBcGlLZXlSb2xlID0gJ3ZpZXdlcicgfCAnb3BlcmF0b3InIHwgJ2FkbWluJyB8ICdzdXBlci1hZG1pbic7CgppbnRlcmZhY2UgQXBpS2V5TWV0YWRhdGEgewogIHJvbGU6IEFwaUtleVJvbGU7CiAgY3JlYXRlZEF0OiBEYXRlOwogIHJvdGF0ZWRBdD86IERhdGU7CiAgcmV2b2tlZEF0PzogRGF0ZTsKICB0ZW5hbnRJZDogc3RyaW5nOwogIHNjb3Blczogc3RyaW5nW107CiAgZXhwaXJlc0F0PzogRGF0ZSB8IG51bGw7CiAgbGFzdFVzZWRBdD86IERhdGUgfCBudWxsOwp9CgpkZWNsYXJlIGdsb2JhbCB7CiAgbmFtZXNwYWNlIEV4cHJlc3MgewogICAgaW50ZXJmYWNlIFJlcXVlc3QgewogICAgICBhdXRoQXBpS2V5SGFzaD86IHN0cmluZzsKICAgICAgYXV0aEFwaUtleVJvbGU/OiBBcGlLZXlSb2xlOwogICAgICBhdXRoQXBpS2V5VGVuYW50SWQ/OiBzdHJpbmc7CiAgICAgIGF1dGhBcGlLZXlTY29wZXM/OiBzdHJpbmdbXTsKICAgICAgYXV0aEFwaUtleUxhc3RVc2VkQXQ/OiBEYXRlIHwgbnVsbDsKICAgICAgYXV0aEFwaUtleUxlZ2FjeT86IGJvb2xlYW47CiAgICB9CiAgfQp9Cgpjb25zdCBST0xFX1BSRUNFREVOQ0U6IFJlY29yZDxBcGlLZXlSb2xlLCBudW1iZXI+ID0gewogIHZpZXdlcjogMCwKICBvcGVyYXRvcjogMSwKICBhZG1pbjogMiwKICAnc3VwZXItYWRtaW4nOiAzLAp9OwoKZXhwb3J0IGZ1bmN0aW9uIG5vcm1hbGl6ZUFwaUtleVJvbGUocmF3OiB1bmtub3duKTogQXBpS2V5Um9sZSB8IG51bGwgewogIGlmICh0eXBlb2YgcmF3ICE9PSAnc3RyaW5nJykgewogICAgcmV0dXJuIG51bGw7CiAgfQogIGNvbnN0IHZhbHVlID0gcmF3LnRyaW0oKS50b0xvd2VyQ2FzZSgpOwogIGlmICh2YWx1ZSA9PT0gJ3ZpZXdlcicgfHwgdmFsdWUgPT09ICdvcGVyYXRvcicgfHwgdmFsdWUgPT09ICdhZG1pbicgfHwgdmFsdWUgPT09ICdzdXBlci1hZG1pbicpIHsKICAgIHJldHVybiB2YWx1ZSBhcyBBcGlLZXlSb2xlOwogIH0KICByZXR1cm4gbnVsbDsKfQoKZnVuY3Rpb24gcGFyc2VTY29wZXMocmF3OiB1bmtub3duKTogc3RyaW5nW10gewogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHJldHVybiByYXcgYXMgc3RyaW5nW107CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnKSB7CiAgICB0cnkgewogICAgICBjb25zdCBwYXJzZWQgPSBKU09OLnBhcnNlKHJhdyk7CiAgICAgIHJldHVybiBBcnJheS5pc0FycmF5KHBhcnNlZCkgPyBwYXJzZWQgOiBbXTsKICAgIH0gY2F0Y2ggewogICAgICByZXR1cm4gW107CiAgICB9CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGludGVyZmFjZSBQZXJzaXN0ZWRBcGlLZXlTdGF0dXMgewogIGlzQWN0aXZlOiBib29sZWFuOwogIGRlbGV0ZWRBdD86IERhdGUgfCBudWxsOwogIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogIHRlbmFudD86IHsgZGVsZXRlZEF0PzogRGF0ZSB8IG51bGwgfTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGlzUGVyc2lzdGVkQXBpS2V5VXNhYmxlKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBhcGlLZXkgaXMgUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmlzQWN0aXZlICYmICFhcGlLZXkuZGVsZXRlZEF0ICYmICFhcGlLZXkudGVuYW50Py5kZWxldGVkQXQpOwp9CgpleHBvcnQgZnVuY3Rpb24gaXNQZXJzaXN0ZWRBcGlLZXlFeHBpcmVkKAogIGFwaUtleTogUGVyc2lzdGVkQXBpS2V5U3RhdHVzIHwgbnVsbApdOiBib29sZWFuIHsKICByZXR1cm4gQm9vbGVhbihhcGlLZXkgJiYgYXBpS2V5LmV4cGlyZXNBdCAmJiBhcGlLZXkuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcTogUmVxdWVzdCk6IHN0cmluZyB8IG51bGwgewogIGNvbnN0IGZyb21QYXJhbXMgPSByZXEucGFyYW1zPy50ZW5hbnRJZDsKICBpZiAodHlwZW9mIGZyb21QYXJhbXMgPT09ICdzdHJpbmcnICYmIGZyb21QYXJhbXMudHJpbSgpKSB7CiAgICByZXR1cm4gZnJvbVBhcmFtcy50cmltKCk7CiAgfQogIGNvbnN0IGZyb21RdWVyeSA9IHJlcS5xdWVyeT8udGVuYW50SWQ7CiAgaWYgKHR5cGVvZiBmcm9tUXVlcnkgPT09ICdzdHJpbmcnICYmIGZyb21RdWVyeS50cmltKCkpIHsKICAgIHJldHVybiBmcm9tUXVlcnkudHJpbSgpOwogIH0KICBjb25zdCBmcm9tSGVhZGVyID0gcmVxLmdldD8uKCd4LXRlbmFudC1pZCcpOwogIGlmICh0eXBlb2YgZnJvbUhlYWRlciA9PT0gJ3N0cmluZycgJiYgZnJvbUhlYWRlci50cmltKCkpIHsKICAgIHJldHVybiBmcm9tSGVhZGVyLnRyaW0oKTsKICB9CiAgcmV0dXJuIG51bGw7Cn0KCmV4cG9ydCBmdW5jdGlvbiBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxOiBSZXF1ZXN0KTogc3RyaW5nW10gewogIGNvbnN0IHJhdyA9IChyZXEgYXMgUmVxdWVzdCAmIHsgcmVxdWlyZWRTY29wZXM/OiB1bmtub3duIH0pLnJlcXVpcmVkU2NvcGVzOwogIGlmIChBcnJheS5pc0FycmF5KHJhdykpIHsKICAgIHJldHVybiByYXcuZmlsdGVyKChzKTogcyBpcyBzdHJpbmcgPT4gdHlwZW9mIHMgPT09ICdzdHJpbmcnKTsKICB9CiAgaWYgKHR5cGVvZiByYXcgPT09ICdzdHJpbmcnICYmIHJhdy50cmltKCkpIHsKICAgIHJldHVybiByYXcuc3BsaXQoJywnKS5tYXAoKHMpID0+IHMudHJpbSgpKS5maWx0ZXIoQm9vbGVhbik7CiAgfQogIHJldHVybiBbXTsKfQoKZXhwb3J0IGZ1bmN0aW9uIGhhc1JlcXVpcmVkU2NvcGVzKGF2YWlsYWJsZTogc3RyaW5nW10sIHJlcXVpcmVkOiBzdHJpbmdbXSk6IGJvb2xlYW4gewogIGlmIChyZXF1aXJlZC5sZW5ndGggPT09IDApIHJldHVybiB0cnVlOwogIGlmIChhdmFpbGFibGUuaW5jbHVkZXMoJyonKSkgcmV0dXJuIHRydWU7CiAgcmV0dXJuIHJlcXVpcmVkLmV2ZXJ5KChzY29wZSkgPT4gYXZhaWxhYmxlLmluY2x1ZGVzKHNjb3BlKSk7Cn0KCmZ1bmN0aW9uIHNlbmRVbmF1dGhvcml6ZWQoCiAgcmVxOiBSZXF1ZXN0LAogIHJlczogUmVzcG9uc2UsCiAgY29kZTogc3RyaW5nLAogIG1lc3NhZ2U6IHN0cmluZywKICBzY2hlbWU6IHN0cmluZyA9ICdCZWFyZXInCik6IHZvaWQgewogIHJlcy5zZXRIZWFkZXIoJ1dXVy1BdXRoZW50aWNhdGUnLCBzY2hlbWUpOwogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlLAogICAgbWVzc2FnZSwKICAgIHJldHJhYmxlOiBmYWxzZSwKICB9KTsKfQoKY29uc3QgTEVHQUNZX0VOVl9LRVkgPSBwcm9jZXNzLmVudi5BUElfS0VZOwpjb25zdCBMRUdBQ1lfRU5WX1RFTkFOVCA9IHByb2Nlc3MuZW52LkFQSV9LRVlfVEVOQU5UX0lEIHx8ICdkZWZhdWx0JzsKY29uc3QgTEVHQUNZX0VOVl9TQ09QRVMgPSAocHJvY2Vzcy5lbnYuQVBJX0tFWV9TQ09QRVMgfHwgJyonKQogIC5zcGxpdCgnLCcpCiAgLm1hcCgocykgPT4gcy50cmltKCkpCiAgLmZpbHRlcihCb29sZWFuKTsKCmZ1bmN0aW9uIGNoZWNrTGVnYWN5RW52S2V5KHByb3ZpZGVkS2V5OiBzdHJpbmcpOiBib29sZWFuIHsKICBpZiAoIUxFR0FDWV9FTlZfS0VZKSByZXR1cm4gZmFsc2U7CiAgY29uc3QgYSA9IEJ1ZmZlci5mcm9tKHByb3ZpZGVkS2V5KTsKICBjb25zdCBiID0gQnVmZmVyLmZyb20oTEVHQUNZX0VOVl9LRVkpOwogIGlmIChhLmxlbmd0aCAhPT0gYi5sZW5ndGgpIHJldHVybiBmYWxzZTsKICByZXR1cm4gY3J5cHRvLnRpbWluZ1NhZmVFcXVhbChhLCBiKTsKfQoKZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHZhbGlkYXRlQXBpS2V5KAogIHJlcTogUmVxdWVzdCwKICByZXM6IFJlc3BvbnNlLAogIG5leHQ6IE5leHRGdW5jdGlvbgopOiBQcm9taXNlPHZvaWQ+IHsKICBjb25zdCBhdXRoSGVhZGVyID0gcmVxLmdldD8uKCdBdXRob3JpemF0aW9uJykgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBhdXRoSGVhZGVyLm1hdGNoKC9eQXBpS2V5XHMrKC4rKSQvaSk7CiAgaWYgKCFtYXRjaCkgewogICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgIHN0YXR1czogNDAxLAogICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX01JU1NJTkcnLAogICAgICBtZXNzYWdlOiAnTWlzc2luZyBvciBpbnZhbGlkIEFQSSBrZXknLAogICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgfSk7CiAgICByZXR1cm47CiAgfQogIGNvbnN0IHByb3ZpZGVkS2V5ID0gbWF0Y2hbMV07CiAgY29uc3QgaGFzaGVkID0gY3J5cHRvLmNyZWF0ZUhhc2goJ3NoYTI1NicpLnVwZGF0ZShwcm92aWRlZEtleSkuZGlnZXN0KCdoZXgnKTsKICBjb25zdCByZXF1ZXN0ZWRUZW5hbnRJZCA9IGV4dHJhY3RUZW5hbnRJZEZyb21SZXF1ZXN0KHJlcSk7CiAgY29uc3QgcmVxdWlyZWRTY29wZXMgPSBleHRyYWN0UmVxdWlyZWRTY29wZXMocmVxKTsKCiAgLy8gVHJ5IFByaXNtYSBmaXJzdDsgZmFsbCBiYWNrIHRvIGluLW1lbW9yeSBzdG9yZSB3aGVuIHRoZSB0YWJsZSBkb2Vzbid0IGV4aXN0CiAgLy8gKGUuZy4gaW4gdGVzdHMgd2l0aCBhbiB1bi1taWdyYXRlZCBTUUxpdGUgZGF0YWJhc2UpLgogIGxldCBhcGlLZXk6IHsKICAgIGlkPzogc3RyaW5nOwogICAgaGFzaGVkS2V5OiBzdHJpbmc7CiAgICByb2xlOiBzdHJpbmc7CiAgICB0ZW5hbnRJZDogc3RyaW5nOwogICAgc2NvcGVzOiB1bmtub3duOwogICAgaXNBY3RpdmU6IGJvb2xlYW47CiAgICBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbDsKICAgIGV4cGlyZXNBdD86IERhdGUgfCBudWxsOwogICAgdGVuYW50PzogeyBkZWxldGVkQXQ/OiBEYXRlIHwgbnVsbCB9OwogIH0gfCBudWxsID0gbnVsbDsKICB0cnkgewogICAgYXBpS2V5ID0gYXdhaXQgcHJpc21hLmFwaUtleS5maW5kVW5pcXVlKHsKICAgICAgd2hlcmU6IHsgaGFzaGVkS2V5OiBoYXNoZWQgfSwKICAgICAgaW5jbHVkZTogeyB0ZW5hbnQ6IHsgc2VsZWN0OiB7IGRlbGV0ZWRBdDogdHJ1ZSB9IH0gfSwKICAgIH0pOwogIH0gY2F0Y2ggKGVycikgewogICAgLy8gT25seSBmYWxsIGJhY2sgd2hlbiB0aGUgQXBpS2V5IHRhYmxlIGhhc24ndCBiZWVuIGNyZWF0ZWQgKHRlc3RzIC8gZnJlc2ggREIpLgogICAgY29uc3QgaXNN aXNzaW5nVGFibGUgPQogICAgICBlcnIgaW5zdGFuY2VvZiBFcnJvciAmJgogICAgICAoZXJyLm1lc3NhZ2UuaW5jbHVkZXMoJ2RvZXMgbm90IGV4aXN0IGluIHRoZSBjdXJyZW50IGRhdGFiYXNlJykgfHwKICAgICAgICAoZXJyIGFzIHsgY29kZT86IHN0cmluZyB9KS5jb2RlID09PSAnUDIwMjEnKTsKICAgIGlmICghaXNN aXNzaW5nVGFibGUpIHRocm93IGVycjsKICB9CgogIGlmIChhcGlLZXkpIHsKICAgIGlmICghaXNQZXJzaXN0ZWRBcGlLZXlVc2FibGUoYXBpS2V5KSkgewogICAgICAvLyBBIHBlcnNpc3RlZC1idXQtZGlzYWJsZWQgY3JlZGVudGlhbCBtdXN0IGZhaWwgY2xvc2VkLiBOZXZlciBsZXQgaXQgZmFsbAogICAgICAvLyB0aHJvdWdoIHRvIHRoZSBsZWdhY3kgaW4tbWVtb3J5IHN0b3JlIHdoZXJlIGFuIG9sZCBkdXBsaWNhdGUgY291bGQgbGl2ZS4KICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCBBUEkga2V5JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGlmIChpc1BlcnNpc3RlZEFwaUtleUV4cGlyZWQoYXBpS2V5KSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKHJlcSwgcmVzLCAnQVBJX0tFWV9FWFBJUkVEJywgJ0FQSSBrZXkgaGFzIGV4cGlyZWQnKTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIC8vIFRlbmFudCBzY29waW5nOiBpZiB0aGUgcmVxdWVzdCB0YXJnZXRzIGEgdGVuYW50LCB0aGUga2V5IG11c3QgYmVsb25nIHRvIGl0LgogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBhcGlLZXkudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQoKICAgIGNvbnN0IGF2YWlsYWJsZVNjb3BlcyA9IHBhcnNlU2NvcGVzKGFwaUtleS5zY29wZXMpOwogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhhdmFpbGFibGVTY29wZXMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CgogICAgcmVxLmF1dGhBcGlLZXlIYXNoID0gYXBpS2V5Lmhhc2hlZEtleTsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IGFwaUtleS5yb2xlIGFzIEFwaUtleVJvbGU7CiAgICByZXEuYXV0aEFwaUtleVRlbmFudElkID0gYXBpS2V5LnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBhdmFpbGFibGVTY29wZXM7CiAgICByZXEuYXV0aEFwaUtleUxlZ2FjeSA9IGZhbHNlOwoKICAgIC8vIEJlc3QtZWZmb3J0IGxhc3QtdXNlZCB0cmFja2luZyBmb3IgYXVkaXQuIE5ldmVyIGZhaWwgYXV0aCBiZWNhdXNlIG9mIGl0LgogICAgdHJ5IHsKICAgICAgY29uc3Qgbm93ID0gbmV3IERhdGUoKTsKICAgICAgYXdhaXQgcHJpc21hLmFwaUtleS51cGRhdGUoewogICAgICAgIHdoZXJlOiB7IGhhc2hlZEtleTogYXBpS2V5Lmhhc2hlZEtleSB9LAogICAgICAgIGRhdGE6IHsgbGFzdFVzZWRBdDogbm93IH0sCiAgICAgIH0pOwogICAgICByZXEuYXV0aEFwaUtleUxhc3RVc2VkQXQgPSBub3c7CiAgICB9IGNhdGNoIHsKICAgICAgLy8gaWdub3JlIGxhc3QtdXNlZCB1cGRhdGUgZmFpbHVyZXMKICAgIH0KCiAgICBuZXh0KCk7CiAgICByZXR1cm47CiAgfQoKICAvLyBGYWxsYmFjazogY2hlY2sgdGhlIGluLW1lbW9yeSBzdG9yZSAodXNlZCBieSB0ZXN0cyBhbmQgbGVnYWN5IGNhbGxlcnMpCiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoZWQpOwogIGlmIChtZXRhICYmICFtZXRhLnJldm9rZWRBdCkgewogICAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQocmVxLCByZXMsICdBUElfS0VZX0VYUElSRUQnLCAnQVBJIGtleSBoYXMgZXhwaXJlZCcpOwogICAgICByZXR1cm47CiAgICB9CiAgICBpZiAocmVxdWVzdGVkVGVuYW50SWQgJiYgcmVxdWVzdGVkVGVuYW50SWQgIT09IG1ldGEudGVuYW50SWQpIHsKICAgICAgc2VuZEFwaUVycm9yKHJlcSwgcmVzLCB7CiAgICAgICAgc3RhdHVzOiA0MDEsCiAgICAgICAgY29kZTogJ0FVVEhfQVBJX0tFWV9JTlZBTElEJywKICAgICAgICBtZXNzYWdlOiAnQVBJIGtleSBpcyBub3QgdmFsaWQgZm9yIHRoaXMgdGVuYW50JywKICAgICAgICByZXRyeWFibGU6IGZhbHNlLAogICAgICB9KTsKICAgICAgcmV0dXJuOwogICAgfQogICAgaWYgKCFoYXNSZXF1aXJlZFNjb3BlcyhtZXRhLnNjb3BlcywgcmVxdWlyZWRTY29wZXMpKSB7CiAgICAgIHNlbmRVbmF1dGhvcml6ZWQoCiAgICAgICAgcmVxLAogICAgICAgIHJlcywKICAgICAgICAnU0NPUEVfSU5TVUZGSUNJRU5UJywKICAgICAgICAnQVBJIGtleSBtaXNzZXMgcmVxdWlyZWQgc2NvcGUocyknLAogICAgICAgICdCZWFyZXIgZXJyb3I9Imluc3VmZmljaWVudF9zY29wZSInCiAgICAgICk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIHJlcS5hdXRoQXBpS2V5SGFzaCA9IGhhc2hlZDsKICAgIHJlcS5hdXRoQXBpS2V5Um9sZSA9IG1ldGEucm9sZTsKICAgIHJlcS5hdXRoQXBpS2V5VGVuYW50SWQgPSBtZXRhLnRlbmFudElkOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBtZXRhLnNjb3BlczsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gZmFsc2U7CiAgICBtZXRhLmxhc3RVc2VkQXQgPSBuZXcgRGF0ZSgpOwogICAgcmVxLmF1dGhBcGlLZXlMYXN0VXNlZEF0ID0gbWV0YS5sYXN0VXNlZEF0OwogICAgbmV4dCgpOwogICAgcmV0dXJuOwogIH0KCiAgLy8gTGVnYWN5IGZhbGxiYWNrOiBzaW5nbGUgZ2xvYmFsIGVudiB2YXIgQVBJX0tFWS4gRGVwcmVjYXRlZC4KICBpZiAoY2hlY2tMZWdhY3lFbnZLZXkocHJvdmlkZWRLZXkpKSB7CiAgICBjb25zb2xlLndhcm4oCiAgICAgICdbZGVwcmVjYXRpb25dIEFQSV9LRVkgZW52IHZhciBmYWxsYmFjayBpcyBkZXByZWNhdGVkLiBNaWdyYXRlIHRvIHBlci10ZW5hbnQgQVBJIGtleXMgaW4gdGhlIGFwaUtleSB0YWJsZS4nCiAgICApOwogICAgaWYgKHJlcXVlc3RlZFRlbmFudElkICYmIHJlcXVlc3RlZFRlbmFudElkICE9PSBMRUdBQ1lfRU5WX1RFTkFOVCkgewogICAgICBzZW5kQXBpRXJyb3IocmVxLCByZXMsIHsKICAgICAgICBzdGF0dXM6IDQwMSwKICAgICAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgICAgIG1lc3NhZ2U6ICdBUElrZXkgaXMgbm90IHZhbGlkIGZvciB0aGlzIHRlbmFudCcsCiAgICAgICAgcmV0cnlhYmxlOiBmYWxzZSwKICAgICAgfSk7CiAgICAgIHJldHVybjsKICAgIH0KICAgIGlmICghaGFzUmVxdWlyZWRTY29wZXMoTEVHQUNZX0VOVl9TQ09QRVMsIHJlcXVpcmVkU2NvcGVzKSkgewogICAgICBzZW5kVW5hdXRob3JpemVkKAogICAgICAgIHJlcSwKICAgICAgICByZXMsCiAgICAgICAgJ1NDT1BFX0lOU1VGRklDSUVOVCcsCiAgICAgICAgJ0FQSSBrZXkgbWlzc2VzIHJlcXVpcmVkIHNjb3BlKHMpJywKICAgICAgICAnQmVhcmVyIGVycm9yPSJpbnN1ZmZpY2llbnRfc2NvcGUiJwogICAgICApOwogICAgICByZXR1cm47CiAgICB9CiAgICByZXEuYXV0aEFwaUtleUhhc2ggPSBoYXNoZWQ7CiAgICByZXEuYXV0aEFwaUtleVJvbGUgPSAnYWRtaW4nOwogICAgcmVxLmF1dGhBcGlLZXlUZW5hbnRJZCA9IExFR0FDWV9FTlZfVEVOQU5UOwogICAgcmVxLmF1dGhBcGlLZXlTY29wZXMgPSBMRUdBQ1lfRU5WX1NDT1BFUzsKICAgIHJlcS5hdXRoQXBpS2V5TGVnYWN5ID0gdHJ1ZTsKICAgIG5leHQoKTsKICAgIHJldHVybjsKICB9CgogIHNlbmRBcGlFcnJvcihyZXEsIHJlcywgewogICAgc3RhdHVzOiA0MDEsCiAgICBjb2RlOiAnQVVUSF9BUElfS0VZX0lOVkFMSUQnLAogICAgbWVzc2FnZTogJ0ludmFsaWQgQVBJIGtleScsCiAgICByZXRyeWFibGU6IGZhbHNlLAogIH0pOwp9CgpleHBvcnQgZnVuY3Rpb24gaGFzaEFwaUtleShrZXk6IHN0cmluZyk6IHN0cmluZyB7CiAgcmV0dXJuIGNyeXB0by5jcmVhdGVIYXNoKCdzaGEyNTYnKS51cGRhdGUoa2V5KS5kaWdlc3QoJ2hleCcpOwp9CgovLyBMZWdhY3kgaGVscGVycyByZXRhaW5lZCBmb3IgY29tcGF0aWJpbGl0eSDigJMgb3BlcmF0ZSBvbiBpbi1tZW1vcnkgbWFwIG9ubHkuCmNvbnN0IElOX01FTU9SWV9LRVlTID0gbmV3IE1hcDxzdHJpbmcsIEFwaUtleU1ldGFkYXRhPigpOwoKZXhwb3J0IGZ1bmN0aW9uIHJlZ2lzdGVyQXBpS2V5KAogIGtleTogc3RyaW5nLAogIG9wdGlvbnM/OiB7IHJvbGU/OiBBcGlLZXlSb2xlOyB0ZW5hbnRJZD86IHN0cmluZzsgc2NvcGVzPzogc3RyaW5nW107IGV4cGlyZXNBdD86IERhdGUgfCBudWxsIH0KKTogc3RyaW5nIHsKICBjb25zdCBoYXNoID0gaGFzaEFwaUtleShrZXkpOwogIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCB7CiAgICByb2xlOiBvcHRpb25zPy5yb2xlIHx8ICdhZG1pbicsCiAgICBjcmVhdGVkQXQ6IG5ldyBEYXRlKCksCiAgICB0ZW5hbnRJZDogb3B0aW9ucz8udGVuYW50SWQgfHwgJ3Vua25vd24nLAogICAgc2NvcGVzOiBvcHRpb25zPy5zY29wZXMgfHwgW10sCiAgICBleHBpcmVzQXQ6IG9wdGlvbnM/LmV4cGlyZXNBdCA/PyBudWxsLAogIH0pOwogIHJldHVybiBoYXNoOwp9CgpleHBvcnQgZnVuY3Rpb24gcmV2b2tlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKG1ldGEpIHsKICAgIG1ldGEucmV2b2tlZEF0ID0gbmV3IERhdGUoKTsKICAgIElOX01FTU9SWV9LRVlTLnNldChoYXNoLCBtZXRhKTsKICB9CiAgcmV0dXJuIElOX01FTU9SWV9LRVlTLmRlbGV0ZShoYXNoKTsKfQoKZXhwb3J0IGZ1bmN0aW9uIHJvdGF0ZUFwaUtleSgKICBvbGRIYXNoOiBzdHJpbmcsCiAgbmV3S2V5OiBzdHJpbmcsCiAgb3B0aW9uczogeyByb2xlPzogQXBpS2V5Um9sZTsgdGVuYW50SWQ/OiBzdHJpbmc7IHNjb3Blcz86IHN0cmluZ1tdIH0gPSB7fQopOiBzdHJpbmcgfCBudWxsIHsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KG9sZEhhc2gpOwogIGlmICghbWV0YSkgcmV0dXJuIG51bGw7CiAgSU5fTUVNT1JZX0tFWVMuZGVsZXRlKG9sZEhhc2gpOwogIGNvbnN0IG5ld0hhc2ggPSBoYXNoQXBpS2V5KG5ld0tleSk7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KG5ld0hhc2gsIHsKICAgIHJvbGU6IG9wdGlvbnMucm9sZSB8fCBtZXRhLnJvbGUsCiAgICBjcmVhdGVkQXQ6IG1ldGEuY3JlYXRlZEF0LAogICAgcm90YXRlZEF0OiBuZXcgRGF0ZSgpLAogICAgdGVuYW50SWQ6IG9wdGlvbnMudGVuYW50SWQgfHwgbWV0YS50ZW5hbnRJZCwKICAgIHNjb3Blczogb3B0aW9ucy5zY29wZXMgfHwgbWV0YS5zY29wZXMsCiAgICBleHBpcmVzQXQ6IG1ldGEuZXhwaXJlc0F0ID8/IG51bGwsCiAgfSk7CiAgcmV0dXJuIG5ld0hhc2g7Cn0KCmV4cG9ydCBmdW5jdGlvbiByZXN0b3JlQXBpS2V5KGhhc2g6IHN0cmluZyk6IGJvb2xlYW4gewogIGNvbnN0IG1ldGEgPSBJTl9NRU1PUllfS0VZUy5nZXQoaGFzaCk7CiAgaWYgKCFtZXRhKSByZXR1cm4gZmFsc2U7CiAgbWV0YS5yZXZva2VkQXQgPSB1bmRlZmluZWQ7CiAgSU5fTUVNT1JZX0tFWVMuc2V0KGhhc2gsIG1ldGEpOwogIHJldHVybiB0cnVlOwp9CgpleHBvcnQgZnVuY3Rpb24gZ2V0QXBpS2V5TWV0YWRhdGEoaGFzaDogc3RyaW5nKSB7CiAgY29uc3QgbWV0YSA9IElOX01FTU9SWV9LRVlTLmdldChoYXNoKTsKICBpZiAoIW1ldGEpIHJldHVybiBudWxsOwogIHJldHVybiB7CiAgICBoYXNoLAogICAgcm9sZTogbWV0YS5yb2xlLAogICAgY3JlYXRlZEF0OiBtZXRhLmNyZWF0ZWRBdC50b0lTT1N0cmluZygpLAogICAgLi4uKG1ldGEucm90YXRlZEF0ID8geyByb3RhdGVkQXQ6IG1ldGEucm90YXRlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICAgIC4uLihtZXRhLnJldm9rZWRBdCA/IHsgcmV2b2tlZEF0OiBtZXRhLnJldm9rZWRBdC50b0lTT1N0cmluZygpIH0gOiB7fSksCiAgICB0ZW5hbnRJZDogbWV0YS50ZW5hbnRJZCwKICAgIHNjb3BlczogbWV0YS5zY29wZXMsCiAgICAuLi4obWV0YS5leHBpcmVzQXQgPyB7IGV4cGlyZXNBdDogbWV0YS5leHBpcmVzQXQudG9JU09TdHJpbmcoKSB9IDoge30pLAogICAgLi4uKG1ldGEubGFzdFVzZWRBdCA/IHsgbGFzdFVzZWRBdDogbWV0YS5sYXN0VXNlZEF0LnRvSVNPU3RyaW5nKCkgfSA6IHt9KSwKICB9Owp9CgpleHBvcnQgZnVuY3Rpb24gYXV0aGVudGljYXRlQXBpS2V5VmFsdWUodmFsdWU6IHN0cmluZykgewogIGNvbnN0IGhhc2ggPSBoYXNoQXBpS2V5KHZhbHVlKTsKICBjb25zdCBtZXRhID0gSU5fTUVNT1JZX0tFWVMuZ2V0KGhhc2gpOwogIGlmICghbWV0YSB8fCBtZXRhLnJldm9rZWRBdCkgcmV0dXJuIG51bGw7CiAgaWYgKG1ldGEuZXhwaXJlc0F0ICYmIG1ldGEuZXhwaXJlc0F0LmdldFRpbWUoKSA8PSBEYXRlLm5vdygpKSByZXR1cm4gbnVsbDsKICByZXR1cm4geyBoYXNoLCByb2xlOiBtZXRhLnJvbGUsIHRlbmFudElkOiBtZXRhLnRlbmFudElkLCBzY29wZXM6IG1ldGEuc2NvcGVzIH07Cn0KCmV4cG9ydCBmdW5jdGlvbiBoYXNSZXF1aXJlZEFwaUtleVJvbGUocmVxOiBSZXF1ZXN0LCByZXF1aXJlZFJvbGU6IEFwaUtleVJvbGUpOiBib29sZWFuIHsKICBjb25zdCBjdXJyZW50Um9sZSA9IHJlcS5hdXRoQXBpS2V5Um9sZSB8fCAnYWRtaW4nOwogIHJldHVybiBST0xFX1BSRUNFREVOQ0VbY3VycmVudFJvbGVdID49IFJPTEVfUFJFQ0VERU5DRVtyZXF1aXJlZFJvbGVdOwp9Cg== \ No newline at end of file +import type { Request, Response, NextFunction } from 'express'; +import crypto from 'crypto'; +import { prisma } from '../prisma'; +import { sendApiError } from './apiError'; + +export type ApiKeyRole = 'viewer' | 'operator' | 'admin' | 'super-admin'; + +interface ApiKeyMetadata { + role: ApiKeyRole; + createdAt: Date; + rotatedAt?: Date; + revokedAt?: Date; + tenantId: string; + scopes: string[]; + expiresAt?: Date | null; + lastUsedAt?: Date | null; +} + +declare global { + namespace Express { + interface Request { + authApiKeyHash?: string; + authApiKeyRole?: ApiKeyRole; + authApiKeyTenantId?: string; + authApiKeyScopes?: string[]; + authApiKeyLastUsedAt?: Date | null; + authApiKeyLegacy?: boolean; + } + } +} + +const ROLE_PRECEDENCE: Record = { + viewer: 0, + operator: 1, + admin: 2, + 'super-admin': 3, +}; + +export function normalizeApiKeyRole(raw: unknown): ApiKeyRole | null { + if (typeof raw !== 'string') { + return null; + } + const value = raw.trim().toLowerCase(); + if (value === 'viewer' || value === 'operator' || value === 'admin' || value === 'super-admin') { + return value as ApiKeyRole; + } + return null; +} + +function parseScopes(raw: unknown): string[] { + if (Array.isArray(raw)) return raw as string[]; + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + return []; +} + +export interface PersistedApiKeyStatus { + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null }; +} + +export function isPersistedApiKeyUsable( + apiKey: PersistedApiKeyStatus | null +): apiKey is PersistedApiKeyStatus { + return Boolean(apiKey && apiKey.isActive && !apiKey.deletedAt && !apiKey.tenant?.deletedAt); +} + +export function isPersistedApiKeyExpired( + apiKey: PersistedApiKeyStatus | null +): boolean { + return Boolean(apiKey && apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()); +} + +export function extractTenantIdFromRequest(req: Request): string | null { + const fromParams = req.params?.tenantId; + if (typeof fromParams === 'string' && fromParams.trim()) { + return fromParams.trim(); + } + const fromQuery = req.query?.tenantId; + if (typeof fromQuery === 'string' && fromQuery.trim()) { + return fromQuery.trim(); + } + const fromHeader = req.get?.('x-tenant-id'); + if (typeof fromHeader === 'string' && fromHeader.trim()) { + return fromHeader.trim(); + } + return null; +} + +export function extractRequiredScopes(req: Request): string[] { + const raw = (req as Request & { requiredScopes?: unknown }).requiredScopes; + if (Array.isArray(raw)) { + return raw.filter((s): s is string => typeof s === 'string'); + } + if (typeof raw === 'string' && raw.trim()) { + return raw.split(',').map((s) => s.trim()).filter(Boolean); + } + return []; +} + +export function hasRequiredScopes(available: string[], required: string[]): boolean { + if (required.length === 0) return true; + if (available.includes('*')) return true; + return required.every((scope) => available.includes(scope)); +} + +function sendUnauthorized( + req: Request, + res: Response, + code: string, + message: string, + scheme: string = 'Bearer' +): void { + res.setHeader('WWW-Authenticate', scheme); + sendApiError(req, res, { + status: 401, + code, + message, + retrable: false, + }); +} + +const LEGACY_ENV_KEY = process.env.API_KEY; +const LEGACY_ENV_TENANT = process.env.API_KEY_TENANT_ID || 'default'; +const LEGACY_ENV_SCOPES = (process.env.API_KEY_SCOPES || '*') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + +function checkLegacyEnvKey(providedKey: string): boolean { + if (!LEGACY_ENV_KEY) return false; + const a = Buffer.from(providedKey); + const b = Buffer.from(LEGACY_ENV_KEY); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +export async function validateApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const authHeader = req.get?.('Authorization') || ''; + const match = authHeader.match(/^ApiKey\s+(.+)$/i); + if (!match) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_MISSING', + message: 'Missing or invalid API key', + retryable: false, + }); + return; + } + const providedKey = match[1]; + const hashed = crypto.createHash('sha256').update(providedKey).digest('hex'); + const requestedTenantId = extractTenantIdFromRequest(req); + const requiredScopes = extractRequiredScopes(req); + + // Try Prisma first; fall back to in-memory store when the table doesn't exist + // (e.g. in tests with an un-migrated SQLite database). + let apiKey: { + id?: string; + hashedKey: string; + role: string; + tenantId: string; + scopes: unknown; + isActive: boolean; + deletedAt?: Date | null; + expiresAt?: Date | null; + tenant?: { deletedAt?: Date | null }; + } | null = null; + try { + apiKey = await prisma.apiKey.findUnique({ + where: { hashedKey: hashed }, + include: { tenant: { select: { deletedAt: true } } }, + }); + } catch (err) { + // Only fall back when the ApiKey table hasn't been created (tests / fresh DB). + const isMissingTable = + err instanceof Error && + (err.message.includes('does not exist in the current database') || + (err as { code?: string }).code === 'P2021'); + if (!isMissingTable) throw err; + } + + if (apiKey) { + if (!isPersistedApiKeyUsable(apiKey)) { + // A persisted-but-disabled credential must fail closed. Never let it fall + // through to the legacy in-memory store where an old duplicate could live. + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); + return; + } + + if (isPersistedApiKeyExpired(apiKey)) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + + // Tenant scoping: if the request targets a tenant, the key must belong to it. + if (requestedTenantId && requestedTenantId !== apiKey.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + + const availableScopes = parseScopes(apiKey.scopes); + if (!hasRequiredScopes(availableScopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + + req.authApiKeyHash = apiKey.hashedKey; + req.authApiKeyRole = apiKey.role as ApiKeyRole; + req.authApiKeyTenantId = apiKey.tenantId; + req.authApiKeyScopes = availableScopes; + req.authApiKeyLegacy = false; + + // Best-effort last-used tracking for audit. Never fail auth because of it. + try { + const now = new Date(); + await prisma.apiKey.update({ + where: { hashedKey: apiKey.hashedKey }, + data: { lastUsedAt: now }, + }); + req.authApiKeyLastUsedAt = now; + } catch { + // ignore last-used update failures + } + + next(); + return; + } + + // Fallback: check the in-memory store (used by tests and legacy callers) + const meta = IN_MEMORY_KEYS.get(hashed); + if (meta && !meta.revokedAt) { + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) { + sendUnauthorized(req, res, 'API_KEY_EXPIRED', 'API key has expired'); + return; + } + if (requestedTenantId && requestedTenantId !== meta.tenantId) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'API key is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(meta.scopes, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = meta.role; + req.authApiKeyTenantId = meta.tenantId; + req.authApiKeyScopes = meta.scopes; + req.authApiKeyLegacy = false; + meta.lastUsedAt = new Date(); + req.authApiKeyLastUsedAt = meta.lastUsedAt; + next(); + return; + } + + // Legacy fallback: single global env var API_KEY. Deprecated. + if (checkLegacyEnvKey(providedKey)) { + console.warn( + '[deprecation] API_KEY env var fallback is deprecated. Migrate to per-tenant API keys in the apiKey table.' + ); + if (requestedTenantId && requestedTenantId !== LEGACY_ENV_TENANT) { + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'APIkey is not valid for this tenant', + retryable: false, + }); + return; + } + if (!hasRequiredScopes(LEGACY_ENV_SCOPES, requiredScopes)) { + sendUnauthorized( + req, + res, + 'SCOPE_INSUFFICIENT', + 'API key misses required scope(s)', + 'Bearer error="insufficient_scope"' + ); + return; + } + req.authApiKeyHash = hashed; + req.authApiKeyRole = 'admin'; + req.authApiKeyTenantId = LEGACY_ENV_TENANT; + req.authApiKeyScopes = LEGACY_ENV_SCOPES; + req.authApiKeyLegacy = true; + next(); + return; + } + + sendApiError(req, res, { + status: 401, + code: 'AUTH_API_KEY_INVALID', + message: 'Invalid API key', + retryable: false, + }); +} + +export function hashApiKey(key: string): string { + return crypto.createHash('sha256').update(key).digest('hex'); +} + +// Legacy helpers retained for compatibility – operate on in-memory map only. +const IN_MEMORY_KEYS = new Map(); + +export function registerApiKey( + key: string, + options?: { role?: ApiKeyRole; tenantId?: string; scopes?: string[]; expiresAt?: Date | null } +): string { + const hash = hashApiKey(key); + IN_MEMORY_KEYS.set(hash, { + role: options?.role || 'admin', + createdAt: new Date(), + tenantId: options?.tenantId || 'unknown', + scopes: options?.scopes || [], + expiresAt: options?.expiresAt ?? null, + }); + return hash; +} + +export function revokeApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (meta) { + meta.revokedAt = new Date(); + IN_MEMORY_KEYS.set(hash, meta); + } + return IN_MEMORY_KEYS.delete(hash); +} + +export function rotateApiKey( + oldHash: string, + newKey: string, + options: { role?: ApiKeyRole; tenantId?: string; scopes?: string[] } = {} +): string | null { + const meta = IN_MEMORY_KEYS.get(oldHash); + if (!meta) return null; + IN_MEMORY_KEYS.delete(oldHash); + const newHash = hashApiKey(newKey); + IN_MEMORY_KEYS.set(newHash, { + role: options.role || meta.role, + createdAt: meta.createdAt, + rotatedAt: new Date(), + tenantId: options.tenantId || meta.tenantId, + scopes: options.scopes || meta.scopes, + expiresAt: meta.expiresAt ?? null, + }); + return newHash; +} + +export function restoreApiKey(hash: string): boolean { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return false; + meta.revokedAt = undefined; + IN_MEMORY_KEYS.set(hash, meta); + return true; +} + +export function getApiKeyMetadata(hash: string) { + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta) return null; + return { + hash, + role: meta.role, + createdAt: meta.createdAt.toISOString(), + ...(meta.rotatedAt ? { rotatedAt: meta.rotatedAt.toISOString() } : {}), + ...(meta.revokedAt ? { revokedAt: meta.revokedAt.toISOString() } : {}), + tenantId: meta.tenantId, + scopes: meta.scopes, + ...(meta.expiresAt ? { expiresAt: meta.expiresAt.toISOString() } : {}), + ...(meta.lastUsedAt ? { lastUsedAt: meta.lastUsedAt.toISOString() } : {}), + }; +} + +export function authenticateApiKeyValue(value: string) { + const hash = hashApiKey(value); + const meta = IN_MEMORY_KEYS.get(hash); + if (!meta || meta.revokedAt) return null; + if (meta.expiresAt && meta.expiresAt.getTime() <= Date.now()) return null; + return { hash, role: meta.role, tenantId: meta.tenantId, scopes: meta.scopes }; +} + +export function hasRequiredApiKeyRole(req: Request, requiredRole: ApiKeyRole): boolean { + const currentRole = req.authApiKeyRole || 'admin'; + return ROLE_PRECEDENCE[currentRole] >= ROLE_PRECEDENCE[requiredRole]; +} diff --git a/backend/src/pagination.ts b/backend/src/pagination.ts index 519495ad3..a72cad7cb 100644 --- a/backend/src/pagination.ts +++ b/backend/src/pagination.ts @@ -176,17 +176,10 @@ export function parsePaginationQuery( query.cursor = req.query.cursor; } - // Parse page (1-based, clamped — never rejected) + // Parse page (1-based, clamped to 1..maxPage — never rejected, Issue #1430) if (req.query.page !== undefined) { - query.page = clampPageNumber(parseInt(req.query.page as string, 10)); - // Parse page (1-based, clamped to 1..maxPage — Issue #1430) - if (req.query.page !== undefined) { - const page = parseInt(req.query.page as string, 10); - if (!isNaN(page) && page > 0) { - query.page = Math.min(page, mergedConfig.maxPage); - } else { - query.page = 1; - } + const page = clampPageNumber(parseInt(req.query.page as string, 10)) ?? 1; + query.page = Math.min(page, mergedConfig.maxPage); } // Parse sortBy diff --git a/backend/src/routes/vaults.ts b/backend/src/routes/vaults.ts index de9854efa..fb9c376f4 100644 --- a/backend/src/routes/vaults.ts +++ b/backend/src/routes/vaults.ts @@ -152,79 +152,5 @@ router.get( }); }, ); -import { Router, Request, Response, NextFunction } from 'express'; -import type { Prisma } from '@prisma/client'; -import { getPrismaClient } from '../prismaClient'; -import { validateApiKey } from '../middleware/apiKeyAuth'; -import { - createPaginatedResponse, - createPaginationEnvelope, - parsePaginationQuery, -} from '../pagination'; -import { buildVaultFilterHash, vaultCountCache } from '../vaultCountCache'; - -const router = Router(); - -/** GET /vaults: fresh page rows with a five-second cached, tenant-scoped total. */ -router.get('/', validateApiKey, async (req: Request, res: Response, next: NextFunction) => { - try { - const rawTenant = req.query.tenantId; - if (rawTenant !== undefined && (typeof rawTenant !== 'string' || !rawTenant.trim())) { - res.status(400).json({ error: 'tenantId must be a non-empty string' }); - return; - } - const isAdmin = ['admin', 'super-admin'].includes(req.authApiKeyRole || ''); - const tenantId = isAdmin ? (rawTenant as string | undefined) : req.authApiKeyTenantId; - if ( - !isAdmin && - (!tenantId || tenantId === 'unknown' || (rawTenant && rawTenant !== tenantId)) - ) { - res.status(403).json({ error: 'Vault access requires a matching authenticated tenant' }); - return; - } - - const where: Prisma.VaultWhereInput = { deletedAt: null, ...(tenantId ? { tenantId } : {}) }; - const query = parsePaginationQuery(req, { defaultSortBy: 'createdAt' }); - const limit = query.limit || 20; - const page = query.page || 1; - const sortBy = query.sortBy || 'createdAt'; - if (!['createdAt', 'updatedAt', 'id', 'aum'].includes(sortBy) || query.cursor) { - res - .status(400) - .json({ error: 'Use page pagination and sortBy createdAt, updatedAt, id or aum' }); - return; - } - const skip = (page - 1) * limit; - if (!Number.isSafeInteger(skip) || skip > 2147483647) { - res.status(400).json({ error: 'Page offset is too large' }); - return; - } - const prisma = getPrismaClient(); - const orderBy: Prisma.VaultOrderByWithRelationInput[] = [{ [sortBy]: query.sortOrder }]; - if (sortBy !== 'id') orderBy.push({ id: query.sortOrder }); - const [data, count] = await Promise.all([ - prisma.vault.findMany({ where, skip, take: limit, orderBy }), - vaultCountCache.get(buildVaultFilterHash(where), () => prisma.vault.count({ where })), - ]); - res.setHeader('X-Cache', count.status); - res.setHeader('Cache-Control', 'no-store'); - res.json( - createPaginatedResponse( - data, - createPaginationEnvelope({ - count: data.length, - limit, - total: count.total, - currentPage: page, - totalPages: Math.max(1, Math.ceil(count.total / limit)), - hasNextPage: skip + data.length < count.total, - hasPrevPage: page > 1, - }) - ) - ); - } catch (error) { - next(error); - } -}); export default router; diff --git a/backend/src/tracing.ts b/backend/src/tracing.ts index 0f3f7db24..fc29dc015 100644 --- a/backend/src/tracing.ts +++ b/backend/src/tracing.ts @@ -11,7 +11,6 @@ import { NodeSDK, type NodeSDKConfiguration } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; import * as otelResources from '@opentelemetry/resources'; -import { Resource } from '@opentelemetry/resources'; import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; @@ -84,7 +83,6 @@ export function initTracing(): void { sdk = new NodeSDK({ resource: buildResource({ - resource: new Resource({ [ATTR_SERVICE_NAME]: SERVICE_NAME, [ATTR_SERVICE_VERSION]: process.env.npm_package_version || '1.0.0', }), diff --git a/backend/src/vaultEndpoints.ts b/backend/src/vaultEndpoints.ts index 9a25673d6..4888c60a5 100644 --- a/backend/src/vaultEndpoints.ts +++ b/backend/src/vaultEndpoints.ts @@ -766,26 +766,6 @@ router.post( cooldownTotal: cooldownSec, }); } -router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), validate({ body: VaultStrategyBodySchema }), (req: Request, res: Response) => { - const cooldownSec = parseInt(process.env.STRATEGY_SWITCH_COOLDOWN_SEC || '0', 10); - const lastSwitchIso = process.env.LAST_STRATEGY_SWITCH_TIME || null; - const now = Date.now(); - const lastSwitchMs = lastSwitchIso ? new Date(lastSwitchIso).getTime() : 0; - - if (cooldownSec > 0 && lastSwitchMs > 0) { - const elapsed = Math.floor((now - lastSwitchMs) / 1000); - if (elapsed < cooldownSec) { - const retryAfter = cooldownSec - elapsed; - res.setHeader('Retry-After', String(retryAfter)); - res.status(429).json({ - error: 'Too Many Requests', - status: 429, - code: 'STRATEGY_COOLDOWN_ACTIVE', - message: `Strategy switch cooldown active. Retry in ${retryAfter}s.`, - cooldownRemaining: retryAfter, - cooldownTotal: cooldownSec, - }); - return; } const strategyId = typeof req.body?.strategyId === 'string' ? req.body.strategyId : 'default'; @@ -825,8 +805,6 @@ router.post('/strategy', depositsLimiter, requireFlag('strategy-selection'), val res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); } ); - return res.status(200).json({ message: 'Strategy selection endpoint (v2 preview)' }); -}); /** * POST /api/v1/vault/gasless-deposits @@ -956,7 +934,6 @@ router.get('/receipts', readsLimiter, async (req: Request, res: Response) => { req.query.limit === undefined ? undefined : parseInt(req.query.limit as string, 10), RECEIPTS_PAGINATION_CONFIG ); - const limit = Math.min(parseInt((req.query.limit as string) || '50', 10), 100); const cursor = req.query.cursor as string | undefined; const where = wallet ? { user: wallet } : {}; diff --git a/package.json b/package.json index 7f9c206b5..23e8ff2bc 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,5 @@ }, "resolutions": { "lodash": "^4.17.21" - }, - + } } From 1d44427a38171cfc3063c9c7a007148a7fe79d84 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Sat, 17 Oct 2026 12:19:19 +0100 Subject: [PATCH 3/4] feat(backend): document and check SQLite/PostgreSQL type mapping - prisma/postgres-type-mapping.json maps every PostgreSQL NUMERIC / JSONB / TIMESTAMPTZ / DATE column to its Prisma (SQLite) field or null - scripts/check-type-mapping.js (npm run db:check-types) fails when a divergent column is unmapped, a mapped column/field disappears, or a Prisma type no longer matches; runs in Backend Governance CI - src/numericColumns.ts: NUMERIC(38, 18) string encoding with 38-digit Decimal precision, plus precision and drift tests - docs/DB_TYPE_MAPPING.md Closes #1546 --- .github/workflows/backend-governance.yml | 3 + backend/docs/DB_TYPE_MAPPING.md | 36 +++++++ backend/package.json | 1 + backend/prisma/postgres-type-mapping.json | 29 +++++ backend/scripts/check-type-mapping.js | 106 +++++++++++++++++++ backend/src/__tests__/numericColumns.test.ts | 50 +++++++++ backend/src/numericColumns.ts | 39 +++++++ 7 files changed, 264 insertions(+) create mode 100644 backend/docs/DB_TYPE_MAPPING.md create mode 100644 backend/prisma/postgres-type-mapping.json create mode 100644 backend/scripts/check-type-mapping.js create mode 100644 backend/src/__tests__/numericColumns.test.ts create mode 100644 backend/src/numericColumns.ts diff --git a/.github/workflows/backend-governance.yml b/.github/workflows/backend-governance.yml index 3713c23a3..b13c44e6d 100644 --- a/.github/workflows/backend-governance.yml +++ b/.github/workflows/backend-governance.yml @@ -91,3 +91,6 @@ jobs: - name: Check for database schema drift run: npm run db:check-drift + + - name: Check dev (SQLite) vs prod (PostgreSQL) column type mapping + run: npm run db:check-types diff --git a/backend/docs/DB_TYPE_MAPPING.md b/backend/docs/DB_TYPE_MAPPING.md new file mode 100644 index 000000000..279ef0db0 --- /dev/null +++ b/backend/docs/DB_TYPE_MAPPING.md @@ -0,0 +1,36 @@ +# Dev / prod column type mapping + +Development and tests run Prisma against SQLite (`prisma/schema.prisma`, +`file:./dev.db`). Production storage is PostgreSQL, created by the raw SQL +migrations in `migrations/*.sql`. SQLite has no exact decimal, JSON or +timezone-aware timestamp type, so those columns use different types on each +side: + +| PostgreSQL (prod) | Prisma / SQLite (dev) | Encoding rule | +|------------------------------|-----------------------|---------------| +| `NUMERIC(38, 18)` | `String` | Plain decimal string, at most 20 integer and 18 fractional digits. Parse and do arithmetic with `NumericDecimal` and write with `toNumeric38_18` (`src/numericColumns.ts`); plain `Decimal` keeps only 20 significant digits and rounds silently. | +| `NUMERIC(12, 6)` | `String` | Plain decimal string. | +| `JSONB` | `String` | `JSON.stringify` on write, `JSON.parse` on read. | +| `TIMESTAMPTZ`, `DATE` | `DateTime` | Always UTC. | + +The column-by-column mapping lives in `prisma/postgres-type-mapping.json`. +Every PostgreSQL `NUMERIC` / `JSONB` / `TIMESTAMPTZ` / `DATE` column must be +listed there, either with the Prisma field that mirrors it or `null` when it +has no dev counterpart. + +## Drift check + +```bash +npm run db:check-types +``` + +`scripts/check-type-mapping.js` parses the schema, the migrations and the +mapping (no database needed) and fails when: + +- a new `NUMERIC` / `JSONB` / `TIMESTAMPTZ` / `DATE` column is not in the mapping, +- a mapped column or Prisma field no longer exists, +- a mapped Prisma field's type differs from the mapping for its PostgreSQL type + (for example an amount changed to `Float`). + +It runs in the Backend Governance workflow next to `db:check-drift`, which +checks the applied PostgreSQL migrations themselves. diff --git a/backend/package.json b/backend/package.json index e99ceb44d..79a46da4b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "snapshots:check": "tsx scripts/check-schema-snapshots.ts", "db:migrate": "node scripts/migrate-postgres.js", "db:check-drift": "node scripts/check-postgres-drift.js", + "db:check-types": "node scripts/check-type-mapping.js", "adrs:check": "node scripts/check-adrs.js", "prisma:generate": "prisma generate --schema prisma/schema.prisma", "prisma:schema-check": "prisma validate --schema prisma/schema.prisma && prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code", diff --git a/backend/prisma/postgres-type-mapping.json b/backend/prisma/postgres-type-mapping.json new file mode 100644 index 000000000..ab5ebb12e --- /dev/null +++ b/backend/prisma/postgres-type-mapping.json @@ -0,0 +1,29 @@ +{ + "$comment": "Dev (Prisma/SQLite) <-> prod (migrations/*.sql, PostgreSQL) type mapping. Checked by scripts/check-type-mapping.js; see docs/DB_TYPE_MAPPING.md.", + "typeMap": { + "NUMERIC(38,18)": "String", + "NUMERIC(12,6)": "String", + "JSONB": "String", + "TIMESTAMPTZ": "DateTime", + "DATE": "DateTime" + }, + "columns": { + "transactions.amount": "Transaction.amount", + "transactions.metadata": null, + "transactions.created_at": "Transaction.timestamp", + "transactions.updated_at": null, + "idempotency_keys.response_body": "IdempotencyKey.responseBody", + "idempotency_keys.locked_until": null, + "idempotency_keys.expires_at": "IdempotencyKey.expiresAt", + "idempotency_keys.created_at": "IdempotencyKey.createdAt", + "idempotency_keys.updated_at": null, + "vault_metrics_snapshots.total_assets": "SharePriceSnapshot.totalAssets", + "vault_metrics_snapshots.total_shares": "SharePriceSnapshot.totalShares", + "vault_metrics_snapshots.share_price": "SharePriceSnapshot.sharePrice", + "vault_metrics_snapshots.apy": null, + "vault_metrics_snapshots.recorded_at": "SharePriceSnapshot.recordedAt", + "apy_snapshots.date": null, + "apy_snapshots.apy": null, + "apy_snapshots.created_at": null + } +} diff --git a/backend/scripts/check-type-mapping.js b/backend/scripts/check-type-mapping.js new file mode 100644 index 000000000..27652a09a --- /dev/null +++ b/backend/scripts/check-type-mapping.js @@ -0,0 +1,106 @@ +'use strict'; +/** + * Static dev/prod type drift check (issue #1546). + * + * The dev database is Prisma on SQLite (`prisma/schema.prisma`), production + * storage is PostgreSQL (`migrations/*.sql`). SQLite has no NUMERIC, JSONB or + * TIMESTAMPTZ, so those columns are stored as Prisma `String` / `DateTime`. + * `prisma/postgres-type-mapping.json` records that mapping; this check fails + * when either side changes without the mapping being updated: + * + * - a NUMERIC / JSONB / TIMESTAMPTZ / DATE column is not listed, + * - a listed column or Prisma field no longer exists, + * - a mapped Prisma field's type differs from the mapping for its Postgres type. + * + * Needs no database, so it runs in CI next to `db:check-drift`. + */ +const fs = require('fs'); +const path = require('path'); + +const backendRoot = path.resolve(__dirname, '..'); +const DIVERGENT_TYPE = /^(NUMERIC|JSONB|TIMESTAMPTZ|DATE)\b/; + +/** Returns `{ "table.column": "PGTYPE" }` for every CREATE TABLE column. */ +function parsePostgresColumns(sql) { + const columns = {}; + const tablePattern = /CREATE TABLE(?: IF NOT EXISTS)?\s+(\w+)\s*\(([\s\S]*?)\n\);/gi; + for (const [, table, body] of sql.matchAll(tablePattern)) { + for (const line of body.split('\n')) { + const match = line.trim().match(/^(\w+)\s+([A-Z]+(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))?)/); + if (!match || /^(PRIMARY|UNIQUE|CHECK|CONSTRAINT|FOREIGN)$/i.test(match[1])) continue; + columns[`${table}.${match[1]}`] = match[2].replace(/\s+/g, '').toUpperCase(); + } + } + return columns; +} + +/** Returns `{ "Model.field": "Type" }` (optional `?` / list `[]` stripped). */ +function parsePrismaFields(schema) { + const fields = {}; + for (const [, model, body] of schema.matchAll(/^model\s+(\w+)\s*\{([\s\S]*?)^\}/gm)) { + for (const line of body.split('\n')) { + const match = line.trim().match(/^(\w+)\s+(\w+)(\?|\[\])?/); + if (match) fields[`${model}.${match[1]}`] = match[2]; + } + } + return fields; +} + +function checkTypeMapping({ sql, schema, mapping }) { + const columns = parsePostgresColumns(sql); + const fields = parsePrismaFields(schema); + const errors = []; + + for (const [column, pgType] of Object.entries(columns)) { + if (DIVERGENT_TYPE.test(pgType) && !(column in mapping.columns)) { + errors.push(`${column} (${pgType}) is not listed in postgres-type-mapping.json`); + } + } + + for (const [column, field] of Object.entries(mapping.columns)) { + const pgType = columns[column]; + if (!pgType) { + errors.push(`${column} is mapped but not defined in migrations/*.sql`); + continue; + } + if (field === null) continue; + const expected = mapping.typeMap[pgType]; + if (!expected) { + errors.push(`${column}: no typeMap entry for ${pgType}`); + } else if (!fields[field]) { + errors.push(`${column}: Prisma field ${field} does not exist`); + } else if (fields[field] !== expected) { + errors.push(`${column} (${pgType}) maps to ${field} which is ${fields[field]}, expected ${expected}`); + } + } + + return errors; +} + +function loadSources(root = backendRoot) { + const migrationsDir = path.join(root, 'migrations'); + const sql = fs + .readdirSync(migrationsDir) + .filter((file) => file.endsWith('.sql')) + .sort() + .map((file) => fs.readFileSync(path.join(migrationsDir, file), 'utf8')) + .join('\n'); + return { + sql, + schema: fs.readFileSync(path.join(root, 'prisma', 'schema.prisma'), 'utf8'), + mapping: JSON.parse( + fs.readFileSync(path.join(root, 'prisma', 'postgres-type-mapping.json'), 'utf8'), + ), + }; +} + +if (require.main === module) { + const errors = checkTypeMapping(loadSources()); + if (errors.length > 0) { + console.error(`Dev/prod type drift detected:\n- ${errors.join('\n- ')}`); + process.exit(1); + } + console.log('Prisma (SQLite) and PostgreSQL column types match postgres-type-mapping.json'); +} + +module.exports = { checkTypeMapping, loadSources, parsePostgresColumns, parsePrismaFields }; diff --git a/backend/src/__tests__/numericColumns.test.ts b/backend/src/__tests__/numericColumns.test.ts new file mode 100644 index 000000000..48b8b3068 --- /dev/null +++ b/backend/src/__tests__/numericColumns.test.ts @@ -0,0 +1,50 @@ +import Decimal from 'decimal.js'; +import { NumericDecimal, toNumeric38_18 } from '../numericColumns'; +import { + checkTypeMapping, + loadSources, +} from '../../scripts/check-type-mapping'; + +/** Largest value PostgreSQL NUMERIC(38, 18) can hold. */ +const MAX_NUMERIC = '99999999999999999999.999999999999999999'; +const SMALLEST_STEP = '0.000000000000000001'; + +describe('NUMERIC(38, 18) precision (issue #1546)', () => { + it('round-trips the full 38-digit range through the String encoding', () => { + for (const value of [MAX_NUMERIC, `-${MAX_NUMERIC}`, SMALLEST_STEP, '0', '1.5']) { + expect(toNumeric38_18(value)).toBe(value); + expect(toNumeric38_18(toNumeric38_18(value))).toBe(value); + } + }); + + it('keeps sums exact where default Decimal precision rounds', () => { + const sum = new NumericDecimal('99999999999999999999.999999999999999998').plus(SMALLEST_STEP); + expect(toNumeric38_18(sum)).toBe(MAX_NUMERIC); + + const lossy = new Decimal('99999999999999999999.999999999999999998').plus(SMALLEST_STEP); + expect(lossy.toFixed()).not.toBe(MAX_NUMERIC); + }); + + it('rejects values PostgreSQL would reject', () => { + expect(() => toNumeric38_18('100000000000000000000')).toThrow(RangeError); + expect(() => toNumeric38_18('0.0000000000000000001')).toThrow(RangeError); + expect(() => toNumeric38_18('Infinity')).toThrow(RangeError); + expect(() => toNumeric38_18('NaN')).toThrow(RangeError); + }); +}); + +describe('dev/prod type mapping check', () => { + it('passes for the committed schema, migrations and mapping', () => { + expect(checkTypeMapping(loadSources())).toEqual([]); + }); + + it('flags an unlisted NUMERIC column and a mismatched Prisma type', () => { + const sources = loadSources(); + const sql = `${sources.sql}\nCREATE TABLE IF NOT EXISTS fees (\n total NUMERIC(38, 18) NOT NULL\n);`; + const schema = sources.schema.replace(/(model Transaction \{[\s\S]*?amount\s+)String/, '$1Float'); + expect(checkTypeMapping({ ...sources, sql, schema })).toEqual([ + 'fees.total (NUMERIC(38,18)) is not listed in postgres-type-mapping.json', + 'transactions.amount (NUMERIC(38,18)) maps to Transaction.amount which is Float, expected String', + ]); + }); +}); diff --git a/backend/src/numericColumns.ts b/backend/src/numericColumns.ts new file mode 100644 index 000000000..548bd1efc --- /dev/null +++ b/backend/src/numericColumns.ts @@ -0,0 +1,39 @@ +import Decimal from 'decimal.js'; + +/** + * Encoding contract for Prisma `String` columns that map to PostgreSQL + * `NUMERIC(38, 18)` (see prisma/postgres-type-mapping.json, issue #1546). + * + * SQLite has no exact decimal type, so dev stores these amounts as strings. + * Decimal.js defaults to 20 significant digits, which silently rounds values + * that PostgreSQL stores exactly; arithmetic on these columns must use + * `NumericDecimal`, and values must pass `toNumeric38_18` to be accepted by + * both databases. + */ +export const NUMERIC_PRECISION = 38; +export const NUMERIC_SCALE = 18; + +export const NumericDecimal = Decimal.clone({ precision: NUMERIC_PRECISION }); + +const MAX_INTEGER_DIGITS = NUMERIC_PRECISION - NUMERIC_SCALE; + +/** + * Returns `value` as a plain decimal string with at most 18 fractional + * digits, exactly as PostgreSQL `NUMERIC(38, 18)` would store it. + * + * @throws RangeError if the value is not finite, has more than 18 fractional + * digits, or more than 20 integer digits (PostgreSQL would reject it). + */ +export function toNumeric38_18(value: Decimal.Value): string { + const decimal = new NumericDecimal(value); + if (!decimal.isFinite()) { + throw new RangeError(`NUMERIC(38, 18) value must be finite: ${String(value)}`); + } + if (decimal.decimalPlaces() > NUMERIC_SCALE) { + throw new RangeError(`NUMERIC(38, 18) allows ${NUMERIC_SCALE} fractional digits: ${String(value)}`); + } + if (decimal.abs().trunc().toFixed(0).replace(/^0$/, '').length > MAX_INTEGER_DIGITS) { + throw new RangeError(`NUMERIC(38, 18) allows ${MAX_INTEGER_DIGITS} integer digits: ${String(value)}`); + } + return decimal.toFixed(); +} From 0e64f4e57aee424739dba6ed5ffb9be5d8357672 Mon Sep 17 00:00:00 2001 From: Douglas Francis Date: Sat, 17 Oct 2026 12:32:25 +0100 Subject: [PATCH 4/4] refactor(backend): extract feature routers from index.ts Move route handlers out of the 5.6k-line src/index.ts into feature routers: - routes/vault.ts: vault summary / metrics / APY / health (legacy, v1, v2) - routes/transactions.ts: transaction exports and admin backfill - routes/webhooks.ts: webhook verification, /api/v1/webhooks and /admin/webhooks/* (incl. deduplication) - routes/exports.ts: /admin/exports/* and /admin/reports/exports/* - routes/admin.ts: remaining /admin/* operational endpoints - routes/shared.ts: helpers and the health cache used by several modules Handler bodies are moved verbatim. index.ts (now ~880 lines) keeps the middleware stack, health/ops routes and startup wiring, and mounts the routers where the routes used to be registered, so the effective route table, handler chains and ordering of overlapping routes are unchanged; the full Jest suite gives identical per-test results before and after. Closes #1545 --- backend/src/index.ts | 4915 +--------------------------- backend/src/routes/admin.ts | 3263 ++++++++++++++++++ backend/src/routes/exports.ts | 366 +++ backend/src/routes/shared.ts | 114 + backend/src/routes/transactions.ts | 259 ++ backend/src/routes/vault.ts | 256 ++ backend/src/routes/webhooks.ts | 492 +++ 7 files changed, 4833 insertions(+), 4832 deletions(-) create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/routes/exports.ts create mode 100644 backend/src/routes/shared.ts create mode 100644 backend/src/routes/transactions.ts create mode 100644 backend/src/routes/vault.ts create mode 100644 backend/src/routes/webhooks.ts diff --git a/backend/src/index.ts b/backend/src/index.ts index 601c238f7..4a1508b1d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -9,20 +9,9 @@ import { initTracing, shutdownTracing, getCurrentTraceId } from './tracing'; initTracing(); import express, { Express, Request, Response, NextFunction, ErrorRequestHandler } from 'express'; -import NodeCache from 'node-cache'; -import { - loginHandler, - nonceHandler, - refreshHandler, - requireAuth, - verifyJwt, - revokeAccessToken, - revokeAllAccessTokens, - revokeCurrentSession, - revokeAllSessions, -} from './auth'; +import { loginHandler, nonceHandler, refreshHandler, requireAuth, revokeAccessToken, revokeAllAccessTokens, revokeCurrentSession, revokeAllSessions } from './auth'; import vaultListRouter from './routes/vaults'; -import { loginHandler, nonceHandler, refreshHandler, requireAuth, verifyJwt } from './auth'; +import { loginHandler, nonceHandler, refreshHandler, requireAuth } from './auth'; import { authLimiter, authIpLimiter, @@ -34,23 +23,8 @@ import { getRateLimitMonitorSnapshot, loadConfig as loadRateLimiterConfig, } from './rateLimiter'; -import { idempotencyStore } from './idempotency'; -import { createAdminAuditMiddleware, getAuditLogs, countAuditLogs, getAuditLogMetrics } from './auditLog'; -import { AuditLogQuerySchema } from './middleware/validate'; -import { recordAdminAuditLog } from './adminAudit'; -import { - recordAdminConfigChange, listAdminConfigChanges, getActorFromRequest -} from './adminConfigChangeAudit'; -import { featureFlags } from './featureFlags'; -import { - startImpersonationSession, - endImpersonationSession, - validateImpersonationSession, - listImpersonationSessions, - resolveImpersonationSessionContext, - type ImpersonationSessionRecord, -} from './impersonationSessionService'; -import { generateAdminReceipt, getAdminReceipt, listAdminReceipts, verifyReceiptSignature } from './adminReceipt'; +import { createAdminAuditMiddleware } from './auditLog'; +import { getActorFromRequest } from './adminConfigChangeAudit'; import { startApySnapshotScheduler } from './apySnapshot'; import { startDbBackupScheduler } from './dbBackupJob'; import { startPositionReconciliationScheduler, startLedgerReconciliationScheduler } from './positionReconciliationJob'; @@ -70,45 +44,16 @@ import { } from './errors'; import { corsMiddleware } from './middleware/cors'; import { geofencingMiddleware } from './middleware/geofencing'; -import { cacheMiddleware, invalidateCache, getCacheStats, registerInvalidationHook } from './middleware/cache'; +import { registerInvalidationHook } from './middleware/cache'; import { invalidateVaultCaches } from './vaultDataCache'; -import { getRedisCacheHealth, redisCacheClient } from './redisCache'; -import { validate, LoginSchema, NonceRequestSchema, RefreshSchema, WebhookRegisterSchema, WebhookUpdateSchema, ApyBackfillBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, FeatureFlagOverrideSchema, CacheInvalidateSchema, EventReplayBodySchema, WithdrawalLimitOverrideSchema, AllowlistWalletBodySchema, ImpersonationSessionBodySchema, ApiKeyRegisterSchema, ApiKeyRotateSchema, ApiKeyRevokeSchema, WebhookVerifyBodySchema, BulkExportBodySchema, TransactionBackfillBodySchema, GovernanceSnapshotExportSchema, ReportExportBodySchema, ChecksumVerifyBodySchema, DeadLetterResolveSchema, DeadLetterIdsSchema, DeadLetterProcessSchema, ScopedTokenCreateSchema, PaginationQuerySchema, WebhookListQuerySchema, IdParamSchema, WindowIdParamSchema, CreateVaultBodySchema } from './middleware/validate'; +import { redisCacheClient } from './redisCache'; +import { validate, LoginSchema, NonceRequestSchema, RefreshSchema } from './middleware/validate'; import { tieredJsonBodyParser } from './middleware/payloadLimit'; import { requireSignedWalletAction } from './middleware/walletSignedAction'; -import { timeoutMiddleware, createTimeoutFor } from './middleware/timeoutMiddleware'; -import { - setWithdrawalLimitOverride, - listWithdrawalLimitAuditEntries, -} from './middleware/withdrawalDailyLimit'; +import { timeoutMiddleware } from './middleware/timeoutMiddleware'; import { adaptiveThrottleMiddleware } from './middleware/adaptiveThrottle'; -import { - validateApiKey, - authenticateApiKeyValue, - registerApiKey, - rotateApiKey, - revokeApiKey, - getApiKeyMetadata, - restoreApiKey, - hasRequiredApiKeyRole, - normalizeApiKeyRole, -} from './middleware/apiKeyAuth'; -import { - API_KEY_AUDIT_ACTIONS, - isApiKeyHash, - getApiKeyFingerprintFromHash, - getApiKeyFingerprintFromValue, - resolveApiKeyAuditActor, - recordApiKeyAuditEvent, - listApiKeyAuditEvents, -} from './apiKeyAudit'; -import { - addAddress, - removeAddress, - listAddresses, - allowlistSize, -} from './middleware/allowlist'; -import { adminRbacMiddleware, assertWebhookParameterUpdate } from './middleware/rbac'; +import { validateApiKey } from './middleware/apiKeyAuth'; +import { adminRbacMiddleware } from './middleware/rbac'; import { apiVersionMiddleware } from './middleware/versionNegotiation'; import { versionRoutingMiddleware } from './middleware/versionRouting'; import { createVersionDiscoveryRouter } from './routes/apiVersions'; @@ -119,22 +64,10 @@ import vaultsListRouter from './routes/vaults'; import walletAliasRouter from './walletAliasEndpoints'; import { walletAliasMappingService } from './walletAliasService'; import transactionRouter from './transactionEndpoints'; -import { - buildPortfolioHoldingsResponse, - buildTransactionExportArtifact, - buildTransactionsResponse, - buildVaultHistoryResponse, - TRANSACTION_PAGINATION_CONFIG, -} from './listEndpoints'; -import { createPaginatedResponse, createPaginationEnvelope, encodeCursor } from './pagination'; +import { buildTransactionsResponse } from './listEndpoints'; import listRouter from './listEndpoints'; -import referralRouter, { - buildReferralCodeErrorBody, - buildReferralStatsErrorBody, - buildReferralStatsNotFoundBody, -} from './referralEndpoints'; +import referralRouter from './referralEndpoints'; import auditLogRouter from './auditLogEndpoints'; -import { referralService } from './referralService'; import { register, httpRequestCount, @@ -151,89 +84,13 @@ import { latencyMonitoringService } from './latencyMonitoring'; import { listEndpointSlaRegistry } from './endpointSlaRegistry'; import { getEventPollingHealth, startEventPollingService, stopEventPollingService } from './eventPollingService'; import { eventOutboxService } from './eventOutbox'; -import { prisma, getPrismaRuntimeConfig } from './prisma'; -import { getPrismaClient } from './prismaClient'; -import { computeVaultApy } from './services/apy'; -import { - verifyWebhookEndpoint, - registerWebhookEndpoint, - updateWebhookEndpoint, - deleteWebhookEndpoint, - restoreWebhookEndpoint, - listWebhookEndpoints, - listWebhookDeliveryPage, - getWebhookDeliveryMetrics, - createWebhookSignature, - verifyWebhookSignature, - listWebhookDeadLetters, - retryWebhookDeadLetter, - initializeWebhookEndpoints, - WEBHOOK_SCHEMA_VERSION, -} from './webhookDelivery'; -import { webhookDeduplicationStore } from './webhookDeduplication'; +import { prisma } from './prisma'; +import { initializeWebhookEndpoints, WEBHOOK_SCHEMA_VERSION } from './webhookDelivery'; import { healthProbeService } from './healthProbe'; -import { writeAheadAuditLog } from './writeAheadAuditLog'; -import { - withdrawalRecoveryCoordinator, - type WithdrawalSagaStatus, -} from './withdrawalRecovery'; -import { - maintenanceModeMiddleware, - getMaintenanceModeState, - updateMaintenanceModeState, - logMaintenanceTransition, -} from './maintenanceMode'; -import { - buildMaintenanceStatusPayload, - cancelMaintenanceWindow, - listMaintenanceWindows, - scheduleMaintenanceWindow, - startMaintenanceWindowScheduler, -} from './maintenanceWindow'; -import { - buildExportMetadataHeaderValue, - getExportJobById, - listExportJobs, - recordExportJob, - resolveExportGeneratedBy, -} from './exportJobs'; -import { parseUtcDateRange, DateRangeParseError } from './dateRange'; -import { backfillApySnapshots } from './apySnapshot'; -import { - getJobMetrics, - getJobHealthStatus, - listDeadLetters, - getDeadLetterRecord, - retryDeadLetter, - resolveDeadLetter, - discardDeadLetter, - bulkRetryDeadLetters, - bulkDiscardDeadLetters, - processDeadLetterQueue, - type JobName, - type DeadLetterStatus, -} from './jobGovernance'; -import { - createBulkExportJob, - getBulkExportJob, - listBulkExportJobs, - cancelBulkExportJob, - processBulkExportJob, - getBulkExportArtifact, -} from './bulkExportJobs'; -import { normalizeWalletAddress } from './walletUtils'; -import { emailQueueService } from './emailQueue'; -import { - createOrResumeTransactionBackfill, - getTransactionBackfillJob, - listTransactionBackfillJobs, -} from './transactionBackfill'; -import { - createExportManifest, - getExportManifestById, - listExportManifests, - verifyExportManifestChecksum, -} from './exportManifest'; +import { withdrawalRecoveryCoordinator } from './withdrawalRecovery'; +import { maintenanceModeMiddleware } from './maintenanceMode'; +import { buildMaintenanceStatusPayload, startMaintenanceWindowScheduler } from './maintenanceWindow'; +import { getJobHealthStatus } from './jobGovernance'; import { reconciliationReportHandler, automatedReconciliationSummaryHandler, @@ -241,16 +98,13 @@ import { import { diagnosticsBundleHandler } from './diagnosticsBundle'; import { errorBoundaryMiddleware } from './middleware/errorBoundary'; import { apiErrorContractMiddleware, sendApiError } from './middleware/apiError'; -import { - exportGovernanceSnapshots, - listGovernanceSnapshots, -} from './governanceSnapshotExport'; -import { - getIdempotencyRetentionMetrics, - pruneStaleIdempotencyRecords, - startIdempotencyRetentionScheduler, -} from './idempotencyRetention'; -import { scopedAdminTokenStore } from './scopedAdminTokens'; +import { startIdempotencyRetentionScheduler } from './idempotencyRetention'; +import vaultRoutes from './routes/vault'; +import transactionRoutes from './routes/transactions'; +import webhookRoutes, { apiV1WebhookRoutes } from './routes/webhooks'; +import exportRoutes from './routes/exports'; +import adminRoutes from './routes/admin'; +import { cache } from './routes/shared'; declare global { namespace Express { @@ -269,7 +123,6 @@ const port = process.env.PORT || 3000; const nodeEnv = process.env.NODE_ENV || 'development'; const logLevel = (process.env.LOG_LEVEL || (nodeEnv === 'development' ? 'debug' : 'info')) as LogLevel; const drainTimeout = parseInt(process.env.DRAIN_TIMEOUT_MS || '30000', 10); -const cacheVaultMetricsTtl = parseInt(process.env.CACHE_TTL_MS || process.env.CACHE_VAULT_METRICS_TTL_MS || '60000', 10); // Configure logger logger.configure(logLevel); @@ -285,412 +138,6 @@ void walletAliasMappingService.loadFromDatabase().catch((error) => { }); }); -// Health check cache to track dependency status -const cache = new NodeCache({ stdTTL: 30 }); - -type VaultHealthResponse = { - vaultId: string; - status: 'healthy' | 'degraded'; - uptimeSeconds: number; - metrics: { - totalAssets: string; - totalShares: string; - sharePrice: string; - apy: number; - }; - dependencies: Record; - cached: boolean; - timestamp: string; -}; - -/** - * Reads the vault summary from the VaultState table and the most recent - * SharePriceSnapshot for the share price. Falls back to zeroed values when - * the database is unavailable (e.g., during startup or in tests). - */ -async function buildVaultSummaryResponseFromDb(): Promise<{ - totalAssets: string; - totalShares: string; - sharePrice: string; - apy: number; - timestamp: string; -}> { - try { - const prismaClient = getPrismaClient(); - const [vaultState, latestSnapshot] = await Promise.all([ - prismaClient.vaultState.findUnique({ where: { id: 1 } }), - prismaClient.sharePriceSnapshot - .findFirst({ orderBy: { recordedAt: 'desc' } }) - .catch(() => null), - ]); - - const totalAssets = vaultState?.totalAssets ?? '0'; - const totalShares = vaultState?.totalShares ?? '0'; - const sharePrice = latestSnapshot?.sharePrice ?? '1.000000'; - - return { - totalAssets, - totalShares, - sharePrice, - apy: 0, // APY is computed by the nightly snapshot job; placeholder until first run - timestamp: new Date().toISOString(), - }; - } catch { - // Fail-open: return safe zero values so the endpoint never 500s - return { - totalAssets: '0', - totalShares: '0', - sharePrice: '1.000000', - apy: 0, - timestamp: new Date().toISOString(), - }; - } -} - -async function buildVaultHealthResponse(vaultId: string): Promise { - const cacheKey = `vault-health:${vaultId}`; - const cached = cache.get>(cacheKey); - if (cached) { - return { ...cached, cached: true }; - } - - const [summary, probes] = await Promise.all([ - buildVaultSummaryResponseFromDb(), - healthProbeService.checkAll().catch( - () => ({}) as Record, - ), - ]); - const dependencies = Object.fromEntries( - Object.entries(probes).map(([name, state]) => [name, state.status]), - ) as Record; - const degraded = Object.values(dependencies).some((state) => state !== 'up'); - const body = { - vaultId, - status: degraded ? 'degraded' as const : 'healthy' as const, - uptimeSeconds: Math.floor(process.uptime()), - metrics: { - totalAssets: summary.totalAssets, - totalShares: summary.totalShares, - sharePrice: summary.sharePrice, - apy: summary.apy, - }, - dependencies, - timestamp: new Date().toISOString(), - }; - - cache.set(cacheKey, body, 5); - return { ...body, cached: false }; -} - -function resolveActingAdminAddress(req: Request): string { - const address = - req.get('x-admin-address') || - req.get('x-admin-id') || - req.get('x-wallet-address') || - 'unknown'; - return address === 'unknown' ? address : normalizeWalletAddress(address); -} - -function parseLimited(v: unknown, fallback: number, min: number, max: number): number { - const n = parseInt(String(v ?? ''), 10); - return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); -} - -function isDryRunRequest(req: Request): boolean { - const value = req.body?.dryRun ?? req.query.dryRun; - return value === true || value === 'true' || value === '1'; -} - -function countInclusiveDays(start: string, end: string): number { - const startMs = Date.parse(start + 'T00:00:00.000Z'); - const endMs = Date.parse(end + 'T00:00:00.000Z'); - return Math.floor((endMs - startMs) / 86400000) + 1; -} - -function paginateByLimit(rows: T[], limit: number): { data: T[]; hasNextPage: boolean } { - const hasNextPage = rows.length > limit; - return { - data: hasNextPage ? rows.slice(0, limit) : rows, - hasNextPage, - }; -} - -function sendStandardListEnvelope( - res: Response, - input: { - data: T[]; - limit: number; - hasNextPage?: boolean; - hasPrevPage?: boolean; - nextCursor?: string; - total?: number; - statusCode?: number; - extras?: Record; - }, -): void { - const payload = createPaginatedResponse(input.data, { - count: input.data.length, - total: input.total ?? input.data.length, - nextCursor: input.nextCursor ?? null, - prevCursor: null, - currentPage: null, - totalPages: null, - hasNextPage: input.hasNextPage ?? false, - hasPrevPage: input.hasPrevPage ?? false, - limit: input.limit, - }); - - res.status(input.statusCode ?? 200).json({ - ...payload, - ...(input.extras || {}), - }); -} - -async function buildReferralStatsSnapshot(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - - try { - const stats = await referralService.getReferralStats(normalizedWallet); - - if (!stats) { - // Must be the exact envelope GET /api/v1/referrals/:wallet returns, not - // a hand-rolled stand-in — admin impersonation is compared against that - // endpoint field-for-field by the governance contract test. - return { - statusCode: 404, - body: buildReferralStatsNotFoundBody(), - }; - } - - return { - statusCode: 200, - body: stats, - }; - } catch (error) { - // Same reasoning as the 404 above. The route reports a failed lookup as a - // 500 with the canonical envelope; letting the rejection escape instead - // would 500 the entire impersonation response with a different message, so - // one broken sub-resource would take the whole snapshot with it (#1319). - logger.log('error', 'Error building referral stats snapshot', { - error: error instanceof Error ? error.message : String(error), - wallet: normalizedWallet, - }); - return { - statusCode: 500, - body: buildReferralStatsErrorBody(), - }; - } -} - -async function buildReferralCodeSnapshot(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - - try { - const code = await referralService.getOrCreateReferralCode(normalizedWallet); - return { - statusCode: 200, - body: { code }, - }; - } catch (error) { - logger.log('error', 'Error building referral code snapshot', { - error: error instanceof Error ? error.message : String(error), - wallet: normalizedWallet, - }); - return { - statusCode: 500, - body: buildReferralCodeErrorBody(), - }; - } -} - -async function buildWalletTransactionsSnapshot(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - const prisma = getPrismaClient(); - // Shared with GET /api/v1/transactions so the proxy can never drift to a - // different page size than the endpoint it stands in for (#1322). - const limit = TRANSACTION_PAGINATION_CONFIG.defaultLimit ?? 20; - const where = { user: normalizedWallet }; - const [total, transactions] = await Promise.all([ - prisma.transaction.count({ where }), - prisma.transaction.findMany({ - where, - orderBy: { timestamp: 'desc' }, - take: limit + 1, - }), - ]); - const hasNextPage = transactions.length > limit; - const data = hasNextPage ? transactions.slice(0, limit) : transactions; - - return createPaginatedResponse( - data, - createPaginationEnvelope({ - count: data.length, - limit, - total, - hasNextPage, - hasPrevPage: false, - nextCursor: hasNextPage && data.length > 0 ? encodeCursor(data[data.length - 1].id) : null, - }), - ); -} - -async function buildImpersonatedVaultState(wallet: string) { - const normalizedWallet = normalizeWalletAddress(wallet); - return { - walletAddress: normalizedWallet, - // Impersonation must show an admin exactly what the wallet's own - // dashboard would show, so read the same DB-backed summary - // GET /api/v1/vault/summary serves. - summary: await buildVaultSummaryResponseFromDb(), - transactions: await buildWalletTransactionsSnapshot(normalizedWallet), - portfolioHoldings: buildPortfolioHoldingsResponse({ walletAddress: normalizedWallet }), - vaultHistory: buildVaultHistoryResponse({}), - referralStats: await buildReferralStatsSnapshot(normalizedWallet), - referralCode: await buildReferralCodeSnapshot(normalizedWallet), - }; -} - -function resolveTransactionExportAccess(req: Request): - | { kind: 'admin'; walletAddress?: string } - | { kind: 'user'; walletAddress: string } - | null { - const authHeader = req.get('authorization') || ''; - const walletAddress = - typeof req.query.walletAddress === 'string' ? normalizeWalletAddress(req.query.walletAddress) : undefined; - - const apiKeyMatch = authHeader.match(/^ApiKey\s+(.+)$/i); - if (apiKeyMatch) { - const authenticated = authenticateApiKeyValue(apiKeyMatch[1]); - if (!authenticated) { - return null; - } - req.authApiKeyHash = authenticated.hash; - req.authApiKeyRole = authenticated.role; - return { - kind: 'admin', - walletAddress: walletAddress || undefined, - }; - } - - const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); - if (!bearerMatch) { - return null; - } - - const payload = verifyJwt(bearerMatch[1]); - const userWallet = normalizeWalletAddress(payload.sub); - if (walletAddress && walletAddress !== userWallet) { - throw new Error('FORBIDDEN_WALLET_EXPORT'); - } - - return { - kind: 'user', - walletAddress: userWallet, - }; -} - -function buildTransactionExportFilename(format: 'csv' | 'json'): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - return `transaction-history-${timestamp}.${format}`; -} - -async function handleTransactionExport(req: Request, res: Response): Promise { - const format = req.query.format === 'csv' ? 'csv' : req.query.format === 'json' ? 'json' : null; - if (!format) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'format query parameter must be either csv or json', - }); - return; - } - - let access; - try { - access = resolveTransactionExportAccess(req); - } catch (error) { - if (error instanceof Error && error.message === 'FORBIDDEN_WALLET_EXPORT') { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Users may only export their own wallet transactions', - }); - return; - } - - res.status(401).json({ - error: 'Unauthorized', - status: 401, - message: error instanceof Error ? error.message : 'Invalid authorization header', - }); - return; - } - - if (!access) { - res.status(401).json({ - error: 'Unauthorized', - status: 401, - message: 'Authorization header must contain a Bearer token or ApiKey', - }); - return; - } - - const exportQuery = { - type: typeof req.query.type === 'string' ? req.query.type : undefined, - status: typeof req.query.status === 'string' ? req.query.status : undefined, - sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : undefined, - sortOrder: ( - req.query.sortOrder === 'asc' || req.query.sortOrder === 'desc' - ? req.query.sortOrder - : undefined - ) as 'asc' | 'desc' | undefined, - walletAddress: access.walletAddress, - startDate: typeof req.query.startDate === 'string' ? req.query.startDate : undefined, - endDate: typeof req.query.endDate === 'string' ? req.query.endDate : undefined, - }; - - try { - const artifact = buildTransactionExportArtifact(format, exportQuery); - const fileName = buildTransactionExportFilename(format); - const job = await recordExportJob({ - format, - fileName, - contentType: artifact.contentType, - checksum: artifact.checksum, - checksumAlgorithm: artifact.checksumAlgorithm, - generatedBy: resolveExportGeneratedBy(req), - walletAddress: access.walletAddress, - rowCount: artifact.rowCount, - filters: { - type: exportQuery.type || null, - status: exportQuery.status || null, - sortBy: exportQuery.sortBy || null, - sortOrder: exportQuery.sortOrder || null, - startDate: exportQuery.startDate || null, - endDate: exportQuery.endDate || null, - walletAddress: exportQuery.walletAddress || null, - }, - }); - - res.setHeader('Content-Type', artifact.contentType); - res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); - res.setHeader('X-Export-Job-Id', job.id); - res.setHeader('X-Export-Checksum', artifact.checksum); - res.setHeader('X-Export-Checksum-Algorithm', artifact.checksumAlgorithm); - res.setHeader('X-Export-Metadata', buildExportMetadataHeaderValue(job)); - res.status(200).send(artifact.body); - } catch (error) { - logger.log('error', 'Transaction export failed', { - error: error instanceof Error ? error.message : String(error), - }); - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to generate transaction export', - }); - } -} - // ─── Rate Limiting Middleware ──────────────────────────────────────────────── // Issue #455: Use the Redis-backed limiter factory from rateLimiter.ts. // @@ -1019,40 +466,8 @@ apiV1.post('/auth/login', authIpLimiter, authLimiter, authUserLimiter, validate( */ apiV1.post('/auth/refresh', authIpLimiter, authLimiter, authUserLimiter, validate({ body: RefreshSchema }), refreshHandler); -/** - * POST /api/v1/webhooks — authenticated clients register an HTTPS callback - * instead of polling for transaction/vault events. - */ -apiV1.post('/webhooks', requireAuth, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { - try { - const { url, eventTypes, enabled, secret } = req.body; - const endpoint = registerWebhookEndpoint({ - url, - eventTypes, - enabled: enabled ?? true, - secret, - }); - res.status(201).json({ - message: 'Webhook endpoint registered', - endpoint, - }); - } catch (error) { - res.status(422).json({ - error: 'Unprocessable Entity', - status: 422, - message: error instanceof Error ? error.message : 'Invalid webhook configuration', - }); - } -}); - -apiV1.get('/webhooks', requireAuth, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { - const includeDeleted = req.query.includeDeleted === 'true'; - const endpoints = listWebhookEndpoints(includeDeleted); - res.status(200).json({ - endpoints, - metrics: getWebhookDeliveryMetrics(), - }); -}); +// Webhook registration on /api/v1 (src/routes/webhooks.ts). +apiV1.use(apiV1WebhookRoutes); // Admin routes share API-key authentication and role-based authorization. app.use('/admin', validateApiKey, adminRbacMiddleware); @@ -1163,55 +578,6 @@ app.post('/auth/logout-all', (req: Request, res: Response) => { res.redirect(301, '/api/v1/auth/logout-all'); }); -// /api/vault/* → /api/v1/vault/* -app.get('/api/vault/summary', (_req: Request, res: Response) => { - res.setHeader('deprecation', 'true'); - res.redirect(301, '/api/v1/vault/summary'); -}); -app.get('/api/vault/transactions/export', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/vault/transactions/export'); -}); -app.get('/api/vault/metrics', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/vault/metrics'); -}); -app.get('/api/vault/apy', (_req: Request, res: Response) => { - res.redirect(301, '/api/v1/vault/apy'); -}); - -// /webhooks/verify → /api/v1/webhooks/verify -app.post('/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { - const { secret, payload, signature } = req.body || {}; - if (typeof secret !== 'string' || !secret.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'secret is required and must be a non-empty string', - }); - return; - } - - if (typeof payload === 'undefined') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'payload is required', - }); - return; - } - - const computedSignature = createWebhookSignature(secret, payload); - const verified = - typeof signature === 'string' && signature.length > 0 - ? verifyWebhookSignature(secret, payload, signature) - : null; - - res.status(200).json({ - algorithm: 'HMAC-SHA256', - signature: computedSignature, - verified, - }); -}); - // ─── Backward-compatibility redirects for list/router-mounted paths ────────── // Generic catch-all redirects for unversioned /vault/*, /referrals/*, // /transactions/*, /portfolio/* paths → /api/v1 equivalents. @@ -1232,3710 +598,74 @@ app.use('/portfolio', (req: Request, res: Response) => { res.redirect(301, `/api/v1/portfolio${req.path}${qs}`); }); -// ─── Versioned export & summary endpoints ──────────────────────────────── -app.get('/api/v1/vault/transactions/export', handleTransactionExport); - -// ─── Versioned vault summary/metrics/apy endpoints ─────────────────────── +// ─── Feature routers (src/routes/, issue #1545) ───────────────────────────── +// Mounted where these routes were previously registered, after the legacy +// redirects above, so request handling order is unchanged. +app.use(vaultRoutes); +app.use(transactionRoutes); +app.use(webhookRoutes); +app.use(exportRoutes); +app.use(adminRoutes); /** - * GET /api/v1/vaults/:id/health - monitoring-friendly vault health payload. - * - * Kept separate from /health so external monitors can target one vault and - * receive vault metrics, dependency state, uptime, and cache status. Responses - * are cached for five seconds to avoid turning health probes into DB pressure. + * Mock vault metrics poll cycle + * In a real application, this would fetch data from a database or Stellar RPC */ -app.get( - '/api/v1/vaults/:id/health', - readsLimiter, - createTimeoutFor.read({ - timeoutMs: 1500, - routeName: '/api/v1/vaults/:id/health', - message: 'Vault health took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_HEALTH_TIMEOUT', - message: 'Vault health is temporarily unavailable. Please retry shortly.', - timestamp: new Date().toISOString(), - }), - }), - async (req: Request, res: Response) => { - const health = await buildVaultHealthResponse(req.params.id); - res.status(health.status === 'healthy' ? 200 : 503).json(health); - }, -); - -app.get('/api/v2/vaults/:id/health', (req: Request, res: Response) => { - const qs = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''; - res.set('X-API-Preview', 'v2'); - res.redirect(307, `/api/v1/vaults/${encodeURIComponent(req.params.id)}/health${qs}`); -}); +const pollVaultMetrics = () => { + // Mock data for TVL and Share Price + const mockTvl = 1000000 + Math.random() * 100000; + const mockSharePrice = 1.25 + Math.random() * 0.05; -/** - * GET /api/v1/vaults/:id/apy - * - * Returns the annualised APY for the vault. Returns `apy: null` with - * `apyStatus: 'insufficient_data'` for new vaults that have zero shares or - * fewer than 2 price snapshots, preventing Infinity / NaN from reaching the - * frontend (Issue #1456). - */ -app.get( - '/api/v1/vaults/:id/apy', - readsLimiter, - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 1500, - routeName: '/api/v1/vaults/:id/apy', - message: 'Vault APY took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_APY_TIMEOUT', - message: 'Vault APY is temporarily unavailable. Please try again shortly.', - timestamp: new Date().toISOString(), - }), - }), - async (_req: Request, res: Response) => { - const result = await computeVaultApy(); - res.json({ - ...result, - timestamp: new Date().toISOString(), - }); - }, -); + updateVaultMetrics(mockTvl, mockSharePrice); -/** - * @openapi - * /vault/summary: - * get: - * summary: Vault summary - * description: Returns high-level vault metrics including total assets, shares, and APY. - * tags: [Vault] - * responses: - * 200: - * description: Vault summary metrics - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/VaultSummary' - */ -/** - * GET /api/v1/vault/summary – read-only summary; relaxed rate limit. - * Data is sourced from VaultState + SharePriceSnapshot (DB) and cached via - * the Redis-backed response cache. - */ -app.get( - '/api/v1/vault/summary', - readsLimiter, - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 1500, - routeName: '/api/v1/vault/summary', - message: 'Vault summary took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_SUMMARY_TIMEOUT', - message: 'Vault summary is temporarily unavailable. Please refresh in a moment.', - stale: true, - data: { - totalAssets: '0', - totalShares: '0', - sharePrice: '1.000000', - apy: 0, - timestamp: new Date().toISOString(), - }, - timestamp: new Date().toISOString(), - }), - }), - async (_req: Request, res: Response) => { - const summary = await buildVaultSummaryResponseFromDb(); - res.json(summary); - }, -); + logger.log('info', 'Vault metrics updated in Prometheus gauges', { + tvl: mockTvl, + sharePrice: mockSharePrice, + }); +}; -/** - * GET /api/v1/vault/metrics - Cache with configurable TTL - */ -app.get( - '/api/v1/vault/metrics', - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 2000, - routeName: '/api/v1/vault/metrics', - message: 'Vault metrics took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_METRICS_TIMEOUT', - message: 'Vault metrics are temporarily unavailable. Showing the last known state.', - stale: true, - timestamp: new Date().toISOString(), - }), - }), - (_req: Request, res: Response) => { - res.json({ - message: 'Vault metrics', - timestamp: new Date().toISOString(), - }); - }, -); +// Start poll cycle every 60 seconds (configurable) +const METRICS_POLL_INTERVAL = parseInt(process.env.METRICS_POLL_INTERVAL_MS || '60000', 10); +const metricsInterval = + process.env.NODE_ENV === 'test' + ? null + : setInterval(pollVaultMetrics, METRICS_POLL_INTERVAL); -/** - * GET /api/v1/vault/apy - Cache with configurable TTL - */ -app.get( - '/api/v1/vault/apy', - cacheMiddleware({ ttl: cacheVaultMetricsTtl }), - createTimeoutFor.read({ - timeoutMs: 1200, - routeName: '/api/v1/vault/apy', - message: 'Vault APY took too long to load', - fallbackResponse: () => ({ - error: 'Service Unavailable', - status: 503, - code: 'VAULT_APY_TIMEOUT', - message: 'Vault APY is temporarily unavailable. Please try again shortly.', - stale: true, - timestamp: new Date().toISOString(), - }), - }), - (_req: Request, res: Response) => { - res.json({ - message: 'Vault APY', - timestamp: new Date().toISOString(), - }); - }, -); +if (process.env.NODE_ENV !== 'test') { + pollVaultMetrics(); // Initial call + void initializeWebhookEndpoints(); +} -// ─── Admin Routes (with API key authentication) ────────────────────────────── +// Start latency monitoring +latencyMonitoringService.startMonitoring(); -/** - * POST /admin/apy/backfill - backfill missing APY snapshots for a date range - * Body: { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } - * Requires API key authentication. - */ -app.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBodySchema }), async (req: Request, res: Response) => { - const { start, end } = req.body; - if (!start || !end || typeof start !== 'string' || typeof end !== 'string') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`start` and `end` (YYYY-MM-DD) are required', - }); - return; - } +// ─── Event Polling Service (Issue: Event Replay) ──────────────────────────── +if (process.env.NODE_ENV !== 'test' && process.env.VAULT_CONTRACT_ID) { + startEventPollingService({ + rpcUrl: process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org', + contractId: process.env.VAULT_CONTRACT_ID, + pollIntervalMs: parseInt(process.env.EVENT_POLL_INTERVAL_MS || '10000', 10), + batchSize: parseInt(process.env.EVENT_REPLAY_BATCH_SIZE || '100', 10), + }); - const datePattern = /^\d{4}-\d{2}-\d{2}$/; - if (!datePattern.test(start) || !datePattern.test(end)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`start` and `end` must be in YYYY-MM-DD format', - }); - return; - } + // stopEventPollingService() is a no-op if the service was never started, + // so this is safe to register unconditionally alongside the block above. + shutdownHandler.onShutdown(async () => { + stopEventPollingService(); + }); +} - if (end < start) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`end` must be >= `start`', +// ─── Outbox Pattern Processor ───────────────────────────────────────── +// Starts the background outbox event relay processor. +// Writes from vault operations go to the EventOutbox table atomically; +// this processor reads them and delivers via the webhook system. +if (process.env.NODE_ENV !== 'test') { + eventOutboxService.replayOnStartup().catch((err) => { + logger.log('error', 'Outbox startup replay failed', { + error: err instanceof Error ? err.message : String(err), }); - return; - } - - const actor = resolveActingAdminAddress(req); - const jobStart = Date.now(); - - try { - if (isDryRunRequest(req)) { - void recordAdminAuditLog(req, 'apy.backfill.dry_run', 200, { - start, - end, - actor, - estimatedDates: countInclusiveDays(start, end), - }); - - res.status(200).json({ - dryRun: true, - message: 'APY backfill dry-run preview', - start, - end, - estimatedDates: countInclusiveDays(start, end), - wouldCreateSnapshots: true, - timestamp: new Date().toISOString(), - }); - return; - } - - const result = await backfillApySnapshots(start, end); - const durationMs = Date.now() - jobStart; - - const receipt = await generateAdminReceipt({ - action: 'apy.backfill', - actor, - input: { start, end }, - resultingState: { - created: result.created, - skipped: result.skipped, - durationMs, - }, - }); - - void recordAdminAuditLog(req, 'apy.backfill', 200, { - start, - end, - created: result.created, - skipped: result.skipped, - durationMs, - actor, - receiptId: receipt.id, - }); - - res.status(200).json({ - message: 'APY backfill completed', - start, - end, - created: result.created, - skipped: result.skipped, - dates: result.dates, - durationMs, - timestamp: new Date().toISOString(), - receipt, - }); - } catch (err) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: err instanceof Error ? err.message : String(err), - }); - } -}); - -/** - * GET /admin/maintenance - get current maintenance mode state - * Requires API key authentication. - */ -app.get('/admin/maintenance', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - maintenance: getMaintenanceModeState(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/maintenance - enable or disable maintenance mode - * Body: { enabled: boolean, reason?: string, retryAfterSeconds?: number } - * Requires API key authentication. - */ -app.post('/admin/maintenance', validateApiKey, validate({ body: MaintenanceToggleSchema }), async (req: Request, res: Response) => { - const { enabled, reason, retryAfterSeconds } = req.body; - if (typeof enabled !== 'boolean') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`enabled` (boolean) is required', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - const previous = getMaintenanceModeState(); - const dryRun = isDryRunRequest(req); - const preview = { - enabled: enabled ?? previous.enabled, - reason: - reason === undefined - ? previous.reason - : typeof reason === 'string' - ? reason.trim() || undefined - : undefined, - updatedAt: new Date().toISOString(), - updatedBy: actor, - retryAfterSeconds: retryAfterSeconds ?? previous.retryAfterSeconds, - }; - - if (dryRun) { - void recordAdminAuditLog(req, 'maintenance.toggle.dry_run', 200, { - enabled: preview.enabled, - previousEnabled: previous.enabled, - reason: preview.reason, - actor, - }); - - res.status(200).json({ - dryRun: true, - message: `Maintenance mode would be ${preview.enabled ? 'enabled' : 'disabled'}`, - previous, - maintenance: preview, - timestamp: new Date().toISOString(), - }); - return; - } - - const next = updateMaintenanceModeState({ enabled, reason, retryAfterSeconds, actor }); - - await recordAdminConfigChange({ - configType: 'maintenance', - action: 'toggle', - actor, - ipAddress: req.ip, - userAgent: req.get('user-agent'), - preChangeSnapshot: previous as unknown as Record, - postChangeSnapshot: next as unknown as Record, - metadata: { receiptId: '' }, - }); - - const receipt = await generateAdminReceipt({ - action: 'maintenance.toggle', - actor, - input: { enabled, reason, retryAfterSeconds }, - resultingState: { - enabled: next.enabled, - reason: next.reason, - retryAfterSeconds: next.retryAfterSeconds, - previousEnabled: previous.enabled, - }, - }); - - logMaintenanceTransition({ - enabled: next.enabled, - actor, - reason: next.reason, - retryAfterSeconds: next.retryAfterSeconds, - previousEnabled: previous.enabled, - }); - - void recordAdminAuditLog(req, 'maintenance.toggle', 200, { - enabled: next.enabled, - previousEnabled: previous.enabled, - reason: next.reason, - actor, - receiptId: receipt.id, - }); - - res.status(200).json({ - message: `Maintenance mode ${next.enabled ? 'enabled' : 'disabled'}`, - maintenance: next, - timestamp: new Date().toISOString(), - receipt, - }); -}); - -/** - * GET /admin/maintenance/windows - list scheduled maintenance windows - */ -app.get('/admin/maintenance/windows', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - windows: listMaintenanceWindows(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/maintenance/windows - schedule a maintenance window - * Body: { title: string, reason?: string, startsAt: string, endsAt: string } - */ -app.post('/admin/maintenance/windows', validateApiKey, validate({ body: MaintenanceWindowBodySchema }), (req: Request, res: Response) => { - const { title, reason, startsAt, endsAt } = req.body; - if (typeof title !== 'string' || !title.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`title` (string) is required', - }); - return; - } - if (typeof startsAt !== 'string' || typeof endsAt !== 'string') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`startsAt` and `endsAt` (ISO strings) are required', - }); - return; - } - - try { - const actor = resolveActingAdminAddress(req); - const window = scheduleMaintenanceWindow({ - title, - reason: typeof reason === 'string' ? reason : undefined, - startsAt, - endsAt, - actor, - }); - res.status(201).json({ window, timestamp: new Date().toISOString() }); - } catch (error) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: error instanceof Error ? error.message : String(error), - }); - } -}); - -/** - * DELETE /admin/maintenance/windows/:windowId - cancel a scheduled window - */ -app.delete('/admin/maintenance/windows/:windowId', validateApiKey, validate({ params: WindowIdParamSchema }), (req: Request, res: Response) => { - const cancelled = cancelMaintenanceWindow(req.params.windowId); - if (!cancelled) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Maintenance window not found', - }); - return; - } - res.status(200).json({ - cancelled: true, - windowId: req.params.windowId, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/config-changes - List admin configuration changes - * Requires API key authentication - */ -app.get('/admin/config-changes', validateApiKey, async (req: Request, res: Response) => { - const { configType, actor, start, end, limit } = req.query; - const configChanges = await listAdminConfigChanges({ - configType: typeof configType === 'string' ? configType : undefined, - actor: typeof actor === 'string' ? actor : undefined, - start: typeof start === 'string' ? start : undefined, - end: typeof end === 'string' ? end : undefined, - limit: typeof limit === 'string' ? parseInt(limit, 10) : 100, - }); - - res.json({ - data: configChanges, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/feature-flags/overrides - List active feature flag overrides - * Requires API key authentication - */ -app.get('/admin/feature-flags/overrides', validateApiKey, async (_req: Request, res: Response) => { - const overrides = await featureFlags.listActiveOverrides(); - res.json({ - data: overrides, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/feature-flags/overrides - Create a new feature flag override - * Requires API key authentication - * Body: { - * flagName: string, - * enabled: boolean, - * scopeType: "wallet" | "environment", - * scopeValue?: string, - * expiresAt: string (ISO 8601) - * } - */ -app.post('/admin/feature-flags/overrides', validateApiKey, validate({ body: FeatureFlagOverrideSchema }), async (req: Request, res: Response) => { - const { flagName, enabled, scopeType, scopeValue, expiresAt } = req.body; - - if (!flagName || typeof flagName !== 'string') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`flagName` (string) is required' - }); - return; - } - - if (typeof enabled !== 'boolean') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`enabled` (boolean) is required' - }); - return; - } - - if (!scopeType || !['wallet', 'environment'].includes(scopeType)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`scopeType` must be "wallet" or "environment"' - }); - return; - } - - if (scopeType === 'wallet' && (!scopeValue || typeof scopeValue !== 'string')) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`scopeValue` (string) is required for wallet scope' - }); - return; - } - - if (scopeType === 'environment' && (!scopeValue || typeof scopeValue !== 'string')) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`scopeValue` (string) is required for environment scope' - }); - return; - } - - if (!expiresAt || typeof expiresAt !== 'string') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`expiresAt` (ISO 8601 string) is required' - }); - return; - } - - const expiresAtDate = new Date(expiresAt); - if (isNaN(expiresAtDate.getTime()) || expiresAtDate <= new Date()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`expiresAt` must be a valid future date in ISO 8601 format' - }); - return; - } - - const actor = resolveActingAdminAddress(req); - const override = await featureFlags.createOverride( - flagName, - enabled, - scopeType as 'wallet' | 'environment', - scopeValue, - expiresAtDate, - actor - ); - - res.status(201).json({ - message: 'Feature flag override created successfully', - data: override, - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/feature-flags/overrides/:id - Delete a feature flag override - * Requires API key authentication - */ -app.delete('/admin/feature-flags/overrides/:id', validateApiKey, async (req: Request, res: Response) => { - const { id } = req.params; - if (!id) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'Override ID is required' - }); - return; - } - - try { - await featureFlags.deleteOverride(id); - res.status(200).json({ - message: 'Feature flag override deleted successfully', - timestamp: new Date().toISOString(), - }); - } catch (err) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Feature flag override not found' - }); - } -}); - -/** - * GET /admin/cache/stats - Get cache statistics including hit rate (R8) and Redis status - * Requires API key authentication - */ -app.get('/admin/cache/stats', validateApiKey, async (_req: Request, res: Response) => { - const stats = getCacheStats(); - // Compute hit rate from Prometheus counters by reading the registry metrics text - let hitRate: number | null = null; - try { - const metricsText = await register.metrics(); - const hitMatch = metricsText.match(/^cache_hit_count(?:\{[^}]*\})?\s+([\d.]+)/m); - const missMatch = metricsText.match(/^cache_miss_count(?:\{[^}]*\})?\s+([\d.]+)/m); - // Sum all label combinations - const hitTotal = hitMatch - ? metricsText - .split('\n') - .filter((l) => l.startsWith('cache_hit_count')) - .reduce((acc, l) => { - const m = l.match(/\s+([\d.]+)$/); - return acc + (m ? parseFloat(m[1]) : 0); - }, 0) - : 0; - const missTotal = missMatch - ? metricsText - .split('\n') - .filter((l) => l.startsWith('cache_miss_count')) - .reduce((acc, l) => { - const m = l.match(/\s+([\d.]+)$/); - return acc + (m ? parseFloat(m[1]) : 0); - }, 0) - : 0; - const total = hitTotal + missTotal; - hitRate = total > 0 ? parseFloat((hitTotal / total).toFixed(4)) : null; - } catch { - hitRate = null; - } - - const redisHealth = await getRedisCacheHealth(); - - res.json({ - entryCount: stats.size, - entries: stats.entries, - hitRate, - redis: { - configured: redisCacheClient.isConfigured, - ready: redisCacheClient.isReady, - health: redisHealth, - }, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/cache/redis-status - Detailed Redis cache connection and health status - * Requires API key authentication - */ -app.get('/admin/cache/redis-status', validateApiKey, async (_req: Request, res: Response) => { - const redisHealth = await getRedisCacheHealth(); - const pingResponse = await redisCacheClient.ping(); - - res.json({ - configured: redisCacheClient.isConfigured, - ready: redisCacheClient.isReady, - health: redisHealth, - ping: pingResponse, - fallbackActive: redisCacheClient.isConfigured && !redisCacheClient.isReady, - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/cache - Clear entire cache or by regex pattern (R8) - * ?pattern= removes only matching entries - * Requires API key authentication - */ -app.delete('/admin/cache', validateApiKey, (req: Request, res: Response) => { - const pattern = typeof req.query.pattern === 'string' ? req.query.pattern : undefined; - - if (pattern !== undefined) { - if (pattern.trim() === '') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'pattern query parameter must not be empty; omit it to clear the entire cache', - }); - return; - } - try { - new RegExp(pattern); - } catch (e) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: `Invalid regex pattern "${pattern}": ${e instanceof Error ? e.message : String(e)}`, - }); - return; - } - } - - const removed = invalidateCache(pattern); - res.json({ - removed, - pattern: pattern ?? null, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/cache/invalidate - Invalidate cache by pattern (legacy endpoint) - * Requires API key authentication - */ -app.post('/admin/cache/invalidate', validateApiKey, validate({ body: CacheInvalidateSchema }), (req: Request, res: Response) => { - const { pattern } = req.body; - if (isDryRunRequest(req)) { - res.json({ - dryRun: true, - message: 'Cache invalidation dry-run preview', - pattern, - wouldInvalidate: true, - stats: getCacheStats(), - timestamp: new Date().toISOString(), - }); - return; - } - - invalidateCache(pattern); - res.json({ - message: 'Cache invalidated', - pattern, - stats: getCacheStats(), - }); -}); - -/** - * POST /admin/events/replay - Manual admin endpoint to replay events for a specific ledger range - * Requires API key authentication - */ -app.post('/admin/events/replay', validateApiKey, validate({ body: EventReplayBodySchema }), async (req: Request, res: Response) => { - try { - const { fromLedger, toLedger } = req.body; - - if (fromLedger === undefined || toLedger === undefined) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'fromLedger and toLedger are required in request body', - }); - return; - } - - if (typeof fromLedger !== 'number' || typeof toLedger !== 'number') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'fromLedger and toLedger must be numbers', - }); - return; - } - - // Validate ledger range - if (fromLedger < 0 || toLedger < 0 || fromLedger > toLedger) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'fromLedger must be >= 0, toLedger must be >= 0, and fromLedger must be <= toLedger', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - if (isDryRunRequest(req)) { - void recordAdminAuditLog(req, 'events.replay.manual.dry_run', 200, { - fromLedger, - toLedger, - ledgerCount: toLedger - fromLedger + 1, - actor, - timestamp: new Date().toISOString(), - }); - - res.status(200).json({ - dryRun: true, - message: 'Event replay dry-run preview', - fromLedger, - toLedger, - ledgerCount: toLedger - fromLedger + 1, - wouldReplay: true, - timestamp: new Date().toISOString(), - }); - return; - } - - // Import the replay function - const { replayEventsForRange } = await import('./eventPollingService'); - - const startTime = Date.now(); - const result = await replayEventsForRange(fromLedger, toLedger); - const duration = Date.now() - startTime; - - const receipt = await generateAdminReceipt({ - action: 'events.replay.manual', - actor, - input: { fromLedger, toLedger }, - resultingState: { - processedCount: result.processedCount, - duplicateCount: result.duplicateCount, - durationMs: duration, - }, - }); - - // Record replay job metadata - void recordAdminAuditLog(req, 'events.replay.manual', 200, { - fromLedger, - toLedger, - processedCount: result.processedCount, - duplicateCount: result.duplicateCount, - durationMs: duration, - timestamp: new Date().toISOString(), - receiptId: receipt.id, - }); - - res.status(200).json({ - message: 'Event replay completed successfully', - fromLedger, - toLedger, - processedCount: result.processedCount, - duplicateCount: result.duplicateCount, - durationMs: duration, - timestamp: new Date().toISOString(), - receipt, - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - - // Record failed replay attempt - void recordAdminAuditLog(req, 'events.replay.manual.failed', 500, { - error: errorMessage, - timestamp: new Date().toISOString(), - }); - - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: errorMessage, - }); - } -}); - -/** - * POST /admin/withdrawal-limits/override - * Grants a temporary admin override for a wallet's daily withdrawal limit. - * Requires super-admin API key. - */ -app.post('/admin/withdrawal-limits/override', validateApiKey, validate({ body: WithdrawalLimitOverrideSchema }), async (req: Request, res: Response) => { - const walletAddress = typeof req.body?.walletAddress === 'string' ? req.body.walletAddress.trim() : ''; - const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; - const ttlSeconds = - typeof req.body?.ttlSeconds === 'number' && req.body.ttlSeconds > 0 - ? req.body.ttlSeconds - : 3600; - - if (!walletAddress || !reason) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'walletAddress and reason are required', - }); - return; - } - - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required to override withdrawal limits', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - const override = setWithdrawalLimitOverride(walletAddress, reason, actor, ttlSeconds); - - await recordAdminAuditLog(req, 'withdrawal.limit.override.grant', 201, { - walletAddress: override.wallet, - reason: override.reason, - expiresAt: override.expiresAt, - actor, - }); - - res.status(201).json({ override }); -}); - -/** - * GET /admin/withdrawal-limits/audit - * Lists recent blocked and overridden withdrawal attempts. - */ -app.get('/admin/withdrawal-limits/audit', validateApiKey, (req: Request, res: Response) => { - const limit = parseLimited(req.query.limit, 50, 1, 200); - const windowed = listWithdrawalLimitAuditEntries(limit + 1); - const { data, hasNextPage } = paginateByLimit(windowed, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { entries: data }, - }); -}); - -/** - * GET /admin/emails/queue - * Lists queued outbound emails, optionally filtered by status. - */ -app.get('/admin/emails/queue', validateApiKey, async (req: Request, res: Response) => { - const status = typeof req.query.status === 'string' ? req.query.status : undefined; - const emails = await emailQueueService.getEmailQueue(status); - - res.status(200).json({ - emails, - count: emails.length, - }); -}); - -/** - * POST /admin/emails/replay/:id - * Requeues a failed/dead-letter email for another send attempt. - */ -app.post('/admin/emails/replay/:id', validateApiKey, async (req: Request, res: Response) => { - try { - const email = await emailQueueService.replayEmail(req.params.id); - res.status(200).json({ - message: 'Email requeued successfully', - email, - }); - } catch { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Email queue item not found', - }); - } -}); - -// ─── Allowlist Admin Endpoints (Issue #375) ────────────────────────────────── - -/** - * POST /admin/allowlist/add - * Adds a wallet address to the private beta allowlist. - * Requires API key authentication. - * Body: { "walletAddress": "G..." } - */ -app.post('/admin/allowlist/add', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { - const { walletAddress } = req.body; - const added = addAddress(walletAddress); - const actor = resolveActingAdminAddress(req); - - const receipt = await generateAdminReceipt({ - action: 'allowlist.add', - actor, - input: { walletAddress }, - resultingState: { - added, - totalCount: allowlistSize(), - }, - }); - - void recordAdminAuditLog(req, 'allowlist.add', added ? 201 : 200, { - walletAddress, - added, - actor, - receiptId: receipt.id, - }); - - res.status(added ? 201 : 200).json({ - message: added ? 'Wallet added to allowlist' : 'Wallet already in allowlist', - walletAddress: walletAddress.trim().toUpperCase(), - count: allowlistSize(), - receipt, - }); -}); - -/** - * DELETE /admin/allowlist/remove - * Removes a wallet address from the private beta allowlist. - * Requires API key authentication. - * Body: { "walletAddress": "G..." } - */ -app.delete('/admin/allowlist/remove', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { - const { walletAddress } = req.body; - const removed = removeAddress(walletAddress); - if (!removed) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Wallet address not found in allowlist', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - const receipt = await generateAdminReceipt({ - action: 'allowlist.remove', - actor, - input: { walletAddress }, - resultingState: { - removed: true, - totalCount: allowlistSize(), - }, - }); - - void recordAdminAuditLog(req, 'allowlist.remove', 200, { - walletAddress, - actor, - receiptId: receipt.id, - }); - - res.json({ - message: 'Wallet removed from allowlist', - walletAddress: walletAddress.trim().toUpperCase(), - count: allowlistSize(), - receipt, - }); -}); - -/** - * GET /admin/allowlist - * Lists all wallet addresses on the allowlist. - * Requires API key authentication. - */ -app.get('/admin/allowlist', validateApiKey, (_req: Request, res: Response) => { - res.json({ - addresses: listAddresses(), - count: allowlistSize(), - enabled: process.env.ALLOWLIST_ENABLED !== 'false', - }); -}); - -/** - * GET /admin/wallet-aliases - * Lists persisted wallet alias identity groups. - * Requires API key authentication. - */ -app.get('/admin/wallet-aliases', validateApiKey, async (_req: Request, res: Response) => { - const groups = await walletAliasMappingService.listIdentityLinks(); - res.json({ - groups, - count: groups.length, - }); -}); - -/** - * DELETE /admin/wallet-aliases/:canonicalId - * Deletes a canonical wallet alias group and all linked aliases. - * Requires API key authentication. - */ -app.delete('/admin/wallet-aliases/:canonicalId', validateApiKey, async (req: Request, res: Response) => { - const { canonicalId } = req.params; - const existing = await walletAliasMappingService.getIdentityLinks(canonicalId); - - if (!existing) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Canonical identity not found', - }); - return; - } - - const deleted = await walletAliasMappingService.deleteIdentityGroup(canonicalId); - if (!deleted) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Canonical identity not found', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - void recordAdminAuditLog(req, 'wallet-alias.delete', 200, { - actor, - canonicalId, - aliases: existing.aliases, - sources: existing.sources, - }); - - res.status(200).json({ - message: 'Wallet alias group deleted', - canonicalId, - aliasesDeleted: existing.aliases.length, - sources: existing.sources, - }); -}); - -/** - * POST /admin/impersonate/sessions - start a time-bounded impersonation session - * Requires super-admin API key. - */ -app.post('/admin/impersonate/sessions', validateApiKey, validate({ body: ImpersonationSessionBodySchema }), async (req: Request, res: Response) => { - const actingAdminAddress = resolveActingAdminAddress(req); - const { actor, apiKeyHash, ipAddress, userAgent } = resolveImpersonationSessionContext(req); - const targetWallet = typeof req.body?.targetWallet === 'string' ? req.body.targetWallet.trim() : ''; - const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; - - req.adminAuditActor = actingAdminAddress; - req.adminAuditMetadata = { - actingAdminAddress, - adminRole: req.authApiKeyRole || 'admin', - targetWallet: targetWallet || 'unknown', - impersonation: true, - }; - - if (!targetWallet || !reason) { - req.adminAuditAction = 'admin.impersonate.session.invalid'; - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'targetWallet and reason are required', - }); - return; - } - - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - req.adminAuditAction = 'admin.impersonate.session.denied'; - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required for impersonation sessions', - }); - return; - } - - try { - const session = await startImpersonationSession({ - actor: actingAdminAddress, - apiKeyHash, - targetWallet, - reason, - ipAddress, - userAgent, - }); - - req.adminAuditAction = 'admin.impersonate.session.started'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - sessionId: session.id, - expiresAt: session.expiresAt, - }; - - res.status(201).json({ session }); - } catch (error) { - req.adminAuditAction = 'admin.impersonate.session.failed'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - error: error instanceof Error ? error.message : String(error), - }; - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to start impersonation session', - }); - } -}); - -/** - * GET /admin/impersonate/sessions - list active and historical impersonation sessions - * Requires super-admin API key. - */ -app.get('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { - const actingAdminAddress = resolveActingAdminAddress(req); - - req.adminAuditActor = actingAdminAddress; - req.adminAuditMetadata = { - actingAdminAddress, - adminRole: req.authApiKeyRole || 'admin', - impersonation: true, - }; - - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - req.adminAuditAction = 'admin.impersonate.session.list.denied'; - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required to list impersonation sessions', - }); - return; - } - - const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'all'; - const status = - statusRaw === 'active' || statusRaw === 'ended' || statusRaw === 'expired' ? statusRaw : 'all'; - const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; - const targetWallet = typeof req.query.targetWallet === 'string' ? req.query.targetWallet : undefined; - const limit = parseLimited(req.query.limit, 50, 1, 200); - - try { - const sessions = await listImpersonationSessions({ - status, - actor, - targetWallet, - limit: limit + 1, - }); - const { data, hasNextPage } = paginateByLimit(sessions, limit); - - req.adminAuditAction = 'admin.impersonate.session.list'; - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - sessions: data, - count: data.length, - }, - }); - } catch (error) { - req.adminAuditAction = 'admin.impersonate.session.list.failed'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - error: error instanceof Error ? error.message : String(error), - }; - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to list impersonation sessions', - }); - } -}); - -/** - * DELETE /admin/impersonate/sessions/:id - end an active impersonation session - * Requires super-admin API key. - */ -app.delete('/admin/impersonate/sessions/:id', validateApiKey, async (req: Request, res: Response) => { - const actingAdminAddress = resolveActingAdminAddress(req); - const sessionId = String(req.params.id || '').trim(); - - req.adminAuditActor = actingAdminAddress; - req.adminAuditMetadata = { - actingAdminAddress, - adminRole: req.authApiKeyRole || 'admin', - sessionId: sessionId || 'unknown', - impersonation: true, - }; - - if (!sessionId) { - req.adminAuditAction = 'admin.impersonate.session.end.invalid'; - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'session id is required', - }); - return; - } - - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - req.adminAuditAction = 'admin.impersonate.session.end.denied'; - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required to end impersonation sessions', - }); - return; - } - - try { - const session = await endImpersonationSession(sessionId, actingAdminAddress); - if (!session) { - req.adminAuditAction = 'admin.impersonate.session.end.not_found'; - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Impersonation session not found or already expired', - }); - return; - } - - req.adminAuditAction = 'admin.impersonate.session.ended'; - res.status(200).json({ session }); - } catch (error) { - req.adminAuditAction = 'admin.impersonate.session.end.failed'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - error: error instanceof Error ? error.message : String(error), - }; - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to end impersonation session', - }); - } -}); - -/** - * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet - * Requires super-admin API key and a valid non-expired impersonation session. - */ -app.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { - const wallet = String(req.params.wallet || '').trim(); - const actingAdminAddress = resolveActingAdminAddress(req); - const sessionId = String(req.get('x-impersonation-session-id') || '').trim(); - - req.adminAuditActor = actingAdminAddress; - req.adminAuditMetadata = { - actingAdminAddress, - adminRole: req.authApiKeyRole || 'admin', - targetWallet: wallet || 'unknown', - sessionId: sessionId || undefined, - impersonation: true, - }; - - if (!wallet) { - req.adminAuditAction = 'admin.impersonate.invalid'; - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'wallet path parameter is required', - }); - return; - } - - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - req.adminAuditAction = 'admin.impersonate.denied'; - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required for impersonation', - }); - return; - } - - if (!sessionId && process.env.IMPERSONATION_SESSION_STORAGE) { - req.adminAuditAction = 'admin.impersonate.session.required'; - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'x-impersonation-session-id header is required', - }); - return; - } - - try { - let activeSession: ImpersonationSessionRecord | undefined; - - if (sessionId) { - const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); - if (!validation.ok) { - const statusCode = validation.reason === 'not_found' ? 404 : 403; - req.adminAuditAction = - validation.reason === 'expired' - ? 'admin.impersonate.session.expired' - : validation.reason === 'ended' - ? 'admin.impersonate.session.ended' - : validation.reason === 'wallet_mismatch' - ? 'admin.impersonate.session.wallet_mismatch' - : validation.reason === 'actor_mismatch' - ? 'admin.impersonate.session.actor_mismatch' - : 'admin.impersonate.session.invalid'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - validationReason: validation.reason, - }; - res.status(statusCode).json({ - error: statusCode === 404 ? 'Not Found' : 'Forbidden', - status: statusCode, - message: - validation.reason === 'expired' - ? 'Impersonation session has expired' - : validation.reason === 'ended' - ? 'Impersonation session has ended' - : validation.reason === 'wallet_mismatch' - ? 'Session wallet does not match target wallet' - : validation.reason === 'actor_mismatch' - ? 'Session actor does not match requesting admin' - : 'Impersonation session is invalid', - }); - return; - } - activeSession = validation.session; - } - - req.adminAuditAction = 'admin.impersonate'; - - const vaultState = await buildImpersonatedVaultState(wallet); - res.status(200).json({ - ...vaultState, - impersonationSession: activeSession - ? { - id: activeSession.id, - reason: activeSession.reason, - startedAt: activeSession.startedAt, - expiresAt: activeSession.expiresAt, - } - : undefined, - }); - } catch (error) { - req.adminAuditAction = 'admin.impersonate.failed'; - req.adminAuditMetadata = { - ...req.adminAuditMetadata, - error: error instanceof Error ? error.message : String(error), - }; - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: 'Failed to build impersonated vault state', - }); - } -}); - -app.get('/admin/receipts', validateApiKey, async (req: Request, res: Response) => { - const action = typeof req.query.action === 'string' ? req.query.action : undefined; - const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; - const limit = parseLimited(req.query.limit, 50, 1, 200); - - try { - const receipts = await listAdminReceipts({ action, actor, limit: limit + 1 }); - const { data, hasNextPage } = paginateByLimit(receipts, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - receipts: data, - count: data.length, - }, - }); - } catch (err) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: err instanceof Error ? err.message : String(err), - }); - } -}); - -/** - * GET /admin/receipts/:id - * Retrieves a specific admin action receipt. - * Requires API key authentication. - */ -app.get('/admin/receipts/:id', validateApiKey, async (req: Request, res: Response) => { - try { - const receipt = await getAdminReceipt(req.params.id); - if (!receipt) { - res.status(404).json({ error: 'Receipt not found' }); - return; - } - res.json(receipt); - } catch (err) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: err instanceof Error ? err.message : String(err), - }); - } -}); - -/** - * GET /admin/receipts/:id/verify - * Verifies the integrity of an admin action receipt. - * Requires API key authentication. - */ -app.get('/admin/receipts/:id/verify', validateApiKey, async (req: Request, res: Response) => { - try { - const receipt = await getAdminReceipt(req.params.id); - if (!receipt) { - res.status(404).json({ error: 'Receipt not found' }); - return; - } - - const isValid = verifyReceiptSignature(receipt); - res.json({ - id: receipt.id, - isValid, - timestamp: new Date().toISOString(), - }); - } catch (err) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: err instanceof Error ? err.message : String(err), - }); - } -}); - -/** - * POST /admin/api-keys/register - Register a new API key - * Requires API key authentication (for boostrapping, requires special permission) - */ -app.post('/admin/api-keys/register', validateApiKey, validate({ body: ApiKeyRegisterSchema }), async (req: Request, res: Response) => { - const { key, role: requestedRole } = req.body; - if (!key || typeof key !== 'string' || !key.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'Missing key in request body', - }); - return; - } - - const role = normalizeApiKeyRole(requestedRole) || 'admin'; - if (role === 'super-admin' && !hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required to register super-admin API keys', - }); - return; - } - - const normalizedKey = key.trim(); - const hash = registerApiKey(normalizedKey, { role }); - - try { - await recordApiKeyAuditEvent({ - actor: resolveApiKeyAuditActor(req), - action: API_KEY_AUDIT_ACTIONS.created, - keyFingerprint: getApiKeyFingerprintFromHash(hash), - }); - - res.json({ - message: 'API key registered', - hash, - fingerprint: getApiKeyFingerprintFromHash(hash), - role, - created: new Date().toISOString(), - }); - } catch (error) { - revokeApiKey(hash); - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to persist API key audit event', - }); - } -}); - -/** - * POST /admin/api-keys/rotate - Rotate an API key - * Body: { oldHash: string, newKey: string } - * Requires API key authentication. - */ -app.post('/admin/api-keys/rotate', validateApiKey, validate({ body: ApiKeyRotateSchema }), async (req: Request, res: Response) => { - const { oldHash, newKey } = req.body || {}; - if (!isApiKeyHash(oldHash)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'oldHash must be a valid SHA-256 API key hash', - }); - return; - } - - if (typeof newKey !== 'string' || !newKey.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'newKey is required', - }); - return; - } - - const previousMetadata = getApiKeyMetadata(oldHash); - if (!previousMetadata) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'API key not found', - }); - return; - } - - const normalizedNewKey = newKey.trim(); - const newHash = rotateApiKey(oldHash, normalizedNewKey); - if (!newHash) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'API key not found', - }); - return; - } - - try { - await recordApiKeyAuditEvent({ - actor: resolveApiKeyAuditActor(req), - action: API_KEY_AUDIT_ACTIONS.rotated, - keyFingerprint: getApiKeyFingerprintFromValue(normalizedNewKey), - }); - - res.status(200).json({ - message: 'API key rotated', - oldFingerprint: getApiKeyFingerprintFromHash(oldHash), - newHash, - newFingerprint: getApiKeyFingerprintFromHash(newHash), - rotatedAt: new Date().toISOString(), - }); - } catch (error) { - revokeApiKey(newHash); - restoreApiKey(oldHash); - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to persist API key audit event', - }); - } -}); - -/** - * POST /admin/api-keys/revoke - Revoke an API key - * Body: { hash: string } - * Requires API key authentication. - */ -app.post('/admin/api-keys/revoke', validateApiKey, validate({ body: ApiKeyRevokeSchema }), async (req: Request, res: Response) => { - const { hash } = req.body || {}; - if (!isApiKeyHash(hash)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'hash must be a valid SHA-256 API key hash', - }); - return; - } - - const previousMetadata = getApiKeyMetadata(hash); - if (!previousMetadata) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'API key not found', - }); - return; - } - - revokeApiKey(hash); - - try { - await recordApiKeyAuditEvent({ - actor: resolveApiKeyAuditActor(req), - action: API_KEY_AUDIT_ACTIONS.revoked, - keyFingerprint: getApiKeyFingerprintFromHash(hash), - }); - - res.status(200).json({ - message: 'API key revoked', - fingerprint: getApiKeyFingerprintFromHash(hash), - revokedAt: new Date().toISOString(), - }); - } catch (error) { - restoreApiKey(hash); - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to persist API key audit event', - }); - } -}); - -/** - * GET /admin/api-keys/audit-events - list API key lifecycle audit events - * Supports ?action=created|rotated|revoked&from=&to=&limit=N - */ -app.get('/admin/api-keys/audit-events', validateApiKey, async (req: Request, res: Response) => { - const rawAction = typeof req.query.action === 'string' ? req.query.action : undefined; - const action = - rawAction === API_KEY_AUDIT_ACTIONS.created || - rawAction === API_KEY_AUDIT_ACTIONS.rotated || - rawAction === API_KEY_AUDIT_ACTIONS.revoked - ? rawAction - : undefined; - - if (rawAction && !action) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'action must be one of: created, rotated, revoked', - }); - return; - } - - try { - const range = parseUtcDateRange({ - from: typeof req.query.from === 'string' ? req.query.from : undefined, - to: typeof req.query.to === 'string' ? req.query.to : undefined, - }); - const limit = parseLimited(req.query.limit, 50, 1, 200); - const events = await listApiKeyAuditEvents({ - action, - start: range.start, - end: range.end, - limit: limit + 1, - }); - const { data, hasNextPage } = paginateByLimit(events, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - events: data, - meta: { - count: data.length, - limit, - filters: { - action: action || null, - from: range.start || null, - to: range.end || null, - }, - timestamp: new Date().toISOString(), - }, - }, - }); - } catch (error) { - if (error instanceof DateRangeParseError) { - res.status(error.status).json({ - error: 'Bad Request', - status: error.status, - message: error.message, - }); - return; - } - - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to read API key audit events', - }); - } -}); - -/** - * POST /admin/webhooks - register webhook endpoint for transaction events - */ -app.post('/admin/webhooks', validateApiKey, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { - const { url, eventTypes, enabled, secret } = req.body; - - const endpoint = registerWebhookEndpoint({ - url, - eventTypes, - enabled: enabled ?? true, - secret, - }); - - res.status(201).json({ - message: 'Webhook endpoint registered', - endpoint, - }); -}); - -/** - * POST /admin/webhooks/:id/verify - run challenge-response verification for an endpoint - */ -app.post('/admin/webhooks/:id/verify', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { - try { - const endpoint = await verifyWebhookEndpoint(req.params.id); - if (!endpoint) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Webhook endpoint not found', - }); - return; - } - - await recordAdminAuditLog(req, 'webhook.verify', endpoint.verificationStatus === 'verified' ? 200 : 422, { - endpointId: endpoint.id, - verificationStatus: endpoint.verificationStatus, - lastVerificationError: endpoint.lastVerificationError, - }); - - res.status(endpoint.verificationStatus === 'verified' ? 200 : 422).json({ - message: - endpoint.verificationStatus === 'verified' - ? 'Webhook endpoint verified' - : 'Webhook endpoint verification failed', - endpoint, - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to verify webhook endpoint', - }); - } -}); - -/** - * PATCH /admin/webhooks/:id - update webhook endpoint - */ -app.patch('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchema, body: WebhookUpdateSchema }), (req: Request, res: Response) => { - if (!assertWebhookParameterUpdate(req, res)) { - return; - } - - const endpoint = updateWebhookEndpoint(req.params.id, req.body || {}); - if (!endpoint) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Webhook endpoint not found', - }); - return; - } - - res.status(200).json({ - message: 'Webhook endpoint updated', - endpoint, - }); -}); - -/** - * GET /admin/webhooks - list webhook endpoints - */ -app.get('/admin/webhooks', validateApiKey, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { - const includeDeleted = req.query.includeDeleted === 'true'; - const limit = parseLimited(req.query.limit, 100, 1, 500); - const allEndpoints = listWebhookEndpoints(includeDeleted); - const windowed = allEndpoints.slice(0, limit + 1); - const { data, hasNextPage } = paginateByLimit(windowed, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - total: allEndpoints.length, - extras: { - endpoints: data, - metrics: getWebhookDeliveryMetrics(), - }, - }); -}); - -/** - * DELETE /admin/webhooks/:id - soft delete webhook endpoint - */ -app.delete('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { - const actor = resolveActingAdminAddress(req); - const endpoint = deleteWebhookEndpoint(req.params.id, actor); - - if (!endpoint) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Webhook endpoint not found or already deleted', - }); - return; - } - - void recordAdminAuditLog(req, 'webhook.delete', 200, { - endpointId: endpoint.id, - url: endpoint.url, - actor, - }); - - res.status(200).json({ - message: 'Webhook endpoint soft-deleted', - endpoint, - }); -}); - -/** - * POST /admin/webhooks/:id/restore - restore soft-deleted webhook endpoint - */ -app.post('/admin/webhooks/:id/restore', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { - const actor = resolveActingAdminAddress(req); - const endpoint = restoreWebhookEndpoint(req.params.id, actor); - - if (!endpoint) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Webhook endpoint not found or not deleted', - }); - return; - } - - void recordAdminAuditLog(req, 'webhook.restore', 200, { - endpointId: endpoint.id, - url: endpoint.url, - actor, - }); - - res.status(200).json({ - message: 'Webhook endpoint restored', - endpoint, - }); -}); - -/** - * GET /admin/webhooks/dead-letter - list permanently failed webhook deliveries - */ -app.get('/admin/webhooks/dead-letter', validateApiKey, validate({ query: WebhookListQuerySchema }), (req: Request, res: Response) => { - const endpointId = typeof req.query.endpointId === 'string' ? req.query.endpointId : undefined; - const eventType = typeof req.query.eventType === 'string' ? req.query.eventType : undefined; - const start = typeof req.query.start === 'string' ? req.query.start : undefined; - const end = typeof req.query.end === 'string' ? req.query.end : undefined; - const limit = parseLimited(req.query.limit, 100, 1, 500); - - const rows = listWebhookDeadLetters({ - endpointId, - eventType: eventType as any, - start, - end, - limit: limit + 1, - }); - const { data, hasNextPage } = paginateByLimit(rows, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - deadLetters: data, - }, - }); -}); - -/** - * POST /admin/webhooks/dead-letter/:id/retry - re-queue a dead-letter delivery - */ -app.post('/admin/webhooks/dead-letter/:id/retry', validateApiKey, async (req: Request, res: Response) => { - try { - const entry = await retryWebhookDeadLetter(req.params.id); - if (!entry) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Dead-letter entry not found', - }); - return; - } - - res.status(200).json({ - message: 'Dead-letter entry re-queued for delivery', - deadLetter: entry, - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to retry dead-letter entry', - }); - } -}); - -/** - * GET /admin/webhooks/deliveries - list recent webhook delivery attempts - * Supports cursor-based pagination: ?limit=N&cursor= - */ -app.get('/admin/webhooks/deliveries', validateApiKey, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { - const limit = parseLimited(req.query.limit, 100, 1, 500); - const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined; - - try { - const page = listWebhookDeliveryPage({ limit, cursor }); - sendStandardListEnvelope(res, { - data: page.deliveries, - limit, - hasNextPage: page.hasNextPage, - hasPrevPage: Boolean(cursor), - nextCursor: page.nextCursor, - extras: { - deliveries: page.deliveries, - nextCursor: page.nextCursor, - hasNextPage: page.hasNextPage, - metrics: getWebhookDeliveryMetrics(), - }, - }); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - if (message.includes('Invalid or expired cursor')) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'Invalid or expired cursor. Start a new page without a cursor.', - }); - return; - } - res.status(500).json({ error: 'Internal Server Error', status: 500, message }); - } -}); - -/** - * POST /api/v1/webhooks/verify - verify webhook secret/signature pairing before go-live - */ -app.post('/api/v1/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { - const { secret, payload, signature } = req.body || {}; - if (typeof secret !== 'string' || !secret.trim()) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'secret is required and must be a non-empty string', - }); - return; - } - - if (typeof payload === 'undefined') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'payload is required', - }); - return; - } - - const computedSignature = createWebhookSignature(secret, payload); - const verified = - typeof signature === 'string' && signature.length > 0 - ? verifyWebhookSignature(secret, payload, signature) - : null; - - res.status(200).json({ - algorithm: 'HMAC-SHA256', - signature: computedSignature, - verified, - }); -}); - -/** - * GET /admin/audit/logs - list admin activity logs - */ -app.get('/admin/audit/logs', validateApiKey, validate({ query: AuditLogQuerySchema }), (req: Request, res: Response) => { - const statusCode = req.query.statusCode ? parseInt(String(req.query.statusCode), 10) : undefined; - const limit = parseLimited(req.query.limit, 100, 1, 500); - const page = parseLimited(req.query.page, 1, 1, 1000000); - const offset = (page - 1) * limit; - const filters = { - actor: req.query.actor ? String(req.query.actor) : undefined, - action: req.query.action ? String(req.query.action) : undefined, - path: req.query.path ? String(req.query.path) : undefined, - statusCode, - from: req.query.from ? String(req.query.from) : undefined, - to: req.query.to ? String(req.query.to) : undefined, - }; - - const logs = getAuditLogs({ ...filters, limit: limit + 1, offset }); - const { data, hasNextPage } = paginateByLimit(logs, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - logs: data, - page, - total: countAuditLogs(filters), - metrics: getAuditLogMetrics(), - }, - }); -}); - -/** - * GET /admin/audit-logs - list admin audit entries (Issue #253) - */ -app.get('/admin/audit-logs', validateApiKey, validate({ query: AuditLogQuerySchema }), async (req: Request, res: Response) => { - const limit = parseLimited(req.query.limit, 50, 1, 200); - const page = parseLimited(req.query.page, 1, 1, 1000000); - const offset = (page - 1) * limit; - const statusValue = req.query.statusCode ?? req.query.status; - const statusCode = statusValue - ? parseLimited(statusValue, 0, 100, 599) - : undefined; - const filters = { - action: typeof req.query.action === 'string' - ? req.query.action - : typeof req.query.type === 'string' ? req.query.type : undefined, - actor: typeof req.query.actor === 'string' ? req.query.actor : undefined, - path: typeof req.query.path === 'string' ? req.query.path : undefined, - statusCode, - from: typeof req.query.from === 'string' ? req.query.from : undefined, - to: typeof req.query.to === 'string' ? req.query.to : undefined, - }; - - const rows = getAuditLogs({ ...filters, limit: limit + 1, offset }); - const { data, hasNextPage } = paginateByLimit(rows, limit); - - void recordAdminAuditLog(req, 'audit-logs.read', 200, { - limit, - returned: data.length, - }); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - meta: { - count: data.length, - total: countAuditLogs(filters), - page, - limit, - timestamp: new Date().toISOString(), - }, - }, - }); -}); - -/** - * GET /admin/exports/jobs - list persisted transaction export metadata - */ -app.get('/admin/exports/jobs', validateApiKey, async (req: Request, res: Response) => { - const rawFormat = typeof req.query.format === 'string' ? req.query.format : undefined; - const format = rawFormat === 'csv' || rawFormat === 'json' ? rawFormat : undefined; - if (rawFormat && !format) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'format must be either csv or json', - }); - return; - } - - try { - const range = parseUtcDateRange({ - from: typeof req.query.from === 'string' ? req.query.from : undefined, - to: typeof req.query.to === 'string' ? req.query.to : undefined, - }); - const limit = parseLimited(req.query.limit, 50, 1, 200); - const jobs = await listExportJobs({ - format, - generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : undefined, - walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined, - checksum: typeof req.query.checksum === 'string' ? req.query.checksum : undefined, - start: range.start, - end: range.end, - limit: limit + 1, - }); - const { data, hasNextPage } = paginateByLimit(jobs, limit); - - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - jobs: data, - meta: { - count: data.length, - limit, - filters: { - format: format || null, - generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : null, - walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : null, - checksum: typeof req.query.checksum === 'string' ? req.query.checksum : null, - from: range.start || null, - to: range.end || null, - }, - timestamp: new Date().toISOString(), - }, - }, - }); - } catch (error) { - if (error instanceof DateRangeParseError) { - res.status(error.status).json({ - error: 'Bad Request', - status: error.status, - message: error.message, - }); - return; - } - - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to query export jobs', - }); - } -}); - -/** - * POST /admin/exports/jobs/:id/verify - verify a previously generated export checksum - * Body: { checksum: string } - */ -app.post('/admin/exports/jobs/:id/verify', validateApiKey, validate({ params: IdParamSchema, body: ChecksumVerifyBodySchema }), async (req: Request, res: Response) => { - const checksum = - typeof req.body?.checksum === 'string' - ? req.body.checksum.trim().toLowerCase() - : typeof req.query.checksum === 'string' - ? req.query.checksum.trim().toLowerCase() - : ''; - - if (!checksum) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'checksum is required', - }); - return; - } - - try { - const job = await getExportJobById(String(req.params.id)); - if (!job) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Export job not found', - }); - return; - } - - res.status(200).json({ - exportJobId: job.id, - valid: job.checksum.toLowerCase() === checksum, - expectedChecksum: job.checksum, - providedChecksum: checksum, - checksumAlgorithm: job.checksumAlgorithm, - generatedBy: job.generatedBy, - createdAt: job.createdAt, - fileName: job.fileName, - format: job.format, - rowCount: job.rowCount, - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to verify export checksum', - }); - } -}); - -/** - * POST /admin/exports/bulk - create a new bulk export job - * Body: { format: "csv"|"json", filters: { ... } } - */ -app.post('/admin/exports/bulk', validateApiKey, validate({ body: BulkExportBodySchema }), async (req: Request, res: Response) => { - try { - const { format, filters } = req.body; - if (format !== 'csv' && format !== 'json') { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'format must be either csv or json', - }); - return; - } - - const generatedBy = resolveExportGeneratedBy(req); - const job = await createBulkExportJob({ - format, - generatedBy, - filters: filters || {}, - }); - - void processBulkExportJob(job.id).catch(() => {}); - - res.status(201).json({ - message: 'Bulk export job created', - job, - timestamp: new Date().toISOString(), - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to create bulk export job', - }); - } -}); - -/** - * GET /admin/exports/bulk/jobs - list bulk export jobs - */ -app.get('/admin/exports/bulk/jobs', validateApiKey, async (req: Request, res: Response) => { - const limit = parseLimited(req.query.limit, 50, 1, 200); - const jobs = await listBulkExportJobs(limit + 1); - const { data, hasNextPage } = paginateByLimit(jobs, limit); - sendStandardListEnvelope(res, { - data, - limit, - hasNextPage, - extras: { - jobs: data, - meta: { - count: data.length, - limit, - timestamp: new Date().toISOString(), - }, - }, - }); -}); - -/** - * GET /admin/exports/bulk/jobs/:id - get bulk export job status - */ -app.get('/admin/exports/bulk/jobs/:id', validateApiKey, async (req: Request, res: Response) => { - const job = await getBulkExportJob(String(req.params.id)); - if (!job) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Bulk export job not found', - }); - return; - } - res.status(200).json({ job }); -}); - -/** - * POST /admin/exports/bulk/jobs/:id/cancel - cancel a pending/processing bulk export job - */ -app.post('/admin/exports/bulk/jobs/:id/cancel', validateApiKey, async (req: Request, res: Response) => { - const cancelled = await cancelBulkExportJob(String(req.params.id)); - if (!cancelled) { - const job = await getBulkExportJob(String(req.params.id)); - if (!job) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Bulk export job not found', - }); - return; - } - res.status(409).json({ - error: 'Conflict', - status: 409, - message: `Bulk export job is already ${job.status} and cannot be cancelled`, - }); - return; - } - res.status(200).json({ - message: 'Bulk export job cancelled', - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/exports/bulk/artifacts/:artifactId - download a completed bulk export artifact - */ -app.get('/admin/exports/bulk/artifacts/:artifactId', validateApiKey, (req: Request, res: Response) => { - const artifact = getBulkExportArtifact(String(req.params.artifactId)); - if (!artifact) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Bulk export artifact not found or expired', - }); - return; - } - res.setHeader('Content-Type', artifact.contentType); - res.setHeader('Content-Disposition', `attachment; filename="bulk-export-${artifact.id}.${artifact.contentType === 'text/csv' ? 'csv' : 'json'}"`); - res.setHeader('X-Artifact-Checksum', artifact.checksum); - res.setHeader('X-Artifact-Checksum-Algorithm', artifact.checksumAlgorithm); - res.setHeader('X-Artifact-Row-Count', String(artifact.rowCount)); - res.status(200).send(artifact.body); -}); - -/** - * GET /admin/prisma/config - operational prisma runtime settings (Issue #254) - */ -app.get('/admin/prisma/config', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - config: getPrismaConfig(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/jobs/monitor - structured JSON for background jobs/webhook workers - */ -app.get('/admin/jobs/monitor', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - jobHealth: getJobHealthStatus(), - jobs: getJobMetrics(), - webhooks: getWebhookDeliveryMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/jobs/metrics - JSON metrics dashboard for background jobs (Issue #255) - */ -app.get('/admin/jobs/metrics', validateApiKey, (req: Request, res: Response) => { - const metrics = getJobMetrics(); - const summary = { - totalDeadLetters: metrics.totalDeadLetters, - recurringFailureJobs: Object.keys(metrics.recurringFailures), - jobHealth: getJobHealthStatus(), - activeJobs: Object.values(metrics.runtime).filter((job) => job.inFlight > 0).length, - }; - - void recordAdminAuditLog(req, 'jobs.metrics.read', 200); - - res.json({ - summary, - metrics, - prisma: getPrismaRuntimeConfig(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/transactions/backfill - controlled backfill of missing ledger index ranges - */ -app.post('/admin/transactions/backfill', validateApiKey, validate({ body: TransactionBackfillBodySchema }), async (req: Request, res: Response) => { - const startLedger = Number(req.body?.startLedger); - const endLedger = Number(req.body?.endLedger); - const batchSize = req.body?.batchSize === undefined ? undefined : Number(req.body.batchSize); - const dryRun = Boolean(req.body?.dryRun); - const rpcUrl = String(req.body?.rpcUrl || process.env.STELLAR_RPC_URL || '').trim(); - const contractId = String(req.body?.contractId || process.env.VAULT_CONTRACT_ID || '').trim(); - - if (!Number.isInteger(startLedger) || !Number.isInteger(endLedger)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'startLedger and endLedger must be integers', - }); - return; - } - - if (!rpcUrl) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'rpcUrl is required (or set STELLAR_RPC_URL)', - }); - return; - } - - if (!contractId) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'contractId is required (or set VAULT_CONTRACT_ID)', - }); - return; - } - - try { - const job = await createOrResumeTransactionBackfill({ - startLedger, - endLedger, - batchSize, - dryRun, - rpcUrl, - contractId, - }); - - res.status(202).json({ - message: 'Backfill accepted', - job, - }); - } catch (error) { - res.status(422).json({ - error: 'Unprocessable Entity', - status: 422, - message: error instanceof Error ? error.message : 'Backfill request failed', - }); - } -}); - -/** - * GET /admin/transactions/backfill - list recent backfill jobs - */ -app.get('/admin/transactions/backfill', validateApiKey, async (req: Request, res: Response) => { - const limit = parseInt(String(req.query.limit || '20'), 10); - const data = await listTransactionBackfillJobs(limit); - res.status(200).json({ - data, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/transactions/backfill/:jobId - fetch a specific backfill job - */ -app.get('/admin/transactions/backfill/:jobId', validateApiKey, async (req: Request, res: Response) => { - const job = await getTransactionBackfillJob(String(req.params.jobId)); - if (!job) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Backfill job not found', - }); - return; - } - - res.status(200).json({ - job, - }); -}); - -/** - * GET /admin/governance/snapshots - list historical governance snapshots - */ -app.get('/admin/governance/snapshots', validateApiKey, async (req: Request, res: Response) => { - const limit = parseLimited(req.query.limit, 50, 1, 500); - const offset = parseLimited(req.query.offset, 0, 0, 10_000); - const type = typeof req.query.type === 'string' ? req.query.type : undefined; - const start = typeof req.query.start === 'string' ? req.query.start : undefined; - const end = typeof req.query.end === 'string' ? req.query.end : undefined; - - const result = await listGovernanceSnapshots({ - type: type as 'reconciliation' | 'config-change' | 'export-manifest' | undefined, - start, - end, - limit, - offset, - }); - - res.status(200).json({ - data: result.data, - total: result.total, - limit, - offset, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/governance/snapshots/export - export historical governance snapshots - */ -app.post('/admin/governance/snapshots/export', validateApiKey, validate({ body: GovernanceSnapshotExportSchema }), async (req: Request, res: Response) => { - const requester = resolveActingAdminAddress(req); - const types = Array.isArray(req.body?.types) - ? (req.body.types as string[]) - : undefined; - const start = typeof req.body?.start === 'string' ? req.body.start : undefined; - const end = typeof req.body?.end === 'string' ? req.body.end : undefined; - const limit = parseLimited(req.body?.limit, 500, 1, 5000); - - const { manifest, rows } = await exportGovernanceSnapshots({ - requester, - types: types as ('reconciliation' | 'config-change' | 'export-manifest')[] | undefined, - start, - end, - limit, - }); - - res.status(201).json({ - message: 'Governance snapshot export generated', - rowCount: rows.length, - manifest, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/reports/exports - generate a report export and immutable manifest record - */ -app.post('/admin/reports/exports', validateApiKey, validate({ body: ReportExportBodySchema }), async (req: Request, res: Response) => { - const reportType = String(req.body?.reportType || 'transactions').trim(); - const requester = resolveActingAdminAddress(req); - const filters = - req.body?.filters && typeof req.body.filters === 'object' - ? (req.body.filters as Record) - : {}; - - const mockRows = [ - { - reportType, - generatedAt: new Date().toISOString(), - filters, - }, - ]; - - const manifest = await createExportManifest({ - requester, - reportType, - filters, - rows: mockRows, - }); - - res.status(201).json({ - message: 'Export generated and manifest recorded', - manifest, - }); -}); - -/** - * GET /admin/reports/exports/manifests - list immutable export manifests - */ -app.get('/admin/reports/exports/manifests', validateApiKey, async (req: Request, res: Response) => { - const limit = parseInt(String(req.query.limit || '50'), 10); - const offset = parseInt(String(req.query.offset || '0'), 10); - const result = await listExportManifests(limit, offset); - res.status(200).json({ - data: result.data, - total: result.total, - limit, - offset, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/reports/exports/manifests/:id - fetch a manifest by id - */ -app.get('/admin/reports/exports/manifests/:id', validateApiKey, async (req: Request, res: Response) => { - const manifest = await getExportManifestById(String(req.params.id)); - if (!manifest) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Export manifest not found', - }); - return; - } - - res.status(200).json({ - manifest, - }); -}); - -/** - * POST /admin/reports/exports/manifests/:id/verify - verify manifest checksum - */ -app.post('/admin/reports/exports/manifests/:id/verify', validateApiKey, validate({ params: IdParamSchema, body: ChecksumVerifyBodySchema }), async (req: Request, res: Response) => { - const checksum = String(req.body?.checksum || '').trim(); - if (!checksum) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'checksum is required', - }); - return; - } - - const result = await verifyExportManifestChecksum(String(req.params.id), checksum); - if (!result.manifest) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Export manifest not found', - }); - return; - } - - res.status(200).json({ - match: result.match, - manifest: result.manifest, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/jobs/dashboard - comprehensive HTML dashboard for operators to monitor jobs and DLQ - */ -app.get('/admin/jobs/dashboard', validateApiKey, async (_req: Request, res: Response) => { - const jobMetrics = getJobMetrics(); - const webhookMetrics = getWebhookDeliveryMetrics(); - const dlqRecords = listDeadLetters({ status: 'dead-letter', limit: 10 }); - - const jobsByStatus = { - 'dead-letter': dlqRecords.records.filter((r) => r.status === 'dead-letter').length, - 'requeued': dlqRecords.records.filter((r) => r.status === 'requeued').length, - 'resolved': dlqRecords.records.filter((r) => r.status === 'resolved').length, - 'discarded': dlqRecords.records.filter((r) => r.status === 'discarded').length, - }; - - const recurringFailures = Object.entries(jobMetrics.recurringFailures || {}) - .map(([jobName, count]) => `${jobName} (${count} failures)`) - .join(', ') || 'None'; - - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.status(200).send(` - - - - - - YieldVault Job Dashboard - - - -
-

🎯 Background Job Monitoring Dashboard

- -
-
-
Overall Health
-
${getJobHealthStatus().toUpperCase()}
-
-
-
Dead-Letter Records
-
${jobMetrics.totalDeadLetters}
-
${dlqRecords.total} total (pending: ${jobsByStatus['dead-letter']})
-
-
-
Webhook Endpoints
-
${webhookMetrics.totalEndpoints}
-
Failed: ${webhookMetrics.failed}
-
-
-
Recurring Failures
-
${Object.keys(jobMetrics.recurringFailures || {}).length}
-
${recurringFailures}
-
-
- - ${jobMetrics.totalDeadLetters > 0 ? ` -
- ⚠️ ${jobMetrics.totalDeadLetters} dead-letter records pending. - Review and retry failed jobs or mark them as resolved. -
- ` : ''} - -

📊 Job Runtime Metrics

-
- - - - - - - - - - - - - - ${Object.entries(jobMetrics.runtime || {}) - .map(([jobName, metrics]) => ` - - - - - - - - - - `) - .join('')} - -
Job NameTotal RunsSuccessfulFailedIn FlightAvg Duration (ms)Last Run
${jobName}${metrics.totalRuns}${metrics.successfulRuns}${metrics.failedRuns}${metrics.inFlight}${metrics.averageDurationMs}${metrics.lastRunAt ? new Date(metrics.lastRunAt).toLocaleString() : 'Never'}
-
- -

🔄 Recent Dead-Letter Records

-
- ${dlqRecords.records.length > 0 ? ` - - - - - - - - - - - - - - ${dlqRecords.records.slice(0, 10).map((record) => ` - - - - - - - - - - `).join('')} - -
IDJobStatusAttemptsErrorFailed AtActions
${record.id}${record.jobName}${record.status}${record.attempts}${record.error}${new Date(record.failedAt).toLocaleString()} - View | - Retry -
- ` : ` -

No dead-letter records (great job!)

- `} -
- -

📡 API Endpoints

-
-

Use these endpoints to manage dead-letter queue records:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
MethodEndpointDescription
GET/admin/jobs/dead-lettersList all dead-letter records with optional filters
GET/admin/jobs/dead-letters/:idRetrieve a specific dead-letter record
POST/admin/jobs/dead-letters/:id/retryRetry a failed job (supports ?dryRun=true)
POST/admin/jobs/dead-letters/:id/resolveMark a job as manually resolved with notes
DELETE/admin/jobs/dead-letters/:idDiscard a dead-letter record
POST/admin/jobs/dead-letters/bulk-retryBulk retry multiple records (body: {ids: []})
POST/admin/jobs/dead-letters/bulk-discardBulk discard multiple records (body: {ids: []})
POST/admin/jobs/dead-letters/processTrigger batch processing worker (body: {batchSize?: 10})
-
- -

📋 Full Metrics JSON

-
- Click to expand full metrics -
${JSON.stringify(jobMetrics, null, 2)}
-
- -
-

- Last updated: ${new Date().toISOString()} | - JSON API -

-
- - - `); -}); - -// ─── Dead-Letter Queue Admin Endpoints ───────────────────────────────────── - -/** - * GET /admin/jobs/metrics - * Returns background job runtime and governance metrics. - */ -app.get('/admin/jobs/metrics', validateApiKey, (_req: Request, res: Response) => { - const metrics = getJobMetrics(); - const health = getJobHealthStatus(); - - res.status(200).json({ - summary: { health }, - metrics, - prisma: getPrismaConfig(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/jobs/dead-letters - * Lists dead-letter queue records with optional filtering (jobName, status, limit, offset). - * Requires API key authentication. - */ -app.get('/admin/jobs/dead-letters', validateApiKey, (req: Request, res: Response) => { - const jobName = typeof req.query.jobName === 'string' ? (req.query.jobName as JobName) : undefined; - const status = typeof req.query.status === 'string' ? (req.query.status as DeadLetterStatus) : undefined; - const limit = parseLimited(req.query.limit, 50, 1, 500); - const offset = parseLimited(req.query.offset, 0, 0, 10000); - - const { records, total } = listDeadLetters({ jobName, status, limit, offset }); - - res.status(200).json({ - data: records, - total, - limit, - offset, - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/jobs/dead-letters/:id - * Retrieves a single dead-letter record by ID. - * Requires API key authentication. - */ -app.get('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { - const record = getDeadLetterRecord(req.params.id); - if (!record) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Dead-letter record not found', - }); - return; - } - - res.status(200).json({ - record, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/jobs/dead-letters/:id/retry - * Retries a single dead-letter queue entry. - * Supports dryRun flag. - * Requires API key authentication. - */ -app.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, async (req: Request, res: Response) => { - const record = getDeadLetterRecord(req.params.id); - if (!record) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Dead-letter record not found', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - - if (isDryRunRequest(req)) { - void recordAdminAuditLog(req, 'jobs.dlq.retry.dry_run', 200, { - id: req.params.id, - jobName: record.jobName, - actor, - }); - - res.status(200).json({ - dryRun: true, - message: `Dead-letter record '${req.params.id}' (${record.jobName}) would be retried`, - record, - wouldRetry: true, - timestamp: new Date().toISOString(), - }); - return; - } - - const outcome = await retryDeadLetter(req.params.id); - void recordAdminAuditLog(req, 'jobs.dlq.retry', outcome.success ? 200 : 500, { - id: req.params.id, - jobName: record.jobName, - success: outcome.success, - actor, - error: outcome.error, - }); - - if (!outcome.success) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: outcome.error || 'Failed to retry dead-letter record', - record: outcome.record, - }); - return; - } - - res.status(200).json({ - message: 'Dead-letter record retried successfully', - result: outcome.result, - record: outcome.record, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/jobs/dead-letters/:id/resolve - * Marks a dead-letter entry as manually resolved. - * Body: { notes?: string } - * Requires API key authentication. - */ -app.post('/admin/jobs/dead-letters/:id/resolve', validateApiKey, validate({ params: IdParamSchema, body: DeadLetterResolveSchema }), async (req: Request, res: Response) => { - const actor = resolveActingAdminAddress(req); - const notes = typeof req.body?.notes === 'string' ? req.body.notes : undefined; - - const record = resolveDeadLetter(req.params.id, actor, notes); - if (!record) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Dead-letter record not found', - }); - return; - } - - void recordAdminAuditLog(req, 'jobs.dlq.resolve', 200, { - id: req.params.id, - jobName: record.jobName, - actor, - notes, - }); - - res.status(200).json({ - message: 'Dead-letter record resolved successfully', - record, - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/jobs/dead-letters/:id - * Discards a dead-letter record from the queue. - * Requires API key authentication. - */ -app.delete('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { - const actor = resolveActingAdminAddress(req); - - if (isDryRunRequest(req)) { - const existing = getDeadLetterRecord(req.params.id); - res.status(existing ? 200 : 404).json({ - dryRun: true, - message: existing - ? `Dead-letter record '${req.params.id}' would be discarded` - : `Dead-letter record '${req.params.id}' not found`, - id: req.params.id, - wouldDiscard: Boolean(existing), - timestamp: new Date().toISOString(), - }); - return; - } - - const record = discardDeadLetter(req.params.id, actor); - if (!record) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Dead-letter record not found', - }); - return; - } - - void recordAdminAuditLog(req, 'jobs.dlq.discard', 200, { - id: req.params.id, - jobName: record.jobName, - actor, - }); - - res.status(200).json({ - message: 'Dead-letter record discarded successfully', - record, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/jobs/dead-letters/bulk-retry - * Bulk retries multiple dead-letter records. - * Body: { ids: string[] } - * Requires API key authentication. - */ -app.post('/admin/jobs/dead-letters/bulk-retry', validateApiKey, validate({ body: DeadLetterIdsSchema }), async (req: Request, res: Response) => { - const ids = Array.isArray(req.body?.ids) ? (req.body.ids as string[]) : []; - - if (ids.length === 0) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`ids` array must not be empty', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - - if (isDryRunRequest(req)) { - res.status(200).json({ - dryRun: true, - message: `Bulk retry preview for ${ids.length} dead-letter records`, - ids, - wouldRetryCount: ids.length, - timestamp: new Date().toISOString(), - }); - return; - } - - const summary = await bulkRetryDeadLetters(ids); - - void recordAdminAuditLog(req, 'jobs.dlq.bulk_retry', 200, { - requestedCount: ids.length, - retried: summary.retried, - failed: summary.failed, - actor, - }); - - res.status(200).json({ - message: 'Bulk retry completed', - retried: summary.retried, - failed: summary.failed, - results: summary.results, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/jobs/dead-letters/bulk-discard - * Bulk discards multiple dead-letter records. - * Body: { ids: string[] } - * Requires API key authentication. - */ -app.post('/admin/jobs/dead-letters/bulk-discard', validateApiKey, validate({ body: DeadLetterIdsSchema }), (req: Request, res: Response) => { - const ids = Array.isArray(req.body?.ids) ? (req.body.ids as string[]) : []; - - if (ids.length === 0) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: '`ids` array must not be empty', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - - if (isDryRunRequest(req)) { - res.status(200).json({ - dryRun: true, - message: `Bulk discard preview for ${ids.length} dead-letter records`, - ids, - wouldDiscardCount: ids.length, - timestamp: new Date().toISOString(), - }); - return; - } - - const summary = bulkDiscardDeadLetters(ids); - - void recordAdminAuditLog(req, 'jobs.dlq.bulk_discard', 200, { - requestedCount: ids.length, - discardedCount: summary.discarded, - actor, - }); - - res.status(200).json({ - message: 'Bulk discard completed', - discardedCount: summary.discarded, - ids: summary.ids, - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/jobs/dead-letters/process - * Triggers batch processing worker for dead-letter records. - * Body: { batchSize?: number } - * Requires API key authentication. - */ -app.post('/admin/jobs/dead-letters/process', validateApiKey, validate({ body: DeadLetterProcessSchema }), async (req: Request, res: Response) => { - const batchSize = typeof req.body?.batchSize === 'number' && req.body.batchSize > 0 ? req.body.batchSize : 10; - const actor = resolveActingAdminAddress(req); - - if (isDryRunRequest(req)) { - res.status(200).json({ - dryRun: true, - message: `DLQ processor dry-run preview for batchSize ${batchSize}`, - batchSize, - wouldProcess: true, - timestamp: new Date().toISOString(), - }); - return; - } - - const summary = await processDeadLetterQueue(batchSize); - - void recordAdminAuditLog(req, 'jobs.dlq.process', 200, { - batchSize, - processed: summary.processed, - succeeded: summary.succeeded, - failed: summary.failed, - actor, - }); - - res.status(200).json({ - message: 'DLQ queue processing batch completed', - batchSize, - processed: summary.processed, - succeeded: summary.succeeded, - failed: summary.failed, - timestamp: new Date().toISOString(), - }); -}); - -// ─── Idempotency Admin Endpoints (Issues #457 & #466) ──────────────────────── - -/** - * GET /admin/idempotency/keys - * Lists idempotency keys with metadata. - * Optional query param: ?prefix= to filter keys by prefix. - * Requires API key authentication. - */ -app.get('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) => { - const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : undefined; - const keys = idempotencyStore.inspectKeys(prefix); - res.status(200).json({ - keys, - count: keys.length, - metrics: idempotencyStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/idempotency/keys/:key - * Removes a single idempotency key from the store. - * Requires API key authentication. - */ -app.delete('/admin/idempotency/keys/:key', validateApiKey, (req: Request, res: Response) => { - const key = decodeURIComponent(req.params.key); - if (isDryRunRequest(req)) { - const exists = idempotencyStore.inspectKeys().some((entry) => entry.key === key); - res.status(exists ? 200 : 404).json({ - dryRun: true, - message: exists - ? `Idempotency key '${key}' would be deleted` - : `Idempotency key '${key}' not found`, - key, - wouldDelete: exists, - metrics: idempotencyStore.getMetrics(), - timestamp: new Date().toISOString(), - }); - return; - } - - const deleted = idempotencyStore.deleteKey(key); - if (!deleted) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: `Idempotency key '${key}' not found`, - }); - return; - } - res.status(200).json({ - message: `Idempotency key '${key}' deleted`, - metrics: idempotencyStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/idempotency/retention/metrics - * Returns idempotency retention policy and sweep metrics. - */ -app.get('/admin/idempotency/retention/metrics', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - metrics: getIdempotencyRetentionMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/idempotency/retention/cleanup - * Runs a retention sweep for stale idempotency records. - */ -app.post('/admin/idempotency/retention/cleanup', validateApiKey, async (req: Request, res: Response) => { - const dryRun = isDryRunRequest(req); - const result = await pruneStaleIdempotencyRecords(dryRun); - res.status(200).json({ - ...result, - metrics: getIdempotencyRetentionMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/idempotency/keys - * Flushes the entire idempotency store. - * Requires super-admin API key. - */ -app.delete('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) => { - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required to flush the idempotency store', - }); - return; - } - if (isDryRunRequest(req)) { - const keys = idempotencyStore.inspectKeys(); - res.status(200).json({ - dryRun: true, - message: 'Idempotency store flush dry-run preview', - wouldFlush: true, - keyCount: keys.length, - metrics: idempotencyStore.getMetrics(), - timestamp: new Date().toISOString(), - }); - return; - } - - idempotencyStore.clear(); - res.status(200).json({ - message: 'Idempotency store flushed', - metrics: idempotencyStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/idempotency/metrics - * Returns hit/conflict/eviction counters for the idempotency store. - * Requires API key authentication. - */ -app.get('/admin/idempotency/metrics', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - metrics: idempotencyStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -// ─── Webhook Deduplication Admin Endpoints (Issue #710) ────────────────────── - -/** - * GET /admin/webhooks/deduplication/metrics - * Returns observability counters for the webhook replay-safe deduplication store. - * Requires API key authentication. - */ -app.get('/admin/webhooks/deduplication/metrics', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - metrics: webhookDeduplicationStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/webhooks/deduplication/entries - * Lists active event fingerprints held in the deduplication store. - * Optional ?prefix= filters by event id prefix. - * Requires API key authentication. - */ -app.get('/admin/webhooks/deduplication/entries', validateApiKey, (req: Request, res: Response) => { - const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : undefined; - const entries = webhookDeduplicationStore.inspect(prefix); - res.status(200).json({ - entries, - count: entries.length, - metrics: webhookDeduplicationStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/webhooks/deduplication/:eventId - * Removes a single event fingerprint from the deduplication store. - * Requires API key authentication. - */ -app.delete('/admin/webhooks/deduplication/:eventId', validateApiKey, (req: Request, res: Response) => { - const eventId = decodeURIComponent(req.params.eventId); - - if (isDryRunRequest(req)) { - const exists = webhookDeduplicationStore.has(eventId); - res.status(exists ? 200 : 404).json({ - dryRun: true, - message: exists - ? `Deduplication entry '${eventId}' would be removed` - : `Deduplication entry '${eventId}' not found`, - eventId, - wouldDelete: exists, - timestamp: new Date().toISOString(), - }); - return; - } - - const removed = webhookDeduplicationStore.remove(eventId); - if (!removed) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: `Deduplication entry '${eventId}' not found`, - }); - return; - } - - void recordAdminAuditLog(req, 'webhook.dedup.entry.removed', 200, { eventId }); - - res.status(200).json({ - message: `Deduplication entry '${eventId}' removed`, - metrics: webhookDeduplicationStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * DELETE /admin/webhooks/deduplication - * Flushes the entire webhook deduplication store. - * Requires super-admin API key. - */ -app.delete('/admin/webhooks/deduplication', validateApiKey, (req: Request, res: Response) => { - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ - error: 'Forbidden', - status: 403, - message: 'Super-admin role is required to flush the webhook deduplication store', - }); - return; - } - - const metrics = webhookDeduplicationStore.getMetrics(); - - if (isDryRunRequest(req)) { - res.status(200).json({ - dryRun: true, - message: 'Webhook deduplication store flush dry-run preview', - wouldFlush: true, - activeFingerprints: metrics.activeFingerprints, - timestamp: new Date().toISOString(), - }); - return; - } - - webhookDeduplicationStore.flush(); - - void recordAdminAuditLog(req, 'webhook.dedup.store.flushed', 200, { - flushedCount: metrics.activeFingerprints, - }); - - res.status(200).json({ - message: 'Webhook deduplication store flushed', - metrics: webhookDeduplicationStore.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -// ─── Wallet Activity Heatmap Endpoint (Issue #712) ─────────────────────────── - -/** - * GET /admin/analytics/wallet-activity/heatmap - * - * Returns aggregated wallet activity counts bucketed by calendar date for use - * in admin analytics dashboards. Raw wallet records are never exposed; only - * the per-bucket counts and the summary statistics are returned. - * - * Query parameters: - * from ISO-8601 date or datetime (inclusive). Defaults to 30 days ago. - * to ISO-8601 date or datetime (inclusive). Defaults to today. - * granularity day | week | month. Defaults to day. - * walletAddress Optional address to scope results to a single wallet. - * - * Requires API key authentication. - */ -app.get('/admin/analytics/wallet-activity/heatmap', validateApiKey, async (req: Request, res: Response) => { - const granularity = - req.query.granularity === 'week' || req.query.granularity === 'month' - ? (req.query.granularity as 'week' | 'month') - : 'day'; - - let rangeStart: string; - let rangeEnd: string; - - try { - const parsed = parseUtcDateRange({ - from: typeof req.query.from === 'string' ? req.query.from : undefined, - to: typeof req.query.to === 'string' ? req.query.to : undefined, - }); - const defaultStart = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); - const defaultEnd = new Date().toISOString().slice(0, 10); - rangeStart = parsed.start ?? defaultStart; - rangeEnd = parsed.end ?? defaultEnd; - } catch (error) { - if (error instanceof DateRangeParseError) { - res.status(error.status).json({ - error: 'Bad Request', - status: error.status, - message: error.message, - }); - return; - } - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to parse date range', - }); - return; - } - - const walletAddress = - typeof req.query.walletAddress === 'string' && req.query.walletAddress.trim() - ? normalizeWalletAddress(req.query.walletAddress.trim()) - : undefined; - - try { - const where: Record = { - timestamp: { - gte: new Date(rangeStart + 'T00:00:00.000Z'), - lte: new Date(rangeEnd + 'T23:59:59.999Z'), - }, - }; - - if (walletAddress) { - where.user = walletAddress; - } - - const transactions = await prisma.transaction.findMany({ - where: where as any, - select: { timestamp: true }, - orderBy: { timestamp: 'asc' }, - }); - - const bucketKey = (date: Date, g: 'day' | 'week' | 'month'): string => { - const y = date.getUTCFullYear(); - const m = String(date.getUTCMonth() + 1).padStart(2, '0'); - const d = String(date.getUTCDate()).padStart(2, '0'); - if (g === 'month') return `${y}-${m}`; - if (g === 'week') { - const dayOfWeek = date.getUTCDay(); - const mondayOffset = (dayOfWeek === 0 ? -6 : 1 - dayOfWeek) * 86400000; - const monday = new Date(date.getTime() + mondayOffset); - const wy = monday.getUTCFullYear(); - const wm = String(monday.getUTCMonth() + 1).padStart(2, '0'); - const wd = String(monday.getUTCDate()).padStart(2, '0'); - return `${wy}-${wm}-${wd}`; - } - return `${y}-${m}-${d}`; - }; - - const buckets = new Map(); - for (const tx of transactions) { - const key = bucketKey(new Date(tx.timestamp), granularity); - buckets.set(key, (buckets.get(key) ?? 0) + 1); - } - - const heatmap = Array.from(buckets.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([bucket, count]) => ({ bucket, count })); - - const totalActivity = heatmap.reduce((sum, b) => sum + b.count, 0); - const peakBucket = heatmap.reduce( - (max, b) => (b.count > (max?.count ?? -1) ? b : max), - null as { bucket: string; count: number } | null, - ); - - res.status(200).json({ - heatmap, - summary: { - totalActivity, - bucketCount: heatmap.length, - granularity, - rangeStart, - rangeEnd, - walletAddress: walletAddress ?? null, - peakBucket: peakBucket ?? null, - }, - timestamp: new Date().toISOString(), - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to aggregate wallet activity', - }); - } -}); - - - -/** - * Mock vault metrics poll cycle - * In a real application, this would fetch data from a database or Stellar RPC - */ -const pollVaultMetrics = () => { - // Mock data for TVL and Share Price - const mockTvl = 1000000 + Math.random() * 100000; - const mockSharePrice = 1.25 + Math.random() * 0.05; - - updateVaultMetrics(mockTvl, mockSharePrice); - - logger.log('info', 'Vault metrics updated in Prometheus gauges', { - tvl: mockTvl, - sharePrice: mockSharePrice, - }); -}; - -// Start poll cycle every 60 seconds (configurable) -const METRICS_POLL_INTERVAL = parseInt(process.env.METRICS_POLL_INTERVAL_MS || '60000', 10); -const metricsInterval = - process.env.NODE_ENV === 'test' - ? null - : setInterval(pollVaultMetrics, METRICS_POLL_INTERVAL); - -if (process.env.NODE_ENV !== 'test') { - pollVaultMetrics(); // Initial call - void initializeWebhookEndpoints(); -} - -// Start latency monitoring -latencyMonitoringService.startMonitoring(); - -// ─── Event Polling Service (Issue: Event Replay) ──────────────────────────── -if (process.env.NODE_ENV !== 'test' && process.env.VAULT_CONTRACT_ID) { - startEventPollingService({ - rpcUrl: process.env.STELLAR_RPC_URL || 'https://soroban-testnet.stellar.org', - contractId: process.env.VAULT_CONTRACT_ID, - pollIntervalMs: parseInt(process.env.EVENT_POLL_INTERVAL_MS || '10000', 10), - batchSize: parseInt(process.env.EVENT_REPLAY_BATCH_SIZE || '100', 10), - }); - - // stopEventPollingService() is a no-op if the service was never started, - // so this is safe to register unconditionally alongside the block above. - shutdownHandler.onShutdown(async () => { - stopEventPollingService(); - }); -} - -// ─── Outbox Pattern Processor ───────────────────────────────────────── -// Starts the background outbox event relay processor. -// Writes from vault operations go to the EventOutbox table atomically; -// this processor reads them and delivers via the webhook system. -if (process.env.NODE_ENV !== 'test') { - eventOutboxService.replayOnStartup().catch((err) => { - logger.log('error', 'Outbox startup replay failed', { - error: err instanceof Error ? err.message : String(err), - }); - }); - eventOutboxService.start(); + }); + eventOutboxService.start(); // Register graceful shutdown for the outbox processor so pending events // are not abandoned when the process receives a termination signal. @@ -5011,15 +741,6 @@ async function getPrismaHealth(): Promise<'up' | 'down'> { } } -function getPrismaConfig() { - const config = getPrismaRuntimeConfig(); - return { - prismaPoolSize: config.poolMax, - prismaQueryTimeoutMs: config.queryTimeoutMs, - prismaPoolTimeoutMs: config.poolTimeoutMs, - }; -} - /** * Check Stellar RPC health * In production, this would make actual RPC calls @@ -5083,424 +804,6 @@ app.get('/health/probes', async (_req: Request, res: Response) => { }); }); -// ─── Write-Ahead Audit Log Endpoints (Issue #707) ─────────────────────────── -/** - * GET /admin/wal/entries - * Lists write-ahead audit log entries with optional filters. - */ -app.get('/admin/wal/entries', validateApiKey, async (req: Request, res: Response) => { - const configType = typeof req.query.configType === 'string' ? req.query.configType : undefined; - const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; - const status = typeof req.query.status === 'string' ? req.query.status as 'pending' | 'committed' | 'rolled_back' : undefined; - const limit = parseLimited(req.query.limit, 50, 1, 200); - - try { - const entries = await writeAheadAuditLog.list({ configType, actor, status, limit }); - const metrics = await writeAheadAuditLog.getMetrics(); - - res.status(200).json({ - entries, - count: entries.length, - metrics, - timestamp: new Date().toISOString(), - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to list write-ahead audit log entries', - }); - } -}); - -/** - * GET /admin/wal/entries/:id - * Returns a specific write-ahead audit log entry. - */ -app.get('/admin/wal/entries/:id', validateApiKey, async (req: Request, res: Response) => { - try { - const entry = await writeAheadAuditLog.getEntry(req.params.id); - if (!entry) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Write-ahead audit log entry not found', - }); - return; - } - res.status(200).json({ entry }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to fetch write-ahead audit log entry', - }); - } -}); - -/** - * GET /admin/wal/metrics - * Returns metrics for the write-ahead audit log. - */ -app.get('/admin/wal/metrics', validateApiKey, async (_req: Request, res: Response) => { - try { - const metrics = await writeAheadAuditLog.getMetrics(); - res.status(200).json({ - metrics, - timestamp: new Date().toISOString(), - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to fetch write-ahead audit log metrics', - }); - } -}); - -/** - * GET /admin/wal/pending - * Returns currently pending (uncommitted) write-ahead entries. - */ -app.get('/admin/wal/pending', validateApiKey, async (_req: Request, res: Response) => { - try { - const pending = await writeAheadAuditLog.getPendingEntries(); - res.status(200).json({ - entries: pending, - count: pending.length, - timestamp: new Date().toISOString(), - }); - } catch (error) { - res.status(500).json({ - error: 'Internal Server Error', - status: 500, - message: error instanceof Error ? error.message : 'Failed to fetch pending write-ahead audit log entries', - }); - } -}); - -// ─── Scoped Admin Token Endpoints (Issue #723 / #858) ─────────────────────── -// All endpoints are async – store operations now hit Prisma for cluster-wide durability. - -/** - * GET /admin/scoped-tokens/permissions - * Returns the list of valid permissions for scoped tokens. - * Must be registered before /:keyId routes to avoid shadowing. - */ -app.get('/admin/scoped-tokens/permissions', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - permissions: scopedAdminTokenStore.getValidPermissions(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * POST /admin/scoped-tokens - * Creates a new permission-scoped admin token. - * Requires super-admin API key. - * Returns the plaintext secret once; it is never stored. - */ -app.post('/admin/scoped-tokens', validateApiKey, validate({ body: ScopedTokenCreateSchema }), async (req: Request, res: Response) => { - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to create scoped tokens' }); - return; - } - const { label, permissions, expiresInSeconds } = req.body; - if (typeof label !== 'string' || !label.trim()) { - res.status(400).json({ error: 'Bad Request', status: 400, message: '`label` (string) is required' }); - return; - } - if (!Array.isArray(permissions) || permissions.length === 0) { - res.status(400).json({ error: 'Bad Request', status: 400, message: '`permissions` (non-empty array) is required' }); - return; - } - const actor = resolveActingAdminAddress(req); - try { - const { token, secret } = await scopedAdminTokenStore.create({ - label: label.trim(), - permissions, - expiresInSeconds: typeof expiresInSeconds === 'number' && expiresInSeconds > 0 ? expiresInSeconds : undefined, - createdBy: actor, - }); - void recordAdminAuditLog(req, 'scoped-token.created', 201, { keyId: token.keyId, label: token.label, permissions: token.permissions, actor }); - res.status(201).json({ message: 'Scoped admin token created', keyId: token.keyId, secret, label: token.label, permissions: token.permissions, expiresAt: token.expiresAt, createdAt: token.createdAt }); - } catch (error) { - res.status(400).json({ error: 'Bad Request', status: 400, message: error instanceof Error ? error.message : 'Failed to create scoped token' }); - } -}); - -/** - * GET /admin/scoped-tokens - * Lists all scoped admin tokens (without secrets). - * Requires admin or super-admin API key. - */ -app.get('/admin/scoped-tokens', validateApiKey, async (req: Request, res: Response) => { - const includeRevoked = req.query.includeRevoked === 'true'; - const tokens = await scopedAdminTokenStore.list({ includeRevoked }); - const sanitized = tokens.map(({ hashedSecret: _hs, ...rest }) => rest); - res.status(200).json({ tokens: sanitized, count: sanitized.length, timestamp: new Date().toISOString() }); -}); - -/** - * GET /admin/scoped-tokens/:keyId - * Returns a single token record (without secret). - * Requires admin or super-admin API key. - */ -app.get('/admin/scoped-tokens/:keyId', validateApiKey, async (req: Request, res: Response) => { - const token = await scopedAdminTokenStore.get(req.params.keyId); - if (!token) { - res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found' }); - return; - } - const { hashedSecret: _hs, ...sanitized } = token; - res.status(200).json({ token: sanitized }); -}); - -/** - * GET /admin/scoped-tokens/:keyId/rotations - * Returns the immutable rotation history for a token (fingerprints only, no old secrets). - * Requires admin or super-admin API key. - */ -app.get('/admin/scoped-tokens/:keyId/rotations', validateApiKey, async (req: Request, res: Response) => { - const token = await scopedAdminTokenStore.get(req.params.keyId); - if (!token) { - res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found' }); - return; - } - const events = await scopedAdminTokenStore.listRotationEvents(req.params.keyId); - res.status(200).json({ keyId: req.params.keyId, rotations: events, count: events.length, timestamp: new Date().toISOString() }); -}); - -/** - * POST /admin/scoped-tokens/:keyId/rotate - * Rotates the secret and writes an immutable audit event row. - * Requires super-admin API key. - */ -app.post('/admin/scoped-tokens/:keyId/rotate', validateApiKey, async (req: Request, res: Response) => { - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to rotate scoped tokens' }); - return; - } - const actor = resolveActingAdminAddress(req); - const result = await scopedAdminTokenStore.rotate(req.params.keyId, { rotatedBy: actor }); - if (!result) { - res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); - return; - } - void recordAdminAuditLog(req, 'scoped-token.rotated', 200, { keyId: result.keyId, rotatedAt: result.rotatedAt, actor }); - res.status(200).json({ message: 'Scoped token rotated', keyId: result.keyId, newSecret: result.newSecret, rotatedAt: result.rotatedAt }); -}); - -/** - * POST /admin/scoped-tokens/:keyId/revoke - * Revokes a scoped admin token cluster-wide. - * Requires super-admin API key. - */ -app.post('/admin/scoped-tokens/:keyId/revoke', validateApiKey, async (req: Request, res: Response) => { - if (!hasRequiredApiKeyRole(req, 'super-admin')) { - res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to revoke scoped tokens' }); - return; - } - const actor = resolveActingAdminAddress(req); - const revoked = await scopedAdminTokenStore.revoke(req.params.keyId, { revokedBy: actor }); - if (!revoked) { - res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); - return; - } - void recordAdminAuditLog(req, 'scoped-token.revoked', 200, { keyId: req.params.keyId, actor }); - res.status(200).json({ message: 'Scoped token revoked', keyId: req.params.keyId, timestamp: new Date().toISOString() }); -}); - -// ─── Withdrawal Partial-Failure Recovery Endpoints (Issue #954) ────────────── - -const WITHDRAWAL_SAGA_STATUSES: readonly WithdrawalSagaStatus[] = [ - 'in_progress', - 'completed', - 'awaiting_retry', - 'compensated', - 'needs_manual_intervention', - 'failed', -]; - -function parseSagaStatus(value: unknown): WithdrawalSagaStatus | undefined { - return typeof value === 'string' && - (WITHDRAWAL_SAGA_STATUSES as readonly string[]).includes(value) - ? (value as WithdrawalSagaStatus) - : undefined; -} - -/** - * GET /admin/withdrawals/recovery - * Lists journalled withdrawal sagas with optional filters. Use - * `?requiresManualIntervention=true` for the operator work queue. - */ -app.get('/admin/withdrawals/recovery', validateApiKey, (req: Request, res: Response) => { - const requiresManualIntervention = - req.query.requiresManualIntervention === undefined - ? undefined - : req.query.requiresManualIntervention === 'true'; - - const sagas = withdrawalRecoveryCoordinator.list({ - status: parseSagaStatus(req.query.status), - walletAddress: - typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined, - withdrawalId: - typeof req.query.withdrawalId === 'string' ? req.query.withdrawalId : undefined, - requiresManualIntervention, - limit: parseLimited(req.query.limit, 50, 1, 200), - }); - - res.status(200).json({ - sagas, - count: sagas.length, - metrics: withdrawalRecoveryCoordinator.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/withdrawals/recovery/metrics - * Aggregate recovery counters for dashboards and alert rules. - */ -app.get('/admin/withdrawals/recovery/metrics', validateApiKey, (_req: Request, res: Response) => { - res.status(200).json({ - metrics: withdrawalRecoveryCoordinator.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - -/** - * GET /admin/withdrawals/recovery/:sagaId - * Returns the full step journal for one withdrawal saga. - */ -app.get('/admin/withdrawals/recovery/:sagaId', validateApiKey, (req: Request, res: Response) => { - const saga = withdrawalRecoveryCoordinator.get(req.params.sagaId); - if (!saga) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Withdrawal recovery saga not found', - }); - return; - } - res.status(200).json({ saga }); -}); - -/** - * POST /admin/withdrawals/recovery/:sagaId/resume - * Replays the saga from its journal. Completed steps are skipped, so no side - * effect is repeated. `force: true` also resumes sagas parked for an operator. - */ -app.post( - '/admin/withdrawals/recovery/:sagaId/resume', - validateApiKey, - async (req: Request, res: Response) => { - const force = req.body?.force === true; - const outcome = await withdrawalRecoveryCoordinator.resume(req.params.sagaId, { force }); - - if (!outcome) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Withdrawal recovery saga not found', - }); - return; - } - - void recordAdminAuditLog(req, 'withdrawal-recovery.resumed', 200, { - sagaId: outcome.saga.id, - withdrawalId: outcome.saga.withdrawalId, - status: outcome.status, - forced: force, - actor: resolveActingAdminAddress(req), - }); - - res.status(200).json({ - message: 'Withdrawal recovery pass completed', - status: outcome.status, - completed: outcome.completed, - partial: outcome.partial, - saga: outcome.saga, - }); - }, -); - -/** - * POST /admin/withdrawals/recovery/:sagaId/resolve - * Closes out a parked saga after an operator reconciled it by hand. - */ -app.post( - '/admin/withdrawals/recovery/:sagaId/resolve', - validateApiKey, - (req: Request, res: Response) => { - const note = typeof req.body?.note === 'string' ? req.body.note.trim() : ''; - if (!note) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'A "note" describing the manual resolution is required', - }); - return; - } - - const outcome = req.body?.outcome; - if (outcome !== undefined && !['completed', 'compensated', 'failed'].includes(outcome)) { - res.status(400).json({ - error: 'Bad Request', - status: 400, - message: 'outcome must be one of: completed, compensated, failed', - }); - return; - } - - const actor = resolveActingAdminAddress(req); - const saga = withdrawalRecoveryCoordinator.resolveManually( - req.params.sagaId, - actor, - note, - outcome, - ); - - if (!saga) { - res.status(404).json({ - error: 'Not Found', - status: 404, - message: 'Withdrawal recovery saga not found', - }); - return; - } - - void recordAdminAuditLog(req, 'withdrawal-recovery.resolved', 200, { - sagaId: saga.id, - withdrawalId: saga.withdrawalId, - outcome: saga.status, - actor, - }); - - res.status(200).json({ message: 'Withdrawal saga resolved', saga }); - }, -); - -/** - * POST /admin/withdrawals/recovery/sweep - * Runs one recovery sweep immediately instead of waiting for the interval. - */ -app.post('/admin/withdrawals/recovery/sweep', validateApiKey, async (req: Request, res: Response) => { - const result = await withdrawalRecoveryCoordinator.sweep(); - void recordAdminAuditLog(req, 'withdrawal-recovery.swept', 200, { - resumed: result.resumed.length, - stale: result.stale.length, - actor: resolveActingAdminAddress(req), - }); - res.status(200).json({ - message: 'Withdrawal recovery sweep completed', - resumed: result.resumed, - staleResumed: result.stale, - metrics: withdrawalRecoveryCoordinator.getMetrics(), - timestamp: new Date().toISOString(), - }); -}); - // Resume partially failed withdrawals in the background. Disabled under test so // suites drive the sweeper explicitly. if (process.env.NODE_ENV !== 'test') { @@ -5510,58 +813,6 @@ if (process.env.NODE_ENV !== 'test') { void initializeJobGovernance(); } -// ─── Vault management ──────────────────────────────────────────────────────── - -/** - * POST /admin/vaults - * - * Creates a new vault for a tenant. - * - * Body: { name: string, symbol: string, tenantId: string } - * - * Validation is handled entirely by CreateVaultBodySchema (which trims - * whitespace before checking min/max lengths), so no manual trim() calls - * are needed in this handler. A whitespace-only name like " " fails schema - * validation and returns 400 before any DB write is attempted. - */ -app.post( - '/admin/vaults', - validateApiKey, - validate({ body: CreateVaultBodySchema }), - async (req: Request, res: Response) => { - const { name, symbol, tenantId } = req.body as { - name: string; - symbol: string; - tenantId: string; - }; - - const prismaClient = getPrismaClient(); - const vault = await prismaClient.vault.create({ - data: { name, symbol, tenantId }, - }); - - void recordAdminAuditLog(req, 'vault.created', 201, { - vaultId: vault.id, - name: vault.name, - symbol: vault.symbol, - tenantId: vault.tenantId, - actor: resolveActingAdminAddress(req), - }); - - res.status(201).json({ - vault: { - id: vault.id, - name: vault.name, - symbol: vault.symbol, - tenantId: vault.tenantId, - aum: vault.aum, - createdAt: vault.createdAt.toISOString(), - }, - timestamp: new Date().toISOString(), - }); - }, -); - // Normalize dependency and unhandled application failures before the 404 route. app.use(errorBoundaryMiddleware); app.use((err: unknown, req: Request, res: Response, next: NextFunction) => { diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 000000000..241433111 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,3263 @@ +/** + * Remaining /admin/* operational endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { API_KEY_AUDIT_ACTIONS, getApiKeyFingerprintFromHash, getApiKeyFingerprintFromValue, isApiKeyHash, listApiKeyAuditEvents, recordApiKeyAuditEvent, resolveApiKeyAuditActor } from '../apiKeyAudit'; +import { AllowlistWalletBodySchema, ApiKeyRegisterSchema, ApiKeyRevokeSchema, ApiKeyRotateSchema, ApyBackfillBodySchema, AuditLogQuerySchema, CacheInvalidateSchema, CreateVaultBodySchema, DeadLetterIdsSchema, DeadLetterProcessSchema, DeadLetterResolveSchema, EventReplayBodySchema, FeatureFlagOverrideSchema, GovernanceSnapshotExportSchema, IdParamSchema, ImpersonationSessionBodySchema, MaintenanceToggleSchema, MaintenanceWindowBodySchema, ScopedTokenCreateSchema, WindowIdParamSchema, WithdrawalLimitOverrideSchema, validate } from '../middleware/validate'; +import { DateRangeParseError, parseUtcDateRange } from '../dateRange'; +import { type DeadLetterStatus, type JobName, bulkDiscardDeadLetters, bulkRetryDeadLetters, discardDeadLetter, getDeadLetterRecord, getJobHealthStatus, getJobMetrics, listDeadLetters, processDeadLetterQueue, resolveDeadLetter, retryDeadLetter } from '../jobGovernance'; +import { type ImpersonationSessionRecord, endImpersonationSession, listImpersonationSessions, resolveImpersonationSessionContext, startImpersonationSession, validateImpersonationSession } from '../impersonationSessionService'; +import { TRANSACTION_PAGINATION_CONFIG, buildPortfolioHoldingsResponse, buildVaultHistoryResponse } from '../listEndpoints'; +import { type WithdrawalSagaStatus, withdrawalRecoveryCoordinator } from '../withdrawalRecovery'; +import { addAddress, allowlistSize, listAddresses, removeAddress } from '../middleware/allowlist'; +import { backfillApySnapshots } from '../apySnapshot'; +import { buildReferralCodeErrorBody, buildReferralStatsErrorBody, buildReferralStatsNotFoundBody } from '../referralEndpoints'; +import { buildVaultSummaryResponseFromDb, isDryRunRequest, paginateByLimit, parseLimited, resolveActingAdminAddress, sendStandardListEnvelope } from './shared'; +import { cancelMaintenanceWindow, listMaintenanceWindows, scheduleMaintenanceWindow } from '../maintenanceWindow'; +import { countAuditLogs, getAuditLogMetrics, getAuditLogs } from '../auditLog'; +import { createPaginatedResponse, createPaginationEnvelope, encodeCursor } from '../pagination'; +import { emailQueueService } from '../emailQueue'; +import { exportGovernanceSnapshots, listGovernanceSnapshots } from '../governanceSnapshotExport'; +import { featureFlags } from '../featureFlags'; +import { generateAdminReceipt, getAdminReceipt, listAdminReceipts, verifyReceiptSignature } from '../adminReceipt'; +import { getApiKeyMetadata, hasRequiredApiKeyRole, normalizeApiKeyRole, registerApiKey, restoreApiKey, revokeApiKey, rotateApiKey, validateApiKey } from '../middleware/apiKeyAuth'; +import { getCacheStats, invalidateCache } from '../middleware/cache'; +import { getIdempotencyRetentionMetrics, pruneStaleIdempotencyRecords } from '../idempotencyRetention'; +import { getMaintenanceModeState, logMaintenanceTransition, updateMaintenanceModeState } from '../maintenanceMode'; +import { getPrismaClient } from '../prismaClient'; +import { getPrismaRuntimeConfig, prisma } from '../prisma'; +import { getRedisCacheHealth, redisCacheClient } from '../redisCache'; +import { getWebhookDeliveryMetrics } from '../webhookDelivery'; +import { idempotencyStore } from '../idempotency'; +import { listAdminConfigChanges, recordAdminConfigChange } from '../adminConfigChangeAudit'; +import { listWithdrawalLimitAuditEntries, setWithdrawalLimitOverride } from '../middleware/withdrawalDailyLimit'; +import { logger } from '../middleware/structuredLogging'; +import { normalizeWalletAddress } from '../walletUtils'; +import { recordAdminAuditLog } from '../adminAudit'; +import { referralService } from '../referralService'; +import { register } from '../metrics'; +import { scopedAdminTokenStore } from '../scopedAdminTokens'; +import { walletAliasMappingService } from '../walletAliasService'; +import { writeAheadAuditLog } from '../writeAheadAuditLog'; + +function countInclusiveDays(start: string, end: string): number { + const startMs = Date.parse(start + 'T00:00:00.000Z'); + const endMs = Date.parse(end + 'T00:00:00.000Z'); + return Math.floor((endMs - startMs) / 86400000) + 1; +} + +async function buildReferralStatsSnapshot(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + + try { + const stats = await referralService.getReferralStats(normalizedWallet); + + if (!stats) { + // Must be the exact envelope GET /api/v1/referrals/:wallet returns, not + // a hand-rolled stand-in — admin impersonation is compared against that + // endpoint field-for-field by the governance contract test. + return { + statusCode: 404, + body: buildReferralStatsNotFoundBody(), + }; + } + + return { + statusCode: 200, + body: stats, + }; + } catch (error) { + // Same reasoning as the 404 above. The route reports a failed lookup as a + // 500 with the canonical envelope; letting the rejection escape instead + // would 500 the entire impersonation response with a different message, so + // one broken sub-resource would take the whole snapshot with it (#1319). + logger.log('error', 'Error building referral stats snapshot', { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return { + statusCode: 500, + body: buildReferralStatsErrorBody(), + }; + } +} + +async function buildReferralCodeSnapshot(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + + try { + const code = await referralService.getOrCreateReferralCode(normalizedWallet); + return { + statusCode: 200, + body: { code }, + }; + } catch (error) { + logger.log('error', 'Error building referral code snapshot', { + error: error instanceof Error ? error.message : String(error), + wallet: normalizedWallet, + }); + return { + statusCode: 500, + body: buildReferralCodeErrorBody(), + }; + } +} + +async function buildWalletTransactionsSnapshot(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + const prisma = getPrismaClient(); + // Shared with GET /api/v1/transactions so the proxy can never drift to a + // different page size than the endpoint it stands in for (#1322). + const limit = TRANSACTION_PAGINATION_CONFIG.defaultLimit ?? 20; + const where = { user: normalizedWallet }; + const [total, transactions] = await Promise.all([ + prisma.transaction.count({ where }), + prisma.transaction.findMany({ + where, + orderBy: { timestamp: 'desc' }, + take: limit + 1, + }), + ]); + const hasNextPage = transactions.length > limit; + const data = hasNextPage ? transactions.slice(0, limit) : transactions; + + return createPaginatedResponse( + data, + createPaginationEnvelope({ + count: data.length, + limit, + total, + hasNextPage, + hasPrevPage: false, + nextCursor: hasNextPage && data.length > 0 ? encodeCursor(data[data.length - 1].id) : null, + }), + ); +} + +async function buildImpersonatedVaultState(wallet: string) { + const normalizedWallet = normalizeWalletAddress(wallet); + return { + walletAddress: normalizedWallet, + // Impersonation must show an admin exactly what the wallet's own + // dashboard would show, so read the same DB-backed summary + // GET /api/v1/vault/summary serves. + summary: await buildVaultSummaryResponseFromDb(), + transactions: await buildWalletTransactionsSnapshot(normalizedWallet), + portfolioHoldings: buildPortfolioHoldingsResponse({ walletAddress: normalizedWallet }), + vaultHistory: buildVaultHistoryResponse({}), + referralStats: await buildReferralStatsSnapshot(normalizedWallet), + referralCode: await buildReferralCodeSnapshot(normalizedWallet), + }; +} + +function getPrismaConfig() { + const config = getPrismaRuntimeConfig(); + return { + prismaPoolSize: config.poolMax, + prismaQueryTimeoutMs: config.queryTimeoutMs, + prismaPoolTimeoutMs: config.poolTimeoutMs, + }; +} + +// ─── Withdrawal Partial-Failure Recovery Endpoints (Issue #954) ────────────── + +const WITHDRAWAL_SAGA_STATUSES: readonly WithdrawalSagaStatus[] = [ + 'in_progress', + 'completed', + 'awaiting_retry', + 'compensated', + 'needs_manual_intervention', + 'failed', +]; + +function parseSagaStatus(value: unknown): WithdrawalSagaStatus | undefined { + return typeof value === 'string' && + (WITHDRAWAL_SAGA_STATUSES as readonly string[]).includes(value) + ? (value as WithdrawalSagaStatus) + : undefined; +} + +const router = Router(); + +// ─── Admin Routes (with API key authentication) ────────────────────────────── + +/** + * POST /admin/apy/backfill - backfill missing APY snapshots for a date range + * Body: { start: "YYYY-MM-DD", end: "YYYY-MM-DD" } + * Requires API key authentication. + */ +router.post('/admin/apy/backfill', validateApiKey, validate({ body: ApyBackfillBodySchema }), async (req: Request, res: Response) => { + const { start, end } = req.body; + if (!start || !end || typeof start !== 'string' || typeof end !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` (YYYY-MM-DD) are required', + }); + return; + } + + const datePattern = /^\d{4}-\d{2}-\d{2}$/; + if (!datePattern.test(start) || !datePattern.test(end)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`start` and `end` must be in YYYY-MM-DD format', + }); + return; + } + + if (end < start) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`end` must be >= `start`', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const jobStart = Date.now(); + + try { + if (isDryRunRequest(req)) { + void recordAdminAuditLog(req, 'apy.backfill.dry_run', 200, { + start, + end, + actor, + estimatedDates: countInclusiveDays(start, end), + }); + + res.status(200).json({ + dryRun: true, + message: 'APY backfill dry-run preview', + start, + end, + estimatedDates: countInclusiveDays(start, end), + wouldCreateSnapshots: true, + timestamp: new Date().toISOString(), + }); + return; + } + + const result = await backfillApySnapshots(start, end); + const durationMs = Date.now() - jobStart; + + const receipt = await generateAdminReceipt({ + action: 'apy.backfill', + actor, + input: { start, end }, + resultingState: { + created: result.created, + skipped: result.skipped, + durationMs, + }, + }); + + void recordAdminAuditLog(req, 'apy.backfill', 200, { + start, + end, + created: result.created, + skipped: result.skipped, + durationMs, + actor, + receiptId: receipt.id, + }); + + res.status(200).json({ + message: 'APY backfill completed', + start, + end, + created: result.created, + skipped: result.skipped, + dates: result.dates, + durationMs, + timestamp: new Date().toISOString(), + receipt, + }); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * GET /admin/maintenance - get current maintenance mode state + * Requires API key authentication. + */ +router.get('/admin/maintenance', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + maintenance: getMaintenanceModeState(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/maintenance - enable or disable maintenance mode + * Body: { enabled: boolean, reason?: string, retryAfterSeconds?: number } + * Requires API key authentication. + */ +router.post('/admin/maintenance', validateApiKey, validate({ body: MaintenanceToggleSchema }), async (req: Request, res: Response) => { + const { enabled, reason, retryAfterSeconds } = req.body; + if (typeof enabled !== 'boolean') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`enabled` (boolean) is required', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const previous = getMaintenanceModeState(); + const dryRun = isDryRunRequest(req); + const preview = { + enabled: enabled ?? previous.enabled, + reason: + reason === undefined + ? previous.reason + : typeof reason === 'string' + ? reason.trim() || undefined + : undefined, + updatedAt: new Date().toISOString(), + updatedBy: actor, + retryAfterSeconds: retryAfterSeconds ?? previous.retryAfterSeconds, + }; + + if (dryRun) { + void recordAdminAuditLog(req, 'maintenance.toggle.dry_run', 200, { + enabled: preview.enabled, + previousEnabled: previous.enabled, + reason: preview.reason, + actor, + }); + + res.status(200).json({ + dryRun: true, + message: `Maintenance mode would be ${preview.enabled ? 'enabled' : 'disabled'}`, + previous, + maintenance: preview, + timestamp: new Date().toISOString(), + }); + return; + } + + const next = updateMaintenanceModeState({ enabled, reason, retryAfterSeconds, actor }); + + await recordAdminConfigChange({ + configType: 'maintenance', + action: 'toggle', + actor, + ipAddress: req.ip, + userAgent: req.get('user-agent'), + preChangeSnapshot: previous as unknown as Record, + postChangeSnapshot: next as unknown as Record, + metadata: { receiptId: '' }, + }); + + const receipt = await generateAdminReceipt({ + action: 'maintenance.toggle', + actor, + input: { enabled, reason, retryAfterSeconds }, + resultingState: { + enabled: next.enabled, + reason: next.reason, + retryAfterSeconds: next.retryAfterSeconds, + previousEnabled: previous.enabled, + }, + }); + + logMaintenanceTransition({ + enabled: next.enabled, + actor, + reason: next.reason, + retryAfterSeconds: next.retryAfterSeconds, + previousEnabled: previous.enabled, + }); + + void recordAdminAuditLog(req, 'maintenance.toggle', 200, { + enabled: next.enabled, + previousEnabled: previous.enabled, + reason: next.reason, + actor, + receiptId: receipt.id, + }); + + res.status(200).json({ + message: `Maintenance mode ${next.enabled ? 'enabled' : 'disabled'}`, + maintenance: next, + timestamp: new Date().toISOString(), + receipt, + }); +}); + +/** + * GET /admin/maintenance/windows - list scheduled maintenance windows + */ +router.get('/admin/maintenance/windows', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + windows: listMaintenanceWindows(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/maintenance/windows - schedule a maintenance window + * Body: { title: string, reason?: string, startsAt: string, endsAt: string } + */ +router.post('/admin/maintenance/windows', validateApiKey, validate({ body: MaintenanceWindowBodySchema }), (req: Request, res: Response) => { + const { title, reason, startsAt, endsAt } = req.body; + if (typeof title !== 'string' || !title.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`title` (string) is required', + }); + return; + } + if (typeof startsAt !== 'string' || typeof endsAt !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`startsAt` and `endsAt` (ISO strings) are required', + }); + return; + } + + try { + const actor = resolveActingAdminAddress(req); + const window = scheduleMaintenanceWindow({ + title, + reason: typeof reason === 'string' ? reason : undefined, + startsAt, + endsAt, + actor, + }); + res.status(201).json({ window, timestamp: new Date().toISOString() }); + } catch (error) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: error instanceof Error ? error.message : String(error), + }); + } +}); + +/** + * DELETE /admin/maintenance/windows/:windowId - cancel a scheduled window + */ +router.delete('/admin/maintenance/windows/:windowId', validateApiKey, validate({ params: WindowIdParamSchema }), (req: Request, res: Response) => { + const cancelled = cancelMaintenanceWindow(req.params.windowId); + if (!cancelled) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Maintenance window not found', + }); + return; + } + res.status(200).json({ + cancelled: true, + windowId: req.params.windowId, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/config-changes - List admin configuration changes + * Requires API key authentication + */ +router.get('/admin/config-changes', validateApiKey, async (req: Request, res: Response) => { + const { configType, actor, start, end, limit } = req.query; + const configChanges = await listAdminConfigChanges({ + configType: typeof configType === 'string' ? configType : undefined, + actor: typeof actor === 'string' ? actor : undefined, + start: typeof start === 'string' ? start : undefined, + end: typeof end === 'string' ? end : undefined, + limit: typeof limit === 'string' ? parseInt(limit, 10) : 100, + }); + + res.json({ + data: configChanges, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/feature-flags/overrides - List active feature flag overrides + * Requires API key authentication + */ +router.get('/admin/feature-flags/overrides', validateApiKey, async (_req: Request, res: Response) => { + const overrides = await featureFlags.listActiveOverrides(); + res.json({ + data: overrides, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/feature-flags/overrides - Create a new feature flag override + * Requires API key authentication + * Body: { + * flagName: string, + * enabled: boolean, + * scopeType: "wallet" | "environment", + * scopeValue?: string, + * expiresAt: string (ISO 8601) + * } + */ +router.post('/admin/feature-flags/overrides', validateApiKey, validate({ body: FeatureFlagOverrideSchema }), async (req: Request, res: Response) => { + const { flagName, enabled, scopeType, scopeValue, expiresAt } = req.body; + + if (!flagName || typeof flagName !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`flagName` (string) is required' + }); + return; + } + + if (typeof enabled !== 'boolean') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`enabled` (boolean) is required' + }); + return; + } + + if (!scopeType || !['wallet', 'environment'].includes(scopeType)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`scopeType` must be "wallet" or "environment"' + }); + return; + } + + if (scopeType === 'wallet' && (!scopeValue || typeof scopeValue !== 'string')) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`scopeValue` (string) is required for wallet scope' + }); + return; + } + + if (scopeType === 'environment' && (!scopeValue || typeof scopeValue !== 'string')) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`scopeValue` (string) is required for environment scope' + }); + return; + } + + if (!expiresAt || typeof expiresAt !== 'string') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`expiresAt` (ISO 8601 string) is required' + }); + return; + } + + const expiresAtDate = new Date(expiresAt); + if (isNaN(expiresAtDate.getTime()) || expiresAtDate <= new Date()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`expiresAt` must be a valid future date in ISO 8601 format' + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const override = await featureFlags.createOverride( + flagName, + enabled, + scopeType as 'wallet' | 'environment', + scopeValue, + expiresAtDate, + actor + ); + + res.status(201).json({ + message: 'Feature flag override created successfully', + data: override, + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/feature-flags/overrides/:id - Delete a feature flag override + * Requires API key authentication + */ +router.delete('/admin/feature-flags/overrides/:id', validateApiKey, async (req: Request, res: Response) => { + const { id } = req.params; + if (!id) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Override ID is required' + }); + return; + } + + try { + await featureFlags.deleteOverride(id); + res.status(200).json({ + message: 'Feature flag override deleted successfully', + timestamp: new Date().toISOString(), + }); + } catch (err) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Feature flag override not found' + }); + } +}); + +/** + * GET /admin/cache/stats - Get cache statistics including hit rate (R8) and Redis status + * Requires API key authentication + */ +router.get('/admin/cache/stats', validateApiKey, async (_req: Request, res: Response) => { + const stats = getCacheStats(); + // Compute hit rate from Prometheus counters by reading the registry metrics text + let hitRate: number | null = null; + try { + const metricsText = await register.metrics(); + const hitMatch = metricsText.match(/^cache_hit_count(?:\{[^}]*\})?\s+([\d.]+)/m); + const missMatch = metricsText.match(/^cache_miss_count(?:\{[^}]*\})?\s+([\d.]+)/m); + // Sum all label combinations + const hitTotal = hitMatch + ? metricsText + .split('\n') + .filter((l) => l.startsWith('cache_hit_count')) + .reduce((acc, l) => { + const m = l.match(/\s+([\d.]+)$/); + return acc + (m ? parseFloat(m[1]) : 0); + }, 0) + : 0; + const missTotal = missMatch + ? metricsText + .split('\n') + .filter((l) => l.startsWith('cache_miss_count')) + .reduce((acc, l) => { + const m = l.match(/\s+([\d.]+)$/); + return acc + (m ? parseFloat(m[1]) : 0); + }, 0) + : 0; + const total = hitTotal + missTotal; + hitRate = total > 0 ? parseFloat((hitTotal / total).toFixed(4)) : null; + } catch { + hitRate = null; + } + + const redisHealth = await getRedisCacheHealth(); + + res.json({ + entryCount: stats.size, + entries: stats.entries, + hitRate, + redis: { + configured: redisCacheClient.isConfigured, + ready: redisCacheClient.isReady, + health: redisHealth, + }, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/cache/redis-status - Detailed Redis cache connection and health status + * Requires API key authentication + */ +router.get('/admin/cache/redis-status', validateApiKey, async (_req: Request, res: Response) => { + const redisHealth = await getRedisCacheHealth(); + const pingResponse = await redisCacheClient.ping(); + + res.json({ + configured: redisCacheClient.isConfigured, + ready: redisCacheClient.isReady, + health: redisHealth, + ping: pingResponse, + fallbackActive: redisCacheClient.isConfigured && !redisCacheClient.isReady, + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/cache - Clear entire cache or by regex pattern (R8) + * ?pattern= removes only matching entries + * Requires API key authentication + */ +router.delete('/admin/cache', validateApiKey, (req: Request, res: Response) => { + const pattern = typeof req.query.pattern === 'string' ? req.query.pattern : undefined; + + if (pattern !== undefined) { + if (pattern.trim() === '') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'pattern query parameter must not be empty; omit it to clear the entire cache', + }); + return; + } + try { + new RegExp(pattern); + } catch (e) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: `Invalid regex pattern "${pattern}": ${e instanceof Error ? e.message : String(e)}`, + }); + return; + } + } + + const removed = invalidateCache(pattern); + res.json({ + removed, + pattern: pattern ?? null, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/cache/invalidate - Invalidate cache by pattern (legacy endpoint) + * Requires API key authentication + */ +router.post('/admin/cache/invalidate', validateApiKey, validate({ body: CacheInvalidateSchema }), (req: Request, res: Response) => { + const { pattern } = req.body; + if (isDryRunRequest(req)) { + res.json({ + dryRun: true, + message: 'Cache invalidation dry-run preview', + pattern, + wouldInvalidate: true, + stats: getCacheStats(), + timestamp: new Date().toISOString(), + }); + return; + } + + invalidateCache(pattern); + res.json({ + message: 'Cache invalidated', + pattern, + stats: getCacheStats(), + }); +}); + +/** + * POST /admin/events/replay - Manual admin endpoint to replay events for a specific ledger range + * Requires API key authentication + */ +router.post('/admin/events/replay', validateApiKey, validate({ body: EventReplayBodySchema }), async (req: Request, res: Response) => { + try { + const { fromLedger, toLedger } = req.body; + + if (fromLedger === undefined || toLedger === undefined) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'fromLedger and toLedger are required in request body', + }); + return; + } + + if (typeof fromLedger !== 'number' || typeof toLedger !== 'number') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'fromLedger and toLedger must be numbers', + }); + return; + } + + // Validate ledger range + if (fromLedger < 0 || toLedger < 0 || fromLedger > toLedger) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'fromLedger must be >= 0, toLedger must be >= 0, and fromLedger must be <= toLedger', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + if (isDryRunRequest(req)) { + void recordAdminAuditLog(req, 'events.replay.manual.dry_run', 200, { + fromLedger, + toLedger, + ledgerCount: toLedger - fromLedger + 1, + actor, + timestamp: new Date().toISOString(), + }); + + res.status(200).json({ + dryRun: true, + message: 'Event replay dry-run preview', + fromLedger, + toLedger, + ledgerCount: toLedger - fromLedger + 1, + wouldReplay: true, + timestamp: new Date().toISOString(), + }); + return; + } + + // Import the replay function + const { replayEventsForRange } = await import('../eventPollingService'); + + const startTime = Date.now(); + const result = await replayEventsForRange(fromLedger, toLedger); + const duration = Date.now() - startTime; + + const receipt = await generateAdminReceipt({ + action: 'events.replay.manual', + actor, + input: { fromLedger, toLedger }, + resultingState: { + processedCount: result.processedCount, + duplicateCount: result.duplicateCount, + durationMs: duration, + }, + }); + + // Record replay job metadata + void recordAdminAuditLog(req, 'events.replay.manual', 200, { + fromLedger, + toLedger, + processedCount: result.processedCount, + duplicateCount: result.duplicateCount, + durationMs: duration, + timestamp: new Date().toISOString(), + receiptId: receipt.id, + }); + + res.status(200).json({ + message: 'Event replay completed successfully', + fromLedger, + toLedger, + processedCount: result.processedCount, + duplicateCount: result.duplicateCount, + durationMs: duration, + timestamp: new Date().toISOString(), + receipt, + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + + // Record failed replay attempt + void recordAdminAuditLog(req, 'events.replay.manual.failed', 500, { + error: errorMessage, + timestamp: new Date().toISOString(), + }); + + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: errorMessage, + }); + } +}); + +/** + * POST /admin/withdrawal-limits/override + * Grants a temporary admin override for a wallet's daily withdrawal limit. + * Requires super-admin API key. + */ +router.post('/admin/withdrawal-limits/override', validateApiKey, validate({ body: WithdrawalLimitOverrideSchema }), async (req: Request, res: Response) => { + const walletAddress = typeof req.body?.walletAddress === 'string' ? req.body.walletAddress.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + const ttlSeconds = + typeof req.body?.ttlSeconds === 'number' && req.body.ttlSeconds > 0 + ? req.body.ttlSeconds + : 3600; + + if (!walletAddress || !reason) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'walletAddress and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to override withdrawal limits', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const override = setWithdrawalLimitOverride(walletAddress, reason, actor, ttlSeconds); + + await recordAdminAuditLog(req, 'withdrawal.limit.override.grant', 201, { + walletAddress: override.wallet, + reason: override.reason, + expiresAt: override.expiresAt, + actor, + }); + + res.status(201).json({ override }); +}); + +/** + * GET /admin/withdrawal-limits/audit + * Lists recent blocked and overridden withdrawal attempts. + */ +router.get('/admin/withdrawal-limits/audit', validateApiKey, (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 200); + const windowed = listWithdrawalLimitAuditEntries(limit + 1); + const { data, hasNextPage } = paginateByLimit(windowed, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { entries: data }, + }); +}); + +/** + * GET /admin/emails/queue + * Lists queued outbound emails, optionally filtered by status. + */ +router.get('/admin/emails/queue', validateApiKey, async (req: Request, res: Response) => { + const status = typeof req.query.status === 'string' ? req.query.status : undefined; + const emails = await emailQueueService.getEmailQueue(status); + + res.status(200).json({ + emails, + count: emails.length, + }); +}); + +/** + * POST /admin/emails/replay/:id + * Requeues a failed/dead-letter email for another send attempt. + */ +router.post('/admin/emails/replay/:id', validateApiKey, async (req: Request, res: Response) => { + try { + const email = await emailQueueService.replayEmail(req.params.id); + res.status(200).json({ + message: 'Email requeued successfully', + email, + }); + } catch { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Email queue item not found', + }); + } +}); + +// ─── Allowlist Admin Endpoints (Issue #375) ────────────────────────────────── + +/** + * POST /admin/allowlist/add + * Adds a wallet address to the private beta allowlist. + * Requires API key authentication. + * Body: { "walletAddress": "G..." } + */ +router.post('/admin/allowlist/add', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { + const { walletAddress } = req.body; + const added = addAddress(walletAddress); + const actor = resolveActingAdminAddress(req); + + const receipt = await generateAdminReceipt({ + action: 'allowlist.add', + actor, + input: { walletAddress }, + resultingState: { + added, + totalCount: allowlistSize(), + }, + }); + + void recordAdminAuditLog(req, 'allowlist.add', added ? 201 : 200, { + walletAddress, + added, + actor, + receiptId: receipt.id, + }); + + res.status(added ? 201 : 200).json({ + message: added ? 'Wallet added to allowlist' : 'Wallet already in allowlist', + walletAddress: walletAddress.trim().toUpperCase(), + count: allowlistSize(), + receipt, + }); +}); + +/** + * DELETE /admin/allowlist/remove + * Removes a wallet address from the private beta allowlist. + * Requires API key authentication. + * Body: { "walletAddress": "G..." } + */ +router.delete('/admin/allowlist/remove', validateApiKey, validate({ body: AllowlistWalletBodySchema }), async (req: Request, res: Response) => { + const { walletAddress } = req.body; + const removed = removeAddress(walletAddress); + if (!removed) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Wallet address not found in allowlist', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const receipt = await generateAdminReceipt({ + action: 'allowlist.remove', + actor, + input: { walletAddress }, + resultingState: { + removed: true, + totalCount: allowlistSize(), + }, + }); + + void recordAdminAuditLog(req, 'allowlist.remove', 200, { + walletAddress, + actor, + receiptId: receipt.id, + }); + + res.json({ + message: 'Wallet removed from allowlist', + walletAddress: walletAddress.trim().toUpperCase(), + count: allowlistSize(), + receipt, + }); +}); + +/** + * GET /admin/allowlist + * Lists all wallet addresses on the allowlist. + * Requires API key authentication. + */ +router.get('/admin/allowlist', validateApiKey, (_req: Request, res: Response) => { + res.json({ + addresses: listAddresses(), + count: allowlistSize(), + enabled: process.env.ALLOWLIST_ENABLED !== 'false', + }); +}); + +/** + * GET /admin/wallet-aliases + * Lists persisted wallet alias identity groups. + * Requires API key authentication. + */ +router.get('/admin/wallet-aliases', validateApiKey, async (_req: Request, res: Response) => { + const groups = await walletAliasMappingService.listIdentityLinks(); + res.json({ + groups, + count: groups.length, + }); +}); + +/** + * DELETE /admin/wallet-aliases/:canonicalId + * Deletes a canonical wallet alias group and all linked aliases. + * Requires API key authentication. + */ +router.delete('/admin/wallet-aliases/:canonicalId', validateApiKey, async (req: Request, res: Response) => { + const { canonicalId } = req.params; + const existing = await walletAliasMappingService.getIdentityLinks(canonicalId); + + if (!existing) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Canonical identity not found', + }); + return; + } + + const deleted = await walletAliasMappingService.deleteIdentityGroup(canonicalId); + if (!deleted) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Canonical identity not found', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + void recordAdminAuditLog(req, 'wallet-alias.delete', 200, { + actor, + canonicalId, + aliases: existing.aliases, + sources: existing.sources, + }); + + res.status(200).json({ + message: 'Wallet alias group deleted', + canonicalId, + aliasesDeleted: existing.aliases.length, + sources: existing.sources, + }); +}); + +/** + * POST /admin/impersonate/sessions - start a time-bounded impersonation session + * Requires super-admin API key. + */ +router.post('/admin/impersonate/sessions', validateApiKey, validate({ body: ImpersonationSessionBodySchema }), async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const { actor, apiKeyHash, ipAddress, userAgent } = resolveImpersonationSessionContext(req); + const targetWallet = typeof req.body?.targetWallet === 'string' ? req.body.targetWallet.trim() : ''; + const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : ''; + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: targetWallet || 'unknown', + impersonation: true, + }; + + if (!targetWallet || !reason) { + req.adminAuditAction = 'admin.impersonate.session.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'targetWallet and reason are required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation sessions', + }); + return; + } + + try { + const session = await startImpersonationSession({ + actor: actingAdminAddress, + apiKeyHash, + targetWallet, + reason, + ipAddress, + userAgent, + }); + + req.adminAuditAction = 'admin.impersonate.session.started'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + sessionId: session.id, + expiresAt: session.expiresAt, + }; + + res.status(201).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to start impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/sessions - list active and historical impersonation sessions + * Requires super-admin API key. + */ +router.get('/admin/impersonate/sessions', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + impersonation: true, + }; + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.list.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to list impersonation sessions', + }); + return; + } + + const statusRaw = typeof req.query.status === 'string' ? req.query.status : 'all'; + const status = + statusRaw === 'active' || statusRaw === 'ended' || statusRaw === 'expired' ? statusRaw : 'all'; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const targetWallet = typeof req.query.targetWallet === 'string' ? req.query.targetWallet : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 200); + + try { + const sessions = await listImpersonationSessions({ + status, + actor, + targetWallet, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(sessions, limit); + + req.adminAuditAction = 'admin.impersonate.session.list'; + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + sessions: data, + count: data.length, + }, + }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.list.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to list impersonation sessions', + }); + } +}); + +/** + * DELETE /admin/impersonate/sessions/:id - end an active impersonation session + * Requires super-admin API key. + */ +router.delete('/admin/impersonate/sessions/:id', validateApiKey, async (req: Request, res: Response) => { + const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.params.id || '').trim(); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + sessionId: sessionId || 'unknown', + impersonation: true, + }; + + if (!sessionId) { + req.adminAuditAction = 'admin.impersonate.session.end.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'session id is required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.session.end.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to end impersonation sessions', + }); + return; + } + + try { + const session = await endImpersonationSession(sessionId, actingAdminAddress); + if (!session) { + req.adminAuditAction = 'admin.impersonate.session.end.not_found'; + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Impersonation session not found or already expired', + }); + return; + } + + req.adminAuditAction = 'admin.impersonate.session.ended'; + res.status(200).json({ session }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.session.end.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to end impersonation session', + }); + } +}); + +/** + * GET /admin/impersonate/:wallet - inspect vault state as a specific wallet + * Requires super-admin API key and a valid non-expired impersonation session. + */ +router.get('/admin/impersonate/:wallet', validateApiKey, async (req: Request, res: Response) => { + const wallet = String(req.params.wallet || '').trim(); + const actingAdminAddress = resolveActingAdminAddress(req); + const sessionId = String(req.get('x-impersonation-session-id') || '').trim(); + + req.adminAuditActor = actingAdminAddress; + req.adminAuditMetadata = { + actingAdminAddress, + adminRole: req.authApiKeyRole || 'admin', + targetWallet: wallet || 'unknown', + sessionId: sessionId || undefined, + impersonation: true, + }; + + if (!wallet) { + req.adminAuditAction = 'admin.impersonate.invalid'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'wallet path parameter is required', + }); + return; + } + + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + req.adminAuditAction = 'admin.impersonate.denied'; + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required for impersonation', + }); + return; + } + + if (!sessionId && process.env.IMPERSONATION_SESSION_STORAGE) { + req.adminAuditAction = 'admin.impersonate.session.required'; + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'x-impersonation-session-id header is required', + }); + return; + } + + try { + let activeSession: ImpersonationSessionRecord | undefined; + + if (sessionId) { + const validation = await validateImpersonationSession(sessionId, wallet, actingAdminAddress); + if (!validation.ok) { + const statusCode = validation.reason === 'not_found' ? 404 : 403; + req.adminAuditAction = + validation.reason === 'expired' + ? 'admin.impersonate.session.expired' + : validation.reason === 'ended' + ? 'admin.impersonate.session.ended' + : validation.reason === 'wallet_mismatch' + ? 'admin.impersonate.session.wallet_mismatch' + : validation.reason === 'actor_mismatch' + ? 'admin.impersonate.session.actor_mismatch' + : 'admin.impersonate.session.invalid'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + validationReason: validation.reason, + }; + res.status(statusCode).json({ + error: statusCode === 404 ? 'Not Found' : 'Forbidden', + status: statusCode, + message: + validation.reason === 'expired' + ? 'Impersonation session has expired' + : validation.reason === 'ended' + ? 'Impersonation session has ended' + : validation.reason === 'wallet_mismatch' + ? 'Session wallet does not match target wallet' + : validation.reason === 'actor_mismatch' + ? 'Session actor does not match requesting admin' + : 'Impersonation session is invalid', + }); + return; + } + activeSession = validation.session; + } + + req.adminAuditAction = 'admin.impersonate'; + + const vaultState = await buildImpersonatedVaultState(wallet); + res.status(200).json({ + ...vaultState, + impersonationSession: activeSession + ? { + id: activeSession.id, + reason: activeSession.reason, + startedAt: activeSession.startedAt, + expiresAt: activeSession.expiresAt, + } + : undefined, + }); + } catch (error) { + req.adminAuditAction = 'admin.impersonate.failed'; + req.adminAuditMetadata = { + ...req.adminAuditMetadata, + error: error instanceof Error ? error.message : String(error), + }; + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to build impersonated vault state', + }); + } +}); + +router.get('/admin/receipts', validateApiKey, async (req: Request, res: Response) => { + const action = typeof req.query.action === 'string' ? req.query.action : undefined; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 200); + + try { + const receipts = await listAdminReceipts({ action, actor, limit: limit + 1 }); + const { data, hasNextPage } = paginateByLimit(receipts, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + receipts: data, + count: data.length, + }, + }); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * GET /admin/receipts/:id + * Retrieves a specific admin action receipt. + * Requires API key authentication. + */ +router.get('/admin/receipts/:id', validateApiKey, async (req: Request, res: Response) => { + try { + const receipt = await getAdminReceipt(req.params.id); + if (!receipt) { + res.status(404).json({ error: 'Receipt not found' }); + return; + } + res.json(receipt); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * GET /admin/receipts/:id/verify + * Verifies the integrity of an admin action receipt. + * Requires API key authentication. + */ +router.get('/admin/receipts/:id/verify', validateApiKey, async (req: Request, res: Response) => { + try { + const receipt = await getAdminReceipt(req.params.id); + if (!receipt) { + res.status(404).json({ error: 'Receipt not found' }); + return; + } + + const isValid = verifyReceiptSignature(receipt); + res.json({ + id: receipt.id, + isValid, + timestamp: new Date().toISOString(), + }); + } catch (err) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: err instanceof Error ? err.message : String(err), + }); + } +}); + +/** + * POST /admin/api-keys/register - Register a new API key + * Requires API key authentication (for boostrapping, requires special permission) + */ +router.post('/admin/api-keys/register', validateApiKey, validate({ body: ApiKeyRegisterSchema }), async (req: Request, res: Response) => { + const { key, role: requestedRole } = req.body; + if (!key || typeof key !== 'string' || !key.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Missing key in request body', + }); + return; + } + + const role = normalizeApiKeyRole(requestedRole) || 'admin'; + if (role === 'super-admin' && !hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to register super-admin API keys', + }); + return; + } + + const normalizedKey = key.trim(); + const hash = registerApiKey(normalizedKey, { role }); + + try { + await recordApiKeyAuditEvent({ + actor: resolveApiKeyAuditActor(req), + action: API_KEY_AUDIT_ACTIONS.created, + keyFingerprint: getApiKeyFingerprintFromHash(hash), + }); + + res.json({ + message: 'API key registered', + hash, + fingerprint: getApiKeyFingerprintFromHash(hash), + role, + created: new Date().toISOString(), + }); + } catch (error) { + revokeApiKey(hash); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to persist API key audit event', + }); + } +}); + +/** + * POST /admin/api-keys/rotate - Rotate an API key + * Body: { oldHash: string, newKey: string } + * Requires API key authentication. + */ +router.post('/admin/api-keys/rotate', validateApiKey, validate({ body: ApiKeyRotateSchema }), async (req: Request, res: Response) => { + const { oldHash, newKey } = req.body || {}; + if (!isApiKeyHash(oldHash)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'oldHash must be a valid SHA-256 API key hash', + }); + return; + } + + if (typeof newKey !== 'string' || !newKey.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'newKey is required', + }); + return; + } + + const previousMetadata = getApiKeyMetadata(oldHash); + if (!previousMetadata) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'API key not found', + }); + return; + } + + const normalizedNewKey = newKey.trim(); + const newHash = rotateApiKey(oldHash, normalizedNewKey); + if (!newHash) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'API key not found', + }); + return; + } + + try { + await recordApiKeyAuditEvent({ + actor: resolveApiKeyAuditActor(req), + action: API_KEY_AUDIT_ACTIONS.rotated, + keyFingerprint: getApiKeyFingerprintFromValue(normalizedNewKey), + }); + + res.status(200).json({ + message: 'API key rotated', + oldFingerprint: getApiKeyFingerprintFromHash(oldHash), + newHash, + newFingerprint: getApiKeyFingerprintFromHash(newHash), + rotatedAt: new Date().toISOString(), + }); + } catch (error) { + revokeApiKey(newHash); + restoreApiKey(oldHash); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to persist API key audit event', + }); + } +}); + +/** + * POST /admin/api-keys/revoke - Revoke an API key + * Body: { hash: string } + * Requires API key authentication. + */ +router.post('/admin/api-keys/revoke', validateApiKey, validate({ body: ApiKeyRevokeSchema }), async (req: Request, res: Response) => { + const { hash } = req.body || {}; + if (!isApiKeyHash(hash)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'hash must be a valid SHA-256 API key hash', + }); + return; + } + + const previousMetadata = getApiKeyMetadata(hash); + if (!previousMetadata) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'API key not found', + }); + return; + } + + revokeApiKey(hash); + + try { + await recordApiKeyAuditEvent({ + actor: resolveApiKeyAuditActor(req), + action: API_KEY_AUDIT_ACTIONS.revoked, + keyFingerprint: getApiKeyFingerprintFromHash(hash), + }); + + res.status(200).json({ + message: 'API key revoked', + fingerprint: getApiKeyFingerprintFromHash(hash), + revokedAt: new Date().toISOString(), + }); + } catch (error) { + restoreApiKey(hash); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to persist API key audit event', + }); + } +}); + +/** + * GET /admin/api-keys/audit-events - list API key lifecycle audit events + * Supports ?action=created|rotated|revoked&from=&to=&limit=N + */ +router.get('/admin/api-keys/audit-events', validateApiKey, async (req: Request, res: Response) => { + const rawAction = typeof req.query.action === 'string' ? req.query.action : undefined; + const action = + rawAction === API_KEY_AUDIT_ACTIONS.created || + rawAction === API_KEY_AUDIT_ACTIONS.rotated || + rawAction === API_KEY_AUDIT_ACTIONS.revoked + ? rawAction + : undefined; + + if (rawAction && !action) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'action must be one of: created, rotated, revoked', + }); + return; + } + + try { + const range = parseUtcDateRange({ + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }); + const limit = parseLimited(req.query.limit, 50, 1, 200); + const events = await listApiKeyAuditEvents({ + action, + start: range.start, + end: range.end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(events, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + events: data, + meta: { + count: data.length, + limit, + filters: { + action: action || null, + from: range.start || null, + to: range.end || null, + }, + timestamp: new Date().toISOString(), + }, + }, + }); + } catch (error) { + if (error instanceof DateRangeParseError) { + res.status(error.status).json({ + error: 'Bad Request', + status: error.status, + message: error.message, + }); + return; + } + + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to read API key audit events', + }); + } +}); + +/** + * GET /admin/audit/logs - list admin activity logs + */ +router.get('/admin/audit/logs', validateApiKey, validate({ query: AuditLogQuerySchema }), (req: Request, res: Response) => { + const statusCode = req.query.statusCode ? parseInt(String(req.query.statusCode), 10) : undefined; + const limit = parseLimited(req.query.limit, 100, 1, 500); + const page = parseLimited(req.query.page, 1, 1, 1000000); + const offset = (page - 1) * limit; + const filters = { + actor: req.query.actor ? String(req.query.actor) : undefined, + action: req.query.action ? String(req.query.action) : undefined, + path: req.query.path ? String(req.query.path) : undefined, + statusCode, + from: req.query.from ? String(req.query.from) : undefined, + to: req.query.to ? String(req.query.to) : undefined, + }; + + const logs = getAuditLogs({ ...filters, limit: limit + 1, offset }); + const { data, hasNextPage } = paginateByLimit(logs, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + logs: data, + page, + total: countAuditLogs(filters), + metrics: getAuditLogMetrics(), + }, + }); +}); + +/** + * GET /admin/audit-logs - list admin audit entries (Issue #253) + */ +router.get('/admin/audit-logs', validateApiKey, validate({ query: AuditLogQuerySchema }), async (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 200); + const page = parseLimited(req.query.page, 1, 1, 1000000); + const offset = (page - 1) * limit; + const statusValue = req.query.statusCode ?? req.query.status; + const statusCode = statusValue + ? parseLimited(statusValue, 0, 100, 599) + : undefined; + const filters = { + action: typeof req.query.action === 'string' + ? req.query.action + : typeof req.query.type === 'string' ? req.query.type : undefined, + actor: typeof req.query.actor === 'string' ? req.query.actor : undefined, + path: typeof req.query.path === 'string' ? req.query.path : undefined, + statusCode, + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }; + + const rows = getAuditLogs({ ...filters, limit: limit + 1, offset }); + const { data, hasNextPage } = paginateByLimit(rows, limit); + + void recordAdminAuditLog(req, 'audit-logs.read', 200, { + limit, + returned: data.length, + }); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + meta: { + count: data.length, + total: countAuditLogs(filters), + page, + limit, + timestamp: new Date().toISOString(), + }, + }, + }); +}); + +/** + * GET /admin/prisma/config - operational prisma runtime settings (Issue #254) + */ +router.get('/admin/prisma/config', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + config: getPrismaConfig(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/monitor - structured JSON for background jobs/webhook workers + */ +router.get('/admin/jobs/monitor', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + jobHealth: getJobHealthStatus(), + jobs: getJobMetrics(), + webhooks: getWebhookDeliveryMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/metrics - JSON metrics dashboard for background jobs (Issue #255) + */ +router.get('/admin/jobs/metrics', validateApiKey, (req: Request, res: Response) => { + const metrics = getJobMetrics(); + const summary = { + totalDeadLetters: metrics.totalDeadLetters, + recurringFailureJobs: Object.keys(metrics.recurringFailures), + jobHealth: getJobHealthStatus(), + activeJobs: Object.values(metrics.runtime).filter((job) => job.inFlight > 0).length, + }; + + void recordAdminAuditLog(req, 'jobs.metrics.read', 200); + + res.json({ + summary, + metrics, + prisma: getPrismaRuntimeConfig(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/governance/snapshots - list historical governance snapshots + */ +router.get('/admin/governance/snapshots', validateApiKey, async (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 500); + const offset = parseLimited(req.query.offset, 0, 0, 10_000); + const type = typeof req.query.type === 'string' ? req.query.type : undefined; + const start = typeof req.query.start === 'string' ? req.query.start : undefined; + const end = typeof req.query.end === 'string' ? req.query.end : undefined; + + const result = await listGovernanceSnapshots({ + type: type as 'reconciliation' | 'config-change' | 'export-manifest' | undefined, + start, + end, + limit, + offset, + }); + + res.status(200).json({ + data: result.data, + total: result.total, + limit, + offset, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/governance/snapshots/export - export historical governance snapshots + */ +router.post('/admin/governance/snapshots/export', validateApiKey, validate({ body: GovernanceSnapshotExportSchema }), async (req: Request, res: Response) => { + const requester = resolveActingAdminAddress(req); + const types = Array.isArray(req.body?.types) + ? (req.body.types as string[]) + : undefined; + const start = typeof req.body?.start === 'string' ? req.body.start : undefined; + const end = typeof req.body?.end === 'string' ? req.body.end : undefined; + const limit = parseLimited(req.body?.limit, 500, 1, 5000); + + const { manifest, rows } = await exportGovernanceSnapshots({ + requester, + types: types as ('reconciliation' | 'config-change' | 'export-manifest')[] | undefined, + start, + end, + limit, + }); + + res.status(201).json({ + message: 'Governance snapshot export generated', + rowCount: rows.length, + manifest, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/dashboard - comprehensive HTML dashboard for operators to monitor jobs and DLQ + */ +router.get('/admin/jobs/dashboard', validateApiKey, async (_req: Request, res: Response) => { + const jobMetrics = getJobMetrics(); + const webhookMetrics = getWebhookDeliveryMetrics(); + const dlqRecords = listDeadLetters({ status: 'dead-letter', limit: 10 }); + + const jobsByStatus = { + 'dead-letter': dlqRecords.records.filter((r) => r.status === 'dead-letter').length, + 'requeued': dlqRecords.records.filter((r) => r.status === 'requeued').length, + 'resolved': dlqRecords.records.filter((r) => r.status === 'resolved').length, + 'discarded': dlqRecords.records.filter((r) => r.status === 'discarded').length, + }; + + const recurringFailures = Object.entries(jobMetrics.recurringFailures || {}) + .map(([jobName, count]) => `${jobName} (${count} failures)`) + .join(', ') || 'None'; + + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.status(200).send(` + + + + + + YieldVault Job Dashboard + + + +
+

🎯 Background Job Monitoring Dashboard

+ +
+
+
Overall Health
+
${getJobHealthStatus().toUpperCase()}
+
+
+
Dead-Letter Records
+
${jobMetrics.totalDeadLetters}
+
${dlqRecords.total} total (pending: ${jobsByStatus['dead-letter']})
+
+
+
Webhook Endpoints
+
${webhookMetrics.totalEndpoints}
+
Failed: ${webhookMetrics.failed}
+
+
+
Recurring Failures
+
${Object.keys(jobMetrics.recurringFailures || {}).length}
+
${recurringFailures}
+
+
+ + ${jobMetrics.totalDeadLetters > 0 ? ` +
+ ⚠️ ${jobMetrics.totalDeadLetters} dead-letter records pending. + Review and retry failed jobs or mark them as resolved. +
+ ` : ''} + +

📊 Job Runtime Metrics

+
+ + + + + + + + + + + + + + ${Object.entries(jobMetrics.runtime || {}) + .map(([jobName, metrics]) => ` + + + + + + + + + + `) + .join('')} + +
Job NameTotal RunsSuccessfulFailedIn FlightAvg Duration (ms)Last Run
${jobName}${metrics.totalRuns}${metrics.successfulRuns}${metrics.failedRuns}${metrics.inFlight}${metrics.averageDurationMs}${metrics.lastRunAt ? new Date(metrics.lastRunAt).toLocaleString() : 'Never'}
+
+ +

🔄 Recent Dead-Letter Records

+
+ ${dlqRecords.records.length > 0 ? ` + + + + + + + + + + + + + + ${dlqRecords.records.slice(0, 10).map((record) => ` + + + + + + + + + + `).join('')} + +
IDJobStatusAttemptsErrorFailed AtActions
${record.id}${record.jobName}${record.status}${record.attempts}${record.error}${new Date(record.failedAt).toLocaleString()} + View | + Retry +
+ ` : ` +

No dead-letter records (great job!)

+ `} +
+ +

📡 API Endpoints

+
+

Use these endpoints to manage dead-letter queue records:

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodEndpointDescription
GET/admin/jobs/dead-lettersList all dead-letter records with optional filters
GET/admin/jobs/dead-letters/:idRetrieve a specific dead-letter record
POST/admin/jobs/dead-letters/:id/retryRetry a failed job (supports ?dryRun=true)
POST/admin/jobs/dead-letters/:id/resolveMark a job as manually resolved with notes
DELETE/admin/jobs/dead-letters/:idDiscard a dead-letter record
POST/admin/jobs/dead-letters/bulk-retryBulk retry multiple records (body: {ids: []})
POST/admin/jobs/dead-letters/bulk-discardBulk discard multiple records (body: {ids: []})
POST/admin/jobs/dead-letters/processTrigger batch processing worker (body: {batchSize?: 10})
+
+ +

📋 Full Metrics JSON

+
+ Click to expand full metrics +
${JSON.stringify(jobMetrics, null, 2)}
+
+ +
+

+ Last updated: ${new Date().toISOString()} | + JSON API +

+
+ + + `); +}); + +// ─── Dead-Letter Queue Admin Endpoints ───────────────────────────────────── + +/** + * GET /admin/jobs/metrics + * Returns background job runtime and governance metrics. + */ +router.get('/admin/jobs/metrics', validateApiKey, (_req: Request, res: Response) => { + const metrics = getJobMetrics(); + const health = getJobHealthStatus(); + + res.status(200).json({ + summary: { health }, + metrics, + prisma: getPrismaConfig(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/dead-letters + * Lists dead-letter queue records with optional filtering (jobName, status, limit, offset). + * Requires API key authentication. + */ +router.get('/admin/jobs/dead-letters', validateApiKey, (req: Request, res: Response) => { + const jobName = typeof req.query.jobName === 'string' ? (req.query.jobName as JobName) : undefined; + const status = typeof req.query.status === 'string' ? (req.query.status as DeadLetterStatus) : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 500); + const offset = parseLimited(req.query.offset, 0, 0, 10000); + + const { records, total } = listDeadLetters({ jobName, status, limit, offset }); + + res.status(200).json({ + data: records, + total, + limit, + offset, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/jobs/dead-letters/:id + * Retrieves a single dead-letter record by ID. + * Requires API key authentication. + */ +router.get('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { + const record = getDeadLetterRecord(req.params.id); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + res.status(200).json({ + record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/:id/retry + * Retries a single dead-letter queue entry. + * Supports dryRun flag. + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/:id/retry', validateApiKey, async (req: Request, res: Response) => { + const record = getDeadLetterRecord(req.params.id); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + void recordAdminAuditLog(req, 'jobs.dlq.retry.dry_run', 200, { + id: req.params.id, + jobName: record.jobName, + actor, + }); + + res.status(200).json({ + dryRun: true, + message: `Dead-letter record '${req.params.id}' (${record.jobName}) would be retried`, + record, + wouldRetry: true, + timestamp: new Date().toISOString(), + }); + return; + } + + const outcome = await retryDeadLetter(req.params.id); + void recordAdminAuditLog(req, 'jobs.dlq.retry', outcome.success ? 200 : 500, { + id: req.params.id, + jobName: record.jobName, + success: outcome.success, + actor, + error: outcome.error, + }); + + if (!outcome.success) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: outcome.error || 'Failed to retry dead-letter record', + record: outcome.record, + }); + return; + } + + res.status(200).json({ + message: 'Dead-letter record retried successfully', + result: outcome.result, + record: outcome.record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/:id/resolve + * Marks a dead-letter entry as manually resolved. + * Body: { notes?: string } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/:id/resolve', validateApiKey, validate({ params: IdParamSchema, body: DeadLetterResolveSchema }), async (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + const notes = typeof req.body?.notes === 'string' ? req.body.notes : undefined; + + const record = resolveDeadLetter(req.params.id, actor, notes); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + void recordAdminAuditLog(req, 'jobs.dlq.resolve', 200, { + id: req.params.id, + jobName: record.jobName, + actor, + notes, + }); + + res.status(200).json({ + message: 'Dead-letter record resolved successfully', + record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/jobs/dead-letters/:id + * Discards a dead-letter record from the queue. + * Requires API key authentication. + */ +router.delete('/admin/jobs/dead-letters/:id', validateApiKey, (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + const existing = getDeadLetterRecord(req.params.id); + res.status(existing ? 200 : 404).json({ + dryRun: true, + message: existing + ? `Dead-letter record '${req.params.id}' would be discarded` + : `Dead-letter record '${req.params.id}' not found`, + id: req.params.id, + wouldDiscard: Boolean(existing), + timestamp: new Date().toISOString(), + }); + return; + } + + const record = discardDeadLetter(req.params.id, actor); + if (!record) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter record not found', + }); + return; + } + + void recordAdminAuditLog(req, 'jobs.dlq.discard', 200, { + id: req.params.id, + jobName: record.jobName, + actor, + }); + + res.status(200).json({ + message: 'Dead-letter record discarded successfully', + record, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/bulk-retry + * Bulk retries multiple dead-letter records. + * Body: { ids: string[] } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/bulk-retry', validateApiKey, validate({ body: DeadLetterIdsSchema }), async (req: Request, res: Response) => { + const ids = Array.isArray(req.body?.ids) ? (req.body.ids as string[]) : []; + + if (ids.length === 0) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`ids` array must not be empty', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: `Bulk retry preview for ${ids.length} dead-letter records`, + ids, + wouldRetryCount: ids.length, + timestamp: new Date().toISOString(), + }); + return; + } + + const summary = await bulkRetryDeadLetters(ids); + + void recordAdminAuditLog(req, 'jobs.dlq.bulk_retry', 200, { + requestedCount: ids.length, + retried: summary.retried, + failed: summary.failed, + actor, + }); + + res.status(200).json({ + message: 'Bulk retry completed', + retried: summary.retried, + failed: summary.failed, + results: summary.results, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/bulk-discard + * Bulk discards multiple dead-letter records. + * Body: { ids: string[] } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/bulk-discard', validateApiKey, validate({ body: DeadLetterIdsSchema }), (req: Request, res: Response) => { + const ids = Array.isArray(req.body?.ids) ? (req.body.ids as string[]) : []; + + if (ids.length === 0) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: '`ids` array must not be empty', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: `Bulk discard preview for ${ids.length} dead-letter records`, + ids, + wouldDiscardCount: ids.length, + timestamp: new Date().toISOString(), + }); + return; + } + + const summary = bulkDiscardDeadLetters(ids); + + void recordAdminAuditLog(req, 'jobs.dlq.bulk_discard', 200, { + requestedCount: ids.length, + discardedCount: summary.discarded, + actor, + }); + + res.status(200).json({ + message: 'Bulk discard completed', + discardedCount: summary.discarded, + ids: summary.ids, + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/jobs/dead-letters/process + * Triggers batch processing worker for dead-letter records. + * Body: { batchSize?: number } + * Requires API key authentication. + */ +router.post('/admin/jobs/dead-letters/process', validateApiKey, validate({ body: DeadLetterProcessSchema }), async (req: Request, res: Response) => { + const batchSize = typeof req.body?.batchSize === 'number' && req.body.batchSize > 0 ? req.body.batchSize : 10; + const actor = resolveActingAdminAddress(req); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: `DLQ processor dry-run preview for batchSize ${batchSize}`, + batchSize, + wouldProcess: true, + timestamp: new Date().toISOString(), + }); + return; + } + + const summary = await processDeadLetterQueue(batchSize); + + void recordAdminAuditLog(req, 'jobs.dlq.process', 200, { + batchSize, + processed: summary.processed, + succeeded: summary.succeeded, + failed: summary.failed, + actor, + }); + + res.status(200).json({ + message: 'DLQ queue processing batch completed', + batchSize, + processed: summary.processed, + succeeded: summary.succeeded, + failed: summary.failed, + timestamp: new Date().toISOString(), + }); +}); + +// ─── Idempotency Admin Endpoints (Issues #457 & #466) ──────────────────────── + +/** + * GET /admin/idempotency/keys + * Lists idempotency keys with metadata. + * Optional query param: ?prefix= to filter keys by prefix. + * Requires API key authentication. + */ +router.get('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) => { + const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : undefined; + const keys = idempotencyStore.inspectKeys(prefix); + res.status(200).json({ + keys, + count: keys.length, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/idempotency/keys/:key + * Removes a single idempotency key from the store. + * Requires API key authentication. + */ +router.delete('/admin/idempotency/keys/:key', validateApiKey, (req: Request, res: Response) => { + const key = decodeURIComponent(req.params.key); + if (isDryRunRequest(req)) { + const exists = idempotencyStore.inspectKeys().some((entry) => entry.key === key); + res.status(exists ? 200 : 404).json({ + dryRun: true, + message: exists + ? `Idempotency key '${key}' would be deleted` + : `Idempotency key '${key}' not found`, + key, + wouldDelete: exists, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); + return; + } + + const deleted = idempotencyStore.deleteKey(key); + if (!deleted) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: `Idempotency key '${key}' not found`, + }); + return; + } + res.status(200).json({ + message: `Idempotency key '${key}' deleted`, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/idempotency/retention/metrics + * Returns idempotency retention policy and sweep metrics. + */ +router.get('/admin/idempotency/retention/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: getIdempotencyRetentionMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/idempotency/retention/cleanup + * Runs a retention sweep for stale idempotency records. + */ +router.post('/admin/idempotency/retention/cleanup', validateApiKey, async (req: Request, res: Response) => { + const dryRun = isDryRunRequest(req); + const result = await pruneStaleIdempotencyRecords(dryRun); + res.status(200).json({ + ...result, + metrics: getIdempotencyRetentionMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/idempotency/keys + * Flushes the entire idempotency store. + * Requires super-admin API key. + */ +router.delete('/admin/idempotency/keys', validateApiKey, (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to flush the idempotency store', + }); + return; + } + if (isDryRunRequest(req)) { + const keys = idempotencyStore.inspectKeys(); + res.status(200).json({ + dryRun: true, + message: 'Idempotency store flush dry-run preview', + wouldFlush: true, + keyCount: keys.length, + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); + return; + } + + idempotencyStore.clear(); + res.status(200).json({ + message: 'Idempotency store flushed', + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/idempotency/metrics + * Returns hit/conflict/eviction counters for the idempotency store. + * Requires API key authentication. + */ +router.get('/admin/idempotency/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: idempotencyStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +// ─── Wallet Activity Heatmap Endpoint (Issue #712) ─────────────────────────── + +/** + * GET /admin/analytics/wallet-activity/heatmap + * + * Returns aggregated wallet activity counts bucketed by calendar date for use + * in admin analytics dashboards. Raw wallet records are never exposed; only + * the per-bucket counts and the summary statistics are returned. + * + * Query parameters: + * from ISO-8601 date or datetime (inclusive). Defaults to 30 days ago. + * to ISO-8601 date or datetime (inclusive). Defaults to today. + * granularity day | week | month. Defaults to day. + * walletAddress Optional address to scope results to a single wallet. + * + * Requires API key authentication. + */ +router.get('/admin/analytics/wallet-activity/heatmap', validateApiKey, async (req: Request, res: Response) => { + const granularity = + req.query.granularity === 'week' || req.query.granularity === 'month' + ? (req.query.granularity as 'week' | 'month') + : 'day'; + + let rangeStart: string; + let rangeEnd: string; + + try { + const parsed = parseUtcDateRange({ + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }); + const defaultStart = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const defaultEnd = new Date().toISOString().slice(0, 10); + rangeStart = parsed.start ?? defaultStart; + rangeEnd = parsed.end ?? defaultEnd; + } catch (error) { + if (error instanceof DateRangeParseError) { + res.status(error.status).json({ + error: 'Bad Request', + status: error.status, + message: error.message, + }); + return; + } + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to parse date range', + }); + return; + } + + const walletAddress = + typeof req.query.walletAddress === 'string' && req.query.walletAddress.trim() + ? normalizeWalletAddress(req.query.walletAddress.trim()) + : undefined; + + try { + const where: Record = { + timestamp: { + gte: new Date(rangeStart + 'T00:00:00.000Z'), + lte: new Date(rangeEnd + 'T23:59:59.999Z'), + }, + }; + + if (walletAddress) { + where.user = walletAddress; + } + + const transactions = await prisma.transaction.findMany({ + where: where as any, + select: { timestamp: true }, + orderBy: { timestamp: 'asc' }, + }); + + const bucketKey = (date: Date, g: 'day' | 'week' | 'month'): string => { + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, '0'); + const d = String(date.getUTCDate()).padStart(2, '0'); + if (g === 'month') return `${y}-${m}`; + if (g === 'week') { + const dayOfWeek = date.getUTCDay(); + const mondayOffset = (dayOfWeek === 0 ? -6 : 1 - dayOfWeek) * 86400000; + const monday = new Date(date.getTime() + mondayOffset); + const wy = monday.getUTCFullYear(); + const wm = String(monday.getUTCMonth() + 1).padStart(2, '0'); + const wd = String(monday.getUTCDate()).padStart(2, '0'); + return `${wy}-${wm}-${wd}`; + } + return `${y}-${m}-${d}`; + }; + + const buckets = new Map(); + for (const tx of transactions) { + const key = bucketKey(new Date(tx.timestamp), granularity); + buckets.set(key, (buckets.get(key) ?? 0) + 1); + } + + const heatmap = Array.from(buckets.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([bucket, count]) => ({ bucket, count })); + + const totalActivity = heatmap.reduce((sum, b) => sum + b.count, 0); + const peakBucket = heatmap.reduce( + (max, b) => (b.count > (max?.count ?? -1) ? b : max), + null as { bucket: string; count: number } | null, + ); + + res.status(200).json({ + heatmap, + summary: { + totalActivity, + bucketCount: heatmap.length, + granularity, + rangeStart, + rangeEnd, + walletAddress: walletAddress ?? null, + peakBucket: peakBucket ?? null, + }, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to aggregate wallet activity', + }); + } +}); + +// ─── Write-Ahead Audit Log Endpoints (Issue #707) ─────────────────────────── +/** + * GET /admin/wal/entries + * Lists write-ahead audit log entries with optional filters. + */ +router.get('/admin/wal/entries', validateApiKey, async (req: Request, res: Response) => { + const configType = typeof req.query.configType === 'string' ? req.query.configType : undefined; + const actor = typeof req.query.actor === 'string' ? req.query.actor : undefined; + const status = typeof req.query.status === 'string' ? req.query.status as 'pending' | 'committed' | 'rolled_back' : undefined; + const limit = parseLimited(req.query.limit, 50, 1, 200); + + try { + const entries = await writeAheadAuditLog.list({ configType, actor, status, limit }); + const metrics = await writeAheadAuditLog.getMetrics(); + + res.status(200).json({ + entries, + count: entries.length, + metrics, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to list write-ahead audit log entries', + }); + } +}); + +/** + * GET /admin/wal/entries/:id + * Returns a specific write-ahead audit log entry. + */ +router.get('/admin/wal/entries/:id', validateApiKey, async (req: Request, res: Response) => { + try { + const entry = await writeAheadAuditLog.getEntry(req.params.id); + if (!entry) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Write-ahead audit log entry not found', + }); + return; + } + res.status(200).json({ entry }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to fetch write-ahead audit log entry', + }); + } +}); + +/** + * GET /admin/wal/metrics + * Returns metrics for the write-ahead audit log. + */ +router.get('/admin/wal/metrics', validateApiKey, async (_req: Request, res: Response) => { + try { + const metrics = await writeAheadAuditLog.getMetrics(); + res.status(200).json({ + metrics, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to fetch write-ahead audit log metrics', + }); + } +}); + +/** + * GET /admin/wal/pending + * Returns currently pending (uncommitted) write-ahead entries. + */ +router.get('/admin/wal/pending', validateApiKey, async (_req: Request, res: Response) => { + try { + const pending = await writeAheadAuditLog.getPendingEntries(); + res.status(200).json({ + entries: pending, + count: pending.length, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to fetch pending write-ahead audit log entries', + }); + } +}); + +// ─── Scoped Admin Token Endpoints (Issue #723 / #858) ─────────────────────── +// All endpoints are async – store operations now hit Prisma for cluster-wide durability. + +/** + * GET /admin/scoped-tokens/permissions + * Returns the list of valid permissions for scoped tokens. + * Must be registered before /:keyId routes to avoid shadowing. + */ +router.get('/admin/scoped-tokens/permissions', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + permissions: scopedAdminTokenStore.getValidPermissions(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * POST /admin/scoped-tokens + * Creates a new permission-scoped admin token. + * Requires super-admin API key. + * Returns the plaintext secret once; it is never stored. + */ +router.post('/admin/scoped-tokens', validateApiKey, validate({ body: ScopedTokenCreateSchema }), async (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to create scoped tokens' }); + return; + } + const { label, permissions, expiresInSeconds } = req.body; + if (typeof label !== 'string' || !label.trim()) { + res.status(400).json({ error: 'Bad Request', status: 400, message: '`label` (string) is required' }); + return; + } + if (!Array.isArray(permissions) || permissions.length === 0) { + res.status(400).json({ error: 'Bad Request', status: 400, message: '`permissions` (non-empty array) is required' }); + return; + } + const actor = resolveActingAdminAddress(req); + try { + const { token, secret } = await scopedAdminTokenStore.create({ + label: label.trim(), + permissions, + expiresInSeconds: typeof expiresInSeconds === 'number' && expiresInSeconds > 0 ? expiresInSeconds : undefined, + createdBy: actor, + }); + void recordAdminAuditLog(req, 'scoped-token.created', 201, { keyId: token.keyId, label: token.label, permissions: token.permissions, actor }); + res.status(201).json({ message: 'Scoped admin token created', keyId: token.keyId, secret, label: token.label, permissions: token.permissions, expiresAt: token.expiresAt, createdAt: token.createdAt }); + } catch (error) { + res.status(400).json({ error: 'Bad Request', status: 400, message: error instanceof Error ? error.message : 'Failed to create scoped token' }); + } +}); + +/** + * GET /admin/scoped-tokens + * Lists all scoped admin tokens (without secrets). + * Requires admin or super-admin API key. + */ +router.get('/admin/scoped-tokens', validateApiKey, async (req: Request, res: Response) => { + const includeRevoked = req.query.includeRevoked === 'true'; + const tokens = await scopedAdminTokenStore.list({ includeRevoked }); + const sanitized = tokens.map(({ hashedSecret: _hs, ...rest }) => rest); + res.status(200).json({ tokens: sanitized, count: sanitized.length, timestamp: new Date().toISOString() }); +}); + +/** + * GET /admin/scoped-tokens/:keyId + * Returns a single token record (without secret). + * Requires admin or super-admin API key. + */ +router.get('/admin/scoped-tokens/:keyId', validateApiKey, async (req: Request, res: Response) => { + const token = await scopedAdminTokenStore.get(req.params.keyId); + if (!token) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found' }); + return; + } + const { hashedSecret: _hs, ...sanitized } = token; + res.status(200).json({ token: sanitized }); +}); + +/** + * GET /admin/scoped-tokens/:keyId/rotations + * Returns the immutable rotation history for a token (fingerprints only, no old secrets). + * Requires admin or super-admin API key. + */ +router.get('/admin/scoped-tokens/:keyId/rotations', validateApiKey, async (req: Request, res: Response) => { + const token = await scopedAdminTokenStore.get(req.params.keyId); + if (!token) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found' }); + return; + } + const events = await scopedAdminTokenStore.listRotationEvents(req.params.keyId); + res.status(200).json({ keyId: req.params.keyId, rotations: events, count: events.length, timestamp: new Date().toISOString() }); +}); + +/** + * POST /admin/scoped-tokens/:keyId/rotate + * Rotates the secret and writes an immutable audit event row. + * Requires super-admin API key. + */ +router.post('/admin/scoped-tokens/:keyId/rotate', validateApiKey, async (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to rotate scoped tokens' }); + return; + } + const actor = resolveActingAdminAddress(req); + const result = await scopedAdminTokenStore.rotate(req.params.keyId, { rotatedBy: actor }); + if (!result) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); + return; + } + void recordAdminAuditLog(req, 'scoped-token.rotated', 200, { keyId: result.keyId, rotatedAt: result.rotatedAt, actor }); + res.status(200).json({ message: 'Scoped token rotated', keyId: result.keyId, newSecret: result.newSecret, rotatedAt: result.rotatedAt }); +}); + +/** + * POST /admin/scoped-tokens/:keyId/revoke + * Revokes a scoped admin token cluster-wide. + * Requires super-admin API key. + */ +router.post('/admin/scoped-tokens/:keyId/revoke', validateApiKey, async (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ error: 'Forbidden', status: 403, message: 'Super-admin role is required to revoke scoped tokens' }); + return; + } + const actor = resolveActingAdminAddress(req); + const revoked = await scopedAdminTokenStore.revoke(req.params.keyId, { revokedBy: actor }); + if (!revoked) { + res.status(404).json({ error: 'Not Found', status: 404, message: 'Scoped token not found or already revoked' }); + return; + } + void recordAdminAuditLog(req, 'scoped-token.revoked', 200, { keyId: req.params.keyId, actor }); + res.status(200).json({ message: 'Scoped token revoked', keyId: req.params.keyId, timestamp: new Date().toISOString() }); +}); + +/** + * GET /admin/withdrawals/recovery + * Lists journalled withdrawal sagas with optional filters. Use + * `?requiresManualIntervention=true` for the operator work queue. + */ +router.get('/admin/withdrawals/recovery', validateApiKey, (req: Request, res: Response) => { + const requiresManualIntervention = + req.query.requiresManualIntervention === undefined + ? undefined + : req.query.requiresManualIntervention === 'true'; + + const sagas = withdrawalRecoveryCoordinator.list({ + status: parseSagaStatus(req.query.status), + walletAddress: + typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined, + withdrawalId: + typeof req.query.withdrawalId === 'string' ? req.query.withdrawalId : undefined, + requiresManualIntervention, + limit: parseLimited(req.query.limit, 50, 1, 200), + }); + + res.status(200).json({ + sagas, + count: sagas.length, + metrics: withdrawalRecoveryCoordinator.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/withdrawals/recovery/metrics + * Aggregate recovery counters for dashboards and alert rules. + */ +router.get('/admin/withdrawals/recovery/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: withdrawalRecoveryCoordinator.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/withdrawals/recovery/:sagaId + * Returns the full step journal for one withdrawal saga. + */ +router.get('/admin/withdrawals/recovery/:sagaId', validateApiKey, (req: Request, res: Response) => { + const saga = withdrawalRecoveryCoordinator.get(req.params.sagaId); + if (!saga) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Withdrawal recovery saga not found', + }); + return; + } + res.status(200).json({ saga }); +}); + +/** + * POST /admin/withdrawals/recovery/:sagaId/resume + * Replays the saga from its journal. Completed steps are skipped, so no side + * effect is repeated. `force: true` also resumes sagas parked for an operator. + */ +router.post( + '/admin/withdrawals/recovery/:sagaId/resume', + validateApiKey, + async (req: Request, res: Response) => { + const force = req.body?.force === true; + const outcome = await withdrawalRecoveryCoordinator.resume(req.params.sagaId, { force }); + + if (!outcome) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Withdrawal recovery saga not found', + }); + return; + } + + void recordAdminAuditLog(req, 'withdrawal-recovery.resumed', 200, { + sagaId: outcome.saga.id, + withdrawalId: outcome.saga.withdrawalId, + status: outcome.status, + forced: force, + actor: resolveActingAdminAddress(req), + }); + + res.status(200).json({ + message: 'Withdrawal recovery pass completed', + status: outcome.status, + completed: outcome.completed, + partial: outcome.partial, + saga: outcome.saga, + }); + }, +); + +/** + * POST /admin/withdrawals/recovery/:sagaId/resolve + * Closes out a parked saga after an operator reconciled it by hand. + */ +router.post( + '/admin/withdrawals/recovery/:sagaId/resolve', + validateApiKey, + (req: Request, res: Response) => { + const note = typeof req.body?.note === 'string' ? req.body.note.trim() : ''; + if (!note) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'A "note" describing the manual resolution is required', + }); + return; + } + + const outcome = req.body?.outcome; + if (outcome !== undefined && !['completed', 'compensated', 'failed'].includes(outcome)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'outcome must be one of: completed, compensated, failed', + }); + return; + } + + const actor = resolveActingAdminAddress(req); + const saga = withdrawalRecoveryCoordinator.resolveManually( + req.params.sagaId, + actor, + note, + outcome, + ); + + if (!saga) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Withdrawal recovery saga not found', + }); + return; + } + + void recordAdminAuditLog(req, 'withdrawal-recovery.resolved', 200, { + sagaId: saga.id, + withdrawalId: saga.withdrawalId, + outcome: saga.status, + actor, + }); + + res.status(200).json({ message: 'Withdrawal saga resolved', saga }); + }, +); + +/** + * POST /admin/withdrawals/recovery/sweep + * Runs one recovery sweep immediately instead of waiting for the interval. + */ +router.post('/admin/withdrawals/recovery/sweep', validateApiKey, async (req: Request, res: Response) => { + const result = await withdrawalRecoveryCoordinator.sweep(); + void recordAdminAuditLog(req, 'withdrawal-recovery.swept', 200, { + resumed: result.resumed.length, + stale: result.stale.length, + actor: resolveActingAdminAddress(req), + }); + res.status(200).json({ + message: 'Withdrawal recovery sweep completed', + resumed: result.resumed, + staleResumed: result.stale, + metrics: withdrawalRecoveryCoordinator.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +// ─── Vault management ──────────────────────────────────────────────────────── + +/** + * POST /admin/vaults + * + * Creates a new vault for a tenant. + * + * Body: { name: string, symbol: string, tenantId: string } + * + * Validation is handled entirely by CreateVaultBodySchema (which trims + * whitespace before checking min/max lengths), so no manual trim() calls + * are needed in this handler. A whitespace-only name like " " fails schema + * validation and returns 400 before any DB write is attempted. + */ +router.post( + '/admin/vaults', + validateApiKey, + validate({ body: CreateVaultBodySchema }), + async (req: Request, res: Response) => { + const { name, symbol, tenantId } = req.body as { + name: string; + symbol: string; + tenantId: string; + }; + + const prismaClient = getPrismaClient(); + const vault = await prismaClient.vault.create({ + data: { name, symbol, tenantId }, + }); + + void recordAdminAuditLog(req, 'vault.created', 201, { + vaultId: vault.id, + name: vault.name, + symbol: vault.symbol, + tenantId: vault.tenantId, + actor: resolveActingAdminAddress(req), + }); + + res.status(201).json({ + vault: { + id: vault.id, + name: vault.name, + symbol: vault.symbol, + tenantId: vault.tenantId, + aum: vault.aum, + createdAt: vault.createdAt.toISOString(), + }, + timestamp: new Date().toISOString(), + }); + }, +); + +export default router; diff --git a/backend/src/routes/exports.ts b/backend/src/routes/exports.ts new file mode 100644 index 000000000..334832426 --- /dev/null +++ b/backend/src/routes/exports.ts @@ -0,0 +1,366 @@ +/** + * Admin export job, bulk export and report export endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { BulkExportBodySchema, ChecksumVerifyBodySchema, IdParamSchema, ReportExportBodySchema, validate } from '../middleware/validate'; +import { DateRangeParseError, parseUtcDateRange } from '../dateRange'; +import { cancelBulkExportJob, createBulkExportJob, getBulkExportArtifact, getBulkExportJob, listBulkExportJobs, processBulkExportJob } from '../bulkExportJobs'; +import { createExportManifest, getExportManifestById, listExportManifests, verifyExportManifestChecksum } from '../exportManifest'; +import { getExportJobById, listExportJobs, resolveExportGeneratedBy } from '../exportJobs'; +import { paginateByLimit, parseLimited, resolveActingAdminAddress, sendStandardListEnvelope } from './shared'; +import { validateApiKey } from '../middleware/apiKeyAuth'; + +const router = Router(); + +/** + * GET /admin/exports/jobs - list persisted transaction export metadata + */ +router.get('/admin/exports/jobs', validateApiKey, async (req: Request, res: Response) => { + const rawFormat = typeof req.query.format === 'string' ? req.query.format : undefined; + const format = rawFormat === 'csv' || rawFormat === 'json' ? rawFormat : undefined; + if (rawFormat && !format) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'format must be either csv or json', + }); + return; + } + + try { + const range = parseUtcDateRange({ + from: typeof req.query.from === 'string' ? req.query.from : undefined, + to: typeof req.query.to === 'string' ? req.query.to : undefined, + }); + const limit = parseLimited(req.query.limit, 50, 1, 200); + const jobs = await listExportJobs({ + format, + generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : undefined, + walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : undefined, + checksum: typeof req.query.checksum === 'string' ? req.query.checksum : undefined, + start: range.start, + end: range.end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(jobs, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + jobs: data, + meta: { + count: data.length, + limit, + filters: { + format: format || null, + generatedBy: typeof req.query.generatedBy === 'string' ? req.query.generatedBy : null, + walletAddress: typeof req.query.walletAddress === 'string' ? req.query.walletAddress : null, + checksum: typeof req.query.checksum === 'string' ? req.query.checksum : null, + from: range.start || null, + to: range.end || null, + }, + timestamp: new Date().toISOString(), + }, + }, + }); + } catch (error) { + if (error instanceof DateRangeParseError) { + res.status(error.status).json({ + error: 'Bad Request', + status: error.status, + message: error.message, + }); + return; + } + + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to query export jobs', + }); + } +}); + +/** + * POST /admin/exports/jobs/:id/verify - verify a previously generated export checksum + * Body: { checksum: string } + */ +router.post('/admin/exports/jobs/:id/verify', validateApiKey, validate({ params: IdParamSchema, body: ChecksumVerifyBodySchema }), async (req: Request, res: Response) => { + const checksum = + typeof req.body?.checksum === 'string' + ? req.body.checksum.trim().toLowerCase() + : typeof req.query.checksum === 'string' + ? req.query.checksum.trim().toLowerCase() + : ''; + + if (!checksum) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'checksum is required', + }); + return; + } + + try { + const job = await getExportJobById(String(req.params.id)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Export job not found', + }); + return; + } + + res.status(200).json({ + exportJobId: job.id, + valid: job.checksum.toLowerCase() === checksum, + expectedChecksum: job.checksum, + providedChecksum: checksum, + checksumAlgorithm: job.checksumAlgorithm, + generatedBy: job.generatedBy, + createdAt: job.createdAt, + fileName: job.fileName, + format: job.format, + rowCount: job.rowCount, + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to verify export checksum', + }); + } +}); + +/** + * POST /admin/exports/bulk - create a new bulk export job + * Body: { format: "csv"|"json", filters: { ... } } + */ +router.post('/admin/exports/bulk', validateApiKey, validate({ body: BulkExportBodySchema }), async (req: Request, res: Response) => { + try { + const { format, filters } = req.body; + if (format !== 'csv' && format !== 'json') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'format must be either csv or json', + }); + return; + } + + const generatedBy = resolveExportGeneratedBy(req); + const job = await createBulkExportJob({ + format, + generatedBy, + filters: filters || {}, + }); + + void processBulkExportJob(job.id).catch(() => {}); + + res.status(201).json({ + message: 'Bulk export job created', + job, + timestamp: new Date().toISOString(), + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to create bulk export job', + }); + } +}); + +/** + * GET /admin/exports/bulk/jobs - list bulk export jobs + */ +router.get('/admin/exports/bulk/jobs', validateApiKey, async (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 50, 1, 200); + const jobs = await listBulkExportJobs(limit + 1); + const { data, hasNextPage } = paginateByLimit(jobs, limit); + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + jobs: data, + meta: { + count: data.length, + limit, + timestamp: new Date().toISOString(), + }, + }, + }); +}); + +/** + * GET /admin/exports/bulk/jobs/:id - get bulk export job status + */ +router.get('/admin/exports/bulk/jobs/:id', validateApiKey, async (req: Request, res: Response) => { + const job = await getBulkExportJob(String(req.params.id)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Bulk export job not found', + }); + return; + } + res.status(200).json({ job }); +}); + +/** + * POST /admin/exports/bulk/jobs/:id/cancel - cancel a pending/processing bulk export job + */ +router.post('/admin/exports/bulk/jobs/:id/cancel', validateApiKey, async (req: Request, res: Response) => { + const cancelled = await cancelBulkExportJob(String(req.params.id)); + if (!cancelled) { + const job = await getBulkExportJob(String(req.params.id)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Bulk export job not found', + }); + return; + } + res.status(409).json({ + error: 'Conflict', + status: 409, + message: `Bulk export job is already ${job.status} and cannot be cancelled`, + }); + return; + } + res.status(200).json({ + message: 'Bulk export job cancelled', + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/exports/bulk/artifacts/:artifactId - download a completed bulk export artifact + */ +router.get('/admin/exports/bulk/artifacts/:artifactId', validateApiKey, (req: Request, res: Response) => { + const artifact = getBulkExportArtifact(String(req.params.artifactId)); + if (!artifact) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Bulk export artifact not found or expired', + }); + return; + } + res.setHeader('Content-Type', artifact.contentType); + res.setHeader('Content-Disposition', `attachment; filename="bulk-export-${artifact.id}.${artifact.contentType === 'text/csv' ? 'csv' : 'json'}"`); + res.setHeader('X-Artifact-Checksum', artifact.checksum); + res.setHeader('X-Artifact-Checksum-Algorithm', artifact.checksumAlgorithm); + res.setHeader('X-Artifact-Row-Count', String(artifact.rowCount)); + res.status(200).send(artifact.body); +}); + +/** + * POST /admin/reports/exports - generate a report export and immutable manifest record + */ +router.post('/admin/reports/exports', validateApiKey, validate({ body: ReportExportBodySchema }), async (req: Request, res: Response) => { + const reportType = String(req.body?.reportType || 'transactions').trim(); + const requester = resolveActingAdminAddress(req); + const filters = + req.body?.filters && typeof req.body.filters === 'object' + ? (req.body.filters as Record) + : {}; + + const mockRows = [ + { + reportType, + generatedAt: new Date().toISOString(), + filters, + }, + ]; + + const manifest = await createExportManifest({ + requester, + reportType, + filters, + rows: mockRows, + }); + + res.status(201).json({ + message: 'Export generated and manifest recorded', + manifest, + }); +}); + +/** + * GET /admin/reports/exports/manifests - list immutable export manifests + */ +router.get('/admin/reports/exports/manifests', validateApiKey, async (req: Request, res: Response) => { + const limit = parseInt(String(req.query.limit || '50'), 10); + const offset = parseInt(String(req.query.offset || '0'), 10); + const result = await listExportManifests(limit, offset); + res.status(200).json({ + data: result.data, + total: result.total, + limit, + offset, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/reports/exports/manifests/:id - fetch a manifest by id + */ +router.get('/admin/reports/exports/manifests/:id', validateApiKey, async (req: Request, res: Response) => { + const manifest = await getExportManifestById(String(req.params.id)); + if (!manifest) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Export manifest not found', + }); + return; + } + + res.status(200).json({ + manifest, + }); +}); + +/** + * POST /admin/reports/exports/manifests/:id/verify - verify manifest checksum + */ +router.post('/admin/reports/exports/manifests/:id/verify', validateApiKey, validate({ params: IdParamSchema, body: ChecksumVerifyBodySchema }), async (req: Request, res: Response) => { + const checksum = String(req.body?.checksum || '').trim(); + if (!checksum) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'checksum is required', + }); + return; + } + + const result = await verifyExportManifestChecksum(String(req.params.id), checksum); + if (!result.manifest) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Export manifest not found', + }); + return; + } + + res.status(200).json({ + match: result.match, + manifest: result.manifest, + timestamp: new Date().toISOString(), + }); +}); + +export default router; diff --git a/backend/src/routes/shared.ts b/backend/src/routes/shared.ts new file mode 100644 index 000000000..ba9430648 --- /dev/null +++ b/backend/src/routes/shared.ts @@ -0,0 +1,114 @@ +/** + * Helpers and state shared by src/index.ts and the feature routers in + * src/routes/ (issue #1545). + */ +import NodeCache from 'node-cache'; +import { Request, Response } from 'express'; +import { createPaginatedResponse } from '../pagination'; +import { getPrismaClient } from '../prismaClient'; +import { normalizeWalletAddress } from '../walletUtils'; + +// Health check cache to track dependency status +export const cache = new NodeCache({ stdTTL: 30 }); + +/** + * Reads the vault summary from the VaultState table and the most recent + * SharePriceSnapshot for the share price. Falls back to zeroed values when + * the database is unavailable (e.g., during startup or in tests). + */ +export async function buildVaultSummaryResponseFromDb(): Promise<{ + totalAssets: string; + totalShares: string; + sharePrice: string; + apy: number; + timestamp: string; +}> { + try { + const prismaClient = getPrismaClient(); + const [vaultState, latestSnapshot] = await Promise.all([ + prismaClient.vaultState.findUnique({ where: { id: 1 } }), + prismaClient.sharePriceSnapshot + .findFirst({ orderBy: { recordedAt: 'desc' } }) + .catch(() => null), + ]); + + const totalAssets = vaultState?.totalAssets ?? '0'; + const totalShares = vaultState?.totalShares ?? '0'; + const sharePrice = latestSnapshot?.sharePrice ?? '1.000000'; + + return { + totalAssets, + totalShares, + sharePrice, + apy: 0, // APY is computed by the nightly snapshot job; placeholder until first run + timestamp: new Date().toISOString(), + }; + } catch { + // Fail-open: return safe zero values so the endpoint never 500s + return { + totalAssets: '0', + totalShares: '0', + sharePrice: '1.000000', + apy: 0, + timestamp: new Date().toISOString(), + }; + } +} + +export function resolveActingAdminAddress(req: Request): string { + const address = + req.get('x-admin-address') || + req.get('x-admin-id') || + req.get('x-wallet-address') || + 'unknown'; + return address === 'unknown' ? address : normalizeWalletAddress(address); +} + +export function parseLimited(v: unknown, fallback: number, min: number, max: number): number { + const n = parseInt(String(v ?? ''), 10); + return Number.isNaN(n) ? fallback : Math.min(Math.max(n, min), max); +} + +export function isDryRunRequest(req: Request): boolean { + const value = req.body?.dryRun ?? req.query.dryRun; + return value === true || value === 'true' || value === '1'; +} + +export function paginateByLimit(rows: T[], limit: number): { data: T[]; hasNextPage: boolean } { + const hasNextPage = rows.length > limit; + return { + data: hasNextPage ? rows.slice(0, limit) : rows, + hasNextPage, + }; +} + +export function sendStandardListEnvelope( + res: Response, + input: { + data: T[]; + limit: number; + hasNextPage?: boolean; + hasPrevPage?: boolean; + nextCursor?: string; + total?: number; + statusCode?: number; + extras?: Record; + }, +): void { + const payload = createPaginatedResponse(input.data, { + count: input.data.length, + total: input.total ?? input.data.length, + nextCursor: input.nextCursor ?? null, + prevCursor: null, + currentPage: null, + totalPages: null, + hasNextPage: input.hasNextPage ?? false, + hasPrevPage: input.hasPrevPage ?? false, + limit: input.limit, + }); + + res.status(input.statusCode ?? 200).json({ + ...payload, + ...(input.extras || {}), + }); +} diff --git a/backend/src/routes/transactions.ts b/backend/src/routes/transactions.ts new file mode 100644 index 000000000..c86b7d0b7 --- /dev/null +++ b/backend/src/routes/transactions.ts @@ -0,0 +1,259 @@ +/** + * Transaction export and admin transaction backfill endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { TransactionBackfillBodySchema, validate } from '../middleware/validate'; +import { authenticateApiKeyValue, validateApiKey } from '../middleware/apiKeyAuth'; +import { buildExportMetadataHeaderValue, recordExportJob, resolveExportGeneratedBy } from '../exportJobs'; +import { buildTransactionExportArtifact } from '../listEndpoints'; +import { createOrResumeTransactionBackfill, getTransactionBackfillJob, listTransactionBackfillJobs } from '../transactionBackfill'; +import { logger } from '../middleware/structuredLogging'; +import { normalizeWalletAddress } from '../walletUtils'; +import { verifyJwt } from '../auth'; + +function resolveTransactionExportAccess(req: Request): + | { kind: 'admin'; walletAddress?: string } + | { kind: 'user'; walletAddress: string } + | null { + const authHeader = req.get('authorization') || ''; + const walletAddress = + typeof req.query.walletAddress === 'string' ? normalizeWalletAddress(req.query.walletAddress) : undefined; + + const apiKeyMatch = authHeader.match(/^ApiKey\s+(.+)$/i); + if (apiKeyMatch) { + const authenticated = authenticateApiKeyValue(apiKeyMatch[1]); + if (!authenticated) { + return null; + } + req.authApiKeyHash = authenticated.hash; + req.authApiKeyRole = authenticated.role; + return { + kind: 'admin', + walletAddress: walletAddress || undefined, + }; + } + + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + if (!bearerMatch) { + return null; + } + + const payload = verifyJwt(bearerMatch[1]); + const userWallet = normalizeWalletAddress(payload.sub); + if (walletAddress && walletAddress !== userWallet) { + throw new Error('FORBIDDEN_WALLET_EXPORT'); + } + + return { + kind: 'user', + walletAddress: userWallet, + }; +} + +function buildTransactionExportFilename(format: 'csv' | 'json'): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `transaction-history-${timestamp}.${format}`; +} + +async function handleTransactionExport(req: Request, res: Response): Promise { + const format = req.query.format === 'csv' ? 'csv' : req.query.format === 'json' ? 'json' : null; + if (!format) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'format query parameter must be either csv or json', + }); + return; + } + + let access; + try { + access = resolveTransactionExportAccess(req); + } catch (error) { + if (error instanceof Error && error.message === 'FORBIDDEN_WALLET_EXPORT') { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Users may only export their own wallet transactions', + }); + return; + } + + res.status(401).json({ + error: 'Unauthorized', + status: 401, + message: error instanceof Error ? error.message : 'Invalid authorization header', + }); + return; + } + + if (!access) { + res.status(401).json({ + error: 'Unauthorized', + status: 401, + message: 'Authorization header must contain a Bearer token or ApiKey', + }); + return; + } + + const exportQuery = { + type: typeof req.query.type === 'string' ? req.query.type : undefined, + status: typeof req.query.status === 'string' ? req.query.status : undefined, + sortBy: typeof req.query.sortBy === 'string' ? req.query.sortBy : undefined, + sortOrder: ( + req.query.sortOrder === 'asc' || req.query.sortOrder === 'desc' + ? req.query.sortOrder + : undefined + ) as 'asc' | 'desc' | undefined, + walletAddress: access.walletAddress, + startDate: typeof req.query.startDate === 'string' ? req.query.startDate : undefined, + endDate: typeof req.query.endDate === 'string' ? req.query.endDate : undefined, + }; + + try { + const artifact = buildTransactionExportArtifact(format, exportQuery); + const fileName = buildTransactionExportFilename(format); + const job = await recordExportJob({ + format, + fileName, + contentType: artifact.contentType, + checksum: artifact.checksum, + checksumAlgorithm: artifact.checksumAlgorithm, + generatedBy: resolveExportGeneratedBy(req), + walletAddress: access.walletAddress, + rowCount: artifact.rowCount, + filters: { + type: exportQuery.type || null, + status: exportQuery.status || null, + sortBy: exportQuery.sortBy || null, + sortOrder: exportQuery.sortOrder || null, + startDate: exportQuery.startDate || null, + endDate: exportQuery.endDate || null, + walletAddress: exportQuery.walletAddress || null, + }, + }); + + res.setHeader('Content-Type', artifact.contentType); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.setHeader('X-Export-Job-Id', job.id); + res.setHeader('X-Export-Checksum', artifact.checksum); + res.setHeader('X-Export-Checksum-Algorithm', artifact.checksumAlgorithm); + res.setHeader('X-Export-Metadata', buildExportMetadataHeaderValue(job)); + res.status(200).send(artifact.body); + } catch (error) { + logger.log('error', 'Transaction export failed', { + error: error instanceof Error ? error.message : String(error), + }); + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: 'Failed to generate transaction export', + }); + } +} + +const router = Router(); + +router.get('/api/vault/transactions/export', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/transactions/export'); +}); + +// ─── Versioned export & summary endpoints ──────────────────────────────── +router.get('/api/v1/vault/transactions/export', handleTransactionExport); + +/** + * POST /admin/transactions/backfill - controlled backfill of missing ledger index ranges + */ +router.post('/admin/transactions/backfill', validateApiKey, validate({ body: TransactionBackfillBodySchema }), async (req: Request, res: Response) => { + const startLedger = Number(req.body?.startLedger); + const endLedger = Number(req.body?.endLedger); + const batchSize = req.body?.batchSize === undefined ? undefined : Number(req.body.batchSize); + const dryRun = Boolean(req.body?.dryRun); + const rpcUrl = String(req.body?.rpcUrl || process.env.STELLAR_RPC_URL || '').trim(); + const contractId = String(req.body?.contractId || process.env.VAULT_CONTRACT_ID || '').trim(); + + if (!Number.isInteger(startLedger) || !Number.isInteger(endLedger)) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'startLedger and endLedger must be integers', + }); + return; + } + + if (!rpcUrl) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'rpcUrl is required (or set STELLAR_RPC_URL)', + }); + return; + } + + if (!contractId) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'contractId is required (or set VAULT_CONTRACT_ID)', + }); + return; + } + + try { + const job = await createOrResumeTransactionBackfill({ + startLedger, + endLedger, + batchSize, + dryRun, + rpcUrl, + contractId, + }); + + res.status(202).json({ + message: 'Backfill accepted', + job, + }); + } catch (error) { + res.status(422).json({ + error: 'Unprocessable Entity', + status: 422, + message: error instanceof Error ? error.message : 'Backfill request failed', + }); + } +}); + +/** + * GET /admin/transactions/backfill - list recent backfill jobs + */ +router.get('/admin/transactions/backfill', validateApiKey, async (req: Request, res: Response) => { + const limit = parseInt(String(req.query.limit || '20'), 10); + const data = await listTransactionBackfillJobs(limit); + res.status(200).json({ + data, + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/transactions/backfill/:jobId - fetch a specific backfill job + */ +router.get('/admin/transactions/backfill/:jobId', validateApiKey, async (req: Request, res: Response) => { + const job = await getTransactionBackfillJob(String(req.params.jobId)); + if (!job) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Backfill job not found', + }); + return; + } + + res.status(200).json({ + job, + }); +}); + +export default router; diff --git a/backend/src/routes/vault.ts b/backend/src/routes/vault.ts new file mode 100644 index 000000000..d6bfde6ff --- /dev/null +++ b/backend/src/routes/vault.ts @@ -0,0 +1,256 @@ +/** + * Vault summary, metrics, APY and health endpoints (legacy, v1, v2). + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { buildVaultSummaryResponseFromDb, cache } from './shared'; +import { cacheMiddleware } from '../middleware/cache'; +import { computeVaultApy } from '../services/apy'; +import { createTimeoutFor } from '../middleware/timeoutMiddleware'; +import { healthProbeService } from '../healthProbe'; +import { readsLimiter } from '../rateLimiter'; + +const cacheVaultMetricsTtl = parseInt(process.env.CACHE_TTL_MS || process.env.CACHE_VAULT_METRICS_TTL_MS || '60000', 10); + +type VaultHealthResponse = { + vaultId: string; + status: 'healthy' | 'degraded'; + uptimeSeconds: number; + metrics: { + totalAssets: string; + totalShares: string; + sharePrice: string; + apy: number; + }; + dependencies: Record; + cached: boolean; + timestamp: string; +}; + +async function buildVaultHealthResponse(vaultId: string): Promise { + const cacheKey = `vault-health:${vaultId}`; + const cached = cache.get>(cacheKey); + if (cached) { + return { ...cached, cached: true }; + } + + const [summary, probes] = await Promise.all([ + buildVaultSummaryResponseFromDb(), + healthProbeService.checkAll().catch( + () => ({}) as Record, + ), + ]); + const dependencies = Object.fromEntries( + Object.entries(probes).map(([name, state]) => [name, state.status]), + ) as Record; + const degraded = Object.values(dependencies).some((state) => state !== 'up'); + const body = { + vaultId, + status: degraded ? 'degraded' as const : 'healthy' as const, + uptimeSeconds: Math.floor(process.uptime()), + metrics: { + totalAssets: summary.totalAssets, + totalShares: summary.totalShares, + sharePrice: summary.sharePrice, + apy: summary.apy, + }, + dependencies, + timestamp: new Date().toISOString(), + }; + + cache.set(cacheKey, body, 5); + return { ...body, cached: false }; +} + +const router = Router(); + +// /api/vault/* → /api/v1/vault/* +router.get('/api/vault/summary', (_req: Request, res: Response) => { + res.setHeader('deprecation', 'true'); + res.redirect(301, '/api/v1/vault/summary'); +}); + +router.get('/api/vault/metrics', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/metrics'); +}); + +router.get('/api/vault/apy', (_req: Request, res: Response) => { + res.redirect(301, '/api/v1/vault/apy'); +}); + +// ─── Versioned vault summary/metrics/apy endpoints ─────────────────────── + +/** + * GET /api/v1/vaults/:id/health - monitoring-friendly vault health payload. + * + * Kept separate from /health so external monitors can target one vault and + * receive vault metrics, dependency state, uptime, and cache status. Responses + * are cached for five seconds to avoid turning health probes into DB pressure. + */ +router.get( + '/api/v1/vaults/:id/health', + readsLimiter, + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vaults/:id/health', + message: 'Vault health took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_HEALTH_TIMEOUT', + message: 'Vault health is temporarily unavailable. Please retry shortly.', + timestamp: new Date().toISOString(), + }), + }), + async (req: Request, res: Response) => { + const health = await buildVaultHealthResponse(req.params.id); + res.status(health.status === 'healthy' ? 200 : 503).json(health); + }, +); + +router.get('/api/v2/vaults/:id/health', (req: Request, res: Response) => { + const qs = req.originalUrl.includes('?') ? req.originalUrl.slice(req.originalUrl.indexOf('?')) : ''; + res.set('X-API-Preview', 'v2'); + res.redirect(307, `/api/v1/vaults/${encodeURIComponent(req.params.id)}/health${qs}`); +}); + +/** + * GET /api/v1/vaults/:id/apy + * + * Returns the annualised APY for the vault. Returns `apy: null` with + * `apyStatus: 'insufficient_data'` for new vaults that have zero shares or + * fewer than 2 price snapshots, preventing Infinity / NaN from reaching the + * frontend (Issue #1456). + */ +router.get( + '/api/v1/vaults/:id/apy', + readsLimiter, + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vaults/:id/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + timestamp: new Date().toISOString(), + }), + }), + async (_req: Request, res: Response) => { + const result = await computeVaultApy(); + res.json({ + ...result, + timestamp: new Date().toISOString(), + }); + }, +); + +/** + * @openapi + * /vault/summary: + * get: + * summary: Vault summary + * description: Returns high-level vault metrics including total assets, shares, and APY. + * tags: [Vault] + * responses: + * 200: + * description: Vault summary metrics + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/VaultSummary' + */ +/** + * GET /api/v1/vault/summary – read-only summary; relaxed rate limit. + * Data is sourced from VaultState + SharePriceSnapshot (DB) and cached via + * the Redis-backed response cache. + */ +router.get( + '/api/v1/vault/summary', + readsLimiter, + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1500, + routeName: '/api/v1/vault/summary', + message: 'Vault summary took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_SUMMARY_TIMEOUT', + message: 'Vault summary is temporarily unavailable. Please refresh in a moment.', + stale: true, + data: { + totalAssets: '0', + totalShares: '0', + sharePrice: '1.000000', + apy: 0, + timestamp: new Date().toISOString(), + }, + timestamp: new Date().toISOString(), + }), + }), + async (_req: Request, res: Response) => { + const summary = await buildVaultSummaryResponseFromDb(); + res.json(summary); + }, +); + +/** + * GET /api/v1/vault/metrics - Cache with configurable TTL + */ +router.get( + '/api/v1/vault/metrics', + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 2000, + routeName: '/api/v1/vault/metrics', + message: 'Vault metrics took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_METRICS_TIMEOUT', + message: 'Vault metrics are temporarily unavailable. Showing the last known state.', + stale: true, + timestamp: new Date().toISOString(), + }), + }), + (_req: Request, res: Response) => { + res.json({ + message: 'Vault metrics', + timestamp: new Date().toISOString(), + }); + }, +); + +/** + * GET /api/v1/vault/apy - Cache with configurable TTL + */ +router.get( + '/api/v1/vault/apy', + cacheMiddleware({ ttl: cacheVaultMetricsTtl }), + createTimeoutFor.read({ + timeoutMs: 1200, + routeName: '/api/v1/vault/apy', + message: 'Vault APY took too long to load', + fallbackResponse: () => ({ + error: 'Service Unavailable', + status: 503, + code: 'VAULT_APY_TIMEOUT', + message: 'Vault APY is temporarily unavailable. Please try again shortly.', + stale: true, + timestamp: new Date().toISOString(), + }), + }), + (_req: Request, res: Response) => { + res.json({ + message: 'Vault APY', + timestamp: new Date().toISOString(), + }); + }, +); + +export default router; diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts new file mode 100644 index 000000000..ecbbc27a5 --- /dev/null +++ b/backend/src/routes/webhooks.ts @@ -0,0 +1,492 @@ +/** + * Webhook verification and admin webhook management endpoints. + * + * Extracted from src/index.ts (issue #1545); mounted by the composition root + * in the same position the routes were registered, so behaviour is unchanged. + */ +import { Router, Request, Response } from 'express'; +import { IdParamSchema, PaginationQuerySchema, WebhookListQuerySchema, WebhookRegisterSchema, WebhookUpdateSchema, WebhookVerifyBodySchema, validate } from '../middleware/validate'; +import { assertWebhookParameterUpdate } from '../middleware/rbac'; +import { createWebhookSignature, deleteWebhookEndpoint, getWebhookDeliveryMetrics, listWebhookDeadLetters, listWebhookDeliveryPage, listWebhookEndpoints, registerWebhookEndpoint, restoreWebhookEndpoint, retryWebhookDeadLetter, updateWebhookEndpoint, verifyWebhookEndpoint, verifyWebhookSignature } from '../webhookDelivery'; +import { hasRequiredApiKeyRole, validateApiKey } from '../middleware/apiKeyAuth'; +import { isDryRunRequest, paginateByLimit, parseLimited, resolveActingAdminAddress, sendStandardListEnvelope } from './shared'; +import { recordAdminAuditLog } from '../adminAudit'; +import { requireAuth } from '../auth'; +import { webhookDeduplicationStore } from '../webhookDeduplication'; + +const router = Router(); + +/** Routes mounted on the `/api/v1` router (`apiV1WebhookRoutes`). */ +export const apiV1WebhookRoutes = Router(); + +/** + * POST /api/v1/webhooks — authenticated clients register an HTTPS callback + * instead of polling for transaction/vault events. + */ +apiV1WebhookRoutes.post('/webhooks', requireAuth, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { + try { + const { url, eventTypes, enabled, secret } = req.body; + const endpoint = registerWebhookEndpoint({ + url, + eventTypes, + enabled: enabled ?? true, + secret, + }); + res.status(201).json({ + message: 'Webhook endpoint registered', + endpoint, + }); + } catch (error) { + res.status(422).json({ + error: 'Unprocessable Entity', + status: 422, + message: error instanceof Error ? error.message : 'Invalid webhook configuration', + }); + } +}); + +apiV1WebhookRoutes.get('/webhooks', requireAuth, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { + const includeDeleted = req.query.includeDeleted === 'true'; + const endpoints = listWebhookEndpoints(includeDeleted); + res.status(200).json({ + endpoints, + metrics: getWebhookDeliveryMetrics(), + }); +}); + +// /webhooks/verify → /api/v1/webhooks/verify +router.post('/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { + const { secret, payload, signature } = req.body || {}; + if (typeof secret !== 'string' || !secret.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'secret is required and must be a non-empty string', + }); + return; + } + + if (typeof payload === 'undefined') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'payload is required', + }); + return; + } + + const computedSignature = createWebhookSignature(secret, payload); + const verified = + typeof signature === 'string' && signature.length > 0 + ? verifyWebhookSignature(secret, payload, signature) + : null; + + res.status(200).json({ + algorithm: 'HMAC-SHA256', + signature: computedSignature, + verified, + }); +}); + +/** + * POST /admin/webhooks - register webhook endpoint for transaction events + */ +router.post('/admin/webhooks', validateApiKey, validate({ body: WebhookRegisterSchema }), (req: Request, res: Response) => { + const { url, eventTypes, enabled, secret } = req.body; + + const endpoint = registerWebhookEndpoint({ + url, + eventTypes, + enabled: enabled ?? true, + secret, + }); + + res.status(201).json({ + message: 'Webhook endpoint registered', + endpoint, + }); +}); + +/** + * POST /admin/webhooks/:id/verify - run challenge-response verification for an endpoint + */ +router.post('/admin/webhooks/:id/verify', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { + try { + const endpoint = await verifyWebhookEndpoint(req.params.id); + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found', + }); + return; + } + + await recordAdminAuditLog(req, 'webhook.verify', endpoint.verificationStatus === 'verified' ? 200 : 422, { + endpointId: endpoint.id, + verificationStatus: endpoint.verificationStatus, + lastVerificationError: endpoint.lastVerificationError, + }); + + res.status(endpoint.verificationStatus === 'verified' ? 200 : 422).json({ + message: + endpoint.verificationStatus === 'verified' + ? 'Webhook endpoint verified' + : 'Webhook endpoint verification failed', + endpoint, + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to verify webhook endpoint', + }); + } +}); + +/** + * PATCH /admin/webhooks/:id - update webhook endpoint + */ +router.patch('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchema, body: WebhookUpdateSchema }), (req: Request, res: Response) => { + if (!assertWebhookParameterUpdate(req, res)) { + return; + } + + const endpoint = updateWebhookEndpoint(req.params.id, req.body || {}); + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found', + }); + return; + } + + res.status(200).json({ + message: 'Webhook endpoint updated', + endpoint, + }); +}); + +/** + * GET /admin/webhooks - list webhook endpoints + */ +router.get('/admin/webhooks', validateApiKey, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { + const includeDeleted = req.query.includeDeleted === 'true'; + const limit = parseLimited(req.query.limit, 100, 1, 500); + const allEndpoints = listWebhookEndpoints(includeDeleted); + const windowed = allEndpoints.slice(0, limit + 1); + const { data, hasNextPage } = paginateByLimit(windowed, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + total: allEndpoints.length, + extras: { + endpoints: data, + metrics: getWebhookDeliveryMetrics(), + }, + }); +}); + +/** + * DELETE /admin/webhooks/:id - soft delete webhook endpoint + */ +router.delete('/admin/webhooks/:id', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + const endpoint = deleteWebhookEndpoint(req.params.id, actor); + + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found or already deleted', + }); + return; + } + + void recordAdminAuditLog(req, 'webhook.delete', 200, { + endpointId: endpoint.id, + url: endpoint.url, + actor, + }); + + res.status(200).json({ + message: 'Webhook endpoint soft-deleted', + endpoint, + }); +}); + +/** + * POST /admin/webhooks/:id/restore - restore soft-deleted webhook endpoint + */ +router.post('/admin/webhooks/:id/restore', validateApiKey, validate({ params: IdParamSchema }), async (req: Request, res: Response) => { + const actor = resolveActingAdminAddress(req); + const endpoint = restoreWebhookEndpoint(req.params.id, actor); + + if (!endpoint) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Webhook endpoint not found or not deleted', + }); + return; + } + + void recordAdminAuditLog(req, 'webhook.restore', 200, { + endpointId: endpoint.id, + url: endpoint.url, + actor, + }); + + res.status(200).json({ + message: 'Webhook endpoint restored', + endpoint, + }); +}); + +/** + * GET /admin/webhooks/dead-letter - list permanently failed webhook deliveries + */ +router.get('/admin/webhooks/dead-letter', validateApiKey, validate({ query: WebhookListQuerySchema }), (req: Request, res: Response) => { + const endpointId = typeof req.query.endpointId === 'string' ? req.query.endpointId : undefined; + const eventType = typeof req.query.eventType === 'string' ? req.query.eventType : undefined; + const start = typeof req.query.start === 'string' ? req.query.start : undefined; + const end = typeof req.query.end === 'string' ? req.query.end : undefined; + const limit = parseLimited(req.query.limit, 100, 1, 500); + + const rows = listWebhookDeadLetters({ + endpointId, + eventType: eventType as any, + start, + end, + limit: limit + 1, + }); + const { data, hasNextPage } = paginateByLimit(rows, limit); + + sendStandardListEnvelope(res, { + data, + limit, + hasNextPage, + extras: { + deadLetters: data, + }, + }); +}); + +/** + * POST /admin/webhooks/dead-letter/:id/retry - re-queue a dead-letter delivery + */ +router.post('/admin/webhooks/dead-letter/:id/retry', validateApiKey, async (req: Request, res: Response) => { + try { + const entry = await retryWebhookDeadLetter(req.params.id); + if (!entry) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: 'Dead-letter entry not found', + }); + return; + } + + res.status(200).json({ + message: 'Dead-letter entry re-queued for delivery', + deadLetter: entry, + }); + } catch (error) { + res.status(500).json({ + error: 'Internal Server Error', + status: 500, + message: error instanceof Error ? error.message : 'Failed to retry dead-letter entry', + }); + } +}); + +/** + * GET /admin/webhooks/deliveries - list recent webhook delivery attempts + * Supports cursor-based pagination: ?limit=N&cursor= + */ +router.get('/admin/webhooks/deliveries', validateApiKey, validate({ query: PaginationQuerySchema }), (req: Request, res: Response) => { + const limit = parseLimited(req.query.limit, 100, 1, 500); + const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : undefined; + + try { + const page = listWebhookDeliveryPage({ limit, cursor }); + sendStandardListEnvelope(res, { + data: page.deliveries, + limit, + hasNextPage: page.hasNextPage, + hasPrevPage: Boolean(cursor), + nextCursor: page.nextCursor, + extras: { + deliveries: page.deliveries, + nextCursor: page.nextCursor, + hasNextPage: page.hasNextPage, + metrics: getWebhookDeliveryMetrics(), + }, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('Invalid or expired cursor')) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'Invalid or expired cursor. Start a new page without a cursor.', + }); + return; + } + res.status(500).json({ error: 'Internal Server Error', status: 500, message }); + } +}); + +/** + * POST /api/v1/webhooks/verify - verify webhook secret/signature pairing before go-live + */ +router.post('/api/v1/webhooks/verify', validate({ body: WebhookVerifyBodySchema }), (req: Request, res: Response) => { + const { secret, payload, signature } = req.body || {}; + if (typeof secret !== 'string' || !secret.trim()) { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'secret is required and must be a non-empty string', + }); + return; + } + + if (typeof payload === 'undefined') { + res.status(400).json({ + error: 'Bad Request', + status: 400, + message: 'payload is required', + }); + return; + } + + const computedSignature = createWebhookSignature(secret, payload); + const verified = + typeof signature === 'string' && signature.length > 0 + ? verifyWebhookSignature(secret, payload, signature) + : null; + + res.status(200).json({ + algorithm: 'HMAC-SHA256', + signature: computedSignature, + verified, + }); +}); + +// ─── Webhook Deduplication Admin Endpoints (Issue #710) ────────────────────── + +/** + * GET /admin/webhooks/deduplication/metrics + * Returns observability counters for the webhook replay-safe deduplication store. + * Requires API key authentication. + */ +router.get('/admin/webhooks/deduplication/metrics', validateApiKey, (_req: Request, res: Response) => { + res.status(200).json({ + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * GET /admin/webhooks/deduplication/entries + * Lists active event fingerprints held in the deduplication store. + * Optional ?prefix= filters by event id prefix. + * Requires API key authentication. + */ +router.get('/admin/webhooks/deduplication/entries', validateApiKey, (req: Request, res: Response) => { + const prefix = typeof req.query.prefix === 'string' ? req.query.prefix : undefined; + const entries = webhookDeduplicationStore.inspect(prefix); + res.status(200).json({ + entries, + count: entries.length, + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/webhooks/deduplication/:eventId + * Removes a single event fingerprint from the deduplication store. + * Requires API key authentication. + */ +router.delete('/admin/webhooks/deduplication/:eventId', validateApiKey, (req: Request, res: Response) => { + const eventId = decodeURIComponent(req.params.eventId); + + if (isDryRunRequest(req)) { + const exists = webhookDeduplicationStore.has(eventId); + res.status(exists ? 200 : 404).json({ + dryRun: true, + message: exists + ? `Deduplication entry '${eventId}' would be removed` + : `Deduplication entry '${eventId}' not found`, + eventId, + wouldDelete: exists, + timestamp: new Date().toISOString(), + }); + return; + } + + const removed = webhookDeduplicationStore.remove(eventId); + if (!removed) { + res.status(404).json({ + error: 'Not Found', + status: 404, + message: `Deduplication entry '${eventId}' not found`, + }); + return; + } + + void recordAdminAuditLog(req, 'webhook.dedup.entry.removed', 200, { eventId }); + + res.status(200).json({ + message: `Deduplication entry '${eventId}' removed`, + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +/** + * DELETE /admin/webhooks/deduplication + * Flushes the entire webhook deduplication store. + * Requires super-admin API key. + */ +router.delete('/admin/webhooks/deduplication', validateApiKey, (req: Request, res: Response) => { + if (!hasRequiredApiKeyRole(req, 'super-admin')) { + res.status(403).json({ + error: 'Forbidden', + status: 403, + message: 'Super-admin role is required to flush the webhook deduplication store', + }); + return; + } + + const metrics = webhookDeduplicationStore.getMetrics(); + + if (isDryRunRequest(req)) { + res.status(200).json({ + dryRun: true, + message: 'Webhook deduplication store flush dry-run preview', + wouldFlush: true, + activeFingerprints: metrics.activeFingerprints, + timestamp: new Date().toISOString(), + }); + return; + } + + webhookDeduplicationStore.flush(); + + void recordAdminAuditLog(req, 'webhook.dedup.store.flushed', 200, { + flushedCount: metrics.activeFingerprints, + }); + + res.status(200).json({ + message: 'Webhook deduplication store flushed', + metrics: webhookDeduplicationStore.getMetrics(), + timestamp: new Date().toISOString(), + }); +}); + +export default router;