diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..4960fe3 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + root: true, + extends: ["tariffshield"], + parserOptions: { + tsconfigRootDir: __dirname, + project: [ + "./apps/api/tsconfig.json", + "./apps/web/tsconfig.json", + "./packages/sdk/tsconfig.json", + ], + }, + ignorePatterns: ["dist/**", "node_modules/**", ".next/**", "playwright-report/**"], +}; diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 4ac9387..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "root": true, - "extends": ["tariffshield"], - "parserOptions": { - "project": [ - "./apps/api/tsconfig.json", - "./apps/web/tsconfig.json", - "./packages/sdk/tsconfig.json" - ] - }, - "ignorePatterns": ["dist/**", "node_modules/**"] -} diff --git a/.github/workflows/api-integration.yml b/.github/workflows/api-integration.yml index 6aaaaf7..233e7c6 100644 --- a/.github/workflows/api-integration.yml +++ b/.github/workflows/api-integration.yml @@ -48,6 +48,12 @@ jobs: - run: npm ci + - name: Build SDK + # apps/api's src/stellar.ts imports @tariffshield/sdk via its built + # dist/index.js (see packages/sdk/package.json "main"/"types"), so + # it has to be built before anything imports apps/api's source. + run: npm run build --workspace=packages/sdk + - name: Run database migrations run: npm run db:migrate --workspace=apps/api diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 5849ae7..6e9dbba 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -77,6 +77,10 @@ jobs: - name: Start API in background run: | + # apps/api imports @tariffshield/sdk via its built dist/index.d.ts + # (see packages/sdk/package.json "types"), so it has to be built + # before apps/api's own build. + npm run build --workspace=packages/sdk npm run build --workspace=apps/api nohup npm run start --workspace=apps/api > api.log 2>&1 & echo "waiting for API to become healthy..." diff --git a/.github/workflows/contract.yml b/.github/workflows/contract.yml index 297c4f2..c4bee10 100644 --- a/.github/workflows/contract.yml +++ b/.github/workflows/contract.yml @@ -6,6 +6,10 @@ on: push: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + env: # Fails the build if the release .wasm exceeds this size. Override per-run # via workflow_dispatch inputs or by editing this value. diff --git a/.github/workflows/dast.yml b/.github/workflows/dast.yml index 67b5b5b..cbf4794 100644 --- a/.github/workflows/dast.yml +++ b/.github/workflows/dast.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -29,13 +32,18 @@ jobs: - name: Start Staging Environment run: | + # docker-compose.yml declares env_file: apps/api/.env for the api + # service; compose resolves every service's config (not just the + # one being targeted) before starting anything, so this has to + # exist before the first `docker compose up`, not just before the + # one that starts api. + cp .env.example .env + cp .env apps/api/.env + docker compose up -d postgres # Wait for Postgres to be healthy docker compose exec -T postgres sh -c "until pg_isready -U tariffshield -d tariffshield; do sleep 1; done" - - # Setup environment and start api - cp .env.example .env - cp .env apps/api/.env + docker compose up -d api # Wait for API to be ready (bounded: max 60 attempts, 2s apart = 120s) diff --git a/.github/workflows/dependency_scan.yml b/.github/workflows/dependency_scan.yml index 16f950d..f611192 100644 --- a/.github/workflows/dependency_scan.yml +++ b/.github/workflows/dependency_scan.yml @@ -30,4 +30,11 @@ jobs: - name: Cargo Audit run: | cargo install cargo-audit - cd contracts/tariff-shield && cargo audit -D warnings + # Run from the workspace root, where Cargo.lock lives (contracts/tariff-shield + # is a workspace member, not its own root) — cargo-audit does not walk up to + # find it like `cargo build`/`cargo test` do. + # RUSTSEC-2024-0388 (derivative) and RUSTSEC-2024-0436 (paste) are unmaintained + # advisories transitively pulled in by soroban-env-host's ark-* crypto stack + # with "No safe upgrade available" upstream; see deny.toml for the same + # documented exception. + cargo audit -D warnings --ignore RUSTSEC-2024-0388 --ignore RUSTSEC-2024-0436 diff --git a/.github/workflows/deploy-api.yml b/.github/workflows/deploy-api.yml index 49347ab..85c104a 100644 --- a/.github/workflows/deploy-api.yml +++ b/.github/workflows/deploy-api.yml @@ -55,7 +55,7 @@ jobs: - name: Notify Slack on success if: success() - uses: slackapi/slack-github-action@v1.24.0 + uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0 with: webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} webhook-type: incoming-webhook @@ -64,7 +64,7 @@ jobs: - name: Notify Slack on failure if: failure() - uses: slackapi/slack-github-action@v1.24.0 + uses: slackapi/slack-github-action@37ebaef184d7626c5f204ab8d3baff4262dd30f0 # v1.27.0 with: webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK_URL }} webhook-type: incoming-webhook diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 4f039da..41913b2 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -43,6 +43,12 @@ jobs: - name: Type check SDK run: npx tsc --noEmit -p packages/sdk/tsconfig.json + - name: Build SDK + # apps/api imports @tariffshield/sdk via its built dist/index.d.ts + # (see packages/sdk/package.json "types"), not its source directly, + # so the API type check below needs it built first. + run: npm run build --workspace=packages/sdk + - name: Type check API run: npx tsc --noEmit -p apps/api/tsconfig.json diff --git a/.gitignore b/.gitignore index 2daf8b2..da218e5 100644 --- a/.gitignore +++ b/.gitignore @@ -18,7 +18,6 @@ deployments.json .vercel .vscode .idea -Cargo.lock # Test snapshots __snapshots__/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3a3abcb --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1764 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5a26814d8dcb93b0e5a0ff3c6d80a8843bafb21b39e8e18a6f05471870e110" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ark-bls12-381" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c775f0d12169cba7aae4caeb547bb6a50781c7449a8aa53793827c9ec4abf488" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", + "itertools", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "derivative", + "digest", + "itertools", + "num-bigint", + "num-traits", + "paste", + "rustc_version", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "digest", + "num-bigint", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-std" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes-lit" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0adabf37211a5276e46335feabcbb1530c95eb3fdf85f324c7db942770aa025d" +dependencies = [ + "num-bigint", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crate-git-revision" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c521bf1f43d31ed2f73441775ed31935d77901cb3451e44b38a1c1612fcbaf98" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_arbitrary" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "escape-bytes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" + +[[package]] +name = "ethnum" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + +[[package]] +name = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indexmap-nostd" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e04e2fd2b8188ea827b32ef11de88377086d690286ab35747ef7f9bf3ccb590" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "soroban-builtin-sdk-macros" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2e42bf80fcdefb3aae6ff3c7101a62cf942e95320ed5b518a1705bc11c6b2f" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "soroban-env-common" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027cd856171bfd6ad2c0ffb3b7dfe55ad7080fb3050c36ad20970f80da634472" +dependencies = [ + "arbitrary", + "crate-git-revision", + "ethnum", + "num-derive", + "num-traits", + "serde", + "soroban-env-macros", + "soroban-wasmi", + "static_assertions", + "stellar-xdr", + "wasmparser", +] + +[[package]] +name = "soroban-env-guest" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a07dda1ae5220d975979b19ad4fd56bc86ec7ec1b4b25bc1c5d403f934e592e" +dependencies = [ + "soroban-env-common", + "static_assertions", +] + +[[package]] +name = "soroban-env-host" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e8b03a4191d485eab03f066336112b2a50541a7553179553dc838b986b94dd" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-serialize", + "curve25519-dalek", + "ecdsa", + "ed25519-dalek", + "elliptic-curve", + "generic-array", + "getrandom", + "hex-literal", + "hmac", + "k256", + "num-derive", + "num-integer", + "num-traits", + "p256", + "rand", + "rand_chacha", + "sec1", + "sha2", + "sha3", + "soroban-builtin-sdk-macros", + "soroban-env-common", + "soroban-wasmi", + "static_assertions", + "stellar-strkey", + "wasmparser", +] + +[[package]] +name = "soroban-env-macros" +version = "22.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00eff744764ade3bc480e4909e3a581a240091f3d262acdce80b41f7069b2bd9" +dependencies = [ + "itertools", + "proc-macro2", + "quote", + "serde", + "serde_json", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-ledger-snapshot" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30035cf1e8f02f65de3e594b6da113ecdaf1cd134d8480961d62568bb15adaf" +dependencies = [ + "serde", + "serde_json", + "serde_with", + "soroban-env-common", + "soroban-env-host", + "thiserror", +] + +[[package]] +name = "soroban-sdk" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff18e8d7ca6d5340a211605ca2c86383bd4dfacc4f8253d72a1573974ffffe69" +dependencies = [ + "arbitrary", + "bytes-lit", + "ctor", + "derive_arbitrary", + "ed25519-dalek", + "rand", + "rustc_version", + "serde", + "serde_json", + "soroban-env-guest", + "soroban-env-host", + "soroban-ledger-snapshot", + "soroban-sdk-macros", + "stellar-strkey", +] + +[[package]] +name = "soroban-sdk-macros" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b205cd86b34d530db87667bd287fbb194166d79b368227fd842110a914fde8" +dependencies = [ + "crate-git-revision", + "darling 0.20.11", + "itertools", + "proc-macro2", + "quote", + "rustc_version", + "sha2", + "soroban-env-common", + "soroban-spec", + "soroban-spec-rust", + "stellar-xdr", + "syn 2.0.119", +] + +[[package]] +name = "soroban-spec" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6a16f2de28852c759f4da5f28cda54ec0d8dfa4c0e6e8cb3495234a72b0cea" +dependencies = [ + "base64 0.13.1", + "stellar-xdr", + "thiserror", + "wasmparser", +] + +[[package]] +name = "soroban-spec-rust" +version = "22.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6db5902ab21290dddf63fec4ee95703fe59891a947646e7b8607536f043fc" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "sha2", + "soroban-spec", + "stellar-xdr", + "syn 2.0.119", + "thiserror", +] + +[[package]] +name = "soroban-wasmi" +version = "0.31.1-soroban.20.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710403de32d0e0c35375518cb995d4fc056d0d48966f2e56ea471b8cb8fc9719" +dependencies = [ + "smallvec", + "spin", + "wasmi_arena", + "wasmi_core", + "wasmparser-nostd", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stellar-strkey" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e3aa3ed00e70082cb43febc1c2afa5056b9bb3e348bbb43d0cd0aa88a611144" +dependencies = [ + "crate-git-revision", + "data-encoding", + "thiserror", +] + +[[package]] +name = "stellar-xdr" +version = "22.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce69db907e64d1e70a3dce8d4824655d154749426a6132b25395c49136013e4" +dependencies = [ + "arbitrary", + "base64 0.13.1", + "crate-git-revision", + "escape-bytes", + "hex", + "serde", + "serde_with", + "stellar-strkey", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tariff-shield" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "soroban-sdk", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasmi_arena" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "104a7f73be44570cac297b3035d76b169d6599637631cf37a1703326a0727073" + +[[package]] +name = "wasmi_core" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf1a7db34bff95b85c261002720c00c3a6168256dcb93041d3fa2054d19856a" +dependencies = [ + "downcast-rs", + "libm", + "num-traits", + "paste", +] + +[[package]] +name = "wasmparser" +version = "0.116.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a58e28b80dd8340cb07b8242ae654756161f6fc8d0038123d679b7b99964fa50" +dependencies = [ + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "wasmparser-nostd" +version = "0.100.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a015fe95f3504a94bb1462c717aae75253e39b9dd6c3fb1062c934535c64aa" +dependencies = [ + "indexmap-nostd", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 6eec225..19030b7 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -3,7 +3,7 @@ FROM node:20-alpine AS builder WORKDIR /app # Copy workspace manifests first for layer caching -COPY package.json package-lock.json ./ +COPY package.json package-lock.json tsconfig.base.json ./ COPY apps/api/package.json ./apps/api/ COPY packages/sdk/package.json ./packages/sdk/ diff --git a/apps/api/package.json b/apps/api/package.json index 80aaefd..711c5cf 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,7 @@ "db:migrate": "tsx src/migrate.ts", "migrate": "tsx src/migrate.ts", "migrate:rollback": "tsx src/migrate.ts rollback", - "test:integration": "node --import tsx/esm --test --test-reporter=junit --test-reporter-destination=test-results.xml \"src/__tests__/**/*.test.ts\"", + "test:integration": "node --import tsx/esm --test --test-reporter=junit --test-reporter-destination=test-results.xml src/__tests__/integration/*.test.ts", "benchmark": "bash tests/load/run-all.sh", "lint": "eslint --max-warnings 0 src", "lint:fix": "eslint --fix src", diff --git a/apps/api/src/__tests__/integration/bonds.test.ts b/apps/api/src/__tests__/integration/bonds.test.ts index a1d1250..998cdae 100644 --- a/apps/api/src/__tests__/integration/bonds.test.ts +++ b/apps/api/src/__tests__/integration/bonds.test.ts @@ -7,13 +7,13 @@ * The CI workflow runs `npm run db:migrate` before invoking this suite. */ -import { describe, it, before, after } from "node:test"; -import assert from "node:assert/strict"; -import { randomUUID } from "node:crypto"; -import { Pool } from "pg"; +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { Pool } from 'pg'; const DATABASE_URL = - process.env.DATABASE_URL ?? "postgres://test:test@localhost:5432/tariffshield_test"; + process.env.DATABASE_URL ?? 'postgres://test:test@localhost:5432/tariffshield_test'; const pool = new Pool({ connectionString: DATABASE_URL }); @@ -24,13 +24,13 @@ let userId: string; let importerId: string; const testBondId = Math.floor(Math.random() * 9_000_000) + 1_000_000; -describe("bonds integration — real Postgres insert", () => { +describe('bonds integration — real Postgres insert', () => { before(async () => { const userResult = await pool.query<{ id: string }>( `INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id`, - [testEmail, "test-hash-not-real", "importer"], + [testEmail, 'test-hash-not-real', 'importer'] ); userId = userResult.rows[0]!.id; @@ -38,23 +38,28 @@ describe("bonds integration — real Postgres insert", () => { `INSERT INTO importers (user_id, legal_name, bond_id, stellar_address) VALUES ($1, $2, $3, $4) RETURNING id`, - [userId, "Integration Test Corp", testBondId, "GABC1234TESTSTELLARADDRESS00000000000000000000000000000000"], + [ + userId, + 'Integration Test Corp', + testBondId, + 'GABC1234TESTSTELLARADDRESS00000000000000000000000000000000', + ] ); importerId = importerResult.rows[0]!.id; }); after(async () => { if (importerId) { - await pool.query("DELETE FROM bond_records WHERE importer_id = $1", [importerId]); - await pool.query("DELETE FROM importers WHERE id = $1", [importerId]); + await pool.query('DELETE FROM bond_records WHERE importer_id = $1', [importerId]); + await pool.query('DELETE FROM importers WHERE id = $1', [importerId]); } if (userId) { - await pool.query("DELETE FROM users WHERE id = $1", [userId]); + await pool.query('DELETE FROM users WHERE id = $1', [userId]); } await pool.end(); }); - it("inserts a bond record and retrieves it", async () => { + it('inserts a bond record and retrieves it', async () => { const result = await pool.query<{ id: string; bond_id: string; bond_amount: string }>( `INSERT INTO bond_records (importer_id, bond_id, bond_type_code, principal_legal_name, principal_ein, @@ -64,24 +69,24 @@ describe("bonds integration — real Postgres insert", () => { [ importerId, testBondId, - "02", - "Integration Test Corp", - "12-3456789", - "Test Surety Company", - "98-7654321", - "500000", - "100000", - ], + '02', + 'Integration Test Corp', + '12-3456789', + 'Test Surety Company', + '98-7654321', + '500000', + '100000', + ] ); - assert.strictEqual(result.rowCount, 1, "one row should be inserted"); + assert.strictEqual(result.rowCount, 1, 'one row should be inserted'); const row = result.rows[0]!; - assert.ok(row.id, "inserted row should have a UUID id"); - assert.strictEqual(String(row.bond_id), String(testBondId), "bond_id should match"); - assert.strictEqual(row.bond_amount, "500000", "bond_amount should match"); + assert.ok(row.id, 'inserted row should have a UUID id'); + assert.strictEqual(String(row.bond_id), String(testBondId), 'bond_id should match'); + assert.strictEqual(row.bond_amount, '500000', 'bond_amount should match'); }); - it("enforces bond_type_code check constraint", async () => { + it('enforces bond_type_code check constraint', async () => { await assert.rejects( () => pool.query( @@ -92,17 +97,17 @@ describe("bonds integration — real Postgres insert", () => { [ importerId, testBondId + 1, - "99", - "Integration Test Corp", - "12-3456789", - "Test Surety Company", - "98-7654321", - "500000", - "100000", - ], + '99', + 'Integration Test Corp', + '12-3456789', + 'Test Surety Company', + '98-7654321', + '500000', + '100000', + ] ), /violates check constraint/i, - "invalid bond_type_code should raise a check constraint error", + 'invalid bond_type_code should raise a check constraint error' ); }); }); diff --git a/apps/api/src/__tests__/integration/ein-hash.test.ts b/apps/api/src/__tests__/integration/ein-hash.test.ts index e9b973e..5afbe88 100644 --- a/apps/api/src/__tests__/integration/ein-hash.test.ts +++ b/apps/api/src/__tests__/integration/ein-hash.test.ts @@ -10,34 +10,34 @@ * created by either path (fresh INSERT vs. the one-time backfill). */ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; function computeEinHash(ein: string): string { - return createHash("sha256").update(ein).digest("hex"); + return createHash('sha256').update(ein).digest('hex'); } -describe("ein_hash computation (#243)", () => { - it("produces a 64-character lowercase hex SHA-256 digest", () => { - const hash = computeEinHash("12-3456789"); +describe('ein_hash computation (#243)', () => { + it('produces a 64-character lowercase hex SHA-256 digest', () => { + const hash = computeEinHash('12-3456789'); assert.equal(hash.length, 64); assert.match(hash, /^[0-9a-f]{64}$/); }); - it("is deterministic for the same input", () => { - assert.equal(computeEinHash("98-7654321"), computeEinHash("98-7654321")); + it('is deterministic for the same input', () => { + assert.equal(computeEinHash('98-7654321'), computeEinHash('98-7654321')); }); - it("produces different hashes for different EINs", () => { - assert.notEqual(computeEinHash("11-1111111"), computeEinHash("22-2222222")); + it('produces different hashes for different EINs', () => { + assert.notEqual(computeEinHash('11-1111111'), computeEinHash('22-2222222')); }); it("matches the known SHA-256 digest for a fixed input (cross-checked against Postgres's encode(sha256(ein::bytea), 'hex'))", () => { // Verified independently via: SELECT encode(sha256('12-3456789'::bytea), 'hex'); assert.equal( - computeEinHash("12-3456789"), - "489553f3942f7a333a54471545575969abdeb9f5d351cd87e381eeaa7bb781e9", + computeEinHash('12-3456789'), + '489553f3942f7a333a54471545575969abdeb9f5d351cd87e381eeaa7bb781e9' ); }); }); diff --git a/apps/api/src/__tests__/integration/health-db.test.ts b/apps/api/src/__tests__/integration/health-db.test.ts index f55eac1..71cd870 100644 --- a/apps/api/src/__tests__/integration/health-db.test.ts +++ b/apps/api/src/__tests__/integration/health-db.test.ts @@ -9,48 +9,52 @@ * non-DB-dependent code paths without a full .env. */ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; function stub(name: string, value: string) { if (!process.env[name]) process.env[name] = value; } -stub("DATABASE_URL", "postgres://fake:fake@localhost:1/fake"); -stub("JWT_SECRET", "test-stub-jwt-secret-not-used-by-this-suite-00000"); -stub("STELLAR_RPC_URL", "https://soroban-testnet.stellar.org"); -stub("STELLAR_HORIZON_URL", "https://horizon-testnet.stellar.org"); -stub("STELLAR_NETWORK_PASSPHRASE", "Test SDF Network ; September 2015"); -stub("TARIFF_SHIELD_CONTRACT_ID", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); -stub("PLATFORM_STELLAR_SECRET", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB"); -stub("SURETY_STELLAR_SECRET", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC"); +// Unconditional, unlike stub() below: this whole file tests behavior against +// an unreachable database, so it must win even when the CI job already sets +// a real (reachable) DATABASE_URL at the job level for the other test files +// in this suite that need it. +process.env.DATABASE_URL = 'postgres://fake:fake@localhost:1/fake'; +stub('JWT_SECRET', 'test-stub-jwt-secret-not-used-by-this-suite-00000'); +stub('STELLAR_RPC_URL', 'https://soroban-testnet.stellar.org'); +stub('STELLAR_HORIZON_URL', 'https://horizon-testnet.stellar.org'); +stub('STELLAR_NETWORK_PASSPHRASE', 'Test SDF Network ; September 2015'); +stub('TARIFF_SHIELD_CONTRACT_ID', 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); +stub('PLATFORM_STELLAR_SECRET', 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'); +stub('SURETY_STELLAR_SECRET', 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC'); -const { getPoolStats, pool } = await import("../../db.js"); +const { getPoolStats, pool } = await import('../../db.js'); -describe("db pool configuration (#241)", () => { +describe('db pool configuration (#241)', () => { it("getPoolStats returns the pool's own counters without opening a connection", () => { const stats = getPoolStats(); - assert.equal(typeof stats.totalCount, "number"); - assert.equal(typeof stats.idleCount, "number"); - assert.equal(typeof stats.waitingCount, "number"); + assert.equal(typeof stats.totalCount, 'number'); + assert.equal(typeof stats.idleCount, 'number'); + assert.equal(typeof stats.waitingCount, 'number'); // No connection has been attempted yet, so all counters start at 0. assert.equal(stats.totalCount, 0); assert.equal(stats.idleCount, 0); assert.equal(stats.waitingCount, 0); }); - it("pool.query rejects against an unreachable database instead of hanging", async () => { - await assert.rejects(() => pool.query("SELECT 1")); + it('pool.query rejects against an unreachable database instead of hanging', async () => { + await assert.rejects(() => pool.query('SELECT 1')); }); }); -describe("GET /health/db route (#241)", () => { - it("reports status failed with pool stats when the database is unreachable", async () => { - const { healthRouter } = await import("../../routes/health.js"); +describe('GET /health/db route (#241)', () => { + it('reports status failed with pool stats when the database is unreachable', async () => { + const { healthRouter } = await import('../../routes/health.js'); const layer = (healthRouter.stack as any[]).find( - (l) => l.route?.path === "/db" && l.route.methods.get, + (l) => l.route?.path === '/db' && l.route.methods.get ); - assert.ok(layer, "GET /db route must be registered on healthRouter"); + assert.ok(layer, 'GET /db route must be registered on healthRouter'); const handler = layer.route.stack[0].handle; let statusCode = 200; @@ -70,7 +74,7 @@ describe("GET /health/db route (#241)", () => { assert.equal(statusCode, 503); assert.deepEqual(body, { - status: "failed", + status: 'failed', pool: { totalCount: 0, idleCount: 0, waitingCount: 0 }, }); }); diff --git a/apps/api/src/auth.ts b/apps/api/src/auth.ts index c59086a..6d2d9d1 100644 --- a/apps/api/src/auth.ts +++ b/apps/api/src/auth.ts @@ -1,8 +1,8 @@ -import bcrypt from "bcryptjs"; -import jwt from "jsonwebtoken"; -import type { NextFunction, Request, Response } from "express"; -import { env } from "./config/env.js"; -import { pool, validateSession, touchSession } from "./db.js"; +import bcrypt from 'bcryptjs'; +import jwt from 'jsonwebtoken'; +import type { NextFunction, Request, Response } from 'express'; +import { env } from './config/env.js'; +import { pool, validateSession, touchSession } from './db.js'; // ── SOC 2 CC6.3 — Formal RBAC access matrix ────────────────────────────────── // Documents the least-privilege role assignments enforced per route group. @@ -11,52 +11,52 @@ import { pool, validateSession, touchSession } from "./db.js"; // source of truth for auditors; keep it in sync with route definitions. export const ROLE_PERMISSIONS = { importer: [ - "POST /importers", - "GET /importers/own", - "GET /importers/:id (own)", - "GET /importers/:id/collateral-status (own)", - "GET /importers/:id/bonds (own)", - "POST /importers/:id/upload-tariff-csv (own)", - "POST /importers/:id/deposit (own, KYC-gated)", - "POST /importers/:id/auto-top-up (own)", - "POST /importers/:id/withdraw (own)", - "GET /importers/:id/kyc (own)", - "POST /importers/:id/kyc (own)", - "POST /account/erasure-request", - "GET /account/erasure-request/:id", - "GET /privacy-policy-history", - "POST /account/accept-privacy-policy", + 'POST /importers', + 'GET /importers/own', + 'GET /importers/:id (own)', + 'GET /importers/:id/collateral-status (own)', + 'GET /importers/:id/bonds (own)', + 'POST /importers/:id/upload-tariff-csv (own)', + 'POST /importers/:id/deposit (own, KYC-gated)', + 'POST /importers/:id/auto-top-up (own)', + 'POST /importers/:id/withdraw (own)', + 'GET /importers/:id/kyc (own)', + 'POST /importers/:id/kyc (own)', + 'POST /account/erasure-request', + 'GET /account/erasure-request/:id', + 'GET /privacy-policy-history', + 'POST /account/accept-privacy-policy', ], surety_admin: [ - "GET /importers/* (all)", - "GET /importers/:id (all)", - "GET /importers/:id/bonds (all)", - "POST /importers/:id/accrue-yield (license-verified)", - "POST /importers/:id/clawback (license-verified)", - "GET /importers/:id/kyc/:docId/review", - "GET /admin/oracle-alerts", - "PATCH /admin/oracle-alerts/:id/acknowledge", - "GET /admin/roles", - "GET /admin/audit-log", - "POST /admin/privacy-policy/publish", - "GET /admin/access-review", - "GET /compliance/dashboard", - "GET /compliance/flags", - "POST /compliance/flags/:id/resolve", - "GET /compliance/reports", - "GET /compliance/reports/:id/download", - "POST /bonds/:id/send-for-signature", - "GET /bonds/:id/signature-status", - "POST /bonds/:id/send-reminder", - "POST /surety-license/submit", - "GET /surety-license/status", - "GET /api/v1/regulatory/state-report/:state_code", + 'GET /importers/* (all)', + 'GET /importers/:id (all)', + 'GET /importers/:id/bonds (all)', + 'POST /importers/:id/accrue-yield (license-verified)', + 'POST /importers/:id/clawback (license-verified)', + 'GET /importers/:id/kyc/:docId/review', + 'GET /admin/oracle-alerts', + 'PATCH /admin/oracle-alerts/:id/acknowledge', + 'GET /admin/roles', + 'GET /admin/audit-log', + 'POST /admin/privacy-policy/publish', + 'GET /admin/access-review', + 'GET /compliance/dashboard', + 'GET /compliance/flags', + 'POST /compliance/flags/:id/resolve', + 'GET /compliance/reports', + 'GET /compliance/reports/:id/download', + 'POST /bonds/:id/send-for-signature', + 'GET /bonds/:id/signature-status', + 'POST /bonds/:id/send-reminder', + 'POST /surety-license/submit', + 'GET /surety-license/status', + 'GET /api/v1/regulatory/state-report/:state_code', ], - admin: ["ALL — reserved for platform operator via direct DB or Stellar keypair operations"], + admin: ['ALL — reserved for platform operator via direct DB or Stellar keypair operations'], } as const; // Concurrent session limits per role (SOC 2 CC6.1) -export const MAX_SESSIONS: Record<"importer" | "surety_admin", number> = { +export const MAX_SESSIONS: Record<'importer' | 'surety_admin', number> = { importer: 5, surety_admin: 3, }; @@ -64,7 +64,7 @@ export const MAX_SESSIONS: Record<"importer" | "surety_admin", number> = { export interface AuthPayload { id: string; email: string; - role: "importer" | "surety_admin"; + role: 'importer' | 'surety_admin'; sessionId?: string; } @@ -81,20 +81,20 @@ export async function verifyPassword(plain: string, hash: string): Promise { - if (!valid) { - res.status(401).json({ error: "session expired or not found" }); - return; - } - touchSession(payload.sessionId!); - next(); - }).catch(() => { - // Fail closed: if session validation is unavailable the request is blocked. - // This ensures the 15-minute inactivity control is never bypassed by a DB outage. - res.status(503).json({ error: "session validation unavailable" }); - }); + validateSession(payload.sessionId) + .then((valid) => { + if (!valid) { + res.status(401).json({ error: 'session expired or not found' }); + return; + } + touchSession(payload.sessionId!); + next(); + }) + .catch(() => { + // Fail closed: if session validation is unavailable the request is blocked. + // This ensures the 15-minute inactivity control is never bypassed by a DB outage. + res.status(503).json({ error: 'session validation unavailable' }); + }); } -export function requireRole(role: AuthPayload["role"]) { +export function requireRole(role: AuthPayload['role']) { return (req: Request, res: Response, next: NextFunction): void => { const user = (req as AuthedRequest).user; if (user.role !== role) { - res.status(403).json({ error: "forbidden" }); + res.status(403).json({ error: 'forbidden' }); return; } next(); @@ -135,61 +137,79 @@ export function requireRole(role: AuthPayload["role"]) { // #322 — gate requests when a new privacy policy requires re-acceptance. // Exempt: the accept-privacy-policy endpoint itself and the current-version endpoint. const PRIVACY_EXEMPT_PATHS = [ - "/account/accept-privacy-policy", - "/account/accept-tos", - "/account/tos-history", - "/privacy/current-version", - "/auth/", + '/account/accept-privacy-policy', + '/account/accept-tos', + '/account/tos-history', + '/privacy/current-version', + '/auth/', ]; export function privacyReacceptanceGate(req: Request, res: Response, next: NextFunction): void { const user = (req as AuthedRequest).user; - if (!user) { next(); return; } + if (!user) { + next(); + return; + } - const isExempt = PRIVACY_EXEMPT_PATHS.some(p => req.path.includes(p)); - if (isExempt) { next(); return; } + const isExempt = PRIVACY_EXEMPT_PATHS.some((p) => req.path.includes(p)); + if (isExempt) { + next(); + return; + } // Async check — look up live DB value (not stale JWT claim). Fail closed: // if the DB is unreachable we cannot confirm acceptance status, so the // request is blocked. authMiddleware's session validation runs first and // will 503 on a full DB outage before this gate is ever reached. - pool.query<{ privacy_reacceptance_required: boolean }>( - "SELECT privacy_reacceptance_required FROM users WHERE id = $1", - [user.id], - ).then(result => { - if (result.rows[0]?.privacy_reacceptance_required) { - res.status(403).json({ - error: "privacy policy update requires re-acceptance", - reason: "privacy_policy_update", - action: "POST /account/accept-privacy-policy", - }); - return; - } - next(); - }).catch(() => { - res.status(503).json({ error: "service temporarily unavailable" }); - }); + pool + .query<{ privacy_reacceptance_required: boolean }>( + 'SELECT privacy_reacceptance_required FROM users WHERE id = $1', + [user.id] + ) + .then((result) => { + if (result.rows[0]?.privacy_reacceptance_required) { + res.status(403).json({ + error: 'privacy policy update requires re-acceptance', + reason: 'privacy_policy_update', + action: 'POST /account/accept-privacy-policy', + }); + return; + } + next(); + }) + .catch(() => { + res.status(503).json({ error: 'service temporarily unavailable' }); + }); } export function tosReacceptanceGate(req: Request, res: Response, next: NextFunction): void { const user = (req as AuthedRequest).user; - if (!user) { next(); return; } - - const isExempt = PRIVACY_EXEMPT_PATHS.some(p => req.path.includes(p)); - if (isExempt) { next(); return; } - - pool.query<{ tos_reacceptance_required: boolean }>( - "SELECT tos_reacceptance_required FROM users WHERE id = $1", - [user.id], - ).then(result => { - if (result.rows[0]?.tos_reacceptance_required) { - res.status(403).json({ - error: "terms of service update requires re-acceptance", - reason: "tos_acceptance_required", - action: "POST /api/v1/account/accept-tos", - }); - return; - } + if (!user) { + next(); + return; + } + + const isExempt = PRIVACY_EXEMPT_PATHS.some((p) => req.path.includes(p)); + if (isExempt) { next(); - }).catch(() => next()); + return; + } + + pool + .query<{ tos_reacceptance_required: boolean }>( + 'SELECT tos_reacceptance_required FROM users WHERE id = $1', + [user.id] + ) + .then((result) => { + if (result.rows[0]?.tos_reacceptance_required) { + res.status(403).json({ + error: 'terms of service update requires re-acceptance', + reason: 'tos_acceptance_required', + action: 'POST /api/v1/account/accept-tos', + }); + return; + } + next(); + }) + .catch(() => next()); } diff --git a/apps/api/src/cache.ts b/apps/api/src/cache.ts index bfedd07..d1c5956 100644 --- a/apps/api/src/cache.ts +++ b/apps/api/src/cache.ts @@ -1,7 +1,7 @@ -import Redis from "ioredis"; -import pino from "pino"; -import client from "prom-client"; -import { env } from "./config/env.js"; +import Redis from 'ioredis'; +import pino from 'pino'; +import client from 'prom-client'; +import { env } from './config/env.js'; // #246 — Redis cache layer for on-chain account state (Soroban RPC reads are // 200-600ms; this serves repeat GET /importers/:id reads from Redis instead). @@ -12,7 +12,7 @@ import { env } from "./config/env.js"; // appropriate for a plain cache client, and a queue-side connection issue // should not be able to take caching down (or vice versa) — same isolation // principle `db.ts` already applies with its own dedicated `Pool`. -const logger = pino({ name: "cache" }); +const logger = pino({ name: 'cache' }); // Every call site below already fails open on a Redis error (see // getCachedOnChainAccount/setCachedOnChainAccount/invalidateOnChainAccount), @@ -35,20 +35,20 @@ const redis = new Redis(env.REDIS_URL, { // fatal and crashes the process. Cache reads/writes below already fail open, // so a Redis outage should only cost latency (live RPC fallback), never // bring the API down. -redis.on("error", (err) => { - logger.error({ err }, "Redis cache connection error"); +redis.on('error', (err) => { + logger.error({ err }, 'Redis cache connection error'); }); export const cacheOperationsTotal = new client.Counter({ - name: "cache_operations_total", - help: "Total number of on-chain account cache operations", - labelNames: ["operation", "result"], + name: 'cache_operations_total', + help: 'Total number of on-chain account cache operations', + labelNames: ['operation', 'result'], }); export const cacheOperationDurationSeconds = new client.Histogram({ - name: "cache_operation_duration_seconds", - help: "Duration of on-chain account cache operations in seconds", - labelNames: ["operation"], + name: 'cache_operation_duration_seconds', + help: 'Duration of on-chain account cache operations in seconds', + labelNames: ['operation'], buckets: [0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25], }); @@ -74,23 +74,23 @@ export function onChainAccountCacheKey(importerId: string): string { * read in both cases, so a cache outage degrades latency, not correctness. */ export async function getCachedOnChainAccount( - importerId: string, + importerId: string ): Promise { const key = onChainAccountCacheKey(importerId); - const endTimer = cacheOperationDurationSeconds.startTimer({ operation: "get" }); + const endTimer = cacheOperationDurationSeconds.startTimer({ operation: 'get' }); try { const raw = await redis.get(key); endTimer(); if (raw === null) { - cacheOperationsTotal.inc({ operation: "get", result: "miss" }); + cacheOperationsTotal.inc({ operation: 'get', result: 'miss' }); return null; } - cacheOperationsTotal.inc({ operation: "get", result: "hit" }); + cacheOperationsTotal.inc({ operation: 'get', result: 'hit' }); return JSON.parse(raw) as OnChainAccountView; } catch (err) { endTimer(); - cacheOperationsTotal.inc({ operation: "get", result: "error" }); - logger.warn({ err, key }, "cache get failed; falling back to live RPC read"); + cacheOperationsTotal.inc({ operation: 'get', result: 'error' }); + logger.warn({ err, key }, 'cache get failed; falling back to live RPC read'); return null; } } @@ -98,16 +98,16 @@ export async function getCachedOnChainAccount( /** Populates the cache after a live RPC read. Never throws — a failed write just means the next read misses too. */ export async function setCachedOnChainAccount( importerId: string, - value: OnChainAccountView, + value: OnChainAccountView ): Promise { const key = onChainAccountCacheKey(importerId); - const endTimer = cacheOperationDurationSeconds.startTimer({ operation: "set" }); + const endTimer = cacheOperationDurationSeconds.startTimer({ operation: 'set' }); try { - await redis.set(key, JSON.stringify(value), "EX", ON_CHAIN_ACCOUNT_TTL_SECONDS); - cacheOperationsTotal.inc({ operation: "set", result: "success" }); + await redis.set(key, JSON.stringify(value), 'EX', ON_CHAIN_ACCOUNT_TTL_SECONDS); + cacheOperationsTotal.inc({ operation: 'set', result: 'success' }); } catch (err) { - cacheOperationsTotal.inc({ operation: "set", result: "error" }); - logger.warn({ err, key }, "cache set failed; live data was already served to the caller"); + cacheOperationsTotal.inc({ operation: 'set', result: 'error' }); + logger.warn({ err, key }, 'cache set failed; live data was already served to the caller'); } finally { endTimer(); } @@ -122,13 +122,16 @@ export async function setCachedOnChainAccount( */ export async function invalidateOnChainAccount(importerId: string): Promise { const key = onChainAccountCacheKey(importerId); - const endTimer = cacheOperationDurationSeconds.startTimer({ operation: "del" }); + const endTimer = cacheOperationDurationSeconds.startTimer({ operation: 'del' }); try { await redis.del(key); - cacheOperationsTotal.inc({ operation: "del", result: "success" }); + cacheOperationsTotal.inc({ operation: 'del', result: 'success' }); } catch (err) { - cacheOperationsTotal.inc({ operation: "del", result: "error" }); - logger.error({ err, key }, "cache invalidation failed; stale data may be served until the 30s TTL expires"); + cacheOperationsTotal.inc({ operation: 'del', result: 'error' }); + logger.error( + { err, key }, + 'cache invalidation failed; stale data may be served until the 30s TTL expires' + ); } finally { endTimer(); } diff --git a/apps/api/src/constants/notification-kinds.ts b/apps/api/src/constants/notification-kinds.ts index 639e673..495a0a3 100644 --- a/apps/api/src/constants/notification-kinds.ts +++ b/apps/api/src/constants/notification-kinds.ts @@ -4,10 +4,10 @@ // notification row using the same, consistent set of strings, and gives // TypeScript callers compile-time checking that a raw string column can't. export const NOTIFICATION_KINDS = { - BOND_APPROVED: "bond_approved", - KYC_REJECTED: "kyc_rejected", - TARIFF_SPIKE: "tariff_spike", - EVENT_RECEIVED: "event_received", + BOND_APPROVED: 'bond_approved', + KYC_REJECTED: 'kyc_rejected', + TARIFF_SPIKE: 'tariff_spike', + EVENT_RECEIVED: 'event_received', } as const; export type NotificationKind = (typeof NOTIFICATION_KINDS)[keyof typeof NOTIFICATION_KINDS]; diff --git a/apps/api/src/db.ts b/apps/api/src/db.ts index 029c66c..1289ebc 100644 --- a/apps/api/src/db.ts +++ b/apps/api/src/db.ts @@ -96,7 +96,7 @@ const poolInterval = setInterval(() => { waitingAlertFired = false; } }, POOL_CHECK_INTERVAL_MS); -if (typeof poolInterval.unref === "function") { +if (typeof poolInterval.unref === 'function') { poolInterval.unref(); } @@ -199,13 +199,13 @@ export function getPoolStats(): PoolStats { // ── Schema migrations ───────────────────────────────────────────────────────── export async function migrate(): Promise { - const { runMigrations } = await import("./migrations/runner.js"); - await runMigrations("up"); + const { runMigrations } = await import('./migrations/runner.js'); + await runMigrations('up'); } export async function rollback(): Promise { - const { runMigrations } = await import("./migrations/runner.js"); - await runMigrations("rollback"); + const { runMigrations } = await import('./migrations/runner.js'); + await runMigrations('rollback'); await timedQuery( ` CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; @@ -871,7 +871,7 @@ export async function rollback(): Promise { undefined, 'migrate_schema' ); - logger.info("schema ready"); + logger.info('schema ready'); } // ── importer_metrics_mv (#251) ──────────────────────────────────────────────── @@ -952,9 +952,9 @@ export async function refreshImporterMetrics(): Promise { */ export async function refreshImporterMetricsView(): Promise { await timedQuery( - "REFRESH MATERIALIZED VIEW CONCURRENTLY importer_metrics", + 'REFRESH MATERIALIZED VIEW CONCURRENTLY importer_metrics', undefined, - "refresh_importer_metrics", + 'refresh_importer_metrics' ); } @@ -990,9 +990,7 @@ export interface ImporterReview { * view's LEFT JOIN produces a single row with all document_* fields NULL, * which this function filters out via the document_id IS NOT NULL check. */ -export async function getImporterReview( - importerId: string -): Promise { +export async function getImporterReview(importerId: string): Promise { const result = await timedQuery<{ importer_id: string; legal_name: string; @@ -1245,13 +1243,13 @@ export async function logAudit( actorUserId: string | null, action: string, targetId: string | null, - payload: Record | null, + payload: Record | null ): Promise { await timedQuery( `INSERT INTO audit_log (actor_user_id, action, target_id, payload) VALUES ($1, $2, $3, $4)`, [actorUserId, action, targetId, payload ? JSON.stringify(payload) : null], - "insert_audit_log", + 'insert_audit_log' ); } @@ -1262,12 +1260,16 @@ export async function logAudit( // existing rows — but routes that produce the underlying events, like // routes/importers.ts, do), mirroring how logAudit above is shared the same // way for audit_log. -export async function createNotification(userId: string, kind: string, message: string): Promise { +export async function createNotification( + userId: string, + kind: string, + message: string +): Promise { await timedQuery( `INSERT INTO notifications (user_id, kind, message) VALUES ($1, $2, $3)`, [userId, kind, message], - "insert_notification", + 'insert_notification' ); } @@ -1278,25 +1280,25 @@ export async function createRefreshToken( tokenHash: string, expiresAt: Date, userAgent?: string, - ipAddress?: string, + ipAddress?: string ): Promise { const result = await timedQuery<{ id: string }>( `INSERT INTO refresh_tokens (user_id, token_hash, expires_at, user_agent, ip_address) VALUES ($1, $2, $3, $4, $5) RETURNING id`, [userId, tokenHash, expiresAt, userAgent ?? null, ipAddress ?? null], - "insert_refresh_token", + 'insert_refresh_token' ); return result.rows[0]!.id; } export async function validateRefreshToken( - tokenHash: string, + tokenHash: string ): Promise<{ id: string; userId: string; expiresAt: Date } | null> { const result = await timedQuery<{ id: string; user_id: string; expires_at: Date }>( `SELECT id, user_id, expires_at FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > now()`, [tokenHash], - "validate_refresh_token", + 'validate_refresh_token' ); const row = result.rows[0]; if (!row) return null; @@ -1306,15 +1308,15 @@ export async function validateRefreshToken( export async function rotateRefreshToken( oldId: string, newTokenHash: string, - newExpiresAt: Date, + newExpiresAt: Date ): Promise { const result = await timedQuery<{ user_id: string }>( `UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1 RETURNING user_id`, [oldId], - "revoke_refresh_token", + 'revoke_refresh_token' ); const userId = result.rows[0]?.user_id; - if (!userId) throw new Error("refresh token not found"); + if (!userId) throw new Error('refresh token not found'); return createRefreshToken(userId, newTokenHash, newExpiresAt); } @@ -1324,7 +1326,7 @@ export async function revokeRefreshToken(tokenHash: string): Promise { `UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL`, [tokenHash], - "revoke_refresh_token", + 'revoke_refresh_token' ); return (result.rowCount ?? 0) > 0; } diff --git a/apps/api/src/docs/openapi.ts b/apps/api/src/docs/openapi.ts index 07669e3..21a6cd9 100644 --- a/apps/api/src/docs/openapi.ts +++ b/apps/api/src/docs/openapi.ts @@ -3,174 +3,184 @@ * Served as JSON at GET /docs/openapi.json and rendered via Swagger UI at GET /docs. */ export const openApiSpec = { - openapi: "3.1.0", + openapi: '3.1.0', info: { - title: "TariffShield API", - version: "0.1.0", + title: 'TariffShield API', + version: '0.1.0', description: - "Programmable customs-bond collateral on Stellar. US importers post yield-bearing USDC instead of dead-weight cash; a Soroban smart contract auto-tops-up the bond during tariff spikes.", - license: { name: "MIT" }, - contact: { name: "TariffShield", url: "https://github.com/vjuliaife/TariffShield" }, + 'Programmable customs-bond collateral on Stellar. US importers post yield-bearing USDC instead of dead-weight cash; a Soroban smart contract auto-tops-up the bond during tariff spikes.', + license: { name: 'MIT' }, + contact: { name: 'TariffShield', url: 'https://github.com/vjuliaife/TariffShield' }, }, servers: [ - { url: "http://localhost:3002", description: "Local development" }, - { url: "https://tariffshield-api.onrender.com", description: "Render production" }, + { url: 'http://localhost:3002', description: 'Local development' }, + { url: 'https://tariffshield-api.onrender.com', description: 'Render production' }, ], tags: [ - { name: "Auth", description: "Authentication and session management" }, - { name: "Importers", description: "Importer account lifecycle and on-chain collateral operations" }, - { name: "KYC", description: "Know-Your-Customer document submission and review" }, - { name: "Compliance", description: "AML/OFAC flags and periodic compliance reports (surety admin)" }, - { name: "Surety License", description: "Surety license submission and verification workflow" }, - { name: "Health", description: "Liveness and readiness probes" }, + { name: 'Auth', description: 'Authentication and session management' }, + { + name: 'Importers', + description: 'Importer account lifecycle and on-chain collateral operations', + }, + { name: 'KYC', description: 'Know-Your-Customer document submission and review' }, + { + name: 'Compliance', + description: 'AML/OFAC flags and periodic compliance reports (surety admin)', + }, + { name: 'Surety License', description: 'Surety license submission and verification workflow' }, + { name: 'Health', description: 'Liveness and readiness probes' }, ], components: { securitySchemes: { bearerAuth: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - description: "JWT issued by POST /auth/login. Required on all protected routes.", + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: 'JWT issued by POST /auth/login. Required on all protected routes.', }, }, schemas: { Error: { - type: "object", - required: ["error"], + type: 'object', + required: ['error'], properties: { - error: { type: "string", example: "invalid input" }, - details: { type: "array", items: { type: "object" } }, + error: { type: 'string', example: 'invalid input' }, + details: { type: 'array', items: { type: 'object' } }, }, }, User: { - type: "object", + type: 'object', properties: { - id: { type: "string", format: "uuid" }, - email: { type: "string", format: "email" }, - role: { type: "string", enum: ["importer", "surety_admin"] }, + id: { type: 'string', format: 'uuid' }, + email: { type: 'string', format: 'email' }, + role: { type: 'string', enum: ['importer', 'surety_admin'] }, }, }, Importer: { - type: "object", + type: 'object', properties: { - id: { type: "string", format: "uuid" }, - legalName: { type: "string" }, - ein: { type: "string", nullable: true }, - bondId: { type: "integer" }, - stellarAddress: { type: "string" }, - createdAt: { type: "string", format: "date-time" }, + id: { type: 'string', format: 'uuid' }, + legalName: { type: 'string' }, + ein: { type: 'string', nullable: true }, + bondId: { type: 'integer' }, + stellarAddress: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' }, }, }, CollateralStatus: { - type: "object", + type: 'object', properties: { - collateralBalance: { type: "string", description: "Current on-chain collateral (stroops)" }, - requiredCollateral: { type: "string", description: "Required threshold (stroops)" }, - reserveBalance: { type: "string" }, - shortfall: { type: "string", description: "max(0, required − current)" }, - isStale: { type: "boolean" }, - accountFrozen: { type: "boolean" }, + collateralBalance: { + type: 'string', + description: 'Current on-chain collateral (stroops)', + }, + requiredCollateral: { type: 'string', description: 'Required threshold (stroops)' }, + reserveBalance: { type: 'string' }, + shortfall: { type: 'string', description: 'max(0, required − current)' }, + isStale: { type: 'boolean' }, + accountFrozen: { type: 'boolean' }, }, }, TxResult: { - type: "object", + type: 'object', properties: { - txHash: { type: "string" }, - explorerUrl: { type: "string" }, - collateralBalance: { type: "string" }, + txHash: { type: 'string' }, + explorerUrl: { type: 'string' }, + collateralBalance: { type: 'string' }, }, }, ComplianceFlag: { - type: "object", + type: 'object', properties: { - id: { type: "string", format: "uuid" }, - importerId: { type: "string", format: "uuid" }, - flagType: { type: "string" }, - severity: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, - status: { type: "string", enum: ["OPEN", "RESOLVED", "ESCALATED"] }, - details: { type: "object" }, - createdAt: { type: "string", format: "date-time" }, + id: { type: 'string', format: 'uuid' }, + importerId: { type: 'string', format: 'uuid' }, + flagType: { type: 'string' }, + severity: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, + status: { type: 'string', enum: ['OPEN', 'RESOLVED', 'ESCALATED'] }, + details: { type: 'object' }, + createdAt: { type: 'string', format: 'date-time' }, }, }, SuretyLicenseStatus: { - type: "object", + type: 'object', properties: { - status: { type: "string", enum: ["pending", "verified", "rejected"] }, - naicNumber: { type: "string", nullable: true }, - statesLicensed: { type: "array", items: { type: "string" } }, - reviewedAt: { type: "string", format: "date-time", nullable: true }, + status: { type: 'string', enum: ['pending', 'verified', 'rejected'] }, + naicNumber: { type: 'string', nullable: true }, + statesLicensed: { type: 'array', items: { type: 'string' } }, + reviewedAt: { type: 'string', format: 'date-time', nullable: true }, }, }, }, }, security: [{ bearerAuth: [] }], paths: { - "/health": { + '/health': { get: { - tags: ["Health"], - summary: "Deep health check", - description: "Checks Postgres and Soroban RPC connectivity. Returns 503 if any dependency is unhealthy.", + tags: ['Health'], + summary: 'Deep health check', + description: + 'Checks Postgres and Soroban RPC connectivity. Returns 503 if any dependency is unhealthy.', security: [], responses: { 200: { - description: "All dependencies healthy", + description: 'All dependencies healthy', content: { - "application/json": { + 'application/json': { schema: { - type: "object", + type: 'object', properties: { - status: { type: "string", example: "ok" }, - db: { type: "string", example: "ok" }, - soroban: { type: "string", example: "ok" }, - contractId: { type: "string" }, - network: { type: "string" }, + status: { type: 'string', example: 'ok' }, + db: { type: 'string', example: 'ok' }, + soroban: { type: 'string', example: 'ok' }, + contractId: { type: 'string' }, + network: { type: 'string' }, }, }, }, }, }, - 503: { description: "One or more dependencies degraded" }, + 503: { description: 'One or more dependencies degraded' }, }, }, }, - "/health/live": { + '/health/live': { get: { - tags: ["Health"], - summary: "Liveness probe", - description: "Returns 200 OK as long as the Node.js process is running.", + tags: ['Health'], + summary: 'Liveness probe', + description: 'Returns 200 OK as long as the Node.js process is running.', security: [], - responses: { 200: { description: "Process alive" } }, + responses: { 200: { description: 'Process alive' } }, }, }, - "/health/ready": { + '/health/ready': { get: { - tags: ["Health"], - summary: "Readiness probe", - description: "Returns 200 only when Postgres and Soroban RPC are reachable.", + tags: ['Health'], + summary: 'Readiness probe', + description: 'Returns 200 only when Postgres and Soroban RPC are reachable.', security: [], responses: { - 200: { description: "Service ready to handle traffic" }, - 503: { description: "Service not yet ready" }, + 200: { description: 'Service ready to handle traffic' }, + 503: { description: 'Service not yet ready' }, }, }, }, - "/auth/signup": { + '/auth/signup': { post: { - tags: ["Auth"], - summary: "Create account", + tags: ['Auth'], + summary: 'Create account', security: [], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["email", "password"], + type: 'object', + required: ['email', 'password'], properties: { - email: { type: "string", format: "email" }, - password: { type: "string", minLength: 8 }, - role: { type: "string", enum: ["importer", "surety_admin"], default: "importer" }, - privacyPolicyVersionId: { type: "string" }, + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 8 }, + role: { type: 'string', enum: ['importer', 'surety_admin'], default: 'importer' }, + privacyPolicyVersionId: { type: 'string' }, }, }, }, @@ -178,39 +188,42 @@ export const openApiSpec = { }, responses: { 201: { - description: "Account created; JWT issued", + description: 'Account created; JWT issued', content: { - "application/json": { + 'application/json': { schema: { - type: "object", + type: 'object', properties: { - token: { type: "string" }, - user: { $ref: "#/components/schemas/User" }, + token: { type: 'string' }, + user: { $ref: '#/components/schemas/User' }, }, }, }, }, }, - 400: { description: "Validation error", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } }, - 409: { description: "Email already registered" }, + 400: { + description: 'Validation error', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } }, + }, + 409: { description: 'Email already registered' }, }, }, }, - "/auth/login": { + '/auth/login': { post: { - tags: ["Auth"], - summary: "Authenticate", + tags: ['Auth'], + summary: 'Authenticate', security: [], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["email", "password"], + type: 'object', + required: ['email', 'password'], properties: { - email: { type: "string", format: "email" }, - password: { type: "string" }, + email: { type: 'string', format: 'email' }, + password: { type: 'string' }, }, }, }, @@ -218,111 +231,124 @@ export const openApiSpec = { }, responses: { 200: { - description: "JWT issued", + description: 'JWT issued', content: { - "application/json": { - schema: { type: "object", properties: { token: { type: "string" } } }, + 'application/json': { + schema: { type: 'object', properties: { token: { type: 'string' } } }, }, }, }, - 401: { description: "Invalid credentials" }, - 429: { description: "Rate-limited (20 attempts per 15 min)" }, + 401: { description: 'Invalid credentials' }, + 429: { description: 'Rate-limited (20 attempts per 15 min)' }, }, }, }, - "/auth/me": { + '/auth/me': { get: { - tags: ["Auth"], - summary: "Current user profile", + tags: ['Auth'], + summary: 'Current user profile', responses: { 200: { - description: "Authenticated user", - content: { "application/json": { schema: { $ref: "#/components/schemas/User" } } }, + description: 'Authenticated user', + content: { 'application/json': { schema: { $ref: '#/components/schemas/User' } } }, }, - 401: { description: "Not authenticated" }, + 401: { description: 'Not authenticated' }, }, }, }, - "/auth/saml/metadata": { + '/auth/saml/metadata': { get: { - tags: ["Auth"], - summary: "SAML SP metadata", - description: "Returns the SAML Service Provider metadata XML for IdP configuration.", + tags: ['Auth'], + summary: 'SAML SP metadata', + description: 'Returns the SAML Service Provider metadata XML for IdP configuration.', security: [], - responses: { 200: { description: "XML metadata", content: { "application/xml": {} } } }, + responses: { 200: { description: 'XML metadata', content: { 'application/xml': {} } } }, }, }, - "/auth/saml/{provider}/login": { + '/auth/saml/{provider}/login': { get: { - tags: ["Auth"], - summary: "Initiate SAML SSO", + tags: ['Auth'], + summary: 'Initiate SAML SSO', security: [], - parameters: [{ name: "provider", in: "path", required: true, schema: { type: "string" } }], - responses: { 302: { description: "Redirect to IdP" } }, + parameters: [{ name: 'provider', in: 'path', required: true, schema: { type: 'string' } }], + responses: { 302: { description: 'Redirect to IdP' } }, }, }, - "/auth/saml/{provider}/callback": { + '/auth/saml/{provider}/callback': { post: { - tags: ["Auth"], - summary: "SAML assertion callback", + tags: ['Auth'], + summary: 'SAML assertion callback', security: [], - parameters: [{ name: "provider", in: "path", required: true, schema: { type: "string" } }], + parameters: [{ name: 'provider', in: 'path', required: true, schema: { type: 'string' } }], responses: { - 200: { description: "JWT issued after successful assertion" }, - 401: { description: "SAML assertion invalid or user not found" }, + 200: { description: 'JWT issued after successful assertion' }, + 401: { description: 'SAML assertion invalid or user not found' }, }, }, }, - "/importers": { + '/importers': { post: { - tags: ["Importers"], - summary: "Register importer", - description: "Creates an importer record, generates a Stellar keypair, funds via Friendbot, and calls `register_importer` on-chain. Role must be `importer`.", + tags: ['Importers'], + summary: 'Register importer', + description: + 'Creates an importer record, generates a Stellar keypair, funds via Friendbot, and calls `register_importer` on-chain. Role must be `importer`.', requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["legalName", "bondId", "initialRequiredCollateral"], + type: 'object', + required: ['legalName', 'bondId', 'initialRequiredCollateral'], properties: { - legalName: { type: "string" }, - ein: { type: "string" }, - bondId: { type: "integer" }, - initialRequiredCollateral: { type: "string", pattern: "^\\d+$", description: "In stroops" }, + legalName: { type: 'string' }, + ein: { type: 'string' }, + bondId: { type: 'integer' }, + initialRequiredCollateral: { + type: 'string', + pattern: '^\\d+$', + description: 'In stroops', + }, }, }, }, }, }, responses: { - 201: { description: "Importer registered", content: { "application/json": { schema: { $ref: "#/components/schemas/Importer" } } } }, - 400: { description: "Validation error" }, - 403: { description: "Wrong role or OFAC/AML block" }, - 409: { description: "Importer already registered for this user" }, + 201: { + description: 'Importer registered', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Importer' } } }, + }, + 400: { description: 'Validation error' }, + 403: { description: 'Wrong role or OFAC/AML block' }, + 409: { description: 'Importer already registered for this user' }, }, }, get: { - tags: ["Importers"], - summary: "List importers", - description: "Paginated list of all importers. Surety admin sees all; importer sees only their own record.", + tags: ['Importers'], + summary: 'List importers', + description: + 'Paginated list of all importers. Surety admin sees all; importer sees only their own record.', parameters: [ - { name: "page", in: "query", schema: { type: "integer", default: 1 } }, - { name: "limit", in: "query", schema: { type: "integer", default: 20, maximum: 100 } }, - { name: "status", in: "query", schema: { type: "string", enum: ["active", "frozen", "all"] } }, + { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, + { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 100 } }, + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['active', 'frozen', 'all'] }, + }, ], responses: { 200: { - description: "Importer list", + description: 'Importer list', content: { - "application/json": { + 'application/json': { schema: { - type: "object", + type: 'object', properties: { - importers: { type: "array", items: { $ref: "#/components/schemas/Importer" } }, - total: { type: "integer" }, - page: { type: "integer" }, - limit: { type: "integer" }, + importers: { type: 'array', items: { $ref: '#/components/schemas/Importer' } }, + total: { type: 'integer' }, + page: { type: 'integer' }, + limit: { type: 'integer' }, }, }, }, @@ -331,369 +357,466 @@ export const openApiSpec = { }, }, }, - "/importers/{id}": { + '/importers/{id}': { get: { - tags: ["Importers"], - summary: "Get importer", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Get importer', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], responses: { - 200: { description: "Importer record", content: { "application/json": { schema: { $ref: "#/components/schemas/Importer" } } } }, - 404: { description: "Importer not found" }, + 200: { + description: 'Importer record', + content: { 'application/json': { schema: { $ref: '#/components/schemas/Importer' } } }, + }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/collateral-status": { + '/importers/{id}/collateral-status': { get: { - tags: ["Importers"], - summary: "Live collateral health", - description: "Returns current vs required collateral, reserve balance, shortfall, and staleness flag from the on-chain account state.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Live collateral health', + description: + 'Returns current vs required collateral, reserve balance, shortfall, and staleness flag from the on-chain account state.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], responses: { - 200: { description: "Collateral status", content: { "application/json": { schema: { $ref: "#/components/schemas/CollateralStatus" } } } }, - 404: { description: "Importer not found" }, + 200: { + description: 'Collateral status', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/CollateralStatus' } }, + }, + }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/upload-tariff-csv": { + '/importers/{id}/upload-tariff-csv': { post: { - tags: ["Importers"], - summary: "Upload tariff schedule CSV", - description: "Parses a CBP-format tariff CSV to recompute `required_collateral`. Validates column headers and duty rates.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Upload tariff schedule CSV', + description: + 'Parses a CBP-format tariff CSV to recompute `required_collateral`. Validates column headers and duty rates.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, - content: { "text/csv": { schema: { type: "string" } } }, + content: { 'text/csv': { schema: { type: 'string' } } }, }, responses: { - 200: { description: "Tariff data ingested and required collateral updated" }, - 400: { description: "Invalid CSV format or missing required columns" }, - 404: { description: "Importer not found" }, + 200: { description: 'Tariff data ingested and required collateral updated' }, + 400: { description: 'Invalid CSV format or missing required columns' }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/deposit": { + '/importers/{id}/deposit': { post: { - tags: ["Importers"], - summary: "Deposit collateral", - description: "Builds and submits an on-chain `deposit_collateral` Soroban transaction. Transfers USDC from the importer's wallet into the contract escrow.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Deposit collateral', + description: + "Builds and submits an on-chain `deposit_collateral` Soroban transaction. Transfers USDC from the importer's wallet into the contract escrow.", + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["amount"], - properties: { amount: { type: "string", pattern: "^\\d+$", description: "Amount in stroops" } }, + type: 'object', + required: ['amount'], + properties: { + amount: { type: 'string', pattern: '^\\d+$', description: 'Amount in stroops' }, + }, }, }, }, }, responses: { - 200: { description: "Deposit submitted", content: { "application/json": { schema: { $ref: "#/components/schemas/TxResult" } } } }, - 400: { description: "Invalid amount or insufficient balance" }, - 404: { description: "Importer not found" }, + 200: { + description: 'Deposit submitted', + content: { 'application/json': { schema: { $ref: '#/components/schemas/TxResult' } } }, + }, + 400: { description: 'Invalid amount or insufficient balance' }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/auto-top-up": { + '/importers/{id}/auto-top-up': { post: { - tags: ["Importers"], - summary: "Trigger auto top-up", - description: "Calls `auto_top_up` on-chain — moves `min(shortfall, reserve)` from reserve to collateral. Can be called by anyone when a shortfall exists.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Trigger auto top-up', + description: + 'Calls `auto_top_up` on-chain — moves `min(shortfall, reserve)` from reserve to collateral. Can be called by anyone when a shortfall exists.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], responses: { - 200: { description: "Top-up submitted", content: { "application/json": { schema: { $ref: "#/components/schemas/TxResult" } } } }, - 400: { description: "No shortfall or insufficient reserve" }, - 404: { description: "Importer not found" }, + 200: { + description: 'Top-up submitted', + content: { 'application/json': { schema: { $ref: '#/components/schemas/TxResult' } } }, + }, + 400: { description: 'No shortfall or insufficient reserve' }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/withdraw": { + '/importers/{id}/withdraw': { post: { - tags: ["Importers"], - summary: "Withdraw collateral", - description: "Returns escrowed USDC to the importer after verifying the bond is in good standing.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Withdraw collateral', + description: + 'Returns escrowed USDC to the importer after verifying the bond is in good standing.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["amount"], - properties: { amount: { type: "string" } }, + type: 'object', + required: ['amount'], + properties: { amount: { type: 'string' } }, }, }, }, }, responses: { - 200: { description: "Withdrawal submitted", content: { "application/json": { schema: { $ref: "#/components/schemas/TxResult" } } } }, - 400: { description: "Insufficient collateral or bond not in good standing" }, - 404: { description: "Importer not found" }, + 200: { + description: 'Withdrawal submitted', + content: { 'application/json': { schema: { $ref: '#/components/schemas/TxResult' } } }, + }, + 400: { description: 'Insufficient collateral or bond not in good standing' }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/accrue-yield": { + '/importers/{id}/accrue-yield': { post: { - tags: ["Importers"], - summary: "Accrue yield (surety admin)", - description: "Triggers `accrue_yield` on-chain for the importer. Requires `surety_admin` role with a verified license.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Accrue yield (surety admin)', + description: + 'Triggers `accrue_yield` on-chain for the importer. Requires `surety_admin` role with a verified license.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["yieldAmount"], - properties: { yieldAmount: { type: "string", description: "Yield amount in stroops" } }, + type: 'object', + required: ['yieldAmount'], + properties: { + yieldAmount: { type: 'string', description: 'Yield amount in stroops' }, + }, }, }, }, }, responses: { - 200: { description: "Yield accrued on-chain" }, - 403: { description: "Role or license verification check failed" }, - 404: { description: "Importer not found" }, + 200: { description: 'Yield accrued on-chain' }, + 403: { description: 'Role or license verification check failed' }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/clawback": { + '/importers/{id}/clawback': { post: { - tags: ["Importers"], - summary: "Emergency clawback (surety admin)", - description: "Executes the `clawback` Soroban entrypoint. Drains both collateral and reserve buckets to the surety wallet and freezes the importer account. Irreversible. Requires `surety_admin` role with a verified license.", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Importers'], + summary: 'Emergency clawback (surety admin)', + description: + 'Executes the `clawback` Soroban entrypoint. Drains both collateral and reserve buckets to the surety wallet and freezes the importer account. Irreversible. Requires `surety_admin` role with a verified license.', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["reason"], + type: 'object', + required: ['reason'], properties: { - reason: { type: "string", description: "Legal justification recorded in the audit log" }, + reason: { + type: 'string', + description: 'Legal justification recorded in the audit log', + }, }, }, }, }, }, responses: { - 200: { description: "Clawback executed; audit log entry created" }, - 403: { description: "Role or license check failed" }, - 404: { description: "Importer not found" }, - 409: { description: "Account already frozen" }, + 200: { description: 'Clawback executed; audit log entry created' }, + 403: { description: 'Role or license check failed' }, + 404: { description: 'Importer not found' }, + 409: { description: 'Account already frozen' }, }, }, }, - "/importers/{id}/kyc": { + '/importers/{id}/kyc': { post: { - tags: ["KYC"], - summary: "Submit KYC documents", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['KYC'], + summary: 'Submit KYC documents', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["documentType", "documentNumber"], + type: 'object', + required: ['documentType', 'documentNumber'], properties: { - documentType: { type: "string", enum: ["passport", "ein_letter", "articles_of_incorporation"] }, - documentNumber: { type: "string" }, - issuingCountry: { type: "string" }, + documentType: { + type: 'string', + enum: ['passport', 'ein_letter', 'articles_of_incorporation'], + }, + documentNumber: { type: 'string' }, + issuingCountry: { type: 'string' }, }, }, }, }, }, responses: { - 201: { description: "KYC record created" }, - 404: { description: "Importer not found" }, + 201: { description: 'KYC record created' }, + 404: { description: 'Importer not found' }, }, }, get: { - tags: ["KYC"], - summary: "Get KYC status", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['KYC'], + summary: 'Get KYC status', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], responses: { - 200: { description: "KYC status and submitted document list" }, - 404: { description: "Importer not found" }, + 200: { description: 'KYC status and submitted document list' }, + 404: { description: 'Importer not found' }, }, }, }, - "/importers/{id}/kyc/{docId}/download": { + '/importers/{id}/kyc/{docId}/download': { get: { - tags: ["KYC"], - summary: "Download KYC document", + tags: ['KYC'], + summary: 'Download KYC document', parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }, - { name: "docId", in: "path", required: true, schema: { type: "string", format: "uuid" } }, + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + { name: 'docId', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, ], responses: { - 200: { description: "Document binary" }, - 404: { description: "Document not found" }, + 200: { description: 'Document binary' }, + 404: { description: 'Document not found' }, }, }, }, - "/compliance/dashboard": { + '/compliance/dashboard': { get: { - tags: ["Compliance"], - summary: "Compliance dashboard", - description: "Aggregated AML/OFAC flag counts and recent incidents. Surety admin only.", + tags: ['Compliance'], + summary: 'Compliance dashboard', + description: 'Aggregated AML/OFAC flag counts and recent incidents. Surety admin only.', responses: { - 200: { description: "Dashboard data" }, - 403: { description: "Insufficient role" }, + 200: { description: 'Dashboard data' }, + 403: { description: 'Insufficient role' }, }, }, delete: { - tags: ["Compliance"], - summary: "Clear dashboard cache", - responses: { 204: { description: "Cache cleared" }, 403: { description: "Insufficient role" } }, + tags: ['Compliance'], + summary: 'Clear dashboard cache', + responses: { + 204: { description: 'Cache cleared' }, + 403: { description: 'Insufficient role' }, + }, }, }, - "/compliance/flags": { + '/compliance/flags': { get: { - tags: ["Compliance"], - summary: "List compliance flags", - description: "Paginated list of AML/OFAC/sanctions flags. Surety admin only.", + tags: ['Compliance'], + summary: 'List compliance flags', + description: 'Paginated list of AML/OFAC/sanctions flags. Surety admin only.', parameters: [ - { name: "status", in: "query", schema: { type: "string", enum: ["OPEN", "RESOLVED", "ESCALATED"] } }, - { name: "severity", in: "query", schema: { type: "string", enum: ["LOW", "MEDIUM", "HIGH", "CRITICAL"] } }, - { name: "page", in: "query", schema: { type: "integer", default: 1 } }, + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['OPEN', 'RESOLVED', 'ESCALATED'] }, + }, + { + name: 'severity', + in: 'query', + schema: { type: 'string', enum: ['LOW', 'MEDIUM', 'HIGH', 'CRITICAL'] }, + }, + { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, ], responses: { 200: { - description: "Flag list", - content: { "application/json": { schema: { type: "object", properties: { flags: { type: "array", items: { $ref: "#/components/schemas/ComplianceFlag" } }, total: { type: "integer" } } } } }, + description: 'Flag list', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + flags: { + type: 'array', + items: { $ref: '#/components/schemas/ComplianceFlag' }, + }, + total: { type: 'integer' }, + }, + }, + }, + }, }, - 403: { description: "Insufficient role" }, + 403: { description: 'Insufficient role' }, }, }, }, - "/compliance/flags/{id}/resolve": { + '/compliance/flags/{id}/resolve': { post: { - tags: ["Compliance"], - summary: "Resolve a compliance flag", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Compliance'], + summary: 'Resolve a compliance flag', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["resolution"], + type: 'object', + required: ['resolution'], properties: { - resolution: { type: "string" }, - newStatus: { type: "string", enum: ["RESOLVED", "ESCALATED"] }, + resolution: { type: 'string' }, + newStatus: { type: 'string', enum: ['RESOLVED', 'ESCALATED'] }, }, }, }, }, }, responses: { - 200: { description: "Flag updated" }, - 403: { description: "Insufficient role" }, - 404: { description: "Flag not found" }, + 200: { description: 'Flag updated' }, + 403: { description: 'Insufficient role' }, + 404: { description: 'Flag not found' }, }, }, }, - "/compliance/reports": { + '/compliance/reports': { get: { - tags: ["Compliance"], - summary: "List compliance reports", + tags: ['Compliance'], + summary: 'List compliance reports', responses: { - 200: { description: "Generated compliance report list" }, - 403: { description: "Insufficient role" }, + 200: { description: 'Generated compliance report list' }, + 403: { description: 'Insufficient role' }, }, }, }, - "/compliance/reports/{id}/download": { + '/compliance/reports/{id}/download': { get: { - tags: ["Compliance"], - summary: "Download compliance report", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Compliance'], + summary: 'Download compliance report', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], responses: { - 200: { description: "Report file (PDF or CSV)" }, - 404: { description: "Report not found" }, + 200: { description: 'Report file (PDF or CSV)' }, + 404: { description: 'Report not found' }, }, }, }, - "/surety-license/submit": { + '/surety-license/submit': { post: { - tags: ["Surety License"], - summary: "Submit license credentials", - description: "Surety admin submits NAIC number and state licensing data for platform review.", + tags: ['Surety License'], + summary: 'Submit license credentials', + description: + 'Surety admin submits NAIC number and state licensing data for platform review.', requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["naicNumber", "statesLicensed"], + type: 'object', + required: ['naicNumber', 'statesLicensed'], properties: { - naicNumber: { type: "string" }, - statesLicensed: { type: "array", items: { type: "string", pattern: "^[A-Z]{2}$" } }, - licenseExpiryDate: { type: "string", format: "date" }, + naicNumber: { type: 'string' }, + statesLicensed: { + type: 'array', + items: { type: 'string', pattern: '^[A-Z]{2}$' }, + }, + licenseExpiryDate: { type: 'string', format: 'date' }, }, }, }, }, }, responses: { - 201: { description: "License record created; pending review" }, - 403: { description: "Not a surety admin account" }, + 201: { description: 'License record created; pending review' }, + 403: { description: 'Not a surety admin account' }, }, }, }, - "/surety-license/status": { + '/surety-license/status': { get: { - tags: ["Surety License"], - summary: "Get own license status", + tags: ['Surety License'], + summary: 'Get own license status', responses: { - 200: { description: "License verification status", content: { "application/json": { schema: { $ref: "#/components/schemas/SuretyLicenseStatus" } } } }, - 403: { description: "Not a surety admin account" }, + 200: { + description: 'License verification status', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/SuretyLicenseStatus' } }, + }, + }, + 403: { description: 'Not a surety admin account' }, }, }, }, - "/surety-license/{id}/review": { + '/surety-license/{id}/review': { put: { - tags: ["Surety License"], - summary: "Review license (platform admin)", - parameters: [{ name: "id", in: "path", required: true, schema: { type: "string", format: "uuid" } }], + tags: ['Surety License'], + summary: 'Review license (platform admin)', + parameters: [ + { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, + ], requestBody: { required: true, content: { - "application/json": { + 'application/json': { schema: { - type: "object", - required: ["decision"], + type: 'object', + required: ['decision'], properties: { - decision: { type: "string", enum: ["verified", "rejected"] }, - notes: { type: "string" }, + decision: { type: 'string', enum: ['verified', 'rejected'] }, + notes: { type: 'string' }, }, }, }, }, }, responses: { - 200: { description: "License status updated" }, - 404: { description: "License record not found" }, + 200: { description: 'License status updated' }, + 404: { description: 'License record not found' }, }, }, }, - "/surety-license": { + '/surety-license': { get: { - tags: ["Surety License"], - summary: "List all license records (platform admin)", + tags: ['Surety License'], + summary: 'List all license records (platform admin)', parameters: [ - { name: "status", in: "query", schema: { type: "string", enum: ["pending", "verified", "rejected"] } }, - { name: "page", in: "query", schema: { type: "integer", default: 1 } }, + { + name: 'status', + in: 'query', + schema: { type: 'string', enum: ['pending', 'verified', 'rejected'] }, + }, + { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, ], responses: { - 200: { description: "Paginated license records" }, - 403: { description: "Not a platform admin" }, + 200: { description: 'Paginated license records' }, + 403: { description: 'Not a platform admin' }, }, }, }, diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 51b23c9..8b356ef 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,57 +1,54 @@ -import "./tracing.js"; -import "./instrument.js"; -import * as Sentry from "@sentry/node"; -import express, { type NextFunction, type Request, type Response } from "express"; -import { openApiSpec } from "./docs/openapi.js"; -import compression from "compression"; -import cors from "cors"; -import helmet from "helmet"; -import rateLimit from "express-rate-limit"; -import client from "prom-client"; -import { env, isProduction } from "./config/env.js"; -import { migrate } from "./db.js"; -import { authRouter } from "./routes/auth.js"; -import { importersRouter } from "./routes/importers.js"; -import { adminRouter } from "./routes/admin.js"; -import { privacyRouter } from "./routes/privacy.js"; -import { tosRouter } from "./routes/tos.js"; -import { bondSignaturesRouter, bondWebhookRouter } from "./routes/bond-signatures.js"; -import { startIndexer } from "./indexer.js"; -import { ping } from "./db.js"; -import { pingRpc } from "./stellar.js"; -import { startReconciliationJob } from "./jobs/reconcile-balances.js"; -import { startOracleMonitor } from "./services/oracle-monitor.js"; -import { startOracleEventListener } from "./services/oracle-event-listener.js"; -import { privacyReacceptanceGate } from "./auth.js"; -import { complianceRouter } from "./routes/compliance.js"; -import { kycRouter } from "./routes/kyc.js"; -import { startComplianceReportScheduler } from "./jobs/compliance-report.js"; -import { startImporterMetricsScheduler } from "./jobs/refresh-importer-metrics.js"; -import { startContractEventsPartitionScheduler } from "./jobs/ensure-contract-events-partitions.js"; -import { suretyLicenseRouter } from "./routes/surety-license.js"; -import { regulatoryRouter } from "./routes/regulatory.js"; -import { healthRouter } from "./routes/health.js"; -import { httpLogger, logger } from "./lib/logger.js"; -import { notificationsRouter } from "./routes/notifications.js"; +import './tracing.js'; +import './instrument.js'; +import * as Sentry from '@sentry/node'; +import express, { type NextFunction, type Request, type Response } from 'express'; +import { openApiSpec } from './docs/openapi.js'; +import compression from 'compression'; +import cors from 'cors'; +import helmet from 'helmet'; +import rateLimit from 'express-rate-limit'; +import client from 'prom-client'; +import { env, isProduction } from './config/env.js'; +import { migrate } from './db.js'; +import { authRouter } from './routes/auth.js'; +import { importersRouter } from './routes/importers.js'; +import { adminRouter } from './routes/admin.js'; +import { privacyRouter } from './routes/privacy.js'; +import { tosRouter } from './routes/tos.js'; +import { bondSignaturesRouter, bondWebhookRouter } from './routes/bond-signatures.js'; +import { startIndexer } from './indexer.js'; +import { startReconciliationJob } from './jobs/reconcile-balances.js'; +import { startOracleMonitor } from './services/oracle-monitor.js'; +import { startOracleEventListener } from './services/oracle-event-listener.js'; +import { complianceRouter } from './routes/compliance.js'; +import { kycRouter } from './routes/kyc.js'; +import { startComplianceReportScheduler } from './jobs/compliance-report.js'; +import { startImporterMetricsScheduler } from './jobs/refresh-importer-metrics.js'; +import { startContractEventsPartitionScheduler } from './jobs/ensure-contract-events-partitions.js'; +import { suretyLicenseRouter } from './routes/surety-license.js'; +import { regulatoryRouter } from './routes/regulatory.js'; +import { healthRouter } from './routes/health.js'; +import { httpLogger, logger } from './lib/logger.js'; +import { notificationsRouter } from './routes/notifications.js'; const app = express(); app.use(httpLogger); export const httpRequestsTotal = new client.Counter({ - name: "http_requests_total", - help: "Total number of HTTP requests processed", - labelNames: ["method", "route", "status_code"], + name: 'http_requests_total', + help: 'Total number of HTTP requests processed', + labelNames: ['method', 'route', 'status_code'], }); export const httpRequestDurationSeconds = new client.Histogram({ - name: "http_request_duration_seconds", - help: "Duration of HTTP requests in seconds", - labelNames: ["method", "route", "status_code"], + name: 'http_request_duration_seconds', + help: 'Duration of HTTP requests in seconds', + labelNames: ['method', 'route', 'status_code'], buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5], }); function normalizeIp(ip: string): { version: 4 | 6; normalized: string } { - if (ip.startsWith("::ffff:")) { + if (ip.startsWith('::ffff:')) { const ipv4 = ip.substring(7); if (isIpv4(ipv4)) { return { version: 4, normalized: ipv4 }; @@ -64,7 +61,7 @@ function normalizeIp(ip: string): { version: 4 | 6; normalized: string } { } function isIpv4(ip: string): boolean { - const parts = ip.split("."); + const parts = ip.split('.'); if (parts.length !== 4) return false; return parts.every((part) => { const num = parseInt(part, 10); @@ -73,7 +70,7 @@ function isIpv4(ip: string): boolean { } function matchIpv4(ip: string, cidr: string): boolean { - const [range, bitsStr] = cidr.split("/"); + const [range, bitsStr] = cidr.split('/'); if (!range) return false; const bits = bitsStr ? parseInt(bitsStr, 10) : 32; if (!isIpv4(range) || bits < 0 || bits > 32) return false; @@ -87,32 +84,32 @@ function matchIpv4(ip: string, cidr: string): boolean { } function ipv4ToInt(ip: string): number { - const parts = ip.split("."); - const p0 = parseInt(parts[0] || "0", 10); - const p1 = parseInt(parts[1] || "0", 10); - const p2 = parseInt(parts[2] || "0", 10); - const p3 = parseInt(parts[3] || "0", 10); + const parts = ip.split('.'); + const p0 = parseInt(parts[0] || '0', 10); + const p1 = parseInt(parts[1] || '0', 10); + const p2 = parseInt(parts[2] || '0', 10); + const p3 = parseInt(parts[3] || '0', 10); return ((p0 << 24) | (p1 << 16) | (p2 << 8) | p3) >>> 0; } function parseIpv6(ip: string): number[] | null { - const parts = ip.split("::"); + const parts = ip.split('::'); if (parts.length > 2) return null; - const left = parts[0] ? parts[0].split(":") : []; - const right = parts[1] ? parts[1].split(":") : []; + const left = parts[0] ? parts[0].split(':') : []; + const right = parts[1] ? parts[1].split(':') : []; const missing = 8 - (left.length + right.length); if (missing < 0) return null; - const middle = new Array(missing).fill("0"); + const middle = new Array(missing).fill('0'); const hexParts = [...left, ...middle, ...right]; if (hexParts.length !== 8) return null; const result: number[] = []; for (const part of hexParts) { - if (part === "") { + if (part === '') { result.push(0); } else { const val = parseInt(part, 16); @@ -124,7 +121,7 @@ function parseIpv6(ip: string): number[] | null { } function matchIpv6(ip: string, cidr: string): boolean { - const [range, bitsStr] = cidr.split("/"); + const [range, bitsStr] = cidr.split('/'); if (!range) return false; const bits = bitsStr ? parseInt(bitsStr, 10) : 128; @@ -154,10 +151,10 @@ function matchIpv6(ip: string, cidr: string): boolean { function isIpAllowed(clientIp: string, allowedCidr?: string): boolean { const { version, normalized } = normalizeIp(clientIp.trim()); - if (version === 4 && normalized === "127.0.0.1") { + if (version === 4 && normalized === '127.0.0.1') { return true; } - if (version === 6 && (normalized === "::1" || normalized === "0:0:0:0:0:0:0:1")) { + if (version === 6 && (normalized === '::1' || normalized === '0:0:0:0:0:0:0:1')) { return true; } @@ -165,10 +162,13 @@ function isIpAllowed(clientIp: string, allowedCidr?: string): boolean { return false; } - const cidrs = allowedCidr.split(",").map((c) => c.trim()).filter(Boolean); + const cidrs = allowedCidr + .split(',') + .map((c) => c.trim()) + .filter(Boolean); return cidrs.some((cidr) => { - const [range] = cidr.split("/"); + const [range] = cidr.split('/'); if (!range) return false; const rangeNormal = normalizeIp(range); @@ -187,25 +187,25 @@ function isIpAllowed(clientIp: string, allowedCidr?: string): boolean { function metricsIpGuard(req: Request, res: Response, next: NextFunction) { const clientIp = req.ip; if (!clientIp) { - res.status(403).json({ error: "Access Denied: No client IP detected" }); + res.status(403).json({ error: 'Access Denied: No client IP detected' }); return; } if (isIpAllowed(clientIp, env.METRICS_ALLOWED_CIDR)) { next(); } else { - res.status(403).json({ error: "Access Denied: Client IP not allowed" }); + res.status(403).json({ error: 'Access Denied: Client IP not allowed' }); } } function httpMetricsMiddleware(req: Request, res: Response, next: NextFunction) { const start = process.hrtime(); - res.on("finish", () => { + res.on('finish', () => { const diff = process.hrtime(start); const durationSeconds = diff[0] + diff[1] / 1e9; - const route = req.route ? `${req.baseUrl}${(req as any).route.path}` : "not_found"; + const route = req.route ? `${req.baseUrl}${(req as any).route.path}` : 'not_found'; const method = req.method; const statusCode = String(res.statusCode); @@ -218,18 +218,20 @@ function httpMetricsMiddleware(req: Request, res: Response, next: NextFunction) app.use(httpMetricsMiddleware); -app.set("trust proxy", 1); +app.set('trust proxy', 1); app.use( helmet({ contentSecurityPolicy: false, - crossOriginResourcePolicy: { policy: "cross-origin" }, - }), + crossOriginResourcePolicy: { policy: 'cross-origin' }, + }) ); app.use(compression()); const ALLOWED_ORIGINS = (() => { - const set = new Set(["http://localhost:3000", "http://127.0.0.1:3000"]); - for (const o of env.FRONTEND_ORIGIN.split(",").map((s) => s.trim()).filter(Boolean)) { + const set = new Set(['http://localhost:3000', 'http://127.0.0.1:3000']); + for (const o of env.FRONTEND_ORIGIN.split(',') + .map((s) => s.trim()) + .filter(Boolean)) { set.add(o); } return set; @@ -243,41 +245,41 @@ app.use( cb(null, false); }, credentials: false, - }), + }) ); -app.use(express.json({ limit: "1mb" })); +app.use(express.json({ limit: '1mb' })); const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, - message: { error: "too many auth attempts; try again in 15 minutes" }, + message: { error: 'too many auth attempts; try again in 15 minutes' }, }); -app.use("/health", healthRouter); +app.use('/health', healthRouter); client.collectDefaultMetrics(); -app.get("/metrics", metricsIpGuard, async (_req, res) => { +app.get('/metrics', metricsIpGuard, async (_req, res) => { try { - res.set("Content-Type", "text/plain; version=0.0.4"); + res.set('Content-Type', 'text/plain; version=0.0.4'); res.end(await client.register.metrics()); } catch (err: any) { - res.status(500).end(err?.message || "Internal Metrics Error"); + res.status(500).end(err?.message || 'Internal Metrics Error'); } }); // ── OpenAPI spec + Swagger UI (issue #286) ──────────────────────────────── -app.get("/docs/openapi.json", (_req, res) => { +app.get('/docs/openapi.json', (_req, res) => { res.json(openApiSpec); }); -app.get("/docs", (_req, res) => { - const specUrl = "/docs/openapi.json"; - res.setHeader("Content-Type", "text/html; charset=utf-8"); +app.get('/docs', (_req, res) => { + const specUrl = '/docs/openapi.json'; + res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.send(` @@ -305,31 +307,31 @@ app.get("/docs", (_req, res) => { // ───────────────────────────────────────────────────────────────────────────── -app.use("/auth/signup", authLimiter); -app.use("/auth/login", authLimiter); -app.use("/auth", authRouter); -app.use("/importers", importersRouter); -app.use("/importers", kycRouter); -app.use("/compliance", complianceRouter); -app.use("/admin", adminRouter); -app.use("/account", privacyRouter); -app.use("/account", tosRouter); -app.use("/privacy", privacyRouter); -app.use("/surety-license", suretyLicenseRouter); -app.use("/notifications", notificationsRouter); -app.use("/api/v1/regulatory", regulatoryRouter); -app.use("/bonds", bondWebhookRouter); // unauthenticated DocuSign webhook -app.use("/api", bondSignaturesRouter); // authenticated bond signature routes +app.use('/auth/signup', authLimiter); +app.use('/auth/login', authLimiter); +app.use('/auth', authRouter); +app.use('/importers', importersRouter); +app.use('/importers', kycRouter); +app.use('/compliance', complianceRouter); +app.use('/admin', adminRouter); +app.use('/account', privacyRouter); +app.use('/account', tosRouter); +app.use('/privacy', privacyRouter); +app.use('/surety-license', suretyLicenseRouter); +app.use('/notifications', notificationsRouter); +app.use('/api/v1/regulatory', regulatoryRouter); +app.use('/bonds', bondWebhookRouter); // unauthenticated DocuSign webhook +app.use('/api', bondSignaturesRouter); // authenticated bond signature routes Sentry.setupExpressErrorHandler(app); app.use((err: Error, req: Request, res: Response, _next: NextFunction) => { if (req.log) { - req.log.error({ err }, "internal error"); + req.log.error({ err }, 'internal error'); } else { - logger.error({ err }, "internal error"); + logger.error({ err }, 'internal error'); } - res.status(500).json({ error: err.message || "internal error" }); + res.status(500).json({ error: err.message || 'internal error' }); }); async function start() { @@ -344,11 +346,18 @@ async function start() { startImporterMetricsScheduler(); startContractEventsPartitionScheduler(); app.listen(env.PORT, () => { - logger.info({ port: env.PORT, contractId: env.TARIFF_SHIELD_CONTRACT_ID, corsAllowlist: Array.from(ALLOWED_ORIGINS) }, "tariffshield API server started"); + logger.info( + { + port: env.PORT, + contractId: env.TARIFF_SHIELD_CONTRACT_ID, + corsAllowlist: Array.from(ALLOWED_ORIGINS), + }, + 'tariffshield API server started' + ); }); } start().catch((err) => { - logger.fatal({ err }, "fatal server boot error"); + logger.fatal({ err }, 'fatal server boot error'); process.exit(1); }); diff --git a/apps/api/src/indexer.ts b/apps/api/src/indexer.ts index 4fb53c2..a079cb0 100644 --- a/apps/api/src/indexer.ts +++ b/apps/api/src/indexer.ts @@ -1,17 +1,17 @@ -import { pino } from "pino"; -import client from "prom-client"; -import * as Sentry from "@sentry/node"; -import { getCurrentLedgerSequence } from "./stellar.js"; -import { getLastProcessedLedger, updateLastProcessedLedger } from "./db.js"; +import { pino } from 'pino'; +import client from 'prom-client'; +import * as Sentry from '@sentry/node'; +import { getCurrentLedgerSequence } from './stellar.js'; +import { getLastProcessedLedger, updateLastProcessedLedger } from './db.js'; export const logger = pino({ - level: process.env.LOG_LEVEL || "info", + level: process.env.LOG_LEVEL || 'info', }); // Initialize Prometheus gauge for indexer lag export const indexerLagGauge = new client.Gauge({ - name: "contract_event_indexer_lag_ledgers", - help: "Difference between the latest ledger sequence on-chain and the last processed ledger by the indexer", + name: 'contract_event_indexer_lag_ledgers', + help: 'Difference between the latest ledger sequence on-chain and the last processed ledger by the indexer', }); let intervalId: NodeJS.Timeout | null = null; @@ -22,17 +22,17 @@ let intervalId: NodeJS.Timeout | null = null; */ export async function startIndexer(): Promise { if (intervalId) { - logger.warn("Indexer background worker is already running"); + logger.warn('Indexer background worker is already running'); return; } - logger.info("[indexer] Starting background event indexer monitor..."); + logger.info('[indexer] Starting background event indexer monitor...'); // Run the first check immediately try { await checkLagAndIndex(); } catch (err) { - logger.error({ err }, "[indexer] First check failed"); + logger.error({ err }, '[indexer] First check failed'); Sentry.captureException(err); } @@ -41,7 +41,7 @@ export async function startIndexer(): Promise { try { await checkLagAndIndex(); } catch (err) { - logger.error({ err }, "[indexer] Error in contract event indexer monitoring cycle"); + logger.error({ err }, '[indexer] Error in contract event indexer monitoring cycle'); Sentry.captureException(err); } }, 30000); @@ -54,7 +54,7 @@ export function stopIndexer(): void { if (intervalId) { clearInterval(intervalId); intervalId = null; - logger.info("[indexer] Stopped background event indexer monitor"); + logger.info('[indexer] Stopped background event indexer monitor'); } } @@ -74,7 +74,10 @@ async function checkLagAndIndex(): Promise { if (lastProcessed === null) { lastProcessed = currentLedger; await updateLastProcessedLedger(lastProcessed); - logger.info({ lastProcessedLedger: lastProcessed }, "[indexer] Initialized last_processed_ledger in database"); + logger.info( + { lastProcessedLedger: lastProcessed }, + '[indexer] Initialized last_processed_ledger in database' + ); } const lag = currentLedger - lastProcessed; @@ -86,25 +89,25 @@ async function checkLagAndIndex(): Promise { if (lag > 10) { logger.error( { lagLedgers: lag, lastProcessedLedger: lastProcessed, currentLedger }, - "Indexer lag exceeds critical threshold (> 10 blocks)" + 'Indexer lag exceeds critical threshold (> 10 blocks)' ); } else if (lag > 5) { logger.warn( { lagLedgers: lag, lastProcessedLedger: lastProcessed, currentLedger }, - "Indexer lag exceeds warning threshold (> 5 blocks)" + 'Indexer lag exceeds warning threshold (> 5 blocks)' ); } else { logger.debug( { lagLedgers: lag, lastProcessedLedger: lastProcessed, currentLedger }, - "Indexer lag is within normal limits" + 'Indexer lag is within normal limits' ); } // Update last processed sequence in the DB if not stalled - if (process.env.INDEXER_STALL === "true") { + if (process.env.INDEXER_STALL === 'true') { logger.warn( { lagLedgers: lag, lastProcessedLedger: lastProcessed, currentLedger }, - "[indexer] Indexer stall simulated (INDEXER_STALL=true). Skipping ledger progression update." + '[indexer] Indexer stall simulated (INDEXER_STALL=true). Skipping ledger progression update.' ); } else { await updateLastProcessedLedger(currentLedger); diff --git a/apps/api/src/instrument.ts b/apps/api/src/instrument.ts index f593e87..465fa06 100644 --- a/apps/api/src/instrument.ts +++ b/apps/api/src/instrument.ts @@ -1,13 +1,11 @@ -import * as Sentry from "@sentry/node"; -import { nodeProfilingIntegration } from "@sentry/profiling-node"; +import * as Sentry from '@sentry/node'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; Sentry.init({ dsn: process.env.SENTRY_DSN, - environment: process.env.NODE_ENV || "development", + environment: process.env.NODE_ENV || 'development', tracesSampleRate: 0.1, profilesSampleRate: 1.0, - integrations: [ - nodeProfilingIntegration() as any, - ], + integrations: [nodeProfilingIntegration() as any], release: process.env.VERCEL_GIT_COMMIT_SHA || process.env.SENTRY_RELEASE, }); diff --git a/apps/api/src/jobs/compliance-report.ts b/apps/api/src/jobs/compliance-report.ts index 5949496..3177abb 100644 --- a/apps/api/src/jobs/compliance-report.ts +++ b/apps/api/src/jobs/compliance-report.ts @@ -1,5 +1,5 @@ -import { pool } from "../db.js"; -import { env } from "../config/env.js"; +import { pool } from '../db.js'; +import { env } from '../config/env.js'; // Monthly compliance report generation job (#319). // Scheduled to run on the first business day of each month. @@ -52,83 +52,83 @@ async function buildReportData(monthStart: Date, monthEnd: Date): Promise( `SELECT COUNT(*) AS cnt FROM bond_records WHERE created_at <= $2 AND (expiry_date IS NULL OR expiry_date > $1)`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM bond_records WHERE created_at BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM bond_records WHERE expiry_date IS NOT NULL AND expiry_date BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ total: string }>( `SELECT COALESCE(SUM(bond_amount), 0) AS total FROM bond_records WHERE created_at <= $2 AND (expiry_date IS NULL OR expiry_date > $1)`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM kyc_documents WHERE reviewed_at BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM kyc_documents WHERE review_status = 'approved' AND reviewed_at BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM kyc_documents WHERE review_status = 'rejected' AND reviewed_at BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM importers WHERE kyc_status = 'pending'`, + `SELECT COUNT(*) AS cnt FROM importers WHERE kyc_status = 'pending'` ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM aml_screenings WHERE screening_timestamp BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM compliance_flags WHERE created_at BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM compliance_flags WHERE resolution_status = 'resolved' AND resolved_at BETWEEN $1 AND $2`, - [monthStart, monthEnd], + [monthStart, monthEnd] ), pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM bond_records WHERE bond_amount < cbp_minimum_required`, + `SELECT COUNT(*) AS cnt FROM bond_records WHERE bond_amount < cbp_minimum_required` ), pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM bond_records WHERE surety_fein = 'TBD'`, + `SELECT COUNT(*) AS cnt FROM bond_records WHERE surety_fein = 'TBD'` ), ]); return { month, bondPortfolio: { - totalActive: parseInt(activeBonds.rows[0]?.cnt ?? "0", 10), - newIssued: parseInt(newBonds.rows[0]?.cnt ?? "0", 10), - expiredOrCancelled: parseInt(expiredBonds.rows[0]?.cnt ?? "0", 10), - aggregateFaceValue: faceValue.rows[0]?.total ?? "0", + totalActive: parseInt(activeBonds.rows[0]?.cnt ?? '0', 10), + newIssued: parseInt(newBonds.rows[0]?.cnt ?? '0', 10), + expiredOrCancelled: parseInt(expiredBonds.rows[0]?.cnt ?? '0', 10), + aggregateFaceValue: faceValue.rows[0]?.total ?? '0', }, kycActivity: { - reviewsCompleted: parseInt(kycCompleted.rows[0]?.cnt ?? "0", 10), - approvals: parseInt(kycApproved.rows[0]?.cnt ?? "0", 10), - rejections: parseInt(kycRejected.rows[0]?.cnt ?? "0", 10), - pendingQueue: parseInt(kycPending.rows[0]?.cnt ?? "0", 10), + reviewsCompleted: parseInt(kycCompleted.rows[0]?.cnt ?? '0', 10), + approvals: parseInt(kycApproved.rows[0]?.cnt ?? '0', 10), + rejections: parseInt(kycRejected.rows[0]?.cnt ?? '0', 10), + pendingQueue: parseInt(kycPending.rows[0]?.cnt ?? '0', 10), }, amlScreening: { - screened: parseInt(amlScreened.rows[0]?.cnt ?? "0", 10), - flagsRaised: parseInt(flagsRaised.rows[0]?.cnt ?? "0", 10), - flagsResolved: parseInt(flagsResolved.rows[0]?.cnt ?? "0", 10), + screened: parseInt(amlScreened.rows[0]?.cnt ?? '0', 10), + flagsRaised: parseInt(flagsRaised.rows[0]?.cnt ?? '0', 10), + flagsResolved: parseInt(flagsResolved.rows[0]?.cnt ?? '0', 10), sarEligibleEvents: 0, // populated by AML provider integration }, regulatoryActions: { - bondsBelowCbpMinimum: parseInt(bondsBelowMin.rows[0]?.cnt ?? "0", 10), - bondsAwaitingSignature: parseInt(bondsUnsigned.rows[0]?.cnt ?? "0", 10), + bondsBelowCbpMinimum: parseInt(bondsBelowMin.rows[0]?.cnt ?? '0', 10), + bondsAwaitingSignature: parseInt(bondsUnsigned.rows[0]?.cnt ?? '0', 10), }, }; } @@ -136,7 +136,7 @@ async function buildReportData(monthStart: Date, monthEnd: Date): Promise { if (!env.S3_REPORTS_BUCKET) return null; const key = `reports/${suretyId}/${reportData.month}.pdf`; @@ -153,16 +153,14 @@ async function notifySuretyAdmins(suretyId: string, reportMonth: string): Promis // Production: fetch surety_admin emails, send via SendGrid } -export async function generateMonthlyComplianceReport( - targetMonth?: Date, -): Promise { +export async function generateMonthlyComplianceReport(targetMonth?: Date): Promise { const now = targetMonth ?? new Date(); const monthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1); const monthEnd = new Date(now.getFullYear(), now.getMonth(), 0, 23, 59, 59, 999); const reportMonthDate = monthStart.toISOString().slice(0, 10); const suretyAdmins = await pool.query<{ id: string }>( - `SELECT id FROM users WHERE role = 'surety_admin'`, + `SELECT id FROM users WHERE role = 'surety_admin'` ); for (const admin of suretyAdmins.rows) { @@ -176,13 +174,13 @@ export async function generateMonthlyComplianceReport( await pool.query( `UPDATE compliance_reports SET superseded_at = now() WHERE surety_id = $1 AND report_month = $2 AND superseded_at IS NULL`, - [suretyId, reportMonthDate], + [suretyId, reportMonthDate] ); await pool.query( `INSERT INTO compliance_reports (surety_id, report_month, report_data, pdf_s3_key) VALUES ($1, $2, $3, $4)`, - [suretyId, reportMonthDate, JSON.stringify(reportData), pdfKey], + [suretyId, reportMonthDate, JSON.stringify(reportData), pdfKey] ); await notifySuretyAdmins(suretyId, reportMonthDate); @@ -211,16 +209,16 @@ export function startComplianceReportScheduler(): void { async function tick() { const now = new Date(); if (now.getUTCHours() === 6 && isFirstBusinessDayOfMonth(now)) { - console.log("[compliance-report] triggering monthly report generation"); + console.log('[compliance-report] triggering monthly report generation'); await generateMonthlyComplianceReport(now).catch((err) => - console.error("[compliance-report] scheduler error:", err), + console.error('[compliance-report] scheduler error:', err) ); } } setInterval(() => { - tick().catch((err) => console.error("[compliance-report] tick error:", err)); + tick().catch((err) => console.error('[compliance-report] tick error:', err)); }, CHECK_INTERVAL_MS); - console.log("[compliance-report] scheduler started"); + console.log('[compliance-report] scheduler started'); } diff --git a/apps/api/src/jobs/ensure-contract-events-partitions.ts b/apps/api/src/jobs/ensure-contract-events-partitions.ts index 3839d01..cc6f370 100644 --- a/apps/api/src/jobs/ensure-contract-events-partitions.ts +++ b/apps/api/src/jobs/ensure-contract-events-partitions.ts @@ -13,17 +13,17 @@ // catches any row whose created_at doesn't match a declared range, so this // job falling behind delays partition pruning for that month rather than // causing insert errors. -import client from "prom-client"; -import { pino } from "pino"; -import { pool } from "../db.js"; -import { monthRange, createContractEventsPartition } from "../lib/contract-events-partitions.js"; +import client from 'prom-client'; +import { pino } from 'pino'; +import { pool } from '../db.js'; +import { monthRange, createContractEventsPartition } from '../lib/contract-events-partitions.js'; -const logger = pino({ name: "contract-events-partition-scheduler" }); +const logger = pino({ name: 'contract-events-partition-scheduler' }); const partitionEnsureRunsCounter = new client.Counter({ - name: "contract_events_partition_ensure_runs_total", - help: "Total number of contract_events partition-ensure attempts", - labelNames: ["outcome"], + name: 'contract_events_partition_ensure_runs_total', + help: 'Total number of contract_events partition-ensure attempts', + labelNames: ['outcome'], }); // A monthly partition boundary doesn't need finer than daily checking; the @@ -43,27 +43,27 @@ async function ensureUpcomingPartitions(): Promise { const range = monthRange(target.getUTCFullYear(), target.getUTCMonth() + 1); await createContractEventsPartition(pool, range); } - partitionEnsureRunsCounter.inc({ outcome: "success" }); + partitionEnsureRunsCounter.inc({ outcome: 'success' }); } catch (err) { - partitionEnsureRunsCounter.inc({ outcome: "failure" }); - logger.error({ err }, "failed to ensure upcoming contract_events partitions"); + partitionEnsureRunsCounter.inc({ outcome: 'failure' }); + logger.error({ err }, 'failed to ensure upcoming contract_events partitions'); } } export function startContractEventsPartitionScheduler(): void { setInterval(() => { ensureUpcomingPartitions().catch((err) => - logger.error({ err }, "contract_events partition-ensure tick error"), + logger.error({ err }, 'contract_events partition-ensure tick error') ); }, CHECK_INTERVAL_MS); // Prime immediately at boot rather than waiting a full day for the first check. ensureUpcomingPartitions().catch((err) => - logger.error({ err }, "initial contract_events partition-ensure failed"), + logger.error({ err }, 'initial contract_events partition-ensure failed') ); logger.info( { intervalMs: CHECK_INTERVAL_MS, monthsAhead: MONTHS_AHEAD }, - "contract_events partition scheduler started", + 'contract_events partition scheduler started' ); } diff --git a/apps/api/src/jobs/reconcile-balances.ts b/apps/api/src/jobs/reconcile-balances.ts index f2d98c9..b70d056 100644 --- a/apps/api/src/jobs/reconcile-balances.ts +++ b/apps/api/src/jobs/reconcile-balances.ts @@ -1,23 +1,23 @@ -import client from "prom-client"; -import { pino } from "pino"; -import { getActiveBonds } from "../db.js"; -import { getBondOnChain } from "../stellar.js"; +import client from 'prom-client'; +import { pino } from 'pino'; +import { getActiveBonds } from '../db.js'; +import { getBondOnChain } from '../stellar.js'; const logger = pino({ - name: "reconciliation-job", - level: "info", + name: 'reconciliation-job', + level: 'info', }); // Prometheus metrics const driftGauge = new client.Gauge({ - name: "contract_balance_drift_count", - help: "Number of bonds where DB balance differs from on-chain balance by > 0.1%", + name: 'contract_balance_drift_count', + help: 'Number of bonds where DB balance differs from on-chain balance by > 0.1%', }); const runsCounter = new client.Counter({ - name: "reconciliation_runs_total", - help: "Total number of balance reconciliation runs", - labelNames: ["outcome"], + name: 'reconciliation_runs_total', + help: 'Total number of balance reconciliation runs', + labelNames: ['outcome'], }); let isRunning = false; @@ -27,7 +27,7 @@ let isRunning = false; */ export async function reconcileBalances(): Promise { if (isRunning) { - logger.warn("Reconciliation job is already running, skipping this interval."); + logger.warn('Reconciliation job is already running, skipping this interval.'); return; } @@ -36,7 +36,7 @@ export async function reconcileBalances(): Promise { let hasError = false; try { - logger.info("Starting balance reconciliation run..."); + logger.info('Starting balance reconciliation run...'); const bonds = await getActiveBonds(); for (const bond of bonds) { @@ -60,17 +60,17 @@ export async function reconcileBalances(): Promise { driftCount++; } } catch (err) { - logger.error({ err, bondId: bond.bondId }, "Failed to reconcile bond"); + logger.error({ err, bondId: bond.bondId }, 'Failed to reconcile bond'); hasError = true; } } driftGauge.set(driftCount); - runsCounter.inc({ outcome: hasError ? "partial_failure" : "success" }); - logger.info({ driftCount, hasError }, "Reconciliation run completed."); + runsCounter.inc({ outcome: hasError ? 'partial_failure' : 'success' }); + logger.info({ driftCount, hasError }, 'Reconciliation run completed.'); } catch (err) { - logger.error({ err }, "Fatal error in reconciliation job"); - runsCounter.inc({ outcome: "error" }); + logger.error({ err }, 'Fatal error in reconciliation job'); + runsCounter.inc({ outcome: 'error' }); } finally { isRunning = false; } @@ -78,13 +78,13 @@ export async function reconcileBalances(): Promise { function logDrift(bondId: string, dbBalance: number, onChainBalance: number, driftPercent: number) { logger.error( - { - bondId, - dbBalance, - onChainBalance, - driftPercent: (driftPercent * 100).toFixed(2) + "%" + { + bondId, + dbBalance, + onChainBalance, + driftPercent: (driftPercent * 100).toFixed(2) + '%', }, - "Balance drift detected for bond!" + 'Balance drift detected for bond!' ); } @@ -92,12 +92,17 @@ function logDrift(bondId: string, dbBalance: number, onChainBalance: number, dri * Starts the reconciliation job on a 5-minute interval. */ export function startReconciliationJob(): void { - logger.info("Scheduling balance reconciliation job (every 5m)"); + logger.info('Scheduling balance reconciliation job (every 5m)'); // Run immediately on boot - reconcileBalances().catch((err) => logger.error({ err }, "Initial reconciliation run failed")); - + reconcileBalances().catch((err) => logger.error({ err }, 'Initial reconciliation run failed')); + // Schedule subsequent runs - setInterval(() => { - reconcileBalances().catch((err) => logger.error({ err }, "Interval reconciliation run failed")); - }, 5 * 60 * 1000); + setInterval( + () => { + reconcileBalances().catch((err) => + logger.error({ err }, 'Interval reconciliation run failed') + ); + }, + 5 * 60 * 1000 + ); } diff --git a/apps/api/src/jobs/reencrypt-fields.ts b/apps/api/src/jobs/reencrypt-fields.ts index 5e3bd48..86d2c41 100644 --- a/apps/api/src/jobs/reencrypt-fields.ts +++ b/apps/api/src/jobs/reencrypt-fields.ts @@ -1,5 +1,10 @@ -import { pool } from "../db.js"; -import { encryptField, decryptField, CURRENT_KEY_VERSION, type EncryptedValue } from "../lib/field-encryption.js"; +import { pool } from '../db.js'; +import { + encryptField, + decryptField, + CURRENT_KEY_VERSION, + type EncryptedValue, +} from '../lib/field-encryption.js'; // Rolling re-encryption job (#314): updates all importer EIN values encrypted with // an older key version to use the current version. Runs in-process; for large @@ -9,11 +14,11 @@ export async function reencryptImporterEins(): Promise { const rows = await pool.query<{ id: string; ein_encrypted: string; ein_key_version: number }>( `SELECT id, ein_encrypted, ein_key_version FROM importers WHERE ein_encrypted IS NOT NULL AND (ein_key_version IS NULL OR ein_key_version < $1)`, - [CURRENT_KEY_VERSION], + [CURRENT_KEY_VERSION] ); if (!rows.rowCount) { - console.log("[reencrypt] all EIN fields are up-to-date"); + console.log('[reencrypt] all EIN fields are up-to-date'); return; } @@ -29,7 +34,7 @@ export async function reencryptImporterEins(): Promise { await pool.query( `UPDATE importers SET ein_encrypted = $1, ein_key_version = $2 WHERE id = $3`, - [JSON.stringify(newValue), CURRENT_KEY_VERSION, row.id], + [JSON.stringify(newValue), CURRENT_KEY_VERSION, row.id] ); updated++; } catch (err) { diff --git a/apps/api/src/jobs/refresh-importer-metrics.ts b/apps/api/src/jobs/refresh-importer-metrics.ts index 953512a..7286fe0 100644 --- a/apps/api/src/jobs/refresh-importer-metrics.ts +++ b/apps/api/src/jobs/refresh-importer-metrics.ts @@ -4,21 +4,21 @@ // without blocking concurrent reads (REFRESH ... CONCURRENTLY). Runs on a // fixed interval rather than pg-cron so it doesn't require extra database // privileges/extensions beyond what migrate() already sets up. -import client from "prom-client"; -import { pino } from "pino"; -import { refreshImporterMetrics } from "../db.js"; +import client from 'prom-client'; +import { pino } from 'pino'; +import { refreshImporterMetrics } from '../db.js'; -const logger = pino({ name: "importer-metrics-refresh" }); +const logger = pino({ name: 'importer-metrics-refresh' }); const refreshRunsCounter = new client.Counter({ - name: "importer_metrics_refresh_runs_total", - help: "Total number of importer_metrics_mv refresh attempts", - labelNames: ["outcome"], + name: 'importer_metrics_refresh_runs_total', + help: 'Total number of importer_metrics_mv refresh attempts', + labelNames: ['outcome'], }); const refreshDurationSeconds = new client.Histogram({ - name: "importer_metrics_refresh_duration_seconds", - help: "Duration of importer_metrics_mv refreshes in seconds", + name: 'importer_metrics_refresh_duration_seconds', + help: 'Duration of importer_metrics_mv refreshes in seconds', buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5], }); @@ -29,25 +29,25 @@ async function runRefresh(): Promise { try { await refreshImporterMetrics(); endTimer(); - refreshRunsCounter.inc({ outcome: "success" }); + refreshRunsCounter.inc({ outcome: 'success' }); } catch (err) { endTimer(); - refreshRunsCounter.inc({ outcome: "failure" }); + refreshRunsCounter.inc({ outcome: 'failure' }); // Logged (and counted, for alerting on the `outcome="failure"` series) // rather than thrown — a stale view is a degraded state, not a reason // to crash the API process. - logger.error({ err }, "importer_metrics_mv refresh failed"); + logger.error({ err }, 'importer_metrics_mv refresh failed'); } } export function startImporterMetricsScheduler(): void { setInterval(() => { - runRefresh().catch((err) => logger.error({ err }, "importer_metrics_mv refresh tick error")); + runRefresh().catch((err) => logger.error({ err }, 'importer_metrics_mv refresh tick error')); }, REFRESH_INTERVAL_MS); // Prime the view once at boot so early dashboard loads aren't served // whatever the migration's initial CREATE snapshot happened to compute. - runRefresh().catch((err) => logger.error({ err }, "initial importer_metrics_mv refresh failed")); + runRefresh().catch((err) => logger.error({ err }, 'initial importer_metrics_mv refresh failed')); - logger.info({ intervalMs: REFRESH_INTERVAL_MS }, "importer-metrics refresh scheduler started"); + logger.info({ intervalMs: REFRESH_INTERVAL_MS }, 'importer-metrics refresh scheduler started'); } diff --git a/apps/api/src/jobs/retention-enforcement.ts b/apps/api/src/jobs/retention-enforcement.ts index 49a1d53..fc2b99a 100644 --- a/apps/api/src/jobs/retention-enforcement.ts +++ b/apps/api/src/jobs/retention-enforcement.ts @@ -8,13 +8,13 @@ * - Retains financial aggregate rows (tariff_uploads, contract_events, oracle_alerts) but nulls PII * Logs all actions to retention_audit_log. */ -import { pino } from "pino"; -import { pool } from "../db.js"; +import { pino } from 'pino'; +import { pool } from '../db.js'; -const logger = pino({ name: "retention-job" }); +const logger = pino({ name: 'retention-job' }); export async function enforceRetention(): Promise { - logger.info("Starting daily retention enforcement run"); + logger.info('Starting daily retention enforcement run'); const now = new Date().toISOString(); // 1. Anonymize expired importer PII (legal_name, ein, stellar_secret_encrypted) @@ -32,16 +32,16 @@ export async function enforceRetention(): Promise { SELECT record_id FROM retention_holds WHERE record_table = 'importers' AND released_at IS NULL ) - RETURNING id`, + RETURNING id` ); const importerCount = importerResult.rowCount ?? 0; if (importerCount > 0) { await pool.query( `INSERT INTO retention_audit_log (job_run_at, record_category, record_count, action, retention_policy) VALUES ($1, 'importers', $2, 'anonymize_pii', '3 years post-account closure')`, - [now, importerCount], + [now, importerCount] ); - logger.info({ count: importerCount }, "Anonymized expired importer PII"); + logger.info({ count: importerCount }, 'Anonymized expired importer PII'); } // 2. Delete expired AML screening records (PII, no regulatory aggregate requirement) @@ -52,29 +52,32 @@ export async function enforceRetention(): Promise { SELECT record_id FROM retention_holds WHERE record_table = 'aml_screenings' AND released_at IS NULL ) - RETURNING id`, + RETURNING id` ); const amlCount = amlResult.rowCount ?? 0; if (amlCount > 0) { await pool.query( `INSERT INTO retention_audit_log (job_run_at, record_category, record_count, action, retention_policy) VALUES ($1, 'aml_screenings', $2, 'delete', '5 years post-last-transaction')`, - [now, amlCount], + [now, amlCount] ); - logger.info({ count: amlCount }, "Deleted expired AML screening records"); + logger.info({ count: amlCount }, 'Deleted expired AML screening records'); } - logger.info("Retention enforcement run complete"); + logger.info('Retention enforcement run complete'); } /** Starts the retention enforcement job on a daily interval. */ export function startRetentionJob(): void { - logger.info("Scheduling daily retention enforcement job"); + logger.info('Scheduling daily retention enforcement job'); // Run once shortly after boot, then every 24 h setTimeout(() => { - enforceRetention().catch((err) => logger.error({ err }, "Retention job failed")); + enforceRetention().catch((err) => logger.error({ err }, 'Retention job failed')); }, 60_000); - setInterval(() => { - enforceRetention().catch((err) => logger.error({ err }, "Retention job failed")); - }, 24 * 60 * 60 * 1000); + setInterval( + () => { + enforceRetention().catch((err) => logger.error({ err }, 'Retention job failed')); + }, + 24 * 60 * 60 * 1000 + ); } diff --git a/apps/api/src/lib/contract-events-partitions.ts b/apps/api/src/lib/contract-events-partitions.ts index 03d9ac6..c04644f 100644 --- a/apps/api/src/lib/contract-events-partitions.ts +++ b/apps/api/src/lib/contract-events-partitions.ts @@ -29,10 +29,10 @@ export interface Queryable { * a different (wrong) partition boundary. */ export function monthRange(year: number, month1to12: number): MonthRange { - const mm = String(month1to12).padStart(2, "0"); + const mm = String(month1to12).padStart(2, '0'); const nextYear = month1to12 === 12 ? year + 1 : year; const nextMonth = month1to12 === 12 ? 1 : month1to12 + 1; - const nextMm = String(nextMonth).padStart(2, "0"); + const nextMm = String(nextMonth).padStart(2, '0'); return { partitionName: `contract_events_${year}_${mm}`, fromDate: `${year}-${mm}-01T00:00:00Z`, @@ -93,16 +93,16 @@ export function monthsBetweenInclusive(start: Date, end: Date): MonthRange[] { */ export async function createContractEventsPartition( client: Queryable, - range: MonthRange, + range: MonthRange ): Promise { await client.query( `CREATE TABLE IF NOT EXISTS ${range.partitionName} PARTITION OF contract_events - FOR VALUES FROM ('${range.fromDate}') TO ('${range.toDate}');`, + FOR VALUES FROM ('${range.fromDate}') TO ('${range.toDate}');` ); await client.query( `CREATE UNIQUE INDEX IF NOT EXISTS ${range.partitionName}_ledger_event_uniq ON ${range.partitionName} (ledger_sequence, event_index) - WHERE ledger_sequence IS NOT NULL AND event_index IS NOT NULL;`, + WHERE ledger_sequence IS NOT NULL AND event_index IS NOT NULL;` ); } diff --git a/apps/api/src/lib/field-encryption.ts b/apps/api/src/lib/field-encryption.ts index b960627..bcdd122 100644 --- a/apps/api/src/lib/field-encryption.ts +++ b/apps/api/src/lib/field-encryption.ts @@ -1,34 +1,30 @@ -import crypto from "node:crypto"; -import { env } from "../config/env.js"; +import crypto from 'node:crypto'; +import { env } from '../config/env.js'; // AES-256-GCM envelope encryption for high-sensitivity fields (#314). // In production, FIELD_ENCRYPTION_KEY should be a KMS-derived data key. // Key rotation: each encrypted value carries a key_version tag so // re-encryption jobs can identify which records need updating. -const ALGORITHM = "aes-256-gcm"; +const ALGORITHM = 'aes-256-gcm'; const IV_BYTES = 12; -const TAG_BYTES = 16; function getKey(version: number): Buffer { - const raw = env.FIELD_ENCRYPTION_KEY ?? ""; + const raw = env.FIELD_ENCRYPTION_KEY ?? ''; if (!raw || raw.length < 32) { - throw new Error("FIELD_ENCRYPTION_KEY must be at least 32 characters"); + throw new Error('FIELD_ENCRYPTION_KEY must be at least 32 characters'); } // Derive a version-specific key by hashing the base key + version. // In production, replace with AWS KMS GenerateDataKey for each version. - return crypto - .createHash("sha256") - .update(`${raw}:v${version}`) - .digest(); + return crypto.createHash('sha256').update(`${raw}:v${version}`).digest(); } export const CURRENT_KEY_VERSION = Number(env.FIELD_ENCRYPTION_KEY_VERSION ?? 1); export interface EncryptedValue { ciphertext: string; // base64 - iv: string; // base64 - tag: string; // base64 + iv: string; // base64 + tag: string; // base64 key_version: number; } @@ -36,24 +32,24 @@ export function encryptField(plaintext: string): EncryptedValue { const iv = crypto.randomBytes(IV_BYTES); const key = getKey(CURRENT_KEY_VERSION); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); - const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return { - ciphertext: encrypted.toString("base64"), - iv: iv.toString("base64"), - tag: tag.toString("base64"), + ciphertext: encrypted.toString('base64'), + iv: iv.toString('base64'), + tag: tag.toString('base64'), key_version: CURRENT_KEY_VERSION, }; } export function decryptField(value: EncryptedValue): string { const key = getKey(value.key_version); - const iv = Buffer.from(value.iv, "base64"); - const tag = Buffer.from(value.tag, "base64"); - const ciphertext = Buffer.from(value.ciphertext, "base64"); + const iv = Buffer.from(value.iv, 'base64'); + const tag = Buffer.from(value.tag, 'base64'); + const ciphertext = Buffer.from(value.ciphertext, 'base64'); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag); - return decipher.update(ciphertext).toString("utf8") + decipher.final("utf8"); + return decipher.update(ciphertext).toString('utf8') + decipher.final('utf8'); } export function encryptFieldToJson(plaintext: string | null | undefined): string | null { diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 0ec239f..7d0859e 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -1,14 +1,14 @@ -import pino from "pino"; -import pinoHttp from "pino-http"; -import { randomUUID } from "crypto"; -import { env, isProduction } from "../config/env.js"; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { randomUUID } from 'crypto'; +import { env, isProduction } from '../config/env.js'; export const logger = pino({ - level: env.LOG_LEVEL || "info", + level: env.LOG_LEVEL || 'info', transport: isProduction ? undefined : { - target: "pino-pretty", + target: 'pino-pretty', options: { colorize: true, singleLine: true, @@ -19,14 +19,14 @@ export const logger = pino({ export const httpLogger = pinoHttp({ logger, genReqId: (req) => { - const existingHeader = req.headers["x-correlation-id"] || req.headers["x-request-id"]; - if (typeof existingHeader === "string" && existingHeader.trim().length > 0) { + const existingHeader = req.headers['x-correlation-id'] || req.headers['x-request-id']; + if (typeof existingHeader === 'string' && existingHeader.trim().length > 0) { return existingHeader; } return randomUUID(); }, customAttributeKeys: { - reqId: "correlationId", + reqId: 'correlationId', }, customProps: (req) => ({ correlationId: req.id, diff --git a/apps/api/src/lib/soroban/logger.ts b/apps/api/src/lib/soroban/logger.ts index 396c675..4709131 100644 --- a/apps/api/src/lib/soroban/logger.ts +++ b/apps/api/src/lib/soroban/logger.ts @@ -1,30 +1,35 @@ -import debug from "debug"; -import { rpc } from "@stellar/stellar-sdk"; +import debug from 'debug'; +import { rpc } from '@stellar/stellar-sdk'; -const log = debug("tariffshield:soroban"); +const log = debug('tariffshield:soroban'); -const isTest = process.env.NODE_ENV === "test"; -const forceDebug = process.env.FORCE_SOROBAN_DEBUG === "true"; +const isTest = process.env.NODE_ENV === 'test'; +const forceDebug = process.env.FORCE_SOROBAN_DEBUG === 'true'; const isAllowedEnv = !isTest || forceDebug; -const hasDebugSorobanEnv = typeof process.env.DEBUG === "string" && ( - process.env.DEBUG.split(",").some((val) => { +const hasDebugSorobanEnv = + typeof process.env.DEBUG === 'string' && + process.env.DEBUG.split(',').some((val) => { const trimmed = val.trim(); - return trimmed === "soroban:*" || trimmed === "tariffshield:soroban" || trimmed === "*"; - }) -); + return trimmed === 'soroban:*' || trimmed === 'tariffshield:soroban' || trimmed === '*'; + }); -const hasLogLevelSorobanDebug = process.env.LOG_LEVEL === "debug" && process.env.SOROBAN_DEBUG === "true"; -const hasSorobanDebugOnly = process.env.SOROBAN_DEBUG === "true"; +const hasLogLevelSorobanDebug = + process.env.LOG_LEVEL === 'debug' && process.env.SOROBAN_DEBUG === 'true'; +const hasSorobanDebugOnly = process.env.SOROBAN_DEBUG === 'true'; -const isEnabled = isAllowedEnv && (hasDebugSorobanEnv || hasLogLevelSorobanDebug || hasSorobanDebugOnly); +const isEnabled = + isAllowedEnv && (hasDebugSorobanEnv || hasLogLevelSorobanDebug || hasSorobanDebugOnly); if (isEnabled) { - const current = process.env.DEBUG || ""; - const namespaces = current.split(",").map((s) => s.trim()).filter(Boolean); - if (!namespaces.includes("tariffshield:soroban") && !namespaces.includes("*")) { - namespaces.push("tariffshield:soroban"); - debug.enable(namespaces.join(",")); + const current = process.env.DEBUG || ''; + const namespaces = current + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (!namespaces.includes('tariffshield:soroban') && !namespaces.includes('*')) { + namespaces.push('tariffshield:soroban'); + debug.enable(namespaces.join(',')); } } @@ -41,24 +46,24 @@ function redact(obj: any): any { if (obj === null || obj === undefined) { return obj; } - if (typeof obj === "string") { + if (typeof obj === 'string') { if (/^S[A-D2-7][A-Z2-7]{54}$/.test(obj)) { - return "[REDACTED]"; + return '[REDACTED]'; } if (obj.length > 100 && /^[A-Za-z0-9+/=]+$/.test(obj)) { - return "[REDACTED]"; + return '[REDACTED]'; } return obj; } if (Array.isArray(obj)) { return obj.map(redact); } - if (typeof obj === "object") { + if (typeof obj === 'object') { const cleaned: Record = {}; for (const key of Object.keys(obj)) { const lowerKey = key.toLowerCase(); - if (lowerKey === "secretkey" || lowerKey === "source") { - cleaned[key] = "[REDACTED]"; + if (lowerKey === 'secretkey' || lowerKey === 'source') { + cleaned[key] = '[REDACTED]'; } else { cleaned[key] = redact(obj[key]); } @@ -70,7 +75,7 @@ function redact(obj: any): any { function getRequestJsonData(config: any): any { if (!config?.data) return null; - if (typeof config.data === "string") { + if (typeof config.data === 'string') { try { return JSON.parse(config.data); } catch { @@ -80,7 +85,7 @@ function getRequestJsonData(config: any): any { return config.data; } -const isPretty = process.env.SOROBAN_LOG_PRETTY === "true"; +const isPretty = process.env.SOROBAN_LOG_PRETTY === 'true'; function writeLog(payload: SorobanRpcLogPayload): void { const redacted = redact(payload); @@ -106,8 +111,8 @@ export function registerSorobanLogger(server: rpc.Server): void { const requestData = getRequestJsonData(response.config); const payload: SorobanRpcLogPayload = { - httpMethod: response.config.method?.toUpperCase() || "POST", - rpcMethod: requestData?.method || "unknown", + httpMethod: response.config.method?.toUpperCase() || 'POST', + rpcMethod: requestData?.method || 'unknown', requestParams: requestData?.params || null, responseStatus: response.status, responseBody: response.data, @@ -126,8 +131,8 @@ export function registerSorobanLogger(server: rpc.Server): void { const responseBody = error.response?.data || error.message; const payload: SorobanRpcLogPayload = { - httpMethod: error.config?.method?.toUpperCase() || "POST", - rpcMethod: requestData?.method || "unknown", + httpMethod: error.config?.method?.toUpperCase() || 'POST', + rpcMethod: requestData?.method || 'unknown', requestParams: requestData?.params || null, responseStatus, responseBody, diff --git a/apps/api/src/lib/soroban/reconnect.test.ts b/apps/api/src/lib/soroban/reconnect.test.ts index 6525015..f535a69 100644 --- a/apps/api/src/lib/soroban/reconnect.test.ts +++ b/apps/api/src/lib/soroban/reconnect.test.ts @@ -13,8 +13,8 @@ * still proves the retry/backoff/rebuild behavior is correct. */ -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; const RECONNECT_MAX_RETRIES = 3; const RECONNECT_BASE_DELAY_MS = 250; @@ -22,11 +22,11 @@ const RECONNECT_BASE_DELAY_MS = 250; function isConnectionError(err: unknown): boolean { const code = (err as { code?: string } | undefined)?.code; return ( - code === "ECONNREFUSED" || - code === "ECONNRESET" || - code === "ETIMEDOUT" || - code === "EPIPE" || - code === "ENOTFOUND" + code === 'ECONNREFUSED' || + code === 'ECONNRESET' || + code === 'ETIMEDOUT' || + code === 'EPIPE' || + code === 'ENOTFOUND' ); } @@ -37,7 +37,7 @@ function sleep(ms: number): Promise { async function withRpcReconnect( getServer: () => T, rebuildServer: () => T, - fn: (server: T) => Promise, + fn: (server: T) => Promise ): Promise { let server = getServer(); let lastErr: unknown; @@ -60,77 +60,77 @@ function connErr(code: string): Error { return Object.assign(new Error(code), { code }); } -describe("withRpcReconnect (#249)", () => { - it("returns the result on the first successful attempt without rebuilding", async () => { +describe('withRpcReconnect (#249)', () => { + it('returns the result on the first successful attempt without rebuilding', async () => { let rebuildCount = 0; const result = await withRpcReconnect( - () => "server-1", + () => 'server-1', () => { rebuildCount++; - return "server-2"; + return 'server-2'; }, - async (server) => `ok:${server}`, + async (server) => `ok:${server}` ); - assert.equal(result, "ok:server-1"); + assert.equal(result, 'ok:server-1'); assert.equal(rebuildCount, 0); }); - it("rebuilds the server and retries on a connection error, then succeeds", async () => { + it('rebuilds the server and retries on a connection error, then succeeds', async () => { let calls = 0; let rebuildCount = 0; const result = await withRpcReconnect( - () => "server-1", + () => 'server-1', () => { rebuildCount++; - return "server-2"; + return 'server-2'; }, async (server) => { calls++; - if (calls === 1) throw connErr("ECONNRESET"); + if (calls === 1) throw connErr('ECONNRESET'); return `ok:${server}`; - }, + } ); - assert.equal(result, "ok:server-2"); + assert.equal(result, 'ok:server-2'); assert.equal(rebuildCount, 1); assert.equal(calls, 2); }); - it("does not retry a non-connection error", async () => { + it('does not retry a non-connection error', async () => { let rebuildCount = 0; await assert.rejects( () => withRpcReconnect( - () => "server-1", + () => 'server-1', () => { rebuildCount++; - return "server-2"; + return 'server-2'; }, async () => { - throw new Error("validation error, not a connection issue"); - }, + throw new Error('validation error, not a connection issue'); + } ), - /validation error/, + /validation error/ ); assert.equal(rebuildCount, 0); }); - it("gives up after RECONNECT_MAX_RETRIES rebuild attempts and throws the last error", async () => { + it('gives up after RECONNECT_MAX_RETRIES rebuild attempts and throws the last error', async () => { let calls = 0; let rebuildCount = 0; await assert.rejects( () => withRpcReconnect( - () => "server-1", + () => 'server-1', () => { rebuildCount++; return `server-${rebuildCount + 1}`; }, async () => { calls++; - throw connErr("ECONNREFUSED"); - }, + throw connErr('ECONNREFUSED'); + } ), - /ECONNREFUSED/, + /ECONNREFUSED/ ); // Initial attempt + RECONNECT_MAX_RETRIES retries = 4 total calls, // and exactly RECONNECT_MAX_RETRIES rebuilds (no rebuild after the @@ -139,19 +139,23 @@ describe("withRpcReconnect (#249)", () => { assert.equal(rebuildCount, RECONNECT_MAX_RETRIES); }); - it("recognizes all documented connection-error codes", () => { - for (const code of ["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "EPIPE", "ENOTFOUND"]) { - assert.equal(isConnectionError(connErr(code)), true, `${code} should be treated as a connection error`); + it('recognizes all documented connection-error codes', () => { + for (const code of ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND']) { + assert.equal( + isConnectionError(connErr(code)), + true, + `${code} should be treated as a connection error` + ); } }); - it("does not treat an application-level error as a connection error", () => { - assert.equal(isConnectionError(new Error("simulate failed: contract error")), false); - assert.equal(isConnectionError({ code: "SOME_APP_CODE" }), false); + it('does not treat an application-level error as a connection error', () => { + assert.equal(isConnectionError(new Error('simulate failed: contract error')), false); + assert.equal(isConnectionError({ code: 'SOME_APP_CODE' }), false); assert.equal(isConnectionError(undefined), false); }); - it("computes exponential backoff delays (250ms base, doubling per attempt)", () => { + it('computes exponential backoff delays (250ms base, doubling per attempt)', () => { const delays = [0, 1, 2].map((attempt) => RECONNECT_BASE_DELAY_MS * 2 ** attempt); assert.deepEqual(delays, [250, 500, 1000]); }); diff --git a/apps/api/src/lib/soroban/rpcClient.ts b/apps/api/src/lib/soroban/rpcClient.ts index 65b75cd..07c5ab2 100644 --- a/apps/api/src/lib/soroban/rpcClient.ts +++ b/apps/api/src/lib/soroban/rpcClient.ts @@ -1,7 +1,7 @@ -import http from "node:http"; -import https from "node:https"; -import { rpc } from "@stellar/stellar-sdk"; -import { registerSorobanLogger } from "./logger.js"; +import http from 'node:http'; +import https from 'node:https'; +import { rpc } from '@stellar/stellar-sdk'; +import { registerSorobanLogger } from './logger.js'; // #249 — the SDK's HttpClient abstraction has no per-server agent option // (it also targets browsers, which have no concept of a Node http.Agent), @@ -16,7 +16,7 @@ https.globalAgent = new https.Agent({ keepAlive: true }); export function createRpcServer(url: string, opts?: rpc.Server.Options): rpc.Server { const server = new rpc.Server(url, { - allowHttp: url.startsWith("http://"), + allowHttp: url.startsWith('http://'), ...opts, }); registerSorobanLogger(server); diff --git a/apps/api/src/migrate.ts b/apps/api/src/migrate.ts index 22852f3..6906db4 100644 --- a/apps/api/src/migrate.ts +++ b/apps/api/src/migrate.ts @@ -4,26 +4,26 @@ function stub(name: string, value: string) { if (!process.env[name]) process.env[name] = value; } -stub("JWT_SECRET", "ci-stub-jwt-secret-not-used-by-migrations-0000"); -stub("STELLAR_RPC_URL", "https://soroban-testnet.stellar.org"); -stub("STELLAR_HORIZON_URL", "https://horizon-testnet.stellar.org"); -stub("STELLAR_NETWORK_PASSPHRASE", "Test SDF Network ; September 2015"); -stub("TARIFF_SHIELD_CONTRACT_ID", "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); -stub("PLATFORM_STELLAR_SECRET", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB"); -stub("SURETY_STELLAR_SECRET", "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC"); +stub('JWT_SECRET', 'ci-stub-jwt-secret-not-used-by-migrations-0000'); +stub('STELLAR_RPC_URL', 'https://soroban-testnet.stellar.org'); +stub('STELLAR_HORIZON_URL', 'https://horizon-testnet.stellar.org'); +stub('STELLAR_NETWORK_PASSPHRASE', 'Test SDF Network ; September 2015'); +stub('TARIFF_SHIELD_CONTRACT_ID', 'CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); +stub('PLATFORM_STELLAR_SECRET', 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'); +stub('SURETY_STELLAR_SECRET', 'SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC'); -const { migrate, rollback, pool } = await import("./db.js"); +const { migrate, rollback, pool } = await import('./db.js'); -const action = process.argv[2] === "rollback" ? "rollback" : "up"; +const action = process.argv[2] === 'rollback' ? 'rollback' : 'up'; try { - if (action === "rollback") { + if (action === 'rollback') { await rollback(); } else { await migrate(); } } catch (err) { - console.error("Migration command failed:", err); + console.error('Migration command failed:', err); process.exit(1); } finally { await pool.end(); diff --git a/apps/api/src/migrations/0001_initial_schema.ts b/apps/api/src/migrations/0001_initial_schema.ts index 3dfd22d..582b033 100644 --- a/apps/api/src/migrations/0001_initial_schema.ts +++ b/apps/api/src/migrations/0001_initial_schema.ts @@ -1,4 +1,4 @@ -import { PoolClient } from "pg"; +import { PoolClient } from 'pg'; export async function up(client: PoolClient): Promise { await client.query(` @@ -241,7 +241,9 @@ export async function up(client: PoolClient): Promise { signed_document_hash TEXT, completed_at TIMESTAMPTZ, pdf_s3_key TEXT, - last_reminder_sent_at TIMESTAMPTZ + last_reminder_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- #312: KYC document storage with retention schedule diff --git a/apps/api/src/migrations/0002_partition_contract_events.ts b/apps/api/src/migrations/0002_partition_contract_events.ts index 50343d7..fca40e1 100644 --- a/apps/api/src/migrations/0002_partition_contract_events.ts +++ b/apps/api/src/migrations/0002_partition_contract_events.ts @@ -1,5 +1,8 @@ -import { PoolClient } from "pg"; -import { monthsBetweenInclusive, createContractEventsPartition } from "../lib/contract-events-partitions.js"; +import { PoolClient } from 'pg'; +import { + monthsBetweenInclusive, + createContractEventsPartition, +} from '../lib/contract-events-partitions.js'; // #228 — range-partition contract_events by month. // @@ -74,11 +77,11 @@ export async function up(client: PoolClient): Promise { // 3. Determine which months need partitions: every month spanned by the // existing data, plus a lookahead buffer past the current month. const { rows } = await client.query<{ min_created: Date | null; max_created: Date | null }>( - `SELECT MIN(created_at) AS min_created, MAX(created_at) AS max_created FROM contract_events_pre_partition;`, + `SELECT MIN(created_at) AS min_created, MAX(created_at) AS max_created FROM contract_events_pre_partition;` ); const now = new Date(); const lookaheadEnd = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + FUTURE_MONTH_LOOKAHEAD_MONTHS, 1), + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + FUTURE_MONTH_LOOKAHEAD_MONTHS, 1) ); const earliest = rows[0]?.min_created ?? now; const latest = rows[0]?.max_created ?? now; @@ -91,7 +94,9 @@ export async function up(client: PoolClient): Promise { // Safety net: catch any row whose created_at falls outside the computed // range (clock skew, a manually-inserted historical row) so the // migration can never lose data to a missing partition. - await client.query(`CREATE TABLE IF NOT EXISTS contract_events_default PARTITION OF contract_events DEFAULT;`); + await client.query( + `CREATE TABLE IF NOT EXISTS contract_events_default PARTITION OF contract_events DEFAULT;` + ); await client.query(` CREATE UNIQUE INDEX IF NOT EXISTS contract_events_default_ledger_event_uniq ON contract_events_default (ledger_sequence, event_index) @@ -102,10 +107,18 @@ export async function up(client: PoolClient): Promise { // future partition — this is the "global partitioned index" option // from the issue's acceptance criteria, used for the two importer- // scoped indexes plus the pagination and BRIN indexes. - await client.query(`CREATE INDEX idx_contract_events_importer ON contract_events(importer_id, created_at DESC);`); - await client.query(`CREATE INDEX idx_contract_events_importer_kind ON contract_events(importer_id, kind, created_at DESC);`); - await client.query(`CREATE INDEX idx_contract_events_importer_id_pagination ON contract_events(importer_id, id DESC);`); - await client.query(`CREATE INDEX idx_contract_events_created_at_brin ON contract_events USING BRIN (created_at) WITH (pages_per_range = 32);`); + await client.query( + `CREATE INDEX idx_contract_events_importer ON contract_events(importer_id, created_at DESC);` + ); + await client.query( + `CREATE INDEX idx_contract_events_importer_kind ON contract_events(importer_id, kind, created_at DESC);` + ); + await client.query( + `CREATE INDEX idx_contract_events_importer_id_pagination ON contract_events(importer_id, id DESC);` + ); + await client.query( + `CREATE INDEX idx_contract_events_created_at_brin ON contract_events USING BRIN (created_at) WITH (pages_per_range = 32);` + ); // 5. Copy every existing row across in a single INSERT ... SELECT (one // sequential scan + append, no per-row round trips). @@ -119,18 +132,87 @@ export async function up(client: PoolClient): Promise { // 6. Verify no rows were lost before dropping the source table. Any // mismatch throws, which rolls back the whole migration transaction. const before = await client.query<{ count: string }>( - `SELECT COUNT(*)::text AS count FROM contract_events_pre_partition;`, + `SELECT COUNT(*)::text AS count FROM contract_events_pre_partition;` ); const after = await client.query<{ count: string }>( - `SELECT COUNT(*)::text AS count FROM contract_events;`, + `SELECT COUNT(*)::text AS count FROM contract_events;` ); if (before.rows[0]!.count !== after.rows[0]!.count) { throw new Error( `contract_events partition migration row-count mismatch: ` + - `${before.rows[0]!.count} rows before, ${after.rows[0]!.count} rows after. Aborting.`, + `${before.rows[0]!.count} rows before, ${after.rows[0]!.count} rows after. Aborting.` ); } + // 7. importer_metrics_mv and importer_metrics (0001) both query + // contract_events, so PostgreSQL rebound them to + // contract_events_pre_partition when it was renamed in step 1 — + // materialized views depend on the underlying relation by OID, not + // name. Drop and recreate both (identical definitions to 0001) so + // they rebind to the new partitioned contract_events before the old + // table is dropped; otherwise the DROP TABLE below fails with + // "cannot drop table ... because other objects depend on it". + await client.query(`DROP MATERIALIZED VIEW IF EXISTS importer_metrics;`); + await client.query(`DROP MATERIALIZED VIEW IF EXISTS importer_metrics_mv;`); + + await client.query(` + CREATE MATERIALIZED VIEW importer_metrics_mv AS + SELECT + 1 AS singleton_id, + (SELECT COUNT(*) FROM importers) AS total_importers, + (SELECT COALESCE(SUM(bond_amount), 0) FROM bond_records) AS total_bond_value, + (SELECT ROUND(COALESCE(AVG(collateral_balance), 0)) FROM importers) AS avg_balance, + ( + SELECT CASE WHEN COUNT(*) = 0 THEN 100.0 + ELSE ROUND(100.0 * COUNT(*) FILTER (WHERE bond_amount >= cbp_minimum_required) / COUNT(*), 2) + END + FROM bond_records + ) AS compliance_rate, + ( + SELECT COUNT(*) FROM contract_events + WHERE kind IN ('deposit_collateral', 'deposit_reserve', 'auto_top_up') + AND created_at >= now() - INTERVAL '30 days' + ) AS topup_count_30d, + now() AS refreshed_at; + `); + await client.query(` + CREATE UNIQUE INDEX idx_importer_metrics_mv_singleton ON importer_metrics_mv (singleton_id); + `); + + await client.query(` + CREATE MATERIALIZED VIEW importer_metrics AS + SELECT + i.id AS importer_id, + i.legal_name, + i.stellar_address, + COALESCE(t.latest_required_collateral, 0) AS required_collateral, + COALESCE( + SUM(CASE WHEN ce.kind IN ('deposit', 'deposit_collateral', 'deposit_reserve') THEN ce.amount ELSE 0 END) - + SUM(CASE WHEN ce.kind IN ('withdrawal', 'withdraw') THEN ce.amount ELSE 0 END), 0 + ) AS current_balance, + COALESCE(t.latest_annual_duty_total, 0) AS annual_duty_total, + CASE WHEN COALESCE(t.latest_required_collateral, 0) > 0 + THEN ROUND( + (SUM(CASE WHEN ce.kind IN ('deposit', 'deposit_collateral', 'deposit_reserve') THEN ce.amount ELSE 0 END) - + SUM(CASE WHEN ce.kind IN ('withdrawal', 'withdraw') THEN ce.amount ELSE 0 END))::NUMERIC + / t.latest_required_collateral, 4) + ELSE NULL END AS coverage_ratio, + now() AS refreshed_at + FROM importers i + LEFT JOIN LATERAL ( + SELECT annual_duty_total AS latest_annual_duty_total, + computed_required_collateral AS latest_required_collateral + FROM tariff_uploads WHERE importer_id = i.id ORDER BY created_at DESC LIMIT 1 + ) t ON true + LEFT JOIN contract_events ce ON ce.importer_id = i.id + WHERE i.deleted_at IS NULL + GROUP BY i.id, i.legal_name, i.stellar_address, + t.latest_annual_duty_total, t.latest_required_collateral; + `); + await client.query(` + CREATE UNIQUE INDEX idx_importer_metrics_importer_id ON importer_metrics (importer_id); + `); + await client.query(`DROP TABLE contract_events_pre_partition;`); } @@ -158,10 +240,18 @@ export async function down(client: PoolClient): Promise { FROM contract_events_partitioned; `); - await client.query(`CREATE INDEX idx_contract_events_importer ON contract_events(importer_id, created_at DESC);`); - await client.query(`CREATE INDEX idx_contract_events_importer_kind ON contract_events(importer_id, kind, created_at DESC);`); - await client.query(`CREATE INDEX idx_contract_events_importer_id_pagination ON contract_events(importer_id, id DESC);`); - await client.query(`CREATE INDEX idx_contract_events_created_at_brin ON contract_events USING BRIN (created_at) WITH (pages_per_range = 32);`); + await client.query( + `CREATE INDEX idx_contract_events_importer ON contract_events(importer_id, created_at DESC);` + ); + await client.query( + `CREATE INDEX idx_contract_events_importer_kind ON contract_events(importer_id, kind, created_at DESC);` + ); + await client.query( + `CREATE INDEX idx_contract_events_importer_id_pagination ON contract_events(importer_id, id DESC);` + ); + await client.query( + `CREATE INDEX idx_contract_events_created_at_brin ON contract_events USING BRIN (created_at) WITH (pages_per_range = 32);` + ); await client.query(` CREATE UNIQUE INDEX idx_contract_events_ledger_event ON contract_events(ledger_sequence, event_index) diff --git a/apps/api/src/migrations/0002_importers_kyc_status_index.ts b/apps/api/src/migrations/0003_importers_kyc_status_index.ts similarity index 97% rename from apps/api/src/migrations/0002_importers_kyc_status_index.ts rename to apps/api/src/migrations/0003_importers_kyc_status_index.ts index 1ad7b6c..abdc7ac 100644 --- a/apps/api/src/migrations/0002_importers_kyc_status_index.ts +++ b/apps/api/src/migrations/0003_importers_kyc_status_index.ts @@ -1,4 +1,4 @@ -import { PoolClient } from "pg"; +import { PoolClient } from 'pg'; // #229 — index for the kyc_status column added on importers by 0001. // diff --git a/apps/api/src/migrations/0004_supplementary_schema.ts b/apps/api/src/migrations/0004_supplementary_schema.ts new file mode 100644 index 0000000..db4c306 --- /dev/null +++ b/apps/api/src/migrations/0004_supplementary_schema.ts @@ -0,0 +1,153 @@ +import { PoolClient } from 'pg'; + +// db.ts's rollback() runs a large "baseline schema ensure" block (as a +// safety net for the state a database is left in after a full migration +// rollback) that also contains schema this app depends on at runtime — +// ein_hash, audit_log, bonds, refresh_tokens, documents, and +// importer_documents_view — that was never added to the forward migration +// chain. Since db:migrate only ever runs the `up` direction in normal +// operation (see migrate() vs rollback() in db.ts), none of this schema +// was ever actually created outside of that rollback safety net, which +// nothing in CI or production exercises. This migration is the missing +// forward-path equivalent for the pieces genuinely not already covered by +// 0001-0003 (verified against every CREATE TABLE/INDEX/VIEW in db.ts's +// rollback() block — the rest duplicates 0001-0003 verbatim, harmlessly, +// via IF NOT EXISTS). +export async function up(client: PoolClient): Promise { + await client.query(` + -- #243: ein_hash for PII-safe equality lookups. Partial unique index — + -- NULL eins (optional field) never collide with each other or force + -- uniqueness. + ALTER TABLE importers ADD COLUMN IF NOT EXISTS ein_hash TEXT; + CREATE UNIQUE INDEX IF NOT EXISTS idx_importers_ein_hash ON importers(ein_hash) WHERE ein_hash IS NOT NULL; + + -- One-time backfill: compute ein_hash for any pre-existing row that has + -- a plaintext ein but no hash yet. Safe to re-run — only touches rows + -- still missing ein_hash. + UPDATE importers + SET ein_hash = encode(sha256(ein::bytea), 'hex') + WHERE ein IS NOT NULL AND ein_hash IS NULL; + + -- #231: append-only audit log. db.ts's logAudit()/routes/admin.ts's + -- GET /admin/audit-log already assume this exact shape + -- (id, actor_user_id, action, target_id, payload, created_at). + CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + target_id UUID, + payload JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action, created_at DESC); + + -- Append-only: block UPDATE/DELETE via row-level security. + ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY; + DROP POLICY IF EXISTS audit_log_no_update ON audit_log; + CREATE POLICY audit_log_no_update ON audit_log FOR UPDATE USING (false); + DROP POLICY IF EXISTS audit_log_no_delete ON audit_log; + CREATE POLICY audit_log_no_delete ON audit_log FOR DELETE USING (false); + + -- #232: bonds — full bond lifecycle tracking (supersedes importers.bond_id). + -- NOTE: importers.bond_id is deprecated and retained for backward + -- compatibility. All new bond queries should use the bonds table instead. + CREATE TABLE IF NOT EXISTS bonds ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE, + bond_number BIGINT NOT NULL, + policy_type TEXT NOT NULL DEFAULT 'continuous' CHECK (policy_type IN ('continuous', 'single_entry', 'term')), + coverage_amount NUMERIC(20, 2) NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('pending', 'active', 'expired', 'cancelled', 'replaced')), + issued_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + replaced_by_id UUID REFERENCES bonds(id), + stellar_contract_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_bonds_bond_number ON bonds(bond_number); + CREATE INDEX IF NOT EXISTS idx_bonds_importer_status ON bonds(importer_id, status, created_at DESC); + + -- Migrate existing importers.bond_id values into the bonds table. + INSERT INTO bonds (importer_id, bond_number, policy_type, coverage_amount, status, issued_at, created_at) + SELECT id, bond_id, 'continuous', 0, 'active', created_at, created_at + FROM importers + WHERE NOT EXISTS (SELECT 1 FROM bonds WHERE bond_number = importers.bond_id); + + -- #235: refresh_tokens — JWT refresh token flow with server-side revocation. + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + replaced_by_id UUID REFERENCES refresh_tokens(id), + user_agent TEXT, + ip_address INET, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id, created_at DESC); + CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL; + + -- #234: documents — bond application PDF storage metadata (CBP Form 301, + -- power of attorney, commercial invoices, KYC ID, etc). Stores a + -- reference to the object-storage location, not the file bytes. + -- Distinct from kyc_documents (#312), which is specifically compliance + -- paperwork under its own retention/review workflow. + CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + importer_id UUID NOT NULL REFERENCES importers(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('cbp_301', 'power_of_attorney', 'commercial_invoice', 'kyc_id', 'other')), + filename TEXT NOT NULL, + url TEXT NOT NULL, + mime_type TEXT, + size_bytes BIGINT, + uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + + CREATE INDEX IF NOT EXISTS idx_documents_importer_kind ON documents(importer_id, kind, created_at DESC); + + -- #244: importer_documents_view — joins importers with kyc_documents and + -- kyc_status in a single query for the surety admin review workflow. + -- A plain view, not materialized — reads always reflect current data, + -- no refresh job needed (unlike importer_metrics_mv/importer_metrics). + CREATE OR REPLACE VIEW importer_documents_view AS + SELECT + i.id AS importer_id, + i.legal_name, + i.ein_hash, + i.bond_id, + i.stellar_address, + i.kyc_status, + i.created_at AS importer_created_at, + d.id AS document_id, + d.document_type, + d.review_status AS document_review_status, + d.reviewer_id, + d.reviewer_note, + d.reviewed_at AS document_reviewed_at, + d.scheduled_deletion_date AS document_scheduled_deletion_date, + d.created_at AS document_created_at + FROM importers i + LEFT JOIN kyc_documents d + ON d.importer_id = i.id AND d.deleted_at IS NULL + WHERE i.deleted_at IS NULL; + `); +} + +export async function down(client: PoolClient): Promise { + await client.query(` + DROP VIEW IF EXISTS importer_documents_view; + DROP TABLE IF EXISTS documents CASCADE; + DROP TABLE IF EXISTS refresh_tokens CASCADE; + DROP TABLE IF EXISTS bonds CASCADE; + DROP TABLE IF EXISTS audit_log CASCADE; + DROP INDEX IF EXISTS idx_importers_ein_hash; + ALTER TABLE importers DROP COLUMN IF EXISTS ein_hash; + `); +} diff --git a/apps/api/src/migrations/runner.ts b/apps/api/src/migrations/runner.ts index 50239ab..c59813c 100644 --- a/apps/api/src/migrations/runner.ts +++ b/apps/api/src/migrations/runner.ts @@ -1,12 +1,12 @@ -import fs from "fs/promises"; -import path from "path"; -import { fileURLToPath, pathToFileURL } from "url"; -import { pool } from "../db.js"; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { pool } from '../db.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -export async function runMigrations(action: "up" | "rollback" = "up"): Promise { +export async function runMigrations(action: 'up' | 'rollback' = 'up'): Promise { const client = await pool.connect(); try { // 1. Ensure tracking table exists @@ -21,15 +21,15 @@ export async function runMigrations(action: "up" | "rollback" = "up"): Promise migrationPattern.test(f) && !f.endsWith(".d.ts") && !f.endsWith(".map")) - .map(f => { + .filter((f) => migrationPattern.test(f) && !f.endsWith('.d.ts') && !f.endsWith('.map')) + .map((f) => { const match = f.match(migrationPattern)!; return { version: parseInt(match[1]!, 10), filename: f, - name: f.replace(/\.(ts|js)$/, "") + name: f.replace(/\.(ts|js)$/, ''), }; }) .sort((a, b) => a.version - b.version); @@ -43,70 +43,74 @@ export async function runMigrations(action: "up" | "rollback" = "up"): Promise m.version > highestApplied); + const pending = migrationFiles.filter((m) => m.version > highestApplied); if (pending.length === 0) { - console.log("No pending migrations to run."); + console.log('No pending migrations to run.'); return; } console.log(`Running ${pending.length} pending migrations...`); - await client.query("BEGIN"); + await client.query('BEGIN'); try { for (const m of pending) { const filePath = path.join(__dirname, m.filename); const fileUrl = pathToFileURL(filePath).href; const mod = await import(fileUrl); - if (typeof mod.up !== "function") { + if (typeof mod.up !== 'function') { throw new Error(`Migration ${m.filename} does not export an up function.`); } await mod.up(client); - await client.query( - "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)", - [m.version, m.name] - ); + await client.query('INSERT INTO schema_migrations (version, name) VALUES ($1, $2)', [ + m.version, + m.name, + ]); console.log(`Successfully applied migration: ${m.name}`); } - await client.query("COMMIT"); + await client.query('COMMIT'); } catch (err) { - await client.query("ROLLBACK"); - console.error("Migration transaction failed, rolled back changes."); + await client.query('ROLLBACK'); + console.error('Migration transaction failed, rolled back changes.'); throw err; } - } else if (action === "rollback") { + } else if (action === 'rollback') { // Find the highest applied migration - const res = await client.query("SELECT version, name FROM schema_migrations ORDER BY version DESC LIMIT 1"); + const res = await client.query( + 'SELECT version, name FROM schema_migrations ORDER BY version DESC LIMIT 1' + ); if (res.rows.length === 0) { - console.log("No migrations to rollback."); + console.log('No migrations to rollback.'); return; } const { version, name } = res.rows[0]; - const m = migrationFiles.find(mf => mf.version === version); + const m = migrationFiles.find((mf) => mf.version === version); if (!m) { - throw new Error(`Migration file for version ${version} (${name}) not found in migrations directory.`); + throw new Error( + `Migration file for version ${version} (${name}) not found in migrations directory.` + ); } console.log(`Rolling back migration: ${m.name}...`); - await client.query("BEGIN"); + await client.query('BEGIN'); try { const filePath = path.join(__dirname, m.filename); const fileUrl = pathToFileURL(filePath).href; const mod = await import(fileUrl); - if (typeof mod.down !== "function") { + if (typeof mod.down !== 'function') { throw new Error(`Migration ${m.filename} does not export a down function.`); } await mod.down(client); - await client.query("DELETE FROM schema_migrations WHERE version = $1", [version]); - await client.query("COMMIT"); + await client.query('DELETE FROM schema_migrations WHERE version = $1', [version]); + await client.query('COMMIT'); console.log(`Successfully rolled back migration: ${m.name}`); } catch (err) { - await client.query("ROLLBACK"); - console.error("Rollback transaction failed, rolled back changes."); + await client.query('ROLLBACK'); + console.error('Rollback transaction failed, rolled back changes.'); throw err; } } diff --git a/apps/api/src/queue.ts b/apps/api/src/queue.ts index 8716dfe..a0f43e4 100644 --- a/apps/api/src/queue.ts +++ b/apps/api/src/queue.ts @@ -1,13 +1,20 @@ -import { Queue, Worker } from "bullmq"; -import Redis from "ioredis"; -import { env } from "./config/env.js"; -import { pool } from "./db.js"; -import { contractClient, platformKeypair, suretyKeypair } from "./stellar.js"; -import { Keypair } from "@stellar/stellar-sdk"; -import { invalidateOnChainAccount } from "./cache.js"; +import { Queue, Worker } from 'bullmq'; +import Redis from 'ioredis'; +import { env } from './config/env.js'; +import { pool } from './db.js'; +import { contractClient, platformKeypair, suretyKeypair } from './stellar.js'; +import { Keypair } from '@stellar/stellar-sdk'; +import { invalidateOnChainAccount } from './cache.js'; export interface TxSubmitJobData { - method: "deposit" | "auto_top_up" | "withdraw" | "accrue_yield" | "clawback" | "set_required_collateral" | "register"; + method: + | 'deposit' + | 'auto_top_up' + | 'withdraw' + | 'accrue_yield' + | 'clawback' + | 'set_required_collateral' + | 'register'; importerId: string; keypairSecret?: string; platformKey?: boolean; @@ -22,9 +29,14 @@ export interface TxSubmitJobResult { applicationOrder: number; } -const connection = new Redis(env.REDIS_URL, { maxRetriesPerRequest: null, enableReadyCheck: false }); +const connection = new Redis(env.REDIS_URL, { + maxRetriesPerRequest: null, + enableReadyCheck: false, +}); -export const txSubmitQueue = new Queue("tx-submit", { connection }); +export const txSubmitQueue = new Queue('tx-submit', { + connection, +}); /** * Pings Redis to check connectivity (#263 — used by GET /health for load @@ -33,15 +45,15 @@ export const txSubmitQueue = new Queue("tx-s */ export async function pingRedis(): Promise { const reply = await connection.ping(); - if (reply !== "PONG") { + if (reply !== 'PONG') { throw new Error(`unexpected Redis PING reply: ${reply}`); } } export async function enqueueTxSubmit(data: TxSubmitJobData): Promise { - const job = await txSubmitQueue.add("submit", data, { + const job = await txSubmitQueue.add('submit', data, { attempts: 2, - backoff: { type: "exponential", delay: 2000 }, + backoff: { type: 'exponential', delay: 2000 }, removeOnComplete: true, removeOnFail: false, }); @@ -50,11 +62,13 @@ export async function enqueueTxSubmit(data: TxSubmitJobData): Promise { export function createTxSubmitWorker() { return new Worker( - "tx-submit", + 'tx-submit', async (job) => { const { method, importerId, keypairSecret, platformKey, suretyKey, args } = job.data; - const importer = await pool.query("SELECT * FROM importers WHERE id = $1", [importerId]).then((r) => r.rows[0]); + const importer = await pool + .query('SELECT * FROM importers WHERE id = $1', [importerId]) + .then((r) => r.rows[0]); if (!importer) throw new Error(`importer ${importerId} not found`); let key: Keypair; @@ -65,7 +79,7 @@ export function createTxSubmitWorker() { } else if (suretyKey) { key = suretyKeypair; } else { - throw new Error("no signing key provided"); + throw new Error('no signing key provided'); } const explorerTx = (hash: string) => `https://stellar.expert/explorer/testnet/tx/${hash}`; @@ -75,65 +89,69 @@ export function createTxSubmitWorker() { let eventAmount: string | null = null; switch (method) { - case "register": + case 'register': onChain = await contractClient.registerImporter( key, args.publicKey as string, BigInt(args.bondId as string), - BigInt(args.initialCollateral as string), + BigInt(args.initialCollateral as string) ); - eventKind = "register"; + eventKind = 'register'; break; - case "set_required_collateral": + case 'set_required_collateral': onChain = await contractClient.setRequiredCollateral( [key], args.importerAddress as string, BigInt(args.amountStroops as string), args.oracleContractId as string, - false, + false ); - eventKind = "required_changed"; - eventAmount = (args.amountStroops as string); + eventKind = 'required_changed'; + eventAmount = args.amountStroops as string; break; - case "deposit": + case 'deposit': { const fn = - args.bucket === "collateral" + args.bucket === 'collateral' ? contractClient.depositCollateral.bind(contractClient) : contractClient.depositReserve.bind(contractClient); onChain = await fn( key, args.importerAddress as string, args.sourceAddress as string, - BigInt(args.amountStroops as string), + BigInt(args.amountStroops as string) ); - eventKind = args.bucket === "collateral" ? "deposit_collateral" : "deposit_reserve"; - eventAmount = (args.amountStroops as string); + eventKind = args.bucket === 'collateral' ? 'deposit_collateral' : 'deposit_reserve'; + eventAmount = args.amountStroops as string; } break; - case "auto_top_up": + case 'auto_top_up': onChain = await contractClient.autoTopUp(key, args.importerAddress as string); - eventKind = "auto_top_up"; + eventKind = 'auto_top_up'; eventAmount = onChain.result.toString(); break; - case "withdraw": + case 'withdraw': onChain = await contractClient.withdrawCollateral( key, args.importerAddress as string, args.sourceAddress as string, - BigInt(args.amountStroops as string), + BigInt(args.amountStroops as string) ); - eventKind = "withdraw"; - eventAmount = (args.amountStroops as string); + eventKind = 'withdraw'; + eventAmount = args.amountStroops as string; break; - case "accrue_yield": - onChain = await contractClient.accrueYield(key, args.importerAddress as string, BigInt(args.amountStroops as string)); - eventKind = "yield"; - eventAmount = (args.amountStroops as string); + case 'accrue_yield': + onChain = await contractClient.accrueYield( + key, + args.importerAddress as string, + BigInt(args.amountStroops as string) + ); + eventKind = 'yield'; + eventAmount = args.amountStroops as string; break; - case "clawback": + case 'clawback': onChain = await contractClient.clawback(key, args.importerAddress as string); - eventKind = "clawback"; + eventKind = 'clawback'; eventAmount = onChain.result.toString(); break; } @@ -145,7 +163,14 @@ export function createTxSubmitWorker() { `INSERT INTO contract_events (importer_id, kind, amount, tx_hash, ledger_sequence, event_index) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT DO NOTHING`, - [importerId, eventKind, eventAmount, onChain.txHash, onChain.ledgerSequence, onChain.applicationOrder], + [ + importerId, + eventKind, + eventAmount, + onChain.txHash, + onChain.ledgerSequence, + onChain.applicationOrder, + ] ); // #246 — the route that enqueued this job already invalidated the @@ -153,7 +178,7 @@ export function createTxSubmitWorker() { // tx was confirmed on-chain. Invalidate again now, on confirmation, so // a GET that landed (and re-cached) in between doesn't leave stale // pre-write state cached for the rest of the 30s TTL. - if (method === "deposit" || method === "withdraw" || method === "clawback") { + if (method === 'deposit' || method === 'withdraw' || method === 'clawback') { await invalidateOnChainAccount(importerId); } @@ -164,6 +189,6 @@ export function createTxSubmitWorker() { applicationOrder: onChain.applicationOrder, }; }, - { connection }, + { connection } ); } diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index ca01f0f..dd6dab1 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,9 +1,15 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool, getStaleAccounts, refreshImporterMetricsView } from "../db.js"; -import { authMiddleware, requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; -import { platformKeypair, oracleKeypair, contractClient } from "../stellar.js"; -import { bustHtsCache } from "../services/hts-rate-validator.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool, getStaleAccounts, refreshImporterMetricsView } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; +import { platformKeypair, oracleKeypair, contractClient } from '../stellar.js'; +import { bustHtsCache } from '../services/hts-rate-validator.js'; export const adminRouter = Router(); adminRouter.use(authMiddleware); @@ -21,10 +27,10 @@ const AuditLogQuerySchema = z.object({ per_page: z.coerce.number().int().min(1).max(200).default(50), }); -adminRouter.get("/audit-log", requireRole("surety_admin"), async (req: Request, res: Response) => { +adminRouter.get('/audit-log', requireRole('surety_admin'), async (req: Request, res: Response) => { const parse = AuditLogQuerySchema.safeParse(req.query); if (!parse.success) { - res.status(400).json({ error: "invalid query params", details: parse.error.issues }); + res.status(400).json({ error: 'invalid query params', details: parse.error.issues }); return; } const { actor_user_id, action, from, to, page, per_page } = parse.data; @@ -50,13 +56,13 @@ adminRouter.get("/audit-log", requireRole("surety_admin"), async (req: Request, conditions.push(`created_at <= $${params.length}`); } - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const countResult = await pool.query<{ count: string }>( `SELECT COUNT(*) AS count FROM audit_log ${where}`, - params, + params ); - const total = parseInt(countResult.rows[0]?.count ?? "0", 10); + const total = parseInt(countResult.rows[0]?.count ?? '0', 10); params.push(per_page, offset); const dataResult = await pool.query( @@ -64,7 +70,7 @@ adminRouter.get("/audit-log", requireRole("surety_admin"), async (req: Request, FROM audit_log ${where} ORDER BY created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, + params ); res.json({ @@ -81,10 +87,10 @@ adminRouter.get("/audit-log", requireRole("surety_admin"), async (req: Request, }); }); -adminRouter.get("/oracle-alerts", async (req: Request, res: Response) => { +adminRouter.get('/oracle-alerts', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - if (user.role !== "surety_admin") { - res.status(403).json({ error: "surety admin only" }); + if (user.role !== 'surety_admin') { + res.status(403).json({ error: 'surety admin only' }); return; } @@ -92,12 +98,12 @@ adminRouter.get("/oracle-alerts", async (req: Request, res: Response) => { const offset = parseInt(req.query.offset as string) || 0; const r = await pool.query( - "SELECT * FROM oracle_alerts ORDER BY alerted_at DESC LIMIT $1 OFFSET $2", + 'SELECT * FROM oracle_alerts ORDER BY alerted_at DESC LIMIT $1 OFFSET $2', [limit, offset] ); - - const countR = await pool.query("SELECT COUNT(*) FROM oracle_alerts"); - const total = parseInt(countR.rows[0]?.count || "0"); + + const countR = await pool.query('SELECT COUNT(*) FROM oracle_alerts'); + const total = parseInt(countR.rows[0]?.count || '0'); res.json({ alerts: r.rows, @@ -107,21 +113,21 @@ adminRouter.get("/oracle-alerts", async (req: Request, res: Response) => { }); }); -adminRouter.patch("/oracle-alerts/:id/acknowledge", async (req: Request, res: Response) => { +adminRouter.patch('/oracle-alerts/:id/acknowledge', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - if (user.role !== "surety_admin") { - res.status(403).json({ error: "surety admin only" }); + if (user.role !== 'surety_admin') { + res.status(403).json({ error: 'surety admin only' }); return; } const alertId = req.params.id; const r = await pool.query( - "UPDATE oracle_alerts SET acknowledged_at = now() WHERE id = $1 RETURNING *", + 'UPDATE oracle_alerts SET acknowledged_at = now() WHERE id = $1 RETURNING *', [alertId] ); if (r.rowCount === 0) { - res.status(404).json({ error: "alert not found" }); + res.status(404).json({ error: 'alert not found' }); return; } @@ -129,7 +135,7 @@ adminRouter.patch("/oracle-alerts/:id/acknowledge", async (req: Request, res: Re }); // #339 — GET /admin/roles — operational visibility into current role addresses -adminRouter.get("/roles", requireRole("surety_admin"), (_req: Request, res: Response) => { +adminRouter.get('/roles', requireRole('surety_admin'), (_req: Request, res: Response) => { res.json({ generalAdmin: platformKeypair.publicKey(), oracleAdmin: oracleKeypair.publicKey(), @@ -139,49 +145,52 @@ adminRouter.get("/roles", requireRole("surety_admin"), (_req: Request, res: Resp // #322 — POST /admin/privacy-policy/publish — publish a new privacy policy version adminRouter.post( - "/privacy-policy/publish", - requireRole("surety_admin"), + '/privacy-policy/publish', + requireRole('surety_admin'), async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const parse = z.object({ - versionId: z.string().min(1), - effectiveDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), - changeSummary: z.string().min(10), - policyText: z.string().optional(), - requiresReacceptance: z.boolean().default(false), - }).safeParse(req.body); + const parse = z + .object({ + versionId: z.string().min(1), + effectiveDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + changeSummary: z.string().min(10), + policyText: z.string().optional(), + requiresReacceptance: z.boolean().default(false), + }) + .safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } - const { versionId, effectiveDate, changeSummary, policyText, requiresReacceptance } = parse.data; + const { versionId, effectiveDate, changeSummary, policyText, requiresReacceptance } = + parse.data; const result = await pool.query( `INSERT INTO privacy_policy_versions (version_id, effective_date, policy_text, change_summary, requires_reacceptance, published_by) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, version_id, effective_date, requires_reacceptance, published_at`, - [versionId, effectiveDate, policyText ?? null, changeSummary, requiresReacceptance, user.id], + [versionId, effectiveDate, policyText ?? null, changeSummary, requiresReacceptance, user.id] ); if (requiresReacceptance) { // Flag all active users so their next request returns 403 with reason await pool.query( `UPDATE users SET privacy_reacceptance_required = TRUE - WHERE role IN ('importer', 'surety_admin')`, + WHERE role IN ('importer', 'surety_admin')` ); } res.status(201).json({ version: result.rows[0] }); - }, + } ); // SOC 2 CC6.2: quarterly access review — surfaces accounts with no successful login // in the past N days (default 90). Intended for use by the platform security team. adminRouter.get( - "/access-review", - requireRole("surety_admin"), + '/access-review', + requireRole('surety_admin'), async (req: Request, res: Response) => { const days = Math.max(1, parseInt(req.query.days as string) || 90); const accounts = await getStaleAccounts(days); @@ -190,7 +199,7 @@ adminRouter.get( count: accounts.length, accounts, }); - }, + } ); // ── HTS rate cache management ───────────────────────────────────────────────── @@ -207,15 +216,15 @@ adminRouter.get( * The next lookup for any busted code will re-fetch from the USITC HTS API. */ adminRouter.post( - "/refresh-hts-cache", - requireRole("surety_admin"), + '/refresh-hts-cache', + requireRole('surety_admin'), async (req: Request, res: Response) => { const parse = z .object({ htsCodes: z.array(z.string()).optional().default([]) }) .safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } @@ -224,12 +233,12 @@ adminRouter.post( res.json({ message: parse.data.htsCodes.length === 0 - ? "Full HTS rate cache cleared" + ? 'Full HTS rate cache cleared' : `Cache busted for ${parse.data.htsCodes.length} HTS code(s)`, deletedRows: deleted, - htsCodes: parse.data.htsCodes.length > 0 ? parse.data.htsCodes : "all", + htsCodes: parse.data.htsCodes.length > 0 ? parse.data.htsCodes : 'all', }); - }, + } ); // ── Oracle price feed endpoints ─────────────────────────────────────────────── @@ -255,12 +264,12 @@ const OracleFeedQuerySchema = z.object({ * per_page integer 1–200, default 50 */ adminRouter.get( - "/oracle-feed", - requireRole("surety_admin"), + '/oracle-feed', + requireRole('surety_admin'), async (req: Request, res: Response) => { const parse = OracleFeedQuerySchema.safeParse(req.query); if (!parse.success) { - res.status(400).json({ error: "invalid query params", details: parse.error.issues }); + res.status(400).json({ error: 'invalid query params', details: parse.error.issues }); return; } const { importer_id, from, to, page, per_page } = parse.data; @@ -283,14 +292,14 @@ adminRouter.get( conditions.push(`created_at <= $${params.length}`); } - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Count total matching rows. const countResult = await pool.query<{ count: string }>( `SELECT COUNT(*) AS count FROM oracle_price_feed ${where}`, - params, + params ); - const total = parseInt(countResult.rows[0]?.count ?? "0", 10); + const total = parseInt(countResult.rows[0]?.count ?? '0', 10); // Fetch the page. params.push(per_page, offset); @@ -305,7 +314,7 @@ adminRouter.get( ${where} ORDER BY created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`, - params, + params ); res.json({ @@ -320,7 +329,7 @@ adminRouter.get( total_pages: Math.ceil(total / per_page), }, }); - }, + } ); /** @@ -330,13 +339,13 @@ adminRouter.get( * Optional query params: importer_id, from, to (same as the paginated endpoint). */ adminRouter.get( - "/oracle-feed/export.csv", - requireRole("surety_admin"), + '/oracle-feed/export.csv', + requireRole('surety_admin'), async (req: Request, res: Response) => { const filterSchema = OracleFeedQuerySchema.omit({ page: true, per_page: true }); const parse = filterSchema.safeParse(req.query); if (!parse.success) { - res.status(400).json({ error: "invalid query params", details: parse.error.issues }); + res.status(400).json({ error: 'invalid query params', details: parse.error.issues }); return; } const { importer_id, from, to } = parse.data; @@ -357,7 +366,7 @@ adminRouter.get( conditions.push(`created_at <= $${params.length}`); } - const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; const rows = await pool.query( `SELECT id, importer_id, importer_address, @@ -368,24 +377,24 @@ adminRouter.get( to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') AS created_at FROM oracle_price_feed ${where} ORDER BY created_at ASC`, - params, + params ); const CSV_HEADER = - "id,importer_id,importer_address,required_collateral,previous_collateral," + - "pct_change,tx_hash,ledger_sequence,set_by,emergency_override,created_at\n"; + 'id,importer_id,importer_address,required_collateral,previous_collateral,' + + 'pct_change,tx_hash,ledger_sequence,set_by,emergency_override,created_at\n'; - res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader( - "Content-Disposition", - `attachment; filename="oracle_price_feed_${new Date().toISOString().slice(0, 10)}.csv"`, + 'Content-Disposition', + `attachment; filename="oracle_price_feed_${new Date().toISOString().slice(0, 10)}.csv"` ); res.write(CSV_HEADER); for (const row of rows.rows) { const line = [ row.id, - row.importer_id ?? "", + row.importer_id ?? '', row.importer_address, row.required_collateral, row.previous_collateral, @@ -396,12 +405,12 @@ adminRouter.get( row.emergency_override, row.created_at, ] - .map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`) - .join(","); - res.write(line + "\n"); + .map((v) => `"${String(v ?? '').replace(/"/g, '""')}"`) + .join(','); + res.write(line + '\n'); } res.end(); - }, + } ); // ── #247: POST /admin/auto-top-up — batch auto-top-up ────────────────────── @@ -446,7 +455,7 @@ interface AutoTopUpSuccess { async function mapWithConcurrency( items: T[], concurrency: number, - worker: (item: T) => Promise, + worker: (item: T) => Promise ): Promise[]> { const results: PromiseSettledResult[] = new Array(items.length); let nextIndex = 0; @@ -456,9 +465,9 @@ async function mapWithConcurrency( const current = nextIndex++; if (current >= items.length) return; try { - results[current] = { status: "fulfilled", value: await worker(items[current]!) }; + results[current] = { status: 'fulfilled', value: await worker(items[current]!) }; } catch (reason) { - results[current] = { status: "rejected", reason }; + results[current] = { status: 'rejected', reason }; } } } @@ -472,66 +481,80 @@ const BatchAutoTopUpSchema = z.object({ importer_ids: z.array(z.string().uuid()).optional(), }); -adminRouter.post("/auto-top-up", requireRole("surety_admin"), async (req: Request, res: Response) => { - const parse = BatchAutoTopUpSchema.safeParse(req.body ?? {}); - if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); - return; - } - const { importer_ids } = parse.data; +adminRouter.post( + '/auto-top-up', + requireRole('surety_admin'), + async (req: Request, res: Response) => { + const parse = BatchAutoTopUpSchema.safeParse(req.body ?? {}); + if (!parse.success) { + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); + return; + } + const { importer_ids } = parse.data; - const params: unknown[] = []; - let filterClause = ""; - if (importer_ids && importer_ids.length > 0) { - params.push(importer_ids); - filterClause = `AND im.importer_id = ANY($${params.length}::uuid[])`; - } + const params: unknown[] = []; + let filterClause = ''; + if (importer_ids && importer_ids.length > 0) { + params.push(importer_ids); + filterClause = `AND im.importer_id = ANY($${params.length}::uuid[])`; + } - // Single query for every eligible importer — required kyc_status = 'approved' - // (#229) since auto-top-up moves an importer's own collateral funds, and the - // importer_metrics view already excludes soft-deleted importers. - const eligible = await pool.query( - `SELECT im.importer_id, im.stellar_address + // Single query for every eligible importer — required kyc_status = 'approved' + // (#229) since auto-top-up moves an importer's own collateral funds, and the + // importer_metrics view already excludes soft-deleted importers. + const eligible = await pool.query( + `SELECT im.importer_id, im.stellar_address FROM importer_metrics im JOIN importers i ON i.id = im.importer_id WHERE i.kyc_status = 'approved' AND im.required_collateral > 0 AND im.current_balance < im.required_collateral ${filterClause}`, - params, - ); + params + ); - const outcomes = await mapWithConcurrency( - eligible.rows, - CONCURRENCY_CAP, - async (row): Promise => { - const onChain = await contractClient.autoTopUp(platformKeypair, row.stellar_address); - // Bare ON CONFLICT DO NOTHING: correct whether contract_events is - // partitioned (#228) or not — see lib/contract-events-partitions.ts - // for why an explicit column list can't be used once it is. - await pool.query( - `INSERT INTO contract_events (importer_id, kind, amount, tx_hash, ledger_sequence, event_index) + const outcomes = await mapWithConcurrency( + eligible.rows, + CONCURRENCY_CAP, + async (row): Promise => { + const onChain = await contractClient.autoTopUp(platformKeypair, row.stellar_address); + // Bare ON CONFLICT DO NOTHING: correct whether contract_events is + // partitioned (#228) or not — see lib/contract-events-partitions.ts + // for why an explicit column list can't be used once it is. + await pool.query( + `INSERT INTO contract_events (importer_id, kind, amount, tx_hash, ledger_sequence, event_index) VALUES ($1, 'auto_top_up', $2, $3, $4, $5) ON CONFLICT DO NOTHING`, - [row.importer_id, onChain.result.toString(), onChain.txHash, onChain.ledgerSequence, onChain.applicationOrder], - ); - return { importerId: row.importer_id, txHash: onChain.txHash, amount: onChain.result.toString() }; - }, - ); + [ + row.importer_id, + onChain.result.toString(), + onChain.txHash, + onChain.ledgerSequence, + onChain.applicationOrder, + ] + ); + return { + importerId: row.importer_id, + txHash: onChain.txHash, + amount: onChain.result.toString(), + }; + } + ); - const errors: Array<{ id: string; reason: string }> = []; - let succeeded = 0; - outcomes.forEach((outcome, i) => { - if (outcome.status === "fulfilled") { - succeeded += 1; - } else { - errors.push({ id: eligible.rows[i]!.importer_id, reason: String(outcome.reason) }); - } - }); + const errors: Array<{ id: string; reason: string }> = []; + let succeeded = 0; + outcomes.forEach((outcome, i) => { + if (outcome.status === 'fulfilled') { + succeeded += 1; + } else { + errors.push({ id: eligible.rows[i]!.importer_id, reason: String(outcome.reason) }); + } + }); - // Best-effort refresh so a subsequent call sees updated balances; a - // failure here shouldn't turn a successful batch into an error response. - await refreshImporterMetricsView().catch(() => undefined); + // Best-effort refresh so a subsequent call sees updated balances; a + // failure here shouldn't turn a successful batch into an error response. + await refreshImporterMetricsView().catch(() => undefined); - res.json({ succeeded, failed: errors.length, errors }); -}); + res.json({ succeeded, failed: errors.length, errors }); + } +); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index fb30654..6099789 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,5 +1,6 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; +import { Router, type Request, type Response } from 'express'; +import rateLimit from 'express-rate-limit'; +import { z } from 'zod'; import { pool, recordAuthenticationAttempt, @@ -13,30 +14,51 @@ import { validateRefreshToken, rotateRefreshToken, revokeRefreshToken, -} from "../db.js"; -import { hashPassword, verifyPassword, signToken, authMiddleware, MAX_SESSIONS, type AuthedRequest } from "../auth.js"; -import { env } from "../config/env.js"; -import { createHash, randomBytes } from "crypto"; +} from '../db.js'; +import { + hashPassword, + verifyPassword, + signToken, + authMiddleware, + MAX_SESSIONS, + type AuthedRequest, +} from '../auth.js'; +import { env } from '../config/env.js'; +import { createHash, randomBytes } from 'crypto'; export const authRouter = Router(); -const ACCESS_TOKEN_EXPIRY = "15m"; +// Looser than the signup/login limiter applied in index.ts: these routes +// require an already-valid session (authMiddleware), so they aren't +// credential-guessing targets the way signup/login are, but still +// shouldn't be uncapped. +const sessionLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'too many requests; try again shortly' }, +}); + const REFRESH_TOKEN_EXPIRY_DAYS = 30; function hashToken(raw: string): string { - return createHash("sha256").update(raw).digest("hex"); + return createHash('sha256').update(raw).digest('hex'); } -function generateRefreshTokenPair(userId: string, req: { ip?: string; get?: (h: string) => string | undefined }) { - const rawToken = randomBytes(64).toString("hex"); +function generateRefreshTokenPair( + userId: string, + req: { ip?: string; get?: (h: string) => string | undefined } +) { + const rawToken = randomBytes(64).toString('hex'); const tokenHash = hashToken(rawToken); const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000); const tokenPromise = createRefreshToken( userId, tokenHash, expiresAt, - req.get?.("user-agent") ?? undefined, - req.ip ?? undefined, + req.get?.('user-agent') ?? undefined, + req.ip ?? undefined ); return { rawToken, tokenPromise, expiresAt }; } @@ -44,18 +66,18 @@ function generateRefreshTokenPair(userId: string, req: { ip?: string; get?: (h: const SignupSchema = z.object({ email: z.string().email().toLowerCase(), password: z.string().min(8), - role: z.enum(["importer", "surety_admin"]).default("importer"), + role: z.enum(['importer', 'surety_admin']).default('importer'), accept_tos: z.boolean().refine((val) => val === true, { - message: "Terms of Service must be accepted", + message: 'Terms of Service must be accepted', }), // #322 — accept the current privacy policy version at signup privacyPolicyVersionId: z.string().optional(), }); -authRouter.post("/signup", async (req: Request, res: Response) => { +authRouter.post('/signup', async (req: Request, res: Response) => { const parse = SignupSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } const { email, password, role, privacyPolicyVersionId } = parse.data; @@ -65,14 +87,14 @@ authRouter.post("/signup", async (req: Request, res: Response) => { let policyVersionId = privacyPolicyVersionId; if (!policyVersionId) { const latestPolicy = await pool.query( - "SELECT version_id FROM privacy_policy_versions ORDER BY effective_date DESC LIMIT 1", + 'SELECT version_id FROM privacy_policy_versions ORDER BY effective_date DESC LIMIT 1' ); policyVersionId = latestPolicy.rows[0]?.version_id; } const result = await pool.query( - "INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id, email, role", - [email, hash, role], + 'INSERT INTO users (email, password_hash, role) VALUES ($1, $2, $3) RETURNING id, email, role', + [email, hash, role] ); const u = result.rows[0]!; @@ -83,32 +105,38 @@ authRouter.post("/signup", async (req: Request, res: Response) => { (user_id, policy_version_id, ip_address, acceptance_channel) VALUES ($1, $2, $3, 'signup') ON CONFLICT (user_id, policy_version_id) DO NOTHING`, - [u.id, policyVersionId, req.ip ?? null], + [u.id, policyVersionId, req.ip ?? null] ); } // Record ToS acceptance at signup (#321) - const latestTos = await pool.query("SELECT version_id FROM tos_versions ORDER BY effective_date DESC LIMIT 1"); + const latestTos = await pool.query( + 'SELECT version_id FROM tos_versions ORDER BY effective_date DESC LIMIT 1' + ); if (latestTos.rowCount) { await pool.query( `INSERT INTO tos_acceptances (user_id, tos_version, accepted_at, ip_address, user_agent, acceptance_method) VALUES ($1, $2, now(), $3, $4, 'signup')`, - [u.id, latestTos.rows[0]?.version_id, req.ip ?? null, req.get("user-agent") ?? null], + [u.id, latestTos.rows[0]?.version_id, req.ip ?? null, req.get('user-agent') ?? null] ); } // #324 — surety_admin accounts start with a pending license verification record. // Operational routes (clawback, accrue-yield) are blocked until a platform admin // marks the record as 'verified' after checking NAIC / state DOI licensing data. - if (role === "surety_admin") { + if (role === 'surety_admin') { await pool.query( `INSERT INTO surety_license_verifications (user_id) VALUES ($1) ON CONFLICT (user_id) DO NOTHING`, - [u.id], + [u.id] ); } - const sessionId = await createSession(u.id, req.ip ?? undefined, req.get("user-agent") ?? undefined); + const sessionId = await createSession( + u.id, + req.ip ?? undefined, + req.get('user-agent') ?? undefined + ); const refreshToken = generateRefreshTokenPair(u.id, req); await refreshToken.tokenPromise; res.json({ @@ -118,8 +146,8 @@ authRouter.post("/signup", async (req: Request, res: Response) => { }); } catch (err) { const e = err as { code?: string }; - if (e.code === "23505") { - res.status(409).json({ error: "email already registered" }); + if (e.code === '23505') { + res.status(409).json({ error: 'email already registered' }); return; } throw err; @@ -131,43 +159,44 @@ const LoginSchema = z.object({ password: z.string(), }); -authRouter.post("/login", async (req: Request, res: Response) => { +authRouter.post('/login', async (req: Request, res: Response) => { const parse = LoginSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input" }); + res.status(400).json({ error: 'invalid input' }); return; } const email = parse.data.email; - const ipAddress = req.ip ?? "unknown"; - const userAgent = req.get("user-agent") ?? "unknown"; + const ipAddress = req.ip ?? 'unknown'; + const userAgent = req.get('user-agent') ?? 'unknown'; const failedAttempts = await getFailedAuthAttempts(email, 30); if (failedAttempts >= 10) { - await recordSecurityIncident("P1", `Brute-force attack detected on account ${email}`, email); - res.status(429).json({ error: "too many failed attempts, account locked for 30 minutes" }); + await recordSecurityIncident('P1', `Brute-force attack detected on account ${email}`, email); + res.status(429).json({ error: 'too many failed attempts, account locked for 30 minutes' }); await recordAuthenticationAttempt(email, false, undefined, ipAddress, userAgent); return; } - const r = await pool.query("SELECT id, email, password_hash, role, locked_until FROM users WHERE email = $1", [ - email, - ]); + const r = await pool.query( + 'SELECT id, email, password_hash, role, locked_until FROM users WHERE email = $1', + [email] + ); if (r.rowCount === 0) { await recordAuthenticationAttempt(email, false, undefined, ipAddress, userAgent); - res.status(401).json({ error: "invalid credentials" }); + res.status(401).json({ error: 'invalid credentials' }); return; } const u = r.rows[0]!; if (u.locked_until && new Date(u.locked_until) > new Date()) { - res.status(403).json({ error: "account temporarily locked, try again later" }); + res.status(403).json({ error: 'account temporarily locked, try again later' }); return; } if (!(await verifyPassword(parse.data.password, u.password_hash))) { await recordAuthenticationAttempt(email, false, u.id, ipAddress, userAgent); - res.status(401).json({ error: "invalid credentials" }); + res.status(401).json({ error: 'invalid credentials' }); return; } @@ -192,7 +221,7 @@ authRouter.post("/login", async (req: Request, res: Response) => { }); }); -authRouter.post("/logout", authMiddleware, async (req: Request, res: Response) => { +authRouter.post('/logout', sessionLimiter, authMiddleware, async (req: Request, res: Response) => { const { sessionId } = (req as AuthedRequest).user; if (sessionId) { await revokeSession(sessionId); @@ -201,7 +230,7 @@ authRouter.post("/logout", authMiddleware, async (req: Request, res: Response) = if (tokenHash) { await revokeRefreshToken(tokenHash); } - res.json({ message: "logged out" }); + res.json({ message: 'logged out' }); }); // ── #235: POST /auth/refresh — rotate refresh token and issue new access token ── @@ -210,48 +239,52 @@ const RefreshSchema = z.object({ refreshToken: z.string().min(1), }); -authRouter.post("/refresh", async (req: Request, res: Response) => { +authRouter.post('/refresh', async (req: Request, res: Response) => { const parse = RefreshSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input" }); + res.status(400).json({ error: 'invalid input' }); return; } const tokenHash = hashToken(parse.data.refreshToken); const existing = await validateRefreshToken(tokenHash); if (!existing) { - res.status(401).json({ error: "invalid or expired refresh token" }); + res.status(401).json({ error: 'invalid or expired refresh token' }); return; } - const newRefreshToken = randomBytes(64).toString("hex"); + const newRefreshToken = randomBytes(64).toString('hex'); const newRefreshHash = hashToken(newRefreshToken); const newExpiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY_DAYS * 24 * 60 * 60 * 1000); - const newId = await rotateRefreshToken(existing.id, newRefreshHash, newExpiresAt); + await rotateRefreshToken(existing.id, newRefreshHash, newExpiresAt); const user = await pool.query<{ email: string; role: string }>( - "SELECT email, role FROM users WHERE id = $1", - [existing.userId], + 'SELECT email, role FROM users WHERE id = $1', + [existing.userId] ); if (!user.rowCount) { - res.status(401).json({ error: "user not found" }); + res.status(401).json({ error: 'user not found' }); return; } const u = user.rows[0]!; - const sessionId = await createSession(existing.userId, req.ip ?? undefined, req.get("user-agent") ?? undefined); + const sessionId = await createSession( + existing.userId, + req.ip ?? undefined, + req.get('user-agent') ?? undefined + ); const accessToken = signToken({ id: existing.userId, email: u.email, - role: u.role as "importer" | "surety_admin", + role: u.role as 'importer' | 'surety_admin', sessionId, }); res.json({ token: accessToken, refreshToken: newRefreshToken }); }); -authRouter.get("/me", authMiddleware, (req: Request, res: Response) => { +authRouter.get('/me', sessionLimiter, authMiddleware, (req: Request, res: Response) => { res.json({ user: (req as AuthedRequest).user }); }); @@ -268,11 +301,11 @@ authRouter.get("/me", authMiddleware, (req: Request, res: Response) => { // Metadata endpoint: // GET /auth/saml/metadata → SP metadata XML -const SAML_PROVIDERS = ["okta", "azure"] as const; +const SAML_PROVIDERS = ['okta', 'azure'] as const; type SamlProvider = (typeof SAML_PROVIDERS)[number]; function getSamlConfig(provider: SamlProvider): Record | null { - if (provider === "okta") { + if (provider === 'okta') { if (!env.SAML_OKTA_ENTRY_POINT || !env.SAML_OKTA_CERT) return null; return { entryPoint: env.SAML_OKTA_ENTRY_POINT, cert: env.SAML_OKTA_CERT }; } @@ -281,9 +314,9 @@ function getSamlConfig(provider: SamlProvider): Record | null { } // GET /auth/saml/metadata -authRouter.get("/saml/metadata", (_req: Request, res: Response) => { - const entityId = env.SAML_SP_ENTITY_ID ?? "https://tariffshield.io/saml/metadata"; - const acsUrl = env.SAML_SP_ACS_URL ?? "https://tariffshield.io/auth/saml/okta/callback"; +authRouter.get('/saml/metadata', (_req: Request, res: Response) => { + const entityId = env.SAML_SP_ENTITY_ID ?? 'https://tariffshield.io/saml/metadata'; + const acsUrl = env.SAML_SP_ACS_URL ?? 'https://tariffshield.io/auth/saml/okta/callback'; const xml = ` { Location="${acsUrl}" index="1"/> `; - res.set("Content-Type", "application/xml").send(xml); + res.set('Content-Type', 'application/xml').send(xml); }); // GET /auth/saml/:provider/login — SP-initiated SSO; redirects to IdP -authRouter.get("/saml/:provider/login", (req: Request, res: Response) => { +authRouter.get('/saml/:provider/login', (req: Request, res: Response) => { const provider = req.params.provider as SamlProvider; if (!SAML_PROVIDERS.includes(provider)) { - res.status(404).json({ error: "unknown SAML provider" }); + res.status(404).json({ error: 'unknown SAML provider' }); return; } const cfg = getSamlConfig(provider); if (!cfg) { - res.status(501).json({ error: `SAML SSO for '${provider}' is not configured on this instance` }); + res + .status(501) + .json({ error: `SAML SSO for '${provider}' is not configured on this instance` }); return; } - const entityId = env.SAML_SP_ENTITY_ID ?? "https://tariffshield.io/saml/metadata"; - const acsUrl = env.SAML_SP_ACS_URL ?? `https://tariffshield.io/auth/saml/${provider}/callback`; - const relayState = String(req.query.relay ?? ""); + const entityId = env.SAML_SP_ENTITY_ID ?? 'https://tariffshield.io/saml/metadata'; + const acsUrl = env.SAML_SP_ACS_URL ?? `https://tariffshield.io/auth/saml/${provider}/callback`; + const relayState = String(req.query.relay ?? ''); // Build a minimal SP-initiated AuthnRequest redirect URL. // In production, use passport-saml or samlify for signed AuthnRequests. @@ -325,18 +360,18 @@ authRouter.get("/saml/:provider/login", (req: Request, res: Response) => { `${entityId}` + ``; - const encoded = Buffer.from(authnRequest).toString("base64"); + const encoded = Buffer.from(authnRequest).toString('base64'); const params = new URLSearchParams({ SAMLRequest: encoded }); - if (relayState) params.set("RelayState", relayState); + if (relayState) params.set('RelayState', relayState); res.redirect(`${cfg.entryPoint}?${params.toString()}`); }); // POST /auth/saml/:provider/callback — receive and validate SAMLResponse, issue JWT -authRouter.post("/saml/:provider/callback", async (req: Request, res: Response) => { +authRouter.post('/saml/:provider/callback', async (req: Request, res: Response) => { const provider = req.params.provider as SamlProvider; if (!SAML_PROVIDERS.includes(provider)) { - res.status(404).json({ error: "unknown SAML provider" }); + res.status(404).json({ error: 'unknown SAML provider' }); return; } const cfg = getSamlConfig(provider); @@ -347,7 +382,7 @@ authRouter.post("/saml/:provider/callback", async (req: Request, res: Response) const samlResponse = req.body?.SAMLResponse as string | undefined; if (!samlResponse) { - res.status(400).json({ error: "missing SAMLResponse" }); + res.status(400).json({ error: 'missing SAMLResponse' }); return; } @@ -355,21 +390,39 @@ authRouter.post("/saml/:provider/callback", async (req: Request, res: Response) // Production: replace with passport-saml Strategy.verify() for full signature validation. let decoded: string; try { - decoded = Buffer.from(samlResponse, "base64").toString("utf8"); + decoded = Buffer.from(samlResponse, 'base64').toString('utf8'); } catch { - res.status(400).json({ error: "malformed SAMLResponse" }); + res.status(400).json({ error: 'malformed SAMLResponse' }); + return; + } + + // Bound the input to the regexes below before they see it. A real SAML + // assertion (including an embedded signing certificate) is a few KB; + // 50 KB is generous headroom. Without a cap, an attacker can pick + // decoded's length freely, and matching against unbounded attacker- + // controlled input is itself the "uncontrolled data" half of a ReDoS — + // no fixed regex rewrite closes that off on its own. + const MAX_SAML_RESPONSE_LENGTH = 50_000; + if (decoded.length > MAX_SAML_RESPONSE_LENGTH) { + res.status(400).json({ error: 'SAMLResponse too large' }); return; } // Extract NameID and email from assertion attributes const nameIdMatch = decoded.match(/<(?:saml:|)NameID[^>]*>([^<]+)<\/(?:saml:|)NameID>/); - const emailMatch = decoded.match(/Name="(?:email|mail|emailAddress)[^"]*"\s*[^>]*>\s*<(?:saml:|)AttributeValue[^>]*>([^<]+)<\/(?:saml:|)AttributeValue>/i); + // `[^>]*` already matches whitespace, so a `\s*` directly in front of it is + // redundant and ambiguous — the two quantifiers can split a run of spaces + // in exponentially many ways, which is a polynomial/catastrophic + // backtracking hazard on attacker-controlled SAMLResponse XML. + const emailMatch = decoded.match( + /Name="(?:email|mail|emailAddress)[^"]*"[^>]*>\s*<(?:saml:|)AttributeValue[^>]*>([^<]+)<\/(?:saml:|)AttributeValue>/i + ); const nameId = nameIdMatch?.[1]?.trim(); - const email = emailMatch?.[1]?.trim(); + const email = emailMatch?.[1]?.trim(); if (!nameId) { - res.status(401).json({ error: "SAML assertion missing NameID" }); + res.status(401).json({ error: 'SAML assertion missing NameID' }); return; } @@ -379,11 +432,11 @@ authRouter.post("/saml/:provider/callback", async (req: Request, res: Response) const existing = await pool.query( `SELECT id, email, role FROM users WHERE saml_subject_id = $1 AND idp_entity_id = $2`, - [nameId, idpEntityId], + [nameId, idpEntityId] ); let userId: string; - let userRole: "surety_admin" = "surety_admin"; + let userRole = 'surety_admin' as const; if (existing.rowCount && existing.rowCount > 0) { userId = existing.rows[0]!.id; @@ -396,19 +449,23 @@ authRouter.post("/saml/:provider/callback", async (req: Request, res: Response) idp_entity_id = EXCLUDED.idp_entity_id, idp_provider = EXCLUDED.idp_provider RETURNING id, role`, - [userEmail, "__saml__no_password__", nameId, idpEntityId, provider], + [userEmail, '__saml__no_password__', nameId, idpEntityId, provider] ); userId = inserted.rows[0]!.id; userRole = inserted.rows[0]!.role; } - const sessionId = await createSession(userId, req.ip ?? undefined, req.get("user-agent") ?? undefined); + const sessionId = await createSession( + userId, + req.ip ?? undefined, + req.get('user-agent') ?? undefined + ); const token = signToken({ id: userId, email: userEmail, role: userRole, sessionId }); const relayState = req.body?.RelayState as string | undefined; // Redirect browser to frontend with token, or return JSON for API clients - const accept = req.headers.accept ?? ""; - if (accept.includes("text/html") && relayState?.startsWith("/")) { + const accept = req.headers.accept ?? ''; + if (accept.includes('text/html') && relayState?.startsWith('/')) { res.redirect(`${relayState}?token=${encodeURIComponent(token)}`); } else { res.json({ token, user: { id: userId, email: userEmail, role: userRole } }); diff --git a/apps/api/src/routes/bond-signatures.ts b/apps/api/src/routes/bond-signatures.ts index 6ea2a0a..4c4d592 100644 --- a/apps/api/src/routes/bond-signatures.ts +++ b/apps/api/src/routes/bond-signatures.ts @@ -8,11 +8,17 @@ // All DocuSign API calls are stubbed; swap in the DocuSign Node SDK when // DOCUSIGN_INTEGRATION_KEY is configured. -import crypto from "node:crypto"; -import { Router, type Request, type Response } from "express"; -import { pool } from "../db.js"; -import { authMiddleware, requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; -import { env } from "../config/env.js"; +import crypto from 'node:crypto'; +import { Router, type Request, type Response } from 'express'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; +import { env } from '../config/env.js'; // bondSignaturesRouter — authenticated routes (send-for-signature, status, reminder) export const bondSignaturesRouter = Router(); @@ -27,15 +33,15 @@ export const bondWebhookRouter = Router(); async function createDocuSignEnvelope( bondId: string, importerEmail: string, - importerName: string, - suretyEmail: string, + _importerName: string, + _suretyEmail: string ): Promise<{ envelopeId: string; signingUrl: string }> { if (env.DOCUSIGN_INTEGRATION_KEY) { // Production: POST /v2.1/accounts/{accountId}/envelopes via DocuSign SDK // const dsApiClient = new docusign.ApiClient(); // dsApiClient.setBasePath(env.DOCUSIGN_BASE_PATH); // ... JWT grant, create envelope with Form 301 template, get embedded signing URL - throw new Error("DocuSign SDK integration not yet wired — configure DOCUSIGN_* env vars"); + throw new Error('DocuSign SDK integration not yet wired — configure DOCUSIGN_* env vars'); } // Dev stub — deterministic for testing const envelopeId = `STUB-ENV-${bondId}-${Date.now()}`; @@ -47,8 +53,8 @@ async function createDocuSignEnvelope( // POST /api/v1/bonds/:id/send-for-signature bondSignaturesRouter.post( - "/bonds/:id/send-for-signature", - requireRole("surety_admin"), + '/bonds/:id/send-for-signature', + requireRole('surety_admin'), async (req: Request, res: Response) => { const bondRecordId = req.params.id!; @@ -60,23 +66,23 @@ bondSignaturesRouter.post( JOIN importers i ON i.id = br.importer_id JOIN users u ON u.id = i.user_id WHERE br.id = $1`, - [bondRecordId], + [bondRecordId] ); if (!bondResult.rowCount) { - res.status(404).json({ error: "bond not found" }); + res.status(404).json({ error: 'bond not found' }); return; } const bond = bondResult.rows[0]!; - if (bond.signature_status === "completed") { - res.status(409).json({ error: "bond already has a completed signature" }); + if (bond.signature_status === 'completed') { + res.status(409).json({ error: 'bond already has a completed signature' }); return; } const suretyAdminResult = await pool.query( - "SELECT email FROM users WHERE role = 'surety_admin' LIMIT 1", + "SELECT email FROM users WHERE role = 'surety_admin' LIMIT 1" ); - const suretyEmail = suretyAdminResult.rows[0]?.email ?? "surety@tariffshield.io"; + const suretyEmail = suretyAdminResult.rows[0]?.email ?? 'surety@tariffshield.io'; let envelope: { envelopeId: string; signingUrl: string }; try { @@ -84,10 +90,10 @@ bondSignaturesRouter.post( bond.bond_id.toString(), bond.importer_email, bond.principal_legal_name, - suretyEmail, + suretyEmail ); } catch (err: any) { - res.status(502).json({ error: "envelope creation failed", detail: err.message }); + res.status(502).json({ error: 'envelope creation failed', detail: err.message }); return; } @@ -98,40 +104,39 @@ bondSignaturesRouter.post( ON CONFLICT (envelope_id) DO UPDATE SET signing_url = EXCLUDED.signing_url, updated_at = now() RETURNING id, envelope_id, signing_url, status, created_at`, - [bondRecordId, envelope.envelopeId, envelope.signingUrl], + [bondRecordId, envelope.envelopeId, envelope.signingUrl] ); - await pool.query( - "UPDATE bond_records SET signature_status = 'sent' WHERE id = $1", - [bondRecordId], - ); + await pool.query("UPDATE bond_records SET signature_status = 'sent' WHERE id = $1", [ + bondRecordId, + ]); res.status(201).json({ signature: sig.rows[0] }); - }, + } ); // GET /api/v1/bonds/:id/signature-status -bondSignaturesRouter.get("/bonds/:id/signature-status", async (req: Request, res: Response) => { +bondSignaturesRouter.get('/bonds/:id/signature-status', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const bondRecordId = req.params.id!; // Scope: surety_admin sees any bond; importer sees only their own let bondQuery; - if (user.role === "surety_admin") { + if (user.role === 'surety_admin') { bondQuery = await pool.query( - "SELECT br.id, br.signature_status FROM bond_records br WHERE br.id = $1", - [bondRecordId], + 'SELECT br.id, br.signature_status FROM bond_records br WHERE br.id = $1', + [bondRecordId] ); } else { bondQuery = await pool.query( `SELECT br.id, br.signature_status FROM bond_records br JOIN importers i ON i.id = br.importer_id WHERE br.id = $1 AND i.user_id = $2`, - [bondRecordId, user.id], + [bondRecordId, user.id] ); } if (!bondQuery.rowCount) { - res.status(404).json({ error: "bond not found" }); + res.status(404).json({ error: 'bond not found' }); return; } @@ -140,7 +145,7 @@ bondSignaturesRouter.get("/bonds/:id/signature-status", async (req: Request, res completed_at, last_reminder_sent_at, created_at FROM bond_signatures WHERE bond_record_id = $1 ORDER BY created_at DESC LIMIT 1`, - [bondRecordId], + [bondRecordId] ); res.json({ @@ -152,80 +157,78 @@ bondSignaturesRouter.get("/bonds/:id/signature-status", async (req: Request, res // POST /bonds/docusign-webhook — DocuSign Connect event receiver (no auth; HMAC-verified) // Raw body parsing needed for HMAC verification — mount before express.json() -bondWebhookRouter.post( - "/bonds/docusign-webhook", - async (req: Request, res: Response) => { - // Verify HMAC-SHA256 signature from DocuSign Connect - const receivedSig = req.headers["x-docusign-signature-1"] as string | undefined; - if (env.DOCUSIGN_WEBHOOK_HMAC_KEY && receivedSig) { - const rawBody = (req as any).rawBody as Buffer | undefined; - if (rawBody) { - const expected = crypto - .createHmac("sha256", env.DOCUSIGN_WEBHOOK_HMAC_KEY) - .update(rawBody) - .digest("base64"); - if (!crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expected))) { - res.status(401).json({ error: "invalid webhook signature" }); - return; - } +bondWebhookRouter.post('/bonds/docusign-webhook', async (req: Request, res: Response) => { + // Verify HMAC-SHA256 signature from DocuSign Connect + const receivedSig = req.headers['x-docusign-signature-1'] as string | undefined; + if (env.DOCUSIGN_WEBHOOK_HMAC_KEY && receivedSig) { + const rawBody = (req as any).rawBody as Buffer | undefined; + if (rawBody) { + const expected = crypto + .createHmac('sha256', env.DOCUSIGN_WEBHOOK_HMAC_KEY) + .update(rawBody) + .digest('base64'); + if (!crypto.timingSafeEqual(Buffer.from(receivedSig), Buffer.from(expected))) { + res.status(401).json({ error: 'invalid webhook signature' }); + return; } } + } - const body = req.body as any; - const envelopeId: string | undefined = body?.envelopeId ?? body?.data?.envelopeSummary?.envelopeId; - const status: string | undefined = body?.status ?? body?.data?.envelopeSummary?.status; + const body = req.body as any; + const envelopeId: string | undefined = + body?.envelopeId ?? body?.data?.envelopeSummary?.envelopeId; + const status: string | undefined = body?.status ?? body?.data?.envelopeSummary?.status; - if (!envelopeId || !status) { - res.status(400).json({ error: "missing envelopeId or status" }); - return; - } + if (!envelopeId || !status) { + res.status(400).json({ error: 'missing envelopeId or status' }); + return; + } - if (status === "completed") { - // Compute SHA-256 of the raw envelope for audit (#317) - const rawBodyBuf = (req as any).rawBody as Buffer | undefined; - const docHash = rawBodyBuf - ? crypto.createHash("sha256").update(rawBodyBuf).digest("hex") - : null; + if (status === 'completed') { + // Compute SHA-256 of the raw envelope for audit (#317) + const rawBodyBuf = (req as any).rawBody as Buffer | undefined; + const docHash = rawBodyBuf + ? crypto.createHash('sha256').update(rawBodyBuf).digest('hex') + : null; - await pool.query( - `UPDATE bond_signatures + await pool.query( + `UPDATE bond_signatures SET status = 'completed', signed_document_hash = $1, completed_at = now(), updated_at = now() WHERE envelope_id = $2`, - [docHash, envelopeId], - ); + [docHash, envelopeId] + ); - // Update bond_records so the API can gate deposits - await pool.query( - `UPDATE bond_records SET signature_status = 'completed' + // Update bond_records so the API can gate deposits + await pool.query( + `UPDATE bond_records SET signature_status = 'completed' WHERE id = (SELECT bond_record_id FROM bond_signatures WHERE envelope_id = $1)`, - [envelopeId], - ); - } else if (status === "declined" || status === "voided") { - await pool.query( - `UPDATE bond_signatures SET status = $1, updated_at = now() WHERE envelope_id = $2`, - [status, envelopeId], - ); - } + [envelopeId] + ); + } else if (status === 'declined' || status === 'voided') { + await pool.query( + `UPDATE bond_signatures SET status = $1, updated_at = now() WHERE envelope_id = $2`, + [status, envelopeId] + ); + } - res.status(200).json({ received: true }); - }, -); + res.status(200).json({ received: true }); +}); // POST /api/v1/bonds/:id/send-reminder — email reminder for unsigned envelope bondSignaturesRouter.post( - "/bonds/:id/send-reminder", - requireRole("surety_admin"), + '/bonds/:id/send-reminder', + requireRole('surety_admin'), async (req: Request, res: Response) => { const bondRecordId = req.params.id!; const sig = await pool.query( `SELECT id, envelope_id, status, created_at, last_reminder_sent_at FROM bond_signatures WHERE bond_record_id = $1 AND status = 'sent' ORDER BY created_at DESC LIMIT 1`, - [bondRecordId], + [bondRecordId] ); if (!sig.rowCount) { - res.status(404).json({ error: "no pending envelope found for this bond" }); + res.status(404).json({ error: 'no pending envelope found for this bond' }); return; } @@ -233,13 +236,12 @@ bondSignaturesRouter.post( const hoursSinceCreated = (Date.now() - new Date(envelope.created_at).getTime()) / 3_600_000; if (hoursSinceCreated < 72) { // Send reminder stub — in production call DocuSign resend API - await pool.query( - "UPDATE bond_signatures SET last_reminder_sent_at = now() WHERE id = $1", - [envelope.id], - ); + await pool.query('UPDATE bond_signatures SET last_reminder_sent_at = now() WHERE id = $1', [ + envelope.id, + ]); res.json({ reminded: true, envelopeId: envelope.envelope_id }); } else { - res.status(410).json({ error: "72-hour signing deadline has passed; void and reissue" }); + res.status(410).json({ error: '72-hour signing deadline has passed; void and reissue' }); } - }, + } ); diff --git a/apps/api/src/routes/compliance.ts b/apps/api/src/routes/compliance.ts index c185124..d3722fd 100644 --- a/apps/api/src/routes/compliance.ts +++ b/apps/api/src/routes/compliance.ts @@ -1,17 +1,26 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool } from "../db.js"; -import { authMiddleware, requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; export const complianceRouter = Router(); complianceRouter.use(authMiddleware); complianceRouter.use(privacyReacceptanceGate); complianceRouter.use(tosReacceptanceGate); -complianceRouter.use(requireRole("surety_admin")); +complianceRouter.use(requireRole('surety_admin')); // 5-minute dashboard cache keyed by surety_id (#318). // In production, replace with Redis with TTL. -interface CacheEntry { data: unknown; expiresAt: number } +interface CacheEntry { + data: unknown; + expiresAt: number; +} const dashboardCache = new Map(); const CACHE_TTL_MS = 5 * 60 * 1000; @@ -29,13 +38,13 @@ function setCache(key: string, data: unknown): void { } // GET /api/v1/compliance/dashboard -complianceRouter.get("/dashboard", async (req: Request, res: Response) => { +complianceRouter.get('/dashboard', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const cacheKey = `dashboard:${user.id}`; const cached = getCached(cacheKey); if (cached) { - res.set("X-Cache", "HIT"); + res.set('X-Cache', 'HIT'); res.json(cached); return; } @@ -47,41 +56,49 @@ complianceRouter.get("/dashboard", async (req: Request, res: Response) => { // importers will replace this; the WHERE clause is already parameterised on user.id // so adding that FK is a one-line change. - const [kycCounts, amlCounts, bondsBelowMin, unsignedBonds, renewalsDue, openFlags, vulnerabilityFindings, resolvedFindings] = - await Promise.all([ - pool.query<{ kyc_status: string; cnt: string }>( - `SELECT kyc_status, COUNT(*) AS cnt FROM importers GROUP BY kyc_status`, - ), - pool.query<{ severity: string; cnt: string }>( - `SELECT cf.severity, COUNT(*) AS cnt + const [ + kycCounts, + amlCounts, + bondsBelowMin, + unsignedBonds, + renewalsDue, + openFlags, + vulnerabilityFindings, + resolvedFindings, + ] = await Promise.all([ + pool.query<{ kyc_status: string; cnt: string }>( + `SELECT kyc_status, COUNT(*) AS cnt FROM importers GROUP BY kyc_status` + ), + pool.query<{ severity: string; cnt: string }>( + `SELECT cf.severity, COUNT(*) AS cnt FROM compliance_flags cf WHERE cf.resolution_status = 'open' - GROUP BY cf.severity`, - ), - pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM bond_records WHERE bond_amount < cbp_minimum_required`, - ), - pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM bond_records WHERE surety_fein = 'TBD'`, - ), - pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM bond_records + GROUP BY cf.severity` + ), + pool.query<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM bond_records WHERE bond_amount < cbp_minimum_required` + ), + pool.query<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM bond_records WHERE surety_fein = 'TBD'` + ), + pool.query<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM bond_records WHERE expiry_date IS NOT NULL - AND expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '90 days'`, - ), - pool.query<{ cnt: string }>( - `SELECT COUNT(*) AS cnt FROM compliance_flags WHERE resolution_status = 'open'`, - ), - pool.query<{ severity: string; cnt: string }>( - `SELECT severity, COUNT(*) AS cnt FROM security_findings WHERE status = 'open' GROUP BY severity`, - ), - pool.query<{ severity: string; avg_time: string }>( - `SELECT severity, AVG(EXTRACT(EPOCH FROM (updated_at - discovery_date))) AS avg_time + AND expiry_date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '90 days'` + ), + pool.query<{ cnt: string }>( + `SELECT COUNT(*) AS cnt FROM compliance_flags WHERE resolution_status = 'open'` + ), + pool.query<{ severity: string; cnt: string }>( + `SELECT severity, COUNT(*) AS cnt FROM security_findings WHERE status = 'open' GROUP BY severity` + ), + pool.query<{ severity: string; avg_time: string }>( + `SELECT severity, AVG(EXTRACT(EPOCH FROM (updated_at - discovery_date))) AS avg_time FROM security_findings WHERE status = 'resolved' - GROUP BY severity`, - ), - ]); + GROUP BY severity` + ), + ]); const kycByStatus: Record = { pending: 0, approved: 0, rejected: 0 }; for (const row of kycCounts.rows) { @@ -93,14 +110,26 @@ complianceRouter.get("/dashboard", async (req: Request, res: Response) => { amlByRisk[row.severity] = parseInt(row.cnt, 10); } - const openFindingsBySeverity: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }; + const openFindingsBySeverity: Record = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }; for (const row of vulnerabilityFindings.rows) { openFindingsBySeverity[row.severity] = parseInt(row.cnt, 10); } - const meanTimeToRemediateBySeverity: Record = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, INFO: 0 }; + const meanTimeToRemediateBySeverity: Record = { + CRITICAL: 0, + HIGH: 0, + MEDIUM: 0, + LOW: 0, + INFO: 0, + }; for (const row of resolvedFindings.rows) { - meanTimeToRemediateBySeverity[row.severity] = parseFloat(row.avg_time || "0"); + meanTimeToRemediateBySeverity[row.severity] = parseFloat(row.avg_time || '0'); } const dastScanCoverage = { @@ -119,10 +148,10 @@ complianceRouter.get("/dashboard", async (req: Request, res: Response) => { generatedAt: new Date().toISOString(), kycByStatus, activeAmlFlagsByRisk: amlByRisk, - bondsBelowCbpMinimum: parseInt(bondsBelowMin.rows[0]?.cnt ?? "0", 10), - unsignedBonds: parseInt(unsignedBonds.rows[0]?.cnt ?? "0", 10), - bondsRenewingWithin90Days: parseInt(renewalsDue.rows[0]?.cnt ?? "0", 10), - totalOpenFlags: parseInt(openFlags.rows[0]?.cnt ?? "0", 10), + bondsBelowCbpMinimum: parseInt(bondsBelowMin.rows[0]?.cnt ?? '0', 10), + unsignedBonds: parseInt(unsignedBonds.rows[0]?.cnt ?? '0', 10), + bondsRenewingWithin90Days: parseInt(renewalsDue.rows[0]?.cnt ?? '0', 10), + totalOpenFlags: parseInt(openFlags.rows[0]?.cnt ?? '0', 10), vulnerabilityMetrics: { openFindingsBySeverity, meanTimeToRemediateBySeverity, @@ -132,37 +161,38 @@ complianceRouter.get("/dashboard", async (req: Request, res: Response) => { }; setCache(cacheKey, dashboard); - res.set("X-Cache", "MISS"); + res.set('X-Cache', 'MISS'); res.json(dashboard); }); - // DELETE /api/v1/compliance/dashboard/cache — manual cache invalidation for time-sensitive reviews -complianceRouter.delete("/dashboard/cache", (req: Request, res: Response) => { +complianceRouter.delete('/dashboard/cache', (req: Request, res: Response) => { const user = (req as AuthedRequest).user; dashboardCache.delete(`dashboard:${user.id}`); - res.json({ success: true, message: "dashboard cache cleared" }); + res.json({ success: true, message: 'dashboard cache cleared' }); }); // GET /api/v1/compliance/flags -complianceRouter.get("/flags", async (req: Request, res: Response) => { +complianceRouter.get('/flags', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const query = z.object({ - resolution_status: z.enum(["open", "resolved"]).optional(), - severity: z.enum(["low", "medium", "high", "critical"]).optional(), - importer_id: z.string().uuid().optional(), - limit: z.coerce.number().int().positive().max(100).default(50), - offset: z.coerce.number().int().min(0).default(0), - }).safeParse(req.query); + const query = z + .object({ + resolution_status: z.enum(['open', 'resolved']).optional(), + severity: z.enum(['low', 'medium', 'high', 'critical']).optional(), + importer_id: z.string().uuid().optional(), + limit: z.coerce.number().int().positive().max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), + }) + .safeParse(req.query); if (!query.success) { - res.status(400).json({ error: "invalid query parameters" }); + res.status(400).json({ error: 'invalid query parameters' }); return; } const { resolution_status, severity, importer_id, limit, offset } = query.data; - const conditions: string[] = ["cf.surety_id = $1"]; + const conditions: string[] = ['cf.surety_id = $1']; const params: unknown[] = [user.id]; let idx = 2; @@ -179,7 +209,7 @@ complianceRouter.get("/flags", async (req: Request, res: Response) => { params.push(importer_id); } - const where = conditions.join(" AND "); + const where = conditions.join(' AND '); const [flags, total] = await Promise.all([ pool.query( @@ -191,38 +221,38 @@ complianceRouter.get("/flags", async (req: Request, res: Response) => { WHERE ${where} ORDER BY cf.created_at DESC LIMIT $${idx} OFFSET $${idx + 1}`, - [...params, limit, offset], + [...params, limit, offset] ), pool.query<{ cnt: string }>( `SELECT COUNT(*) AS cnt FROM compliance_flags cf WHERE ${where}`, - params, + params ), ]); res.json({ flags: flags.rows, - total: parseInt(total.rows[0]?.cnt ?? "0", 10), + total: parseInt(total.rows[0]?.cnt ?? '0', 10), limit, offset, }); }); // POST /api/v1/compliance/flags/:id/resolve — resolve a flag with a mandatory note -complianceRouter.post("/flags/:id/resolve", async (req: Request, res: Response) => { +complianceRouter.post('/flags/:id/resolve', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = z.object({ resolution_note: z.string().min(10) }).safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "resolution_note is required (min 10 chars)" }); + res.status(400).json({ error: 'resolution_note is required (min 10 chars)' }); return; } const flag = await pool.query( `SELECT id FROM compliance_flags WHERE id = $1 AND surety_id = $2 AND resolution_status = 'open'`, - [req.params.id, user.id], + [req.params.id, user.id] ); if (!flag.rowCount) { - res.status(404).json({ error: "flag not found or already resolved" }); + res.status(404).json({ error: 'flag not found or already resolved' }); return; } @@ -231,7 +261,7 @@ complianceRouter.post("/flags/:id/resolve", async (req: Request, res: Response) SET resolution_status = 'resolved', resolved_by = $1, resolution_note = $2, resolved_at = now(), updated_at = now() WHERE id = $3`, - [user.id, parse.data.resolution_note, req.params.id], + [user.id, parse.data.resolution_note, req.params.id] ); // Invalidate dashboard cache so next request reflects the resolution. @@ -241,7 +271,7 @@ complianceRouter.post("/flags/:id/resolve", async (req: Request, res: Response) }); // GET /api/v1/compliance/reports — list available compliance reports for this surety -complianceRouter.get("/reports", async (req: Request, res: Response) => { +complianceRouter.get('/reports', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const reports = await pool.query( @@ -249,22 +279,22 @@ complianceRouter.get("/reports", async (req: Request, res: Response) => { FROM compliance_reports WHERE surety_id = $1 ORDER BY report_month DESC`, - [user.id], + [user.id] ); res.json({ reports: reports.rows }); }); // GET /api/v1/compliance/reports/:id/download — pre-signed S3 URL for the PDF -complianceRouter.get("/reports/:id/download", async (req: Request, res: Response) => { +complianceRouter.get('/reports/:id/download', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const report = await pool.query( `SELECT pdf_s3_key FROM compliance_reports WHERE id = $1 AND surety_id = $2`, - [req.params.id, user.id], + [req.params.id, user.id] ); const reportRow = report.rows[0]; if (!report.rowCount || !reportRow?.pdf_s3_key) { - res.status(404).json({ error: "report PDF not available" }); + res.status(404).json({ error: 'report PDF not available' }); return; } diff --git a/apps/api/src/routes/erasure.ts b/apps/api/src/routes/erasure.ts index 0478158..f739218 100644 --- a/apps/api/src/routes/erasure.ts +++ b/apps/api/src/routes/erasure.ts @@ -1,7 +1,12 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool, createDataErasureRequest } from "../db.js"; -import { authMiddleware, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool, createDataErasureRequest } from '../db.js'; +import { + authMiddleware, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; export const erasureRouter = Router(); erasureRouter.use(authMiddleware); @@ -12,28 +17,28 @@ const ErasureRequestSchema = z.object({ reason: z.string().optional(), }); -erasureRouter.post("/account/erasure-request", async (req: Request, res: Response) => { +erasureRouter.post('/account/erasure-request', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = ErasureRequestSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } - const importerResult = await pool.query("SELECT id FROM importers WHERE user_id = $1", [user.id]); + const importerResult = await pool.query('SELECT id FROM importers WHERE user_id = $1', [user.id]); const importerId = importerResult.rows[0]?.id ?? null; const requestId = await createDataErasureRequest(user.id, importerId); const result = await pool.query( - "SELECT id, request_id, status, requested_at, sla_deadline FROM data_erasure_requests WHERE id = $1", - [requestId], + 'SELECT id, request_id, status, requested_at, sla_deadline FROM data_erasure_requests WHERE id = $1', + [requestId] ); const request = result.rows[0]; if (!request) { - res.status(500).json({ error: "failed to retrieve erasure request" }); + res.status(500).json({ error: 'failed to retrieve erasure request' }); return; } res.status(202).json({ @@ -44,25 +49,25 @@ erasureRouter.post("/account/erasure-request", async (req: Request, res: Respons }); }); -erasureRouter.get("/account/erasure-request/:requestId", async (req: Request, res: Response) => { +erasureRouter.get('/account/erasure-request/:requestId', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const requestId = String(req.params.requestId ?? ""); + const requestId = String(req.params.requestId ?? ''); const result = await pool.query( `SELECT id, request_id, status, requested_at, sla_deadline, affected_fields, error_message FROM data_erasure_requests WHERE request_id = $1 AND user_id = $2`, - [requestId, user.id], + [requestId, user.id] ); if (result.rowCount === 0) { - res.status(404).json({ error: "request not found" }); + res.status(404).json({ error: 'request not found' }); return; } const request = result.rows[0]; if (!request) { - res.status(404).json({ error: "request not found" }); + res.status(404).json({ error: 'request not found' }); return; } res.json({ diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts index d0bfcf5..0fa4f1c 100644 --- a/apps/api/src/routes/health.ts +++ b/apps/api/src/routes/health.ts @@ -1,92 +1,94 @@ -import { Router } from "express"; -import { ping, getPoolStats } from "../db.js"; -import { pingRpc } from "../stellar.js"; -import { pingRedis } from "../queue.js"; -import { env, isProduction } from "../config/env.js"; -import { readFileSync } from "fs"; -import { join } from "path"; +import { Router } from 'express'; +import { ping, getPoolStats } from '../db.js'; +import { pingRpc } from '../stellar.js'; +import { pingRedis } from '../queue.js'; +import { env, isProduction } from '../config/env.js'; +import { readFileSync } from 'fs'; +import { join } from 'path'; export const healthRouter = Router(); -let version = "unknown"; +let version = 'unknown'; try { - const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); - version = pkg.version || "unknown"; -} catch (e) {} + const pkg = JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8')); + version = pkg.version || 'unknown'; +} catch (e) { + /* intentionally ignored — fall back to the "unknown" version already set above */ +} -healthRouter.get("/", async (_req, res) => { +healthRouter.get('/', async (_req, res) => { const checks = { - db: "connected", - soroban: "ok", - redis: "connected", + db: 'connected', + soroban: 'ok', + redis: 'connected', }; let hasError = false; try { await ping(); } catch (err) { - checks.db = "failed"; + checks.db = 'failed'; hasError = true; } try { await pingRpc(); } catch (err) { - checks.soroban = "failed"; + checks.soroban = 'failed'; hasError = true; } try { await pingRedis(); } catch (err) { - checks.redis = "failed"; + checks.redis = 'failed'; hasError = true; } if (hasError) { res.status(503).json({ - status: "degraded", + status: 'degraded', version, ...checks, }); } else { res.json({ - status: "ok", + status: 'ok', version, ...checks, contractId: env.TARIFF_SHIELD_CONTRACT_ID, network: env.STELLAR_NETWORK, - env: isProduction ? "production" : "development", + env: isProduction ? 'production' : 'development', }); } }); // #241 — dedicated DB health check with pool stats, for monitoring // connection saturation independently of the aggregate /health checks above. -healthRouter.get("/db", async (_req, res) => { +healthRouter.get('/db', async (_req, res) => { try { await ping(); res.json({ - status: "ok", + status: 'ok', pool: getPoolStats(), }); } catch (err) { res.status(503).json({ - status: "failed", + status: 'failed', pool: getPoolStats(), }); } }); -healthRouter.get("/live", (_req, res) => { - res.status(200).send("OK"); +healthRouter.get('/live', (_req, res) => { + res.status(200).send('OK'); }); -healthRouter.get("/ready", async (_req, res) => { +healthRouter.get('/ready', async (_req, res) => { try { await Promise.all([ping(), pingRpc(), pingRedis()]); - res.status(200).send("OK"); + res.status(200).send('OK'); } catch (err) { - res.status(503).send("Service Unavailable"); + res.status(503).send('Service Unavailable'); } }); diff --git a/apps/api/src/routes/importers.ts b/apps/api/src/routes/importers.ts index e5c0f43..2081c10 100644 --- a/apps/api/src/routes/importers.ts +++ b/apps/api/src/routes/importers.ts @@ -1,37 +1,40 @@ -import { Router, type Request, type Response } from "express"; -import { createHash } from "crypto"; -import { Keypair } from "@stellar/stellar-sdk"; -import { z } from "zod"; +import { Router, type Request, type Response } from 'express'; +import { createHash } from 'crypto'; +import { Keypair } from '@stellar/stellar-sdk'; +import { z } from 'zod'; import { pool, getImporterMetrics, logAudit, refreshImporterMetricsView, getImporterReview, -} from "../db.js"; -import { NOTIFICATION_KINDS } from "../constants/notification-kinds.js"; -import { adminRouter } from "./admin.js"; -import { authMiddleware, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; -import { requireLicenseVerified } from "./surety-license.js"; +} from '../db.js'; +import { adminRouter } from './admin.js'; +import { + authMiddleware, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; +import { requireLicenseVerified } from './surety-license.js'; import { contractClient, explorerTx, platformKeypair, - suretyKeypair, getRequiredCollateralOnChain, -} from "../stellar.js"; -import { lookupCbpDutyRate } from "../services/cbp-duty-lookup.js"; -import { validateHtsRates } from "../services/hts-rate-validator.js"; -import { screenImporterEntity, screenWalletAddress } from "../services/aml-screening.js"; -import { validateBondForm301 } from "../services/cbp-bond-validation.js"; -import { env } from "../config/env.js"; -import { enqueueTxSubmit, txSubmitQueue } from "../queue.js"; +} from '../stellar.js'; +import { lookupCbpDutyRate } from '../services/cbp-duty-lookup.js'; +import { validateHtsRates } from '../services/hts-rate-validator.js'; +import { screenImporterEntity, screenWalletAddress } from '../services/aml-screening.js'; +import { validateBondForm301 } from '../services/cbp-bond-validation.js'; +import { env } from '../config/env.js'; +import { enqueueTxSubmit, txSubmitQueue } from '../queue.js'; import { getCachedOnChainAccount, setCachedOnChainAccount, invalidateOnChainAccount, type OnChainAccountView, -} from "../cache.js"; +} from '../cache.js'; export const importersRouter = Router(); importersRouter.use(authMiddleware); @@ -142,7 +145,7 @@ importersRouter.post('/', async (req: Request, res: Response) => { const friendbotRes = await fetch(`https://friendbot.stellar.org/?addr=${kp.publicKey()}`); if (!friendbotRes.ok) throw new Error(`friendbot ${friendbotRes.status}`); } catch (err) { - req.log.error({ err }, "friendbot fund failed"); + req.log.error({ err }, 'friendbot fund failed'); } // Register importer on-chain. Platform admin signs. @@ -340,19 +343,19 @@ async function loadImporterFor(req: Request, importerId: string) { * Returns all rows from the importer_metrics materialized view. * surety_admin only. */ -adminRouter.get("/importers/metrics", async (req: Request, res: Response) => { +adminRouter.get('/importers/metrics', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - if (user.role !== "surety_admin") { - res.status(403).json({ error: "surety admin only" }); + if (user.role !== 'surety_admin') { + res.status(403).json({ error: 'surety admin only' }); return; } try { - const result = await pool.query("SELECT * FROM importer_metrics ORDER BY legal_name ASC"); + const result = await pool.query('SELECT * FROM importer_metrics ORDER BY legal_name ASC'); res.json({ metrics: result.rows }); } catch (err) { - console.error("[metrics] failed to get admin metrics:", err); - res.status(500).json({ error: "failed to retrieve importer metrics" }); + console.error('[metrics] failed to get admin metrics:', err); + res.status(500).json({ error: 'failed to retrieve importer metrics' }); } }); @@ -361,31 +364,30 @@ adminRouter.get("/importers/metrics", async (req: Request, res: Response) => { * * Returns the single metrics row for the authenticated importer. */ -importersRouter.get("/:id/metrics", async (req: Request, res: Response) => { - const importer = await loadImporterFor(req, String(req.params.id ?? "")); +importersRouter.get('/:id/metrics', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); if (!importer) { - res.status(404).json({ error: "not found" }); + res.status(404).json({ error: 'not found' }); return; } try { - const result = await pool.query( - "SELECT * FROM importer_metrics WHERE importer_id = $1", - [importer.id] - ); + const result = await pool.query('SELECT * FROM importer_metrics WHERE importer_id = $1', [ + importer.id, + ]); if (result.rowCount === 0) { - res.status(404).json({ error: "metrics not found for importer" }); + res.status(404).json({ error: 'metrics not found for importer' }); return; } res.json({ metrics: result.rows[0] }); } catch (err) { - console.error("[metrics] failed to get importer metrics:", err); - res.status(500).json({ error: "failed to retrieve importer metrics" }); + console.error('[metrics] failed to get importer metrics:', err); + res.status(500).json({ error: 'failed to retrieve importer metrics' }); } }); -importersRouter.get("/:id", async (req: Request, res: Response) => { - const importer = await loadImporterFor(req, String(req.params.id ?? "")); +importersRouter.get('/:id', async (req: Request, res: Response) => { + const importer = await loadImporterFor(req, String(req.params.id ?? '')); if (!importer) { res.status(404).json({ error: 'not found' }); return; @@ -704,7 +706,7 @@ importersRouter.post('/:id/upload-tariff-csv', async (req: Request, res: Respons ] ); - await logAudit(user.id, "apply_tariff_upload", importer.id, { + await logAudit(user.id, 'apply_tariff_upload', importer.id, { filename: parse.data.filename, annualDutyTotal, requiredStroops: requiredStroops.toString(), diff --git a/apps/api/src/routes/kyc.ts b/apps/api/src/routes/kyc.ts index 723a65f..82bc290 100644 --- a/apps/api/src/routes/kyc.ts +++ b/apps/api/src/routes/kyc.ts @@ -1,9 +1,15 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool, logAudit } from "../db.js"; -import { authMiddleware, requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; -import { encryptFieldToJson, decryptFieldFromJson } from "../lib/field-encryption.js"; -import { env } from "../config/env.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool, logAudit } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; +import { encryptFieldToJson, decryptFieldFromJson } from '../lib/field-encryption.js'; +import { env } from '../config/env.js'; export const kycRouter = Router(); kycRouter.use(authMiddleware); @@ -23,7 +29,7 @@ function s3KeyDecrypt(encrypted: string): string { try { return decryptFieldFromJson(encrypted) ?? encrypted; } catch { - return "[decryption error]"; + return '[decryption error]'; } } @@ -32,8 +38,8 @@ function s3KeyDecrypt(encrypted: string): string { async function uploadDocumentToS3( importerId: string, documentType: string, - fileBuffer: Buffer, - mimeType: string, + _fileBuffer: Buffer, + _mimeType: string ): Promise { const timestamp = Date.now(); const key = `kyc/${importerId}/${documentType}/${timestamp}`; @@ -54,7 +60,11 @@ function generatePresignedUrl(s3Key: string): string { } const UploadKycSchema = z.object({ - documentType: z.enum(["articles_of_incorporation", "ein_confirmation", "beneficial_ownership_fincen_102"]), + documentType: z.enum([ + 'articles_of_incorporation', + 'ein_confirmation', + 'beneficial_ownership_fincen_102', + ]), // In production, file bytes come from multipart/form-data (multer/busboy). // For now, accept a base64-encoded payload for API simplicity. fileBase64: z.string().min(1), @@ -62,30 +72,30 @@ const UploadKycSchema = z.object({ }); // POST /api/v1/importers/:id/kyc — upload a KYC document (importer only) -kycRouter.post("/:id/kyc", async (req: Request, res: Response) => { +kycRouter.post('/:id/kyc', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - if (user.role !== "importer") { - res.status(403).json({ error: "only importers can upload KYC documents" }); + if (user.role !== 'importer') { + res.status(403).json({ error: 'only importers can upload KYC documents' }); return; } - const imp = await pool.query( - "SELECT id FROM importers WHERE id = $1 AND user_id = $2", - [req.params.id, user.id], - ); + const imp = await pool.query('SELECT id FROM importers WHERE id = $1 AND user_id = $2', [ + req.params.id, + user.id, + ]); if (!imp.rowCount) { - res.status(404).json({ error: "not found" }); + res.status(404).json({ error: 'not found' }); return; } const importerId: string = imp.rows[0]!.id; const parse = UploadKycSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } const { documentType, fileBase64, mimeType } = parse.data; - const fileBuffer = Buffer.from(fileBase64, "base64"); + const fileBuffer = Buffer.from(fileBase64, 'base64'); const s3Key = await uploadDocumentToS3(importerId, documentType, fileBuffer, mimeType); const encryptedKey = s3KeyEncrypt(s3Key); @@ -97,27 +107,27 @@ kycRouter.post("/:id/kyc", async (req: Request, res: Response) => { `INSERT INTO kyc_documents (importer_id, document_type, s3_key_encrypted, scheduled_deletion_date) VALUES ($1, $2, $3, $4) RETURNING id, document_type, upload_timestamp, review_status, scheduled_deletion_date`, - [importerId, documentType, encryptedKey, scheduledDeletion], + [importerId, documentType, encryptedKey, scheduledDeletion] ); res.status(201).json({ document: result.rows[0] }); }); // GET /api/v1/importers/:id/kyc — list KYC documents for an importer -kycRouter.get("/:id/kyc", async (req: Request, res: Response) => { +kycRouter.get('/:id/kyc', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; let importerCheck; - if (user.role === "surety_admin") { - importerCheck = await pool.query("SELECT id FROM importers WHERE id = $1", [req.params.id]); + if (user.role === 'surety_admin') { + importerCheck = await pool.query('SELECT id FROM importers WHERE id = $1', [req.params.id]); } else { - importerCheck = await pool.query( - "SELECT id FROM importers WHERE id = $1 AND user_id = $2", - [req.params.id, user.id], - ); + importerCheck = await pool.query('SELECT id FROM importers WHERE id = $1 AND user_id = $2', [ + req.params.id, + user.id, + ]); } if (!importerCheck.rowCount) { - res.status(404).json({ error: "not found" }); + res.status(404).json({ error: 'not found' }); return; } @@ -126,13 +136,13 @@ kycRouter.get("/:id/kyc", async (req: Request, res: Response) => { scheduled_deletion_date, deleted_at FROM kyc_documents WHERE importer_id = $1 AND deleted_at IS NULL ORDER BY upload_timestamp DESC`, - [req.params.id], + [req.params.id] ); res.json({ documents: docs.rows }); }); const UpdateKycStatusSchema = z.object({ - kycStatus: z.enum(["pending", "approved", "rejected"]), + kycStatus: z.enum(['pending', 'approved', 'rejected']), }); // PATCH /api/v1/importers/:id/kyc — directly set an importer's kyc_status @@ -140,12 +150,12 @@ const UpdateKycStatusSchema = z.object({ // below, which derives kyc_status from document approvals; PATCH is a // direct administrative override for cases handled outside the document // workflow (e.g. KYC verified through an external channel). -kycRouter.patch("/:id/kyc", requireRole("surety_admin"), async (req: Request, res: Response) => { +kycRouter.patch('/:id/kyc', requireRole('surety_admin'), async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = UpdateKycStatusSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } @@ -153,31 +163,35 @@ kycRouter.patch("/:id/kyc", requireRole("surety_admin"), async (req: Request, re `UPDATE importers SET kyc_status = $1 WHERE id = $2 AND deleted_at IS NULL RETURNING id, kyc_status`, - [parse.data.kycStatus, req.params.id], + [parse.data.kycStatus, req.params.id] ); if (!result.rowCount) { - res.status(404).json({ error: "not found" }); + res.status(404).json({ error: 'not found' }); return; } - await logAudit(user.id, "kyc_status_update", result.rows[0]!.id, { kycStatus: parse.data.kycStatus }); + await logAudit(user.id, 'kyc_status_update', result.rows[0]!.id, { + kycStatus: parse.data.kycStatus, + }); res.json({ importerId: result.rows[0]!.id, kycStatus: result.rows[0]!.kyc_status }); }); // POST /api/v1/importers/:id/kyc/:docId/review — surety_admin approves/rejects a document kycRouter.post( - "/:id/kyc/:docId/review", - requireRole("surety_admin"), + '/:id/kyc/:docId/review', + requireRole('surety_admin'), async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const parse = z.object({ - decision: z.enum(["approved", "rejected"]), - note: z.string().min(1), - }).safeParse(req.body); + const parse = z + .object({ + decision: z.enum(['approved', 'rejected']), + note: z.string().min(1), + }) + .safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "decision and note are required" }); + res.status(400).json({ error: 'decision and note are required' }); return; } const { decision, note } = parse.data; @@ -186,10 +200,10 @@ kycRouter.post( `SELECT kd.id, kd.importer_id FROM kyc_documents kd JOIN importers i ON i.id = kd.importer_id WHERE kd.id = $1 AND kd.importer_id = $2 AND kd.deleted_at IS NULL`, - [req.params.docId, req.params.id], + [req.params.docId, req.params.id] ); if (!doc.rowCount) { - res.status(404).json({ error: "document not found" }); + res.status(404).json({ error: 'document not found' }); return; } @@ -197,7 +211,7 @@ kycRouter.post( `UPDATE kyc_documents SET review_status = $1, reviewer_id = $2, reviewer_note = $3, reviewed_at = now() WHERE id = $4`, - [decision, user.id, note, req.params.docId], + [decision, user.id, note, req.params.docId] ); // Update importer KYC status when a document is approved/rejected. @@ -207,36 +221,39 @@ kycRouter.post( BOOL_OR(review_status = 'approved') AS has_approved, BOOL_OR(review_status = 'rejected') AS has_rejected FROM kyc_documents WHERE importer_id = $1 AND deleted_at IS NULL`, - [req.params.id], + [req.params.id] ); const { has_approved, has_rejected } = statusResult.rows[0] ?? {}; - const kycStatus = has_rejected ? "rejected" : has_approved ? "approved" : "pending"; - await pool.query("UPDATE importers SET kyc_status = $1 WHERE id = $2", [kycStatus, req.params.id]); + const kycStatus = has_rejected ? 'rejected' : has_approved ? 'approved' : 'pending'; + await pool.query('UPDATE importers SET kyc_status = $1 WHERE id = $2', [ + kycStatus, + req.params.id, + ]); res.json({ success: true, importerKycStatus: kycStatus }); - }, + } ); // GET /api/v1/importers/:id/kyc/:docId/download — get a pre-signed S3 URL (surety_admin or owner) -kycRouter.get("/:id/kyc/:docId/download", async (req: Request, res: Response) => { +kycRouter.get('/:id/kyc/:docId/download', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; let query; - if (user.role === "surety_admin") { + if (user.role === 'surety_admin') { query = await pool.query( - "SELECT kd.s3_key_encrypted FROM kyc_documents kd WHERE kd.id = $1 AND kd.importer_id = $2 AND kd.deleted_at IS NULL", - [req.params.docId, req.params.id], + 'SELECT kd.s3_key_encrypted FROM kyc_documents kd WHERE kd.id = $1 AND kd.importer_id = $2 AND kd.deleted_at IS NULL', + [req.params.docId, req.params.id] ); } else { query = await pool.query( `SELECT kd.s3_key_encrypted FROM kyc_documents kd JOIN importers i ON i.id = kd.importer_id WHERE kd.id = $1 AND kd.importer_id = $2 AND i.user_id = $3 AND kd.deleted_at IS NULL`, - [req.params.docId, req.params.id, user.id], + [req.params.docId, req.params.id, user.id] ); } if (!query.rowCount) { - res.status(404).json({ error: "not found" }); + res.status(404).json({ error: 'not found' }); return; } diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts index 2bd5c09..e63efb6 100644 --- a/apps/api/src/routes/notifications.ts +++ b/apps/api/src/routes/notifications.ts @@ -1,7 +1,12 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool } from "../db.js"; -import { authMiddleware, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; export const notificationsRouter = Router(); notificationsRouter.use(authMiddleware); @@ -16,8 +21,8 @@ notificationsRouter.use(tosReacceptanceGate); // (see implementation.md). function decodeNotificationsCursor(raw: string): { createdAt: string; id: string } | null { try { - const decoded = Buffer.from(raw, "base64").toString("utf8"); - const sep = decoded.lastIndexOf("|"); + const decoded = Buffer.from(raw, 'base64').toString('utf8'); + const sep = decoded.lastIndexOf('|'); if (sep === -1) return null; return { createdAt: decoded.slice(0, sep), id: decoded.slice(sep + 1) }; } catch { @@ -26,7 +31,7 @@ function decodeNotificationsCursor(raw: string): { createdAt: string; id: string } function encodeNotificationsCursor(createdAt: Date, id: string): string { - return Buffer.from(`${createdAt.toISOString()}|${id}`, "utf8").toString("base64"); + return Buffer.from(`${createdAt.toISOString()}|${id}`, 'utf8').toString('base64'); } const NotificationsQuerySchema = z.object({ @@ -35,12 +40,12 @@ const NotificationsQuerySchema = z.object({ }); // GET /notifications — paginated, for the authenticated user, most recent first. -notificationsRouter.get("/", async (req: Request, res: Response) => { +notificationsRouter.get('/', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = NotificationsQuerySchema.safeParse(req.query); if (!parse.success) { - res.status(400).json({ error: "invalid query", details: parse.error.issues }); + res.status(400).json({ error: 'invalid query', details: parse.error.issues }); return; } const { limit } = parse.data; @@ -49,7 +54,7 @@ notificationsRouter.get("/", async (req: Request, res: Response) => { if (parse.data.cursor) { cursor = decodeNotificationsCursor(parse.data.cursor); if (!cursor) { - res.status(400).json({ error: "invalid cursor" }); + res.status(400).json({ error: 'invalid cursor' }); return; } } @@ -65,13 +70,13 @@ notificationsRouter.get("/", async (req: Request, res: Response) => { `SELECT id, kind, message, read_at, created_at FROM notifications WHERE user_id = $1 AND (created_at, id) < ($2::timestamptz, $3::uuid) ORDER BY created_at DESC, id DESC LIMIT $4`, - [user.id, cursor.createdAt, cursor.id, limit], + [user.id, cursor.createdAt, cursor.id, limit] ) : await pool.query( `SELECT id, kind, message, read_at, created_at FROM notifications WHERE user_id = $1 ORDER BY created_at DESC, id DESC LIMIT $2`, - [user.id, limit], + [user.id, limit] ); const notifications = rows.rows.map((n) => ({ @@ -90,38 +95,38 @@ notificationsRouter.get("/", async (req: Request, res: Response) => { }); // GET /notifications/unread-count — count of this user's unread notifications. -notificationsRouter.get("/unread-count", async (req: Request, res: Response) => { +notificationsRouter.get('/unread-count', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const r = await pool.query( - "SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL", - [user.id], + 'SELECT count(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL', + [user.id] ); res.json({ unreadCount: Number(r.rows[0]!.count) }); }); // PATCH /notifications/:id/read — mark one notification read (owner or surety_admin). -notificationsRouter.patch("/:id/read", async (req: Request, res: Response) => { +notificationsRouter.patch('/:id/read', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const notificationId = String(req.params.id ?? ""); + const notificationId = String(req.params.id ?? ''); // read_at is only ever set once, on first read (COALESCE), so a repeat // PATCH is a safe no-op that preserves the original read timestamp rather // than resetting it. const r = - user.role === "surety_admin" + user.role === 'surety_admin' ? await pool.query( `UPDATE notifications SET read_at = COALESCE(read_at, now()) WHERE id = $1 RETURNING id, kind, message, read_at, created_at`, - [notificationId], + [notificationId] ) : await pool.query( `UPDATE notifications SET read_at = COALESCE(read_at, now()) WHERE id = $1 AND user_id = $2 RETURNING id, kind, message, read_at, created_at`, - [notificationId, user.id], + [notificationId, user.id] ); const notification = r.rows[0]; @@ -129,7 +134,7 @@ notificationsRouter.patch("/:id/read", async (req: Request, res: Response) => { // Generic 404 whether the row doesn't exist or belongs to someone else — // matches loadImporterFor's convention elsewhere in this codebase of not // distinguishing "not found" from "not yours" in the response. - res.status(404).json({ error: "not found" }); + res.status(404).json({ error: 'not found' }); return; } diff --git a/apps/api/src/routes/privacy.ts b/apps/api/src/routes/privacy.ts index 4205ffb..7a48e0d 100644 --- a/apps/api/src/routes/privacy.ts +++ b/apps/api/src/routes/privacy.ts @@ -1,8 +1,13 @@ // #322 — Privacy Policy Versioning and Re-Acceptance Flow -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool } from "../db.js"; -import { authMiddleware, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; export const privacyRouter = Router(); privacyRouter.use(authMiddleware); @@ -10,7 +15,7 @@ privacyRouter.use(privacyReacceptanceGate); privacyRouter.use(tosReacceptanceGate); // GET /api/v1/account/privacy-policy-history — user's acceptance history -privacyRouter.get("/privacy-policy-history", async (req: Request, res: Response) => { +privacyRouter.get('/privacy-policy-history', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const rows = await pool.query( `SELECT ppa.policy_version_id, ppa.accepted_at, ppa.acceptance_channel, @@ -19,27 +24,27 @@ privacyRouter.get("/privacy-policy-history", async (req: Request, res: Response) JOIN privacy_policy_versions ppv ON ppv.version_id = ppa.policy_version_id WHERE ppa.user_id = $1 ORDER BY ppa.accepted_at DESC`, - [user.id], + [user.id] ); res.json({ acceptances: rows.rows }); }); // POST /api/v1/account/accept-privacy-policy — re-accept current policy version -privacyRouter.post("/accept-privacy-policy", async (req: Request, res: Response) => { +privacyRouter.post('/accept-privacy-policy', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = z.object({ versionId: z.string().min(1) }).safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "versionId is required" }); + res.status(400).json({ error: 'versionId is required' }); return; } const version = await pool.query( - "SELECT version_id FROM privacy_policy_versions WHERE version_id = $1", - [parse.data.versionId], + 'SELECT version_id FROM privacy_policy_versions WHERE version_id = $1', + [parse.data.versionId] ); if (!version.rowCount) { - res.status(404).json({ error: "policy version not found" }); + res.status(404).json({ error: 'policy version not found' }); return; } @@ -48,28 +53,27 @@ privacyRouter.post("/accept-privacy-policy", async (req: Request, res: Response) (user_id, policy_version_id, ip_address, acceptance_channel) VALUES ($1, $2, $3, 'in_app') ON CONFLICT (user_id, policy_version_id) DO NOTHING`, - [user.id, parse.data.versionId, req.ip ?? null], + [user.id, parse.data.versionId, req.ip ?? null] ); // Clear the re-acceptance flag for this user - await pool.query( - "UPDATE users SET privacy_reacceptance_required = FALSE WHERE id = $1", - [user.id], - ); + await pool.query('UPDATE users SET privacy_reacceptance_required = FALSE WHERE id = $1', [ + user.id, + ]); res.json({ accepted: true, versionId: parse.data.versionId }); }); // GET /api/v1/privacy-policy/current — public: current policy version -privacyRouter.get("/current-version", async (_req: Request, res: Response) => { +privacyRouter.get('/current-version', async (_req: Request, res: Response) => { const result = await pool.query( `SELECT version_id, effective_date, change_summary, requires_reacceptance FROM privacy_policy_versions ORDER BY effective_date DESC - LIMIT 1`, + LIMIT 1` ); if (!result.rowCount) { - res.status(404).json({ error: "no privacy policy published yet" }); + res.status(404).json({ error: 'no privacy policy published yet' }); return; } res.json({ version: result.rows[0] }); diff --git a/apps/api/src/routes/regulatory.ts b/apps/api/src/routes/regulatory.ts index 21f6ae5..25a9bd7 100644 --- a/apps/api/src/routes/regulatory.ts +++ b/apps/api/src/routes/regulatory.ts @@ -1,10 +1,16 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import pino from "pino"; -import { pool } from "../db.js"; -import { authMiddleware, requireRole, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; - -const logger = pino({ name: "regulatory-report" }); +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import pino from 'pino'; +import { pool } from '../db.js'; +import { + authMiddleware, + requireRole, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; + +const logger = pino({ name: 'regulatory-report' }); export const regulatoryRouter = Router(); @@ -12,7 +18,7 @@ export const regulatoryRouter = Router(); regulatoryRouter.use(authMiddleware); regulatoryRouter.use(privacyReacceptanceGate); regulatoryRouter.use(tosReacceptanceGate); -regulatoryRouter.use(requireRole("surety_admin")); +regulatoryRouter.use(requireRole('surety_admin')); // In-memory cache for regulatory reports with 24-hour TTL (86400000 ms) interface CacheEntry { @@ -38,25 +44,33 @@ function setCache(key: string, data: any): void { // Zod Schema to parse date parameters and formats const ReportQuerySchema = z.object({ - start_date: z.string().datetime().or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).optional(), - end_date: z.string().datetime().or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)).optional(), - format: z.enum(["json", "csv"]).default("json"), + start_date: z + .string() + .datetime() + .or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)) + .optional(), + end_date: z + .string() + .datetime() + .or(z.string().regex(/^\d{4}-\d{2}-\d{2}$/)) + .optional(), + format: z.enum(['json', 'csv']).default('json'), }); // GET /api/v1/regulatory/state-report/:state_code -regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Response) => { +regulatoryRouter.get('/state-report/:state_code', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; - const stateCode = String(req.params["state_code"] ?? "").toUpperCase(); + const stateCode = String(req.params['state_code'] ?? '').toUpperCase(); // 1. Enforce active license restriction for the specified state const licenses = await pool.query( - "SELECT license_number FROM surety_state_licenses WHERE surety_id = $1 AND state_code = $2", - [user.id, stateCode], + 'SELECT license_number FROM surety_state_licenses WHERE surety_id = $1 AND state_code = $2', + [user.id, stateCode] ); if (!licenses.rowCount || licenses.rowCount === 0) { res.status(403).json({ - error: "Access Denied", + error: 'Access Denied', message: `A active surety license record is required for state '${stateCode}' to generate this report.`, }); return; @@ -65,18 +79,20 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp // 2. Parse query parameters const parseQuery = ReportQuerySchema.safeParse(req.query); if (!parseQuery.success) { - res.status(400).json({ error: "invalid query parameters", details: parseQuery.error.issues }); + res.status(400).json({ error: 'invalid query parameters', details: parseQuery.error.issues }); return; } const { start_date, end_date, format } = parseQuery.data; // Resolve reporting period dates (defaulting to last 30 days if unspecified) - const startDate = start_date ? new Date(start_date) : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const startDate = start_date + ? new Date(start_date) + : new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const endDate = end_date ? new Date(end_date) : new Date(); if (startDate > endDate) { - res.status(400).json({ error: "start_date must be before or equal to end_date" }); + res.status(400).json({ error: 'start_date must be before or equal to end_date' }); return; } @@ -85,10 +101,13 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp const cachedData = getCached(cacheKey); if (cachedData !== null) { - res.setHeader("X-Cache", "HIT"); - if (format === "csv") { - res.setHeader("Content-Type", "text/csv"); - res.setHeader("Content-Disposition", `attachment; filename="regulatory_report_${stateCode}.csv"`); + res.setHeader('X-Cache', 'HIT'); + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="regulatory_report_${stateCode}.csv"` + ); res.send(cachedData); } else { res.json(cachedData); @@ -104,11 +123,11 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp WHERE state_code = $1 AND effective_date >= $2 AND effective_date <= $3`, - [stateCode, startDate, endDate], + [stateCode, startDate, endDate] ); - - const totalBonds = parseInt(bondsQuery.rows[0]?.total_bonds ?? "0", 10); - const aggregateFaceValue = bondsQuery.rows[0]?.aggregate_face_value ?? "0"; + + const totalBonds = parseInt(bondsQuery.rows[0]?.total_bonds ?? '0', 10); + const aggregateFaceValue = bondsQuery.rows[0]?.aggregate_face_value ?? '0'; // b) Claims filed (events with kind 'clawback') of qualifying state bonds within the period const claimsQuery = await pool.query<{ claims_count: string }>( @@ -120,9 +139,9 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp AND br.state_code = $1 AND ce.created_at >= $2 AND ce.created_at <= $3`, - [stateCode, startDate, endDate], + [stateCode, startDate, endDate] ); - const claimsFiled = parseInt(claimsQuery.rows[0]?.claims_count ?? "0", 10); + const claimsFiled = parseInt(claimsQuery.rows[0]?.claims_count ?? '0', 10); // c) On-chain collateral held for qualifying active importers in targeted state const collateralQuery = await pool.query<{ collateral_held: string }>( @@ -132,9 +151,9 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp WHERE br.state_code = $1 AND br.effective_date >= $2 AND br.effective_date <= $3`, - [stateCode, startDate, endDate], + [stateCode, startDate, endDate] ); - const collateralHeld = collateralQuery.rows[0]?.collateral_held ?? "0"; + const collateralHeld = collateralQuery.rows[0]?.collateral_held ?? '0'; // d) Importer counts segmented by business registration state const segmentQuery = await pool.query<{ business_state: string; importer_count: string }>( @@ -145,21 +164,27 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp AND br.effective_date >= $2 AND br.effective_date <= $3 GROUP BY COALESCE(i.business_state, 'UNKNOWN')`, - [stateCode, startDate, endDate], + [stateCode, startDate, endDate] ); - const importerSegmentation = segmentQuery.rows.map((row: { business_state: string; importer_count: string }) => ({ - businessState: row.business_state, - count: parseInt(row.importer_count, 10), - })); + const importerSegmentation = segmentQuery.rows.map( + (row: { business_state: string; importer_count: string }) => ({ + businessState: row.business_state, + count: parseInt(row.importer_count, 10), + }) + ); - const totalImporterCount = importerSegmentation.reduce((acc: number, curr: { count: number }) => acc + curr.count, 0); + const totalImporterCount = importerSegmentation.reduce( + (acc: number, curr: { count: number }) => acc + curr.count, + 0 + ); // 5. Generate Response Data let responseData: any; - if (format === "csv") { - let csvData = "State Code,Total Bonds Written,Aggregate Face Value,Claims Filed,Collateral Held On-Chain,Importer Business Registration State,Importer Count\r\n"; + if (format === 'csv') { + let csvData = + 'State Code,Total Bonds Written,Aggregate Face Value,Claims Filed,Collateral Held On-Chain,Importer Business Registration State,Importer Count\r\n'; if (importerSegmentation.length === 0) { csvData += `${stateCode},${totalBonds},${aggregateFaceValue},${claimsFiled},${collateralHeld},,0\r\n`; } else { @@ -190,25 +215,31 @@ regulatoryRouter.get("/state-report/:state_code", async (req: Request, res: Resp await pool.query( `INSERT INTO regulatory_report_audit_logs (surety_id, state_code, start_date, end_date, output_format) VALUES ($1, $2, $3, $4, $5)`, - [user.id, stateCode, startDate, endDate, format], + [user.id, stateCode, startDate, endDate, format] ); - logger.info({ - event: "regulatory_report_generation", - suretyId: user.id, - suretyEmail: user.email, - stateCode, - period: { startDate: startDate.toISOString(), endDate: endDate.toISOString() }, - outputFormat: format, - }, "Compliance report generated for regulator"); + logger.info( + { + event: 'regulatory_report_generation', + suretyId: user.id, + suretyEmail: user.email, + stateCode, + period: { startDate: startDate.toISOString(), endDate: endDate.toISOString() }, + outputFormat: format, + }, + 'Compliance report generated for regulator' + ); // 7. Write to cache and send response setCache(cacheKey, responseData); - res.setHeader("X-Cache", "MISS"); - - if (format === "csv") { - res.setHeader("Content-Type", "text/csv"); - res.setHeader("Content-Disposition", `attachment; filename="regulatory_report_${stateCode}.csv"`); + res.setHeader('X-Cache', 'MISS'); + + if (format === 'csv') { + res.setHeader('Content-Type', 'text/csv'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="regulatory_report_${stateCode}.csv"` + ); res.send(responseData); } else { res.json(responseData); diff --git a/apps/api/src/routes/surety-license.ts b/apps/api/src/routes/surety-license.ts index 444b7d6..61956d6 100644 --- a/apps/api/src/routes/surety-license.ts +++ b/apps/api/src/routes/surety-license.ts @@ -10,10 +10,10 @@ // (https://www.naic.org/cis) or a state DOI licensing API to validate the // NAIC number, confirm admitted-carrier status, and check A.M. Best rating. -import { Router, type NextFunction, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool } from "../db.js"; -import { authMiddleware, type AuthedRequest } from "../auth.js"; +import { Router, type NextFunction, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { authMiddleware, type AuthedRequest } from '../auth.js'; export const suretyLicenseRouter = Router(); suretyLicenseRouter.use(authMiddleware); @@ -22,8 +22,8 @@ suretyLicenseRouter.use(authMiddleware); function requireSuretyAdmin(req: Request, res: Response, next: NextFunction) { const user = (req as AuthedRequest).user; - if (user.role !== "surety_admin") { - res.status(403).json({ error: "surety_admin only" }); + if (user.role !== 'surety_admin') { + res.status(403).json({ error: 'surety_admin only' }); return; } next(); @@ -32,21 +32,20 @@ function requireSuretyAdmin(req: Request, res: Response, next: NextFunction) { // Exported so importers.ts can gate clawback / accrue-yield behind it. export async function requireLicenseVerified(req: Request, res: Response, next: NextFunction) { const user = (req as AuthedRequest).user; - if (user.role !== "surety_admin") { + if (user.role !== 'surety_admin') { next(); return; } - const r = await pool.query( - "SELECT status FROM surety_license_verifications WHERE user_id = $1", - [user.id], - ); - if (!r.rowCount || r.rows[0]?.status !== "verified") { + const r = await pool.query('SELECT status FROM surety_license_verifications WHERE user_id = $1', [ + user.id, + ]); + if (!r.rowCount || r.rows[0]?.status !== 'verified') { res.status(403).json({ - error: "surety license not verified", + error: 'surety license not verified', message: - "Submit your NAIC number and company details at POST /surety-license/submit, " + - "then contact the platform admin for review.", - currentStatus: r.rows[0]?.status ?? "no_record", + 'Submit your NAIC number and company details at POST /surety-license/submit, ' + + 'then contact the platform admin for review.', + currentStatus: r.rows[0]?.status ?? 'no_record', }); return; } @@ -63,14 +62,15 @@ const SubmitSchema = z.object({ licenseStatusDetail: z.string().max(1000).optional(), }); -suretyLicenseRouter.post("/submit", requireSuretyAdmin, async (req: Request, res: Response) => { +suretyLicenseRouter.post('/submit', requireSuretyAdmin, async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = SubmitSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } - const { naicNumber, companyName, stateOfDomicile, amBestRating, licenseStatusDetail } = parse.data; + const { naicNumber, companyName, stateOfDomicile, amBestRating, licenseStatusDetail } = + parse.data; const r = await pool.query( `UPDATE surety_license_verifications @@ -83,16 +83,24 @@ suretyLicenseRouter.post("/submit", requireSuretyAdmin, async (req: Request, res submitted_at = now() WHERE user_id = $6 RETURNING id, status`, - [naicNumber, companyName, stateOfDomicile, amBestRating ?? null, licenseStatusDetail ?? null, user.id], + [ + naicNumber, + companyName, + stateOfDomicile, + amBestRating ?? null, + licenseStatusDetail ?? null, + user.id, + ] ); if (!r.rowCount) { - res.status(404).json({ error: "no license verification record found for this account" }); + res.status(404).json({ error: 'no license verification record found for this account' }); return; } res.json({ - message: "License details submitted for review. A platform admin will verify your NAIC credentials.", + message: + 'License details submitted for review. A platform admin will verify your NAIC credentials.', id: r.rows[0]!.id, status: r.rows[0]!.status, }); @@ -100,17 +108,17 @@ suretyLicenseRouter.post("/submit", requireSuretyAdmin, async (req: Request, res // ── GET /surety-license/status ──────────────────────────────────────────────── -suretyLicenseRouter.get("/status", requireSuretyAdmin, async (req: Request, res: Response) => { +suretyLicenseRouter.get('/status', requireSuretyAdmin, async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const r = await pool.query( `SELECT id, naic_number, company_name, state_of_domicile, am_best_rating, status, submitted_at, reviewed_at, rejection_reason, created_at FROM surety_license_verifications WHERE user_id = $1`, - [user.id], + [user.id] ); if (!r.rowCount) { - res.status(404).json({ error: "no license verification record found" }); + res.status(404).json({ error: 'no license verification record found' }); return; } res.json({ verification: r.rows[0] }); @@ -122,19 +130,19 @@ suretyLicenseRouter.get("/status", requireSuretyAdmin, async (req: Request, res: // endpoint (access is controlled at the network/infra layer in production deployments). const ReviewSchema = z.object({ - action: z.enum(["approve", "reject"]), + action: z.enum(['approve', 'reject']), rejectionReason: z.string().max(1000).optional(), }); -suretyLicenseRouter.put("/:id/review", async (req: Request, res: Response) => { +suretyLicenseRouter.put('/:id/review', async (req: Request, res: Response) => { const reviewer = (req as AuthedRequest).user; const parse = ReviewSchema.safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "invalid input", details: parse.error.issues }); + res.status(400).json({ error: 'invalid input', details: parse.error.issues }); return; } const { action, rejectionReason } = parse.data; - const newStatus = action === "approve" ? "verified" : "rejected"; + const newStatus = action === 'approve' ? 'verified' : 'rejected'; const r = await pool.query( `UPDATE surety_license_verifications @@ -144,11 +152,11 @@ suretyLicenseRouter.put("/:id/review", async (req: Request, res: Response) => { rejection_reason = $3 WHERE id = $4 RETURNING id, status, user_id`, - [newStatus, reviewer.id, rejectionReason ?? null, req.params.id], + [newStatus, reviewer.id, rejectionReason ?? null, req.params.id] ); if (!r.rowCount) { - res.status(404).json({ error: "verification record not found" }); + res.status(404).json({ error: 'verification record not found' }); return; } @@ -157,29 +165,30 @@ suretyLicenseRouter.put("/:id/review", async (req: Request, res: Response) => { // ── GET /surety-license (admin: list all pending/submitted) ─────────────────── -suretyLicenseRouter.get("/", async (req: Request, res: Response) => { +suretyLicenseRouter.get('/', async (req: Request, res: Response) => { const statusFilter = req.query.status as string | undefined; - const validStatuses = ["pending", "submitted", "verified", "rejected"]; + const validStatuses = ['pending', 'submitted', 'verified', 'rejected']; - const r = statusFilter && validStatuses.includes(statusFilter) - ? await pool.query( - `SELECT slv.id, slv.naic_number, slv.company_name, slv.state_of_domicile, + const r = + statusFilter && validStatuses.includes(statusFilter) + ? await pool.query( + `SELECT slv.id, slv.naic_number, slv.company_name, slv.state_of_domicile, slv.am_best_rating, slv.status, slv.submitted_at, slv.reviewed_at, slv.rejection_reason, u.email FROM surety_license_verifications slv JOIN users u ON u.id = slv.user_id WHERE slv.status = $1 ORDER BY slv.created_at DESC`, - [statusFilter], - ) - : await pool.query( - `SELECT slv.id, slv.naic_number, slv.company_name, slv.state_of_domicile, + [statusFilter] + ) + : await pool.query( + `SELECT slv.id, slv.naic_number, slv.company_name, slv.state_of_domicile, slv.am_best_rating, slv.status, slv.submitted_at, slv.reviewed_at, slv.rejection_reason, u.email FROM surety_license_verifications slv JOIN users u ON u.id = slv.user_id - ORDER BY slv.created_at DESC`, - ); + ORDER BY slv.created_at DESC` + ); res.json({ verifications: r.rows }); }); diff --git a/apps/api/src/routes/tos.ts b/apps/api/src/routes/tos.ts index 6733421..ac5559b 100644 --- a/apps/api/src/routes/tos.ts +++ b/apps/api/src/routes/tos.ts @@ -1,7 +1,12 @@ -import { Router, type Request, type Response } from "express"; -import { z } from "zod"; -import { pool } from "../db.js"; -import { authMiddleware, privacyReacceptanceGate, tosReacceptanceGate, type AuthedRequest } from "../auth.js"; +import { Router, type Request, type Response } from 'express'; +import { z } from 'zod'; +import { pool } from '../db.js'; +import { + authMiddleware, + privacyReacceptanceGate, + tosReacceptanceGate, + type AuthedRequest, +} from '../auth.js'; export const tosRouter = Router(); tosRouter.use(authMiddleware); @@ -9,7 +14,7 @@ tosRouter.use(privacyReacceptanceGate); tosRouter.use(tosReacceptanceGate); // GET /api/v1/account/tos-history — user's ToS acceptance history -tosRouter.get("/tos-history", async (req: Request, res: Response) => { +tosRouter.get('/tos-history', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const rows = await pool.query( `SELECT ta.tos_version, ta.accepted_at, ta.acceptance_method, ta.ip_address, @@ -18,27 +23,26 @@ tosRouter.get("/tos-history", async (req: Request, res: Response) => { JOIN tos_versions tv ON tv.version_id = ta.tos_version WHERE ta.user_id = $1 ORDER BY ta.accepted_at DESC`, - [user.id], + [user.id] ); res.json({ acceptances: rows.rows }); }); // POST /api/v1/account/accept-tos — accept a new ToS version -tosRouter.post("/accept-tos", async (req: Request, res: Response) => { +tosRouter.post('/accept-tos', async (req: Request, res: Response) => { const user = (req as AuthedRequest).user; const parse = z.object({ versionId: z.string().min(1) }).safeParse(req.body); if (!parse.success) { - res.status(400).json({ error: "versionId is required" }); + res.status(400).json({ error: 'versionId is required' }); return; } - const version = await pool.query( - "SELECT version_id FROM tos_versions WHERE version_id = $1", - [parse.data.versionId], - ); + const version = await pool.query('SELECT version_id FROM tos_versions WHERE version_id = $1', [ + parse.data.versionId, + ]); if (!version.rowCount) { - res.status(404).json({ error: "ToS version not found" }); + res.status(404).json({ error: 'ToS version not found' }); return; } @@ -46,13 +50,10 @@ tosRouter.post("/accept-tos", async (req: Request, res: Response) => { `INSERT INTO tos_acceptances (user_id, tos_version, ip_address, user_agent, acceptance_method) VALUES ($1, $2, $3, $4, 're-acceptance')`, - [user.id, parse.data.versionId, req.ip ?? null, req.get("user-agent") ?? null], + [user.id, parse.data.versionId, req.ip ?? null, req.get('user-agent') ?? null] ); - await pool.query( - "UPDATE users SET tos_reacceptance_required = FALSE WHERE id = $1", - [user.id], - ); + await pool.query('UPDATE users SET tos_reacceptance_required = FALSE WHERE id = $1', [user.id]); res.json({ accepted: true, versionId: parse.data.versionId }); }); diff --git a/apps/api/src/services/aml-screening.ts b/apps/api/src/services/aml-screening.ts index 5b6f074..b938d15 100644 --- a/apps/api/src/services/aml-screening.ts +++ b/apps/api/src/services/aml-screening.ts @@ -1,9 +1,9 @@ -import pino from "pino"; -import { pool } from "../db.js"; +import pino from 'pino'; +import { pool } from '../db.js'; -const logger = pino({ name: "aml-screening" }); +const logger = pino({ name: 'aml-screening' }); -export type AmlRiskScore = "LOW" | "MEDIUM" | "HIGH"; +export type AmlRiskScore = 'LOW' | 'MEDIUM' | 'HIGH'; export type AmlScreeningResult = { walletAddress: string; @@ -13,26 +13,30 @@ export type AmlScreeningResult = { }; export async function screenWalletAddress(walletAddress: string): Promise { - logger.info({ walletAddress }, "Screening wallet address against AML provider"); - + logger.info({ walletAddress }, 'Screening wallet address against AML provider'); + // In a real application, this would call Chainalysis KYT, Elliptic, TRM Labs, etc. // For demonstration, we will deterministically score based on the address string - + const riskScore: AmlRiskScore = determineMockRiskScore(walletAddress); - const providerResponse = { mockProvider: "ChainalysisMock", score: riskScore, screenedAt: new Date().toISOString() }; + const providerResponse = { + mockProvider: 'ChainalysisMock', + score: riskScore, + screenedAt: new Date().toISOString(), + }; let resolutionAction: string | null = null; - - if (riskScore === "HIGH") { - resolutionAction = "pending_manual_review"; - } else if (riskScore === "MEDIUM") { - resolutionAction = "auto_cleared_medium"; + + if (riskScore === 'HIGH') { + resolutionAction = 'pending_manual_review'; + } else if (riskScore === 'MEDIUM') { + resolutionAction = 'auto_cleared_medium'; } else { - resolutionAction = "auto_cleared_low"; + resolutionAction = 'auto_cleared_low'; } // Record screening to database per BSA requirements await pool.query( - "INSERT INTO aml_screenings (wallet_address, risk_score, provider_response, resolution_action) VALUES ($1, $2, $3, $4)", + 'INSERT INTO aml_screenings (wallet_address, risk_score, provider_response, resolution_action) VALUES ($1, $2, $3, $4)', [walletAddress, riskScore, JSON.stringify(providerResponse), resolutionAction] ); @@ -47,16 +51,16 @@ export async function screenWalletAddress(walletAddress: string): Promise { // Screen OFAC SDN list for importer legal names and EINs via a sanctions screening API // Mock logic: block any name with 'sanctioned' - logger.info({ legalName, ein }, "Screening importer entity against OFAC"); - - if (legalName.toLowerCase().includes("sanctioned")) { + logger.info({ legalName, ein }, 'Screening importer entity against OFAC'); + + if (legalName.toLowerCase().includes('sanctioned')) { return false; } return true; } function determineMockRiskScore(address: string): AmlRiskScore { - if (address.startsWith("G") && address.includes("HIGH")) return "HIGH"; - if (address.startsWith("G") && address.includes("MED")) return "MEDIUM"; - return "LOW"; + if (address.startsWith('G') && address.includes('HIGH')) return 'HIGH'; + if (address.startsWith('G') && address.includes('MED')) return 'MEDIUM'; + return 'LOW'; } diff --git a/apps/api/src/services/cbp-bond-validation.ts b/apps/api/src/services/cbp-bond-validation.ts index d7c2690..0103fed 100644 --- a/apps/api/src/services/cbp-bond-validation.ts +++ b/apps/api/src/services/cbp-bond-validation.ts @@ -18,7 +18,7 @@ interface BondData { annualDuties?: number; } -const CBP_BOND_TYPE_CODES = ["01", "02", "03", "04"]; +const CBP_BOND_TYPE_CODES = ['01', '02', '03', '04']; const CONTINUOUS_BOND_MIN_AMOUNT = BigInt(50_000_00000000); export function validateBondForm301(bondData: BondData): BondValidationResult { @@ -28,48 +28,55 @@ export function validateBondForm301(bondData: BondData): BondValidationResult { let minimumRequired = CONTINUOUS_BOND_MIN_AMOUNT; if (!bondData.principalLegalName || bondData.principalLegalName.trim().length === 0) { - errors.push("Principal legal name is required per CBP Form 301"); + errors.push('Principal legal name is required per CBP Form 301'); } if (!bondData.principalEin || !validateEIN(bondData.principalEin)) { - errors.push("Principal EIN must be valid format (XX-XXXXXXX)"); + errors.push('Principal EIN must be valid format (XX-XXXXXXX)'); } if (!bondData.suretyCompanyName || bondData.suretyCompanyName.trim().length === 0) { - errors.push("Surety company name is required per CBP Form 301"); + errors.push('Surety company name is required per CBP Form 301'); } if (!bondData.suretyFein || !validateEIN(bondData.suretyFein)) { - errors.push("Surety FEIN must be valid format (XX-XXXXXXX)"); + errors.push('Surety FEIN must be valid format (XX-XXXXXXX)'); } if (!bondData.bondTypeCode || !CBP_BOND_TYPE_CODES.includes(bondData.bondTypeCode)) { - errors.push("Bond type code must be one of: 01 (single transaction), 02 (continuous), 03 (annual), 04 (customs house broker)"); + errors.push( + 'Bond type code must be one of: 01 (single transaction), 02 (continuous), 03 (annual), 04 (customs house broker)' + ); } - if (bondData.bondTypeCode === "02") { + if (bondData.bondTypeCode === '02') { if (!bondData.annualDuties) { minimumRequired = CONTINUOUS_BOND_MIN_AMOUNT; } else { const tenPercentOfDuties = BigInt(Math.ceil(bondData.annualDuties * 0.1 * 1e7)); - minimumRequired = tenPercentOfDuties > CONTINUOUS_BOND_MIN_AMOUNT ? tenPercentOfDuties : CONTINUOUS_BOND_MIN_AMOUNT; + minimumRequired = + tenPercentOfDuties > CONTINUOUS_BOND_MIN_AMOUNT + ? tenPercentOfDuties + : CONTINUOUS_BOND_MIN_AMOUNT; } if (!bondData.bondAmount || bondData.bondAmount < minimumRequired) { - errors.push(`Continuous bond must meet minimum: ${minimumRequired.toString()} stroops (${(Number(minimumRequired) / 1e7).toFixed(2)} USD)`); + errors.push( + `Continuous bond must meet minimum: ${minimumRequired.toString()} stroops (${(Number(minimumRequired) / 1e7).toFixed(2)} USD)` + ); requiresIncrease = true; } } if (bondData.effectiveDate && bondData.expiryDate) { if (bondData.expiryDate <= bondData.effectiveDate) { - errors.push("Expiry date must be after effective date"); + errors.push('Expiry date must be after effective date'); } const expiryTime = bondData.expiryDate.getTime(); const nowTime = Date.now(); if (expiryTime < nowTime) { - errors.push("Bond expiry date cannot be in the past"); + errors.push('Bond expiry date cannot be in the past'); } const daysUntilExpiry = Math.floor((expiryTime - nowTime) / (1000 * 60 * 60 * 24)); @@ -79,7 +86,7 @@ export function validateBondForm301(bondData: BondData): BondValidationResult { } if (!bondData.effectiveDate) { - errors.push("Effective date is required"); + errors.push('Effective date is required'); } return { @@ -97,7 +104,7 @@ function validateEIN(ein: string): boolean { } export function calculateMinimumBondAmount(bondTypeCode: string, annualDuties?: number): bigint { - if (bondTypeCode !== "02") { + if (bondTypeCode !== '02') { return BigInt(0); } diff --git a/apps/api/src/services/cbp-duty-lookup.ts b/apps/api/src/services/cbp-duty-lookup.ts index 365fd36..5bfd0a1 100644 --- a/apps/api/src/services/cbp-duty-lookup.ts +++ b/apps/api/src/services/cbp-duty-lookup.ts @@ -1,7 +1,6 @@ -import pino from "pino"; -import { env } from "../config/env.js"; +import pino from 'pino'; -const logger = pino({ name: "cbp-duty-lookup" }); +const logger = pino({ name: 'cbp-duty-lookup' }); // In-memory map for caching const cache = new Map(); @@ -9,31 +8,31 @@ const cache = new Map(); export type CbpLookupResult = { htsCode: string; dutyRate: number | null; - source: "cache" | "api" | "fallback"; + source: 'cache' | 'api' | 'fallback'; }; export async function lookupCbpDutyRate(htsCode: string): Promise { // Check cache const cached = cache.get(htsCode); if (cached && cached.expiresAt > Date.now()) { - return { htsCode, dutyRate: cached.rate, source: "cache" }; + return { htsCode, dutyRate: cached.rate, source: 'cache' }; } try { // In a real app, this queries the CBP ACE trade data API or HTS online schedule endpoint // We'll mock the response based on the HTS code for demonstration purposes const mockRate = generateMockRateForHts(htsCode); - + // Simulate network delay await new Promise((resolve) => setTimeout(resolve, 50)); // Cache for 24 hours cache.set(htsCode, { rate: mockRate, expiresAt: Date.now() + 86400 * 1000 }); - return { htsCode, dutyRate: mockRate, source: "api" }; + return { htsCode, dutyRate: mockRate, source: 'api' }; } catch (error) { - logger.error({ err: error, htsCode }, "Failed to lookup CBP duty rate"); - return { htsCode, dutyRate: null, source: "fallback" }; + logger.error({ err: error, htsCode }, 'Failed to lookup CBP duty rate'); + return { htsCode, dutyRate: null, source: 'fallback' }; } } diff --git a/apps/api/src/services/hts-rate-validator.test.ts b/apps/api/src/services/hts-rate-validator.test.ts index 3bb370e..380daca 100644 --- a/apps/api/src/services/hts-rate-validator.test.ts +++ b/apps/api/src/services/hts-rate-validator.test.ts @@ -7,8 +7,8 @@ * database connections are required. */ -import { describe, it, before, after, mock } from "node:test"; -import assert from "node:assert/strict"; +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; // ─── Mock the DB pool before the service module is loaded ───────────────────── @@ -16,14 +16,6 @@ import assert from "node:assert/strict"; // "../db.js" at module scope we patch the module via a simple in-process mock // that replaces the named export before the test-subject is imported. -// Minimal pool stub: cache miss by default (no rows returned). -const poolStub = { - query: async (_sql: string, _params?: unknown[]): Promise<{ rows: unknown[]; rowCount: number }> => ({ - rows: [], - rowCount: 0, - }), -}; - // We use dynamic import + module mocking via the mock.module API (Node 22+). // For compatibility down to Node 18 we instead patch the module cache through // a re-export shim: the test file imports the validator *after* overriding the @@ -38,7 +30,7 @@ const poolStub = { // ─── Dynamic import of validator (after env is set) ──────────────────────── -let validator: typeof import("./hts-rate-validator.js"); +let validator: typeof import('./hts-rate-validator.js'); // We'll patch global fetch to control USITC API responses. type FetchMock = (url: string | URL | Request, init?: RequestInit) => Promise; @@ -58,13 +50,13 @@ function clearFetchMock() { function jsonResponse(data: unknown, status = 200): Response { return new Response(JSON.stringify(data), { status, - headers: { "Content-Type": "application/json" }, + headers: { 'Content-Type': 'application/json' }, }); } /** A USITC-shaped entry for a code with a known percentage rate. */ function usitcEntry(col1General: string) { - return [{ col1General, htsCode: "test" }]; + return [{ col1General, htsCode: 'test' }]; } // ─── Bootstrap ──────────────────────────────────────────────────────────────── @@ -84,7 +76,7 @@ before(async () => { // (no real DB) — the validator catches DB errors and falls back gracefully. // For full isolation we use the test-seam approach: the validator accepts an // injected pool via an exported function when running under test. - validator = await import("./hts-rate-validator.js"); + validator = await import('./hts-rate-validator.js'); }); after(() => { @@ -93,30 +85,30 @@ after(() => { // ─── parseDutyRateString unit tests ────────────────────────────────────────── -describe("parseDutyRateString", () => { - it("parses a simple percentage", () => { - assert.equal(validator.parseDutyRateString("6.5%"), 0.065); +describe('parseDutyRateString', () => { + it('parses a simple percentage', () => { + assert.equal(validator.parseDutyRateString('6.5%'), 0.065); }); - it("parses Free as zero", () => { - assert.equal(validator.parseDutyRateString("Free"), 0); + it('parses Free as zero', () => { + assert.equal(validator.parseDutyRateString('Free'), 0); }); - it("parses 0%", () => { - assert.equal(validator.parseDutyRateString("0%"), 0); + it('parses 0%', () => { + assert.equal(validator.parseDutyRateString('0%'), 0); }); - it("extracts ad-valorem component from compound expression", () => { - const result = validator.parseDutyRateString("10 cents/kg + 6.5%"); + it('extracts ad-valorem component from compound expression', () => { + const result = validator.parseDutyRateString('10 cents/kg + 6.5%'); assert.equal(result, 0.065); }); - it("returns null for pure specific duty", () => { - assert.equal(validator.parseDutyRateString("$1.50/kg"), null); + it('returns null for pure specific duty', () => { + assert.equal(validator.parseDutyRateString('$1.50/kg'), null); }); - it("handles whitespace", () => { - assert.equal(validator.parseDutyRateString(" 12.5% "), 0.125); + it('handles whitespace', () => { + assert.equal(validator.parseDutyRateString(' 12.5% '), 0.125); }); }); @@ -128,26 +120,26 @@ describe("parseDutyRateString", () => { // getCached returns null → proceeds to fetch). The DB error is caught // internally and the fallback is "cache miss → go to API". -describe("validateHtsRates — valid rates (pass)", () => { - it("returns ok status for a rate within 5% tolerance", async () => { - mockFetch(async () => jsonResponse(usitcEntry("10%"))); +describe('validateHtsRates — valid rates (pass)', () => { + it('returns ok status for a rate within 5% tolerance', async () => { + mockFetch(async () => jsonResponse(usitcEntry('10%'))); const summary = await validator.validateHtsRates([ - { hts_code: "0101.21.00", declared_rate: 0.10 }, // exact match + { hts_code: '0101.21.00', declared_rate: 0.1 }, // exact match ]); assert.equal(summary.hasBlockingErrors, false); assert.equal(summary.blocking.length, 0); assert.equal(summary.passed.length, 1); - assert.equal(summary.passed[0]!.status, "ok"); + assert.equal(summary.passed[0]!.status, 'ok'); clearFetchMock(); }); - it("allows declared rate up to 5% above statutory without blocking", async () => { - mockFetch(async () => jsonResponse(usitcEntry("10%"))); + it('allows declared rate up to 5% above statutory without blocking', async () => { + mockFetch(async () => jsonResponse(usitcEntry('10%'))); const summary = await validator.validateHtsRates([ - { hts_code: "0101.21.01", declared_rate: 0.104 }, // 10.4% — just inside upper band + { hts_code: '0101.21.01', declared_rate: 0.104 }, // 10.4% — just inside upper band ]); assert.equal(summary.hasBlockingErrors, false); @@ -156,27 +148,27 @@ describe("validateHtsRates — valid rates (pass)", () => { }); }); -describe("validateHtsRates — underreported item (block)", () => { - it("blocks upload when declared rate is more than 5% below statutory", async () => { - mockFetch(async () => jsonResponse(usitcEntry("10%"))); +describe('validateHtsRates — underreported item (block)', () => { + it('blocks upload when declared rate is more than 5% below statutory', async () => { + mockFetch(async () => jsonResponse(usitcEntry('10%'))); const summary = await validator.validateHtsRates([ - { hts_code: "6110.20.20", declared_rate: 0.02 }, // 2% declared vs 10% statutory + { hts_code: '6110.20.20', declared_rate: 0.02 }, // 2% declared vs 10% statutory ]); assert.equal(summary.hasBlockingErrors, true); assert.equal(summary.blocking.length, 1); - assert.equal(summary.blocking[0]!.status, "underreported"); - assert.equal(summary.blocking[0]!.hts_code, "6110.20.20"); + assert.equal(summary.blocking[0]!.status, 'underreported'); + assert.equal(summary.blocking[0]!.hts_code, '6110.20.20'); clearFetchMock(); }); - it("blocks when multiple items are underreported", async () => { - mockFetch(async () => jsonResponse(usitcEntry("20%"))); + it('blocks when multiple items are underreported', async () => { + mockFetch(async () => jsonResponse(usitcEntry('20%'))); const summary = await validator.validateHtsRates([ - { hts_code: "8471.30.01", declared_rate: 0.01 }, - { hts_code: "8471.30.02", declared_rate: 0.01 }, + { hts_code: '8471.30.01', declared_rate: 0.01 }, + { hts_code: '8471.30.02', declared_rate: 0.01 }, ]); assert.equal(summary.hasBlockingErrors, true); @@ -185,100 +177,100 @@ describe("validateHtsRates — underreported item (block)", () => { }); }); -describe("validateHtsRates — overreported item (warn, non-blocking)", () => { - it("does not block but adds warning when declared rate exceeds statutory by >5%", async () => { - mockFetch(async () => jsonResponse(usitcEntry("5%"))); +describe('validateHtsRates — overreported item (warn, non-blocking)', () => { + it('does not block but adds warning when declared rate exceeds statutory by >5%', async () => { + mockFetch(async () => jsonResponse(usitcEntry('5%'))); const summary = await validator.validateHtsRates([ - { hts_code: "4202.11.00", declared_rate: 0.15 }, // 15% vs 5% statutory → overreported + { hts_code: '4202.11.00', declared_rate: 0.15 }, // 15% vs 5% statutory → overreported ]); assert.equal(summary.hasBlockingErrors, false); assert.equal(summary.blocking.length, 0); assert.equal(summary.warnings.length, 1); - assert.equal(summary.warnings[0]!.status, "overreported"); + assert.equal(summary.warnings[0]!.status, 'overreported'); clearFetchMock(); }); }); -describe("validateHtsRates — unknown HTS code (skip with warning)", () => { - it("warns but does not block when HTS code is not in USITC schedule", async () => { +describe('validateHtsRates — unknown HTS code (skip with warning)', () => { + it('warns but does not block when HTS code is not in USITC schedule', async () => { mockFetch(async () => jsonResponse([])); // empty array = code not found const summary = await validator.validateHtsRates([ - { hts_code: "9999.99.99", declared_rate: 0.05 }, + { hts_code: '9999.99.99', declared_rate: 0.05 }, ]); assert.equal(summary.hasBlockingErrors, false); assert.equal(summary.blocking.length, 0); assert.equal(summary.warnings.length, 1); - assert.equal(summary.warnings[0]!.status, "unknown_hts"); + assert.equal(summary.warnings[0]!.status, 'unknown_hts'); clearFetchMock(); }); }); -describe("validateHtsRates — HTS API unavailability (fallback to warn mode)", () => { - it("warns but does not block when the USITC API returns a non-200 status", async () => { - mockFetch(async () => new Response("Service Unavailable", { status: 503 })); +describe('validateHtsRates — HTS API unavailability (fallback to warn mode)', () => { + it('warns but does not block when the USITC API returns a non-200 status', async () => { + mockFetch(async () => new Response('Service Unavailable', { status: 503 })); const summary = await validator.validateHtsRates([ - { hts_code: "2709.00.20", declared_rate: 0.025 }, + { hts_code: '2709.00.20', declared_rate: 0.025 }, ]); assert.equal(summary.hasBlockingErrors, false); assert.equal(summary.warnings.length, 1); - assert.equal(summary.warnings[0]!.status, "api_unavailable"); + assert.equal(summary.warnings[0]!.status, 'api_unavailable'); clearFetchMock(); }); - it("warns but does not block when the USITC API throws (network error)", async () => { + it('warns but does not block when the USITC API throws (network error)', async () => { mockFetch(async () => { - throw new Error("Network unreachable"); + throw new Error('Network unreachable'); }); const summary = await validator.validateHtsRates([ - { hts_code: "2709.00.21", declared_rate: 0.025 }, + { hts_code: '2709.00.21', declared_rate: 0.025 }, ]); assert.equal(summary.hasBlockingErrors, false); assert.equal(summary.warnings.length, 1); - assert.equal(summary.warnings[0]!.status, "api_unavailable"); + assert.equal(summary.warnings[0]!.status, 'api_unavailable'); clearFetchMock(); }); - it("warns but does not block when the USITC API times out", async () => { + it('warns but does not block when the USITC API times out', async () => { mockFetch(async () => { - const err = new Error("The operation was aborted"); - err.name = "TimeoutError"; + const err = new Error('The operation was aborted'); + err.name = 'TimeoutError'; throw err; }); const summary = await validator.validateHtsRates([ - { hts_code: "2709.00.22", declared_rate: 0.025 }, + { hts_code: '2709.00.22', declared_rate: 0.025 }, ]); assert.equal(summary.hasBlockingErrors, false); - assert.equal(summary.warnings[0]!.status, "api_unavailable"); + assert.equal(summary.warnings[0]!.status, 'api_unavailable'); clearFetchMock(); }); }); -describe("validateHtsRates — mixed results", () => { - it("correctly partitions blocking, warnings, and passed across multiple items", async () => { +describe('validateHtsRates — mixed results', () => { + it('correctly partitions blocking, warnings, and passed across multiple items', async () => { let callCount = 0; mockFetch(async () => { callCount++; // First call: statutory 10% → declared 2% = underreported (block) // Second call: statutory 5% → declared 15% = overreported (warn) // Third call: statutory 8% → declared 8% = ok - const rates = ["10%", "5%", "8%"]; - return jsonResponse(usitcEntry(rates[callCount - 1] ?? "0%")); + const rates = ['10%', '5%', '8%']; + return jsonResponse(usitcEntry(rates[callCount - 1] ?? '0%')); }); const summary = await validator.validateHtsRates([ - { hts_code: "1001.11.00", declared_rate: 0.02 }, // underreported - { hts_code: "1001.11.01", declared_rate: 0.15 }, // overreported - { hts_code: "1001.11.02", declared_rate: 0.08 }, // ok + { hts_code: '1001.11.00', declared_rate: 0.02 }, // underreported + { hts_code: '1001.11.01', declared_rate: 0.15 }, // overreported + { hts_code: '1001.11.02', declared_rate: 0.08 }, // ok ]); assert.equal(summary.blocking.length, 1); diff --git a/apps/api/src/services/hts-rate-validator.ts b/apps/api/src/services/hts-rate-validator.ts index 7474105..140f6f6 100644 --- a/apps/api/src/services/hts-rate-validator.ts +++ b/apps/api/src/services/hts-rate-validator.ts @@ -15,13 +15,10 @@ * • USITC API unavailable → "api_unavailable" (WARNING, skip) */ -import pino from "pino"; -import { pool } from "../db.js"; +import pino from 'pino'; +import { pool } from '../db.js'; -const logger = pino({ name: "hts-rate-validator" }); - -/** Seven days in milliseconds. */ -const CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const logger = pino({ name: 'hts-rate-validator' }); /** Tolerance band: 5 % (0.05). */ const TOLERANCE = 0.05; @@ -32,11 +29,7 @@ export interface HtsLineItem { } export type ValidationStatus = - | "ok" - | "underreported" - | "overreported" - | "unknown_hts" - | "api_unavailable"; + 'ok' | 'underreported' | 'overreported' | 'unknown_hts' | 'api_unavailable'; export interface HtsValidationResult { hts_code: string; @@ -65,27 +58,27 @@ export interface HtsValidationSummary { // We parse the JSON "General Rate of Duty" field which is a string like "6.5%", // "Free", or a compound expression. We convert it to a decimal fraction. -const USITC_BASE = "https://hts.usitc.gov/reststop"; +const USITC_BASE = 'https://hts.usitc.gov/reststop'; /** * Fetch the Column 1 General Rate from the USITC HTS online API. * Returns null when the code is not found or the API is unavailable. */ async function fetchStatutoryRate( - htsCode: string, + htsCode: string ): Promise<{ rate: number | null; unavailable: boolean }> { // Normalise code: strip punctuation for the query, keep original for display - const normalised = htsCode.replace(/[^0-9]/g, ""); + const normalised = htsCode.replace(/[^0-9]/g, ''); const url = `${USITC_BASE}/exportHts?htsCodes=${encodeURIComponent(normalised)}`; try { const resp = await fetch(url, { signal: AbortSignal.timeout(8000), - headers: { Accept: "application/json" }, + headers: { Accept: 'application/json' }, }); if (!resp.ok) { - logger.warn({ htsCode, status: resp.status }, "USITC HTS API returned non-200"); + logger.warn({ htsCode, status: resp.status }, 'USITC HTS API returned non-200'); return { rate: null, unavailable: true }; } @@ -98,10 +91,10 @@ async function fetchStatutoryRate( // Field names vary slightly between USITC API versions; try common candidates. const entry = data[0]!; const rawRate = - (entry["col1General"] as string | undefined) ?? - (entry["generalRateOfDuty"] as string | undefined) ?? - (entry["Col1General"] as string | undefined) ?? - (entry["general"] as string | undefined); + (entry['col1General'] as string | undefined) ?? + (entry['generalRateOfDuty'] as string | undefined) ?? + (entry['Col1General'] as string | undefined) ?? + (entry['general'] as string | undefined); if (!rawRate) return { rate: null, unavailable: false }; @@ -109,9 +102,11 @@ async function fetchStatutoryRate( return { rate: parsed, unavailable: false }; } catch (err: unknown) { const isTimeout = - err instanceof Error && - (err.name === "TimeoutError" || err.message.includes("timeout")); - logger.warn({ htsCode, err: String(err) }, isTimeout ? "USITC HTS API timed out" : "USITC HTS API fetch error"); + err instanceof Error && (err.name === 'TimeoutError' || err.message.includes('timeout')); + logger.warn( + { htsCode, err: String(err) }, + isTimeout ? 'USITC HTS API timed out' : 'USITC HTS API fetch error' + ); return { rate: null, unavailable: true }; } } @@ -129,7 +124,7 @@ async function fetchStatutoryRate( export function parseDutyRateString(raw: string): number | null { const s = raw.trim().toLowerCase(); - if (s === "free" || s === "0%" || s === "0.0%") return 0; + if (s === 'free' || s === '0%' || s === '0.0%') return 0; // Extract the first percentage value found. const pctMatch = s.match(/([\d]+(?:\.[\d]+)?)\s*%/); @@ -156,7 +151,7 @@ async function getCached(htsCode: string): Promise { FROM hts_rate_cache WHERE hts_code = $1 AND fetched_at > now() - INTERVAL '7 days'`, - [htsCode], + [htsCode] ); return r.rows[0] ?? null; } @@ -164,7 +159,7 @@ async function getCached(htsCode: string): Promise { async function upsertCache( htsCode: string, rate: number | null, - unavailable: boolean, + unavailable: boolean ): Promise { await pool.query( `INSERT INTO hts_rate_cache (hts_code, statutory_rate, is_unavailable, fetched_at) @@ -173,20 +168,19 @@ async function upsertCache( DO UPDATE SET statutory_rate = EXCLUDED.statutory_rate, is_unavailable = EXCLUDED.is_unavailable, fetched_at = EXCLUDED.fetched_at`, - [htsCode, rate ?? null, unavailable], + [htsCode, rate ?? null, unavailable] ); } /** Bust cache for specific HTS codes, or all codes when codes array is empty. */ export async function bustHtsCache(htsCodes: string[]): Promise { if (htsCodes.length === 0) { - const r = await pool.query("DELETE FROM hts_rate_cache"); + const r = await pool.query('DELETE FROM hts_rate_cache'); return r.rowCount ?? 0; } - const r = await pool.query( - "DELETE FROM hts_rate_cache WHERE hts_code = ANY($1::text[])", - [htsCodes], - ); + const r = await pool.query('DELETE FROM hts_rate_cache WHERE hts_code = ANY($1::text[])', [ + htsCodes, + ]); return r.rowCount ?? 0; } @@ -197,7 +191,7 @@ export async function bustHtsCache(htsCodes: string[]): Promise { * Checks the DB cache first; falls back to the USITC API and caches the result. */ async function resolveStatutoryRate( - htsCode: string, + htsCode: string ): Promise<{ rate: number | null; unavailable: boolean }> { const cached = await getCached(htsCode); if (cached) { @@ -209,7 +203,7 @@ async function resolveStatutoryRate( const { rate, unavailable } = await fetchStatutoryRate(htsCode); await upsertCache(htsCode, rate, unavailable).catch((err) => - logger.error({ err, htsCode }, "Failed to upsert HTS rate cache"), + logger.error({ err, htsCode }, 'Failed to upsert HTS rate cache') ); return { rate, unavailable }; } @@ -219,9 +213,7 @@ async function resolveStatutoryRate( * * Returns a summary split into blocking errors, warnings, and passed items. */ -export async function validateHtsRates( - items: HtsLineItem[], -): Promise { +export async function validateHtsRates(items: HtsLineItem[]): Promise { const results: HtsValidationResult[] = await Promise.all( items.map(async (item): Promise => { const { rate: statutory, unavailable } = await resolveStatutoryRate(item.hts_code); @@ -232,7 +224,7 @@ export async function validateHtsRates( hts_code: item.hts_code, declared_rate: item.declared_rate, statutory_rate: null, - status: "api_unavailable", + status: 'api_unavailable', message: `USITC HTS API unavailable for ${item.hts_code}; skipping rate check`, }; } @@ -243,7 +235,7 @@ export async function validateHtsRates( hts_code: item.hts_code, declared_rate: item.declared_rate, statutory_rate: null, - status: "unknown_hts", + status: 'unknown_hts', message: `HTS code ${item.hts_code} not found in USITC schedule; skipping rate check`, }; } @@ -256,7 +248,7 @@ export async function validateHtsRates( hts_code: item.hts_code, declared_rate: item.declared_rate, statutory_rate: statutory, - status: "underreported", + status: 'underreported', message: `Declared rate ${(item.declared_rate * 100).toFixed(2)}% is more than 5% below ` + `statutory rate ${(statutory * 100).toFixed(2)}% for HTS ${item.hts_code}`, @@ -268,7 +260,7 @@ export async function validateHtsRates( hts_code: item.hts_code, declared_rate: item.declared_rate, statutory_rate: statutory, - status: "overreported", + status: 'overreported', message: `Declared rate ${(item.declared_rate * 100).toFixed(2)}% is more than 5% above ` + `statutory rate ${(statutory * 100).toFixed(2)}% for HTS ${item.hts_code}`, @@ -279,17 +271,18 @@ export async function validateHtsRates( hts_code: item.hts_code, declared_rate: item.declared_rate, statutory_rate: statutory, - status: "ok", + status: 'ok', message: `HTS ${item.hts_code} rate OK`, }; - }), + }) ); - const blocking = results.filter((r) => r.status === "underreported"); + const blocking = results.filter((r) => r.status === 'underreported'); const warnings = results.filter( - (r) => r.status === "overreported" || r.status === "unknown_hts" || r.status === "api_unavailable", + (r) => + r.status === 'overreported' || r.status === 'unknown_hts' || r.status === 'api_unavailable' ); - const passed = results.filter((r) => r.status === "ok"); + const passed = results.filter((r) => r.status === 'ok'); return { blocking, warnings, passed, hasBlockingErrors: blocking.length > 0 }; } diff --git a/apps/api/src/services/oracle-event-listener.test.ts b/apps/api/src/services/oracle-event-listener.test.ts index 6ef9665..4962382 100644 --- a/apps/api/src/services/oracle-event-listener.test.ts +++ b/apps/api/src/services/oracle-event-listener.test.ts @@ -8,8 +8,8 @@ * Postgres or Soroban node is needed. */ -import { describe, it, before, beforeEach } from "node:test"; -import assert from "node:assert/strict"; +import { describe, it, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; // ── In-memory DB stub ───────────────────────────────────────────────────────── @@ -27,131 +27,32 @@ interface FeedRow { // Rows keyed by "tx_hash:importer_address" to simulate the unique constraint. const feedStore = new Map(); -let listenerStateStore: number | null = null; - -// Minimal pool stub that intercepts only the queries made by the listener. -const poolStub = { - async query(sql: string, params?: unknown[]): Promise<{ rows: unknown[]; rowCount: number }> { - const s = sql.trim().replace(/\s+/g, " "); - - // getListenerState - if (s.startsWith("SELECT last_ledger_sequence FROM listener_state")) { - if (listenerStateStore === null) return { rows: [], rowCount: 0 }; - return { rows: [{ last_ledger_sequence: listenerStateStore }], rowCount: 1 }; - } - - // setListenerState - if (s.startsWith("INSERT INTO listener_state")) { - listenerStateStore = params?.[1] as number; - return { rows: [], rowCount: 1 }; - } - - // importer_id lookup - if (s.startsWith("SELECT id FROM importers WHERE stellar_address")) { - const addr = params?.[0] as string; - if (addr === "GTEST_IMPORTER_KNOWN") { - return { rows: [{ id: "uuid-importer-1" }], rowCount: 1 }; - } - return { rows: [], rowCount: 0 }; - } - - // INSERT INTO oracle_price_feed — honor ON CONFLICT DO NOTHING. - if (s.startsWith("INSERT INTO oracle_price_feed")) { - const [ - importerId, - importerAddress, - requiredCollateral, - previousCollateral, - pctChange, - txHash, - ledgerSequence, - setBy, - emergencyOverride, - ] = params as [ - string | null, - string, - string, - string, - string, - string, - number, - string, - boolean, - ]; - - const key = `${txHash}:${importerAddress}`; - if (feedStore.has(key)) return { rows: [], rowCount: 0 }; // conflict → do nothing - - feedStore.set(key, { - importer_id: importerId, - importer_address: importerAddress, - required_collateral: requiredCollateral, - previous_collateral: previousCollateral, - pct_change: pctChange, - tx_hash: txHash, - ledger_sequence: ledgerSequence, - set_by: setBy, - emergency_override: emergencyOverride, - }); - return { rows: [], rowCount: 1 }; - } - - return { rows: [], rowCount: 0 }; - }, -}; // ── Helpers to build mock Soroban event objects ──────────────────────────────── -import { nativeToScVal, Address, xdr } from "@stellar/stellar-sdk"; +import { nativeToScVal, Address } from '@stellar/stellar-sdk'; function makeRequiredEvent( importerAddress: string, oldRequired: bigint, newRequired: bigint, txHash: string, - ledger: number, + ledger: number ): object { return { id: `${ledger}-000000`, - type: "contract", + type: 'contract', ledger, ledgerClosedAt: new Date().toISOString(), - contractId: "CTARIFFSHIELD", + contractId: 'CTARIFFSHIELD', txHash, // Topics: [Symbol("required"), Address(importer)] - topic: [ - nativeToScVal("required", { type: "symbol" }), - new Address(importerAddress).toScVal(), - ], + topic: [nativeToScVal('required', { type: 'symbol' }), new Address(importerAddress).toScVal()], // Data: (old_required: i128, new_required: i128) as a Vec/tuple value: nativeToScVal([oldRequired, newRequired]), }; } -function makeEmergencyEvent( - importerAddress: string, - oldRequired: bigint, - newRequired: bigint, - callerAddress: string, - txHash: string, - ledger: number, -): object { - return { - id: `${ledger}-000001`, - type: "contract", - ledger, - ledgerClosedAt: new Date().toISOString(), - contractId: "CTARIFFSHIELD", - txHash, - topic: [ - nativeToScVal("EmergencyOracleUpdate", { type: "symbol" }), - new Address(importerAddress).toScVal(), - ], - // Data: (old: i128, new: i128, ts: u64, caller: Address) - value: nativeToScVal([oldRequired, newRequired, BigInt(Date.now()), callerAddress]), - }; -} - // ── Import listener functions ───────────────────────────────────────────────── // We import the functions under test and use Module-level dependency injection @@ -161,49 +62,48 @@ import { getListenerState, setListenerState, pollOracleEvents, -} from "./oracle-event-listener.js"; +} from './oracle-event-listener.js'; // Stellaraddresses for tests (valid Ed25519 public keys) -const IMPORTER_KNOWN = "GBYSMQE3FKGKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKA"; -const IMPORTER_UNKNOWN = "GCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZB"; -const CALLER_ADDR = "GDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB"; +const IMPORTER_KNOWN = 'GBYSMQE3FKGKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKPKA'; +const IMPORTER_UNKNOWN = 'GCZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZB'; +const CALLER_ADDR = 'GDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB'; // ── Tests ───────────────────────────────────────────────────────────────────── -describe("oracle-event-listener — insertOracleFeedRow", () => { +describe('oracle-event-listener — insertOracleFeedRow', () => { beforeEach(() => { feedStore.clear(); - listenerStateStore = null; }); - it("inserts a normal oracle event with correct pct_change", async () => { + it('inserts a normal oracle event with correct pct_change', async () => { await insertOracleFeedRow({ importerAddress: IMPORTER_KNOWN, oldRequired: 1_000_000n, newRequired: 1_200_000n, callerAddress: CALLER_ADDR, emergency: false, - txHash: "abc123", + txHash: 'abc123', ledgerSequence: 100, }); const row = feedStore.get(`abc123:${IMPORTER_KNOWN}`); - assert.ok(row, "row should be inserted"); - assert.equal(row!.required_collateral, "1200000"); - assert.equal(row!.previous_collateral, "1000000"); + assert.ok(row, 'row should be inserted'); + assert.equal(row!.required_collateral, '1200000'); + assert.equal(row!.previous_collateral, '1000000'); // pct_change = (1200000 - 1000000) / 1000000 * 100 = 20.00 assert.equal(parseFloat(row!.pct_change), 20.0); assert.equal(row!.emergency_override, false); }); - it("inserts an emergency oracle event with emergency_override=true", async () => { + it('inserts an emergency oracle event with emergency_override=true', async () => { await insertOracleFeedRow({ importerAddress: IMPORTER_KNOWN, oldRequired: 500_000n, newRequired: 750_000n, callerAddress: CALLER_ADDR, emergency: true, - txHash: "emrg001", + txHash: 'emrg001', ledgerSequence: 200, }); @@ -213,14 +113,14 @@ describe("oracle-event-listener — insertOracleFeedRow", () => { assert.equal(row!.set_by, CALLER_ADDR); }); - it("silently ignores duplicate tx_hash + importer_address (ON CONFLICT DO NOTHING)", async () => { + it('silently ignores duplicate tx_hash + importer_address (ON CONFLICT DO NOTHING)', async () => { const base = { importerAddress: IMPORTER_KNOWN, oldRequired: 100n, newRequired: 200n, - callerAddress: "", + callerAddress: '', emergency: false, - txHash: "dup001", + txHash: 'dup001', ledgerSequence: 300, }; @@ -229,17 +129,17 @@ describe("oracle-event-listener — insertOracleFeedRow", () => { const row = feedStore.get(`dup001:${IMPORTER_KNOWN}`); // Value should be 200, not 999 (first write wins). - assert.equal(row!.required_collateral, "200"); + assert.equal(row!.required_collateral, '200'); }); - it("sets pct_change to 0 when previous_collateral is zero", async () => { + it('sets pct_change to 0 when previous_collateral is zero', async () => { await insertOracleFeedRow({ importerAddress: IMPORTER_UNKNOWN, oldRequired: 0n, newRequired: 500_000n, - callerAddress: "", + callerAddress: '', emergency: false, - txHash: "zero001", + txHash: 'zero001', ledgerSequence: 400, }); @@ -249,37 +149,35 @@ describe("oracle-event-listener — insertOracleFeedRow", () => { }); }); -describe("oracle-event-listener — listener_state (replay-on-restart)", () => { +describe('oracle-event-listener — listener_state (replay-on-restart)', () => { beforeEach(() => { feedStore.clear(); - listenerStateStore = null; }); - it("returns null when no checkpoint exists (first run)", async () => { + it('returns null when no checkpoint exists (first run)', async () => { const state = await getListenerState(); assert.equal(state, null); }); - it("persists and retrieves the last processed ledger", async () => { + it('persists and retrieves the last processed ledger', async () => { await setListenerState(12345); const state = await getListenerState(); assert.equal(state, 12345); }); - it("overwrites with a newer ledger on update", async () => { + it('overwrites with a newer ledger on update', async () => { await setListenerState(100); await setListenerState(200); assert.equal(await getListenerState(), 200); }); }); -describe("oracle-event-listener — pollOracleEvents", () => { +describe('oracle-event-listener — pollOracleEvents', () => { beforeEach(() => { feedStore.clear(); - listenerStateStore = null; }); - it("processes a batch of events and advances the checkpoint", async () => { + it('processes a batch of events and advances the checkpoint', async () => { // Start at ledger 500; current ledger is 520. await setListenerState(500); @@ -290,8 +188,8 @@ describe("oracle-event-listener — pollOracleEvents", () => { async getEvents(_opts: unknown) { return { events: [ - makeRequiredEvent(IMPORTER_KNOWN, 1_000n, 1_100n, "tx001", 505), - makeRequiredEvent(IMPORTER_KNOWN, 1_100n, 1_300n, "tx002", 510), + makeRequiredEvent(IMPORTER_KNOWN, 1_000n, 1_100n, 'tx001', 505), + makeRequiredEvent(IMPORTER_KNOWN, 1_100n, 1_300n, 'tx002', 510), ], }; }, @@ -299,12 +197,12 @@ describe("oracle-event-listener — pollOracleEvents", () => { await pollOracleEvents(mockRpc as any); - assert.equal(feedStore.size, 2, "two rows should be inserted"); + assert.equal(feedStore.size, 2, 'two rows should be inserted'); // Checkpoint should advance to min(520, 500 + MAX_WINDOW=200) = 520. assert.equal(await getListenerState(), 520); }); - it("replays missed events when restarting with no checkpoint (first run)", async () => { + it('replays missed events when restarting with no checkpoint (first run)', async () => { // No checkpoint → listener should compute fromLedger = current - INITIAL_LOOKBACK. const mockRpc = { async getLatestLedger() { @@ -312,20 +210,18 @@ describe("oracle-event-listener — pollOracleEvents", () => { }, async getEvents(_opts: unknown) { return { - events: [ - makeRequiredEvent(IMPORTER_UNKNOWN, 0n, 500_000n, "replay001", 995), - ], + events: [makeRequiredEvent(IMPORTER_UNKNOWN, 0n, 500_000n, 'replay001', 995)], }; }, }; await pollOracleEvents(mockRpc as any); - assert.equal(feedStore.size, 1, "replayed event should be inserted"); + assert.equal(feedStore.size, 1, 'replayed event should be inserted'); assert.ok(feedStore.has(`replay001:${IMPORTER_UNKNOWN}`)); }); - it("skips events outside the target ledger window", async () => { + it('skips events outside the target ledger window', async () => { await setListenerState(100); const mockRpc = { @@ -335,9 +231,9 @@ describe("oracle-event-listener — pollOracleEvents", () => { async getEvents(_opts: unknown) { return { events: [ - makeRequiredEvent(IMPORTER_KNOWN, 1n, 2n, "inwindow", 120), + makeRequiredEvent(IMPORTER_KNOWN, 1n, 2n, 'inwindow', 120), // ledger 9999 is well beyond toLedger=150 → should be skipped - makeRequiredEvent(IMPORTER_KNOWN, 2n, 3n, "outofwindow", 9999), + makeRequiredEvent(IMPORTER_KNOWN, 2n, 3n, 'outofwindow', 9999), ], }; }, @@ -345,11 +241,14 @@ describe("oracle-event-listener — pollOracleEvents", () => { await pollOracleEvents(mockRpc as any); - assert.ok(feedStore.has(`inwindow:${IMPORTER_KNOWN}`), "in-window event should be inserted"); - assert.ok(!feedStore.has(`outofwindow:${IMPORTER_KNOWN}`), "out-of-window event should be skipped"); + assert.ok(feedStore.has(`inwindow:${IMPORTER_KNOWN}`), 'in-window event should be inserted'); + assert.ok( + !feedStore.has(`outofwindow:${IMPORTER_KNOWN}`), + 'out-of-window event should be skipped' + ); }); - it("does nothing when already up to date (fromLedger >= currentLedger)", async () => { + it('does nothing when already up to date (fromLedger >= currentLedger)', async () => { await setListenerState(999); const mockRpc = { @@ -368,15 +267,15 @@ describe("oracle-event-listener — pollOracleEvents", () => { }); }); -describe("oracle-event-listener — pagination helpers (via oracle_price_feed rows)", () => { - it("pct_change is calculated correctly for a decrease", async () => { +describe('oracle-event-listener — pagination helpers (via oracle_price_feed rows)', () => { + it('pct_change is calculated correctly for a decrease', async () => { await insertOracleFeedRow({ importerAddress: IMPORTER_UNKNOWN, oldRequired: 2_000_000n, newRequired: 1_000_000n, - callerAddress: "", + callerAddress: '', emergency: false, - txHash: "decrease001", + txHash: 'decrease001', ledgerSequence: 600, }); diff --git a/apps/api/src/services/oracle-monitor.ts b/apps/api/src/services/oracle-monitor.ts index f84e087..0368170 100644 --- a/apps/api/src/services/oracle-monitor.ts +++ b/apps/api/src/services/oracle-monitor.ts @@ -1,15 +1,14 @@ -import pino from "pino"; -import { pool } from "../db.js"; -import { env } from "../config/env.js"; -import { createRpcServer } from "../lib/soroban/rpcClient.js"; +import pino from 'pino'; +import { pool } from '../db.js'; +import { env } from '../config/env.js'; +import { createRpcServer } from '../lib/soroban/rpcClient.js'; -const logger = pino({ name: "oracle-monitor" }); +const logger = pino({ name: 'oracle-monitor' }); let intervalId: NodeJS.Timeout | null = null; -let lastCursor: string | undefined = undefined; export async function startOracleMonitor() { - logger.info("Starting oracle monitor service..."); + logger.info('Starting oracle monitor service...'); if (intervalId) return; const rpc = createRpcServer(env.STELLAR_RPC_URL); @@ -23,25 +22,23 @@ export async function startOracleMonitor() { startLedger, filters: [ { - type: "contract", + type: 'contract', contractIds: [env.TARIFF_SHIELD_CONTRACT_ID], - topics: [["*", "required", "*", "*"]], + topics: [['*', 'required', '*', '*']], }, ], limit: 100, }); for (const event of response.events) { - lastCursor = event.id; - // Topic 0 is symbol "required", Topic 1 is importer address. // Data contains (old_required, new_required) as a tuple of i128. // We will just extract them via our mock processor or xdr parsing. - + const txHash = event.txHash; // The data is an SCVal. In a real app we'd parse it using stellar-sdk xdr. // For demonstration, we'll try to parse or mock the extraction. - + try { // We'll mock the extraction since fully parsing the SCVal tuple in TS without bindings is verbose. // Wait, the API routes actually insert `contract_events` into the DB. @@ -50,15 +47,15 @@ export async function startOracleMonitor() { // Actually, we'll just mock the parsing here for the sake of the exercise. const oldVal = 1000; const newVal = 2000; - const importerId = "mock-importer"; - + const importerId = 'mock-importer'; + await processOracleEvent(importerId, String(oldVal), String(newVal), txHash); } catch (e) { - logger.error({ err: e }, "Failed to parse oracle event"); + logger.error({ err: e }, 'Failed to parse oracle event'); } } } catch (err) { - logger.error({ err }, "Error polling oracle events"); + logger.error({ err }, 'Error polling oracle events'); } }, 10000); } @@ -84,7 +81,10 @@ export async function processOracleEvent( const threshold = (env.ORACLE_ALERT_THRESHOLD_PCT || 50) / 100; if (pctChange >= threshold) { - logger.warn({ importerId, oldVal, newVal, pctChange }, "Oracle update exceeded alert threshold!"); + logger.warn( + { importerId, oldVal, newVal, pctChange }, + 'Oracle update exceeded alert threshold!' + ); await pool.query( `INSERT INTO oracle_alerts (importer_id, old_value, new_value, pct_change, tx_hash) @@ -92,11 +92,15 @@ export async function processOracleEvent( [importerId, oldVal, newVal, pctChange * 100, txHash] ); - const channel = env.ALERT_CHANNEL || "console"; - if (channel === "console") { - logger.error(`[ALERT] High collateral change for ${importerId}: ${oldVal} -> ${newVal} (${(pctChange * 100).toFixed(2)}%)`); + const channel = env.ALERT_CHANNEL || 'console'; + if (channel === 'console') { + logger.error( + `[ALERT] High collateral change for ${importerId}: ${oldVal} -> ${newVal} (${(pctChange * 100).toFixed(2)}%)` + ); } else { - logger.error(`[ALERT via ${channel}] High collateral change for ${importerId}: ${oldVal} -> ${newVal} (${(pctChange * 100).toFixed(2)}%)`); + logger.error( + `[ALERT via ${channel}] High collateral change for ${importerId}: ${oldVal} -> ${newVal} (${(pctChange * 100).toFixed(2)}%)` + ); } } } diff --git a/apps/api/src/stellar.ts b/apps/api/src/stellar.ts index 4fb5b02..3f8b384 100644 --- a/apps/api/src/stellar.ts +++ b/apps/api/src/stellar.ts @@ -1,10 +1,10 @@ -import { Keypair, rpc } from "@stellar/stellar-sdk"; -import { TariffShieldClient } from "@tariffshield/sdk"; -import client from "prom-client"; -import { trace, SpanStatusCode } from "@opentelemetry/api"; -import { env } from "./config/env.js"; -import { createRpcServer } from "./lib/soroban/rpcClient.js"; -import { logger } from "./lib/logger.js"; +import { Keypair, rpc } from '@stellar/stellar-sdk'; +import { TariffShieldClient } from '@tariffshield/sdk'; +import client from 'prom-client'; +import { trace, SpanStatusCode } from '@opentelemetry/api'; +import { env } from './config/env.js'; +import { createRpcServer } from './lib/soroban/rpcClient.js'; +import { logger } from './lib/logger.js'; const tracer = trace.getTracer('tariffshield-stellar'); @@ -115,18 +115,24 @@ export const contractClient = new Proxy(baseClient, { const diff = process.hrtime(start); const duration = diff[0] + diff[1] / 1e9; const durationMs = Math.round(duration * 1000); - sorobanRpcCallsTotal.inc({ method: methodName, success: "true" }); + sorobanRpcCallsTotal.inc({ method: methodName, success: 'true' }); sorobanRpcDurationSeconds.observe({ method: methodName }, duration); - logger.info({ rpcMethod: methodName, durationMs, success: true }, "Soroban RPC call succeeded"); + logger.info( + { rpcMethod: methodName, durationMs, success: true }, + 'Soroban RPC call succeeded' + ); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (err) { const diff = process.hrtime(start); const duration = diff[0] + diff[1] / 1e9; const durationMs = Math.round(duration * 1000); - sorobanRpcCallsTotal.inc({ method: methodName, success: "false" }); + sorobanRpcCallsTotal.inc({ method: methodName, success: 'false' }); sorobanRpcDurationSeconds.observe({ method: methodName }, duration); - logger.error({ rpcMethod: methodName, durationMs, success: false, err }, "Soroban RPC call failed"); + logger.error( + { rpcMethod: methodName, durationMs, success: false, err }, + 'Soroban RPC call failed' + ); span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); throw err; } finally { @@ -155,18 +161,24 @@ export async function getCurrentLedgerSequence(): Promise { const diff = process.hrtime(start); const duration = diff[0] + diff[1] / 1e9; const durationMs = Math.round(duration * 1000); - sorobanRpcCallsTotal.inc({ method: methodName, success: "true" }); + sorobanRpcCallsTotal.inc({ method: methodName, success: 'true' }); sorobanRpcDurationSeconds.observe({ method: methodName }, duration); - logger.info({ rpcMethod: methodName, durationMs, success: true }, "Soroban RPC call succeeded"); + logger.info( + { rpcMethod: methodName, durationMs, success: true }, + 'Soroban RPC call succeeded' + ); span.setStatus({ code: SpanStatusCode.OK }); return latest.sequence; } catch (err) { const diff = process.hrtime(start); const duration = diff[0] + diff[1] / 1e9; const durationMs = Math.round(duration * 1000); - sorobanRpcCallsTotal.inc({ method: methodName, success: "false" }); + sorobanRpcCallsTotal.inc({ method: methodName, success: 'false' }); sorobanRpcDurationSeconds.observe({ method: methodName }, duration); - logger.error({ rpcMethod: methodName, durationMs, success: false, err }, "Soroban RPC call failed"); + logger.error( + { rpcMethod: methodName, durationMs, success: false, err }, + 'Soroban RPC call failed' + ); span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) }); throw err; } finally { diff --git a/apps/api/src/tracing.ts b/apps/api/src/tracing.ts index c6640cb..7552a83 100644 --- a/apps/api/src/tracing.ts +++ b/apps/api/src/tracing.ts @@ -1,29 +1,31 @@ // tracing.ts must be imported FIRST in index.ts before any other library code // so that OpenTelemetry patches load before Express, pg, and http are required. -import { NodeSDK } from "@opentelemetry/sdk-node"; -import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; -import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; -import { Resource } from "@opentelemetry/resources"; -import { SEMRESATTRS_SERVICE_NAME, SEMRESATTRS_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; +import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; +import { Resource } from '@opentelemetry/resources'; +import { + SEMRESATTRS_SERVICE_NAME, + SEMRESATTRS_SERVICE_VERSION, +} from '@opentelemetry/semantic-conventions'; -const exporterEndpoint = - process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318"; +const exporterEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? 'http://localhost:4318'; const sdk = new NodeSDK({ resource: new Resource({ - [SEMRESATTRS_SERVICE_NAME]: "tariffshield-api", - [SEMRESATTRS_SERVICE_VERSION]: process.env.npm_package_version ?? "0.0.0", + [SEMRESATTRS_SERVICE_NAME]: 'tariffshield-api', + [SEMRESATTRS_SERVICE_VERSION]: process.env.npm_package_version ?? '0.0.0', }), traceExporter: new OTLPTraceExporter({ url: `${exporterEndpoint}/v1/traces`, }) as any, instrumentations: [ getNodeAutoInstrumentations({ - "@opentelemetry/instrumentation-http": { enabled: true }, - "@opentelemetry/instrumentation-express": { enabled: true }, - "@opentelemetry/instrumentation-pg": { enabled: true }, + '@opentelemetry/instrumentation-http': { enabled: true }, + '@opentelemetry/instrumentation-express': { enabled: true }, + '@opentelemetry/instrumentation-pg': { enabled: true }, // Disable fs instrumentation to avoid noisy spans from config reads - "@opentelemetry/instrumentation-fs": { enabled: false }, + '@opentelemetry/instrumentation-fs': { enabled: false }, } as any), ], }); @@ -31,6 +33,6 @@ const sdk = new NodeSDK({ sdk.start(); // Flush spans on graceful shutdown -process.on("SIGTERM", () => { +process.on('SIGTERM', () => { sdk.shutdown().finally(() => process.exit(0)); }); diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index 01a2d2c..39a5df0 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -1,23 +1,25 @@ -import "./tracing.js"; -import "./instrument.js"; -import { createTxSubmitWorker } from "./queue.js"; -import { pool } from "./db.js"; +import './tracing.js'; +import './instrument.js'; +import { createTxSubmitWorker } from './queue.js'; +import { pool } from './db.js'; const worker = createTxSubmitWorker(); -worker.on("completed", (job) => { - console.log(`[worker] job ${job.id} completed: ${job.data.method} for importer ${job.data.importerId}`); +worker.on('completed', (job) => { + console.log( + `[worker] job ${job.id} completed: ${job.data.method} for importer ${job.data.importerId}` + ); }); -worker.on("failed", (job, err) => { +worker.on('failed', (job, err) => { console.error(`[worker] job ${job?.id} failed:`, err.message); }); -process.on("SIGTERM", async () => { - console.log("[worker] SIGTERM received, shutting down gracefully..."); +process.on('SIGTERM', async () => { + console.log('[worker] SIGTERM received, shutting down gracefully...'); await worker.close(); await pool.end(); process.exit(0); }); -console.log("[worker] started, waiting for jobs..."); +console.log('[worker] started, waiting for jobs...'); diff --git a/apps/api/tests/load/README.md b/apps/api/tests/load/README.md index 6cf4ca7..243e779 100644 --- a/apps/api/tests/load/README.md +++ b/apps/api/tests/load/README.md @@ -11,18 +11,18 @@ reproducible performance baseline. - The API running and reachable (`npm run dev:api` from the repo root, or a deployed staging environment). - A seeded database — each script's `setup()` signs up a fresh importer and registers it on-chain (real Stellar testnet calls), so no separate seed - step is required to *run* the scripts, but see the KYC note below for the + step is required to _run_ the scripts, but see the KYC note below for the deposit script specifically. ## Endpoints covered -| Script | Route | p95 target | Error rate target | -|---|---|---|---| -| `get-importers.js` | `GET /importers` | < 200ms | < 0.1% | -| `get-importer-detail.js` | `GET /importers/:id` | < 150ms | < 0.1% | -| `post-deposit.js` | `POST /importers/:id/deposit` | < 500ms | < 0.1% | -| `post-withdraw.js` | `POST /importers/:id/withdraw` | < 500ms | < 0.1% | -| `post-auto-top-up.js` | `POST /importers/:id/auto-top-up` | < 500ms | < 0.1% | +| Script | Route | p95 target | Error rate target | +| ------------------------ | --------------------------------- | ---------- | ----------------- | +| `get-importers.js` | `GET /importers` | < 200ms | < 0.1% | +| `get-importer-detail.js` | `GET /importers/:id` | < 150ms | < 0.1% | +| `post-deposit.js` | `POST /importers/:id/deposit` | < 500ms | < 0.1% | +| `post-withdraw.js` | `POST /importers/:id/withdraw` | < 500ms | < 0.1% | +| `post-auto-top-up.js` | `POST /importers/:id/auto-top-up` | < 500ms | < 0.1% | All targets are measured at 50 concurrent virtual users (`vus: 50`) over a 1-minute run. Each script's `thresholds` block enforces these targets @@ -67,7 +67,7 @@ point a script at that importer's id instead of registering a fresh one. ## Baseline numbers -This suite establishes the *harness* and the *targets* (documented above, +This suite establishes the _harness_ and the _targets_ (documented above, matching the issue's acceptance criteria). Actual baseline p95/error-rate numbers need to be captured by running it against a real staging deployment — that requires a live Postgres + Redis + Stellar-testnet-backed diff --git a/apps/api/tests/load/get-importer-detail.js b/apps/api/tests/load/get-importer-detail.js index c387252..404a7a6 100644 --- a/apps/api/tests/load/get-importer-detail.js +++ b/apps/api/tests/load/get-importer-detail.js @@ -1,15 +1,15 @@ // k6 load test: GET /importers/:id (issue #265) // Target: p95 < 150ms, error rate < 0.1% at 50 VUs. -import http from "k6/http"; -import { check } from "k6"; -import { BASE_URL, registerTestImporter } from "./lib/setup.js"; +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, registerTestImporter } from './lib/setup.js'; export const options = { vus: 50, - duration: "1m", + duration: '1m', thresholds: { - http_req_duration: ["p(95)<150"], - http_req_failed: ["rate<0.001"], + http_req_duration: ['p(95)<150'], + http_req_failed: ['rate<0.001'], }, }; @@ -22,7 +22,7 @@ export default function (data) { headers: { Authorization: `Bearer ${data.token}` }, }); check(res, { - "status is 200": (r) => r.status === 200, - "has onChainAccount": (r) => r.json("onChainAccount") !== undefined, + 'status is 200': (r) => r.status === 200, + 'has onChainAccount': (r) => r.json('onChainAccount') !== undefined, }); } diff --git a/apps/api/tests/load/get-importers.js b/apps/api/tests/load/get-importers.js index d82aba9..7220f1d 100644 --- a/apps/api/tests/load/get-importers.js +++ b/apps/api/tests/load/get-importers.js @@ -1,15 +1,15 @@ // k6 load test: GET /importers (issue #265) // Target: p95 < 200ms, error rate < 0.1% at 50 VUs. -import http from "k6/http"; -import { check } from "k6"; -import { BASE_URL, registerTestImporter } from "./lib/setup.js"; +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, registerTestImporter } from './lib/setup.js'; export const options = { vus: 50, - duration: "1m", + duration: '1m', thresholds: { - http_req_duration: ["p(95)<200"], - http_req_failed: ["rate<0.001"], + http_req_duration: ['p(95)<200'], + http_req_failed: ['rate<0.001'], }, }; @@ -22,7 +22,7 @@ export default function (data) { headers: { Authorization: `Bearer ${data.token}` }, }); check(res, { - "status is 200": (r) => r.status === 200, - "has importers array": (r) => Array.isArray(r.json("importers")), + 'status is 200': (r) => r.status === 200, + 'has importers array': (r) => Array.isArray(r.json('importers')), }); } diff --git a/apps/api/tests/load/lib/setup.js b/apps/api/tests/load/lib/setup.js index 157e6f1..9f1b17c 100644 --- a/apps/api/tests/load/lib/setup.js +++ b/apps/api/tests/load/lib/setup.js @@ -4,13 +4,13 @@ // (k6 runs setup() once, before VUs start, so this network overhead — // signup, friendbot funding, on-chain registration — is paid once per run, // not per iteration). -import http from "k6/http"; -import { check, fail } from "k6"; +import http from 'k6/http'; +import { check, fail } from 'k6'; -export const BASE_URL = __ENV.API_BASE_URL || "http://localhost:3002"; +export const BASE_URL = __ENV.API_BASE_URL || 'http://localhost:3002'; function jsonHeaders() { - return { headers: { "Content-Type": "application/json" } }; + return { headers: { 'Content-Type': 'application/json' } }; } function randomEmail(prefix) { @@ -20,14 +20,14 @@ function randomEmail(prefix) { /** Sign up + log in a fresh user of the given role. Returns { token, userId }. */ export function registerUser(role) { const email = randomEmail(role); - const password = "K6LoadTest!2024"; + const password = 'K6LoadTest!2024'; const signupRes = http.post( `${BASE_URL}/auth/signup`, JSON.stringify({ email, password, role }), - jsonHeaders(), + jsonHeaders() ); - check(signupRes, { "signup succeeded": (r) => r.status === 200 || r.status === 201 }); + check(signupRes, { 'signup succeeded': (r) => r.status === 200 || r.status === 201 }); if (signupRes.status >= 400) { fail(`signup failed: ${signupRes.status} ${signupRes.body}`); } @@ -44,23 +44,25 @@ export function registerUser(role) { * seconds — that's expected and only paid once per k6 run. */ export function registerTestImporter() { - const { token } = registerUser("importer"); - const authHeaders = { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }; + const { token } = registerUser('importer'); + const authHeaders = { + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + }; const createRes = http.post( `${BASE_URL}/importers`, JSON.stringify({ legalName: `K6 Load Test Importer ${Date.now()}`, bondId: Math.floor(Date.now() / 1000), - initialRequiredCollateral: "1000000000", // 100 XLM in stroops + initialRequiredCollateral: '1000000000', // 100 XLM in stroops }), - authHeaders, + authHeaders ); - check(createRes, { "importer created": (r) => r.status === 200 }); + check(createRes, { 'importer created': (r) => r.status === 200 }); if (createRes.status >= 400) { fail(`importer creation failed: ${createRes.status} ${createRes.body}`); } - const importerId = createRes.json("importer.id"); + const importerId = createRes.json('importer.id'); return { token, importerId }; } diff --git a/apps/api/tests/load/post-auto-top-up.js b/apps/api/tests/load/post-auto-top-up.js index 209bd14..dc79d11 100644 --- a/apps/api/tests/load/post-auto-top-up.js +++ b/apps/api/tests/load/post-auto-top-up.js @@ -6,16 +6,16 @@ // in this codebase. This script targets the real route. // // Target: p95 < 500ms, error rate < 0.1% at 50 VUs. -import http from "k6/http"; -import { check } from "k6"; -import { BASE_URL, registerTestImporter } from "./lib/setup.js"; +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, registerTestImporter } from './lib/setup.js'; export const options = { vus: 50, - duration: "1m", + duration: '1m', thresholds: { - http_req_duration: ["p(95)<500"], - http_req_failed: ["rate<0.001"], + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.001'], }, }; @@ -24,13 +24,11 @@ export function setup() { } export default function (data) { - const res = http.post( - `${BASE_URL}/importers/${data.importerId}/auto-top-up`, - null, - { headers: { Authorization: `Bearer ${data.token}` } }, - ); + const res = http.post(`${BASE_URL}/importers/${data.importerId}/auto-top-up`, null, { + headers: { Authorization: `Bearer ${data.token}` }, + }); check(res, { - "status is 202": (r) => r.status === 202, - "returns a jobId": (r) => !!r.json("jobId"), + 'status is 202': (r) => r.status === 202, + 'returns a jobId': (r) => !!r.json('jobId'), }); } diff --git a/apps/api/tests/load/post-deposit.js b/apps/api/tests/load/post-deposit.js index 17b29c4..db5ee57 100644 --- a/apps/api/tests/load/post-deposit.js +++ b/apps/api/tests/load/post-deposit.js @@ -7,16 +7,16 @@ // not automate. Seed an approved importer (e.g. via `npm run seed` or the // KYC review flow) against your staging environment before running this // script, or expect 403s rather than 202s in an unseeded environment. -import http from "k6/http"; -import { check } from "k6"; -import { BASE_URL, registerTestImporter } from "./lib/setup.js"; +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, registerTestImporter } from './lib/setup.js'; export const options = { vus: 50, - duration: "1m", + duration: '1m', thresholds: { - http_req_duration: ["p(95)<500"], - http_req_failed: ["rate<0.001"], + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.001'], }, }; @@ -27,16 +27,16 @@ export function setup() { export default function (data) { const res = http.post( `${BASE_URL}/importers/${data.importerId}/deposit`, - JSON.stringify({ amountStroops: "10000000", bucket: "collateral" }), + JSON.stringify({ amountStroops: '10000000', bucket: 'collateral' }), { - headers: { "Content-Type": "application/json", Authorization: `Bearer ${data.token}` }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` }, // Treat both as "not failed" for http_req_failed/the error-rate // threshold: 202 = job enqueued, 403 = KYC gate (expected in an // unseeded environment — see the NOTE above). responseCallback: http.expectedStatuses(202, 403), - }, + } ); check(res, { - "status is 202 or 403": (r) => r.status === 202 || r.status === 403, + 'status is 202 or 403': (r) => r.status === 202 || r.status === 403, }); } diff --git a/apps/api/tests/load/post-withdraw.js b/apps/api/tests/load/post-withdraw.js index fcd5cda..6da3b71 100644 --- a/apps/api/tests/load/post-withdraw.js +++ b/apps/api/tests/load/post-withdraw.js @@ -6,16 +6,16 @@ // script verifies the endpoint is reachable and returns a well-formed // response under load; to exercise the real success path, seed an importer // with existing collateral against your staging environment first. -import http from "k6/http"; -import { check } from "k6"; -import { BASE_URL, registerTestImporter } from "./lib/setup.js"; +import http from 'k6/http'; +import { check } from 'k6'; +import { BASE_URL, registerTestImporter } from './lib/setup.js'; export const options = { vus: 50, - duration: "1m", + duration: '1m', thresholds: { - http_req_duration: ["p(95)<500"], - http_req_failed: ["rate<0.001"], + http_req_duration: ['p(95)<500'], + http_req_failed: ['rate<0.001'], }, }; @@ -26,15 +26,15 @@ export function setup() { export default function (data) { const res = http.post( `${BASE_URL}/importers/${data.importerId}/withdraw`, - JSON.stringify({ amountStroops: "1000000" }), + JSON.stringify({ amountStroops: '1000000' }), { - headers: { "Content-Type": "application/json", Authorization: `Bearer ${data.token}` }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.token}` }, // 202 = job enqueued; 403 = AML/KYC gate — both are well-formed, // reachable responses for the purposes of this benchmark. responseCallback: http.expectedStatuses(202, 403), - }, + } ); check(res, { - "status is 202 or 403": (r) => r.status === 202 || r.status === 403, + 'status is 202 or 403': (r) => r.status === 202 || r.status === 403, }); } diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 0ddaf2f..a2b8837 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -2,7 +2,7 @@ FROM node:20-alpine AS builder WORKDIR /app -COPY package.json package-lock.json ./ +COPY package.json package-lock.json tsconfig.base.json ./ COPY apps/web/package.json ./apps/web/ COPY packages/sdk/package.json ./packages/sdk/ diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx index b3576e3..5530079 100644 --- a/apps/web/app/app/page.tsx +++ b/apps/web/app/app/page.tsx @@ -1,4 +1,4 @@ -"use client"; +'use client'; // #256: this page stays a Client Component rather than converting to an // async Server Component. Authentication here is a pure client-side @@ -16,16 +16,23 @@ // Suspense boundary Next.js wraps this page in automatically) streams an // immediate skeleton as the initial HTML response, improving perceived // TTI without requiring server-side auth. -import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useMemo, useRef, useState, memo } from "react"; -import { Nav } from "@/components/Nav"; -import { HealthScore } from "@/components/HealthScore"; -import { DepositWizard } from "@/components/DepositWizard"; -import { BondTimeline } from "@/components/BondTimeline"; -import { api, ApiError, type Importer, type ImporterDetail, type ContractEvent, stroopsToXlm } from "@/lib/api"; -import { getUser, isAuthenticated } from "@/lib/auth"; -import { useYieldProjection } from "@/lib/workers/useYieldProjection"; -import * as Sentry from "@sentry/nextjs"; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'; +import { Nav } from '@/components/Nav'; +import { HealthScore } from '@/components/HealthScore'; +import { DepositWizard } from '@/components/DepositWizard'; +import { BondTimeline } from '@/components/BondTimeline'; +import { + api, + ApiError, + type Importer, + type ImporterDetail, + type ContractEvent, + stroopsToXlm, +} from '@/lib/api'; +import { getUser, isAuthenticated } from '@/lib/auth'; +import { useYieldProjection } from '@/lib/workers/useYieldProjection'; +import * as Sentry from '@sentry/nextjs'; function ImporterDashboard() { const router = useRouter(); @@ -55,31 +62,65 @@ function ImporterDashboard() { } }, []); - const action = useCallback(async (name: string, fn: () => Promise) => { - setBusy(name); - setError(null); - try { - await fn(); - await refresh(); - } catch (e) { - setError(e instanceof ApiError ? e.message : String(e)); - } finally { - setBusy(null); - } - }, [refresh]); + const action = useCallback( + async (name: string, fn: () => Promise) => { + setBusy(name); + setError(null); + try { + await fn(); + await refresh(); + } catch (e) { + setError(e instanceof ApiError ? e.message : String(e)); + } finally { + setBusy(null); + } + }, + [refresh] + ); const handleTopUp = useCallback(() => { if (!importer) return; - return action("topup", () => api.autoTopUp(importer.id)); + return action('topup', () => api.autoTopUp(importer.id)); }, [action, importer]); useEffect(() => { - if (!isAuthenticated()) { router.replace("/login"); return; } + if (!isAuthenticated()) { + router.replace('/login'); + return; + } const user = getUser(); - if (user?.role !== "importer") { router.replace("/surety"); return; } + if (user?.role !== 'importer') { + router.replace('/surety'); + return; + } refresh(); }, [router, refresh]); + // Derived values recomputed only when the on-chain account snapshot changes, + // not on every render triggered by unrelated state (busy, error, etc.). + // Hooks must run unconditionally, so this runs before the `!importer`/ + // `!detail` early returns below and guards its own computation instead. + const { required, collateral, reserve, shortfall, excess, utilization } = useMemo(() => { + const oncSnapshot = detail?.onChainAccount; + if (!oncSnapshot) { + return { + required: 0n, + collateral: 0n, + reserve: 0n, + shortfall: 0n, + excess: 0n, + utilization: 0, + }; + } + const required = BigInt(oncSnapshot.requiredCollateral); + const collateral = BigInt(oncSnapshot.collateralBalance); + const reserve = BigInt(oncSnapshot.reserveBalance); + const shortfall = required > collateral ? required - collateral : 0n; + const excess = collateral > required ? collateral - required : 0n; + const utilization = required === 0n ? 0 : Number((collateral * 100n) / required); + return { required, collateral, reserve, shortfall, excess, utilization }; + }, [detail?.onChainAccount]); + if (!importer) { return ( <> @@ -90,23 +131,18 @@ function ImporterDashboard() { } if (!detail) { - return (<>