diff --git a/backend/package-lock.json b/backend/package-lock.json index 658ff930..09d895d2 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -17,6 +17,7 @@ "lru-cache": "^10.4.3", "nanoid": "^3.3.7", "pino": "^10.3.1", + "pino-http": "10.2.0", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", "ws": "^8.21.0", @@ -1809,8 +1810,15 @@ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.13.0", + "@typescript-eslint/type-utils": "7.13.0", + "@typescript-eslint/utils": "7.13.0", + "@typescript-eslint/visitor-keys": "7.13.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" }, "engines": { "node": ">= 0.6" @@ -1834,6 +1842,332 @@ "bin": { "acorn": "bin/acorn" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "7.13.0", + "@typescript-eslint/types": "7.13.0", + "@typescript-eslint/typescript-estree": "7.13.0", + "@typescript-eslint/visitor-keys": "7.13.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.13.0.tgz", + "integrity": "sha512-ZrMCe1R6a01T94ilV13egvcnvVJ1pxShkE0+NDjDzH4nvG1wXpwsVI5bZCvE7AEDH1mXEx5tJSVR68bLgG7Dng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.13.0", + "@typescript-eslint/visitor-keys": "7.13.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.13.0.tgz", + "integrity": "sha512-xMEtMzxq9eRkZy48XuxlBFzpVMDurUAfDu5Rz16GouAtXm0TaAoTFzqWUFPPuQYXI/CDaH/Bgx/fk/84t/Bc9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "7.13.0", + "@typescript-eslint/utils": "7.13.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.13.0.tgz", + "integrity": "sha512-QWuwm9wcGMAuTsxP+qz6LBBd3Uq8I5Nv8xb0mk54jmNoCyDspnMvVsOxI6IsMmway5d1S9Su2+sCKv1st2l6eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.13.0.tgz", + "integrity": "sha512-cAvBvUoobaoIcoqox1YatXOnSl3gx92rCZoMRPzMNisDiM12siGilSM4+dJAekuuHTibI2hVC2fYK79iSFvWjw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "7.13.0", + "@typescript-eslint/visitor-keys": "7.13.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.13.0.tgz", + "integrity": "sha512-jceD8RgdKORVnB4Y6BqasfIkFhl4pajB1wVxrF4akxD2QPM8GNYjgGwEzYS+437ewlqqrg7Dw+6dhdpjMpeBFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "7.13.0", + "@typescript-eslint/types": "7.13.0", + "@typescript-eslint/typescript-estree": "7.13.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.13.0.tgz", + "integrity": "sha512-nxn+dozQx+MK61nn/JP+M4eCkHDSxSLDpgE3WcQo0+fkjEolnaB5jswvIKC4K56By8MMgIho7f1PVxERHEo8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "7.13.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, "engines": { "node": ">=0.4.0" } @@ -2005,6 +2339,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2931,6 +3275,19 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3505,6 +3862,36 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3802,7 +4189,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -5320,6 +5706,16 @@ "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -5777,6 +6173,16 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5828,6 +6234,80 @@ "split2": "^4.0.0" } }, + "node_modules/pino-http": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.2.0.tgz", + "integrity": "sha512-am03BxnV3Ckx68OkbH0iZs3indsrH78wncQ6w1w51KroIbvJZNImBKX2X1wjdY8lSyaJ0UrX/dnO2DY3cTeCRw==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^3.0.0" + } + }, + "node_modules/pino-http/node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-http/node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http/node_modules/pino/node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/pino-http/node_modules/process-warning": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-3.0.0.tgz", + "integrity": "sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==", + "license": "MIT" + }, + "node_modules/pino-http/node_modules/thread-stream": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", + "integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/pino-std-serializers": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", @@ -6920,6 +7400,19 @@ "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", "license": "MIT" }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/ts-jest": { "version": "29.1.4", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.4.tgz", diff --git a/backend/src/services/reconciliation.test.ts b/backend/src/services/reconciliation.test.ts index ff8dd644..82069274 100644 --- a/backend/src/services/reconciliation.test.ts +++ b/backend/src/services/reconciliation.test.ts @@ -499,4 +499,156 @@ describe("reconciliation API routes", () => { expect(response.status).toBe(404); expect(response.body.error).toBe("NO_RECONCILIATION_REPORT"); }); +}); + +// ─── Repair (idempotent drift repair) ───────────────────────────────────────── + +describe("ReconciliationService.repair", () => { + it("repairs missing_on_chain by updating local status to released", async () => { + const records = [makeRecord({ taskId: "t1", nodeId: "n1", status: "locked" })]; + const paymentDb = makePaymentDb(records); + const service = new ReconciliationService({ + paymentDb, + onChainProvider: makeOnChainProvider([]), // no on-chain balance → missing_on_chain + reportStore: makeReportStore().store, + }); + + const report = await service.repair("manual"); + + expect(report.discrepancies).toHaveLength(1); + expect(report.discrepancies[0].type).toBe("missing_on_chain"); + // Local record should now be "released" + const updated = paymentDb.findByKey("t1", "n1"); + expect(updated?.status).toBe("released"); + expect(updated?.txHash).toBe("reconciled-repair"); + }); + + it("repairs amount_mismatch when on-chain balance is 0", async () => { + const records = [makeRecord({ taskId: "t2", nodeId: "n2", status: "locked" })]; + const paymentDb = makePaymentDb(records); + const service = new ReconciliationService({ + paymentDb, + onChainProvider: makeOnChainProvider([ + makeBalance({ balanceId: "cb-local-1", amountStroops: "0" }), + ]), + reportStore: makeReportStore().store, + }); + + const report = await service.repair("manual"); + + expect(report.discrepancies).toHaveLength(1); + expect(report.discrepancies[0].type).toBe("amount_mismatch"); + const updated = paymentDb.findByKey("t2", "n2"); + expect(updated?.status).toBe("released"); + }); + + it("does not auto-repair missing_local discrepancies", async () => { + const paymentDb = makePaymentDb([]); + const service = new ReconciliationService({ + paymentDb, + onChainProvider: makeOnChainProvider([makeBalance()]), + reportStore: makeReportStore().store, + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, + }); + + const report = await service.repair("manual"); + + expect(report.discrepancies).toHaveLength(1); + expect(report.discrepancies[0].type).toBe("missing_local"); + // No local records to update + }); + + it("repair is idempotent — running twice produces the same result", async () => { + const records = [makeRecord({ taskId: "t3", nodeId: "n3", status: "locked" })]; + const paymentDb = makePaymentDb(records); + const service = new ReconciliationService({ + paymentDb, + onChainProvider: makeOnChainProvider([]), + reportStore: makeReportStore().store, + }); + + const report1 = await service.repair("manual"); + const report2 = await service.repair("manual"); + + // Second run: the record is already released, so no missing_on_chain discrepancy + expect(report1.discrepancies).toHaveLength(1); + expect(report2.discrepancies).toHaveLength(0); + expect(report2.status).toBe("consistent"); + }); + + it("returns consistent report when no discrepancies exist", async () => { + const paymentDb = makePaymentDb([makeRecord()]); + const service = new ReconciliationService({ + paymentDb, + onChainProvider: makeOnChainProvider([ + makeBalance({ balanceId: "cb-local-1" }), + ]), + reportStore: makeReportStore().store, + }); + + const report = await service.repair("manual"); + + expect(report.status).toBe("consistent"); + expect(report.discrepancies).toHaveLength(0); + }); +}); + +// ─── Frequent scheduling (5-minute drift detection) ─────────────────────────── + +describe("ReconciliationService.startFrequent", () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("starts a 5-minute interval scheduler", async () => { + const paymentDb = makePaymentDb([]); + const service = new ReconciliationService({ + paymentDb, + onChainProvider: makeOnChainProvider([]), + reportStore: makeReportStore().store, + }); + const runSpy = jest.spyOn(service, "run").mockResolvedValue({ + id: "r-1", + runAt: new Date().toISOString(), + triggeredBy: "scheduled", + status: "consistent", + summary: { + totalLocalRecords: 0, + totalOnChainBalances: 0, + matched: 0, + discrepancies: 0, + missingOnChain: 0, + missingLocal: 0, + amountMismatch: 0, + }, + discrepancies: [], + }); + + service.startFrequent(300_000); + + // Advance 5 minutes → should trigger one run + jest.advanceTimersByTime(300_000); + await Promise.resolve(); // flush microtasks + + expect(runSpy).toHaveBeenCalledWith("scheduled"); + + service.stop(); + }); + + it("stop() cancels the scheduler", () => { + const service = new ReconciliationService({ + paymentDb: makePaymentDb(), + onChainProvider: makeOnChainProvider([]), + reportStore: makeReportStore().store, + }); + service.startFrequent(300_000); + service.stop(); + + // No timer active after stop + expect((service as any).timer).toBeNull(); + }); }); \ No newline at end of file diff --git a/backend/src/services/reconciliation.ts b/backend/src/services/reconciliation.ts index f0e51b74..f03370b8 100644 --- a/backend/src/services/reconciliation.ts +++ b/backend/src/services/reconciliation.ts @@ -25,6 +25,7 @@ import type { const log = createLogger({ component: 'reconciliation' }); const DEFAULT_DAILY_INTERVAL_MS = 86_400_000; // 24h +const DEFAULT_FREQUENT_INTERVAL_MS = 300_000; // 5 minutes — drift detection SLA const LIST_BALANCES_MAX_PAGES = 10; const LIST_BALANCES_PAGE_LIMIT = 200; @@ -318,13 +319,75 @@ export class ReconciliationService { this.logger.info({ intervalMs }, 'Daily reconciliation scheduled'); } + /** Schedule automated (frequent) reconciliation runs for drift detection (default 5 min). */ + startFrequent(intervalMs: number = DEFAULT_FREQUENT_INTERVAL_MS): void { + if (this.timer) return; + const tick = async () => { + try { + await this.run('scheduled'); + } catch (err) { + this.logger.error({ err }, 'Frequent reconciliation run failed'); + } + }; + this.timer = setInterval(tick, intervalMs); + this.timer.unref?.(); + this.logger.info({ intervalMs }, 'Frequent reconciliation scheduled'); + } + /** Stop the automated scheduler. */ stop(): void { if (this.timer) { clearInterval(this.timer); this.timer = null; - this.logger.info('Daily reconciliation stopped'); + this.logger.info('Reconciliation scheduler stopped'); + } + } + + /** + * Idempotent repair: for each discrepancy, update the local DB to match + * the on-chain reality. Safe to re-run — produces the same result. + * + * Repair actions: + * - `missing_on_chain` with status `locked` → mark as `released` (balance was claimed externally) + * - `amount_mismatch` with status `locked` and on-chain balance < local → mark as `released` (partial claim) + * - `missing_local` → no action (on-chain-only balances have no local source) + * - All other types → logged, no action (manual review needed) + */ + async repair(triggeredBy: ReconciliationTrigger = 'manual'): Promise { + const report = await this.run(triggeredBy); + if (report.status === 'consistent') return report; + + let repaired = 0; + for (const d of report.discrepancies) { + if (d.type === 'missing_on_chain' && d.taskId && d.nodeId) { + // Balance was claimed on-chain but local still says locked → sync to released + this.paymentDb.updateStatus(d.taskId, d.nodeId, 'released', 'reconciled-repair'); + repaired++; + this.logger.info( + { balanceId: d.balanceId, taskId: d.taskId, nodeId: d.nodeId }, + 'Repair: updated local status locked → released (missing_on_chain)' + ); + } else if (d.type === 'amount_mismatch' && d.taskId && d.nodeId && d.onChainAmountStroops === '0') { + // On-chain balance is 0 but local says locked → was claimed, sync status + this.paymentDb.updateStatus(d.taskId, d.nodeId, 'released', 'reconciled-repair'); + repaired++; + this.logger.info( + { balanceId: d.balanceId, taskId: d.taskId, nodeId: d.nodeId }, + 'Repair: updated local status locked → released (amount_mismatch, on-chain=0)' + ); + } else { + this.logger.warn( + { discrepancy: d }, + 'Repair: discrepancy requires manual review — no automatic repair applied' + ); + } } + + if (repaired > 0) { + this.logger.info({ repaired, total: report.discrepancies.length }, 'Reconciliation repair complete'); + } + + return report; } /** diff --git a/docs/SECURITY_CHECKLIST.md b/docs/SECURITY_CHECKLIST.md new file mode 100644 index 00000000..5ec0d158 --- /dev/null +++ b/docs/SECURITY_CHECKLIST.md @@ -0,0 +1,76 @@ +# Security Checklist — Release Gate + +**Every item must be checked before tagging a release.** +Unchecked items must have a tracked issue with owner and ETA. + +--- + +## Pre-Release Gate + +- [ ] **T1** — Threat model reviewed: `docs/THREAT_MODEL.md` exists and covers all contracts +- [ ] **T2** — No hardcoded secrets: `STELLAR_SECRET_KEY`, `VENICE_API_KEY`, DB passwords absent from source +- [ ] **T3** — All environment variables validated at startup (fail-fast on missing required vars) +- [ ] **T4** — Input validation: all API endpoints use Zod/schema validation on request body +- [ ] **T5** — SQL injection prevention: all queries use parameterized statements (no string concatenation) +- [ ] **T6** — XSS prevention: frontend HTML is escaped; no `dangerouslySetInnerHTML` on untrusted content +- [ ] **T7** — CSRF: SameSite=Strict cookies on all auth tokens +- [ ] **T8** — Rate limiting: all API endpoints have rate limits configured +- [ ] **T9** — Error messages: no stack traces or internal details leaked to clients +- [ ] **T10** — Logging: no secrets or PII in log output + +## Smart Contracts + +- [ ] **S1** — Coordinator: `CyclicDAGError` thrown for circular dependency detection +- [ ] **S2** — Coordinator: `handleAgentFailure` retries with fallback agents correctly +- [ ] **S3** — Coordinator: `MAX_RETRIES` (3) enforced per agent before failover +- [ ] **S4** — Payment: `lockEscrow` rejects taskId > 28 bytes (Stellar Memo limit) +- [ ] **S5** — Payment: `releasePayment` atomic claim+pay (single Stellar transaction) +- [ ] **S6** — Payment: `EscrowAlreadySettledError` thrown for double-release attempts +- [ ] **S7** — Payment: `getEscrowBalance` returns 0 for settled balances (not a false positive) +- [ ] **S8** — Registry: `clearRegistry` provides test isolation (no cross-test contamination) + +## Fault Injection Tests + +- [ ] **F1** — Agent crash: node retries with fallback agent, task completes +- [ ] **F2** — All agents for type fail: node marked failed, dependent nodes cascade-fail +- [ ] **F3** — Provider timeout: abort triggers retry, then fallback +- [ ] **F4** — Horizon 429/504: exponential backoff retries, then fails cleanly +- [ ] **F5** — Horizon 404 on release: `EscrowAlreadySettledError` thrown (not silent) +- [ ] **F6** — Horizon 404 on refund: `EscrowAlreadySettledError` thrown (not silent) + +## Reconciliation + +- [ ] **R1** — Drift detection runs on configurable interval (default 5 minutes) +- [ ] **R2** — DB records vs on-chain claimable balance status compared +- [ ] **R3** — Drift events logged with full diff (DB status, on-chain status, timestamp) +- [ ] **R4** — Idempotent repair: re-running reconciliation produces same result +- [ ] **R5** — Alert emitted when drift detected (event bus or webhook) + +## i18n + +- [ ] **I1** — Zero untranslated UI strings (en/zh parity test passes in CI) +- [ ] **I2** — Language switcher persists choice to localStorage +- [ ] **I3** — `` attribute synced on language change +- [ ] **I4** — `zh-CN` resolves to `zh` bundle (languageOnly mode active) + +## CI/CD + +- [ ] **C1** — All tests pass (unit + integration + fault injection) +- [ ] **C2** — Linter passes with zero warnings +- [ ] **C3** — TypeScript compilation succeeds with zero errors +- [ ] **C4** — Test coverage >= 80% for new code +- [ ] **C5** — No new `console.log` or `debug` statements in committed code + +--- + +## Sign-Off + +| Item | Owner | Status | Date | +|------|-------|--------|------| +| Threat model | | | | +| Fault injection | | | | +| Reconciliation | | | | +| i18n parity | | | | +| Full checklist | | | | + +**Release blocked** if any CRITICAL or HIGH item is unchecked. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md new file mode 100644 index 00000000..288fc6c5 --- /dev/null +++ b/docs/THREAT_MODEL.md @@ -0,0 +1,110 @@ +# Threat Model — ai-net + +**Last updated:** 2026-08-29 +**Scope:** smart-contracts (coordinator, payment, registry), backend services, frontend + +--- + +## 1. Trust Boundaries + +| Boundary | Trust Level | Notes | +|----------|-------------|-------| +| **User Browser** → Backend API | Low | All input untrusted; validate + sanitize | +| **Backend API** → Stellar Horizon | Medium | Requires valid keypair; Horizon may be slow/malicious | +| **Backend API** → Agent HTTP endpoints | Low | Agents are external; responses may be malformed or hostile | +| **Coordinator** → Payment Service | High | Internal; same process, but follows defense-in-depth | +| **Coordinator** → DB (SQLite) | High | Local file; WAL mode; no network access | +| **Registry** → Agent Registration | Low | Anyone can register; reputation gated by coordinator | +| **Frontend** → Backend API | Low | Wallet-signed requests; CSRF via SameSite cookies | + +--- + +## 2. Access-Control Matrix + +| Component | Can Sign Tx | Can Claim Balance | Can Read DB | Can Write DB | Can Deregister Agents | +|-----------|-------------|-------------------|-------------|--------------|----------------------| +| Coordinator | Yes (keypair) | Yes (sole claimant) | Yes | Yes | Yes | +| Agent | No | No | No | No | No | +| User/Browser | No (wallet signs) | No | No | No | No | +| Payment Service | Yes (via coordinator) | Yes (via coordinator) | Yes (payment DB) | Yes (payment DB) | No | + +--- + +## 3. Reentrancy & Replay Notes + +### 3.1 Stellar Claimable Balances +- **Reentrancy risk:** LOW. `releasePayment` atomically claims + pays in a single Stellar transaction. No intermediate state is observable. +- **Replay protection:** Stellar transactions include `sequence` numbers. Each tx is unique. The `setTimeout(180)` envelope prevents stale tx submission. +- **Double-release protection:** `EscrowAlreadySettledError` thrown if balance already claimed. Horizon returns 404 for non-existent balances. + +### 3.2 Task Execution +- **DAG execution:** Each node runs once. Topological sort ensures dependency ordering. `handleAgentFailure` retries with fallback agents (max 3 per agent, tries all registered). +- **Concurrent task execution:** `ConcurrencyLimiter` caps parallel nodes (default 3). No shared mutable state between concurrent node executions. + +### 3.3 Coordinator Dispatch +- **Replay:** HTTP requests to agents include `X-Correlation-ID` for tracing. Agent idempotency is the agent's responsibility. +- **Timeout handling:** `AbortController` enforces `timeoutMs`. Aborted requests throw `RetryableAgentError`. + +--- + +## 4. Storage-Rent Risks (Soroban Migration) + +| Data | Current | Soroban Risk | Mitigation | +|------|---------|--------------|------------| +| Task metadata | In-memory / DB | High — Soroban charges rent for storage | TTL-based expiry (`ttlDays`); `store_task_metadata` accepts TTL | +| Agent registry | In-memory with TTL | Medium — agents re-register periodically | 30s TTL cache; expired entries pruned | +| Payment records | SQLite | N/A (off-chain) | Already off-chain; on-chain is Stellar claimable balances | +| Execution traces | Filesystem (`logs/tasks/`) | N/A | Cron cleanup; not on-chain | + +**Known limitation:** In-memory registry is ephemeral. On crash, all registrations lost. Soroban migration will fix this. + +--- + +## 5. Known Limitations + +1. **In-memory registry** — Lost on restart. Agents must re-register. No persistence across process restarts. +2. **Coordinator keypair in env** — `STELLAR_SECRET_KEY` in environment variables. If compromised, full escrow control. Mitigation: use a signing service / HSM in production. +3. **Memo.text 28-byte limit** — Task IDs longer than 28 bytes (UTF-8) cannot be used as Stellar memos. `lockEscrow` enforces this. +4. **1e-7 precision bug** — `xlmToStroops(1e-7)` throws because JS renders small numbers in scientific notation. Only affects bare `number` inputs; string `'0.0000001'` works. +5. **Retry cap** — `handleAgentFailure` retries up to 3 times per agent. If all agents for a type are exhausted, the node fails permanently. No infinite retry. +6. **Horizon dependency** — Payment operations depend on Stellar Horizon availability. Exponential backoff retries on 429/504, but prolonged outages will cascade. + +--- + +## 6. Audit Slots + +Each contract/module has a designated audit focus area: + +| Module | Audit Focus | Key Questions | +|--------|-------------|---------------| +| **Coordinator** | DAG execution correctness | Is topological sort correct? Can failure cascade be exploited? | +| **Payment** | Escrow atomicity | Can double-claim occur? Are refund/release race conditions safe? | +| **Registry** | Access control | Can unregistered agents be assigned tasks? Is reputation manipulation possible? | +| **Backend API** | Input validation | Are all endpoints schema-validated? Is rate limiting enforced? | +| **Frontend** | XSS / wallet security | Are secret keys handled safely? Is CSP configured? | + +--- + +## 7. Release Gating + +**Before tagging a release:** +1. All items in `SECURITY_CHECKLIST.md` must be checked. +2. No CRITICAL or HIGH security findings from the checklist are open. +3. Fault injection tests pass in CI. +4. Reconciliation job detects drift within 5 minutes. +5. i18n parity test confirms en/zh key equality. + +--- + +## Appendix: Contract-Level Audit Checklist + +- [ ] Coordinator: `CyclicDAGError` thrown for circular deps +- [ ] Coordinator: `handleAgentFailure` retries correctly across agents +- [ ] Coordinator: `MAX_RETRIES` enforced per agent +- [ ] Payment: `lockEscrow` memo size validated +- [ ] Payment: `releasePayment` atomic claim+pay +- [ ] Payment: `refundEscrow` idempotent (returns same tx or throws) +- [ ] Payment: `getEscrowBalance` returns 0 for settled balances +- [ ] Registry: `clearRegistry` isolates tests +- [ ] Registry: Composite index sorted correctly +- [ ] Registry: TTL expiry removes stale agents diff --git a/frontend/src/i18n/parity.test.ts b/frontend/src/i18n/parity.test.ts new file mode 100644 index 00000000..5f79851b --- /dev/null +++ b/frontend/src/i18n/parity.test.ts @@ -0,0 +1,72 @@ +/** + * i18n parity test — ensures en and zh bundles have identical key sets. + * + * Fails CI if: + * - A key exists in en.json but not zh.json (missing translation) + * - A key exists in zh.json but not en.json (stale translation) + * + * This guarantees zero untranslated UI strings in both directions. + */ +import { describe, it, expect } from 'vitest' +import en from './locales/en.json' +import zh from './locales/zh.json' + +type NestedKeys = T extends Record + ? { + [K in keyof T & string]: T[K] extends Record + ? NestedKeys + : `${Prefix}${K}` + }[keyof T & string] + : never + +function flattenKeys(obj: Record, prefix = ''): string[] { + const keys: string[] = [] + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + keys.push(...flattenKeys(value as Record, fullKey)) + } else { + keys.push(fullKey) + } + } + return keys +} + +describe('i18n parity · en ↔ zh', () => { + const enKeys = flattenKeys(en as Record).sort() + const zhKeys = flattenKeys(zh as Record).sort() + + it('en.json has no keys missing from zh.json', () => { + const missingInZh = enKeys.filter((k) => !zhKeys.includes(k)) + expect(missingInZh).toEqual([]) + }) + + it('zh.json has no keys missing from en.json', () => { + const missingInEn = zhKeys.filter((k) => !enKeys.includes(k)) + expect(missingInEn).toEqual([]) + }) + + it('both bundles have the same number of keys', () => { + expect(zhKeys.length).toBe(enKeys.length) + }) + + it('no duplicate keys within en.json', () => { + const seen = new Set() + const duplicates: string[] = [] + for (const key of enKeys) { + if (seen.has(key)) duplicates.push(key) + seen.add(key) + } + expect(duplicates).toEqual([]) + }) + + it('no duplicate keys within zh.json', () => { + const seen = new Set() + const duplicates: string[] = [] + for (const key of zhKeys) { + if (seen.has(key)) duplicates.push(key) + seen.add(key) + } + expect(duplicates).toEqual([]) + }) +}) diff --git a/smart-contracts/src/registry/registry.ts b/smart-contracts/src/registry/registry.ts index e945a13f..26101b38 100644 --- a/smart-contracts/src/registry/registry.ts +++ b/smart-contracts/src/registry/registry.ts @@ -189,65 +189,16 @@ export function deregisterAgent(id: string): boolean { if (!store.has(id)) return false; unindexAgent(id); return store.delete(id); -const TTL_MS = 30_000; - -interface CacheEntry { - agent: Agent; - registeredAt: number; -} - -const agents = new Map(); - -function isExpired(entry: CacheEntry): boolean { - return Date.now() - entry.registeredAt > TTL_MS; -} - -function pruneExpired(): void { - for (const [id, entry] of agents) { - if (isExpired(entry)) { - agents.delete(id); - } - } -} - -export function registerAgent(agent: Agent): Agent { - agents.set(agent.id, { agent, registeredAt: Date.now() }); - return agent; -} - -export function discoverAgents(capability: string): Agent[] { - pruneExpired(); - return Array.from(agents.values()) - .filter((e) => e.agent.capability === capability) - .map((e) => e.agent); -} - -export function getAgent(id: string): Agent | undefined { - pruneExpired(); - const entry = agents.get(id); - if (!entry) return undefined; - if (isExpired(entry)) { - agents.delete(id); - return undefined; - } - return entry.agent; -} - -export function lookupAgent(id: string): Agent | undefined { - return getAgent(id); -} - -export function deregisterAgent(id: string): boolean { - return agents.delete(id); } -export function updatePricing(id: string, priceXLM: number): Agent | undefined { - pruneExpired(); - const entry = agents.get(id); - if (!entry) return undefined; - const updated = { ...entry.agent, priceXLM }; - agents.set(id, { ...entry, agent: updated }); - return updated; +export function updatePricing(id: string, priceXLM: number): boolean { + const agent = store.get(id); + if (!agent) return false; + unindexAgent(id); + const updated: AgentRecord = { ...agent, priceXLM }; + store.set(id, updated); + indexAgent(updated); + return true; } /** diff --git a/smart-contracts/tests/coordinator.fault-injection.test.ts b/smart-contracts/tests/coordinator.fault-injection.test.ts new file mode 100644 index 00000000..bebb2c1d --- /dev/null +++ b/smart-contracts/tests/coordinator.fault-injection.test.ts @@ -0,0 +1,244 @@ +/** + * Fault-injection tests for the smart-contracts coordinator layer. + * + * Tests that injected failures (agent crash, provider timeout, delivery fail) + * produce correct resume/fallback behavior. + * + * Each injected fault has an asserting test — see THREAT_MODEL.md §3 + * and SECURITY_CHECKLIST.md §F. + */ +import axios from 'axios'; +import * as fs from 'fs'; +import { + decomposeTask, + assignAgents, + executeDAG, + handleAgentFailure, + CyclicDAGError, + DAGNode, +} from '../src/coordinator/coordinator'; +import { registerAgent, clearRegistry } from '../src/registry/registry'; + +jest.mock('axios'); +jest.mock('fs', () => ({ mkdirSync: jest.fn(), writeFileSync: jest.fn() })); + +const mockedAxios = axios as jest.Mocked; +const mockedFs = fs as jest.Mocked; + +beforeEach(() => clearRegistry()); + +function makeAgent(overrides: { id: string; name: string; capability: string; priceXLM: number; stellarAddress?: string; reputationScore?: number }) { + return { + stellarAddress: '', + reputationScore: 1, + ...overrides, + }; +} + +// ── F1: Agent crash → retry with next cheapest agent ────────────────────────── + +describe('F1 · agent crash → fallback to next agent', () => { + it('retries with the next-cheapest agent when the primary crashes', async () => { + registerAgent(makeAgent({ id: 'crasher', name: 'Crasher', capability: 'research', priceXLM: 1 })); + registerAgent(makeAgent({ id: 'reliable', name: 'Reliable', capability: 'research', priceXLM: 2 })); + + const dag: DAGNode[] = [{ id: 'n1', taskType: 'research', dependsOn: [], status: 'pending' }]; + assignAgents(dag); + + let callCount = 0; + const runNode = async () => { + callCount++; + if (callCount === 1) throw new Error('agent crashed'); + return { result: 'fallback-ok' }; + }; + + await handleAgentFailure(dag[0], {}, runNode); + + expect(callCount).toBe(2); + expect(dag[0].status).toBe('done'); + expect(dag[0].assignedAgent).toBe('reliable'); + }); +}); + +// ── F2: All agents fail → node marked failed, dependent nodes cascade-fail ───── + +describe('F2 · all agents fail → node and dependency cascade', () => { + it('marks node failed when every agent throws', async () => { + registerAgent(makeAgent({ id: 'a1', name: 'A1', capability: 'risk', priceXLM: 1 })); + registerAgent(makeAgent({ id: 'a2', name: 'A2', capability: 'risk', priceXLM: 2 })); + registerAgent(makeAgent({ id: 'a3', name: 'A3', capability: 'risk', priceXLM: 3 })); + + const node: DAGNode = { id: 'n1', taskType: 'risk', dependsOn: [], status: 'running' }; + const runNode = async () => { throw new Error('all agents down'); }; + + await handleAgentFailure(node, {}, runNode); + + expect(node.status).toBe('failed'); + }); + + it('cascades failure to dependent nodes in executeDAG', async () => { + const dag: DAGNode[] = [ + { id: 'n1', taskType: 'research', dependsOn: [], status: 'pending' }, + { id: 'n2', taskType: 'risk', dependsOn: ['n1'], status: 'pending' }, + ]; + + // No agents registered → all fail immediately + const runNode = async () => { throw new Error('no agents'); }; + + const results = await executeDAG(dag, 'cascade-test', runNode); + + expect(dag[0].status).toBe('failed'); + expect(dag[1].status).toBe('failed'); + expect(results).toEqual({}); + }); +}); + +// ── F3: Provider timeout → abort + retry + fallback ─────────────────────────── + +describe('F3 · provider timeout → retry then fallback', () => { + it('retries on timeout and falls back to another agent', async () => { + registerAgent(makeAgent({ id: 'slow', name: 'Slow', capability: 'coding', priceXLM: 1 })); + registerAgent(makeAgent({ id: 'fast', name: 'Fast', capability: 'coding', priceXLM: 2 })); + + const dag: DAGNode[] = [{ id: 'n1', taskType: 'coding', dependsOn: [], status: 'pending' }]; + assignAgents(dag); + + let callCount = 0; + const runNode = async () => { + callCount++; + if (callCount <= 2) { + throw new Error('Agent slow timed out after 30000ms'); + } + return { code: 'fn fast() {}' }; + }; + + await handleAgentFailure(dag[0], {}, runNode); + + // Mock must have been called (at least 2 failures before success) + expect(callCount).toBeGreaterThanOrEqual(2); + }); +}); + +// ── F4: HTTP 500 from agent → retryable error ───────────────────────────────── + +describe('F4 · HTTP 500 from agent → retryable', () => { + it('retries after HTTP 500 and succeeds on fallback', async () => { + registerAgent(makeAgent({ id: 'flaky', name: 'Flaky', capability: 'design', priceXLM: 1 })); + registerAgent(makeAgent({ id: 'solid', name: 'Solid', capability: 'design', priceXLM: 2 })); + + const dag: DAGNode[] = [{ id: 'n1', taskType: 'design', dependsOn: [], status: 'pending' }]; + assignAgents(dag); + + let callCount = 0; + const runNode = async () => { + callCount++; + if (callCount <= 2) { + const err = new Error('Internal Server Error'); + (err as any).response = { status: 500 }; + throw err; + } + return { design: 'wireframe' }; + }; + + await handleAgentFailure(dag[0], {}, runNode); + + // Mock must have been called (at least 2 failures before success) + expect(callCount).toBeGreaterThanOrEqual(2); + }); +}); + +// ── F5: Registry returns 0 agents → immediate fail ──────────────────────────── + +describe('F5 · registry empty → immediate node failure', () => { + it('marks node failed when no agents registered for taskType', async () => { + const node: DAGNode = { id: 'n1', taskType: 'report', dependsOn: [], status: 'running' }; + + await handleAgentFailure(node, {}, async () => ({})); + + expect(node.status).toBe('failed'); + }); +}); + +// ── F6: Cyclic dependency → CyclicDAGError ──────────────────────────────────── + +describe('F6 · cyclic dependency → CyclicDAGError', () => { + it('throws CyclicDAGError on circular DAG', async () => { + const dag: DAGNode[] = [ + { id: 'a', taskType: 'research', dependsOn: ['b'], status: 'pending' }, + { id: 'b', taskType: 'risk', dependsOn: ['a'], status: 'pending' }, + ]; + + await expect(executeDAG(dag, 'cyclic-fault')).rejects.toBeInstanceOf(CyclicDAGError); + }); +}); + +// ── F7: Mixed success/failure → partial results ─────────────────────────────── + +describe('F7 · mixed success/failure → partial DAG results', () => { + it('returns results for successful nodes only', async () => { + registerAgent(makeAgent({ id: 'r1', name: 'R1', capability: 'research', priceXLM: 1 })); + registerAgent(makeAgent({ id: 'c1', name: 'C1', capability: 'coding', priceXLM: 1 })); + + const dag: DAGNode[] = [ + { id: 'n1', taskType: 'research', dependsOn: [], status: 'pending' }, + { id: 'n2', taskType: 'coding', dependsOn: [], status: 'pending' }, + ]; + + let callCount = 0; + const runNode = async (node: DAGNode) => { + callCount++; + if (node.taskType === 'coding') throw new Error('coding agent crashed'); + return { research: 'done' }; + }; + + const results = await executeDAG(dag, 'partial-test', runNode); + + expect(dag[0].status).toBe('done'); + expect(dag[1].status).toBe('failed'); + expect(results).toHaveProperty('n1'); + expect(results).not.toHaveProperty('n2'); + }); +}); + +// ── F8: decomposeTask failure → propagates error ────────────────────────────── + +describe('F8 · decomposeTask API failure → error propagation', () => { + it('throws when Venice API is unreachable', async () => { + mockedAxios.post.mockRejectedValueOnce(new Error('Network Error')); + + await expect(decomposeTask('test prompt')).rejects.toThrow('Network Error'); + }); + + it('throws when Venice API returns malformed JSON', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { choices: [{ message: { content: 'NOT JSON' } }] }, + }); + + await expect(decomposeTask('test prompt')).rejects.toThrow(); + }); +}); + +// ── F9: Execution trace persisted even on failure ───────────────────────────── + +describe('F9 · trace persisted on DAG failure', () => { + it('writes trace JSON even when nodes fail', async () => { + registerAgent(makeAgent({ id: 'r1', name: 'R1', capability: 'research', priceXLM: 1 })); + + const dag: DAGNode[] = [ + { id: 'n1', taskType: 'research', dependsOn: [], status: 'pending' }, + ]; + + const runNode = async () => { throw new Error('crash'); }; + + await executeDAG(dag, 'trace-fail-test', runNode); + + expect(mockedFs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('trace-fail-test.json'), + expect.any(String), + ); + + const writtenData = mockedFs.writeFileSync.mock.calls[0][1] as string; + const parsed = JSON.parse(writtenData); + expect(parsed[0].status).toBe('failed'); + }); +}); diff --git a/smart-contracts/tests/payment.fault-injection.test.ts b/smart-contracts/tests/payment.fault-injection.test.ts new file mode 100644 index 00000000..e6485a1f --- /dev/null +++ b/smart-contracts/tests/payment.fault-injection.test.ts @@ -0,0 +1,350 @@ +/** + * Fault-injection tests for the payment layer. + * + * Injects Horizon failures (429, 504, 404, network errors) and asserts + * correct resume/fallback behavior. + * + * Each injected fault has an asserting test — see SECURITY_CHECKLIST.md §F4-F6. + */ +import { + Keypair, + Asset, + Operation, + TransactionBuilder, + Networks, + Claimant, + Memo, + NotFoundError, + Account, +} from '@stellar/stellar-sdk'; +import { + lockEscrow, + releasePayment, + refundEscrow, + getEscrowBalance, + EscrowAlreadySettledError, + xlmToStroops, + stroopsToXlm, +} from '../src/payment/payment'; + +jest.setTimeout(180000); + +const mockLoadAccount = jest.fn(); +const mockFetchBaseFee = jest.fn(); +const mockTransactionsCall = jest.fn(); +const mockClaimableBalanceCall = jest.fn(); +const mockSubmitTransaction = jest.fn(); + +jest.mock('@stellar/stellar-sdk', () => { + const actual = jest.requireActual('@stellar/stellar-sdk'); + return { + ...actual, + Horizon: { + ...actual.Horizon, + Server: jest.fn().mockImplementation(() => ({ + loadAccount: mockLoadAccount, + fetchBaseFee: mockFetchBaseFee, + submitTransaction: mockSubmitTransaction, + transactions: jest.fn().mockReturnValue({ + forAccount: jest.fn().mockReturnValue({ + order: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + call: mockTransactionsCall, + }), + }), + }), + }), + claimableBalances: jest.fn().mockReturnValue({ + claimableBalance: jest.fn().mockReturnValue({ + call: mockClaimableBalanceCall, + }), + }), + })), + }, + }; +}); + +let coordinatorKeypair: Keypair; +let agentKeypair: Keypair; +let taskId: string; +let envelopeXdr: string; +let originalSetTimeout: any; + +beforeAll(() => { + coordinatorKeypair = Keypair.random(); + agentKeypair = Keypair.random(); + taskId = 'fault_inject_task'; + + const account = new Account(coordinatorKeypair.publicKey(), '123'); + const claimants = [ + new Claimant(coordinatorKeypair.publicKey(), Claimant.predicateUnconditional()), + ]; + const tx = new TransactionBuilder(account, { + fee: '100', + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + Operation.createClaimableBalance({ + asset: Asset.native(), + amount: '10.0000000', + claimants, + }) + ) + .addMemo(Memo.text(taskId)) + .setTimeout(180) + .build(); + tx.sign(coordinatorKeypair); + envelopeXdr = tx.toEnvelope().toXDR('base64'); + + originalSetTimeout = global.setTimeout; + // @ts-ignore + global.setTimeout = (fn: any) => fn(); +}); + +afterAll(() => { + global.setTimeout = originalSetTimeout; +}); + +beforeEach(() => { + mockLoadAccount.mockReset(); + mockFetchBaseFee.mockReset(); + mockSubmitTransaction.mockReset(); + mockTransactionsCall.mockReset(); + mockClaimableBalanceCall.mockReset(); + + mockLoadAccount.mockImplementation((pubkey: string) => + Promise.resolve(new Account(pubkey, '123')) + ); + mockFetchBaseFee.mockResolvedValue(100); + mockSubmitTransaction.mockResolvedValue({ hash: 'mock_tx_hash' }); + mockTransactionsCall.mockResolvedValue({ records: [] }); + mockClaimableBalanceCall.mockResolvedValue({ amount: '10.0000000' }); + + process.env.STELLAR_NETWORK = 'testnet'; +}); + +// ── F4: Horizon 429 → exponential backoff retries ───────────────────────────── + +describe('F4 · Horizon 429 rate limit → retry with backoff', () => { + it('retries 5 times on 429 and then throws', async () => { + const err429 = new Error('Rate limit exceeded'); + (err429 as any).response = { status: 429 }; + mockLoadAccount.mockRejectedValue(err429); + + await expect( + lockEscrow(coordinatorKeypair, agentKeypair.publicKey(), '1', 'task_429') + ).rejects.toThrow(); + + expect(mockLoadAccount).toHaveBeenCalledTimes(5); + }); + + it('succeeds after transient 429 errors resolve', async () => { + const err429 = new Error('Rate limit'); + (err429 as any).response = { status: 429 }; + + mockLoadAccount + .mockRejectedValueOnce(err429) + .mockRejectedValueOnce(err429) + .mockImplementationOnce((pubkey: string) => Promise.resolve(new Account(pubkey, '123'))); + + const hash = await lockEscrow(coordinatorKeypair, agentKeypair.publicKey(), '1', 'task_429_ok'); + + expect(hash).toBe('mock_tx_hash'); + expect(mockLoadAccount).toHaveBeenCalledTimes(3); + }); +}); + +// ── F4b: Horizon 504 → retry then success ──────────────────────────────────── + +describe('F4b · Horizon 504 gateway timeout → retry', () => { + it('succeeds after transient 504 errors', async () => { + const err504 = new Error('Gateway Timeout'); + (err504 as any).response = { status: 504 }; + + mockLoadAccount + .mockRejectedValueOnce(err504) + .mockRejectedValueOnce(err504) + .mockImplementationOnce((pubkey: string) => Promise.resolve(new Account(pubkey, '123'))); + + const hash = await lockEscrow(coordinatorKeypair, agentKeypair.publicKey(), '1', 'task_504'); + + expect(hash).toBe('mock_tx_hash'); + expect(mockLoadAccount).toHaveBeenCalledTimes(3); + }); +}); + +// ── F5: Horizon 404 on release → EscrowAlreadySettledError ──────────────────── + +describe('F5 · Horizon 404 on release → EscrowAlreadySettledError', () => { + it('throws EscrowAlreadySettledError when balance is 404', async () => { + mockTransactionsCall.mockResolvedValueOnce({ + records: [{ memo_type: 'text', memo: taskId, envelope_xdr: envelopeXdr }], + }); + + const err404 = new NotFoundError('Not Found', { + status: 404, + statusText: 'Not Found', + headers: {}, + config: {}, + data: {}, + }); + mockClaimableBalanceCall.mockRejectedValueOnce(err404); + + await expect( + releasePayment(coordinatorKeypair, agentKeypair.publicKey(), taskId) + ).rejects.toThrow(EscrowAlreadySettledError); + }); +}); + +// ── F6: Horizon 404 on refund → EscrowAlreadySettledError ───────────────────── + +describe('F6 · Horizon 404 on refund → EscrowAlreadySettledError', () => { + it('throws EscrowAlreadySettledError when balance is 404', async () => { + mockTransactionsCall.mockResolvedValueOnce({ + records: [{ memo_type: 'text', memo: taskId, envelope_xdr: envelopeXdr }], + }); + + const err404 = new NotFoundError('Not Found', { + status: 404, + statusText: 'Not Found', + headers: {}, + config: {}, + data: {}, + }); + mockClaimableBalanceCall.mockRejectedValueOnce(err404); + + await expect(refundEscrow(coordinatorKeypair, taskId)).rejects.toThrow( + EscrowAlreadySettledError + ); + }); +}); + +// ── F7: Non-404 Horizon error → propagated (not swallowed) ──────────────────── + +describe('F7 · non-404 Horizon errors propagated', () => { + it('releasePayment propagates Horizon internal error', async () => { + mockTransactionsCall.mockResolvedValueOnce({ + records: [{ memo_type: 'text', memo: taskId, envelope_xdr: envelopeXdr }], + }); + mockClaimableBalanceCall.mockRejectedValueOnce(new Error('horizon 500')); + + await expect( + releasePayment(coordinatorKeypair, agentKeypair.publicKey(), taskId) + ).rejects.toThrow('horizon 500'); + }); + + it('refundEscrow propagates Horizon internal error', async () => { + mockTransactionsCall.mockResolvedValueOnce({ + records: [{ memo_type: 'text', memo: taskId, envelope_xdr: envelopeXdr }], + }); + mockClaimableBalanceCall.mockRejectedValueOnce(new Error('horizon 500')); + + await expect(refundEscrow(coordinatorKeypair, taskId)).rejects.toThrow('horizon 500'); + }); +}); + +// ── F8: No creation tx found → descriptive error (not EscrowAlreadySettled) ──── + +describe('F8 · no creation tx → descriptive error', () => { + it('throws when no CreateClaimableBalance tx exists', async () => { + mockTransactionsCall.mockResolvedValueOnce({ records: [] }); + + await expect( + releasePayment(coordinatorKeypair, agentKeypair.publicKey(), 'never_locked') + ).rejects.toThrow(/No CreateClaimableBalance transaction found/); + + expect(mockClaimableBalanceCall).not.toHaveBeenCalled(); + }); +}); + +// ── F9: getEscrowBalance fault scenarios ─────────────────────────────────────── + +describe('F9 · getEscrowBalance fault scenarios', () => { + const originalEnv = process.env; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('throws when neither key env var is set', async () => { + delete process.env.STELLAR_COORDINATOR_PUBLIC_KEY; + delete process.env.STELLAR_SECRET_KEY; + + await expect(getEscrowBalance('any_task')).rejects.toThrow( + 'Either STELLAR_COORDINATOR_PUBLIC_KEY or STELLAR_SECRET_KEY must be set.' + ); + }); + + it('returns 0 when balance is settled (404)', async () => { + process.env.STELLAR_COORDINATOR_PUBLIC_KEY = coordinatorKeypair.publicKey(); + + mockTransactionsCall.mockResolvedValueOnce({ + records: [{ memo_type: 'text', memo: taskId, envelope_xdr: envelopeXdr }], + }); + + const err404 = new NotFoundError('Not Found', { + status: 404, + statusText: 'Not Found', + headers: {}, + config: {}, + data: {}, + }); + mockClaimableBalanceCall.mockRejectedValueOnce(err404); + + await expect(getEscrowBalance(taskId)).resolves.toBe(0); + }); + + it('propagates unexpected non-404 errors', async () => { + process.env.STELLAR_COORDINATOR_PUBLIC_KEY = coordinatorKeypair.publicKey(); + + mockTransactionsCall.mockResolvedValueOnce({ + records: [{ memo_type: 'text', memo: taskId, envelope_xdr: envelopeXdr }], + }); + mockClaimableBalanceCall.mockRejectedValueOnce(new Error('horizon 500')); + + await expect(getEscrowBalance(taskId)).rejects.toThrow('horizon 500'); + }); +}); + +// ── F10: Lock succeeds after transient errors ───────────────────────────────── + +describe('F10 · lockEscrow succeeds after transient errors', () => { + it('returns tx hash after Horizon recovers', async () => { + const err504 = new Error('Gateway Timeout'); + (err504 as any).response = { status: 504 }; + + mockLoadAccount + .mockRejectedValueOnce(err504) + .mockRejectedValueOnce(err504) + .mockImplementationOnce((pubkey: string) => Promise.resolve(new Account(pubkey, '123'))); + + const hash = await lockEscrow( + coordinatorKeypair, + agentKeypair.publicKey(), + '5.0', + 'task_lock_recovery' + ); + + expect(hash).toBe('mock_tx_hash'); + expect(mockSubmitTransaction).toHaveBeenCalledTimes(1); + }); +}); + +// ── F11: Fee fallback when fetchBaseFee fails ───────────────────────────────── + +describe('F11 · fee fallback on fetchBaseFee failure', () => { + it('uses baseFee of 100 stroops when fetchBaseFee throws', async () => { + mockFetchBaseFee.mockRejectedValueOnce(new Error('fee endpoint down')); + + const hash = await lockEscrow( + coordinatorKeypair, + agentKeypair.publicKey(), + '1', + 'task_fee_fallback' + ); + + expect(hash).toBe('mock_tx_hash'); + const submittedTx = mockSubmitTransaction.mock.calls[0][0]; + expect(submittedTx.fee).toBe('100'); + }); +});