From a6e7dfc49241313a4e1ff7180e2f123b297e4211 Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Sun, 18 Oct 2026 03:35:33 +0100 Subject: [PATCH 1/5] fix(api): restore a clean type check and build Several services did not compile on dev, so the type check and build jobs could not pass: - auto-invest passed an investment without the investor wallet that createInvestment requires - document-export called an IPFS fetch helper that did not exist - invoice-refund referred to an underscore prefixed parameter by its unprefixed name - issuer-analytics used a cache API that no longer existed The lock file is regenerated as well, since it did not match package.json and npm ci refused to run. --- package-lock.json | 525 +++++++++++++++++- src/lib/ttl-cache.ts | 10 + .../auto-invest-rule-engine.service.ts | 30 +- src/services/invoice-refund.service.ts | 7 +- src/services/ipfs.service.ts | 43 +- src/services/issuer-analytics.service.ts | 12 +- tests/auto-invest-rule-engine.service.test.ts | 69 ++- tests/issuer-analytics.service.test.ts | 16 +- 8 files changed, 659 insertions(+), 53 deletions(-) diff --git a/package-lock.json b/package-lock.json index 58ccf1a4..d54d9d54 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "archiver": "^7.0.1", "bcrypt": "^5.1.1", "cors": "^2.8.5", "decimal.js": "^10.6.0", @@ -34,6 +35,7 @@ "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", + "@types/archiver": "^6.0.2", "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/decimal.js": "^0.0.32", @@ -1934,6 +1936,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@types/archiver": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-6.0.4.tgz", + "integrity": "sha512-ULdQpARQ3sz9WH4nb98mJDYA0ft2A8C4f4fovvUcFwINa1cgGjY36JCAYuP5YypRq4mco1lJp1/7jEMS2oR0Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/readdir-glob": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2207,6 +2219,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/readdir-glob": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz", + "integrity": "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/sanitize-html": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz", @@ -2510,6 +2532,18 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "license": "ISC" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -2712,6 +2746,131 @@ "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", "license": "ISC" }, + "node_modules/archiver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", + "integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.2", + "async": "^3.2.4", + "buffer-crc32": "^1.0.0", + "readable-stream": "^4.0.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^3.0.0", + "zip-stream": "^6.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz", + "integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==", + "license": "MIT", + "dependencies": { + "glob": "^10.0.0", + "graceful-fs": "^4.2.0", + "is-stream": "^2.0.1", + "lazystream": "^1.0.0", + "lodash": "^4.17.15", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/archiver-utils/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/archiver-utils/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.2.tgz", + "integrity": "sha512-+8NeqHRjQWH9nYlwo2gamAMImZCVzI4UoEgDpWorBt9OEfppiZn+uSkskzQKPWIZyji/C8fpWO7u69G0DX0tbg==", + "license": "MIT", + "dependencies": { + "b4a": "^1.9.0", + "bare-fs": "^4.8.2", + "fast-fifo": "^1.3.2", + "streamx": "^2.28.1" + } + }, "node_modules/are-we-there-yet": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", @@ -2802,6 +2961,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/b4a": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.9.0.tgz", + "integrity": "sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ==", + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -2953,6 +3126,44 @@ } } }, + "node_modules/bare-events": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", + "integrity": "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA==", + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.8.4.tgz", + "integrity": "sha512-MPC2eyrpbk3A873sK4y6rk+Iv4o2enxTjHZ8zH3HO0mjrpm5XjnguY2XmfidIsDV6vnjpbMBKUAJpV0ISD2SsQ==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.28.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, "node_modules/bare-module-resolve": { "version": "1.12.1", "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.1.tgz", @@ -2971,6 +3182,12 @@ } } }, + "node_modules/bare-path": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.1.2.tgz", + "integrity": "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw==", + "license": "Apache-2.0" + }, "node_modules/bare-semver": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.0.2.tgz", @@ -2978,6 +3195,42 @@ "license": "Apache-2.0", "optional": true }, + "node_modules/bare-stream": { + "version": "2.13.4", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.4.tgz", + "integrity": "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" + }, + "peerDependencies": { + "bare-abort-controller": "*", + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.5.tgz", + "integrity": "sha512-iM9qJCruvpt2rPaLsrumVDru9pogI7PHziHIa9kTNRFt8C8HuSa+kI9kFezK+NvHEFnnz71rKGZ1EAilq/3V/Q==", + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base32.js": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.1.0.tgz", @@ -3227,6 +3480,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -3660,6 +3922,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compress-commons": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", + "integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "crc32-stream": "^6.0.0", + "is-stream": "^2.0.1", + "normalize-path": "^3.0.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/compress-commons/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3784,6 +4078,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -3846,6 +4146,47 @@ "typescript": ">=5" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", + "integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/crc32-stream/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/create-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", @@ -4650,6 +4991,33 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", @@ -4783,6 +5151,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -5434,7 +5808,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, "license": "ISC" }, "node_modules/graphemer": { @@ -6959,6 +7332,54 @@ "dayjs": "^1.11.7" } }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -7006,6 +7427,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -7901,7 +8328,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8559,6 +8985,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -8780,6 +9221,27 @@ "node": ">= 6" } }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -9654,6 +10116,17 @@ "node": ">=10.0.0" } }, + "node_modules/streamx": { + "version": "2.28.1", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz", + "integrity": "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA==", + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -9895,6 +10368,15 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", + "license": "MIT", + "dependencies": { + "streamx": "^2.12.5" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -9934,6 +10416,15 @@ "node": "*" } }, + "node_modules/text-decoder": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -10852,6 +11343,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zip-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", + "integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^5.0.0", + "compress-commons": "^6.0.2", + "readable-stream": "^4.0.0" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/zip-stream/node_modules/readable-stream": { + "version": "4.8.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.8.0.tgz", + "integrity": "sha512-2jhTTU3Eilp/2DMT71gbcoqC35eQHasMJH0RHkYxwlxIv3+FR6Y5xAFZIwEGLxKkjZYEFlsiMNIdwUqjK2lLHQ==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/src/lib/ttl-cache.ts b/src/lib/ttl-cache.ts index 7c28c414..acef5cec 100644 --- a/src/lib/ttl-cache.ts +++ b/src/lib/ttl-cache.ts @@ -119,6 +119,16 @@ export class TtlCache { } } + /** Drops every in-process entry whose key starts with `prefix`. */ + invalidatePrefix(prefix: string): void { + const namespaced = this.buildKey(prefix); + for (const key of this.memory.keys()) { + if (key.startsWith(namespaced)) { + this.memory.delete(key); + } + } + } + /** Drops every entry held in-process (Redis entries expire via their TTL). */ clear(): void { this.memory.clear(); diff --git a/src/services/auto-invest-rule-engine.service.ts b/src/services/auto-invest-rule-engine.service.ts index 50a38fa7..0ac6ad9f 100644 --- a/src/services/auto-invest-rule-engine.service.ts +++ b/src/services/auto-invest-rule-engine.service.ts @@ -64,9 +64,17 @@ export class AutoInvestRuleEngineService { const amount = Decimal.min(new Decimal(rule.maxPerInvoice), new Decimal(invoice.netAmount)); try { - const investor = await this.dependencies.dataSource.getRepository(User).findOneBy({ id: rule.investorId }); + const investor = await this.dependencies.dataSource + .getRepository(User) + .findOneBy({ id: rule.investorId }); if (!investor) { - await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.FAILED, "Investor not found"); + await this.recordExecution( + rule, + invoice, + amount, + AutoInvestExecutionStatus.FAILED, + "Investor not found" + ); return; } @@ -77,7 +85,7 @@ export class AutoInvestRuleEngineService { invoice, amount, AutoInvestExecutionStatus.SKIPPED_INSUFFICIENT_BALANCE, - `Wallet balance ${balance.toString()} is below the rule's minimum ${rule.minWalletBalance}`, + `Wallet balance ${balance.toString()} is below the rule's minimum ${rule.minWalletBalance}` ); return; } @@ -86,9 +94,19 @@ export class AutoInvestRuleEngineService { invoiceId: invoice.id, investorId: rule.investorId, investmentAmount: amount.toFixed(4), + // Investments are made from the investor's own wallet; createInvestment + // requires it to check accreditation and risk suitability. + investorWallet: investor.stellarAddress, }); - await this.recordExecution(rule, invoice, amount, AutoInvestExecutionStatus.EXECUTED, null, investment.id); + await this.recordExecution( + rule, + invoice, + amount, + AutoInvestExecutionStatus.EXECUTED, + null, + investment.id + ); this.dependencies.logger.info("AutoInvestExecuted", { rule_id: rule.id, @@ -116,7 +134,7 @@ export class AutoInvestRuleEngineService { amount: Decimal, status: AutoInvestExecutionStatus, reason: string | null, - investmentId: string | null = null, + investmentId: string | null = null ): Promise { const execution = this.dependencies.dataSource.getRepository(AutoInvestExecution).create({ ruleId: rule.id, @@ -132,7 +150,7 @@ export class AutoInvestRuleEngineService { } export function createAutoInvestRuleEngineService( - dependencies: AutoInvestRuleEngineDependencies, + dependencies: AutoInvestRuleEngineDependencies ): AutoInvestRuleEngineService { return new AutoInvestRuleEngineService(dependencies); } diff --git a/src/services/invoice-refund.service.ts b/src/services/invoice-refund.service.ts index e339ca5b..06803038 100644 --- a/src/services/invoice-refund.service.ts +++ b/src/services/invoice-refund.service.ts @@ -37,10 +37,7 @@ export class InvoiceRefundService { return { canRefund: true }; } - async refundInvestment( - invoiceId: string, - walletAddress: string - ): Promise { + async refundInvestment(invoiceId: string, walletAddress: string): Promise { const invoice = await this.invoiceRepo.findOne({ where: { id: invoiceId } }); if (!invoice) { @@ -81,7 +78,7 @@ export class InvoiceRefundService { } } - async getRefundableInvoices(_walletAddress: string): Promise { + async getRefundableInvoices(walletAddress: string): Promise { const now = new Date(); // Scope to invoices this wallet actually invested in - without this diff --git a/src/services/ipfs.service.ts b/src/services/ipfs.service.ts index 74e385d9..6c9fdf6f 100644 --- a/src/services/ipfs.service.ts +++ b/src/services/ipfs.service.ts @@ -147,9 +147,40 @@ export class IPFSService { } } + /** Downloads a pinned file from the public gateway and returns its bytes. */ + async fetchFile(cid: string): Promise { + const targetUrl = `${this.config.gatewayUrl}/ipfs/${cid}`; + + try { + const response = await this.fetchImplementation(targetUrl, { + method: "GET", + headers: withCorrelationHeaders({}), + }); + + if (!response.ok) { + throw new Error(`IPFS gateway returned ${response.status}: ${response.statusText}`); + } + + return Buffer.from(await response.arrayBuffer()); + } catch (error) { + if (error instanceof ServiceError) throw error; + + this.logger.error("Failed to fetch IPFS document", { + cid, + error: error instanceof Error ? error.message : String(error), + }); + + throw new ServiceError( + "ipfs_document_unavailable", + "The requested IPFS document is currently unavailable from the gateway. Please try again in a few moments.", + 503 + ); + } + } + async generateSignedUrl(cid: string): Promise { const gatewayUrl = this.config.gatewayUrl; - // Pinata V3 uses /files/CID, but /ipfs/CID is often supported on gateways. + // Pinata V3 uses /files/CID, but /ipfs/CID is often supported on gateways. // We'll use /ipfs/CID as default for backward compatibility with v2 uploads. const targetUrl = `${gatewayUrl}/ipfs/${cid}`; const expires = this.config.gatewayTokenTtlSeconds; @@ -172,7 +203,7 @@ export class IPFSService { url: targetUrl, expires, date: Math.floor(Date.now() / 1000), - method: "GET" + method: "GET", }), } ); @@ -182,18 +213,18 @@ export class IPFSService { throw new Error(`Pinata API returned ${response.status}: ${errorText}`); } - const result = await response.json() as { data?: string; url?: string }; + const result = (await response.json()) as { data?: string; url?: string }; // Pinata V3 usually returns the url directly or inside a data wrapper const signedUrl = result.data || result.url; - + if (!signedUrl) { - throw new Error("Pinata API did not return a valid URL in response"); + throw new Error("Pinata API did not return a valid URL in response"); } return signedUrl; } catch (error) { if (error instanceof ServiceError) throw error; - + this.logger.error("Failed to generate signed IPFS URL", { cid, error: error instanceof Error ? error.message : String(error), diff --git a/src/services/issuer-analytics.service.ts b/src/services/issuer-analytics.service.ts index 17637045..035b173f 100644 --- a/src/services/issuer-analytics.service.ts +++ b/src/services/issuer-analytics.service.ts @@ -40,7 +40,7 @@ export interface IssuerAnalytics { generatedAt: string; } -const CACHE_TTL_MS = 5 * 60 * 1000; +const CACHE_TTL_SECONDS = 5 * 60; function cacheKey(issuerId: string, range: AnalyticsRange): string { return `${issuerId}:${range}`; @@ -99,7 +99,11 @@ function daysBetween(from: Date, to: Date): number { * column would remove the ambiguity; out of scope here. */ export class IssuerAnalyticsService { - private readonly cache = new TtlCache(CACHE_TTL_MS); + private readonly cache = new TtlCache({ + ttlSeconds: CACHE_TTL_SECONDS, + namespace: "issuer-analytics", + enabled: true, + }); constructor(private readonly dataSource: DataSource) {} @@ -126,11 +130,11 @@ export class IssuerAnalyticsService { } const key = cacheKey(issuerId, range); - const cached = this.cache.get(key); + const cached = await this.cache.get(key); if (cached) return cached; const value = await this.computeAnalytics(issuerId, range, now); - this.cache.set(key, value); + await this.cache.set(key, value); return value; } diff --git a/tests/auto-invest-rule-engine.service.test.ts b/tests/auto-invest-rule-engine.service.test.ts index 157a4271..8153bc53 100644 --- a/tests/auto-invest-rule-engine.service.test.ts +++ b/tests/auto-invest-rule-engine.service.test.ts @@ -67,7 +67,8 @@ function makeDataSource(rules: AutoInvestRule[], users: User[], executions: Auto } if (entity === User) { return { - findOneBy: async (options: { id: string }) => users.find((user) => user.id === options.id) ?? null, + findOneBy: async (options: { id: string }) => + users.find((user) => user.id === options.id) ?? null, }; } if (entity === AutoInvestExecution) { @@ -92,7 +93,9 @@ describe("AutoInvestRuleEngineService", () => { const invoice = makeInvoice(); const executions: AutoInvestExecution[] = []; const createInvestment = jest.fn().mockResolvedValue({ id: "investment-1" }); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn().mockResolvedValue(new Decimal(500)) }; + const balanceChecker: AccountBalanceChecker = { + getBalance: jest.fn().mockResolvedValue(new Decimal(500)), + }; const engine = new AutoInvestRuleEngineService({ dataSource: makeDataSource([rule], [investor], executions), @@ -107,6 +110,7 @@ describe("AutoInvestRuleEngineService", () => { invoiceId: invoice.id, investorId: rule.investorId, investmentAmount: "500.0000", // min(maxPerInvoice=500, netAmount=950) + investorWallet: investor.stellarAddress, }); expect(executions).toHaveLength(1); expect(executions[0]).toMatchObject({ @@ -123,7 +127,9 @@ describe("AutoInvestRuleEngineService", () => { const rule = makeRule({ maxPerInvoice: "500.0000" }); const invoice = makeInvoice({ netAmount: "200.0000" }); const createInvestment = jest.fn().mockResolvedValue({ id: "investment-1" }); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn().mockResolvedValue(new Decimal(1000)) }; + const balanceChecker: AccountBalanceChecker = { + getBalance: jest.fn().mockResolvedValue(new Decimal(1000)), + }; const engine = new AutoInvestRuleEngineService({ dataSource: makeDataSource([rule], [investor], []), @@ -135,7 +141,7 @@ describe("AutoInvestRuleEngineService", () => { await engine.evaluateInvoice(invoice); expect(createInvestment).toHaveBeenCalledWith( - expect.objectContaining({ investmentAmount: "200.0000" }), + expect.objectContaining({ investmentAmount: "200.0000" }) ); }); @@ -144,7 +150,9 @@ describe("AutoInvestRuleEngineService", () => { const invoice = makeInvoice(); const executions: AutoInvestExecution[] = []; const createInvestment = jest.fn(); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn().mockResolvedValue(new Decimal(50)) }; + const balanceChecker: AccountBalanceChecker = { + getBalance: jest.fn().mockResolvedValue(new Decimal(50)), + }; const engine = new AutoInvestRuleEngineService({ dataSource: makeDataSource([rule], [investor], executions), @@ -165,7 +173,9 @@ describe("AutoInvestRuleEngineService", () => { const invoice = makeInvoice(); const executions: AutoInvestExecution[] = []; const createInvestment = jest.fn().mockRejectedValue(new Error("Invoice already fully funded")); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn().mockResolvedValue(new Decimal(500)) }; + const balanceChecker: AccountBalanceChecker = { + getBalance: jest.fn().mockResolvedValue(new Decimal(500)), + }; const engine = new AutoInvestRuleEngineService({ dataSource: makeDataSource([rule], [investor], executions), @@ -186,24 +196,27 @@ describe("AutoInvestRuleEngineService", () => { ["yield below the rule's minimum", makeRule({ minYield: "10.00" })], // invoice discountRate is 5.00 ["face value above the rule's cap", makeRule({ maxFaceValue: "500.0000" })], // invoice amount is 1000 ["risk tier mismatch", makeRule({ riskTier: RiskTier.HIGH })], // invoice riskScore 20 -> LOW - ])("does not execute or record anything when the rule doesn't match (%s)", async (_label, rule) => { - const invoice = makeInvoice(); - const executions: AutoInvestExecution[] = []; - const createInvestment = jest.fn(); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn() }; - - const engine = new AutoInvestRuleEngineService({ - dataSource: makeDataSource([rule], [investor], executions), - investmentService: { createInvestment } as unknown as InvestmentService, - balanceChecker, - logger: NOOP_LOGGER, - }); - - await engine.evaluateInvoice(invoice); - - expect(createInvestment).not.toHaveBeenCalled(); - expect(executions).toHaveLength(0); - }); + ])( + "does not execute or record anything when the rule doesn't match (%s)", + async (_label, rule) => { + const invoice = makeInvoice(); + const executions: AutoInvestExecution[] = []; + const createInvestment = jest.fn(); + const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn() }; + + const engine = new AutoInvestRuleEngineService({ + dataSource: makeDataSource([rule], [investor], executions), + investmentService: { createInvestment } as unknown as InvestmentService, + balanceChecker, + logger: NOOP_LOGGER, + }); + + await engine.evaluateInvoice(invoice); + + expect(createInvestment).not.toHaveBeenCalled(); + expect(executions).toHaveLength(0); + } + ); it("executes every matching rule independently, across different investors", async () => { const ruleA = makeRule({ id: "rule-a", investorId: "investor-1", maxPerInvoice: "100.0000" }); @@ -215,7 +228,9 @@ describe("AutoInvestRuleEngineService", () => { .fn() .mockResolvedValueOnce({ id: "investment-a" }) .mockResolvedValueOnce({ id: "investment-b" }); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn().mockResolvedValue(new Decimal(1000)) }; + const balanceChecker: AccountBalanceChecker = { + getBalance: jest.fn().mockResolvedValue(new Decimal(1000)), + }; const engine = new AutoInvestRuleEngineService({ dataSource: makeDataSource([ruleA, ruleB], [investor, investorB], executions), @@ -244,7 +259,9 @@ describe("AutoInvestRuleEngineService", () => { .fn() .mockRejectedValueOnce(new Error("capacity exceeded")) .mockResolvedValueOnce({ id: "investment-b" }); - const balanceChecker: AccountBalanceChecker = { getBalance: jest.fn().mockResolvedValue(new Decimal(1000)) }; + const balanceChecker: AccountBalanceChecker = { + getBalance: jest.fn().mockResolvedValue(new Decimal(1000)), + }; const engine = new AutoInvestRuleEngineService({ dataSource: makeDataSource([ruleA, ruleB], [investor, investorB], executions), diff --git a/tests/issuer-analytics.service.test.ts b/tests/issuer-analytics.service.test.ts index b0a1fc7a..2a21376f 100644 --- a/tests/issuer-analytics.service.test.ts +++ b/tests/issuer-analytics.service.test.ts @@ -65,7 +65,7 @@ function createFakeDataSource(invoices: Invoice[], investments: Investment[], us }, getMany: async () => invoices.filter( - (invoice) => invoice.sellerId === sellerId && (!since || invoice.createdAt >= since), + (invoice) => invoice.sellerId === sellerId && (!since || invoice.createdAt >= since) ), }; return builder; @@ -78,7 +78,8 @@ function createFakeDataSource(invoices: Invoice[], investments: Investment[], us invoiceIds = params.invoiceIds; return builder; }, - getMany: async () => investments.filter((investment) => invoiceIds.includes(investment.invoiceId)), + getMany: async () => + investments.filter((investment) => invoiceIds.includes(investment.invoiceId)), }; return builder; } @@ -121,7 +122,11 @@ describe("IssuerAnalyticsService", () => { it("sums investmentAmount across CONFIRMED and SETTLED investments only", async () => { const invoice = makeInvoice({ status: InvoiceStatus.FUNDED }); const investments = [ - makeInvestment({ id: "i1", status: InvestmentStatus.CONFIRMED, investmentAmount: "300.0000" }), + makeInvestment({ + id: "i1", + status: InvestmentStatus.CONFIRMED, + investmentAmount: "300.0000", + }), makeInvestment({ id: "i2", status: InvestmentStatus.SETTLED, investmentAmount: "200.0000" }), makeInvestment({ id: "i3", status: InvestmentStatus.PENDING, investmentAmount: "999.0000" }), ]; @@ -207,7 +212,10 @@ describe("IssuerAnalyticsService", () => { invoice.status = InvoiceStatus.SETTLED; const second = await service.getAnalytics("issuer-1", "all"); - expect(second).toBe(first); // same cached object, not recomputed + // The cache round trips through JSON, so identity is not preserved; what + // matters is that the second call is served from cache rather than + // recomputed against the mutated invoice. + expect(second).toStrictEqual(first); }); it("invalidate() busts the cache for every range of that issuer", async () => { From 9f249c6744b573a0d507c3bad2d1ed2f93d7e0e8 Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Sun, 18 Oct 2026 03:26:56 +0100 Subject: [PATCH 2/5] fix(settlement): allow system actor to settle matured invoices The settlement worker passed the seller wallet as the acting wallet, falling back to the literal "system" when no wallet was recorded. The seller wallet guard then rejected every such settlement with 403 FORBIDDEN because a Stellar address can never equal "system", so automated settlement cycles failed. Automated settlement now identifies itself as a system actor and marks the request as system triggered. The seller wallet equality check is skipped for system actors while the explicit seller id check still applies, and a wallet actor that is not the seller is still rejected. The system matured trigger is recorded as the existing maturity settled lifecycle reason. --- src/services/settlement.service.ts | 74 +++++- src/workers/settlement.worker.ts | 20 +- .../services/settlement-system-actor.test.ts | 224 ++++++++++++++++++ tests/unit/workers/settlement.worker.test.ts | 81 +++++++ 4 files changed, 386 insertions(+), 13 deletions(-) create mode 100644 tests/unit/services/settlement-system-actor.test.ts create mode 100644 tests/unit/workers/settlement.worker.test.ts diff --git a/src/services/settlement.service.ts b/src/services/settlement.service.ts index d93c3efb..775e877a 100644 --- a/src/services/settlement.service.ts +++ b/src/services/settlement.service.ts @@ -39,12 +39,41 @@ import type { IssuerReputationService } from "./issuer-reputation.service"; // loss of precision, since 7 - 4 = 3. const DECIMAL_SCALE_TO_STROOP_FACTOR = 10n ** 3n; +/** + * Identity recorded when a background job, rather than a wallet holder, + * triggers settlement. It is never a Stellar address, so it must never be + * compared against a seller's wallet. + */ +export const SYSTEM_SETTLEMENT_ACTOR = "system"; + +/** + * Settlement triggers that only automated infrastructure may request. They + * authorize settlement without the actor impersonating the seller's wallet. + */ +export type SystemSettlementTrigger = "system_matured"; + +export type SettlementTrigger = InvoiceTransitionReason | SystemSettlementTrigger; + +/** + * System actors are either the bare `system` identity or a namespaced job + * actor such as `system:invoice-maturity-job`. Anything else is treated as a + * wallet address and is still subject to the seller equality check. + */ +export function isSystemSettlementActor(actorWallet: string | null | undefined): boolean { + if (!actorWallet) return false; + return ( + actorWallet === SYSTEM_SETTLEMENT_ACTOR || actorWallet.startsWith(`${SYSTEM_SETTLEMENT_ACTOR}:`) + ); +} + export interface SettleInvoiceInput { invoiceId: string; proceeds: string; actorWallet: string; /** Recorded in the status history; defaults to `admin_settled`. */ - trigger?: InvoiceTransitionReason; + trigger?: SettlementTrigger; + /** Set for automated settlement of a matured invoice by a background job. */ + systemTrigger?: SystemSettlementTrigger; sellerId?: string; } @@ -193,7 +222,10 @@ export class SettlementService { actorWallet, sellerId, trigger = "admin_settled", + systemTrigger, } = input; + const authorizedForAutomatedSettlement = + isSystemSettlementActor(actorWallet) || Boolean(systemTrigger); const proceeds = new Decimal(proceedsInput); if (proceeds.isNegative() || proceeds.isZero()) { @@ -234,12 +266,26 @@ export class SettlementService { throw new ServiceError("FORBIDDEN", "Only the seller can settle this invoice", 403); } - if (actorWallet && invoice.sellerId && typeof transactionalEntityManager.findOne === "function") { + // Automated settlement runs without a seller wallet: the worker + // identifies itself as a system actor instead of impersonating the + // seller. Skip the wallet equality check in that case, otherwise it + // would reject every invoice whose seller wallet is unknown. The + // explicit sellerId check above still applies. + if ( + !authorizedForAutomatedSettlement && + actorWallet && + invoice.sellerId && + typeof transactionalEntityManager.findOne === "function" + ) { const seller = await transactionalEntityManager.findOne(User, { where: { id: invoice.sellerId }, }); if (seller && seller.stellarAddress && seller.stellarAddress !== actorWallet) { - throw new ServiceError("FORBIDDEN", "Only the invoice seller can settle this invoice", 403); + throw new ServiceError( + "FORBIDDEN", + "Only the invoice seller can settle this invoice", + 403 + ); } } @@ -377,11 +423,15 @@ export class SettlementService { await transactionalEntityManager.save(SettlementRemainder, settlementRemainderRecord); // 5. Transition invoice to SETTLED (history row written in this transaction) + // `system_matured` has no lifecycle reason of its own; it records the + // same settlement outcome as the maturity job does. + const transitionReason: InvoiceTransitionReason = + trigger === "system_matured" ? "maturity_settled" : trigger; const transition = await this.stateMachine.transition( entityManagerTransitionStore(transactionalEntityManager), invoice, InvoiceStatus.SETTLED, - { actor: { role: "system", wallet: actorWallet }, trigger } + { actor: { role: "system", wallet: actorWallet }, trigger: transitionReason } ); const eventPayload: SettlementEventPayload = { @@ -425,9 +475,13 @@ export class SettlementService { // Update issuer reputation after settlement if (this.issuerReputationService && result.sellerId) { try { - const user = await this.dataSource.getRepository(User).findOne({ where: { id: result.sellerId } }); + const user = await this.dataSource + .getRepository(User) + .findOne({ where: { id: result.sellerId } }); if (user?.stellarAddress) { - const invoice = await this.dataSource.getRepository(Invoice).findOne({ where: { id: result.invoiceId } }); + const invoice = await this.dataSource + .getRepository(Invoice) + .findOne({ where: { id: result.invoiceId } }); if (invoice) { const dueDate = new Date(invoice.dueDate); const settledDate = new Date(); @@ -449,7 +503,8 @@ export class SettlementService { } catch (reputationError) { logger.error("settlement.reputation_update_failed", { invoiceId: result.invoiceId, - error: reputationError instanceof Error ? reputationError.message : String(reputationError), + error: + reputationError instanceof Error ? reputationError.message : String(reputationError), }); // Don't fail settlement if reputation update fails } @@ -478,7 +533,10 @@ export class SettlementService { } catch (notificationError) { logger.error("settlement.notifications.failed", { invoiceId: result.invoiceId, - error: notificationError instanceof Error ? notificationError.message : String(notificationError), + error: + notificationError instanceof Error + ? notificationError.message + : String(notificationError), }); // Don't fail the settlement if notifications fail } diff --git a/src/workers/settlement.worker.ts b/src/workers/settlement.worker.ts index ec760c95..d0204a9a 100644 --- a/src/workers/settlement.worker.ts +++ b/src/workers/settlement.worker.ts @@ -2,7 +2,7 @@ import cron from "node-cron"; import { DataSource } from "typeorm"; import { Invoice } from "../models/Invoice.model"; import { InvoiceStatus } from "../types/enums"; -import { SettlementService } from "../services/settlement.service"; +import { SettlementService, SYSTEM_SETTLEMENT_ACTOR } from "../services/settlement.service"; import { logger } from "../observability/logger"; export interface SettlementWorkerDependencies { @@ -51,9 +51,10 @@ export class SettlementWorker { /** * Process all invoices that have reached maturity date. - * This is the main settlement logic triggered by the cron job. + * This is the main settlement logic triggered by the cron job. It is public + * so it can be exercised directly without waiting for a cron tick. */ - private async processMaturedInvoices(): Promise { + async processMaturedInvoices(): Promise { this.isRunning = true; const startTime = Date.now(); @@ -103,7 +104,12 @@ export class SettlementWorker { await settlementService.settleInvoice({ invoiceId: invoice.id, proceeds, - actorWallet: invoice.sellerWallet || "system", + // Automated settlement is performed by the system, not by the + // seller. Passing the seller's wallet here made the service treat + // the value as an authenticated actor, which then failed the + // seller wallet check for any invoice without a recorded wallet. + actorWallet: SYSTEM_SETTLEMENT_ACTOR, + systemTrigger: "system_matured", sellerId: invoice.sellerId, }); @@ -141,7 +147,11 @@ export class SettlementWorker { /** * Manually trigger settlement processing (for admin endpoint). */ - async triggerManualSettlement(invoiceId: string, proceeds: string, actorWallet: string): Promise { + async triggerManualSettlement( + invoiceId: string, + proceeds: string, + actorWallet: string + ): Promise { logger.info("Manual settlement triggered", { invoice_id: invoiceId, proceeds, diff --git a/tests/unit/services/settlement-system-actor.test.ts b/tests/unit/services/settlement-system-actor.test.ts new file mode 100644 index 00000000..914de5e9 --- /dev/null +++ b/tests/unit/services/settlement-system-actor.test.ts @@ -0,0 +1,224 @@ +import crypto from "crypto"; +import { DataSource } from "typeorm"; +import { + SettlementService, + SYSTEM_SETTLEMENT_ACTOR, + isSystemSettlementActor, +} from "../../../src/services/settlement.service"; +import { Invoice } from "../../../src/models/Invoice.model"; +import { Investment } from "../../../src/models/Investment.model"; +import { InvestorReturn } from "../../../src/models/InvestorReturn.model"; +import { SettlementRemainder } from "../../../src/models/SettlementRemainder.model"; +import { User } from "../../../src/models/User.model"; +import { InvoiceStatus, InvestmentStatus } from "../../../src/types/enums"; + +const SELLER_WALLET = "GSELLER" + "A".repeat(49); + +function createInvoice(overrides: Partial = {}): Invoice { + return { + id: crypto.randomUUID(), + sellerId: crypto.randomUUID(), + invoiceNumber: "INV-SYSTEM-628", + customerName: "Buyer Corp", + amount: "1000.0000", + discountRate: "0.00", + netAmount: "1000.0000", + fundedAmount: "1000.0000", + settlementRemainder: "0.0000", + dueDate: new Date(Date.now() - 60_000), + ipfsHash: null, + riskScore: null, + status: InvoiceStatus.FUNDED, + smartContractId: null, + rejectionReason: null, + sellerWallet: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + seller: undefined as unknown as Invoice["seller"], + investments: [], + transactions: [], + investorReturns: [], + settlementRemainders: [], + ...overrides, + } as Invoice; +} + +function createInvestment(invoiceId: string): Investment { + return { + id: crypto.randomUUID(), + invoiceId, + investorId: crypto.randomUUID(), + investmentAmount: "1000.0000", + expectedReturn: "1100.0000", + actualReturn: null, + status: InvestmentStatus.CONFIRMED, + investorWallet: "GINVESTOR" + "B".repeat(46), + fundingBlock: "12345", + transactionHash: "hash628", + stellarOperationIndex: 0, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + version: 1, + investor: { stellarAddress: "GINVESTOR" + "B".repeat(46) } as Investment["investor"], + } as Investment; +} + +/** + * Fake DataSource that mirrors the shape `settleInvoice` relies on. The seller + * lookup is the part under test: it returns a seller with a stellar address so + * the wallet equality guard is actually reachable. + */ +function createFakeDataSource(invoice: Invoice, seller: Partial | null) { + const invoices = new Map([[invoice.id, invoice]]); + const investments = new Map( + [createInvestment(invoice.id)].map((investment) => [investment.id, investment]) + ); + + const manager = { + createQueryBuilder: () => { + let targetId: string | undefined; + const builder = { + setLock: () => builder, + where: (_clause: string, params: { id: string }) => { + targetId = params.id; + return builder; + }, + getOne: async () => (targetId ? (invoices.get(targetId) ?? null) : null), + }; + return builder; + }, + find: async (entity: unknown) => (entity === Investment ? [...investments.values()] : []), + findOne: async (entity: unknown) => { + if (entity === User) return seller; + return null; + }, + create: (_entity: unknown, data: Record) => ({ + id: crypto.randomUUID(), + ...data, + }), + save: async (entity: unknown, data: unknown) => { + const record = data as { id: string }; + if (entity === Invoice) invoices.set(record.id, data as Invoice); + if (entity === Investment) investments.set(record.id, data as Investment); + return data; + }, + }; + + const dataSource = { + transaction: async (callback: (m: typeof manager) => Promise) => callback(manager), + getRepository: () => ({ findOne: async () => null }), + } as unknown as DataSource; + + return { dataSource }; +} + +describe("SettlementService system actor authorization (#628)", () => { + it("settles without impersonating the seller when the actor is the system identity", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice, { + id: invoice.sellerId, + stellarAddress: SELLER_WALLET, + }); + const service = new SettlementService(dataSource); + + const result = await service.settleInvoice({ + invoiceId: invoice.id, + proceeds: "1100.0000", + actorWallet: SYSTEM_SETTLEMENT_ACTOR, + sellerId: invoice.sellerId, + }); + + expect(result.status).toBe(InvoiceStatus.SETTLED); + expect(result.settlements).toHaveLength(1); + }); + + it("settles for a namespaced system job actor", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice, { + id: invoice.sellerId, + stellarAddress: SELLER_WALLET, + }); + const service = new SettlementService(dataSource); + + await expect( + service.settleInvoice({ + invoiceId: invoice.id, + proceeds: "1100.0000", + actorWallet: "system:invoice-maturity-job", + trigger: "maturity_settled", + }) + ).resolves.toMatchObject({ status: InvoiceStatus.SETTLED }); + }); + + it("settles when the caller marks the settlement as system triggered", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice, { + id: invoice.sellerId, + stellarAddress: SELLER_WALLET, + }); + const service = new SettlementService(dataSource); + + await expect( + service.settleInvoice({ + invoiceId: invoice.id, + proceeds: "1100.0000", + actorWallet: "", + systemTrigger: "system_matured", + sellerId: invoice.sellerId, + }) + ).resolves.toMatchObject({ status: InvoiceStatus.SETTLED }); + }); + + it("still rejects a wallet actor that is not the seller", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice, { + id: invoice.sellerId, + stellarAddress: SELLER_WALLET, + }); + const service = new SettlementService(dataSource); + + await expect( + service.settleInvoice({ + invoiceId: invoice.id, + proceeds: "1100.0000", + actorWallet: "GSOMEONEELSE" + "C".repeat(46), + }) + ).rejects.toMatchObject({ code: "FORBIDDEN", statusCode: 403 }); + }); + + it("still enforces the explicit sellerId check for system actors", async () => { + const invoice = createInvoice(); + const { dataSource } = createFakeDataSource(invoice, { + id: invoice.sellerId, + stellarAddress: SELLER_WALLET, + }); + const service = new SettlementService(dataSource); + + await expect( + service.settleInvoice({ + invoiceId: invoice.id, + proceeds: "1100.0000", + actorWallet: SYSTEM_SETTLEMENT_ACTOR, + sellerId: crypto.randomUUID(), + }) + ).rejects.toMatchObject({ code: "FORBIDDEN", statusCode: 403 }); + }); +}); + +describe("isSystemSettlementActor (#628)", () => { + it.each(["system", "system:invoice-maturity-job", "system:settlement-worker"])( + "recognizes %s as a system actor", + (actor) => { + expect(isSystemSettlementActor(actor)).toBe(true); + } + ); + + it.each(["", null, undefined, "GSYSTEM", "systemic", "gsystem"])( + "does not treat %p as a system actor", + (actor) => { + expect(isSystemSettlementActor(actor)).toBe(false); + } + ); +}); diff --git a/tests/unit/workers/settlement.worker.test.ts b/tests/unit/workers/settlement.worker.test.ts new file mode 100644 index 00000000..eaa97920 --- /dev/null +++ b/tests/unit/workers/settlement.worker.test.ts @@ -0,0 +1,81 @@ +import type { DataSource } from "typeorm"; +import { SettlementWorker } from "../../../src/workers/settlement.worker"; +import type { SettlementService } from "../../../src/services/settlement.service"; +import { SYSTEM_SETTLEMENT_ACTOR } from "../../../src/services/settlement.service"; +import { Invoice } from "../../../src/models/Invoice.model"; +import { InvoiceStatus } from "../../../src/types/enums"; + +function createMaturedInvoice(overrides: Partial = {}): Invoice { + return { + id: "invoice-628-1", + sellerId: "seller-628", + invoiceNumber: "INV-628-001", + amount: "5000.0000", + dueDate: new Date(Date.now() - 24 * 60 * 60 * 1000), + status: InvoiceStatus.FUNDED, + sellerWallet: null, + ...overrides, + } as Invoice; +} + +function createWorker(invoices: Invoice[]) { + const find = jest.fn(async () => invoices); + const settleInvoice = jest.fn(async (_input: { actorWallet: string }) => ({ + invoiceId: invoices[0]?.id ?? "none", + status: InvoiceStatus.SETTLED, + proceeds: "0.0000", + totalDistributed: "0.0000", + remainder: "0.0000", + remainderDust: "0.0000", + settlements: [], + })); + + const worker = new SettlementWorker({ + dataSource: { + getRepository: () => ({ find }), + } as unknown as DataSource, + settlementService: { settleInvoice } as unknown as SettlementService, + }); + + return { worker, settleInvoice }; +} + +describe("SettlementWorker automated settlement (#628)", () => { + it("settles matured invoices as the system actor instead of the seller", async () => { + const invoice = createMaturedInvoice(); + const { worker, settleInvoice } = createWorker([invoice]); + + await worker.processMaturedInvoices(); + + expect(settleInvoice).toHaveBeenCalledTimes(1); + expect(settleInvoice).toHaveBeenCalledWith({ + invoiceId: invoice.id, + proceeds: invoice.amount, + actorWallet: SYSTEM_SETTLEMENT_ACTOR, + systemTrigger: "system_matured", + sellerId: invoice.sellerId, + }); + }); + + it("does not impersonate a seller wallet even when one is recorded", async () => { + const invoice = createMaturedInvoice({ sellerWallet: "GSELLERWALLET" }); + const { worker, settleInvoice } = createWorker([invoice]); + + await worker.processMaturedInvoices(); + + const [input] = settleInvoice.mock.calls[0] as unknown as [{ actorWallet: string }]; + expect(input.actorWallet).toBe(SYSTEM_SETTLEMENT_ACTOR); + expect(input.actorWallet).not.toBe(invoice.sellerWallet); + }); + + it("skips invoices that have not reached maturity", async () => { + const invoice = createMaturedInvoice({ + dueDate: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + const { worker, settleInvoice } = createWorker([invoice]); + + await worker.processMaturedInvoices(); + + expect(settleInvoice).not.toHaveBeenCalled(); + }); +}); From c735d5b5af7925662c53c89e7af8b90f4d4b79f0 Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Sun, 18 Oct 2026 03:43:12 +0100 Subject: [PATCH 3/5] fix(migrations): run enum changes outside a transaction ALTER TYPE ... ADD VALUE cannot execute inside a transaction block in Postgres, and TypeORM wrapped migrations in one, so adding the rejected status failed with SQLSTATE 25001. Migrations now run in per-migration transaction mode, and every migration that adds an enum member opts out of the managed transaction. The same failure affected the notification type, failed status and admin role migrations, so they are marked non-transactional too. Each statement stays guarded so a partially applied migration can be retried. --- src/config/database.ts | 5 ++ .../1732000000000-AddInvoiceRejection.ts | 14 +++- ...00-AddInvoiceLifecycleNotificationTypes.ts | 3 + .../1732700000000-AddInvoiceFailedStatus.ts | 3 + .../1732800000000-AddUserAdminManagement.ts | 3 + ...900000000-AddNotificationDispatchDedupe.ts | 3 + ...000000-AddSubscriptionNotificationTypes.ts | 3 + .../enum-migrations-transaction.test.ts | 74 +++++++++++++++++++ 8 files changed, 105 insertions(+), 3 deletions(-) create mode 100644 tests/migrations/enum-migrations-transaction.test.ts diff --git a/src/config/database.ts b/src/config/database.ts index 1ae49b2b..01a538db 100644 --- a/src/config/database.ts +++ b/src/config/database.ts @@ -62,6 +62,11 @@ const baseConfig = { entities: [entitiesPath], migrations: [migrationsPath], migrationsTableName: "migrations", + // Postgres refuses `ALTER TYPE ... ADD VALUE` inside a transaction block, so + // migrations that change enums opt out by setting `transaction = false`. + // TypeORM rejects such overrides under the default "all" mode, which wraps + // the entire run in one transaction; "each" honours the per-migration flag. + migrationsTransactionMode: "each" as const, migrationsRun: !isDevelopment, }; diff --git a/src/migrations/1732000000000-AddInvoiceRejection.ts b/src/migrations/1732000000000-AddInvoiceRejection.ts index 920dd154..773b600d 100644 --- a/src/migrations/1732000000000-AddInvoiceRejection.ts +++ b/src/migrations/1732000000000-AddInvoiceRejection.ts @@ -8,10 +8,18 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddInvoiceRejection1732000000000 implements MigrationInterface { name = "AddInvoiceRejection1732000000000"; + /** + * Postgres refuses `ALTER TYPE ... ADD VALUE` inside a transaction block + * (SQLSTATE 25001), and TypeORM wraps each migration in one by default. + * Running this migration without a managed transaction keeps the enum + * change in autocommit, where it is allowed. Both statements are written + * idempotently so a partially applied run can simply be retried. + */ + transaction = false as const; + public async up(queryRunner: QueryRunner): Promise { - // Postgres requires ALTER TYPE ... ADD VALUE to run outside of a - // surrounding transaction in older versions; guard with IF NOT EXISTS - // (supported since PG 9.6+) so this migration is safe to re-run. + // Guard with IF NOT EXISTS (supported since PG 9.6+) so this migration is + // safe to re-run. await queryRunner.query(` ALTER TYPE "public"."invoices_invoicestatus_enum" ADD VALUE IF NOT EXISTS 'rejected'; `); diff --git a/src/migrations/1732500000000-AddInvoiceLifecycleNotificationTypes.ts b/src/migrations/1732500000000-AddInvoiceLifecycleNotificationTypes.ts index 5da558db..6a58e41e 100644 --- a/src/migrations/1732500000000-AddInvoiceLifecycleNotificationTypes.ts +++ b/src/migrations/1732500000000-AddInvoiceLifecycleNotificationTypes.ts @@ -8,6 +8,9 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddInvoiceLifecycleNotificationTypes1732500000000 implements MigrationInterface { name = "AddInvoiceLifecycleNotificationTypes1732500000000"; + /** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */ + transaction = false as const; + public async up(queryRunner: QueryRunner): Promise { for (const value of [ "invoice_rejected", diff --git a/src/migrations/1732700000000-AddInvoiceFailedStatus.ts b/src/migrations/1732700000000-AddInvoiceFailedStatus.ts index 1906b79e..c48533fa 100644 --- a/src/migrations/1732700000000-AddInvoiceFailedStatus.ts +++ b/src/migrations/1732700000000-AddInvoiceFailedStatus.ts @@ -7,6 +7,9 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddInvoiceFailedStatus1732700000000 implements MigrationInterface { name = "AddInvoiceFailedStatus1732700000000"; + /** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */ + transaction = false as const; + public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TYPE "public"."invoices_invoicestatus_enum" ADD VALUE IF NOT EXISTS 'failed';` diff --git a/src/migrations/1732800000000-AddUserAdminManagement.ts b/src/migrations/1732800000000-AddUserAdminManagement.ts index 110195e0..c75d37cd 100644 --- a/src/migrations/1732800000000-AddUserAdminManagement.ts +++ b/src/migrations/1732800000000-AddUserAdminManagement.ts @@ -7,6 +7,9 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddUserAdminManagement1732800000000 implements MigrationInterface { name = "AddUserAdminManagement1732800000000"; + /** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */ + transaction = false as const; + public async up(queryRunner: QueryRunner): Promise { await queryRunner.query( `ALTER TYPE "public"."users_usertype_enum" ADD VALUE IF NOT EXISTS 'admin';` diff --git a/src/migrations/1732900000000-AddNotificationDispatchDedupe.ts b/src/migrations/1732900000000-AddNotificationDispatchDedupe.ts index 581b6838..bb985b58 100644 --- a/src/migrations/1732900000000-AddNotificationDispatchDedupe.ts +++ b/src/migrations/1732900000000-AddNotificationDispatchDedupe.ts @@ -7,6 +7,9 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddNotificationDispatchDedupe1732900000000 implements MigrationInterface { name = "AddNotificationDispatchDedupe1732900000000"; + /** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */ + transaction = false as const; + public async up(queryRunner: QueryRunner): Promise { for (const value of ["invoice_approved", "invoice_deadline_extended", "invoice_matured"]) { await queryRunner.query( diff --git a/src/migrations/1754000000000-AddSubscriptionNotificationTypes.ts b/src/migrations/1754000000000-AddSubscriptionNotificationTypes.ts index fb6a4070..e9a3dd8b 100644 --- a/src/migrations/1754000000000-AddSubscriptionNotificationTypes.ts +++ b/src/migrations/1754000000000-AddSubscriptionNotificationTypes.ts @@ -6,6 +6,9 @@ import { MigrationInterface, QueryRunner } from "typeorm"; export class AddSubscriptionNotificationTypes1754000000000 implements MigrationInterface { name = "AddSubscriptionNotificationTypes1754000000000"; + /** Postgres rejects ALTER TYPE ... ADD VALUE inside a transaction (SQLSTATE 25001). */ + transaction = false as const; + public async up(queryRunner: QueryRunner): Promise { for (const value of ["subscription", "subscription_expiring"]) { await queryRunner.query( diff --git a/tests/migrations/enum-migrations-transaction.test.ts b/tests/migrations/enum-migrations-transaction.test.ts new file mode 100644 index 00000000..835fb5a4 --- /dev/null +++ b/tests/migrations/enum-migrations-transaction.test.ts @@ -0,0 +1,74 @@ +import { AddInvoiceRejection1732000000000 } from "../../src/migrations/1732000000000-AddInvoiceRejection"; +import { AddInvoiceFailedStatus1732700000000 } from "../../src/migrations/1732700000000-AddInvoiceFailedStatus"; +import { AddInvoiceLifecycleNotificationTypes1732500000000 } from "../../src/migrations/1732500000000-AddInvoiceLifecycleNotificationTypes"; +import { AddNotificationDispatchDedupe1732900000000 } from "../../src/migrations/1732900000000-AddNotificationDispatchDedupe"; +import { AddSubscriptionNotificationTypes1754000000000 } from "../../src/migrations/1754000000000-AddSubscriptionNotificationTypes"; +import { AddUserAdminManagement1732800000000 } from "../../src/migrations/1732800000000-AddUserAdminManagement"; + +import dataSource from "../../src/config/database"; + +/** + * Postgres rejects `ALTER TYPE ... ADD VALUE` with SQLSTATE 25001 when the + * statement runs inside a transaction block. TypeORM wraps migrations in a + * transaction unless the migration opts out, so every migration that adds an + * enum member must declare `transaction = false`. + */ +const ENUM_MIGRATIONS = [ + new AddInvoiceRejection1732000000000(), + new AddInvoiceLifecycleNotificationTypes1732500000000(), + new AddInvoiceFailedStatus1732700000000(), + new AddUserAdminManagement1732800000000(), + new AddNotificationDispatchDedupe1732900000000(), + new AddSubscriptionNotificationTypes1754000000000(), +]; + +function createQueryRunner() { + const query = jest.fn(async (_sql: string) => []); + return { query }; +} + +describe("enum migrations run outside a transaction (#632)", () => { + it.each( + ENUM_MIGRATIONS.map((migration) => [migration.name ?? migration.constructor.name, migration]) + )("%s opts out of the managed transaction", (_name, migration) => { + expect(migration.transaction).toBe(false); + }); + + it("adds the rejected status without a surrounding transaction", async () => { + const runner = createQueryRunner(); + const migration = new AddInvoiceRejection1732000000000(); + + await migration.up(runner as never); + + const statements = runner.query.mock.calls.map(([sql]) => String(sql)); + expect(statements.join("\n")).toContain( + `ALTER TYPE "public"."invoices_invoicestatus_enum" ADD VALUE IF NOT EXISTS 'rejected'` + ); + expect(statements.join("\n")).toContain('ADD COLUMN IF NOT EXISTS "rejection_reason"'); + }); + + it("is safe to re-run because every statement is guarded", async () => { + const runner = createQueryRunner(); + await new AddInvoiceRejection1732000000000().up(runner as never); + await new AddInvoiceRejection1732000000000().up(runner as never); + + for (const [sql] of runner.query.mock.calls) { + expect(String(sql)).toMatch(/IF NOT EXISTS/); + } + }); + + it("reverts only the additive column", async () => { + const runner = createQueryRunner(); + await new AddInvoiceRejection1732000000000().down(runner as never); + + const statements = runner.query.mock.calls.map(([sql]) => String(sql)).join("\n"); + expect(statements).toContain('DROP COLUMN IF EXISTS "rejection_reason"'); + expect(statements).not.toContain("ALTER TYPE"); + }); +}); + +describe("data source migration transaction mode (#632)", () => { + it("honours per-migration transaction overrides", () => { + expect(dataSource.options.migrationsTransactionMode).toBe("each"); + }); +}); From 5b8d92e0f59f3136cd23a0e4977bfc3aee004de8 Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Sun, 18 Oct 2026 03:47:16 +0100 Subject: [PATCH 4/5] fix(cache): bound the in-process ttl cache The in-process map only dropped an entry when that exact key was read again after it expired, so keys written once and never read stayed resident for the lifetime of the process. Under real traffic that is an unbounded leak that ends in a heap exhaustion crash. The cache now caps itself at five thousand entries by default and evicts the least recently used one when full, refreshing recency on reads. Expired entries are pruned whenever a new value is written and by a background sweeper that does not hold the process open. The sweep interval and capacity are both configurable, and the cache can be closed explicitly. --- src/lib/ttl-cache.ts | 97 ++++++++++++++++++ tests/unit/ttl-cache-bounds.test.ts | 146 ++++++++++++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 tests/unit/ttl-cache-bounds.test.ts diff --git a/src/lib/ttl-cache.ts b/src/lib/ttl-cache.ts index acef5cec..4eed7d72 100644 --- a/src/lib/ttl-cache.ts +++ b/src/lib/ttl-cache.ts @@ -6,6 +6,12 @@ * multiple API instances share the same warm cache. Redis is strictly best * effort: any connection/command failure degrades to the in-process map rather * than failing the request. + * + * The in-process map is bounded: it holds at most `maxEntries` entries, evicts + * the least recently used one when full, and periodically drops expired + * entries. Without those bounds, keys that are written once and never read + * (or keys with randomized ids) would accumulate for the lifetime of the + * process and eventually exhaust the heap. */ export interface TtlCacheOptions { @@ -22,6 +28,14 @@ export interface TtlCacheOptions { enabled?: boolean; /** Injectable clock, for deterministic TTL assertions in tests. */ now?: () => number; + /** Maximum number of in-process entries before LRU eviction. */ + maxEntries?: number; + /** + * How often expired entries are swept, in milliseconds. Sweeping is skipped + * while the cache holds nothing. Set to 0 to disable the timer and rely on + * lazy expiry plus pruning on write. + */ + sweepIntervalMs?: number; } interface MemoryEntry { @@ -29,23 +43,45 @@ interface MemoryEntry { expiresAt: number; } +export const DEFAULT_MAX_ENTRIES = 5000; + +/** Expired entries are swept at most this often, and at least once a minute. */ +function defaultSweepIntervalMs(ttlSeconds: number): number { + return Math.max(1_000, Math.min(ttlSeconds * 1000, 60_000)); +} + export class TtlCache { readonly ttlSeconds: number; + readonly maxEntries: number; private readonly namespace: string; private readonly redis?: TtlCacheOptions["redisClient"]; private readonly enabled: boolean; private readonly now: () => number; + private readonly sweepIntervalMs: number; + /** Insertion order doubles as recency order: a hit is re-inserted at the end. */ private readonly memory = new Map(); + private sweepTimer: ReturnType | null = null; constructor(options: TtlCacheOptions) { if (!Number.isFinite(options.ttlSeconds) || options.ttlSeconds <= 0) { throw new Error("TtlCache requires a positive ttlSeconds."); } + const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; + if (!Number.isInteger(maxEntries) || maxEntries <= 0) { + throw new Error("TtlCache requires a positive integer maxEntries."); + } + const sweepIntervalMs = options.sweepIntervalMs ?? defaultSweepIntervalMs(options.ttlSeconds); + if (!Number.isFinite(sweepIntervalMs) || sweepIntervalMs < 0) { + throw new Error("TtlCache requires a non-negative sweepIntervalMs."); + } + this.ttlSeconds = options.ttlSeconds; + this.maxEntries = maxEntries; this.namespace = options.namespace ?? "cache"; this.redis = options.redisClient; this.enabled = options.enabled !== false; this.now = options.now ?? (() => Date.now()); + this.sweepIntervalMs = sweepIntervalMs; } isEnabled(): boolean { @@ -56,6 +92,11 @@ export class TtlCache { return this.ttlSeconds; } + /** Number of entries currently held in-process, expired ones included. */ + get size(): number { + return this.memory.size; + } + async get(key: string): Promise { if (!this.enabled) return null; const namespaced = this.buildKey(key); @@ -79,6 +120,9 @@ export class TtlCache { this.memory.delete(namespaced); return null; } + // Reading refreshes recency so hot keys outlive a burst of one-off writes. + this.memory.delete(namespaced); + this.memory.set(namespaced, entry); try { return JSON.parse(entry.value) as T; } catch { @@ -101,7 +145,11 @@ export class TtlCache { } } + this.memory.delete(namespaced); this.memory.set(namespaced, { value: serialized, expiresAt: this.now() + ttl * 1000 }); + this.pruneExpired(); + this.evictToCapacity(); + this.ensureSweeper(); } /** Drops a single entry from both the in-process map and Redis. */ @@ -134,9 +182,58 @@ export class TtlCache { this.memory.clear(); } + /** Removes expired entries and returns how many were dropped. */ + pruneExpired(): number { + const now = this.now(); + let removed = 0; + for (const [key, entry] of this.memory) { + if (now > entry.expiresAt) { + this.memory.delete(key); + removed++; + } + } + return removed; + } + + /** Stops the background sweeper. Safe to call more than once. */ + close(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer); + this.sweepTimer = null; + } + } + + private evictToCapacity(): void { + while (this.memory.size > this.maxEntries) { + // Map iteration order is insertion order, which the read path refreshes, + // so the first key is the least recently used one. + const oldest = this.memory.keys().next(); + if (oldest.done) return; + this.memory.delete(oldest.value); + } + } + + private ensureSweeper(): void { + if (this.sweepTimer || this.sweepIntervalMs === 0 || !this.enabled) return; + this.sweepTimer = setInterval(() => { + if (this.memory.size === 0) { + this.close(); + return; + } + this.pruneExpired(); + }, this.sweepIntervalMs); + // Never hold the process open just to sweep a cache. + if (typeof this.sweepTimer.unref === "function") { + this.sweepTimer.unref(); + } + } + private remember(key: string, raw: string, ttlSeconds?: number): void { const ttl = ttlSeconds ?? this.ttlSeconds; + this.memory.delete(key); this.memory.set(key, { value: raw, expiresAt: this.now() + ttl * 1000 }); + this.evictToCapacity(); + this.ensureSweeper(); } private buildKey(key: string): string { diff --git a/tests/unit/ttl-cache-bounds.test.ts b/tests/unit/ttl-cache-bounds.test.ts new file mode 100644 index 00000000..2ad96f90 --- /dev/null +++ b/tests/unit/ttl-cache-bounds.test.ts @@ -0,0 +1,146 @@ +import { DEFAULT_MAX_ENTRIES, TtlCache } from "../../src/lib/ttl-cache"; + +function createClock(start = 1_000_000) { + let current = start; + return { + now: () => current, + advance(ms: number) { + current += ms; + }, + }; +} + +describe("TtlCache bounds (#633)", () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it("defaults to a bounded capacity", () => { + const cache = new TtlCache({ ttlSeconds: 60, sweepIntervalMs: 0 }); + expect(cache.maxEntries).toBe(DEFAULT_MAX_ENTRIES); + expect(cache.maxEntries).toBeGreaterThan(0); + }); + + it("rejects a non-positive capacity", () => { + expect(() => new TtlCache({ ttlSeconds: 60, maxEntries: 0 })).toThrow(/maxEntries/); + expect(() => new TtlCache({ ttlSeconds: 60, maxEntries: -1 })).toThrow(/maxEntries/); + }); + + it("does not grow past maxEntries when distinct keys are written and never read", async () => { + const cache = new TtlCache({ ttlSeconds: 300, maxEntries: 25, sweepIntervalMs: 0 }); + + for (let i = 0; i < 5_000; i++) { + await cache.set(`invoice-${i}`, { i }); + } + + expect(cache.size).toBe(25); + }); + + it("evicts the least recently used entry, not simply the oldest write", async () => { + const cache = new TtlCache({ ttlSeconds: 300, maxEntries: 2, sweepIntervalMs: 0 }); + + await cache.set("a", "value-a"); + await cache.set("b", "value-b"); + // Touch "a" so "b" becomes the least recently used entry. + expect(await cache.get("a")).toBe("value-a"); + + await cache.set("c", "value-c"); + + expect(cache.size).toBe(2); + expect(await cache.get("a")).toBe("value-a"); + expect(await cache.get("b")).toBeNull(); + expect(await cache.get("c")).toBe("value-c"); + }); + + it("overwriting a key does not evict an unrelated entry", async () => { + const cache = new TtlCache({ ttlSeconds: 300, maxEntries: 2, sweepIntervalMs: 0 }); + + await cache.set("a", 1); + await cache.set("b", 2); + await cache.set("a", 3); + + expect(cache.size).toBe(2); + expect(await cache.get("a")).toBe(3); + expect(await cache.get("b")).toBe(2); + }); + + it("prunes expired entries on write", async () => { + const clock = createClock(); + const cache = new TtlCache({ + ttlSeconds: 10, + maxEntries: 100, + sweepIntervalMs: 0, + now: clock.now, + }); + + await cache.set("short", "x", 1); + clock.advance(2_000); + await cache.set("fresh", "y", 60); + + expect(cache.size).toBe(1); + expect(await cache.get("short")).toBeNull(); + expect(await cache.get("fresh")).toBe("y"); + }); + + it("sweeps expired entries on an interval", async () => { + jest.useFakeTimers(); + const clock = createClock(); + const cache = new TtlCache({ + ttlSeconds: 2, + sweepIntervalMs: 1_000, + now: clock.now, + }); + + await cache.set("one", 1); + await cache.set("two", 2); + expect(cache.size).toBe(2); + + clock.advance(3_000); + jest.advanceTimersByTime(1_000); + + expect(cache.size).toBe(0); + + cache.close(); + }); + + it("does not keep the process alive for the sweeper", async () => { + const cache = new TtlCache({ ttlSeconds: 60, sweepIntervalMs: 1_000 }); + await cache.set("one", 1); + + const timer = (cache as unknown as { sweepTimer: { unref?: () => void } | null }).sweepTimer; + expect(timer).not.toBeNull(); + expect(typeof timer?.unref).toBe("function"); + + cache.close(); + }); + + it("close() stops sweeping and is idempotent", async () => { + const cache = new TtlCache({ ttlSeconds: 60, sweepIntervalMs: 1_000 }); + await cache.set("one", 1); + + expect(() => { + cache.close(); + cache.close(); + }).not.toThrow(); + expect((cache as unknown as { sweepTimer: unknown }).sweepTimer).toBeNull(); + }); + + it("stays bounded when reading from the redis mirror as well", async () => { + const cache = new TtlCache({ + ttlSeconds: 300, + maxEntries: 10, + sweepIntervalMs: 0, + redisClient: { + get: async (key: string) => JSON.stringify({ key }), + set: async () => "OK", + del: async () => 1, + }, + }); + + for (let i = 0; i < 500; i++) { + await cache.get(`missing-${i}`); + } + + expect(cache.size).toBe(10); + }); +}); From 6e16ae4e295f0db9d2cfff15ad2d2bbb2d1648cf Mon Sep 17 00:00:00 2001 From: dotmantissa Date: Sun, 18 Oct 2026 04:03:43 +0100 Subject: [PATCH 5/5] docs(openapi): document the investor, portfolio, seller and readiness routes The schema drift check failed because several mounted routers and the readiness probe had no specification. The routes are documented now, and the unversioned alias mounts follow the same allowance as the auth and notification aliases. --- docs/openapi.json | 1407 +++++++++++++++++++++----------- scripts/verify-openapi-sync.ts | 6 +- 2 files changed, 916 insertions(+), 497 deletions(-) diff --git a/docs/openapi.json b/docs/openapi.json index f80c8303..49c5bcab 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -38,10 +38,7 @@ "example": "AAAA..." } }, - "required": [ - "status", - "contractId" - ] + "required": ["status", "contractId"] }, "ErrorResponse": { "type": "object", @@ -56,9 +53,7 @@ }, "BatchPublishRequest": { "type": "object", - "required": [ - "invoiceIds" - ], + "required": ["invoiceIds"], "properties": { "invoiceIds": { "type": "array", @@ -99,10 +94,7 @@ }, "RevokeKYCRequest": { "type": "object", - "required": [ - "userId", - "reviewerId" - ], + "required": ["userId", "reviewerId"], "properties": { "userId": { "type": "string", @@ -201,11 +193,7 @@ }, "status": { "type": "string", - "enum": [ - "pending", - "executed", - "expired" - ] + "enum": ["pending", "executed", "expired"] }, "proposedParams": { "type": "object", @@ -286,10 +274,7 @@ }, "status": { "type": "string", - "enum": [ - "active", - "removed" - ] + "enum": ["active", "removed"] }, "addedAt": { "type": "string", @@ -321,10 +306,7 @@ }, "action": { "type": "string", - "enum": [ - "add", - "remove" - ] + "enum": ["add", "remove"] }, "permittedFunctions": { "type": "array", @@ -459,11 +441,7 @@ "description": "RFC 3339 payment time." } }, - "required": [ - "id", - "keyAddress", - "amount" - ] + "required": ["id", "keyAddress", "amount"] }, "RoyaltyClaim": { "type": "object", @@ -498,10 +476,7 @@ "description": "RFC 3339 claim time." } }, - "required": [ - "claimId", - "amount" - ] + "required": ["claimId", "amount"] }, "RoyaltyEarnings": { "type": "object", @@ -587,12 +562,7 @@ "description": "earned - claimed, floored at zero." } }, - "required": [ - "cycleId", - "earned", - "claimed", - "claimable" - ] + "required": ["cycleId", "earned", "claimed", "claimable"] }, "DividendClaimable": { "type": "object", @@ -678,10 +648,7 @@ "description": "RFC 3339 claim time." } }, - "required": [ - "claimId", - "amount" - ] + "required": ["claimId", "amount"] }, "DividendSummary": { "type": "object", @@ -796,102 +763,59 @@ "description": "RFC 3339 time of the last write." } }, - "required": [ - "wallet", - "tourCompleted" - ] + "required": ["wallet", "tourCompleted"] } } }, "paths": { - "/api/v1/kyc/submit": { - "post": { - "summary": "Submit KYC verification", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "201": { - "description": "Verification submitted" - }, - "401": { - "description": "Authentication required" - } - } - } - }, - "/api/v1/kyc/webhook": { - "post": { - "summary": "Process a KYC provider webhook", - "description": "Updates verification and user KYC state after validating the provider HMAC signature.", - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - } - }, - "responses": { - "204": { - "description": "Webhook processed" - }, - "401": { - "description": "Invalid webhook signature" - } - } - } - }, "/api/v1/admin": { "description": "Administrative routes are defined under this prefix." }, - "/api/v1/invoices/{id}/escrow-status": { + "/api/v1/admin/acl": { "get": { - "summary": "Get Soroban Escrow Status", - "description": "Retrieves the on-chain escrow status for a specific invoice via Soroban smart contracts.", + "summary": "Get the contract integration ACL", + "description": "Returns the whitelisted contract integrations and the functions each may call, projected from ACLUpdated contract events. Cached for five minutes and invalidated whenever a new ACLUpdated event is indexed. Admin only.", "security": [ { "bearerAuth": [] } ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "Invoice ID", - "schema": { - "type": "string" - } - } - ], "responses": { "200": { "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/EscrowStatusResponse" - } - } - } - }, - "400": { - "description": "Bad Request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "contracts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AclEntry" + } + }, + "total": { + "type": "integer" + }, + "cachedAt": { + "type": "string", + "format": "date-time" + } + } + } + } } } } }, - "401": { - "description": "Unauthorized", + "Admin access required": { + "description": "Admin access required", "content": { "application/json": { "schema": { @@ -900,8 +824,8 @@ } } }, - "503": { - "description": "Service Unavailable", + "Authentication required": { + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -913,58 +837,62 @@ } } }, - "/api/v1/invoices/batch-publish": { - "post": { - "summary": "Batch publish draft invoices", - "description": "Publishes several of the authenticated seller's draft invoices to the marketplace in a single atomic transaction. Every invoice is validated first; if any one of them cannot be published the whole batch is rejected and no invoice changes state. The 400 response lists every rejected invoice and why.", + "/api/v1/admin/acl/log": { + "get": { + "summary": "Get the ACL add/remove history", + "description": "Returns the append-only audit trail of ACL changes derived from ACLUpdated events, newest first. Admin only.", "security": [ { "bearerAuth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchPublishRequest" - } + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 0, + "default": 0 } } - }, + ], "responses": { "200": { - "description": "All invoices published", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BatchPublishResponse" - } - } - } - }, - "400": { - "description": "Batch rejected; no invoice was changed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "401": { - "description": "Unauthorized", + "description": "OK", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AclLogEntry" + } + } + } } } } }, - "403": { - "description": "KYC approval required", + "Admin access required": { + "description": "Admin access required", "content": { "application/json": { "schema": { @@ -973,8 +901,8 @@ } } }, - "429": { - "description": "Rate limit exceeded", + "Authentication required": { + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -983,8 +911,8 @@ } } }, - "503": { - "description": "Service Unavailable", + "Bad Request": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -1047,19 +975,13 @@ } } }, - "/api/v1/keys/{id}": { + "/api/v1/dividends/claimable": { "get": { - "summary": "Get creator key detail", - "description": "Returns the creator key configuration, including the per-transaction buy limit. Public read; the limit is enforced on-chain.", - "parameters": [ + "summary": "Claimable dividends per cycle", + "description": "Claimable dividend amounts per distribution cycle for the authenticated holder wallet, derived from synced DividendDistributed / DividendClaimed events. Cached for 30 seconds and invalidated by dividend events.", + "security": [ { - "name": "id", - "in": "path", - "required": true, - "description": "Creator key id", - "schema": { - "type": "string" - } + "bearerAuth": [] } ], "responses": { @@ -1074,25 +996,16 @@ "type": "boolean" }, "data": { - "$ref": "#/components/schemas/CreatorKeyDetail" + "$ref": "#/components/schemas/DividendClaimable" } - } - } - } - } - }, - "Creator key not found": { - "description": "Creator key not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + }, + "required": ["success", "data"] } } } }, - "Bad Request": { - "description": "Bad Request", + "401": { + "description": "Missing or invalid authentication token", "content": { "application/json": { "schema": { @@ -1104,24 +1017,98 @@ } } }, - "/api/v1/keys/{id}/buy-limit": { + "/api/v1/dividends/claims": { "get": { - "summary": "Get per-transaction buy limit for a creator key", - "description": "Pre-trade check: returns the per-transaction buy cap (max_buy_per_tx) and the current key supply so the frontend can clamp the buy input before submitting a transaction. Public read, cached for 60 seconds and invalidated when a key configuration update event is indexed.", - "parameters": [ + "summary": "Dividend claim history", + "description": "Dividends the authenticated holder wallet has claimed, newest first, with amounts and the on-chain transaction that paid them. Cursor paginated.", + "security": [ { - "name": "id", - "in": "path", - "required": true, - "description": "Creator key id", - "schema": { - "type": "string" - } + "bearerAuth": [] } ], - "responses": { - "200": { - "description": "OK", + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Page size (1-100).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page's meta.nextCursor.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "success": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DividendClaim" + } + }, + "meta": { + "type": "object", + "properties": { + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string", + "nullable": true, + "description": "Opaque cursor for the next page." + }, + "limit": { + "type": "integer" + } + } + } + } + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/dividends/summary": { + "get": { + "summary": "Dividend totals across every cycle", + "description": "Lifetime earned, claimed and pending dividend totals for the authenticated holder wallet, aggregated across every distribution cycle the wallet was allocated in.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { @@ -1131,15 +1118,16 @@ "type": "boolean" }, "data": { - "$ref": "#/components/schemas/BuyLimitResponse" + "$ref": "#/components/schemas/DividendSummary" } - } + }, + "required": ["success", "data"] } } } }, - "Creator key not found": { - "description": "Creator key not found", + "401": { + "description": "Missing or invalid authentication token", "content": { "application/json": { "schema": { @@ -1147,9 +1135,294 @@ } } } + } + } + } + }, + "/api/v1/investors": { + "post": { + "summary": "Acknowledge investor accreditation terms", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Acknowledge investor accreditation terms", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } }, - "Bad Request": { - "description": "Bad Request", + "401": { + "description": "Authentication required" + } + } + }, + "get": { + "summary": "List the authenticated investor's portfolios and payouts", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List the authenticated investor's portfolios and payouts", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/investors/acknowledge": { + "post": { + "summary": "Acknowledge investor accreditation terms", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Acknowledge investor accreditation terms", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/investors/acknowledgement-status": { + "get": { + "summary": "Read the accreditation acknowledgement status", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Read the accreditation acknowledgement status", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/investors/payouts/export/csv": { + "get": { + "summary": "Export the investor's payout history as CSV", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "CSV export", + "content": { + "text/csv": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/investors/payouts/history": { + "get": { + "summary": "List the investor's payout history", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List the investor's payout history", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/investors/risk-profile": { + "post": { + "summary": "Create the investor's risk profile", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "201": { + "description": "Risk profile created", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + }, + "get": { + "summary": "Read the investor's risk profile", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Risk profile", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + }, + "delete": { + "summary": "Delete the investor's risk profile", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/invoices/batch-publish": { + "post": { + "summary": "Batch publish draft invoices", + "description": "Publishes several of the authenticated seller's draft invoices to the marketplace in a single atomic transaction. Every invoice is validated first; if any one of them cannot be published the whole batch is rejected and no invoice changes state. The 400 response lists every rejected invoice and why.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPublishRequest" + } + } + } + }, + "responses": { + "200": { + "description": "All invoices published", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BatchPublishResponse" + } + } + } + }, + "400": { + "description": "Batch rejected; no invoice was changed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "403": { + "description": "KYC approval required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "429": { + "description": "Rate limit exceeded", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service Unavailable", "content": { "application/json": { "schema": { @@ -1161,10 +1434,10 @@ } } }, - "/api/v1/keys/{id}/curve-migrations": { + "/api/v1/invoices/{id}/escrow-status": { "get": { - "summary": "List bonding curve migrations for a creator key", - "description": "Returns the migration proposals indexed from CurveMigrationProposed / CurveMigrationExecuted events, each with the proposed parameters, timelock expiry and status. Executed migrations are visible to any authenticated user; migrations still behind the timelock are creator-only.", + "summary": "Get Soroban Escrow Status", + "description": "Retrieves the on-chain escrow status for a specific invoice via Soroban smart contracts.", "security": [ { "bearerAuth": [] @@ -1175,24 +1448,68 @@ "name": "id", "in": "path", "required": true, - "description": "Creator key id", + "description": "Invoice ID", "schema": { "type": "string" } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EscrowStatusResponse" + } + } + } }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "503": { + "description": "Service Unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/v1/keys/{id}": { + "get": { + "summary": "Get creator key detail", + "description": "Returns the creator key configuration, including the per-transaction buy limit. Public read; the limit is enforced on-chain.", + "parameters": [ { - "name": "status", - "in": "query", - "required": false, - "description": "Filter by status; `pending` requires creator access", + "name": "id", + "in": "path", + "required": true, + "description": "Creator key id", "schema": { - "type": "string", - "enum": [ - "all", - "pending", - "executed" - ], - "default": "all" + "type": "string" } } ], @@ -1208,25 +1525,15 @@ "type": "boolean" }, "data": { - "$ref": "#/components/schemas/CurveMigrationList" + "$ref": "#/components/schemas/CreatorKeyDetail" } } } } } }, - "Creator access required": { - "description": "Creator access required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" - } - } - } - }, - "Authentication required": { - "description": "Authentication required", + "Creator key not found": { + "description": "Creator key not found", "content": { "application/json": { "schema": { @@ -1248,13 +1555,19 @@ } } }, - "/api/v1/admin/acl": { + "/api/v1/keys/{id}/buy-limit": { "get": { - "summary": "Get the contract integration ACL", - "description": "Returns the whitelisted contract integrations and the functions each may call, projected from ACLUpdated contract events. Cached for five minutes and invalidated whenever a new ACLUpdated event is indexed. Admin only.", - "security": [ + "summary": "Get per-transaction buy limit for a creator key", + "description": "Pre-trade check: returns the per-transaction buy cap (max_buy_per_tx) and the current key supply so the frontend can clamp the buy input before submitting a transaction. Public read, cached for 60 seconds and invalidated when a key configuration update event is indexed.", + "parameters": [ { - "bearerAuth": [] + "name": "id", + "in": "path", + "required": true, + "description": "Creator key id", + "schema": { + "type": "string" + } } ], "responses": { @@ -1269,30 +1582,15 @@ "type": "boolean" }, "data": { - "type": "object", - "properties": { - "contracts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AclEntry" - } - }, - "total": { - "type": "integer" - }, - "cachedAt": { - "type": "string", - "format": "date-time" - } - } + "$ref": "#/components/schemas/BuyLimitResponse" } } } } } }, - "Admin access required": { - "description": "Admin access required", + "Creator key not found": { + "description": "Creator key not found", "content": { "application/json": { "schema": { @@ -1301,8 +1599,8 @@ } } }, - "Authentication required": { - "description": "Authentication required", + "Bad Request": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -1314,10 +1612,10 @@ } } }, - "/api/v1/admin/acl/log": { + "/api/v1/keys/{id}/curve-migrations": { "get": { - "summary": "Get the ACL add/remove history", - "description": "Returns the append-only audit trail of ACL changes derived from ACLUpdated events, newest first. Admin only.", + "summary": "List bonding curve migrations for a creator key", + "description": "Returns the migration proposals indexed from CurveMigrationProposed / CurveMigrationExecuted events, each with the proposed parameters, timelock expiry and status. Executed migrations are visible to any authenticated user; migrations still behind the timelock are creator-only.", "security": [ { "bearerAuth": [] @@ -1325,24 +1623,23 @@ ], "parameters": [ { - "name": "limit", - "in": "query", - "required": false, + "name": "id", + "in": "path", + "required": true, + "description": "Creator key id", "schema": { - "type": "integer", - "minimum": 1, - "maximum": 200, - "default": 50 + "type": "string" } }, { - "name": "offset", + "name": "status", "in": "query", "required": false, - "schema": { - "type": "integer", - "minimum": 0, - "default": 0 + "description": "Filter by status; `pending` requires creator access", + "schema": { + "type": "string", + "enum": ["all", "pending", "executed"], + "default": "all" } } ], @@ -1358,18 +1655,15 @@ "type": "boolean" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AclLogEntry" - } + "$ref": "#/components/schemas/CurveMigrationList" } } } } } }, - "Admin access required": { - "description": "Admin access required", + "Creator access required": { + "description": "Creator access required", "content": { "application/json": { "schema": { @@ -1401,35 +1695,105 @@ } } }, - "/api/v1/swaps/history": { - "get": { - "summary": "List atomic swaps for the authenticated wallet", - "description": "Returns every atomic swap where the authenticated wallet is either the buyer or the seller, keyset-paginated on (executed_at, id).", + "/api/v1/kyc/submit": { + "post": { + "summary": "Submit KYC verification", "security": [ { "bearerAuth": [] } ], - "parameters": [ + "responses": { + "201": { + "description": "Verification submitted" + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/kyc/webhook": { + "post": { + "summary": "Process a KYC provider webhook", + "description": "Updates verification and user KYC state after validating the provider HMAC signature.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "responses": { + "204": { + "description": "Webhook processed" + }, + "401": { + "description": "Invalid webhook signature" + } + } + } + }, + "/api/v1/marketplace": { + "get": { + "summary": "List invoices available on the secondary marketplace", + "security": [ { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 25 + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List invoices available on the secondary marketplace", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } } }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/marketplace/invoices": { + "get": { + "summary": "List invoices available on the secondary marketplace", + "security": [ { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page's meta.nextCursor", - "schema": { - "type": "string" + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "List invoices available on the secondary marketplace", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/onboarding/complete": { + "post": { + "summary": "Record onboarding tour completion", + "description": "Records that the authenticated wallet finished the onboarding tour. Idempotent: re-completing keeps the original completedAt timestamp.", + "security": [ + { + "bearerAuth": [] } ], "responses": { @@ -1444,46 +1808,16 @@ "type": "boolean" }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AtomicSwap" - } - }, - "meta": { - "type": "object", - "properties": { - "total": { - "type": "integer" - }, - "limit": { - "type": "integer" - }, - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string", - "nullable": true - } - } + "$ref": "#/components/schemas/OnboardingStatus" } - } - } - } - } - }, - "Authentication required": { - "description": "Authentication required", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ErrorResponse" + }, + "required": ["success", "data"] } } } }, - "Bad Request": { - "description": "Bad Request", + "401": { + "description": "Missing or invalid authentication token", "content": { "application/json": { "schema": { @@ -1495,19 +1829,13 @@ } } }, - "/api/v1/swaps/{id}": { + "/api/v1/onboarding/status": { "get": { - "summary": "Get a single atomic swap", - "description": "Public lookup of one atomic swap by its id or on-chain swap id.", - "parameters": [ + "summary": "Onboarding tour status", + "description": "Whether the authenticated wallet has completed the onboarding tour, and when. Called on every app load, so it is a single indexed row lookup.", + "security": [ { - "name": "id", - "in": "path", - "required": true, - "description": "Swap id", - "schema": { - "type": "string" - } + "bearerAuth": [] } ], "responses": { @@ -1522,15 +1850,16 @@ "type": "boolean" }, "data": { - "$ref": "#/components/schemas/AtomicSwap" + "$ref": "#/components/schemas/OnboardingStatus" } - } + }, + "required": ["success", "data"] } } } }, - "Swap not found": { - "description": "Swap not found", + "401": { + "description": "Missing or invalid authentication token", "content": { "application/json": { "schema": { @@ -1538,16 +1867,31 @@ } } } - }, - "Bad Request": { - "description": "Bad Request", + } + } + } + }, + "/api/v1/portfolio": { + "get": { + "summary": "Read the authenticated investor's portfolio", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Read the authenticated investor's portfolio", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object" } } } + }, + "401": { + "description": "Authentication required" } } } @@ -1590,10 +1934,7 @@ "$ref": "#/components/schemas/RoyaltyEarnings" } }, - "required": [ - "success", - "data" - ] + "required": ["success", "data"] } } } @@ -1711,13 +2052,79 @@ } } }, - "/api/v1/dividends/claimable": { + "/api/v1/seller": { "get": { - "summary": "Claimable dividends per cycle", - "description": "Claimable dividend amounts per distribution cycle for the authenticated holder wallet, derived from synced DividendDistributed / DividendClaimed events. Cached for 30 seconds and invalidated by dividend events.", + "summary": "Read the authenticated seller's dashboard", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Read the authenticated seller's dashboard", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/seller/dashboard": { + "get": { + "summary": "Read the authenticated seller's dashboard", "security": [ { - "bearerAuth": [] + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Read the authenticated seller's dashboard", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/api/v1/subscriptions/status": { + "get": { + "summary": "Gated-content subscription status", + "description": "Whether a wallet holds the minimum key balance required to access gated content, with the expiry ledger and whole days remaining. Public by design: a content gate has to answer before the visitor is known to the platform, and the response only reflects public on-chain holdings. Cached for 30 seconds and evicted when a holding-change event is observed.", + "parameters": [ + { + "name": "wallet", + "in": "query", + "required": false, + "description": "Stellar account (strkey) public key to check.", + "schema": { + "type": "string", + "pattern": "^G[A-Z2-7]{55}$" + } + }, + { + "name": "key_id", + "in": "query", + "required": false, + "description": "Soroban contract id, or the key identifier the contract uses.", + "schema": { + "type": "string" + } } ], "responses": { @@ -1732,19 +2139,16 @@ "type": "boolean" }, "data": { - "$ref": "#/components/schemas/DividendClaimable" + "$ref": "#/components/schemas/SubscriptionStatus" } }, - "required": [ - "success", - "data" - ] + "required": ["success", "data"] } } } }, - "401": { - "description": "Missing or invalid authentication token", + "400": { + "description": "'wallet' is missing or malformed, or 'key_id' is missing or malformed", "content": { "application/json": { "schema": { @@ -1756,10 +2160,10 @@ } } }, - "/api/v1/dividends/claims": { + "/api/v1/swaps/history": { "get": { - "summary": "Dividend claim history", - "description": "Dividends the authenticated holder wallet has claimed, newest first, with amounts and the on-chain transaction that paid them. Cursor paginated.", + "summary": "List atomic swaps for the authenticated wallet", + "description": "Returns every atomic swap where the authenticated wallet is either the buyer or the seller, keyset-paginated on (executed_at, id).", "security": [ { "bearerAuth": [] @@ -1770,19 +2174,18 @@ "name": "limit", "in": "query", "required": false, - "description": "Page size (1-100).", "schema": { "type": "integer", "minimum": 1, "maximum": 100, - "default": 20 + "default": 25 } }, { "name": "cursor", "in": "query", "required": false, - "description": "Opaque cursor from the previous page's meta.nextCursor.", + "description": "Opaque cursor from the previous page's meta.nextCursor", "schema": { "type": "string" } @@ -1794,28 +2197,33 @@ "content": { "application/json": { "schema": { - "success": { - "type": "boolean" - }, - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/DividendClaim" - } - }, - "meta": { - "type": "object", - "properties": { - "hasMore": { - "type": "boolean" - }, - "nextCursor": { - "type": "string", - "nullable": true, - "description": "Opaque cursor for the next page." - }, - "limit": { - "type": "integer" + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AtomicSwap" + } + }, + "meta": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "limit": { + "type": "integer" + }, + "hasMore": { + "type": "boolean" + }, + "nextCursor": { + "type": "string", + "nullable": true + } } } } @@ -1823,8 +2231,8 @@ } } }, - "401": { - "description": "Missing or invalid authentication token", + "Authentication required": { + "description": "Authentication required", "content": { "application/json": { "schema": { @@ -1832,44 +2240,9 @@ } } } - } - } - } - }, - "/api/v1/dividends/summary": { - "get": { - "summary": "Dividend totals across every cycle", - "description": "Lifetime earned, claimed and pending dividend totals for the authenticated holder wallet, aggregated across every distribution cycle the wallet was allocated in.", - "security": [ - { - "bearerAuth": [] - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$ref": "#/components/schemas/DividendSummary" - } - }, - "required": [ - "success", - "data" - ] - } - } - } }, - "401": { - "description": "Missing or invalid authentication token", + "Bad Request": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -1881,26 +2254,16 @@ } } }, - "/api/v1/subscriptions/status": { + "/api/v1/swaps/{id}": { "get": { - "summary": "Gated-content subscription status", - "description": "Whether a wallet holds the minimum key balance required to access gated content, with the expiry ledger and whole days remaining. Public by design: a content gate has to answer before the visitor is known to the platform, and the response only reflects public on-chain holdings. Cached for 30 seconds and evicted when a holding-change event is observed.", + "summary": "Get a single atomic swap", + "description": "Public lookup of one atomic swap by its id or on-chain swap id.", "parameters": [ { - "name": "wallet", - "in": "query", - "required": false, - "description": "Stellar account (strkey) public key to check.", - "schema": { - "type": "string", - "pattern": "^G[A-Z2-7]{55}$" - } - }, - { - "name": "key_id", - "in": "query", - "required": false, - "description": "Soroban contract id, or the key identifier the contract uses.", + "name": "id", + "in": "path", + "required": true, + "description": "Swap id", "schema": { "type": "string" } @@ -1918,19 +2281,25 @@ "type": "boolean" }, "data": { - "$ref": "#/components/schemas/SubscriptionStatus" + "$ref": "#/components/schemas/AtomicSwap" } - }, - "required": [ - "success", - "data" - ] + } } } } }, - "400": { - "description": "'wallet' is missing or malformed, or 'key_id' is missing or malformed", + "Swap not found": { + "description": "Swap not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "Bad Request": { + "description": "Bad Request", "content": { "application/json": { "schema": { @@ -1942,10 +2311,9 @@ } } }, - "/api/v1/onboarding/status": { + "/api/v1/watchlist": { "get": { - "summary": "Onboarding tour status", - "description": "Whether the authenticated wallet has completed the onboarding tour, and when. Called on every app load, so it is a single indexed row lookup.", + "summary": "List the authenticated user's watchlist", "security": [ { "bearerAuth": [] @@ -1953,78 +2321,125 @@ ], "responses": { "200": { - "description": "OK", + "description": "Watchlist", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$ref": "#/components/schemas/OnboardingStatus" - } - }, - "required": [ - "success", - "data" - ] + "type": "object" } } } }, "401": { - "description": "Missing or invalid authentication token", + "description": "Authentication required" + } + } + }, + "post": { + "summary": "Add an invoice to the watchlist", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "201": { + "description": "Added", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object" } } } + }, + "401": { + "description": "Authentication required" } } } }, - "/api/v1/onboarding/complete": { + "/api/v1/watchlist/{invoiceId}": { "post": { - "summary": "Record onboarding tour completion", - "description": "Records that the authenticated wallet finished the onboarding tour. Idempotent: re-completing keeps the original completedAt timestamp.", + "summary": "Add an invoice to the watchlist", "security": [ { "bearerAuth": [] } ], + "parameters": [ + { + "name": "invoiceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], "responses": { - "200": { - "description": "OK", + "201": { + "description": "Added", "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "data": { - "$ref": "#/components/schemas/OnboardingStatus" - } - }, - "required": [ - "success", - "data" - ] + "type": "object" } } } }, "401": { - "description": "Missing or invalid authentication token", + "description": "Authentication required" + } + } + }, + "delete": { + "summary": "Remove an invoice from the watchlist", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "invoiceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Removed" + }, + "401": { + "description": "Authentication required" + } + } + } + }, + "/ready": { + "get": { + "summary": "Readiness probe", + "responses": { + "200": { + "description": "Service is ready", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object" + } + } + } + }, + "503": { + "description": "Service is not ready", + "content": { + "application/json": { + "schema": { + "type": "object" } } } diff --git a/scripts/verify-openapi-sync.ts b/scripts/verify-openapi-sync.ts index 119b7265..5035c428 100644 --- a/scripts/verify-openapi-sync.ts +++ b/scripts/verify-openapi-sync.ts @@ -207,7 +207,11 @@ function validateOpenAPIDrift(): boolean { route === "/notifications" || // Alias mounts of documented routers. route === "/keys" || - route === "/swaps"; + route === "/swaps" || + route === "/investors" || + route === "/portfolio" || + route === "/marketplace" || + route === "/seller"; if (isDocumented) { logSuccess(`Registered route has spec: ${route}`);