diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 7c5fd774..fca0f0d8 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -47,6 +47,9 @@ jobs: NODE_ENV: development run: npm run spec:export + - name: Check for Prisma schema without migration + run: chmod +x app/backend/scripts/check-prisma-migration.sh && app/backend/scripts/check-prisma-migration.sh + - name: Check for OpenAPI spec drift run: | git diff --exit-code app/frontend/openapi.json || \ diff --git a/app/backend/.env.example b/app/backend/.env.example index ab1af48c..6922901a 100644 --- a/app/backend/.env.example +++ b/app/backend/.env.example @@ -13,3 +13,11 @@ TWILIO_FROM_NUMBER="" SENDGRID_API_KEY="" EMAIL_FROM_ADDRESS="no-reply@chainforge.local" EMAIL_FROM_NAME="ChainForge" + +# Evidence Storage (S3/MinIO/GCS compatible) +EVIDENCE_STORAGE_BUCKET="chainforge-evidence" +EVIDENCE_STORAGE_REGION="us-east-1" +EVIDENCE_STORAGE_ACCESS_KEY_ID="" +EVIDENCE_STORAGE_SECRET_ACCESS_KEY="" +EVIDENCE_STORAGE_ENDPOINT="" # For MinIO or custom S3 endpoints +EVIDENCE_STORAGE_FORCE_PATH_STYLE="false" diff --git a/app/backend/package-lock.json b/app/backend/package-lock.json index 6d97c9d4..0a10d196 100644 --- a/app/backend/package-lock.json +++ b/app/backend/package-lock.json @@ -10,6 +10,8 @@ "hasInstallScript": true, "license": "UNLICENSED", "dependencies": { + "@aws-sdk/client-s3": "^3.1088.0", + "@aws-sdk/s3-request-presigner": "^3.1088.0", "@liaoliaots/nestjs-redis": "^10.0.0", "@nestjs/axios": "^4.0.1", "@nestjs/bull": "^11.0.4", @@ -55,6 +57,8 @@ "@types/jest": "^30.0.0", "@types/multer": "^2.1.0", "@types/node": "^22.19.7", + "@types/pg": "^8.20.0", + "@types/redis": "^4.0.10", "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", @@ -205,6 +209,331 @@ "tslib": "^2.1.0" } }, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.18.tgz", + "integrity": "sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1088.0.tgz", + "integrity": "sha512-9ryKKxnjtJRKLDI24P+2gQZki7k28BeV6gz1AySvFDWayhjAZuh4QZjCyx55tqR8Oz0IJxWutgJRO43dDOcXoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.18", + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-node": "^3.972.69", + "@aws-sdk/middleware-sdk-s3": "^3.972.64", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.975.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.3.tgz", + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.36", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.4", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.59.tgz", + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.61", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.61.tgz", + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.3.tgz", + "integrity": "sha512-WpuqYX4gGkx++fCTSWE8+41JzkZVcrI50SH48Ml4CsG1pyuHKyMmpw/FixBHDrmjoQ553PmeCLa/fZIcst+WyA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-login": "^3.972.65", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.65.tgz", + "integrity": "sha512-xr9rgjYEdmC2Tpg2lwt9o+nOEaK9Qpd+dBjzrVCuWWyQfvhO91Ezu0Hh9ts2VUxOZxmS/k5T9msa34e4R1bnrQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.69.tgz", + "integrity": "sha512-wbJGGesd0Tl18bmUcbj1xJ+e7CpuRJ6PIpMywLFuUttGy615lua87cJ0EA8pFpY/QgPuUXbnupWBtSPJ9tyZhg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.59", + "@aws-sdk/credential-provider-http": "^3.972.61", + "@aws-sdk/credential-provider-ini": "^3.973.3", + "@aws-sdk/credential-provider-process": "^3.972.59", + "@aws-sdk/credential-provider-sso": "^3.973.3", + "@aws-sdk/credential-provider-web-identity": "^3.972.65", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/credential-provider-imds": "^4.4.9", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.59.tgz", + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.3.tgz", + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/token-providers": "3.1088.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.65", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.65.tgz", + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.64", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.64.tgz", + "integrity": "sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.33", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.33.tgz", + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/fetch-http-handler": "^5.6.6", + "@smithy/node-http-handler": "^4.9.6", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1088.0.tgz", + "integrity": "sha512-NEv9O5q+IrsDNKPmNhTlZ3hjtomgfL1OEJRCmDSF06dbmZ6IUJfrLQTLO7LWbM4sxvYui1LRAFCdO2TM3Hdq4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/signature-v4-multi-region": "^3.996.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz", + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.5", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1088.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1088.0.tgz", + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.975.3", + "@aws-sdk/nested-clients": "^3.997.33", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.36", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz", + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -2743,6 +3072,83 @@ "@prisma/debug": "6.19.2" } }, + "node_modules/@redis/bloom": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.1.0.tgz", + "integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, + "node_modules/@redis/client": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.1.0.tgz", + "integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.1.0.tgz", + "integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, + "node_modules/@redis/search": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.1.0.tgz", + "integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, + "node_modules/@redis/time-series": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.1.0.tgz", + "integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.1.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "hasInstallScript": true, @@ -2811,6 +3217,87 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@smithy/core": { + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.4.tgz", + "integrity": "sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.9.tgz", + "integrity": "sha512-2nfV4qRKiYeXU4zD2vvSCfg5dfp/BuhrM73vt7q9gzBhxs4rbPxXY21wo+kyI3bRmXcEGRnCLTaW8O437jzHIg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.6", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.6.tgz", + "integrity": "sha512-NHLgAlORUFZjn5ZfhYuyyKMlXA1WLYOdGxEhyNxrPpbJzoacGbl0chn1lN2KiZ8mpNVk0tV5607CSYlYs/OFgw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.6.tgz", + "integrity": "sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.5.tgz", + "integrity": "sha512-MO5VEhwVl0BN7xVoVeNrZfiUFoQtqxUbgl6/RwOTlMMxCSjblG8twSrVTwz3J4w9WZxd2rBfBAUXjH77agspBg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.29.4", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "dev": true, @@ -3139,6 +3626,18 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.15.0", "dev": true, @@ -3149,6 +3648,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/redis": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/@types/redis/-/redis-4.0.10.tgz", + "integrity": "sha512-7CLy5b5fzzEGVcOccgZjoMlNpPhX6d10jEeRy2YWbFuaMNrSPc9ExRsMYsd+0VxvEHucf4EWx24Ja7cSU1FGUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "redis": "*" + } + }, "node_modules/@types/send": { "version": "1.2.1", "dev": true, @@ -4400,6 +4909,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, "node_modules/boxen": { "version": "5.1.2", "license": "MIT", @@ -8810,6 +9325,40 @@ "dev": true, "license": "MIT" }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -9005,6 +9554,49 @@ "node": ">= 0.4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "dev": true, @@ -9274,6 +9866,23 @@ "node": ">= 12.13.0" } }, + "node_modules/redis": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.1.0.tgz", + "integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@redis/bloom": "6.1.0", + "@redis/client": "6.1.0", + "@redis/json": "6.1.0", + "@redis/search": "6.1.0", + "@redis/time-series": "6.1.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, "node_modules/redis-errors": { "version": "1.2.0", "license": "MIT", @@ -11028,6 +11637,16 @@ "node": ">=6.0" } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/app/backend/package.json b/app/backend/package.json index c6a274fb..8bfe9903 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -30,6 +30,8 @@ "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts" }, "dependencies": { + "@aws-sdk/client-s3": "^3.1088.0", + "@aws-sdk/s3-request-presigner": "^3.1088.0", "@liaoliaots/nestjs-redis": "^10.0.0", "@nestjs/axios": "^4.0.1", "@nestjs/bull": "^11.0.4", @@ -75,6 +77,8 @@ "@types/jest": "^30.0.0", "@types/multer": "^2.1.0", "@types/node": "^22.19.7", + "@types/pg": "^8.20.0", + "@types/redis": "^4.0.10", "@types/supertest": "^6.0.3", "@typescript-eslint/eslint-plugin": "^8.53.1", "@typescript-eslint/parser": "^8.53.1", diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma index 4e3567e5..91cf1e12 100644 --- a/app/backend/prisma/schema.prisma +++ b/app/backend/prisma/schema.prisma @@ -437,8 +437,9 @@ enum EvidenceStatus { model EvidenceQueueItem { id String @id @default(cuid()) fileName String - filePath String? // Path to local encrypted file - fileHash String @unique // To prevent duplicate uploads globally + filePath String? // Path to local encrypted file (for backward compatibility) + storageKey String? // Key in external storage (S3, MinIO, GCS) + fileHash String? @unique // To prevent duplicate uploads globally (nullable for backward compatibility) fingerprint String? // Stable fingerprint for near-duplicate detection mimeType String size Int diff --git a/app/backend/scripts/check-prisma-migration.sh b/app/backend/scripts/check-prisma-migration.sh new file mode 100644 index 00000000..28ce0829 --- /dev/null +++ b/app/backend/scripts/check-prisma-migration.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +# Check for changes to schema.prisma without corresponding migrations + +SCHEMA_FILE="app/backend/prisma/schema.prisma" +MIGRATIONS_DIR="app/backend/prisma/migrations" + +# First, check if we're in a git repo +if [ ! -d .git ]; then + echo "Not a git repository" + exit 1 +fi + +# For PRs: compare base and head +if [ -n "$GITHUB_BASE_REF" ] && [ -n "$GITHUB_HEAD_REF" ]; then + echo "Checking PR: comparing $GITHUB_BASE_REF to $GITHUB_HEAD_REF" + git fetch origin $GITHUB_BASE_REF $GITHUB_HEAD_REF + BASE_SHA=$(git rev-parse origin/$GITHUB_BASE_REF) + HEAD_SHA=$(git rev-parse HEAD) +else + # For pushes, check current state vs previous commit + echo "Checking push: comparing HEAD~1 to HEAD" + BASE_SHA=HEAD~1 + HEAD_SHA=HEAD +fi + +# Check if schema.prisma has changed +SCHEMA_CHANGED=$(git diff --name-only $BASE_SHA $HEAD_SHA | grep -q "$SCHEMA_FILE" || true) + +if [ -n "$SCHEMA_CHANGED" ]; then + echo "schema.prisma has changed" + + # Check if there are new migration files + MIGRATION_CHANGED=$(git diff --name-only $BASE_SHA $HEAD_SHA | grep -q "$MIGRATIONS_DIR/.*migration.sql" || true) + + if [ -z "$MIGRATION_CHANGED" ]; then + echo "ERROR: schema.prisma changed but no corresponding migration found!" + echo "Please run 'pnpm --filter backend run prisma:migrate' to create a migration" + exit 1 + else + echo "✅ schema.prisma changed and migration(s) added" + fi +else + echo "No changes to schema.prisma" +fi + +exit 0 diff --git a/app/backend/src/app.controller.ts b/app/backend/src/app.controller.ts index d64adfef..e5b4e473 100644 --- a/app/backend/src/app.controller.ts +++ b/app/backend/src/app.controller.ts @@ -5,6 +5,28 @@ import { API_VERSIONS } from './common/constants/api-version.constants'; import { Public } from './common/decorators/public.decorator'; import { Deprecated } from './common/decorators/deprecated.decorator'; +interface VersionInfo { + latestVersion: string; + minRequiredVersion: string; + releaseNotes: string[]; + storeUrl: { ios: string; android: string }; +} + +const MOCK_VERSION_INFO: VersionInfo = { + latestVersion: '1.1.0', + minRequiredVersion: '1.0.0', + releaseNotes: [ + 'Added support for on-chain verification', + 'Improved sync reliability in low-bandwidth areas', + 'Fixed a bug in QR code scanning for legacy NGO cards', + 'Reduced app bundle size by 15%', + ], + storeUrl: { + ios: 'https://apps.apple.com/app/chainforge', + android: 'https://play.google.com/store/apps/details?id=com.chainforge.mobile', + }, +}; + @ApiTags('App') @Controller() export class AppController { @@ -43,6 +65,18 @@ export class AppController { return { status: 'ok', service: 'backend' }; } + @Public() + @Get('mobile/version') + @ApiOperation({ + summary: 'Get mobile app version information', + description: + 'Returns version info including latest version, minimum required, and release notes.', + }) + @ApiOkResponse({ description: 'Version information returned successfully.' }) + getMobileVersion(): VersionInfo { + return MOCK_VERSION_INFO; + } + @Public() @Get('deprecated-test') @Deprecated({ diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 3935b65e..8200a7cf 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -1,10 +1,11 @@ import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { BullModule } from '@nestjs/bullmq'; -import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core'; +import { APP_FILTER, APP_INTERCEPTOR, APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; +import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @@ -20,7 +21,6 @@ import { JobsModule } from './jobs/jobs.module'; import { RequestCorrelationMiddleware } from './middleware/request-correlation.middleware'; import { SecurityModule } from './common/security/security.module'; import { CampaignsModule } from './campaigns/campaigns.module'; -import { APP_GUARD } from '@nestjs/core'; import { ApiKeyGuard } from './common/guards/api-key.guard'; import { RolesGuard } from './auth/roles.guard'; import { ObservabilityModule } from './observability/observability.module'; @@ -29,7 +29,6 @@ import { LoggingInterceptor } from './interceptors/logging.interceptor'; import { LoggerService } from './logger/logger.service'; import { AllExceptionsFilter } from './common/filters/http-exception.filter'; import { AnalyticsModule } from './analytics/analytics.module'; -import { ThrottlerModule } from '@nestjs/throttler'; import { AidEscrowModule } from './onchain/aid-escrow.module'; import { ApiKeysModule } from './api-keys/api-keys.module'; import { SessionModule } from './session/session.module'; @@ -41,10 +40,10 @@ import { AdminSearchModule } from './search/admin-search.module'; import { EntityLinkingModule } from './entity-linking/entity-linking.module'; import { DeploymentMetadataModule } from './deployment-metadata/deployment-metadata.module'; import { RedisModule } from '@liaoliaots/nestjs-redis'; -import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guard'; import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; +import { StorageModule } from './storage/storage.module'; @Module({ imports: [ @@ -114,6 +113,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; EntityLinkingModule, DeploymentMetadataModule, SandboxModule, + StorageModule, RedisModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -149,7 +149,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; }, { provide: APP_GUARD, - useClass: AdaptiveRateLimitGuard, // Adaptive rate limiting using Redis + useClass: ThrottlerGuard, // NestJS Throttler with per-handler overrides }, { provide: APP_INTERCEPTOR, diff --git a/app/backend/src/campaigns/campaigns.service.spec.ts b/app/backend/src/campaigns/campaigns.service.spec.ts index 7071f389..c8ac01c2 100644 --- a/app/backend/src/campaigns/campaigns.service.spec.ts +++ b/app/backend/src/campaigns/campaigns.service.spec.ts @@ -50,7 +50,7 @@ describe('CampaignsService', () => { }); const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0]; - + // Clean match validation instead of strict object equivalence structures expect(createArgs).toMatchObject({ data: { @@ -159,4 +159,4 @@ describe('CampaignsService', () => { expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) }); expect(result.deletedAt).not.toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/app/backend/src/campaigns/campaigns.service.ts b/app/backend/src/campaigns/campaigns.service.ts index 0b9239e4..1ba1346c 100644 --- a/app/backend/src/campaigns/campaigns.service.ts +++ b/app/backend/src/campaigns/campaigns.service.ts @@ -53,7 +53,12 @@ export class CampaignsService { }); } - async findAll(includeArchived = false, ngoId?: string | null, page = 1, limit = 50) { + async findAll( + includeArchived = false, + ngoId?: string | null, + page = 1, + limit = 50, + ) { const where: Prisma.CampaignWhereInput = { deletedAt: null, ...(includeArchived ? {} : { archivedAt: null }), diff --git a/app/backend/src/claims/claims.controller.ts b/app/backend/src/claims/claims.controller.ts index 19240b12..60282b4a 100644 --- a/app/backend/src/claims/claims.controller.ts +++ b/app/backend/src/claims/claims.controller.ts @@ -10,6 +10,7 @@ import { Res, Version, } from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; import type { Response } from 'express'; import { Request as ExpressRequest } from 'express'; import { @@ -52,6 +53,7 @@ export class ClaimsController { ) {} @Post() + @Throttle({ default: { limit: 100, ttl: 60_000 } }) @ApiOperation({ summary: 'Create a claim', description: 'Initializes a new claim for a specific campaign.', diff --git a/app/backend/src/common/budget/budget.service.spec.ts b/app/backend/src/common/budget/budget.service.spec.ts index 32b4e244..04ed60e9 100644 --- a/app/backend/src/common/budget/budget.service.spec.ts +++ b/app/backend/src/common/budget/budget.service.spec.ts @@ -55,4 +55,3 @@ describe('BudgetService', () => { ); }); }); - diff --git a/app/backend/src/common/guards/adaptive-rate-limit.guard.ts b/app/backend/src/common/guards/adaptive-rate-limit.guard.ts index fcb4ad4b..9b4cb824 100644 --- a/app/backend/src/common/guards/adaptive-rate-limit.guard.ts +++ b/app/backend/src/common/guards/adaptive-rate-limit.guard.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import { RedisService } from '@liaoliaots/nestjs-redis'; import { Request } from 'express'; +import { AppRole } from '../../auth/app-role.enum'; @Injectable() export class AdaptiveRateLimitGuard implements CanActivate { @@ -65,10 +66,10 @@ export class AdaptiveRateLimitGuard implements CanActivate { } private getStrategy(request: Request): keyof typeof this.limits { - const path = (request as any).path ?? (request as any).url ?? ''; + const path = request.path ?? request.url ?? ''; if (path.includes('/search')) return 'search'; - const user = (request as any).user; + const user = request.user; if (user) { if (user.authType === 'apiKey' || user.authType === 'envApiKey') { return 'apiKey'; @@ -80,13 +81,13 @@ export class AdaptiveRateLimitGuard implements CanActivate { } private getIdentifier(request: Request): string { - const user = (request as any).user; - if (user?.id) return user.id as string; - if (user?.apiKeyId) return user.apiKeyId as string; + const user = request.user; + if (user?.id) return user.id; + if (user?.apiKeyId) return user.apiKeyId; - const ips = (request as any).ips; + const ips = request.ips; const forwardedIp = - Array.isArray(ips) && ips.length > 0 ? (ips[0] as string) : undefined; + Array.isArray(ips) && ips.length > 0 ? ips[0] : undefined; return forwardedIp ?? request.ip ?? 'anonymous'; } } diff --git a/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts b/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts index 184d8e2c..89c49f76 100644 --- a/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts +++ b/app/backend/src/common/interceptors/__tests__/http-cache.interceptor.spec.ts @@ -203,9 +203,7 @@ describe('HttpCacheInterceptor', () => { expect(response.getHeader('Cache-Control')).toBe( 'private, must-revalidate', ); - expect(response.getHeader('Vary')).toBe( - 'Authorization, Accept-Encoding', - ); + expect(response.getHeader('Vary')).toBe('Authorization, Accept-Encoding'); expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/); }); @@ -242,9 +240,7 @@ describe('HttpCacheInterceptor', () => { expect(response.getHeader('Cache-Control')).toBe( 'private, must-revalidate', ); - expect(response.getHeader('Vary')).toBe( - 'Authorization, Accept-Encoding', - ); + expect(response.getHeader('Vary')).toBe('Authorization, Accept-Encoding'); expect(response.getHeader('ETag')).toMatch(/^"[a-f0-9]{64}"$/); expect(response.getHeader('X-Http-Cache')).toBe('miss'); }); @@ -355,7 +351,10 @@ describe('HttpCacheInterceptor', () => { }), ); expect(result).toBeUndefined(); - return { status: next.response.statusCode, etag: next.response.getHeader('ETag') }; + return { + status: next.response.statusCode, + etag: next.response.getHeader('ETag'), + }; }; it('returns 304 on exact strong ETag match', async () => { diff --git a/app/backend/src/common/interceptors/http-cache.interceptor.ts b/app/backend/src/common/interceptors/http-cache.interceptor.ts index 83d473d2..a8d1b66c 100644 --- a/app/backend/src/common/interceptors/http-cache.interceptor.ts +++ b/app/backend/src/common/interceptors/http-cache.interceptor.ts @@ -105,9 +105,10 @@ export class HttpCacheInterceptor implements NestInterceptor { private readonly reflector: Reflector, configService: ConfigService, ) { - this.enabled = (configService.get('HTTP_CACHE_ENABLED') ?? 'true') - .toLowerCase() - !== 'false'; + this.enabled = + ( + configService.get('HTTP_CACHE_ENABLED') ?? 'true' + ).toLowerCase() !== 'false'; const ttlRaw = configService.get('HTTP_CACHE_DEFAULT_TTL'); const parsedTtl = ttlRaw !== undefined ? Number.parseInt(ttlRaw, 10) : NaN; @@ -127,8 +128,7 @@ export class HttpCacheInterceptor implements NestInterceptor { : MAX_ETAG_PAYLOAD_BYTES; this.debugHeaders = - (configService.get('NODE_ENV') ?? 'development') !== - 'production'; + (configService.get('NODE_ENV') ?? 'development') !== 'production'; } intercept(context: ExecutionContext, next: CallHandler): Observable { @@ -188,7 +188,7 @@ export class HttpCacheInterceptor implements NestInterceptor { this.markNonCacheable(response); return throwError(() => err); }), - map((data) => this.applyGetHeaders(request, response, data)), + map(data => this.applyGetHeaders(request, response, data)), ); } @@ -222,10 +222,7 @@ export class HttpCacheInterceptor implements NestInterceptor { // Don't bother hashing huge payloads; ETag is a "free" win only on // small responses that we expect to be re-served often. const serialized = canonicalStringify(data); - if ( - serialized.length === 0 || - serialized.length > this.maxPayloadBytes - ) { + if (serialized.length === 0 || serialized.length > this.maxPayloadBytes) { if (serialized.length > this.maxPayloadBytes) { this.logger.warn( `Skipping ETag hash for ${request.method} ${ @@ -310,12 +307,19 @@ export class HttpCacheInterceptor implements NestInterceptor { // Node Readables expose `.pipe`; Web streams expose `.pipeTo`. const candidate = data as { pipe?: unknown; pipeTo?: unknown }; - if (typeof candidate.pipe === 'function' || typeof candidate.pipeTo === 'function') { + if ( + typeof candidate.pipe === 'function' || + typeof candidate.pipeTo === 'function' + ) { return false; } // Web Readables expose an async iterator (ReadableStream / Readable.fromWeb). - if (typeof (data as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function') { + if ( + typeof (data as { [Symbol.asyncIterator]?: unknown })[ + Symbol.asyncIterator + ] === 'function' + ) { return false; } @@ -348,10 +352,7 @@ export class HttpCacheInterceptor implements NestInterceptor { return true; } - private ifNoneMatchMatches( - request: Request, - etag: string, - ): boolean { + private ifNoneMatchMatches(request: Request, etag: string): boolean { const raw = request.headers['if-none-match']; if (raw === undefined) return false; const values = normalizeIfNoneMatch(raw); diff --git a/app/backend/src/common/security/security.module.ts b/app/backend/src/common/security/security.module.ts index f0961b12..68f394d4 100644 --- a/app/backend/src/common/security/security.module.ts +++ b/app/backend/src/common/security/security.module.ts @@ -275,9 +275,9 @@ export const createRateLimiter = (config: ConfigService): RequestHandler => { * CSRF is currently mitigated by design due to our stateless, token-based authentication * mechanism (`x-api-key` header). Since browsers do not automatically attach custom headers * on cross-origin requests, CSRF attacks are inherently prevented. - * + * * WARNING: - * If cookie-based session management or any browser-managed credentials are introduced + * If cookie-based session management or any browser-managed credentials are introduced * in the future, CSRF protection middleware MUST be implemented. */ @Module({}) diff --git a/app/backend/src/deployment-metadata/deployment-metadata.controller.ts b/app/backend/src/deployment-metadata/deployment-metadata.controller.ts index 286b73d9..c71e3b65 100644 --- a/app/backend/src/deployment-metadata/deployment-metadata.controller.ts +++ b/app/backend/src/deployment-metadata/deployment-metadata.controller.ts @@ -74,9 +74,19 @@ export class DeploymentMetadataController { ); try { return await this.deploymentMetadataService.create(dto); - } catch (error) { + } catch (error: unknown) { this.logger.error('Failed to create deployment metadata:', error); - if (error.code === 'P2002') { + const isPrismaError = ( + err: unknown + ): err is { code: string } => { + return ( + typeof err === 'object' && + err !== null && + 'code' in err && + typeof (err as { code: unknown }).code === 'string' + ); + }; + if (isPrismaError(error) && error.code === 'P2002') { throw new BadRequestException( `Deployment metadata already exists for ${dto.network}/${dto.contractName}`, ); diff --git a/app/backend/src/deployment-metadata/deployment-metadata.service.ts b/app/backend/src/deployment-metadata/deployment-metadata.service.ts index 9836e3e5..dd6412e6 100644 --- a/app/backend/src/deployment-metadata/deployment-metadata.service.ts +++ b/app/backend/src/deployment-metadata/deployment-metadata.service.ts @@ -135,7 +135,7 @@ export class DeploymentMetadataService { /** * Map Prisma model to response DTO */ - private mapToResponse(metadata: any): DeploymentMetadataResponseDto { + private mapToResponse(metadata: Prisma.DeploymentMetadataGetPayload>): DeploymentMetadataResponseDto { return { id: metadata.id, contractName: metadata.contractName, diff --git a/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts b/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts index f2f997e8..aa5dba64 100644 --- a/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts +++ b/app/backend/src/deployment-metadata/dto/deployment-metadata.dto.ts @@ -65,7 +65,7 @@ export class DeploymentMetadataResponseDto { commitSha?: string; deployer?: string; transactionHash?: string; - metadata?: Record; + metadata?: unknown; createdAt: Date; updatedAt: Date; } diff --git a/app/backend/src/entity-linking/entity-linking.controller.ts b/app/backend/src/entity-linking/entity-linking.controller.ts index d35d1e64..cc938aeb 100644 --- a/app/backend/src/entity-linking/entity-linking.controller.ts +++ b/app/backend/src/entity-linking/entity-linking.controller.ts @@ -82,7 +82,7 @@ export class EntityLinkingController { }) async getLinksByCampaign( @Param('campaignId') campaignId: string, - @Query('entityType') entityType?: string, + @Query('entityType') entityType?: 'organization' | 'location' | 'asset' | 'project', ) { return this.entityLinkingService.getLinksByCampaign(campaignId, entityType); } @@ -100,7 +100,7 @@ export class EntityLinkingController { }) async getLinksByClaim( @Param('claimId') claimId: string, - @Query('entityType') entityType?: string, + @Query('entityType') entityType?: 'organization' | 'location' | 'asset' | 'project', ) { return this.entityLinkingService.getLinksByClaim(claimId, entityType); } @@ -119,7 +119,7 @@ export class EntityLinkingController { }) async getLinksByVerification( @Param('verificationId') verificationId: string, - @Query('entityType') entityType?: string, + @Query('entityType') entityType?: 'organization' | 'location' | 'asset' | 'project', ) { return this.entityLinkingService.getLinksByVerification( verificationId, diff --git a/app/backend/src/entity-linking/entity-linking.service.ts b/app/backend/src/entity-linking/entity-linking.service.ts index 3eecf4fa..d6c89b7a 100644 --- a/app/backend/src/entity-linking/entity-linking.service.ts +++ b/app/backend/src/entity-linking/entity-linking.service.ts @@ -5,6 +5,7 @@ import { BadRequestException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { Prisma } from '@prisma/client'; import { CreateEntityLinkDto, LinkEntityResult, @@ -57,7 +58,7 @@ export class EntityLinkingService { } // Create entity link - const linkData: any = { + const linkData: Prisma.EntityLinkCreateInput = { sourceType: dto.sourceType, sourceId: dto.sourceId, extractedName: dto.extractedName, @@ -65,23 +66,23 @@ export class EntityLinkingService { entityType: dto.entityType, confidenceScore: dto.confidenceScore, matchMethod, - metadata: dto.metadata ? JSON.parse(JSON.stringify(dto.metadata)) : null, + metadata: dto.metadata ? (dto.metadata as Prisma.InputJsonValue) : undefined, }; // Set the appropriate registry relation if (registryRecordId) { switch (dto.entityType) { case 'organization': - linkData.organizationId = registryRecordId; + linkData.organization = { connect: { id: registryRecordId } }; break; case 'location': - linkData.locationId = registryRecordId; + linkData.location = { connect: { id: registryRecordId } }; break; case 'asset': - linkData.assetId = registryRecordId; + linkData.asset = { connect: { id: registryRecordId } }; break; case 'project': - linkData.projectId = registryRecordId; + linkData.project = { connect: { id: registryRecordId } }; break; } } @@ -110,7 +111,7 @@ export class EntityLinkingService { const limit = query.limit || 20; const skip = (page - 1) * limit; - const where: any = {}; + const where: Prisma.EntityLinkWhereInput = {}; if (query.sourceType) { where.sourceType = query.sourceType; @@ -155,9 +156,9 @@ export class EntityLinkingService { */ async getLinksByCampaign( campaignId: string, - entityType?: string, + entityType?: 'organization' | 'location' | 'asset' | 'project', ): Promise { - const where: any = { + const where: Prisma.EntityLinkWhereInput = { sourceType: 'campaign', sourceId: campaignId, }; @@ -179,9 +180,9 @@ export class EntityLinkingService { */ async getLinksByClaim( claimId: string, - entityType?: string, + entityType?: 'organization' | 'location' | 'asset' | 'project', ): Promise { - const where: any = { + const where: Prisma.EntityLinkWhereInput = { sourceType: 'claim', sourceId: claimId, }; @@ -203,9 +204,9 @@ export class EntityLinkingService { */ async getLinksByVerification( verificationId: string, - entityType?: string, + entityType?: 'organization' | 'location' | 'asset' | 'project', ): Promise { - const where: any = { + const where: Prisma.EntityLinkWhereInput = { sourceType: 'verification', sourceId: verificationId, }; @@ -529,7 +530,7 @@ export class EntityLinkingService { /** * Helper: Map Prisma entity link to result DTO */ - private mapLinkResult(link: any): LinkEntityResult { + private mapLinkResult(link: Prisma.EntityLinkGetPayload>): LinkEntityResult { return { id: link.id, sourceType: link.sourceType, diff --git a/app/backend/src/evidence/evidence.controller.ts b/app/backend/src/evidence/evidence.controller.ts index 0e670b65..c81dd9fb 100644 --- a/app/backend/src/evidence/evidence.controller.ts +++ b/app/backend/src/evidence/evidence.controller.ts @@ -10,6 +10,7 @@ import { HttpCode, HttpStatus, BadRequestException, + Body, } from '@nestjs/common'; import { Request as ExpressRequest } from 'express'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; @@ -32,6 +33,10 @@ import { evidenceMulterOptions, validateUploadedFile, } from './file-validation'; +import { + InitiatePresignedUploadDto, + CompletePresignedUploadDto, +} from './presigned-upload.dto'; @ApiTags('Evidence Queue') @ApiBearerAuth('JWT-auth') @@ -39,6 +44,51 @@ import { export class EvidenceController { constructor(private readonly evidenceService: EvidenceService) {} + @Post('upload/initiate') + @Roles(AppRole.operator, AppRole.admin) + @ApiOperation({ + summary: 'Initiate an evidence upload', + description: + 'Creates an evidence queue item and returns a presigned URL for direct client upload to storage. ' + + `Accepts one file (max ${MAX_FILE_SIZE / (1024 * 1024)}MB). ` + + `Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}.`, + }) + @ApiCreatedResponse({ + description: 'Upload initiated successfully with presigned URL.', + }) + async initiateUpload( + @Body() dto: InitiatePresignedUploadDto, + @Request() req: ExpressRequest, + ) { + const ownerId = req.user?.apiKeyId || req.user?.authType || 'system'; + const orgId = (req.headers['x-org-id'] as string) || undefined; + return this.evidenceService.initiateUpload( + dto.fileName, + dto.mimeType, + dto.totalSize, + ownerId, + orgId, + ); + } + + @Post('upload/:id/complete') + @Roles(AppRole.operator, AppRole.admin) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Complete an evidence upload', + description: + 'Marks an evidence queue item as completed after client has uploaded directly to storage.', + }) + @ApiOkResponse({ description: 'Upload completed successfully.' }) + async completeUpload( + @Param('id') id: string, + @Body() dto: CompletePresignedUploadDto, + @Request() req: ExpressRequest, + ) { + const ownerId = req.user?.apiKeyId || req.user?.authType || 'system'; + return this.evidenceService.completeUpload(id, dto.fileHash, ownerId); + } + @Post('upload') @Roles(AppRole.operator, AppRole.admin) // AnyFilesInterceptor lets us detect ambiguous inputs (zero or multiple @@ -48,7 +98,7 @@ export class EvidenceController { @UseInterceptors(AnyFilesInterceptor(evidenceMulterOptions)) @ApiConsumes('multipart/form-data') @ApiOperation({ - summary: 'Upload evidence to queue', + summary: '[Legacy] Upload evidence to queue', description: 'Encrypts and stores a single evidence file locally for eventual upload. ' + `Accepts one file (max ${MAX_FILE_SIZE / (1024 * 1024)}MB) in the "${UPLOAD_FIELD}" field. ` + diff --git a/app/backend/src/evidence/evidence.service.ts b/app/backend/src/evidence/evidence.service.ts index 882b2e03..4d862112 100644 --- a/app/backend/src/evidence/evidence.service.ts +++ b/app/backend/src/evidence/evidence.service.ts @@ -3,11 +3,13 @@ import { Logger, NotFoundException, BadRequestException, + Inject, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EncryptionService } from '../common/encryption/encryption.service'; import { AuditService } from '../audit/audit.service'; import { FingerprintService } from './fingerprint.service'; +import { StorageProvider, STORAGE_PROVIDER } from '../storage/storage.provider'; import * as fs from 'fs/promises'; import { existsSync, mkdirSync } from 'fs'; import * as path from 'path'; @@ -24,13 +26,101 @@ export class EvidenceService { private readonly encryptionService: EncryptionService, private readonly auditService: AuditService, private readonly fingerprintService: FingerprintService, + @Inject(STORAGE_PROVIDER) private readonly storageProvider: StorageProvider, ) { - // Ensure upload directory exists + // Ensure upload directory exists (for backward compatibility) if (!existsSync(this.uploadDir)) { mkdirSync(this.uploadDir, { recursive: true }); } } + /** + * Initiate an evidence upload by creating a queue item and signing a presigned URL + */ + async initiateUpload( + fileName: string, + mimeType: string, + totalSize: number, + ownerId: string, + orgId?: string, + ) { + const fileKey = `evidence/${crypto.randomUUID()}/${fileName}`; + + // Create queue item first + const item = await this.prisma.evidenceQueueItem.create({ + data: { + fileName, + fileHash: null, // Will be filled after upload + fingerprint: null, // Will be filled after upload + mimeType, + size: totalSize, + storageKey: fileKey, + ownerId, + orgId, + status: EvidenceStatus.pending, + }, + }); + + // Sign presigned URL + const presignedUrl = await this.storageProvider.signPresignedUrl(fileKey, { + contentType: mimeType, + expiresIn: 3600, + }); + + await this.auditService.record({ + actorId: ownerId, + entity: 'evidence_queue', + entityId: item.id, + action: 'upload_initiated', + metadata: { fileName, size: totalSize, orgId }, + }); + + return { + evidenceId: item.id, + presignedUrl, + }; + } + + /** + * Complete an evidence upload after client has uploaded the file directly to storage + */ + async completeUpload(evidenceId: string, fileHash: string, ownerId: string) { + const item = await this.prisma.evidenceQueueItem.findUnique({ + where: { id: evidenceId }, + }); + + if (!item) throw new NotFoundException('Evidence queue item not found'); + if (item.ownerId !== ownerId) + throw new BadRequestException('Not authorized'); + + // Verify file exists in storage + if ( + item.storageKey && + !(await this.storageProvider.fileExists(item.storageKey)) + ) { + throw new BadRequestException('File not found in storage'); + } + + // Update queue item + const updated = await this.prisma.evidenceQueueItem.update({ + where: { id: evidenceId }, + data: { + fileHash, + status: EvidenceStatus.completed, + }, + }); + + await this.auditService.record({ + actorId: ownerId, + entity: 'evidence_queue', + entityId: evidenceId, + action: 'upload_completed', + metadata: { fileHash }, + }); + + return updated; + } + async queueEvidence( file: Express.Multer.File, ownerId: string, diff --git a/app/backend/src/evidence/presigned-upload.dto.ts b/app/backend/src/evidence/presigned-upload.dto.ts new file mode 100644 index 00000000..893cbc9d --- /dev/null +++ b/app/backend/src/evidence/presigned-upload.dto.ts @@ -0,0 +1,18 @@ +import { IsString, IsInt, IsIn } from 'class-validator'; +import { ALLOWED_MIME_TYPES } from './file-validation'; + +export class InitiatePresignedUploadDto { + @IsString() + fileName: string; + + @IsIn(ALLOWED_MIME_TYPES as unknown as string[]) + mimeType: string; + + @IsInt() + totalSize: number; +} + +export class CompletePresignedUploadDto { + @IsString() + fileHash: string; +} diff --git a/app/backend/src/storage/s3.storage.provider.ts b/app/backend/src/storage/s3.storage.provider.ts new file mode 100644 index 00000000..6c528877 --- /dev/null +++ b/app/backend/src/storage/s3.storage.provider.ts @@ -0,0 +1,108 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + HeadObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { StorageProvider } from './storage.provider'; + +@Injectable() +export class S3StorageProvider implements StorageProvider { + private readonly logger = new Logger(S3StorageProvider.name); + private readonly s3Client: S3Client; + private readonly bucket: string; + + constructor(private readonly configService: ConfigService) { + const region = this.configService.get( + 'EVIDENCE_STORAGE_REGION', + 'us-east-1', + ); + const endpoint = this.configService.get( + 'EVIDENCE_STORAGE_ENDPOINT', + ); + const accessKeyId = this.configService.get( + 'EVIDENCE_STORAGE_ACCESS_KEY_ID', + ); + const secretAccessKey = this.configService.get( + 'EVIDENCE_STORAGE_SECRET_ACCESS_KEY', + ); + this.bucket = this.configService.getOrThrow( + 'EVIDENCE_STORAGE_BUCKET', + ); + + this.s3Client = new S3Client({ + region, + endpoint, + credentials: + accessKeyId && secretAccessKey + ? { + accessKeyId, + secretAccessKey, + } + : undefined, + forcePathStyle: this.configService.get( + 'EVIDENCE_STORAGE_FORCE_PATH_STYLE', + false, + ), + }); + + this.logger.log('S3 Storage Provider initialized'); + } + + async signPresignedUrl( + key: string, + options?: { contentType?: string; expiresIn?: number }, + ): Promise { + const command = new PutObjectCommand({ + Bucket: this.bucket, + Key: key, + ContentType: options?.contentType, + }); + + const expiresIn = options?.expiresIn || 3600; // 1 hour default + + return getSignedUrl(this.s3Client, command, { expiresIn }); + } + + async signPresignedDownloadUrl( + key: string, + options?: { expiresIn?: number }, + ): Promise { + const command = new GetObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + + const expiresIn = options?.expiresIn || 3600; // 1 hour default + + return getSignedUrl(this.s3Client, command, { expiresIn }); + } + + async fileExists(key: string): Promise { + try { + const command = new HeadObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + await this.s3Client.send(command); + return true; + } catch (err: any) { + if (err.name === 'NotFound') { + return false; + } + throw err; + } + } + + async deleteFile(key: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: this.bucket, + Key: key, + }); + await this.s3Client.send(command); + } +} diff --git a/app/backend/src/storage/storage.module.ts b/app/backend/src/storage/storage.module.ts new file mode 100644 index 00000000..40636e06 --- /dev/null +++ b/app/backend/src/storage/storage.module.ts @@ -0,0 +1,17 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { S3StorageProvider } from './s3.storage.provider'; +import { STORAGE_PROVIDER } from './storage.provider'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [ + { + provide: STORAGE_PROVIDER, + useClass: S3StorageProvider, + }, + ], + exports: [STORAGE_PROVIDER], +}) +export class StorageModule {} diff --git a/app/backend/src/storage/storage.provider.ts b/app/backend/src/storage/storage.provider.ts new file mode 100644 index 00000000..527ede11 --- /dev/null +++ b/app/backend/src/storage/storage.provider.ts @@ -0,0 +1,29 @@ +export const STORAGE_PROVIDER = 'STORAGE_PROVIDER'; + +export interface StorageProvider { + /** + * Sign a presigned URL for uploading a file + */ + signPresignedUrl( + key: string, + options?: { contentType?: string; expiresIn?: number }, + ): Promise; + + /** + * Sign a presigned URL for downloading a file + */ + signPresignedDownloadUrl( + key: string, + options?: { expiresIn?: number }, + ): Promise; + + /** + * Check if a file exists at the given key + */ + fileExists(key: string): Promise; + + /** + * Delete a file at the given key + */ + deleteFile(key: string): Promise; +} diff --git a/app/backend/src/types/express.d.ts b/app/backend/src/types/express.d.ts index fc2314e3..2e3cf184 100644 --- a/app/backend/src/types/express.d.ts +++ b/app/backend/src/types/express.d.ts @@ -5,6 +5,11 @@ declare global { interface Request { user?: { role: AppRole; + id?: string; + sub?: string; + email?: string; + address?: string; + orgId?: string; ngoId?: string | null; apiKeyId?: string; authType?: 'apiKey' | 'envApiKey'; diff --git a/app/backend/test/claim-lifecycle.integration-spec.ts b/app/backend/test/claim-lifecycle.integration-spec.ts index 8ae9ce55..d606dcbc 100644 --- a/app/backend/test/claim-lifecycle.integration-spec.ts +++ b/app/backend/test/claim-lifecycle.integration-spec.ts @@ -31,7 +31,9 @@ jest.mock('openai', () => ({ chat: { completions: { create: jest.fn().mockResolvedValue({ - choices: [{ message: { content: JSON.stringify({ verified: true }) } }], + choices: [ + { message: { content: JSON.stringify({ verified: true }) } }, + ], }), }, }, diff --git a/app/mobile/package.json b/app/mobile/package.json index 333fed45..f002447b 100644 --- a/app/mobile/package.json +++ b/app/mobile/package.json @@ -33,6 +33,7 @@ "expo-linking": "~8.0.11", "expo-local-authentication": "^55.0.9", "expo-notifications": "^55.0.20", + "expo-secure-store": "^57.0.1", "expo-status-bar": "~3.0.9", "fast-text-encoding": "^1.0.6", "react": "19.1.0", diff --git a/app/mobile/src/__tests__/storage.test.tsx b/app/mobile/src/__tests__/storage.test.tsx new file mode 100644 index 00000000..c1dd2d40 --- /dev/null +++ b/app/mobile/src/__tests__/storage.test.tsx @@ -0,0 +1,88 @@ + +import * as SecureStore from 'expo-secure-store'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { + getItem, + setItem, + removeItem, + registerSensitiveKey, +} from '../services/storage'; + +// Mock the storage modules +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), +})); + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), +})); + +describe('Storage Service', () => { + const SENSITIVE_KEY = '@ChainForge:AuthToken'; + const NON_SENSITIVE_KEY = '@ChainForge:SomeNonSensitiveKey'; + const TEST_VALUE = 'test-value'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses SecureStore for sensitive keys when setting', async () => { + await setItem(SENSITIVE_KEY, TEST_VALUE); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + SENSITIVE_KEY, + TEST_VALUE, + ); + expect(AsyncStorage.setItem).not.toHaveBeenCalled(); + }); + + it('uses SecureStore for sensitive keys when getting', async () => { + (SecureStore.getItemAsync as jest.Mock).mockResolvedValueOnce(TEST_VALUE); + const result = await getItem(SENSITIVE_KEY); + expect(SecureStore.getItemAsync).toHaveBeenCalledWith(SENSITIVE_KEY); + expect(result).toBe(TEST_VALUE); + }); + + it('uses SecureStore for sensitive keys when removing', async () => { + await removeItem(SENSITIVE_KEY); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith(SENSITIVE_KEY); + expect(AsyncStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('uses AsyncStorage for non-sensitive keys when setting', async () => { + await setItem(NON_SENSITIVE_KEY, TEST_VALUE); + expect(AsyncStorage.setItem).toHaveBeenCalledWith( + NON_SENSITIVE_KEY, + TEST_VALUE, + ); + expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); + }); + + it('uses AsyncStorage for non-sensitive keys when getting', async () => { + (AsyncStorage.getItem as jest.Mock).mockResolvedValueOnce(TEST_VALUE); + const result = await getItem(NON_SENSITIVE_KEY); + expect(AsyncStorage.getItem).toHaveBeenCalledWith(NON_SENSITIVE_KEY); + expect(result).toBe(TEST_VALUE); + }); + + it('uses AsyncStorage for non-sensitive keys when removing', async () => { + await removeItem(NON_SENSITIVE_KEY); + expect(AsyncStorage.removeItem).toHaveBeenCalledWith(NON_SENSITIVE_KEY); + expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); + }); + + it('registers new sensitive keys and uses SecureStore for them', async () => { + const NEW_SENSITIVE_KEY = '@ChainForge:NewSensitiveKey'; + registerSensitiveKey(NEW_SENSITIVE_KEY); + + await setItem(NEW_SENSITIVE_KEY, TEST_VALUE); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + NEW_SENSITIVE_KEY, + TEST_VALUE, + ); + }); +}); diff --git a/app/mobile/src/__tests__/updateContext.test.tsx b/app/mobile/src/__tests__/updateContext.test.tsx new file mode 100644 index 00000000..9f5c0d57 --- /dev/null +++ b/app/mobile/src/__tests__/updateContext.test.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { render, waitFor } from '@testing-library/react-native'; +import { UpdateProvider, useUpdate } from '../contexts/UpdateContext'; +import * as updateService from '../services/updateService'; + +// Mock AsyncStorage +jest.mock('@react-native-async-storage/async-storage', () => ({ + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), +})); + +// Mock Constants from expo-constants +jest.mock('expo-constants', () => ({ + expoConfig: { + version: '1.0.0', + }, +})); + +// Test component to access the update context +const TestComponent = () => { + const update = useUpdate(); + return ( + + {update.isLoading ? 'loading' : 'done'} + {update.versionInfo?.latestVersion || 'null'} + + ); +}; + +describe('UpdateProvider - Token Expiry Handling', () => { + const mockVersionInfo = { + latestVersion: '1.1.0', + minRequiredVersion: '1.0.0', + releaseNotes: ['Test note'], + storeUrl: { ios: 'https://ios', android: 'https://android' }, + }; + + let fetchVersionInfoSpy: jest.SpyInstance; + let setAuthTokenSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + fetchVersionInfoSpy = jest.spyOn(updateService, 'fetchVersionInfo'); + setAuthTokenSpy = jest.spyOn(updateService, 'setAuthToken'); + }); + + it('should refresh token and retry on 401 error', async () => { + // First call returns 401, second call returns success + let callCount = 0; + fetchVersionInfoSpy.mockImplementation(async () => { + callCount++; + if (callCount === 1) { + const error = new Error('Unauthorized'); + (error as any).status = 401; + throw error; + } + return mockVersionInfo; + }); + + const { getByTestId } = render( + + + + ); + + await waitFor(() => { + expect(getByTestId('loading')).toHaveTextContent('done'); + }); + + // Verify fetch was called twice (first failed, second succeeded) + expect(fetchVersionInfoSpy).toHaveBeenCalledTimes(2); + // Verify setAuthToken was called to set new token + expect(setAuthTokenSpy).toHaveBeenCalledTimes(1); + // Verify version info was successfully fetched on retry + expect(getByTestId('versionInfo')).toHaveTextContent(mockVersionInfo.latestVersion); + }); + + it('should not retry more than once', async () => { + fetchVersionInfoSpy.mockImplementation(async () => { + const error = new Error('Unauthorized'); + (error as any).status = 401; + throw error; + }); + + render( + + + + ); + + await waitFor(() => { + expect(fetchVersionInfoSpy).toHaveBeenCalledTimes(2); // Initial + 1 retry + }); + }); +}); diff --git a/app/mobile/src/contexts/UpdateContext.tsx b/app/mobile/src/contexts/UpdateContext.tsx index 0eb4fe7a..52b88db2 100644 --- a/app/mobile/src/contexts/UpdateContext.tsx +++ b/app/mobile/src/contexts/UpdateContext.tsx @@ -2,7 +2,12 @@ import React, { createContext, useContext, useState, useEffect } from 'react'; import Constants from 'expo-constants'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { VersionInfo, UpdateState } from '../types/update'; -import { fetchVersionInfo, compareVersions } from '../services/updateService'; +import { + fetchVersionInfo, + compareVersions, + clearAuthToken, + setAuthToken, +} from '../services/updateService'; interface UpdateContextType extends UpdateState { markReleaseNotesSeen: () => Promise; @@ -14,6 +19,14 @@ const UpdateContext = createContext(undefined); const SEEN_RELEASE_NOTES_KEY = '@ChainForge:SeenReleaseNotes'; +// Mock refresh token function for now +const refreshAuthToken = async (): Promise => { + // In real app, this would call your refresh token endpoint + console.log('UpdateProvider: Refreshing auth token'); + // For demo, just return a new mock token + return `new-mock-token-${Date.now()}`; +}; + export const UpdateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [state, setState] = useState({ isUpdateAvailable: false, @@ -22,6 +35,7 @@ export const UpdateProvider: React.FC<{ children: React.ReactNode }> = ({ childr hasSeenReleaseNotes: true, }); const [isLoading, setIsLoading] = useState(true); + const [authRetryCount, setAuthRetryCount] = useState(0); const currentVersion = Constants.expoConfig?.version || '0.0.0'; @@ -45,8 +59,22 @@ export const UpdateProvider: React.FC<{ children: React.ReactNode }> = ({ childr versionInfo, hasSeenReleaseNotes: hasSeen, }); - } catch (error) { + setAuthRetryCount(0); // Reset retry count on success + } catch (error: any) { console.error('UpdateProvider: Failed to check for updates', error); + // If we get a 401, try to refresh token and retry + if (error.status === 401 && authRetryCount < 1) { + console.log('UpdateProvider: Received 401, refreshing token'); + try { + const newToken = await refreshAuthToken(); + await setAuthToken(newToken); + setAuthRetryCount(prev => prev + 1); + await checkUpdates(); // Retry after refreshing + } catch (refreshError) { + console.error('UpdateProvider: Failed to refresh token', refreshError); + await clearAuthToken(); + } + } } finally { setIsLoading(false); } diff --git a/app/mobile/src/services/storage.ts b/app/mobile/src/services/storage.ts new file mode 100644 index 00000000..aedfd396 --- /dev/null +++ b/app/mobile/src/services/storage.ts @@ -0,0 +1,70 @@ + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as SecureStore from 'expo-secure-store'; + +// Sensitive keys (use SecureStore) +const SENSITIVE_KEYS = new Set([ + '@ChainForge:AuthToken', // from updateService +]); + +// Helper to check if a key is sensitive +function isSensitiveKey(key: string): boolean { + return SENSITIVE_KEYS.has(key); +} + +/** + * Get an item from storage. + * Uses SecureStore for sensitive keys, AsyncStorage otherwise. + */ +export async function getItem(key: string): Promise { + if (isSensitiveKey(key)) { + return await SecureStore.getItemAsync(key); + } else { + return await AsyncStorage.getItem(key); + } +} + +/** + * Set an item in storage. + * Uses SecureStore for sensitive keys, AsyncStorage otherwise. + */ +export async function setItem(key: string, value: string): Promise { + if (isSensitiveKey(key)) { + await SecureStore.setItemAsync(key, value); + } else { + await AsyncStorage.setItem(key, value); + } +} + +/** + * Remove an item from storage. + * Uses SecureStore for sensitive keys, AsyncStorage otherwise. + */ +export async function removeItem(key: string): Promise { + if (isSensitiveKey(key)) { + await SecureStore.deleteItemAsync(key); + } else { + await AsyncStorage.removeItem(key); + } +} + +/** + * Clear all storage (use carefully). + */ +export async function clear(): Promise { + // Clear AsyncStorage + await AsyncStorage.clear(); + // Clear sensitive keys + for (const key of SENSITIVE_KEYS) { + try { + await SecureStore.deleteItemAsync(key); + } catch (e) { + // Ignore errors when clearing + } + } +} + +// Add more sensitive keys as needed +export function registerSensitiveKey(key: string): void { + SENSITIVE_KEYS.add(key); +} diff --git a/app/mobile/src/services/updateService.ts b/app/mobile/src/services/updateService.ts index cf2e6054..f5eb77d6 100644 --- a/app/mobile/src/services/updateService.ts +++ b/app/mobile/src/services/updateService.ts @@ -1,35 +1,48 @@ import { VersionInfo } from '../types/update'; +import { config } from '../config'; +import { getItem, setItem, removeItem } from './storage'; -// In a real app, this would be a URL to your backend or a config file (e.g., hosted on GitHub or S3) -const VERSION_CONFIG_URL = 'https://api.chainforge.app/mobile/version'; +const AUTH_TOKEN_KEY = '@ChainForge:AuthToken'; +const VERSION_CONFIG_PATH = '/mobile/version'; + +export const getAuthToken = async (): Promise => { + return await getItem(AUTH_TOKEN_KEY); +}; + +export const setAuthToken = async (token: string): Promise => { + await setItem(AUTH_TOKEN_KEY, token); +}; + +export const clearAuthToken = async (): Promise => { + await removeItem(AUTH_TOKEN_KEY); +}; export const fetchVersionInfo = async (): Promise => { try { - // For now, we'll return mock data. - // In production, uncomment the fetch block. - /* - const response = await fetch(VERSION_CONFIG_URL); - if (!response.ok) throw new Error('Failed to fetch version info'); - return await response.json(); - */ - - // Simulate network delay - await new Promise(resolve => setTimeout(resolve, 800)); - - return { - latestVersion: '1.1.0', - minRequiredVersion: '1.0.0', // Set to something higher than current to test force upgrade - releaseNotes: [ - 'Added support for on-chain verification', - 'Improved sync reliability in low-bandwidth areas', - 'Fixed a bug in QR code scanning for legacy NGO cards', - 'Reduced app bundle size by 15%', - ], - storeUrl: { - ios: 'https://apps.apple.com/app/chainforge', - android: 'https://play.google.com/store/apps/details?id=com.chainforge.mobile', - }, + const authToken = await getAuthToken(); + const headers: Record = { + 'Content-Type': 'application/json', }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + + const response = await fetch(`${config.apiUrl}${VERSION_CONFIG_PATH}`, { + headers, + }); + + if (response.status === 401) { + // Throw specific error for 401 to let UpdateProvider handle it + const error = new Error('Unauthorized: Token expired'); + (error as any).status = 401; + throw error; + } + + if (!response.ok) { + throw new Error(`Failed to fetch version info: ${response.status}`); + } + + return await response.json(); } catch (error) { console.error('UpdateService: Error fetching version info', error); throw error; diff --git a/docs/mobile/encrypted-storage.md b/docs/mobile/encrypted-storage.md new file mode 100644 index 00000000..949013d0 --- /dev/null +++ b/docs/mobile/encrypted-storage.md @@ -0,0 +1,41 @@ +# Mobile Encrypted Storage + +## Overview +The ChainForge mobile app uses `expo-secure-store` to store sensitive information securely on both iOS and Android. This module provides encryption at rest using platform-native secure storage mechanisms. + +## Platform Details + +### Android +On Android, `expo-secure-store` uses the system's KeyStore to generate and store encryption keys, and encrypts values in SharedPreferences. + +### iOS +On iOS, `expo-secure-store` uses the system Keychain, providing strong encryption. The data persists even when the app is uninstalled (as is Keychain behavior). + +## Sensitive Storage Keys +All sensitive keys are defined in `app/mobile/src/services/storage.ts`. + +### Current Sensitive Keys +- `@ChainForge:AuthToken`: Stores the authentication token for API requests. +- (WalletConnect session data): WalletConnect session data (topic, public key, etc.) are stored via WalletConnect's internal storage. + +## How to Use the Storage Service + +### Import +```typescript +import { getItem, setItem, removeItem, registerSensitiveKey } from './storage'; +``` + +### Set an Item +```typescript +// Sensitive keys are automatically stored in SecureStore if they're in the SENSITIVE_KEYS set. +await setItem('@ChainForge:AuthToken', 'some-token-value'); +``` + +### Register a New Sensitive Key +```typescript +registerSensitiveKey('@ChainForge:NewSensitiveKey'); +await setItem('@ChainForge:NewSensitiveKey', 'sensitive-value'); +``` + +## Testing +The storage service tests ensure sensitive keys are stored in `expo-secure-store` and non‑sensitive keys are stored in AsyncStorage. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd7d856..0eaf6080 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,6 +73,12 @@ importers: app/backend: dependencies: + '@aws-sdk/client-s3': + specifier: ^3.1088.0 + version: 3.1089.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.1088.0 + version: 3.1089.0 '@liaoliaots/nestjs-redis': specifier: ^10.0.0 version: 10.0.0(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(ioredis@5.10.1) @@ -136,6 +142,9 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.4 + compression: + specifier: 1.7.5 + version: 1.7.5 dotenv: specifier: ^17.2.3 version: 17.4.2 @@ -185,6 +194,9 @@ importers: '@nestjs/testing': specifier: ^11.0.1 version: 11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)) + '@types/compression': + specifier: 1.7.5 + version: 1.7.5 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -197,6 +209,12 @@ importers: '@types/node': specifier: ^22.19.7 version: 22.19.15 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 + '@types/redis': + specifier: ^4.0.10 + version: 4.0.11(@opentelemetry/api@1.9.0) '@types/supertest': specifier: ^6.0.3 version: 6.0.3 @@ -453,6 +471,9 @@ importers: expo-notifications: specifier: ^55.0.20 version: 55.0.20(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-secure-store: + specifier: ^57.0.1 + version: 57.0.1(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)) expo-status-bar: specifier: ~3.0.9 version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0) @@ -575,6 +596,82 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-sdk/checksums@3.1000.18': + resolution: {integrity: sha512-IImkbEyXdV6/uaF5r6Wkk+8718mQw1ll83j0a4a30R3JM/rHVFdWAiT4jtJpFjJiIwM/oJ6SxIxr0z2TaQUGqw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-s3@3.1089.0': + resolution: {integrity: sha512-Sc+JOtNeP+v+XdP9Rb4Hh+uhiNO2hh/CZQbKYooNakhOIgK6/K0cjoDCEGeFmkG0vf2DopTmSctzKlcKwy1BPw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.975.3': + resolution: {integrity: sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.59': + resolution: {integrity: sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.61': + resolution: {integrity: sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.4': + resolution: {integrity: sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.66': + resolution: {integrity: sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.70': + resolution: {integrity: sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.59': + resolution: {integrity: sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.3': + resolution: {integrity: sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.65': + resolution: {integrity: sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.64': + resolution: {integrity: sha512-RBi43anhDBUv+HCfxCOXwGOE7GmT4n7ChV04Mwr22RhXTNcamW/iWnJlOotDPCZSrJ4dEvhZSiWWQMwLX+ZhFA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.33': + resolution: {integrity: sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.1089.0': + resolution: {integrity: sha512-NYLT340eRdqv8XIUEIEWcIYICDxBGxpgdyQ9veBkW5XxlRDNn19IZ8b46ddec+HBQea/18W+/IfogCqbac3CGQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.41': + resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1088.0': + resolution: {integrity: sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.36': + resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -2958,6 +3055,30 @@ packages: '@sinonjs/fake-timers@15.4.0': resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@smithy/core@3.29.5': + resolution: {integrity: sha512-i0dk2t5B+CwV/dcJdUHILYkOQF5lof8f44dFCfDWToGCxjT9YQ+CgHqTAvJxzc3+zqQwm2QtVoJ5IqiNar/CnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.10': + resolution: {integrity: sha512-MJenAe4OKRZUo1LdYYFDCsSHxaHvInIU/z52GsheO9vl1/VSySVCr0zkyKD6TFiGkSUaWGxvKZ/70OvgUZR5HQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.7': + resolution: {integrity: sha512-3zpg8yqqyXzoK2TsRDdkqVOj2RDBFfLXwCczOZ5c7TWB4eiaebfSCsbMjDPYB3PJ9ihV62QaeadZ+wLadZtNGA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.9.7': + resolution: {integrity: sha512-wCU8HCLjAtAVqxxe0j2xff9LcEPw3yjBbg5IdQDIYFnxnPxbxcSLc7rgex7kqm9L/WYOnJEgaWQlfDkZleozMA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.6.6': + resolution: {integrity: sha512-efP6DN3UTFrzIsGO42/xcabv8jU7+9nwEdphFUH7yL0k010ERyAWaO41KFQIDLcFZLZ8xzIQr4wplFxNzslSGQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3242,6 +3363,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -3844,9 +3968,6 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} - argparse@1.0.10: - resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} - argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -4068,6 +4189,9 @@ packages: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@5.1.2: resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} engines: {node: '>=10'} @@ -4370,8 +4494,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -5127,6 +5251,11 @@ packages: react: '*' react-native: '*' + expo-secure-store@57.0.1: + resolution: {integrity: sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA==} + peerDependencies: + expo: '*' + expo-server@1.0.5: resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==} engines: {node: '>=20.16.0'} @@ -6121,10 +6250,6 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} - hasBin: true - js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -6973,8 +7098,8 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} once@1.4.0: @@ -7935,9 +8060,6 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} - sprintf-js@1.0.3: - resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - sprintf-js@1.1.3: resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} @@ -9039,6 +9161,180 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@aws-sdk/checksums@3.1000.18': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.1089.0': + dependencies: + '@aws-sdk/checksums': 3.1000.18 + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-node': 3.972.70 + '@aws-sdk/middleware-sdk-s3': 3.972.64 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/core@3.975.3': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.36 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.5 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.61': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.4': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-login': 3.972.66 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.66': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.70': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.59 + '@aws-sdk/credential-provider-http': 3.972.61 + '@aws-sdk/credential-provider-ini': 3.973.4 + '@aws-sdk/credential-provider-process': 3.972.59 + '@aws-sdk/credential-provider-sso': 3.973.3 + '@aws-sdk/credential-provider-web-identity': 3.972.65 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/credential-provider-imds': 4.4.10 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.59': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.3': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/token-providers': 3.1088.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.65': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.33': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/fetch-http-handler': 5.6.7 + '@smithy/node-http-handler': 4.9.7 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.1089.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/signature-v4-multi-region': 3.996.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.41': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1088.0': + dependencies: + '@aws-sdk/core': 3.975.3 + '@aws-sdk/nested-clients': 3.997.33 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.2': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.36': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -9792,7 +10088,7 @@ snapshots: bplist-parser: 0.3.2 chalk: 4.1.2 ci-info: 3.9.0 - compression: 1.8.1 + compression: 1.7.5 connect: 3.7.0 debug: 4.4.3(supports-color@10.2.2) env-editor: 0.4.2 @@ -10378,7 +10674,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.2 + js-yaml: 4.1.1 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.3': {} @@ -11925,6 +12221,39 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@smithy/core@3.29.5': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.10': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.9.7': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/signature-v4@5.6.6': + dependencies: + '@smithy/core': 3.29.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@stellar/freighter-api@6.0.1': @@ -12192,6 +12521,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.9.1 + '@types/compression@1.7.5': + dependencies: + '@types/express': 5.0.6 + '@types/connect@3.4.38': dependencies: '@types/node': 25.9.1 @@ -13016,10 +13349,6 @@ snapshots: arg@5.0.2: {} - argparse@1.0.10: - dependencies: - sprintf-js: 1.0.3 - argparse@2.0.1: {} aria-hidden@1.2.6: @@ -13341,6 +13670,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + boxen@5.1.2: dependencies: ansi-align: 3.0.1 @@ -13661,13 +13992,13 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.7.5: dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 negotiator: 0.6.4 - on-headers: 1.1.0 + on-headers: 1.0.2 safe-buffer: 5.2.1 vary: 1.1.2 transitivePeerDependencies: @@ -14566,6 +14897,10 @@ snapshots: - supports-color - typescript + expo-secure-store@57.0.1(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3)): + dependencies: + expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + expo-server@1.0.5: {} expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0): @@ -16188,11 +16523,6 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: - dependencies: - argparse: 1.0.10 - esprima: 4.0.1 - js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -17178,7 +17508,7 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} + on-headers@1.0.2: {} once@1.4.0: dependencies: @@ -18286,8 +18616,6 @@ snapshots: split2@4.2.0: {} - sprintf-js@1.0.3: {} - sprintf-js@1.1.3: {} sqlstring@2.3.3: {}