diff --git a/migration/1781788436511-AddArchiveAnchoring.js b/migration/1781788436511-AddArchiveAnchoring.js new file mode 100644 index 0000000000..4c40c6ccce --- /dev/null +++ b/migration/1781788436511-AddArchiveAnchoring.js @@ -0,0 +1,34 @@ +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +/** + * @class + * @implements {MigrationInterface} + */ +module.exports = class AddArchiveAnchoring1781788436511 { + name = 'AddArchiveAnchoring1781788436511' + + /** + * @param {QueryRunner} queryRunner + */ + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "archive_batch" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "merkleRoot" character varying(64) NOT NULL, "otsProof" text, "bitcoinHeight" integer, "status" character varying(256) NOT NULL DEFAULT 'pendingBtc', CONSTRAINT "PK_06f898016522a01c619a214aa18" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "archive_file" ("id" SERIAL NOT NULL, "updated" TIMESTAMP NOT NULL DEFAULT now(), "created" TIMESTAMP NOT NULL DEFAULT now(), "bucket" character varying(256) NOT NULL, "name" character varying(256) NOT NULL, "sha256" character varying(64) NOT NULL, "leafIndex" integer, "batchId" integer, CONSTRAINT "PK_17e252452a46a911a66d67dd50d" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e7da94bafd8e348ead75dcb000" ON "archive_file" ("bucket", "name") `); + await queryRunner.query(`CREATE INDEX "IDX_4065eeb67cf015dc7327499de9" ON "archive_file" ("batchId") `); + await queryRunner.query(`ALTER TABLE "archive_file" ADD CONSTRAINT "FK_4065eeb67cf015dc7327499de94" FOREIGN KEY ("batchId") REFERENCES "archive_batch"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + /** + * @param {QueryRunner} queryRunner + */ + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "archive_file" DROP CONSTRAINT "FK_4065eeb67cf015dc7327499de94"`); + await queryRunner.query(`DROP INDEX "public"."IDX_4065eeb67cf015dc7327499de9"`); + await queryRunner.query(`DROP INDEX "public"."IDX_e7da94bafd8e348ead75dcb000"`); + await queryRunner.query(`DROP TABLE "archive_file"`); + await queryRunner.query(`DROP TABLE "archive_batch"`); + } +} diff --git a/package-lock.json b/package-lock.json index 8dde9abddd..a7eec6e332 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@arbitrum/sdk": "^3.7.3", "@arkade-os/sdk": "^0.4.36", - "@azure/storage-blob": "^12.29.1", + "@aws-sdk/client-s3": "^3.1071.0", "@blockfrost/blockfrost-js": "^6.1.0", "@buildonspark/spark-sdk": "^0.6.7", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", @@ -94,6 +94,7 @@ "node-pty": "^1.1.0", "node-sql-parser": "^5.3.13", "nodemailer": "^6.10.1", + "opentimestamps": "^0.4.9", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.15.2", @@ -131,6 +132,7 @@ "@types/passport-jwt": "^3.0.13", "@types/pdfkit": "^0.13.9", "@types/supertest": "^2.0.16", + "aws-sdk-client-mock": "^4.1.0", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", @@ -419,11 +421,87 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", @@ -439,7 +517,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -452,7 +529,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -466,7 +542,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -480,7 +555,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/util": "^5.2.0", @@ -495,7 +569,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -505,7 +578,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.222.0", @@ -517,7 +589,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -530,7 +601,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^2.2.0", @@ -544,7 +614,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@smithy/util-buffer-from": "^2.2.0", @@ -554,68 +623,61 @@ "node": ">=14.0.0" } }, - "node_modules/@aws-sdk/client-ses": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.982.0.tgz", - "integrity": "sha512-FMfZsrdevWomqEwLWaW5Jfq+8jRbROQe8sbEANVTNPYBfXvnd8TxPs/09h7TgFjtSB7hsjUv8Ja6IjeMV5HHPA==", - "dev": true, + "node_modules/@aws-sdk/checksums": { + "version": "3.1000.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.7.tgz", + "integrity": "sha512-qh0fG/RtrFztst4+vn1HZehAvAhr5Jlq/WMP7e5KvvfF16oNVBc9CDNVdxdm19vzOY2x0qiDMFCRjhxQAusGWQ==", "license": "Apache-2.0", "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1071.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1071.0.tgz", + "integrity": "sha512-BqsqkaU/FztbQnq5Aw0BK6/weQgwnC3n2w19M7CjEjRHscr5dZU8+ihi7PIY6UMW9RkJrzUUEmaoHQrIVScFYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/credential-provider-node": "^3.972.5", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.6", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.982.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.4", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.22.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/middleware-retry": "^4.4.29", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.28", - "@smithy/util-defaults-mode-node": "^4.2.31", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.8", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-node": "^3.972.57", + "@aws-sdk/middleware-flexible-checksums": "^3.974.32", + "@aws-sdk/middleware-sdk-s3": "^3.972.53", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/client-sso": { + "node_modules/@aws-sdk/client-ses": { "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.982.0.tgz", - "integrity": "sha512-qJrIiivmvujdGqJ0ldSUvhN3k3N7GtPesoOI1BSt0fNXovVnMz4C/JmnkhZihU7hJhDvxJaBROLYTU+lpild4w==", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.982.0.tgz", + "integrity": "sha512-FMfZsrdevWomqEwLWaW5Jfq+8jRbROQe8sbEANVTNPYBfXvnd8TxPs/09h7TgFjtSB7hsjUv8Ja6IjeMV5HHPA==", "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.6", + "@aws-sdk/credential-provider-node": "^3.972.5", "@aws-sdk/middleware-host-header": "^3.972.3", "@aws-sdk/middleware-logger": "^3.972.3", "@aws-sdk/middleware-recursion-detection": "^3.972.3", @@ -650,6 +712,7 @@ "@smithy/util-middleware": "^4.2.8", "@smithy/util-retry": "^4.2.8", "@smithy/util-utf8": "^4.2.0", + "@smithy/util-waiter": "^4.2.8", "tslib": "^2.6.2" }, "engines": { @@ -657,24 +720,18 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.6.tgz", - "integrity": "sha512-pz4ZOw3BLG0NdF25HoB9ymSYyPbMiIjwQJ2aROXRhAzt+b+EOxStfFv8s5iZyP6Kiw7aYhyWxj5G3NhmkoOTKw==", - "dev": true, + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.22.tgz", + "integrity": "sha512-YofH63shc6YRdXjz80BJkpJW+Bkn0Cuu2dn4Rv7s9G2Idt58tgtzQEWxrR2xVljlVfIBeUjPuULnSVYLke3sUQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/xml-builder": "^3.972.4", - "@smithy/core": "^3.22.0", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/signature-v4": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.30", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", "tslib": "^2.6.2" }, "engines": { @@ -682,16 +739,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.4.tgz", - "integrity": "sha512-/8dnc7+XNMmViEom2xsNdArQxQPSgy4Z/lm6qaFPTrMFesT1bV3PsBhb19n09nmxHdrtQskYmViddUIjUQElXg==", - "dev": true, + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.48.tgz", + "integrity": "sha512-h6FEC95fbexUd6zxm4PdgS82bTcI2PRtUb2ZwMipb/Xr8bPwtf0G8rBo2jp7NA24Mbx2JA8/WingiYpA9RCCyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -699,21 +755,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.6.tgz", - "integrity": "sha512-5ERWqRljiZv44AIdvIRQ3k+EAV0Sq2WeJHvXuK7gL7bovSxOf8Al7MLH7Eh3rdovH4KHFnlIty7J71mzvQBl5Q==", - "dev": true, + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.50.tgz", + "integrity": "sha512-lJO3OLpjvz5m/RSBQmsG/CEUGsvCy5ruxKwPQaOCqxqCMuyYT2BZwQUTDZVVwqQ9LrZKuK24JSa6r31hL/tvkg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/util-stream": "^4.5.10", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -721,25 +773,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.4.tgz", - "integrity": "sha512-eRUg+3HaUKuXWn/lEMirdiA5HOKmEl8hEHVuszIDt2MMBUKgVX5XNGmb3XmbgU17h6DZ+RtjbxQpjhz3SbTjZg==", - "dev": true, + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.55.tgz", + "integrity": "sha512-TBoF4buBGYhXjdZAryayY2TrkQj2B2KfE/msG4V53XCt+w0EhEwM2JRjx8p2grJ2C6gtH5++SAwEvGMRdi0yyw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/credential-provider-env": "^3.972.4", - "@aws-sdk/credential-provider-http": "^3.972.6", - "@aws-sdk/credential-provider-login": "^3.972.4", - "@aws-sdk/credential-provider-process": "^3.972.4", - "@aws-sdk/credential-provider-sso": "^3.972.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.4", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-login": "^3.972.54", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -747,19 +797,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.4.tgz", - "integrity": "sha512-nLGjXuvWWDlQAp505xIONI7Gam0vw2p7Qu3P6on/W2q7rjJXtYjtpHbcsaOjJ/pAju3eTvEQuSuRedcRHVQIAQ==", - "dev": true, + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.54.tgz", + "integrity": "sha512-hBWI3wZTdTGiuMfmPts6AWbAjFfRniOQnqx68tc2cQvRKWawFbN9wkLOVPWM1FAOyowZU73mC6Fi+rHSHNyLFw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -767,23 +814,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.5.tgz", - "integrity": "sha512-VWXKgSISQCI2GKN3zakTNHSiZ0+mux7v6YHmmbLQp/o3fvYUQJmKGcLZZzg2GFA+tGGBStplra9VFNf/WwxpYg==", - "dev": true, + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.57.tgz", + "integrity": "sha512-u6dClpzNdWf1HGWz4wwhdXi1wiOofCLniM9S4BQQGlLAN9TW7VB+ld5V533GdKrYMaFeBGFqKnj0JCYvynLqwQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.4", - "@aws-sdk/credential-provider-http": "^3.972.6", - "@aws-sdk/credential-provider-ini": "^3.972.4", - "@aws-sdk/credential-provider-process": "^3.972.4", - "@aws-sdk/credential-provider-sso": "^3.972.4", - "@aws-sdk/credential-provider-web-identity": "^3.972.4", - "@aws-sdk/types": "^3.973.1", - "@smithy/credential-provider-imds": "^4.2.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/credential-provider-env": "^3.972.48", + "@aws-sdk/credential-provider-http": "^3.972.50", + "@aws-sdk/credential-provider-ini": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.48", + "@aws-sdk/credential-provider-sso": "^3.972.54", + "@aws-sdk/credential-provider-web-identity": "^3.972.54", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -791,17 +836,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.4.tgz", - "integrity": "sha512-TCZpWUnBQN1YPk6grvd5x419OfXjHvhj5Oj44GYb84dOVChpg/+2VoEj+YVA4F4E/6huQPNnX7UYbTtxJqgihw==", - "dev": true, + "version": "3.972.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.48.tgz", + "integrity": "sha512-w6VZwojPt12WnEkAUy6Nu4K6sWCbBmR7QX390b0nE6vRvkXbrYr9Lq9VySGkfjiMjpUA87op+J4EgvRmtWIDoQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -809,19 +852,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.4.tgz", - "integrity": "sha512-wzsGwv9mKlwJ3vHLyembBvGE/5nPUIwRR2I51B1cBV4Cb4ql9nIIfpmHzm050XYTY5fqTOKJQnhLj7zj89VG8g==", - "dev": true, + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.54.tgz", + "integrity": "sha512-23uZpIpF2SIFDCa1fcWa202tK4gGeyvX6GIIAjiB8WBsvsVRBMnJ/7dCxHzxf7eZT7GToJg837LDIBnZsl/VUg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.982.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/token-providers": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/token-providers": "3.1071.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -829,18 +870,29 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.4.tgz", - "integrity": "sha512-hIzw2XzrG8jzsUSEatehmpkd5rWzASg5IHUfA+m01k/RtvfAML7ZJVVohuKdhAYx+wV2AThLiQJVzqn7F0khrw==", - "dev": true, + "version": "3.972.54", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.54.tgz", + "integrity": "sha512-0Iv5QttS6wcATlodYKgvQj6B9Db51rx7NU9fqu0PoLeS4BIgdYMc/QK4smwLwpm5RFrs02V/eLyEFp3FklvlNQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.32.tgz", + "integrity": "sha512-KhuzFMzUbb3oEj43CdPDbEJ/RG/RkErkmXk3J/LE8OPFNvkCn8PYPMpjOLgzAzvxBacsSyytdWf+R50q0alJ4w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/checksums": "^3.1000.7", "tslib": "^2.6.2" }, "engines": { @@ -895,6 +947,23 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.53", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.53.tgz", + "integrity": "sha512-keWp6Z5cEIJzPwoCf/WRm0ceAeephPDDivhRsK/xXs2ZYXyypJ2/DL9G1IR0bz/s+iZC0EgzmFV4r7rlvLlxQQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-user-agent": { "version": "3.972.6", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.6.tgz", @@ -915,49 +984,20 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.982.0.tgz", - "integrity": "sha512-VVkaH27digrJfdVrT64rjkllvOp4oRiZuuJvrylLXAKl18ujToJR7AqpDldL/LS63RVne3QWIpkygIymxFtliQ==", - "dev": true, + "version": "3.997.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.22.tgz", + "integrity": "sha512-4IwtcYSxEIVw5hcp8ogq0CMbFNZFw7jJUetpfFUhFFeqsa1K8j2Ihg2hnxLyOp3stMZnXda6VzOmPi1AFZQXcg==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/middleware-host-header": "^3.972.3", - "@aws-sdk/middleware-logger": "^3.972.3", - "@aws-sdk/middleware-recursion-detection": "^3.972.3", - "@aws-sdk/middleware-user-agent": "^3.972.6", - "@aws-sdk/region-config-resolver": "^3.972.3", - "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.982.0", - "@aws-sdk/util-user-agent-browser": "^3.972.3", - "@aws-sdk/util-user-agent-node": "^3.972.4", - "@smithy/config-resolver": "^4.4.6", - "@smithy/core": "^3.22.0", - "@smithy/fetch-http-handler": "^5.3.9", - "@smithy/hash-node": "^4.2.8", - "@smithy/invalid-dependency": "^4.2.8", - "@smithy/middleware-content-length": "^4.2.8", - "@smithy/middleware-endpoint": "^4.4.12", - "@smithy/middleware-retry": "^4.4.29", - "@smithy/middleware-serde": "^4.2.9", - "@smithy/middleware-stack": "^4.2.8", - "@smithy/node-config-provider": "^4.3.8", - "@smithy/node-http-handler": "^4.4.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/smithy-client": "^4.11.1", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.28", - "@smithy/util-defaults-mode-node": "^4.2.31", - "@smithy/util-endpoints": "^3.2.8", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-retry": "^4.2.8", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -981,19 +1021,32 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/token-providers": { - "version": "3.982.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.982.0.tgz", - "integrity": "sha512-v3M0KYp2TVHYHNBT7jHD9lLTWAdS9CaWJ2jboRKt0WAB65bA7iUEpR+k4VqKYtpQN4+8kKSc4w+K6kUNZkHKQw==", - "dev": true, + "version": "3.1071.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1071.0.tgz", + "integrity": "sha512-4LDW2Qob6LoLFuqYSYZq2AyTE9koSE9+i+n5UZcm10GpmQOK0zRD9L4uYlzItiTKksIWgC/qMFChAi3RvKYtMg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.6", - "@aws-sdk/nested-clients": "3.982.0", - "@aws-sdk/types": "^3.973.1", - "@smithy/property-provider": "^4.2.8", - "@smithy/shared-ini-file-loader": "^4.4.3", - "@smithy/types": "^4.12.0", + "@aws-sdk/core": "^3.974.22", + "@aws-sdk/nested-clients": "^3.997.22", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1001,13 +1054,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.1.tgz", - "integrity": "sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==", - "dev": true, + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.12.0", + "@smithy/types": "^4.14.3", "tslib": "^2.6.2" }, "engines": { @@ -1035,7 +1087,6 @@ "version": "3.965.4", "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.4.tgz", "integrity": "sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -1083,14 +1134,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", - "dev": true, + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.30.tgz", + "integrity": "sha512-StElZPEoBquWwNqw1AcfpzEyZqJvFxouG+mpDNYlcH6ZOrqd2CuIryv+8LV8gNHZUOyKyJF3Dq9vxaXEmDR9TQ==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" }, @@ -1102,7 +1151,6 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.3.tgz", "integrity": "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -1144,6 +1192,8 @@ "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.10.1.tgz", "integrity": "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", @@ -1158,6 +1208,8 @@ "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.10.1.tgz", "integrity": "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -1176,6 +1228,8 @@ "resolved": "https://registry.npmjs.org/@azure/core-http-compat/-/core-http-compat-2.3.1.tgz", "integrity": "sha512-az9BkXND3/d5VgdRRQVkiJb2gOmDU8Qcq4GvjtBmDICNiQ9udFmDk4ZpSB5Qq1OmtDJGlQAfBaS4palFsazQ5g==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-client": "^1.10.0", @@ -1190,6 +1244,8 @@ "resolved": "https://registry.npmjs.org/@azure/core-lro/-/core-lro-2.7.2.tgz", "integrity": "sha512-0YIpccoX8m/k00O7mDDMdJpbr6mf1yWo2dfmxt5A8XVZVVMz2SSKaEbMCeJRvgQ0IaSlqhjT47p4hVIRRy90xw==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-util": "^1.2.0", @@ -1205,6 +1261,8 @@ "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.6.2.tgz", "integrity": "sha512-YKWi9YuCU04B55h25cnOYZHxXYtEvQEbKST5vqRga7hWY9ydd3FZHdeQF8pyh+acWZvppw13M/LMGx0LABUVMA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "tslib": "^2.6.2" }, @@ -1217,6 +1275,8 @@ "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.22.1.tgz", "integrity": "sha512-UVZlVLfLyz6g3Hy7GNDpooMQonUygH7ghdiSASOOHy97fKj/mPLqgDX7aidOijn+sCMU+WU8NjlPlNTgnvbcGA==", "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", @@ -1256,19 +1316,6 @@ "node": ">=20.0.0" } }, - "node_modules/@azure/core-xml": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.5.0.tgz", - "integrity": "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw==", - "license": "MIT", - "dependencies": { - "fast-xml-parser": "^5.0.7", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@azure/identity": { "version": "4.13.1", "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.1.tgz", @@ -1439,55 +1486,10 @@ "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@azure/storage-blob": { - "version": "12.29.1", - "resolved": "https://registry.npmjs.org/@azure/storage-blob/-/storage-blob-12.29.1.tgz", - "integrity": "sha512-7ktyY0rfTM0vo7HvtK6E3UvYnI9qfd6Oz6z/+92VhGRveWng3kJwMKeUpqmW/NmwcDNbxHpSlldG+vsUnRFnBg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-client": "^1.9.3", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-lro": "^2.2.0", - "@azure/core-paging": "^1.6.2", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/core-xml": "^1.4.5", - "@azure/logger": "^1.1.4", - "@azure/storage-common": "^12.1.1", - "events": "^3.0.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@azure/storage-common": { - "version": "12.1.1", - "resolved": "https://registry.npmjs.org/@azure/storage-common/-/storage-common-12.1.1.tgz", - "integrity": "sha512-eIOH1pqFwI6UmVNnDQvmFeSg0XppuzDLFeUNO/Xht7ODAzRLgGDh7h550pSxoA+lPDxBl1+D2m/KG3jWzCUjTg==", - "license": "MIT", - "dependencies": { - "@azure/abort-controller": "^2.1.2", - "@azure/core-auth": "^1.9.0", - "@azure/core-http-compat": "^2.2.0", - "@azure/core-rest-pipeline": "^1.19.1", - "@azure/core-tracing": "^1.2.0", - "@azure/core-util": "^1.11.0", - "@azure/logger": "^1.1.4", - "events": "^3.3.0", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=20.0.0" + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "node_modules/@babel/code-frame": { @@ -8239,6 +8241,17 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.3.tgz", + "integrity": "sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "type-detect": "^4.1.0" + } + }, "node_modules/@smithy/abort-controller": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.8.tgz", @@ -8272,21 +8285,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.22.1", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.22.1.tgz", - "integrity": "sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==", - "dev": true, + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.25.1.tgz", + "integrity": "sha512-zpDbpXBCBsxfLtG2GEUyfgvHvSFrw5CwDZSNzL0v52gx/c3oPlPbm+7W7num8xs6vyiUBn+bvYPHcQDOXZynCQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.9", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-stream": "^4.5.11", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -8294,16 +8299,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.8.tgz", - "integrity": "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==", - "dev": true, + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.1.tgz", + "integrity": "sha512-TSAF5NHgxEsllbErYWbK8aLnl5L601NGc5VYJlSPsKnf3YlkhdoBN+geGcaU00oiw2OK3QO5LA3QNXiiWhCidQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.8", - "@smithy/property-provider": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/url-parser": "^4.2.8", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -8311,16 +8313,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.9", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.9.tgz", - "integrity": "sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==", - "dev": true, + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.1.tgz", + "integrity": "sha512-96JrD1q71anokymx9Iblb+zKmNQYNstlV/25A9ZYIJ2A0rp1r7/GZAIm0bDWSmVvz3DpNOCZuabzsiL+w0UHhw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -8472,16 +8471,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.9", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.9.tgz", - "integrity": "sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==", - "dev": true, + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.1.tgz", + "integrity": "sha512-emtXvoky671puri18ETf64AFIQUGIEA093F2drXpBgB0OGnBLjcwNR3CA2mYu62IAqNsS56xa5lnTxAgPq7cjw==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.8", - "@smithy/protocol-http": "^5.3.8", - "@smithy/querystring-builder": "^4.2.8", - "@smithy/types": "^4.12.0", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -8516,21 +8512,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/querystring-builder": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.8.tgz", - "integrity": "sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.12.0", - "@smithy/util-uri-escape": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/querystring-parser": { "version": "4.2.8", "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.8.tgz", @@ -8573,19 +8554,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.8.tgz", - "integrity": "sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==", - "dev": true, + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.1.tgz", + "integrity": "sha512-X9rVls3En0z3NtrmguTmpRM0/NqtWUxBjal6fcAkwtsub+gOdLZ6kD+V7xhUgFMGdG14bHbZ7M5QjaRI1+DatQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.8", - "@smithy/types": "^4.12.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.8", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/core": "^3.25.1", + "@smithy/types": "^4.15.0", "tslib": "^2.6.2" }, "engines": { @@ -8612,10 +8587,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", - "dev": true, + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8819,19 +8793,6 @@ "node": ">=18.0.0" } }, - "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@smithy/util-utf8": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", @@ -10149,6 +10110,23 @@ "integrity": "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==", "license": "MIT" }, + "node_modules/@types/sinon": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", + "integrity": "sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinonjs__fake-timers": "*" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz", + "integrity": "sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -11945,6 +11923,18 @@ "node": ">=6" } }, + "node_modules/aws-sdk-client-mock": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/aws-sdk-client-mock/-/aws-sdk-client-mock-4.1.0.tgz", + "integrity": "sha512-h/tOYTkXEsAcV3//6C1/7U4ifSpKyJvb6auveAepqqNJl6TdZaPFEtKjBQNf8UxQdDP850knB2i/whq4zlsxJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sinon": "^17.0.3", + "sinon": "^18.0.1", + "tslib": "^2.1.0" + } + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -12500,6 +12490,11 @@ "node": "*" } }, + "node_modules/bigi": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/bigi/-/bigi-1.4.2.tgz", + "integrity": "sha512-ddkU+dFIuEIW8lE7ZwdIAf2UPoM90eaprg5m3YXAVVTmKlqV/9BX4A2M8BOK2yOq6/VgZFVhK6QAxJebhlbhzw==" + }, "node_modules/bigint-buffer": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/bigint-buffer/-/bigint-buffer-1.1.5.tgz", @@ -12543,6 +12538,42 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bip-schnorr": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/bip-schnorr/-/bip-schnorr-0.6.4.tgz", + "integrity": "sha512-dNKw7Lea8B0wMIN4OjEmOk/Z5qUGqoPDY0P2QttLqGk1hmDPytLWW8PR5Pb6Vxy6CprcdEgfJpOjUu+ONQveyg==", + "license": "MIT", + "dependencies": { + "bigi": "^1.4.2", + "ecurve": "^1.0.6", + "js-sha256": "^0.9.0", + "randombytes": "^2.1.0", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/bip-schnorr/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/bip174": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", @@ -12720,6 +12751,34 @@ "node": ">=4.0.0" } }, + "node_modules/bitcore-lib": { + "version": "8.25.47", + "resolved": "https://registry.npmjs.org/bitcore-lib/-/bitcore-lib-8.25.47.tgz", + "integrity": "sha512-qDZr42HuP4P02I8kMGZUx/vvwuDsz8X3rQxXLfM0BtKzlQBcbSM7ycDkDN99Xc5jzpd4fxNQyyFXOmc6owUsrQ==", + "license": "MIT", + "dependencies": { + "bech32": "=2.0.0", + "bip-schnorr": "=0.6.4", + "bn.js": "=4.11.8", + "bs58": "^4.0.1", + "buffer-compare": "=1.1.1", + "elliptic": "^6.5.3", + "inherits": "=2.0.1", + "lodash": "^4.17.20" + } + }, + "node_modules/bitcore-lib/node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "license": "MIT" + }, + "node_modules/bitcore-lib/node_modules/inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha512-8nWq2nLTAwd02jTqJExUYFSD/fKq6VH9Y/oG2accc/kdI0V98Bag8d5a4gi3XHz73rDWa2PvTtvcWYquKqSENA==", + "license": "ISC" + }, "node_modules/bitset": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/bitset/-/bitset-5.2.3.tgz", @@ -12950,7 +13009,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", - "dev": true, "license": "MIT" }, "node_modules/boxen": { @@ -13155,6 +13213,11 @@ "ieee754": "^1.2.1" } }, + "node_modules/buffer-compare": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-compare/-/buffer-compare-1.1.1.tgz", + "integrity": "sha512-O6NvNiHZMd3mlIeMDjP6t/gPG75OqGPeiRZXoMQZJ6iy9GofCls4Ijs5YkPZZwoysizLiedhticmdyx/GyHghA==" + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -13291,6 +13354,27 @@ "node": ">=10.16.0" } }, + "node_modules/bytebuffer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/bytebuffer/-/bytebuffer-5.0.1.tgz", + "integrity": "sha512-IuzSdmADppkZ6DlpycMkm8l9zeEq16fWtLvunEwFiYciR/BHo4E8/xs5piFquG+Za8OWmMqHF8zuRviz2LHvRQ==", + "license": "Apache-2.0", + "dependencies": { + "long": "~3" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/bytebuffer/node_modules/long": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/long/-/long-3.2.0.tgz", + "integrity": "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.6" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -14868,7 +14952,6 @@ "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.3.1" } @@ -15105,6 +15188,16 @@ "node": ">=8.0.0" } }, + "node_modules/ecurve": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/ecurve/-/ecurve-1.0.6.tgz", + "integrity": "sha512-/BzEjNfiSuB7jIWKcS/z8FK9jNjmEWvUV2YZ4RLSmcDtP7Lq0m6FvDuSnJpBlDpGRpfRQeTLGLBI8H+kEv0r+w==", + "license": "MIT", + "dependencies": { + "bigi": "^1.1.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/editorconfig": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", @@ -16186,6 +16279,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -17101,6 +17195,12 @@ "node": ">= 0.8" } }, + "node_modules/fs": { + "version": "0.0.1-security", + "resolved": "https://registry.npmjs.org/fs/-/fs-0.0.1-security.tgz", + "integrity": "sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==", + "license": "ISC" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -20362,6 +20462,12 @@ "optional": true, "peer": true }, + "node_modules/js-sha256": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.9.0.tgz", + "integrity": "sha512-sga3MHh9sgQN2+pJ9VYZ+1LPwXOxuBJBA5nrR5/ofPfuiJBE2hnjsaN8se8JznOmGLN2p49Pe5U/ttafcs/apA==", + "license": "MIT" + }, "node_modules/js-sha3": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", @@ -20634,6 +20740,13 @@ "node": ">= 6" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/jwa": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", @@ -22657,7 +22770,18 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "license": "MIT", - "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, "engines": { "node": "*" } @@ -23200,6 +23324,40 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "license": "MIT" }, + "node_modules/nise": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.5.tgz", + "integrity": "sha512-SnRDPDBjxZZoU2n0+gzzLtSvo1OZo7j6jnbXsoh3AFxEGhaFU7ZF0TmefuKERq79wxR2U+MPn7ArW+Tl+clC3A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^15.1.1", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.3.0" + } + }, + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/no-case": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/no-case/-/no-case-2.3.2.tgz", @@ -23629,6 +23787,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/opentimestamps": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/opentimestamps/-/opentimestamps-0.4.9.tgz", + "integrity": "sha512-EWA6nFSeNsD6aSX/Ek0WrWeTT+PKoP7455zWPkyvqf7/h7QsmDDbfFEY8S3Sd9/VxeQcyVu7yW0SqkvP+Zu/fg==", + "license": "LGPL-3.0", + "dependencies": { + "bitcore-lib": "^8.14.4", + "bytebuffer": "^5.0.1", + "commander": "^2.20.3", + "fs": "0.0.1-security", + "minimatch": "^3.0.4", + "moment-timezone": "^0.5.27", + "promise": "^7.3.1", + "properties": "^1.2.1", + "randomstring": "^1.1.5", + "request": "^2.85.0", + "request-promise": "^4.2.2" + }, + "bin": { + "ots-cli.js": "ots-cli.js" + } + }, + "node_modules/opentimestamps/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -24682,6 +24868,15 @@ "node": ">= 6" } }, + "node_modules/properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/properties/-/properties-1.2.1.tgz", + "integrity": "sha512-qYNxyMj1JeW54i/EWEFsM1cVwxJbtgPp8+0Wg9XjNaK6VE/c4oRi6PNu5p7w1mNXEIQIjV5Wwn8v8Gz82/QzdQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -25152,6 +25347,21 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/randomstring": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.3.1.tgz", + "integrity": "sha512-lgXZa80MUkjWdE7g2+PZ1xDLzc7/RokXVEQOv5NN2UOTChW1I8A9gha5a9xYBOqgaSoI6uJikDmCU8PyRdArRQ==", + "license": "MIT", + "dependencies": { + "randombytes": "2.1.0" + }, + "bin": { + "randomstring": "bin/randomstring" + }, + "engines": { + "node": "*" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -25357,6 +25567,40 @@ "node": ">= 6" } }, + "node_modules/request-promise": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/request-promise/-/request-promise-4.2.6.tgz", + "integrity": "sha512-HCHI3DJJUakkOr8fNoCc73E5nU5bqITjOYFMDrKHYOXWXrgD/SBaC7LjwuPymUprRyuF06UK7hd/lMHkmUXglQ==", + "deprecated": "request-promise has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", + "license": "ISC", + "dependencies": { + "bluebird": "^3.5.0", + "request-promise-core": "1.1.4", + "stealthy-require": "^1.1.1", + "tough-cookie": "^2.3.3" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, + "node_modules/request-promise-core": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", + "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", + "license": "ISC", + "dependencies": { + "lodash": "^4.17.19" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "request": "^2.34" + } + }, "node_modules/request/node_modules/form-data": { "version": "2.5.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", @@ -25415,6 +25659,16 @@ ], "license": "MIT" }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -26879,6 +27133,35 @@ "node": ">=4" } }, + "node_modules/sinon": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-18.0.1.tgz", + "integrity": "sha512-a2N2TDY1uGviajJ6r4D1CyRAkzE9NNVlYOV1wX5xQDuAk0ONgzgRl0EjCQuRCPxOwp13ghsMwt9Gdldujs39qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "11.2.2", + "@sinonjs/samsam": "^8.0.0", + "diff": "^5.2.0", + "nise": "^6.0.0", + "supports-color": "^7" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -27360,6 +27643,15 @@ "node": ">= 0.8" } }, + "node_modules/stealthy-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", + "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", + "license": "ISC", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/package.json b/package.json index 526e3da1e8..d8a011d3f8 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dependencies": { "@arbitrum/sdk": "^3.7.3", "@arkade-os/sdk": "^0.4.36", - "@azure/storage-blob": "^12.29.1", + "@aws-sdk/client-s3": "^3.1071.0", "@blockfrost/blockfrost-js": "^6.1.0", "@buildonspark/spark-sdk": "^0.6.7", "@cardano-foundation/cardano-verify-datasignature": "^1.0.11", @@ -106,15 +106,16 @@ "lnurl": "^0.27.0", "lodash": "^4.17.21", "morgan": "^1.10.1", - "pg": "^8.13.3", "nestjs-i18n": "^10.5.1", "node-2fa": "^2.0.3", "node-pty": "^1.1.0", "node-sql-parser": "^5.3.13", "nodemailer": "^6.10.1", + "opentimestamps": "^0.4.9", "passport": "^0.6.0", "passport-jwt": "^4.0.1", "pdfkit": "^0.15.2", + "pg": "^8.13.3", "qrcode": "^1.5.4", "reflect-metadata": "^0.1.14", "rimraf": "^4.4.1", @@ -148,6 +149,7 @@ "@types/passport-jwt": "^3.0.13", "@types/pdfkit": "^0.13.9", "@types/supertest": "^2.0.16", + "aws-sdk-client-mock": "^4.1.0", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", @@ -190,7 +192,8 @@ "body-parser": "1.20.3", "qs": "^6.14.1", "request": { - "form-data": "2.5.5" + "form-data": "2.5.5", + "uuid": "3.4.0" }, "multer": "^2.0.0", "uuid": "^9.0.0", diff --git a/scripts/kyc/kyc-storage.js b/scripts/kyc/kyc-storage.js index ba8fa2280a..93b427c444 100644 --- a/scripts/kyc/kyc-storage.js +++ b/scripts/kyc/kyc-storage.js @@ -53,7 +53,7 @@ async function main() { console.log('========================================'); console.log(''); console.log('In local development mode, KYC files are automatically'); - console.log('loaded from scripts/kyc/dummy-files/ by the azure-storage'); + console.log('loaded from scripts/kyc/dummy-files/ by the mock storage'); console.log('service when the requested file is not in memory storage.'); console.log(''); diff --git a/scripts/storage/provision-bucket.ts b/scripts/storage/provision-bucket.ts new file mode 100644 index 0000000000..eff6021dc7 --- /dev/null +++ b/scripts/storage/provision-bucket.ts @@ -0,0 +1,135 @@ +/** + * WORM bucket provisioning for the GeBüV anchoring pipeline (Stage 3). + * + * Creates (or reconciles) an S3-compatible bucket with Object Lock enabled and a default + * COMPLIANCE-mode retention, so archived compliance objects (KYC documents, EP2 settlement + * reports) become immutable for the legally required retention period (Swiss GeBüV: 10 years + * of business records; we provision a safety margin, default 11 years). + * + * IMPORTANT — dynamic EP2 containers: + * EP2 settlement reports are written to a per-merchant container resolved at runtime + * (paymentLinksConfigObj.ep2ReportContainer in fiat-output-job.service.ts). Each such + * container is a distinct S3 bucket and MUST be provisioned with this exact same Object + * Lock + COMPLIANCE retention BEFORE its first PUT — otherwise its objects are not WORM + * protected and Object Lock cannot be retro-fitted onto an existing non-locked bucket. + * Run this script once per container name before onboarding a new EP2 merchant. + * + * Idempotent: if the bucket already exists, creation is skipped and only the Object Lock + * default-retention configuration is (re)applied. + * + * Configuration (no silent defaults for credentials — fails fast if incomplete): + * - S3 endpoint/region/credentials come from the standard S3_* env vars (via Config.s3). + * + * Run with (bucket name required, retention years optional, default 11): + * BUCKET=kyc RETENTION_YEARS=11 npx ts-node scripts/storage/provision-bucket.ts + * npx ts-node scripts/storage/provision-bucket.ts kyc 11 + */ + +import { + CreateBucketCommand, + GetBucketVersioningCommand, + HeadBucketCommand, + ObjectLockRetentionMode, + PutBucketVersioningCommand, + PutObjectLockConfigurationCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +// Loaded after dotenv so Config.s3 reads the populated env. +import { Config } from '../../src/config/config'; + +function getBucketName(): string { + const bucket = process.env.BUCKET ?? process.argv[2]; + if (!bucket) throw new Error('Missing bucket name. Provide via BUCKET env var or as the first CLI argument.'); + return bucket; +} + +function getRetentionYears(): number { + const raw = process.env.RETENTION_YEARS ?? process.argv[3]; + if (raw == null) return 11; // GeBüV 10y + safety margin; explicit, not a silent credential default. + + const years = Number(raw); + if (!Number.isInteger(years) || years <= 0) + throw new Error(`Invalid RETENTION_YEARS: ${raw} (expected positive integer)`); + return years; +} + +function buildClient(): S3Client { + const { endpoint, region, accessKey, secretKey } = Config.s3; + if (!endpoint || !region || !accessKey || !secretKey) + throw new Error('Incomplete S3 config: S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY and S3_SECRET_KEY are required'); + + return new S3Client({ + endpoint, + region, + forcePathStyle: true, // MinIO requires path-style addressing (matches S3StorageService) + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); +} + +async function bucketExists(client: S3Client, bucket: string): Promise { + try { + await client.send(new HeadBucketCommand({ Bucket: bucket })); + return true; + } catch (e) { + const status = e?.$metadata?.httpStatusCode; + if (status === 404 || e?.name === 'NotFound' || e?.name === 'NoSuchBucket') return false; + throw e; + } +} + +async function ensureVersioning(client: S3Client, bucket: string): Promise { + // Object Lock requires versioning; CreateBucket with ObjectLockEnabledForBucket enables it + // implicitly, but we assert/enable it explicitly so reconciling an existing bucket is safe. + const current = await client.send(new GetBucketVersioningCommand({ Bucket: bucket })); + if (current.Status === 'Enabled') { + console.log(' Versioning: already enabled'); + return; + } + + await client.send(new PutBucketVersioningCommand({ Bucket: bucket, VersioningConfiguration: { Status: 'Enabled' } })); + console.log(' Versioning: enabled'); +} + +async function applyObjectLock(client: S3Client, bucket: string, years: number): Promise { + await client.send( + new PutObjectLockConfigurationCommand({ + Bucket: bucket, + ObjectLockConfiguration: { + ObjectLockEnabled: 'Enabled', + Rule: { DefaultRetention: { Mode: ObjectLockRetentionMode.COMPLIANCE, Years: years } }, + }, + }), + ); + console.log(` Object Lock: default retention COMPLIANCE / ${years} year(s)`); +} + +async function main(): Promise { + const bucket = getBucketName(); + const years = getRetentionYears(); + const client = buildClient(); + + console.log(`Provisioning WORM bucket "${bucket}" (endpoint ${Config.s3.endpoint})`); + + if (await bucketExists(client, bucket)) { + console.log(' Bucket: already exists -> reconciling lock configuration only'); + } else { + await client.send(new CreateBucketCommand({ Bucket: bucket, ObjectLockEnabledForBucket: true })); + console.log(' Bucket: created with Object Lock enabled'); + } + + await ensureVersioning(client, bucket); + await applyObjectLock(client, bucket, years); + + console.log(`Done. Bucket "${bucket}" is WORM-protected (COMPLIANCE, ${years}y).`); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error('Provisioning failed:', e?.message ?? e); + process.exit(1); + }); diff --git a/src/config/config.ts b/src/config/config.ts index 0c75396d2b..8034431355 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1165,18 +1165,23 @@ export class Configuration { tenantId: process.env.AZURE_TENANT_ID, clientId: process.env.AZURE_CLIENT_ID, clientSecret: process.env.AZURE_CLIENT_SECRET, - storage: { - url: process.env.AZURE_STORAGE_CONNECTION_STRING?.split(';') - .find((p) => p.includes('BlobEndpoint')) - ?.replace('BlobEndpoint=', ''), - connectionString: process.env.AZURE_STORAGE_CONNECTION_STRING, - }, appInsights: { appId: process.env.APPINSIGHTS_APP_ID, apiKey: process.env.APPINSIGHTS_API_KEY, }, }; + // S3-compatible object storage (on-prem MinIO) — replacement for Azure Blob. + // Connection only; WORM/Object-Lock retention is provisioned on the bucket itself. + // secrets -> .env; per-env endpoint/url -> compose. + s3 = { + endpoint: process.env.S3_ENDPOINT, + region: process.env.S3_REGION, + accessKey: process.env.S3_ACCESS_KEY, + secretKey: process.env.S3_SECRET_KEY, + publicUrl: process.env.S3_PUBLIC_URL, + }; + alby = { clientId: process.env.ALBY_CLIENT_ID, clientSecret: process.env.ALBY_CLIENT_SECRET, diff --git a/src/integration/infrastructure/azure-storage.service.ts b/src/integration/infrastructure/azure-storage.service.ts deleted file mode 100644 index 9d17cced65..0000000000 --- a/src/integration/infrastructure/azure-storage.service.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { BlobGetPropertiesResponse, BlobServiceClient, ContainerClient } from '@azure/storage-blob'; -import * as fs from 'fs'; -import * as path from 'path'; -import { Config, Environment, GetConfig } from 'src/config/config'; -import { DfxLogger } from 'src/shared/services/dfx-logger'; - -export interface BlobMetaData { - contentType: string; - created: Date; - updated: Date; - metadata: Record; -} - -export interface Blob extends BlobMetaData { - name: string; - url: string; -} - -export interface BlobContent extends BlobMetaData { - data: Buffer; -} - -// In-memory storage for local development -const mockStorage = new Map }>(); - -// Dummy files directory for local development -const DUMMY_FILES_DIR = path.join(process.cwd(), 'scripts', 'kyc', 'dummy-files'); - -// Load dummy file from disk -function loadDummyFile(filename: string): Buffer { - const filePath = path.join(DUMMY_FILES_DIR, filename); - return fs.readFileSync(filePath); -} - -export class AzureStorageService { - private readonly logger = new DfxLogger(AzureStorageService); - private readonly client: ContainerClient; - private readonly isMockMode: boolean; - - constructor(private readonly container: string) { - const config = GetConfig(); - this.isMockMode = config.environment === Environment.LOC; - - if (this.isMockMode) { - this.logger.verbose(`AzureStorageService [${container}] running in MOCK mode`); - return; - } - - const connectionString = config.azure.storage.connectionString; - if (connectionString) - this.client = BlobServiceClient.fromConnectionString(connectionString).getContainerClient(container); - } - - async listBlobs(prefix?: string): Promise { - if (this.isMockMode) { - const blobs: Blob[] = []; - const keyPrefix = `${this.container}/${prefix ?? ''}`; - for (const [key, value] of mockStorage.entries()) { - if (key.startsWith(keyPrefix)) { - const name = key.replace(`${this.container}/`, ''); - blobs.push({ - name, - url: this.blobUrl(name), - contentType: value.type, - created: new Date(), - updated: new Date(), - metadata: value.metadata ?? {}, - }); - } - } - return blobs; - } - - const iterator = this.client.listBlobsFlat({ prefix, includeMetadata: true }).byPage({ maxPageSize: 100 }); - - const blobs: Blob[] = []; - - let done = false; - while (!done) { - const batch = await iterator.next(); - - const items: Blob[] = batch.value?.segment?.blobItems?.map((i) => ({ - ...this.mapProperties(i.properties), - name: i.name, - url: this.blobUrl(i.name), - metadata: i.metadata, - })); - if (items) blobs.push(...items); - - done = batch.done; - } - - return blobs; - } - - async getBlob(name: string): Promise { - if (this.isMockMode) { - const key = `${this.container}/${name}`; - const stored = mockStorage.get(key); - - // Return stored data if available, otherwise return dummy test data based on file extension - if (stored) { - return { - data: stored.data, - contentType: stored.type, - created: new Date(), - updated: new Date(), - metadata: stored.metadata ?? {}, - }; - } - - // Provide dummy data for missing files in local dev mode - const ext = name.split('.').pop()?.toLowerCase(); - const filename = name.split('/').pop() ?? name; - - // Map common KYC file names to dummy files - const dummyFileMap: Record = { - 'id_front.png': { file: 'id_front.png', type: 'image/png' }, - 'id_back.png': { file: 'id_back.png', type: 'image/png' }, - 'selfie.jpg': { file: 'selfie.jpg', type: 'image/jpeg' }, - 'passport.png': { file: 'passport.png', type: 'image/png' }, - 'residence_permit.png': { file: 'residence_permit.png', type: 'image/png' }, - 'proof_of_address.pdf': { file: 'proof_of_address.pdf', type: 'application/pdf' }, - 'bank_statement.pdf': { file: 'bank_statement.pdf', type: 'application/pdf' }, - 'source_of_funds.pdf': { file: 'source_of_funds.pdf', type: 'application/pdf' }, - 'commercial_register.pdf': { file: 'commercial_register.pdf', type: 'application/pdf' }, - 'additional_document.pdf': { file: 'additional_document.pdf', type: 'application/pdf' }, - }; - - const mapping = dummyFileMap[filename]; - if (mapping) { - return { - data: loadDummyFile(mapping.file), - contentType: mapping.type, - created: new Date(), - updated: new Date(), - metadata: {}, - }; - } - - // Fallback based on extension - const isJpg = ext === 'jpg' || ext === 'jpeg'; - const isPdf = ext === 'pdf'; - return { - data: loadDummyFile(isPdf ? 'proof_of_address.pdf' : isJpg ? 'selfie.jpg' : 'id_front.png'), - contentType: isPdf ? 'application/pdf' : isJpg ? 'image/jpeg' : 'image/png', - created: new Date(), - updated: new Date(), - metadata: {}, - }; - } - - const blobClient = this.client.getBlockBlobClient(name); - const properties = await blobClient.getProperties(); - return { - ...this.mapProperties(properties), - data: await blobClient.downloadToBuffer(), - }; - } - - async uploadBlob(name: string, data: Buffer, type: string, metadata?: Record): Promise { - if (this.isMockMode) { - const key = `${this.container}/${name}`; - mockStorage.set(key, { data, type, metadata }); - this.logger.verbose(`Mock: Uploaded ${key} (${data.length} bytes)`); - return this.blobUrl(name); - } - - await this.client - .getBlockBlobClient(name) - .uploadData(data, { blobHTTPHeaders: { blobContentType: type }, metadata: !metadata ? undefined : metadata }); - - return this.blobUrl(name); - } - - async copyBlobs(sourcePrefix: string, targetPrefix: string): Promise { - const blobs = await this.listBlobs(sourcePrefix); - - for (const blob of blobs) { - const data = await this.getBlob(blob.name); - const targetName = blob.name.replace(sourcePrefix, targetPrefix); - await this.uploadBlob(targetName, data.data, data.contentType, blob.metadata); - } - } - - blobUrl(name: string): string { - const urlEncodedName = name.split('/').map(encodeURIComponent).join('/'); - return `${Config.azure.storage.url}${this.container}/${urlEncodedName}`; - } - - blobName(url: string): string { - const filePath = url.split(`${this.container}/`)[1]; - return filePath.split('/').map(decodeURIComponent).join('/'); - } - - private mapProperties(properties: BlobGetPropertiesResponse): BlobMetaData { - return { - contentType: properties.contentType, - created: properties.createdOn, - updated: properties.lastModified, - metadata: properties.metadata, - }; - } -} diff --git a/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts b/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts new file mode 100644 index 0000000000..e19ae0d290 --- /dev/null +++ b/src/integration/infrastructure/storage/__tests__/mock-storage.service.spec.ts @@ -0,0 +1,132 @@ +import { Test } from '@nestjs/testing'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { MockStorageService } from '../mock-storage.service'; + +const CONTAINER = 'mock-spec'; + +describe('MockStorageService', () => { + beforeAll(async () => { + await Test.createTestingModule({ + providers: [TestUtil.provideConfig({ s3: { publicUrl: 'https://files.test.local/' } })], + }).compile(); + }); + + describe('upload / get roundtrip', () => { + it('stores and reads back data, type and metadata', async () => { + const service = new MockStorageService(CONTAINER); + const data = Buffer.from('payload'); + + const url = await service.uploadBlob('user/1/note.txt', data, 'text/plain', { owner: 'u1' }); + expect(url).toBe('https://files.test.local/mock-spec/user/1/note.txt'); + + const res = await service.getBlob('user/1/note.txt'); + expect(res.data).toBe(data); + expect(res.contentType).toBe('text/plain'); + expect(res.metadata).toEqual({ owner: 'u1' }); + expect(res.created).toBeInstanceOf(Date); + expect(res.updated).toBeInstanceOf(Date); + }); + + it('defaults metadata to {} when uploaded without metadata', async () => { + const service = new MockStorageService(CONTAINER); + await service.uploadBlob('user/1/plain.txt', Buffer.from('x'), 'text/plain'); + + expect((await service.getBlob('user/1/plain.txt')).metadata).toEqual({}); + }); + }); + + describe('listBlobs', () => { + it('filters by prefix and maps stored entries', async () => { + const service = new MockStorageService('mock-spec-list'); + await service.uploadBlob('user/1/a.png', Buffer.from('a'), 'image/png', { k: 'v' }); + await service.uploadBlob('user/1/b.pdf', Buffer.from('b'), 'application/pdf'); + await service.uploadBlob('user/2/c.png', Buffer.from('c'), 'image/png'); + + const blobs = await service.listBlobs('user/1/'); + + expect(blobs.map((b) => b.name).sort()).toEqual(['user/1/a.png', 'user/1/b.pdf']); + const a = blobs.find((b) => b.name === 'user/1/a.png'); + expect(a).toMatchObject({ + url: 'https://files.test.local/mock-spec-list/user/1/a.png', + contentType: 'image/png', + metadata: { k: 'v' }, + }); + expect(a.created).toBeInstanceOf(Date); + }); + + it('returns [] when nothing matches the prefix', async () => { + const service = new MockStorageService('mock-spec-empty'); + + expect(await service.listBlobs('nope/')).toEqual([]); + }); + + it('lists all entries of the container when no prefix is given', async () => { + const service = new MockStorageService('mock-spec-all'); + await service.uploadBlob('x.png', Buffer.from('x'), 'image/png'); + await service.uploadBlob('y.png', Buffer.from('y'), 'image/png'); + + expect((await service.listBlobs()).map((b) => b.name).sort()).toEqual(['x.png', 'y.png']); + }); + }); + + describe('getBlob dummy-file fallback', () => { + const service = new MockStorageService('mock-spec-dummy'); + + it('returns a mapped dummy file by basename', async () => { + const res = await service.getBlob('user/1/id_front.png'); + expect(res.contentType).toBe('image/png'); + expect(res.data.length).toBeGreaterThan(0); + expect(res.metadata).toEqual({}); + }); + + it('returns a mapped pdf dummy file', async () => { + const res = await service.getBlob('proof_of_address.pdf'); + expect(res.contentType).toBe('application/pdf'); + expect(res.data.length).toBeGreaterThan(0); + }); + + it('falls back by .pdf extension for an unmapped name', async () => { + const res = await service.getBlob('user/1/some-random.pdf'); + expect(res.contentType).toBe('application/pdf'); + expect(res.data.length).toBeGreaterThan(0); + }); + + it('falls back by .jpg extension for an unmapped name', async () => { + const res = await service.getBlob('user/1/holiday.jpg'); + expect(res.contentType).toBe('image/jpeg'); + expect(res.data.length).toBeGreaterThan(0); + }); + + it('falls back by .jpeg extension for an unmapped name', async () => { + const res = await service.getBlob('user/1/holiday.jpeg'); + expect(res.contentType).toBe('image/jpeg'); + }); + + it('falls back to png for any other / extensionless name', async () => { + const res = await service.getBlob('user/1/whatever'); + expect(res.contentType).toBe('image/png'); + expect(res.data.length).toBeGreaterThan(0); + }); + }); + + describe('copyBlobs', () => { + it('copies matching blobs with the prefix replaced', async () => { + const service = new MockStorageService('mock-spec-copy'); + await service.uploadBlob('src/a.png', Buffer.from('a'), 'image/png', { k: 'v' }); + await service.uploadBlob('src/sub/b.pdf', Buffer.from('b'), 'application/pdf'); + + await service.copyBlobs('src/', 'dst/'); + + const copied = await service.listBlobs('dst/'); + expect(copied.map((b) => b.name).sort()).toEqual(['dst/a.png', 'dst/sub/b.pdf']); + + const copiedA = await service.getBlob('dst/a.png'); + expect(copiedA.data.equals(Buffer.from('a'))).toBe(true); + expect(copiedA.contentType).toBe('image/png'); + expect(copiedA.metadata).toEqual({ k: 'v' }); + + // source remains untouched + expect((await service.listBlobs('src/')).map((b) => b.name).sort()).toEqual(['src/a.png', 'src/sub/b.pdf']); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts b/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts new file mode 100644 index 0000000000..e28ce29ca3 --- /dev/null +++ b/src/integration/infrastructure/storage/__tests__/s3-storage.service.spec.ts @@ -0,0 +1,264 @@ +import { + CopyObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { Test } from '@nestjs/testing'; +import { mockClient } from 'aws-sdk-client-mock'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { S3StorageService } from '../s3-storage.service'; + +const validS3 = { + endpoint: 'https://s3.test.local', + region: 'us-east-1', + accessKey: 'access-key', + secretKey: 'secret-key', + publicUrl: 'https://files.test.local/', // trailing slash required +}; + +const CONTAINER = 'kyc'; + +const s3Mock = mockClient(S3Client); + +async function provideConfig(s3: Partial = validS3): Promise { + await Test.createTestingModule({ + providers: [TestUtil.provideConfig({ s3 })], + }).compile(); +} + +// Minimal stream stub matching the parts the service consumes (transformToByteArray). +function bodyStream(bytes: Uint8Array): { transformToByteArray: () => Promise } { + return { transformToByteArray: async () => bytes }; +} + +describe('S3StorageService', () => { + beforeEach(async () => { + s3Mock.reset(); + await provideConfig(); + }); + + describe('constructor', () => { + it('builds with a complete config', () => { + expect(new S3StorageService(CONTAINER)).toBeInstanceOf(S3StorageService); + }); + + it('throws when endpoint is missing', async () => { + await provideConfig({ ...validS3, endpoint: undefined }); + expect(() => new S3StorageService(CONTAINER)).toThrow('Incomplete S3 config'); + }); + + it('throws when region is missing', async () => { + await provideConfig({ ...validS3, region: undefined }); + expect(() => new S3StorageService(CONTAINER)).toThrow('Incomplete S3 config'); + }); + + it('throws when accessKey is missing', async () => { + await provideConfig({ ...validS3, accessKey: undefined }); + expect(() => new S3StorageService(CONTAINER)).toThrow('Incomplete S3 config'); + }); + + it('throws when secretKey is missing', async () => { + await provideConfig({ ...validS3, secretKey: undefined }); + expect(() => new S3StorageService(CONTAINER)).toThrow('Incomplete S3 config'); + }); + + it('throws when publicUrl is missing', async () => { + await provideConfig({ ...validS3, publicUrl: undefined }); + expect(() => new S3StorageService(CONTAINER)).toThrow('Incomplete S3 config'); + }); + + it('throws when publicUrl has no trailing slash', async () => { + await provideConfig({ ...validS3, publicUrl: 'https://files.test.local' }); + expect(() => new S3StorageService(CONTAINER)).toThrow('must end with a trailing slash'); + }); + }); + + describe('listBlobs', () => { + it('paginates ListObjectsV2 and heads each key', async () => { + s3Mock + .on(ListObjectsV2Command) + .resolvesOnce({ Contents: [{ Key: 'user/1/a.png' }], IsTruncated: true, NextContinuationToken: 'tok-1' }) + .resolvesOnce({ Contents: [{ Key: 'user/1/b.pdf' }], IsTruncated: false }); + + const created = new Date('2026-01-01T00:00:00.000Z'); + s3Mock + .on(HeadObjectCommand, { Key: 'user/1/a.png' }) + .resolves({ ContentType: 'image/png', LastModified: created, Metadata: { kind: 'id' } }); + s3Mock + .on(HeadObjectCommand, { Key: 'user/1/b.pdf' }) + .resolves({ ContentType: 'application/pdf', LastModified: created, Metadata: { kind: 'poa' } }); + + const blobs = await new S3StorageService(CONTAINER).listBlobs('user/1/'); + + expect(blobs).toEqual([ + { + name: 'user/1/a.png', + url: 'https://files.test.local/kyc/user/1/a.png', + contentType: 'image/png', + created, + updated: created, + metadata: { kind: 'id' }, + }, + { + name: 'user/1/b.pdf', + url: 'https://files.test.local/kyc/user/1/b.pdf', + contentType: 'application/pdf', + created, + updated: created, + metadata: { kind: 'poa' }, + }, + ]); + + const listCalls = s3Mock.commandCalls(ListObjectsV2Command); + expect(listCalls).toHaveLength(2); + expect(listCalls[0].args[0].input).toMatchObject({ Bucket: CONTAINER, Prefix: 'user/1/', MaxKeys: 1000 }); + expect(listCalls[0].args[0].input.ContinuationToken).toBeUndefined(); + expect(listCalls[1].args[0].input.ContinuationToken).toBe('tok-1'); + expect(s3Mock.commandCalls(HeadObjectCommand)).toHaveLength(2); + }); + + it('defaults missing metadata to an empty object', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: 'k' }], IsTruncated: false }); + s3Mock.on(HeadObjectCommand).resolves({ ContentType: 'image/png', LastModified: new Date() }); + + const [blob] = await new S3StorageService(CONTAINER).listBlobs(); + + expect(blob.metadata).toEqual({}); + }); + + it('skips entries without a Key and returns [] for an empty listing', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: undefined }], IsTruncated: false }); + + expect(await new S3StorageService(CONTAINER).listBlobs()).toEqual([]); + expect(s3Mock.commandCalls(HeadObjectCommand)).toHaveLength(0); + }); + + it('returns [] when Contents is absent', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ IsTruncated: false }); + + expect(await new S3StorageService(CONTAINER).listBlobs()).toEqual([]); + }); + }); + + describe('getBlob', () => { + it('returns the decoded body with metadata', async () => { + const bytes = new Uint8Array([1, 2, 3, 4]); + const created = new Date('2026-02-02T00:00:00.000Z'); + s3Mock.on(GetObjectCommand, { Bucket: CONTAINER, Key: 'doc.pdf' }).resolves({ + Body: bodyStream(bytes) as never, + ContentType: 'application/pdf', + LastModified: created, + Metadata: { source: 'kyc' }, + }); + + const res = await new S3StorageService(CONTAINER).getBlob('doc.pdf'); + + expect(res.data).toBeInstanceOf(Buffer); + expect(res.data.equals(Buffer.from(bytes))).toBe(true); + expect(res.contentType).toBe('application/pdf'); + expect(res.created).toBe(created); + expect(res.updated).toBe(created); + expect(res.metadata).toEqual({ source: 'kyc' }); + }); + + it('throws when the body is empty', async () => { + s3Mock.on(GetObjectCommand).resolves({ Body: undefined }); + + await expect(new S3StorageService(CONTAINER).getBlob('missing')).rejects.toThrow( + 'Empty body for blob kyc/missing', + ); + }); + }); + + describe('uploadBlob', () => { + it('sends a PutObjectCommand and returns the blob URL', async () => { + s3Mock.on(PutObjectCommand).resolves({}); + const data = Buffer.from('hello'); + + const url = await new S3StorageService(CONTAINER).uploadBlob('user/1/file.txt', data, 'text/plain', { + owner: 'u1', + }); + + expect(url).toBe('https://files.test.local/kyc/user/1/file.txt'); + const calls = s3Mock.commandCalls(PutObjectCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toMatchObject({ + Bucket: CONTAINER, + Key: 'user/1/file.txt', + Body: data, + ContentType: 'text/plain', + Metadata: { owner: 'u1' }, + }); + }); + + it('passes undefined metadata through', async () => { + s3Mock.on(PutObjectCommand).resolves({}); + + await new S3StorageService(CONTAINER).uploadBlob('a.bin', Buffer.from('x'), 'application/octet-stream'); + + expect(s3Mock.commandCalls(PutObjectCommand)[0].args[0].input.Metadata).toBeUndefined(); + }); + }); + + describe('copyBlobs', () => { + it('URL-encodes keys with spaces / special chars and rewrites the prefix', async () => { + const key = 'src/sub dir/file (1)+&.png'; + s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: key }], IsTruncated: false }); + s3Mock.on(CopyObjectCommand).resolves({}); + + await new S3StorageService(CONTAINER).copyBlobs('src/', 'dst/'); + + const calls = s3Mock.commandCalls(CopyObjectCommand); + expect(calls).toHaveLength(1); + expect(calls[0].args[0].input).toMatchObject({ + Bucket: CONTAINER, + Key: 'dst/sub dir/file (1)+&.png', + // path separators preserved, each segment percent-encoded + CopySource: 'kyc/src/sub%20dir/file%20(1)%2B%26.png', + }); + // copy must not fan out to HeadObject + expect(s3Mock.commandCalls(HeadObjectCommand)).toHaveLength(0); + }); + + it('copies every listed key', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ Contents: [{ Key: 'src/a' }, { Key: 'src/b' }], IsTruncated: false }); + s3Mock.on(CopyObjectCommand).resolves({}); + + await new S3StorageService(CONTAINER).copyBlobs('src/', 'dst/'); + + const keys = s3Mock.commandCalls(CopyObjectCommand).map((c) => c.args[0].input.Key); + expect(keys).toEqual(['dst/a', 'dst/b']); + }); + + it('does nothing for an empty source prefix', async () => { + s3Mock.on(ListObjectsV2Command).resolves({ Contents: [], IsTruncated: false }); + + await new S3StorageService(CONTAINER).copyBlobs('src/', 'dst/'); + + expect(s3Mock.commandCalls(CopyObjectCommand)).toHaveLength(0); + }); + }); + + describe('blobUrl / blobName roundtrip (inherited)', () => { + it('builds a URL and reverses it, including encoded segments', () => { + const service = new S3StorageService(CONTAINER); + const name = 'user/1/my file.png'; + + const url = service.blobUrl(name); + + expect(url).toBe('https://files.test.local/kyc/user/1/my%20file.png'); + expect(service.blobName(url)).toBe(name); + }); + + it('throws blobName for a URL outside the container', () => { + const service = new S3StorageService(CONTAINER); + + expect(() => service.blobName('https://files.test.local/other/x.png')).toThrow( + 'URL does not belong to container kyc', + ); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/__tests__/storage.factory.spec.ts b/src/integration/infrastructure/storage/__tests__/storage.factory.spec.ts new file mode 100644 index 0000000000..69dfc2ab07 --- /dev/null +++ b/src/integration/infrastructure/storage/__tests__/storage.factory.spec.ts @@ -0,0 +1,57 @@ +import { Test } from '@nestjs/testing'; +import { Environment } from 'src/config/config'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { MockStorageService } from '../mock-storage.service'; +import { S3StorageService } from '../s3-storage.service'; +import { createStorageService } from '../storage.factory'; + +const validS3 = { + endpoint: 'https://s3.test.local', + region: 'us-east-1', + accessKey: 'access-key', + secretKey: 'secret-key', + publicUrl: 'https://files.test.local/', +}; + +// The factory branches on `GetConfig().environment`, which reads process.env.ENVIRONMENT +// directly (a fresh Configuration), so the environment is driven via the env var here. +// The injected ConfigService still supplies the global `Config` (s3 block) used by the +// S3StorageService constructor. +async function provideConfig(environment: Environment, s3 = validS3): Promise { + process.env.ENVIRONMENT = environment; + await Test.createTestingModule({ + providers: [TestUtil.provideConfig({ environment, s3 })], + }).compile(); +} + +describe('createStorageService', () => { + const originalEnvironment = process.env.ENVIRONMENT; + + afterAll(() => { + process.env.ENVIRONMENT = originalEnvironment; + }); + + it('returns a MockStorageService for the LOC environment', async () => { + await provideConfig(Environment.LOC); + + expect(createStorageService('kyc')).toBeInstanceOf(MockStorageService); + }); + + it('returns an S3StorageService for the DEV environment', async () => { + await provideConfig(Environment.DEV); + + expect(createStorageService('kyc')).toBeInstanceOf(S3StorageService); + }); + + it('returns an S3StorageService for the PRD environment', async () => { + await provideConfig(Environment.PRD); + + expect(createStorageService('kyc')).toBeInstanceOf(S3StorageService); + }); + + it('fails fast on an incomplete S3 config in a non-LOC environment', async () => { + await provideConfig(Environment.DEV, { ...validS3, endpoint: undefined }); + + expect(() => createStorageService('kyc')).toThrow('Incomplete S3 config'); + }); +}); diff --git a/src/integration/infrastructure/storage/__tests__/storage.service.spec.ts b/src/integration/infrastructure/storage/__tests__/storage.service.spec.ts new file mode 100644 index 0000000000..0f27d7c9d4 --- /dev/null +++ b/src/integration/infrastructure/storage/__tests__/storage.service.spec.ts @@ -0,0 +1,39 @@ +import { Test } from '@nestjs/testing'; +import { TestUtil } from 'src/shared/utils/test.util'; +import { MockStorageService } from '../mock-storage.service'; + +describe('StorageService', () => { + let service: MockStorageService; + + beforeAll(async () => { + await Test.createTestingModule({ + providers: [TestUtil.provideConfig({ s3: { publicUrl: 'https://files.example.com/' } })], + }).compile(); + + service = new MockStorageService('kyc'); + }); + + describe('blobUrl / blobName round-trip', () => { + // This reversibility is the load-bearing migration invariant: URLs produced by + // uploadBlob are persisted in the DB and must decode back to the exact object key. + const keys = [ + 'user/123/Identification/id_front.png', + 'spider/9/report.pdf', + 'user/1/notes/file with spaces.pdf', + 'user/2/notes/special #&+%.png', + 'user/3/Ausweis/Gruesse_Uemlaeuet.png', + ]; + + it.each(keys)('reverses %s exactly', (key) => { + expect(service.blobName(service.blobUrl(key))).toBe(key); + }); + + it('builds the public URL from the configured base with encoded segments', () => { + expect(service.blobUrl('a b/c#d')).toBe('https://files.example.com/kyc/a%20b/c%23d'); + }); + + it('rejects a URL that does not belong to the container', () => { + expect(() => service.blobName('https://example.com/other/file.png')).toThrow(); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/anchoring/__tests__/archive.scheduler.spec.ts b/src/integration/infrastructure/storage/anchoring/__tests__/archive.scheduler.spec.ts new file mode 100644 index 0000000000..66dcb10ecf --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/__tests__/archive.scheduler.spec.ts @@ -0,0 +1,58 @@ +// Stub the heavy `opentimestamps` library (pulled in transitively via ArchiveService) so its +// eager network/`request` deps never load; ArchiveService is fully mocked in this spec anyway. +jest.mock('opentimestamps', () => ({})); + +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ArchiveBatch } from '../archive-batch.entity'; +import { ArchiveScheduler } from '../archive.scheduler'; +import { ArchiveService } from '../archive.service'; + +describe('ArchiveScheduler', () => { + let scheduler: ArchiveScheduler; + let archiveService: ArchiveService; + + beforeEach(async () => { + archiveService = createMock(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ArchiveScheduler, { provide: ArchiveService, useValue: archiveService }], + }).compile(); + + scheduler = module.get(ArchiveScheduler); + }); + + it('should be defined', () => { + expect(scheduler).toBeDefined(); + }); + + describe('anchorPending', () => { + it('delegates to ArchiveService.anchorPending', async () => { + (archiveService.anchorPending as jest.Mock).mockResolvedValue({ id: 1, merkleRoot: 'ab' } as ArchiveBatch); + + await scheduler.anchorPending(); + + expect(archiveService.anchorPending).toHaveBeenCalledTimes(1); + }); + + it('swallows errors so a failure never crashes the scheduler', async () => { + (archiveService.anchorPending as jest.Mock).mockRejectedValue(new Error('calendar down')); + + await expect(scheduler.anchorPending()).resolves.toBeUndefined(); + }); + }); + + describe('upgradeBatches', () => { + it('delegates to ArchiveService.upgradeBatches', async () => { + await scheduler.upgradeBatches(); + + expect(archiveService.upgradeBatches).toHaveBeenCalledTimes(1); + }); + + it('swallows errors so a failure never crashes the scheduler', async () => { + (archiveService.upgradeBatches as jest.Mock).mockRejectedValue(new Error('upgrade failed')); + + await expect(scheduler.upgradeBatches()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/anchoring/__tests__/archive.service.spec.ts b/src/integration/infrastructure/storage/anchoring/__tests__/archive.service.spec.ts new file mode 100644 index 0000000000..0e514ccc7d --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/__tests__/archive.service.spec.ts @@ -0,0 +1,345 @@ +// Stub the heavy `opentimestamps` library so its eager network/`request` deps never load: +// this spec mocks OpenTimestampsService entirely, so the real implementation is never used. +jest.mock('opentimestamps', () => ({})); + +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ArchiveBatch } from '../archive-batch.entity'; +import { ArchiveBatchRepository } from '../archive-batch.repository'; +import { ArchiveFile } from '../archive-file.entity'; +import { ArchiveFileRepository } from '../archive-file.repository'; +import { ArchiveService } from '../archive.service'; +import { sha256 } from '../merkle'; +import { OpenTimestampsService } from '../opentimestamps.service'; + +/** + * A shared in-memory store backing both fake repositories. It reproduces just enough TypeORM + * behaviour (auto-increment ids, `(bucket, name)` upsert, batch-null filtering, ordering and + * `manager.transaction`) for the round-trip the service performs, so that saves inside the + * transaction the service runs on the batch repo's manager land in the same `files`/`batches` + * arrays the assertions inspect. The Merkle math is the REAL Stage-1 module; only + * OpenTimestamps is mocked so no network is touched. + */ +function fakeStore() { + const files: ArchiveFile[] = []; + const batches: ArchiveBatch[] = []; + let fileSeq = 0; + let batchSeq = 0; + + // dispatch a single entity (or array) by type, assigning ids on first save. + const saveEntity = (entity: any): any => { + const list = Array.isArray(entity) ? entity : [entity]; + for (const item of list) { + if (item instanceof ArchiveBatch) { + if (!item.id) { + item.id = ++batchSeq; + batches.push(item); + } + } else { + if (!item.id) { + item.id = ++fileSeq; + files.push(item); + } + } + } + return entity; + }; + + const manager = { + save: async (entity: any) => saveEntity(entity), + transaction: async (run: (manager: any) => Promise) => run(manager), + }; + + const fileRepo: any = { + files, + manager, + create: (data: Partial) => Object.assign(new ArchiveFile(), data), + save: async (entity: ArchiveFile | ArchiveFile[]) => saveEntity(entity), + update: async (id: number, partial: Partial) => { + const file = files.find((f) => f.id === id); + if (file) Object.assign(file, partial); + }, + findOneBy: async (where: Partial) => + files.find((f) => f.bucket === where.bucket && f.name === where.name) ?? undefined, + // supports lookup by id or by (bucket, name). Faithful to TypeORM: the `batch` relation is + // ONLY hydrated when the caller explicitly asks for it via `relations: ['batch']`. Without + // that, `batch` is left undefined on the returned row, so a caller that drops the relation + // would see `existing.batch === undefined` here too (and the anchored-hash guard would fail + // its test) instead of silently passing on the in-memory reference. + findOne: async ({ where, relations }: any) => { + const match = files.find((f) => + where.id != null ? f.id === where.id : f.bucket === where.bucket && f.name === where.name, + ); + if (!match) return undefined; + + const wantsBatch = Array.isArray(relations) ? relations.includes('batch') : !!relations?.batch; + // Return a shallow copy so we can withhold `batch` without mutating the stored entity. + const result = Object.assign(new ArchiveFile(), match); + if (!wantsBatch) result.batch = undefined; + return result; + }, + find: async ({ where, order }: any) => { + let result = [...files]; + + if (where?.batch !== undefined) { + // TypeORM IsNull() carries `_type === 'isNull'`; otherwise we match a batch id. + const wantsNull = where.batch?._type === 'isNull' || where.batch === null; + if (wantsNull) result = result.filter((f) => f.batch == null); + else if (where.batch?.id != null) result = result.filter((f) => f.batch?.id === where.batch.id); + } + + if (order?.id === 'ASC') result.sort((a, b) => a.id - b.id); + if (order?.leafIndex === 'ASC') result.sort((a, b) => a.leafIndex - b.leafIndex); + + return result; + }, + }; + + const batchRepo: any = { + batches, + manager, + create: (data: Partial) => Object.assign(new ArchiveBatch(), data), + save: async (entity: ArchiveBatch) => saveEntity(entity), + findBy: async (where: Partial) => batches.filter((b) => b.status === where.status), + }; + + return { fileRepo, batchRepo }; +} + +describe('ArchiveService', () => { + let service: ArchiveService; + let fileRepo: any; + let batchRepo: any; + let ots: OpenTimestampsService; + + beforeEach(async () => { + ({ fileRepo, batchRepo } = fakeStore()); + + ots = createMock(); + // Deterministic, network-free fakes: the .ots bytes just wrap the root; pending forever. + (ots.stamp as jest.Mock).mockImplementation(async (digest: Buffer) => Buffer.concat([Buffer.from('OTS'), digest])); + (ots.upgrade as jest.Mock).mockImplementation(async (bytes: Buffer) => bytes); + (ots.verify as jest.Mock).mockResolvedValue({ confirmed: false, pending: true }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ArchiveService, + { provide: ArchiveFileRepository, useValue: fileRepo }, + { provide: ArchiveBatchRepository, useValue: batchRepo }, + { provide: OpenTimestampsService, useValue: ots }, + ], + }).compile(); + + service = module.get(ArchiveService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('round-trip', () => { + const docs = [ + { bucket: 'archive', name: 'doc-a.pdf', data: Buffer.from('content of document A') }, + { bucket: 'archive', name: 'doc-b.pdf', data: Buffer.from('content of document B') }, + { bucket: 'archive', name: 'doc-c.pdf', data: Buffer.from('content of document C') }, + ]; + + beforeEach(async () => { + for (const doc of docs) { + await service.recordHash(doc.bucket, doc.name, sha256(doc.data).toString('hex')); + } + }); + + it('records all files unanchored', () => { + expect(fileRepo.files).toHaveLength(3); + expect(fileRepo.files.every((f: ArchiveFile) => f.batch == null)).toBe(true); + }); + + it('upserts idempotently on (bucket, name)', async () => { + await service.recordHash('archive', 'doc-a.pdf', sha256(Buffer.from('updated A')).toString('hex')); + + expect(fileRepo.files).toHaveLength(3); + const fileA = fileRepo.files.find((f: ArchiveFile) => f.name === 'doc-a.pdf'); + expect(fileA.sha256).toBe(sha256(Buffer.from('updated A')).toString('hex')); + }); + + it('anchors pending files into a batch and stamps the root', async () => { + const batch = await service.anchorPending(); + + expect(batch).toBeDefined(); + expect(batch.merkleRoot).toMatch(/^[0-9a-f]{64}$/); + expect(batch.status).toBe('pendingBtc'); + expect(batch.otsProof).toBeDefined(); + expect(ots.stamp).toHaveBeenCalledTimes(1); + + // every file got assigned to the batch with a leaf index + expect(fileRepo.files.every((f: ArchiveFile) => f.batch?.id === batch.id)).toBe(true); + expect(fileRepo.files.map((f: ArchiveFile) => f.leafIndex).sort()).toEqual([0, 1, 2]); + }); + + it('returns undefined when there is nothing to anchor', async () => { + await service.anchorPending(); + const second = await service.anchorPending(); + expect(second).toBeUndefined(); + }); + + it('verifies an unchanged, anchored document (real Merkle proof)', async () => { + await service.anchorPending(); + + const result = await service.verifyDocument('archive', 'doc-b.pdf', docs[1].data); + + expect(result.found).toBe(true); + expect(result.hashMatches).toBe(true); + expect(result.anchored).toBe(true); + expect(result.proofValid).toBe(true); + expect(result.pending).toBe(true); + expect(result.bitcoinHeight).toBeUndefined(); + }); + + it('detects tampered data via hash mismatch', async () => { + await service.anchorPending(); + + const result = await service.verifyDocument('archive', 'doc-b.pdf', Buffer.from('tampered content')); + + expect(result.found).toBe(true); + expect(result.hashMatches).toBe(false); + // the stored hash is still genuinely anchored, only the supplied bytes differ + expect(result.anchored).toBe(true); + expect(result.proofValid).toBe(true); + }); + + it('reports an unanchored file as anchored:false', async () => { + const result = await service.verifyDocument('archive', 'doc-a.pdf', docs[0].data); + + expect(result.found).toBe(true); + expect(result.hashMatches).toBe(true); + expect(result.anchored).toBe(false); + expect(result.proofValid).toBeUndefined(); + }); + + it('reports an unknown document as found:false', async () => { + const result = await service.verifyDocument('archive', 'missing.pdf', Buffer.from('whatever')); + + expect(result.found).toBe(false); + }); + + it('confirms a batch once OpenTimestamps reports a Bitcoin attestation', async () => { + await service.anchorPending(); + + (ots.verify as jest.Mock).mockResolvedValue({ confirmed: true, pending: false, bitcoin: { height: 840000 } }); + + await service.upgradeBatches(); + + expect(batchRepo.batches[0].status).toBe('confirmed'); + expect(batchRepo.batches[0].bitcoinHeight).toBe(840000); + }); + + it('loads the batch relation when recording and verifying (guards the anchored-hash check)', async () => { + // The anchored-hash guard in recordHash and the anchored-branch in verifyDocument both + // depend on `existing.batch`/`file.batch` being populated, which only happens when the + // query explicitly requests `relations: ['batch']`. Assert the option is actually passed, + // so dropping it in production (which would let an anchored leaf be silently overwritten) + // breaks this test rather than passing unnoticed. + const findOneSpy = jest.spyOn(fileRepo, 'findOne'); + + await service.recordHash('archive', 'doc-a.pdf', sha256(docs[0].data).toString('hex')); + await service.verifyDocument('archive', 'doc-a.pdf', docs[0].data); + + expect(findOneSpy).toHaveBeenCalled(); + for (const call of findOneSpy.mock.calls) { + expect(call[0]).toMatchObject({ relations: ['batch'] }); + } + }); + + it('refuses to overwrite an anchored hash with a differing hash (avoids bogus tampering)', async () => { + await service.anchorPending(); + + const anchored = fileRepo.files.find((f: ArchiveFile) => f.name === 'doc-a.pdf'); + const originalHash = anchored.sha256; + + await expect( + service.recordHash('archive', 'doc-a.pdf', sha256(Buffer.from('re-uploaded A')).toString('hex')), + ).rejects.toThrow(/Refusing to overwrite anchored hash/); + + // the anchored leaf hash is untouched + expect(anchored.sha256).toBe(originalHash); + }); + + it('is a no-op when recording the same hash on an already-anchored file', async () => { + await service.anchorPending(); + + const anchored = fileRepo.files.find((f: ArchiveFile) => f.name === 'doc-a.pdf'); + const originalHash = anchored.sha256; + + await expect(service.recordHash('archive', 'doc-a.pdf', originalHash)).resolves.toBeUndefined(); + + expect(anchored.sha256).toBe(originalHash); + expect(fileRepo.files).toHaveLength(3); + }); + + it('persists upgraded proof bytes even while verify still reports pending', async () => { + await service.anchorPending(); + + const batch = batchRepo.batches[0]; + const originalProof = batch.otsProof; + + // upgrade yields changed bytes, but the attestation is not yet on-chain + (ots.upgrade as jest.Mock).mockResolvedValueOnce(Buffer.from('UPGRADED-BUT-PENDING')); + (ots.verify as jest.Mock).mockResolvedValueOnce({ confirmed: false, pending: true }); + + await service.upgradeBatches(); + + expect(batch.otsProof).toBe(Buffer.from('UPGRADED-BUT-PENDING').toString('base64')); + expect(batch.otsProof).not.toBe(originalProof); + // still not confirmed + expect(batch.status).toBe('pendingBtc'); + expect(batch.bitcoinHeight).toBeUndefined(); + }); + + it('does not save a batch when upgrade is unchanged and still pending', async () => { + await service.anchorPending(); + + const batch = batchRepo.batches[0]; + const saveSpy = jest.spyOn(batchRepo, 'save'); + + // upgrade returns the same bytes, verify still pending => nothing to persist + await service.upgradeBatches(); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(batch.status).toBe('pendingBtc'); + }); + + it('skips a pending batch that carries no OTS proof (never touches the library)', async () => { + await service.anchorPending(); + + // simulate a malformed/empty proof on the only pending batch + const batch = batchRepo.batches[0]; + batch.otsProof = null; + + const saveSpy = jest.spyOn(batchRepo, 'save'); + + await service.upgradeBatches(); + + expect(saveSpy).not.toHaveBeenCalled(); + expect(ots.upgrade).not.toHaveBeenCalled(); + expect(ots.verify).not.toHaveBeenCalled(); + expect(batch.status).toBe('pendingBtc'); + }); + + it('reports the Bitcoin height when verifying a document whose batch is confirmed on-chain', async () => { + await service.anchorPending(); + + // the proof now carries a Bitcoin attestation + (ots.verify as jest.Mock).mockResolvedValue({ confirmed: true, pending: false, bitcoin: { height: 850000 } }); + + const result = await service.verifyDocument('archive', 'doc-b.pdf', docs[1].data); + + expect(result.found).toBe(true); + expect(result.hashMatches).toBe(true); + expect(result.anchored).toBe(true); + expect(result.proofValid).toBe(true); + expect(result.pending).toBe(false); + expect(result.bitcoinHeight).toBe(850000); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/anchoring/__tests__/merkle.spec.ts b/src/integration/infrastructure/storage/anchoring/__tests__/merkle.spec.ts new file mode 100644 index 0000000000..b50ab59b5e --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/__tests__/merkle.spec.ts @@ -0,0 +1,112 @@ +import { buildMerkleRoot, merkleInclusionProof, sha256, verifyMerkleProof } from '../merkle'; + +/** Deterministic leaf: sha256 of `leaf-` so tests don't depend on random data. */ +function leaf(i: number): Buffer { + return sha256(Buffer.from(`leaf-${i}`)); +} + +function leaves(count: number): Buffer[] { + return Array.from({ length: count }, (_, i) => leaf(i)); +} + +/** Reference parent hash matching the module's documented `sha256(left || right)` rule. */ +function parent(left: Buffer, right: Buffer): Buffer { + return sha256(Buffer.concat([left, right])); +} + +describe('merkle', () => { + describe('buildMerkleRoot', () => { + it('throws on zero leaves', () => { + expect(() => buildMerkleRoot([])).toThrow(); + }); + + it('returns the leaf itself for a single-leaf tree', () => { + const l = leaf(0); + expect(buildMerkleRoot([l]).equals(l)).toBe(true); + }); + + it('hashes the pair for two leaves', () => { + const [a, b] = leaves(2); + expect(buildMerkleRoot([a, b]).equals(parent(a, b))).toBe(true); + }); + + it('duplicates the last node for three leaves', () => { + const [a, b, c] = leaves(3); + // level 1: [h(a,b), h(c,c)] -> root: h( h(a,b), h(c,c) ) + const expected = parent(parent(a, b), parent(c, c)); + expect(buildMerkleRoot([a, b, c]).equals(expected)).toBe(true); + }); + + it('builds a balanced tree for four leaves', () => { + const [a, b, c, d] = leaves(4); + const expected = parent(parent(a, b), parent(c, d)); + expect(buildMerkleRoot([a, b, c, d]).equals(expected)).toBe(true); + }); + + it('is deterministic across repeated calls', () => { + const ls = leaves(4); + expect(buildMerkleRoot(ls).equals(buildMerkleRoot(ls))).toBe(true); + }); + }); + + describe('inclusion proof + verification', () => { + for (let size = 1; size <= 5; size++) { + it(`verifies every leaf in a tree of size ${size}`, () => { + const ls = leaves(size); + const root = buildMerkleRoot(ls); + + for (let index = 0; index < size; index++) { + const proof = merkleInclusionProof(ls, index); + expect(verifyMerkleProof(ls[index], proof, root)).toBe(true); + } + }); + } + + it('produces an empty proof for a single-leaf tree', () => { + const ls = leaves(1); + expect(merkleInclusionProof(ls, 0)).toEqual([]); + }); + + it('throws for an out-of-range index', () => { + const ls = leaves(3); + expect(() => merkleInclusionProof(ls, 3)).toThrow(); + expect(() => merkleInclusionProof(ls, -1)).toThrow(); + }); + + it('throws for a proof over zero leaves', () => { + expect(() => merkleInclusionProof([], 0)).toThrow(); + }); + }); + + describe('tamper detection', () => { + const ls = leaves(4); + const root = buildMerkleRoot(ls); + const index = 1; + const proof = merkleInclusionProof(ls, index); + + it('rejects a manipulated leaf', () => { + const tampered = sha256(Buffer.from('not-the-original-leaf')); + expect(verifyMerkleProof(tampered, proof, root)).toBe(false); + }); + + it('rejects a tampered sibling in the proof', () => { + const badProof = proof.map((s, i) => (i === 0 ? { ...s, sibling: sha256(Buffer.from('wrong')) } : s)); + expect(verifyMerkleProof(ls[index], badProof, root)).toBe(false); + }); + + it('rejects a flipped left/right position', () => { + const badProof = proof.map((s) => ({ ...s, right: !s.right })); + expect(verifyMerkleProof(ls[index], badProof, root)).toBe(false); + }); + + it('rejects verification against a wrong root', () => { + const wrongRoot = sha256(Buffer.from('some-other-root')); + expect(verifyMerkleProof(ls[index], proof, wrongRoot)).toBe(false); + }); + + it("rejects another leaf's proof for this leaf", () => { + const otherProof = merkleInclusionProof(ls, 2); + expect(verifyMerkleProof(ls[index], otherProof, root)).toBe(false); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/anchoring/__tests__/opentimestamps.service.spec.ts b/src/integration/infrastructure/storage/anchoring/__tests__/opentimestamps.service.spec.ts new file mode 100644 index 0000000000..ef569aa55b --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/__tests__/opentimestamps.service.spec.ts @@ -0,0 +1,176 @@ +// Mock the `opentimestamps` npm library so stamp/upgrade/verify can be exercised WITHOUT any +// network access. We mock exactly the surface OpenTimestampsService touches: +// - OpenTimestamps.stamp / .upgrade / .verify (promise-returning network calls) +// - OpenTimestamps.DetachedTimestampFile.fromHash (sync constructor from a digest) +// - OpenTimestamps.DetachedTimestampFile.deserialize (sync constructor from .ots bytes) +// - OpenTimestamps.Ops.OpSHA256 (sync op constructor) +// Each fake DetachedTimestampFile carries a `serializeToBytes()` returning a recognizable byte +// array so we can assert on the bytes the service returns. + +const stampMock = jest.fn(); +const upgradeMock = jest.fn(); +const verifyMock = jest.fn(); +const fromHashMock = jest.fn(); +const deserializeMock = jest.fn(); +const opSHA256Mock = jest.fn(); + +jest.mock('opentimestamps', () => ({ + stamp: (...args: any[]) => stampMock(...args), + upgrade: (...args: any[]) => upgradeMock(...args), + verify: (...args: any[]) => verifyMock(...args), + DetachedTimestampFile: { + fromHash: (...args: any[]) => fromHashMock(...args), + deserialize: (...args: any[]) => deserializeMock(...args), + }, + Ops: { + OpSHA256: function OpSHA256(this: any) { + opSHA256Mock(); + }, + }, +})); + +import { Test, TestingModule } from '@nestjs/testing'; +import { OpenTimestampsService } from '../opentimestamps.service'; + +/** A fake DetachedTimestampFile whose serialized form is deterministic for assertions. */ +function fakeDetached(serialized: number[]) { + return { + serializeToBytes: jest.fn(() => serialized), + }; +} + +describe('OpenTimestampsService', () => { + let service: OpenTimestampsService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [OpenTimestampsService], + }).compile(); + + service = module.get(OpenTimestampsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('stamp', () => { + it('builds a detached file from the digest, submits it and returns the serialized .ots bytes', async () => { + const digest = Buffer.alloc(32, 7); + const serialized = [1, 2, 3, 4]; + const detached = fakeDetached(serialized); + fromHashMock.mockReturnValue(detached); + stampMock.mockResolvedValue(undefined); + + const result = await service.stamp(digest); + + // a SHA-256 op was constructed and fromHash was called with that op + the raw digest + expect(opSHA256Mock).toHaveBeenCalledTimes(1); + expect(fromHashMock).toHaveBeenCalledTimes(1); + const [, passedDigest] = fromHashMock.mock.calls[0]; + expect(passedDigest).toBe(digest); + + // the library stamp was invoked on exactly that detached file + expect(stampMock).toHaveBeenCalledTimes(1); + expect(stampMock).toHaveBeenCalledWith(detached); + + // and the returned bytes are a Buffer wrapping the serialized form + expect(Buffer.isBuffer(result)).toBe(true); + expect(result).toEqual(Buffer.from(serialized)); + expect(detached.serializeToBytes).toHaveBeenCalledTimes(1); + }); + + it('propagates a calendar/network failure from the library', async () => { + fromHashMock.mockReturnValue(fakeDetached([0])); + stampMock.mockRejectedValue(new Error('calendar unreachable')); + + await expect(service.stamp(Buffer.alloc(32))).rejects.toThrow('calendar unreachable'); + }); + }); + + describe('upgrade', () => { + it('returns the re-serialized bytes when the proof changed', async () => { + const otsBytes = Buffer.from([9, 9, 9]); + const upgradedSerialized = [5, 6, 7, 8]; + const detached = fakeDetached(upgradedSerialized); + deserializeMock.mockReturnValue(detached); + upgradeMock.mockResolvedValue(true); // changed + + const result = await service.upgrade(otsBytes); + + expect(deserializeMock).toHaveBeenCalledWith(otsBytes); + expect(upgradeMock).toHaveBeenCalledWith(detached); + expect(detached.serializeToBytes).toHaveBeenCalledTimes(1); + expect(result).toEqual(Buffer.from(upgradedSerialized)); + // genuinely new bytes, not the original + expect(result.equals(otsBytes)).toBe(false); + }); + + it('returns the original bytes unchanged when nothing changed', async () => { + const otsBytes = Buffer.from([9, 9, 9]); + const detached = fakeDetached([1, 1, 1]); + deserializeMock.mockReturnValue(detached); + upgradeMock.mockResolvedValue(false); // unchanged + + const result = await service.upgrade(otsBytes); + + // exact same buffer reference is returned, serialize is never consulted + expect(result).toBe(otsBytes); + expect(detached.serializeToBytes).not.toHaveBeenCalled(); + }); + }); + + describe('verify', () => { + it('reports confirmed with the Bitcoin height when an attestation is present', async () => { + const digest = Buffer.alloc(32, 1); + const otsBytes = Buffer.from([4, 2]); + const detachedOts = fakeDetached([]); + const detachedDigest = fakeDetached([]); + deserializeMock.mockReturnValue(detachedOts); + fromHashMock.mockReturnValue(detachedDigest); + verifyMock.mockResolvedValue({ bitcoin: { height: 840123, timestamp: 1700000000 } }); + + const result = await service.verify(digest, otsBytes); + + // proof deserialized, digest re-derived, and verify run against the trusted explorers + expect(deserializeMock).toHaveBeenCalledWith(otsBytes); + expect(fromHashMock).toHaveBeenCalledTimes(1); + expect(verifyMock).toHaveBeenCalledWith(detachedOts, detachedDigest, { ignoreBitcoinNode: true }); + + expect(result).toEqual({ bitcoin: { height: 840123 }, confirmed: true, pending: false }); + }); + + it('reports pending when the library returns no attestation (undefined result)', async () => { + deserializeMock.mockReturnValue(fakeDetached([])); + fromHashMock.mockReturnValue(fakeDetached([])); + verifyMock.mockResolvedValue(undefined); + + const result = await service.verify(Buffer.alloc(32), Buffer.from([1])); + + expect(result).toEqual({ confirmed: false, pending: true }); + expect(result.bitcoin).toBeUndefined(); + }); + + it('reports pending when the result has no bitcoin attestation', async () => { + deserializeMock.mockReturnValue(fakeDetached([])); + fromHashMock.mockReturnValue(fakeDetached([])); + verifyMock.mockResolvedValue({}); + + const result = await service.verify(Buffer.alloc(32), Buffer.from([1])); + + expect(result).toEqual({ confirmed: false, pending: true }); + }); + + it('reports pending when the bitcoin field lacks a numeric height', async () => { + deserializeMock.mockReturnValue(fakeDetached([])); + fromHashMock.mockReturnValue(fakeDetached([])); + verifyMock.mockResolvedValue({ bitcoin: {} }); + + const result = await service.verify(Buffer.alloc(32), Buffer.from([1])); + + expect(result).toEqual({ confirmed: false, pending: true }); + }); + }); +}); diff --git a/src/integration/infrastructure/storage/anchoring/archive-batch.entity.ts b/src/integration/infrastructure/storage/anchoring/archive-batch.entity.ts new file mode 100644 index 0000000000..c001247f63 --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive-batch.entity.ts @@ -0,0 +1,33 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Column, Entity, OneToMany } from 'typeorm'; +import { ArchiveFile } from './archive-file.entity'; + +/** Lifecycle of an anchoring batch on the way to a Bitcoin attestation. */ +export enum ArchiveBatchStatus { + PENDING_BTC = 'pendingBtc', + CONFIRMED = 'confirmed', +} + +/** + * A daily (or on-demand) Merkle batch over a set of {@link ArchiveFile} hashes for the + * GeBüV anchoring pipeline. The `merkleRoot` is timestamped via OpenTimestamps; the + * resulting detached `.ots` proof is persisted (base64) in `otsProof` and upgraded over + * time until it carries a Bitcoin attestation (`bitcoinHeight` set, `status` confirmed). + */ +@Entity() +export class ArchiveBatch extends IEntity { + @Column({ length: 64 }) + merkleRoot: string; + + @Column({ type: 'text', nullable: true }) + otsProof?: string; + + @Column({ type: 'int', nullable: true }) + bitcoinHeight?: number; + + @Column({ length: 256, default: ArchiveBatchStatus.PENDING_BTC }) + status: ArchiveBatchStatus; + + @OneToMany(() => ArchiveFile, (file) => file.batch) + files: ArchiveFile[]; +} diff --git a/src/integration/infrastructure/storage/anchoring/archive-batch.repository.ts b/src/integration/infrastructure/storage/anchoring/archive-batch.repository.ts new file mode 100644 index 0000000000..5ca13be275 --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive-batch.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { ArchiveBatch } from './archive-batch.entity'; + +@Injectable() +export class ArchiveBatchRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(ArchiveBatch, manager); + } +} diff --git a/src/integration/infrastructure/storage/anchoring/archive-file.entity.ts b/src/integration/infrastructure/storage/anchoring/archive-file.entity.ts new file mode 100644 index 0000000000..829a639e54 --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive-file.entity.ts @@ -0,0 +1,30 @@ +import { IEntity } from 'src/shared/models/entity'; +import { Column, Entity, Index, ManyToOne } from 'typeorm'; +import { ArchiveBatch } from './archive-batch.entity'; + +/** + * A single archived storage object whose content hash participates in the GeBüV anchoring + * pipeline. Each file is identified uniquely by its `(bucket, name)` location and records + * the SHA-256 of its content. Once anchored, it points to its {@link ArchiveBatch} and + * carries its `leafIndex` within that batch's Merkle tree (needed to rebuild the inclusion + * proof during verification). + */ +@Entity() +@Index((file: ArchiveFile) => [file.bucket, file.name], { unique: true }) +export class ArchiveFile extends IEntity { + @Column({ length: 256 }) + bucket: string; + + @Column({ length: 256 }) + name: string; + + @Column({ length: 64 }) + sha256: string; + + @Index() + @ManyToOne(() => ArchiveBatch, (batch) => batch.files, { nullable: true }) + batch?: ArchiveBatch; + + @Column({ type: 'int', nullable: true }) + leafIndex?: number; +} diff --git a/src/integration/infrastructure/storage/anchoring/archive-file.repository.ts b/src/integration/infrastructure/storage/anchoring/archive-file.repository.ts new file mode 100644 index 0000000000..b162ce3b20 --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive-file.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from 'src/shared/repositories/base.repository'; +import { EntityManager } from 'typeorm'; +import { ArchiveFile } from './archive-file.entity'; + +@Injectable() +export class ArchiveFileRepository extends BaseRepository { + constructor(manager: EntityManager) { + super(ArchiveFile, manager); + } +} diff --git a/src/integration/infrastructure/storage/anchoring/archive.module.ts b/src/integration/infrastructure/storage/anchoring/archive.module.ts new file mode 100644 index 0000000000..04ad7ac7d2 --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SharedModule } from 'src/shared/shared.module'; +import { ArchiveBatch } from './archive-batch.entity'; +import { ArchiveBatchRepository } from './archive-batch.repository'; +import { ArchiveFile } from './archive-file.entity'; +import { ArchiveFileRepository } from './archive-file.repository'; +import { ArchiveScheduler } from './archive.scheduler'; +import { ArchiveService } from './archive.service'; +import { OpenTimestampsService } from './opentimestamps.service'; + +@Module({ + imports: [SharedModule, TypeOrmModule.forFeature([ArchiveBatch, ArchiveFile])], + controllers: [], + providers: [ArchiveBatchRepository, ArchiveFileRepository, ArchiveService, OpenTimestampsService, ArchiveScheduler], + exports: [ArchiveService, OpenTimestampsService], +}) +export class ArchiveModule {} diff --git a/src/integration/infrastructure/storage/anchoring/archive.scheduler.ts b/src/integration/infrastructure/storage/anchoring/archive.scheduler.ts new file mode 100644 index 0000000000..688985f15e --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive.scheduler.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { CronExpression } from '@nestjs/schedule'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { Process } from 'src/shared/services/process.service'; +import { DfxCron } from 'src/shared/utils/cron'; +import { ArchiveService } from './archive.service'; + +/** + * Stage 3 of the GeBüV anchoring pipeline: drives {@link ArchiveService} on a schedule. + * + * Uses the repo-wide `@DfxCron` decorator (see src/shared/utils/cron.ts), which the + * central DfxCronService discovers at boot and runs behind a per-method `@Lock` plus a + * `Process` guard — so a disabled process or a multi-instance deployment never double-runs + * the same job. Each job is additionally wrapped in try/catch with structured logging so a + * single failure (e.g. an OpenTimestamps calendar outage) never crashes the scheduler. + */ +@Injectable() +export class ArchiveScheduler { + private readonly logger = new DfxLogger(ArchiveScheduler); + + constructor(private readonly archiveService: ArchiveService) {} + + @DfxCron(CronExpression.EVERY_DAY_AT_2AM, { process: Process.ARCHIVE_ANCHOR, timeout: 3600 }) + async anchorPending(): Promise { + try { + const batch = await this.archiveService.anchorPending(); + + if (batch) { + this.logger.info(`Anchored batch ${batch.id} with Merkle root ${batch.merkleRoot}`); + } else { + this.logger.verbose('No unanchored archive files to anchor'); + } + } catch (e) { + this.logger.error('Failed to anchor pending archive files:', e); + } + } + + @DfxCron(CronExpression.EVERY_HOUR, { process: Process.ARCHIVE_UPGRADE, timeout: 1800 }) + async upgradeBatches(): Promise { + try { + await this.archiveService.upgradeBatches(); + } catch (e) { + this.logger.error('Failed to upgrade pending archive batches:', e); + } + } +} diff --git a/src/integration/infrastructure/storage/anchoring/archive.service.ts b/src/integration/infrastructure/storage/anchoring/archive.service.ts new file mode 100644 index 0000000000..c8d841f0ff --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/archive.service.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@nestjs/common'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; +import { IsNull } from 'typeorm'; +import { ArchiveBatch, ArchiveBatchStatus } from './archive-batch.entity'; +import { ArchiveBatchRepository } from './archive-batch.repository'; +import { ArchiveFileRepository } from './archive-file.repository'; +import { buildMerkleRoot, merkleInclusionProof, sha256, verifyMerkleProof } from './merkle'; +import { OpenTimestampsService } from './opentimestamps.service'; + +/** Result of verifying an archived document against its anchored Merkle batch. */ +export interface ArchiveVerification { + /** false if no archive record exists for the given `(bucket, name)`. */ + found: boolean; + /** true if the stored SHA-256 equals the hash recomputed from the supplied data. */ + hashMatches?: boolean; + /** true once the file has been assigned to a Merkle batch. */ + anchored?: boolean; + /** true if the inclusion proof recomputes the batch's stored Merkle root. */ + proofValid?: boolean; + /** Bitcoin block height of the OpenTimestamps attestation, once anchored on-chain. */ + bitcoinHeight?: number; + /** true while the OpenTimestamps proof is still calendar-only (not yet on-chain). */ + pending?: boolean; +} + +/** + * Stage 2 of the GeBüV anchoring pipeline: it records content hashes of archived storage + * objects, batches the still-unanchored ones into a daily Merkle tree, timestamps the root + * via OpenTimestamps (Stage 1 primitives), upgrades those proofs to Bitcoin attestations, + * and verifies a given document against its anchored batch end-to-end. + * + * Leaves are the raw 32-byte SHA-256 digests of the file contents (the Merkle module does + * NOT re-hash leaves). `merkleRoot` is stored hex, `otsProof` is stored base64 of the + * serialized detached `.ots` bytes. + */ +@Injectable() +export class ArchiveService { + private readonly logger = new DfxLogger(ArchiveService); + + constructor( + private readonly archiveBatchRepo: ArchiveBatchRepository, + private readonly archiveFileRepo: ArchiveFileRepository, + private readonly ots: OpenTimestampsService, + ) {} + + /** + * Idempotently record the SHA-256 of an archived object identified by `(bucket, name)`. + * + * Only UNANCHORED records may be updated in place (their hash refreshed, kept unanchored); + * a new record is created unanchored (`batch` null). Anchoring happens later via + * {@link anchorPending}. + * + * Once a record has been assigned to a Merkle batch its leaf hash is immutable: it is part + * of a (possibly already Bitcoin-anchored) proof. Because KYC blob names are deterministic, + * a re-upload to the same `(bucket, name)` would otherwise silently overwrite the anchored + * leaf hash and make {@link verifyDocument} report bogus tampering. Therefore, for an + * already-anchored record: an identical hash is a no-op, and a differing hash is a hard + * error (the existing anchored hash is never overwritten). + */ + async recordHash(bucket: string, name: string, sha256Hex: string): Promise { + const existing = await this.archiveFileRepo.findOne({ where: { bucket, name }, relations: ['batch'] }); + + if (existing) { + if (existing.batch != null) { + if (existing.sha256 === sha256Hex) return; + + const message = + `Refusing to overwrite anchored hash for ${bucket}/${name} (file ${existing.id}, batch ` + + `${existing.batch.id}): stored ${existing.sha256} differs from new ${sha256Hex}`; + this.logger.error(message); + throw new Error(message); + } + + await this.archiveFileRepo.update(existing.id, { sha256: sha256Hex }); + return; + } + + const file = this.archiveFileRepo.create({ bucket, name, sha256: sha256Hex }); + await this.archiveFileRepo.save(file); + } + + /** + * Batch all currently unanchored files (ordered by id) into one Merkle tree, timestamp its + * root via OpenTimestamps, and persist batch + per-file assignment in a single transaction. + * + * Returns the created batch, or `undefined` if there is nothing to anchor. + */ + async anchorPending(): Promise { + const files = await this.archiveFileRepo.find({ where: { batch: IsNull() }, order: { id: 'ASC' } }); + if (files.length === 0) return undefined; + + const leaves = files.map((file) => Buffer.from(file.sha256, 'hex')); + const root = buildMerkleRoot(leaves); + + const otsBytes = await this.ots.stamp(root); + + const batch = this.archiveBatchRepo.create({ + merkleRoot: root.toString('hex'), + otsProof: otsBytes.toString('base64'), + status: ArchiveBatchStatus.PENDING_BTC, + }); + + await this.archiveBatchRepo.manager.transaction(async (manager) => { + const savedBatch = await manager.save(batch); + + files.forEach((file, index) => { + file.batch = savedBatch; + file.leafIndex = index; + }); + + await manager.save(files); + }); + + this.logger.info(`Anchored batch ${batch.id} over ${files.length} file(s), root ${batch.merkleRoot}`); + + return batch; + } + + /** + * Try to upgrade every pending batch's OpenTimestamps proof towards a Bitcoin attestation. + * + * The upgraded `.ots` bytes are persisted whenever the proof changed at all (e.g. it now + * carries additional calendar commitments but `verify` still reports pending) so that + * progress is never thrown away. `bitcoinHeight`/`status = confirmed` are set additionally + * only once `verify` reports a Bitcoin attestation. + */ + async upgradeBatches(): Promise { + const batches = await this.archiveBatchRepo.findBy({ status: ArchiveBatchStatus.PENDING_BTC }); + + for (const batch of batches) { + if (!batch.otsProof) continue; + + const rootBuffer = Buffer.from(batch.merkleRoot, 'hex'); + const originalProof = batch.otsProof; + const upgraded = await this.ots.upgrade(Buffer.from(originalProof, 'base64')); + const upgradedProof = upgraded.toString('base64'); + const result = await this.ots.verify(rootBuffer, upgraded); + + const proofChanged = upgradedProof !== originalProof; + if (!proofChanged && !result.confirmed) continue; + + // Always persist progress when the proof bytes changed; confirm only on a real attestation. + if (proofChanged) batch.otsProof = upgradedProof; + + if (result.confirmed) { + batch.bitcoinHeight = result.bitcoin.height; + batch.status = ArchiveBatchStatus.CONFIRMED; + } + + await this.archiveBatchRepo.save(batch); + + if (result.confirmed) { + this.logger.info(`Confirmed batch ${batch.id} at Bitcoin height ${batch.bitcoinHeight}`); + } else { + this.logger.info(`Upgraded pending OpenTimestamps proof for batch ${batch.id}`); + } + } + } + + /** + * Verify a supplied document against its archived, anchored Merkle batch end-to-end: + * recompute its SHA-256, compare with the stored hash, rebuild the inclusion proof against + * the batch's Merkle root, and check the OpenTimestamps attestation status. + */ + async verifyDocument(bucket: string, name: string, data: Buffer): Promise { + const file = await this.archiveFileRepo.findOne({ where: { bucket, name }, relations: ['batch'] }); + if (!file) return { found: false }; + + const computedHex = sha256(data).toString('hex'); + const hashMatches = file.sha256 === computedHex; + + const batch = file.batch; + if (!batch) return { found: true, hashMatches, anchored: false }; + + const batchFiles = await this.archiveFileRepo.find({ + where: { batch: { id: batch.id } }, + order: { leafIndex: 'ASC' }, + }); + const leaves = batchFiles.map((batchFile) => Buffer.from(batchFile.sha256, 'hex')); + + const rootBuffer = Buffer.from(batch.merkleRoot, 'hex'); + const proof = merkleInclusionProof(leaves, file.leafIndex); + const proofValid = verifyMerkleProof(Buffer.from(file.sha256, 'hex'), proof, rootBuffer); + + let bitcoinHeight: number; + let pending = true; + + if (batch.otsProof) { + const ots = await this.ots.verify(rootBuffer, Buffer.from(batch.otsProof, 'base64')); + pending = ots.pending; + if (ots.bitcoin) bitcoinHeight = ots.bitcoin.height; + } + + return { found: true, hashMatches, anchored: true, proofValid, bitcoinHeight, pending }; + } +} diff --git a/src/integration/infrastructure/storage/anchoring/merkle.ts b/src/integration/infrastructure/storage/anchoring/merkle.ts new file mode 100644 index 0000000000..5d32b8e259 --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/merkle.ts @@ -0,0 +1,114 @@ +import { createHash } from 'node:crypto'; + +/** + * Pure Merkle-tree primitives for the GeBüV anchoring pipeline. + * + * HASH / CONCATENATION RULE (load-bearing — verification depends on it): + * - All hashing is SHA-256 (`node:crypto`). + * - A parent node is `parent = sha256(left || right)`, where `||` is raw byte + * concatenation of the two 32-byte child digests (left first, then right). + * - Leaves are used as-is: they are NOT re-hashed by this module. Callers pass + * already-hashed leaves (e.g. `sha256(documentBytes)`). This keeps the module + * agnostic about leaf preimages and avoids a hidden hashing convention. + * - On a level with an ODD number of nodes, the last node is DUPLICATED and + * paired with itself (`parent = sha256(last || last)`). This is the classic + * Bitcoin-style promotion rule and is reproduced identically in proofs and + * verification so the computed root always matches. + * + * Edge cases: + * - 0 leaves: `buildMerkleRoot` throws (an empty tree has no root). + * - 1 leaf: the root IS that leaf (no hashing applied), and its inclusion proof + * is the empty path. + */ + +/** A single step of an inclusion proof: the sibling digest and whether it sits on the right. */ +export interface MerkleProofStep { + sibling: Buffer; + /** true if the sibling is the RIGHT child (i.e. the running hash is the LEFT child). */ + right: boolean; +} + +/** SHA-256 of the given bytes. */ +export function sha256(data: Buffer): Buffer { + return createHash('sha256').update(data).digest(); +} + +/** Hash a parent from its two children using the documented `sha256(left || right)` rule. */ +function hashPair(left: Buffer, right: Buffer): Buffer { + return sha256(Buffer.concat([left, right])); +} + +/** + * Build the Merkle root over `leaves`. + * + * Throws on an empty input. For a single leaf the root equals that leaf. + * On odd levels the last node is duplicated (see module rule). + */ +export function buildMerkleRoot(leaves: Buffer[]): Buffer { + if (leaves.length === 0) throw new Error('Cannot build a Merkle root from zero leaves'); + + let level = leaves; + while (level.length > 1) { + const next: Buffer[] = []; + for (let i = 0; i < level.length; i += 2) { + const left = level[i]; + // Odd node count: duplicate the last node and pair it with itself. + const right = i + 1 < level.length ? level[i + 1] : level[i]; + next.push(hashPair(left, right)); + } + level = next; + } + + return level[0]; +} + +/** + * Compute the inclusion proof for the leaf at `index` — the ordered list of sibling + * digests (with their left/right position) from the leaf up to (but excluding) the root. + * + * For a single-leaf tree the proof is empty. The duplication rule for odd levels is + * applied identically here, so a leaf that is the duplicated odd node gets a sibling + * equal to itself on the right. + */ +export function merkleInclusionProof(leaves: Buffer[], index: number): MerkleProofStep[] { + if (leaves.length === 0) throw new Error('Cannot build a proof from zero leaves'); + if (index < 0 || index >= leaves.length) throw new Error(`Leaf index ${index} out of range [0, ${leaves.length})`); + + const proof: MerkleProofStep[] = []; + + let level = leaves; + let idx = index; + while (level.length > 1) { + const isLeft = idx % 2 === 0; + // Sibling index; for the duplicated last odd node the sibling is the node itself. + const siblingIdx = isLeft ? Math.min(idx + 1, level.length - 1) : idx - 1; + + proof.push({ sibling: level[siblingIdx], right: isLeft }); + + const next: Buffer[] = []; + for (let i = 0; i < level.length; i += 2) { + const left = level[i]; + const right = i + 1 < level.length ? level[i + 1] : level[i]; + next.push(hashPair(left, right)); + } + + level = next; + idx = Math.floor(idx / 2); + } + + return proof; +} + +/** + * Recompute the root from `leaf` and its `proof` and compare it against the expected `root`. + * Returns true only on an exact byte match. + */ +export function verifyMerkleProof(leaf: Buffer, proof: MerkleProofStep[], root: Buffer): boolean { + let computed = leaf; + + for (const step of proof) { + computed = step.right ? hashPair(computed, step.sibling) : hashPair(step.sibling, computed); + } + + return computed.equals(root); +} diff --git a/src/integration/infrastructure/storage/anchoring/opentimestamps.service.ts b/src/integration/infrastructure/storage/anchoring/opentimestamps.service.ts new file mode 100644 index 0000000000..6391b5b43f --- /dev/null +++ b/src/integration/infrastructure/storage/anchoring/opentimestamps.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; +import * as OpenTimestamps from 'opentimestamps'; + +/** Result of verifying a detached `.ots` proof against its digest. */ +export interface OtsVerifyResult { + /** Present once the timestamp is anchored in a Bitcoin block. */ + bitcoin?: { height: number }; + /** true once a Bitcoin attestation was found and verified (i.e. `bitcoin` is set). */ + confirmed: boolean; + /** + * true while the timestamp is NOT yet confirmed on-chain. This conflates "calendar-only, + * not yet anchored" and "verify could not confirm anything"; use `confirmed` to assert a + * positive Bitcoin attestation. + */ + pending: boolean; +} + +/** + * Thin async/await wrapper around the `opentimestamps` npm library for the GeBüV + * anchoring pipeline. It deliberately knows nothing about Merkle trees, storage or + * scheduling — callers feed it a single 32-byte SHA-256 digest (typically a daily + * Merkle root) and get back / consume serialized detached `.ots` proof bytes. + * + * The underlying library mixes synchronous constructors with promise-returning + * network calls; everything is normalized to `async` here. + * + * NOTE: `verify` is run with `ignoreBitcoinNode: true`, so attestation is checked + * against the public block explorers the library trusts, NOT a local Bitcoin node. + * Verifying against a trusted local node (the strongest GeBüV posture) is not possible + * from this pure service and must be wired up separately if/when a node is available. + */ +@Injectable() +export class OpenTimestampsService { + /** + * Create a detached timestamp over `digest` (an already-computed SHA-256, e.g. a + * Merkle root) by submitting it to the public OpenTimestamps calendars. + * + * Returns the serialized `.ots` bytes to be persisted. At this point the proof is + * typically still "pending" — it carries calendar commitments but no Bitcoin + * attestation yet; call `upgrade` later to complete it. + */ + async stamp(digest: Buffer): Promise { + const detached = this.detachedFromDigest(digest); + + await OpenTimestamps.stamp(detached); + + return Buffer.from(detached.serializeToBytes()); + } + + /** + * Attempt to upgrade a pending `.ots` proof to a complete Bitcoin attestation by + * asking the calendars for the now-available block path. + * + * Returns the upgraded `.ots` bytes if anything changed, otherwise the original + * bytes unchanged (still pending). + */ + async upgrade(otsBytes: Buffer): Promise { + const detached = OpenTimestamps.DetachedTimestampFile.deserialize(otsBytes); + + const changed = await OpenTimestamps.upgrade(detached); + + return changed ? Buffer.from(detached.serializeToBytes()) : otsBytes; + } + + /** + * Verify that `otsBytes` is a valid timestamp over `digest`. + * + * Returns the Bitcoin attestation height once anchored; while the proof is still + * calendar-only it reports `pending: true` with no `bitcoin` field. + */ + async verify(digest: Buffer, otsBytes: Buffer): Promise { + const detachedOts = OpenTimestamps.DetachedTimestampFile.deserialize(otsBytes); + const detached = this.detachedFromDigest(digest); + + // ignoreBitcoinNode: verify against the library's trusted explorers, not a local node. + const result = await OpenTimestamps.verify(detachedOts, detached, { ignoreBitcoinNode: true }); + + // The library returns an object keyed by chain (e.g. { bitcoin: { height, timestamp } }); + // an empty/undefined result means the proof is not yet anchored. + const bitcoin = result && result.bitcoin; + if (bitcoin && typeof bitcoin.height === 'number') + return { bitcoin: { height: bitcoin.height }, confirmed: true, pending: false }; + + return { confirmed: false, pending: true }; + } + + /** Build a DetachedTimestampFile that commits directly to an already-computed SHA-256 digest. */ + private detachedFromDigest(digest: Buffer): any { + return OpenTimestamps.DetachedTimestampFile.fromHash(new OpenTimestamps.Ops.OpSHA256(), digest); + } +} diff --git a/src/integration/infrastructure/storage/mock-storage.service.ts b/src/integration/infrastructure/storage/mock-storage.service.ts new file mode 100644 index 0000000000..cf510ea081 --- /dev/null +++ b/src/integration/infrastructure/storage/mock-storage.service.ts @@ -0,0 +1,101 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { Blob, BlobContent, StorageService } from './storage.service'; + +// In-memory storage for local development (LOC). +const mockStorage = new Map }>(); + +// Dummy KYC files for local development (used when a blob was not uploaded this process lifetime). +const DUMMY_FILES_DIR = path.join(process.cwd(), 'scripts', 'kyc', 'dummy-files'); +const DUMMY_FILE_MAP: Record = { + 'id_front.png': { file: 'id_front.png', type: 'image/png' }, + 'id_back.png': { file: 'id_back.png', type: 'image/png' }, + 'selfie.jpg': { file: 'selfie.jpg', type: 'image/jpeg' }, + 'passport.png': { file: 'passport.png', type: 'image/png' }, + 'residence_permit.png': { file: 'residence_permit.png', type: 'image/png' }, + 'proof_of_address.pdf': { file: 'proof_of_address.pdf', type: 'application/pdf' }, + 'bank_statement.pdf': { file: 'bank_statement.pdf', type: 'application/pdf' }, + 'source_of_funds.pdf': { file: 'source_of_funds.pdf', type: 'application/pdf' }, + 'commercial_register.pdf': { file: 'commercial_register.pdf', type: 'application/pdf' }, + 'additional_document.pdf': { file: 'additional_document.pdf', type: 'application/pdf' }, +}; + +function loadDummyFile(filename: string): Buffer { + return fs.readFileSync(path.join(DUMMY_FILES_DIR, filename)); +} + +export class MockStorageService extends StorageService { + constructor(container: string) { + super(container); + } + + async listBlobs(prefix?: string): Promise { + const keyPrefix = `${this.container}/${prefix ?? ''}`; + + return [...mockStorage.entries()] + .filter(([key]) => key.startsWith(keyPrefix)) + .map(([key, value]) => { + const name = key.replace(`${this.container}/`, ''); + return { + name, + url: this.blobUrl(name), + contentType: value.type, + created: new Date(), + updated: new Date(), + metadata: value.metadata ?? {}, + }; + }); + } + + async getBlob(name: string): Promise { + const stored = mockStorage.get(`${this.container}/${name}`); + if (stored) + return { + data: stored.data, + contentType: stored.type, + created: new Date(), + updated: new Date(), + metadata: stored.metadata ?? {}, + }; + + // Fallback to a dummy file (parity with the previous mock) so LOC document reads return bytes. + const fileName = name.split('/').pop() ?? name; + const mapping = DUMMY_FILE_MAP[fileName]; + if (mapping) + return { + data: loadDummyFile(mapping.file), + contentType: mapping.type, + created: new Date(), + updated: new Date(), + metadata: {}, + }; + + const ext = name.split('.').pop()?.toLowerCase(); + const isPdf = ext === 'pdf'; + const isJpg = ext === 'jpg' || ext === 'jpeg'; + return { + data: loadDummyFile(isPdf ? 'proof_of_address.pdf' : isJpg ? 'selfie.jpg' : 'id_front.png'), + contentType: isPdf ? 'application/pdf' : isJpg ? 'image/jpeg' : 'image/png', + created: new Date(), + updated: new Date(), + metadata: {}, + }; + } + + async uploadBlob(name: string, data: Buffer, type: string, metadata?: Record): Promise { + mockStorage.set(`${this.container}/${name}`, { data, type, metadata }); + return this.blobUrl(name); + } + + async copyBlobs(sourcePrefix: string, targetPrefix: string): Promise { + for (const blob of await this.listBlobs(sourcePrefix)) { + const content = await this.getBlob(blob.name); + await this.uploadBlob( + blob.name.replace(sourcePrefix, targetPrefix), + content.data, + content.contentType, + blob.metadata, + ); + } + } +} diff --git a/src/integration/infrastructure/storage/s3-storage.service.ts b/src/integration/infrastructure/storage/s3-storage.service.ts new file mode 100644 index 0000000000..f98ddf5716 --- /dev/null +++ b/src/integration/infrastructure/storage/s3-storage.service.ts @@ -0,0 +1,117 @@ +import { + CopyObjectCommand, + GetObjectCommand, + HeadObjectCommand, + ListObjectsV2Command, + PutObjectCommand, + S3Client, +} from '@aws-sdk/client-s3'; +import { Config } from 'src/config/config'; +import { Blob, BlobContent, BlobMetaData, StorageService } from './storage.service'; + +/** + * S3-protocol storage implementation. Talks to the configured S3-compatible + * endpoint (on-prem MinIO today; any S3 store via `Config.s3.endpoint`) — this is a + * protocol client, not the AWS cloud: no AWS account, no data leaves to AWS. + * + * Replaces AzureStorageService. The blob URL shape is kept identical so `blobName()` + * stays reversible and URLs persisted in the DB remain consistent after migration. + * + * WORM / Object-Lock is expected to be enforced server-side via the bucket's default + * retention (Compliance mode), provisioned externally at bucket setup. It is + * intentionally not applied per request here. + */ +export class S3StorageService extends StorageService { + private readonly client: S3Client; + + constructor(container: string) { + super(container); + + const { endpoint, region, accessKey, secretKey, publicUrl } = Config.s3; + if (!endpoint || !region || !accessKey || !secretKey || !publicUrl) + throw new Error('Incomplete S3 config: endpoint, region, accessKey, secretKey and publicUrl are required'); + if (!publicUrl.endsWith('/')) throw new Error('S3 publicUrl must end with a trailing slash'); + + this.client = new S3Client({ + endpoint, + region, + forcePathStyle: true, // MinIO requires path-style addressing + credentials: { accessKeyId: accessKey, secretAccessKey: secretKey }, + }); + } + + async listBlobs(prefix?: string): Promise { + // S3 listings carry no content-type / user metadata (unlike the Azure listing this + // replaces), so fetch per object. Per-prefix counts are modest (per-user KYC/support). + const keys = await this.listKeys(prefix); + return Promise.all(keys.map((key) => this.head(key))); + } + + async getBlob(name: string): Promise { + const res = await this.client.send(new GetObjectCommand({ Bucket: this.container, Key: name })); + if (!res.Body) throw new Error(`Empty body for blob ${this.container}/${name}`); + + return { data: Buffer.from(await res.Body.transformToByteArray()), ...this.toMetaData(res) }; + } + + async uploadBlob(name: string, data: Buffer, type: string, metadata?: Record): Promise { + await this.client.send( + new PutObjectCommand({ Bucket: this.container, Key: name, Body: data, ContentType: type, Metadata: metadata }), + ); + + return this.blobUrl(name); + } + + async copyBlobs(sourcePrefix: string, targetPrefix: string): Promise { + // copy needs only the keys, not metadata — avoid the per-object HeadObject fan-out. + const keys = await this.listKeys(sourcePrefix); + + for (const key of keys) { + await this.client.send( + new CopyObjectCommand({ + Bucket: this.container, + Key: key.replace(sourcePrefix, targetPrefix), + CopySource: `${this.container}/${this.encodeKey(key)}`, // key must be URL-encoded + }), + ); + } + } + + private async listKeys(prefix?: string): Promise { + const keys: string[] = []; + + let token: string | undefined; + do { + const res = await this.client.send( + new ListObjectsV2Command({ Bucket: this.container, Prefix: prefix, ContinuationToken: token, MaxKeys: 1000 }), + ); + + for (const o of res.Contents ?? []) if (o.Key) keys.push(o.Key); + + token = res.IsTruncated ? res.NextContinuationToken : undefined; + } while (token); + + return keys; + } + + private async head(name: string): Promise { + const res = await this.client.send(new HeadObjectCommand({ Bucket: this.container, Key: name })); + return { name, url: this.blobUrl(name), ...this.toMetaData(res) }; + } + + // NOTE: S3 has no creation timestamp (created == updated == LastModified) and lowercases + // user-metadata keys. contentType/timestamps are always present for objects we write + // (ContentType is always set on upload). + private toMetaData(res: { + ContentType?: string; + LastModified?: Date; + Metadata?: Record; + }): BlobMetaData { + return { + contentType: res.ContentType, + created: res.LastModified, + updated: res.LastModified, + metadata: res.Metadata ?? {}, + }; + } +} diff --git a/src/integration/infrastructure/storage/storage.factory.ts b/src/integration/infrastructure/storage/storage.factory.ts new file mode 100644 index 0000000000..af4595f3c8 --- /dev/null +++ b/src/integration/infrastructure/storage/storage.factory.ts @@ -0,0 +1,23 @@ +import { Environment, GetConfig } from 'src/config/config'; +import { MockStorageService } from './mock-storage.service'; +import { S3StorageService } from './s3-storage.service'; +import { StorageService } from './storage.service'; + +/** + * Returns the configured storage implementation for a bucket/container. + * + * Deliberately a factory function rather than a DI provider: instances are + * per-container and some containers are resolved at runtime (e.g. the per-merchant + * EP2 settlement container in fiat-output), which a singleton provider can't express. + * Drop-in replacement for `new AzureStorageService(container)` at the call sites: + * - kyc-document.service.ts (constructed at boot — eager config validation / fail-fast) + * - support-document.service.ts (constructed at boot — eager config validation / fail-fast) + * - fiat-output-job.service.ts (per-job, runtime EP2 container) + * Because the KYC/support providers are eagerly instantiated, an incomplete S3 config + * fails the application boot, not just the first storage call. + */ +export function createStorageService(container: string): StorageService { + return GetConfig().environment === Environment.LOC + ? new MockStorageService(container) + : new S3StorageService(container); +} diff --git a/src/integration/infrastructure/storage/storage.service.ts b/src/integration/infrastructure/storage/storage.service.ts new file mode 100644 index 0000000000..5ffd9314c7 --- /dev/null +++ b/src/integration/infrastructure/storage/storage.service.ts @@ -0,0 +1,51 @@ +import { Config } from 'src/config/config'; + +export interface BlobMetaData { + contentType: string; + created: Date; + updated: Date; + metadata: Record; +} + +export interface Blob extends BlobMetaData { + name: string; + url: string; +} + +export interface BlobContent extends BlobMetaData { + data: Buffer; +} + +/** + * Provider-agnostic blob storage abstraction. + * + * The method surface is signature-compatible with the previous AzureStorageService, + * so consumers only change how the instance is obtained (see storage.factory.ts). + * + * `blobUrl`/`blobName` live here so the URL shape — and its reversibility — is + * identical across implementations and stays consistent with URLs persisted in the DB. + * The trailing-slash contract on the public URL base is enforced by the concrete + * implementation's config validation. + */ +export abstract class StorageService { + constructor(protected readonly container: string) {} + + abstract listBlobs(prefix?: string): Promise; + abstract getBlob(name: string): Promise; + abstract uploadBlob(name: string, data: Buffer, type: string, metadata?: Record): Promise; + abstract copyBlobs(sourcePrefix: string, targetPrefix: string): Promise; + + blobUrl(name: string): string { + return `${Config.s3.publicUrl}${this.container}/${this.encodeKey(name)}`; + } + + blobName(url: string): string { + const filePath = url.split(`${this.container}/`)[1]; + if (filePath == null) throw new Error(`URL does not belong to container ${this.container}: ${url}`); + return filePath.split('/').map(decodeURIComponent).join('/'); + } + + protected encodeKey(name: string): string { + return name.split('/').map(encodeURIComponent).join('/'); + } +} diff --git a/src/shared/services/process.service.ts b/src/shared/services/process.service.ts index f16da5d8c0..bf4209463e 100644 --- a/src/shared/services/process.service.ts +++ b/src/shared/services/process.service.ts @@ -91,6 +91,8 @@ export enum Process { TRADE_APPROVAL_DATE = 'TradeApprovalDate', SUPPORT_BOT = 'SupportBot', GUARANTEED_PRICE = 'GuaranteedPrice', + ARCHIVE_ANCHOR = 'ArchiveAnchor', + ARCHIVE_UPGRADE = 'ArchiveUpgrade', } const safetyProcesses: Process[] = [ diff --git a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts index 8c28b89546..d7e6c5a91c 100644 --- a/src/subdomains/generic/kyc/dto/kyc-file.dto.ts +++ b/src/subdomains/generic/kyc/dto/kyc-file.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Blob } from 'src/integration/infrastructure/azure-storage.service'; +import { Blob } from 'src/integration/infrastructure/storage/storage.service'; import { UserData } from '../../user/models/user-data/user-data.entity'; import { KycWebhookData } from '../../user/services/webhook/dto/kyc-webhook.dto'; import { KycStep } from '../entities/kyc-step.entity'; diff --git a/src/subdomains/generic/kyc/dto/mapper/kyc-file.mapper.ts b/src/subdomains/generic/kyc/dto/mapper/kyc-file.mapper.ts index aad02c6f37..e7367c6c35 100644 --- a/src/subdomains/generic/kyc/dto/mapper/kyc-file.mapper.ts +++ b/src/subdomains/generic/kyc/dto/mapper/kyc-file.mapper.ts @@ -1,4 +1,4 @@ -import { BlobContent } from 'src/integration/infrastructure/azure-storage.service'; +import { BlobContent } from 'src/integration/infrastructure/storage/storage.service'; import { KycFile } from '../../entities/kyc-file.entity'; import { KycFileDataDto } from '../kyc-file.dto'; diff --git a/src/subdomains/generic/kyc/kyc.module.ts b/src/subdomains/generic/kyc/kyc.module.ts index eb6d8d78c0..d554792a1c 100644 --- a/src/subdomains/generic/kyc/kyc.module.ts +++ b/src/subdomains/generic/kyc/kyc.module.ts @@ -1,5 +1,6 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ArchiveModule } from 'src/integration/infrastructure/storage/anchoring/archive.module'; import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoModule } from 'src/subdomains/core/buy-crypto/buy-crypto.module'; import { SellCryptoModule } from 'src/subdomains/core/sell-crypto/sell-crypto.module'; @@ -52,6 +53,7 @@ import { TfaService } from './services/tfa.service'; KycFile, ]), SharedModule, + ArchiveModule, NotificationModule, forwardRef(() => UserModule), forwardRef(() => BuyCryptoModule), diff --git a/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts b/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts new file mode 100644 index 0000000000..98e3d68347 --- /dev/null +++ b/src/subdomains/generic/kyc/services/integration/__tests__/kyc-document.service.spec.ts @@ -0,0 +1,100 @@ +// Stub the heavy `opentimestamps` library (pulled in transitively via ArchiveService) so its +// eager network/`request` deps never load; ArchiveService is fully mocked in this spec. +jest.mock('opentimestamps', () => ({})); + +// Control the storage backend the service constructs in its constructor, so uploadBlob is a +// spy and no real S3/Azure/mock storage is touched. +const uploadBlobMock = jest.fn(); +jest.mock('src/integration/infrastructure/storage/storage.factory', () => ({ + createStorageService: jest.fn(() => ({ + uploadBlob: (...args: any[]) => uploadBlobMock(...args), + })), +})); + +import { createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ArchiveService } from 'src/integration/infrastructure/storage/anchoring/archive.service'; +import { sha256 } from 'src/integration/infrastructure/storage/anchoring/merkle'; +import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; +import { FileType } from '../../../dto/kyc-file.dto'; +import { KycFile } from '../../../entities/kyc-file.entity'; +import { ContentType } from '../../../enums/content-type.enum'; +import { KycFileService } from '../../kyc-file.service'; +import { KycDocumentService } from '../kyc-document.service'; + +describe('KycDocumentService - GeBüV hash recording', () => { + let service: KycDocumentService; + let kycFileService: KycFileService; + let archiveService: ArchiveService; + + const userData = { id: 42 } as UserData; + const data = Buffer.from('a kyc document payload'); + const expectedBlobName = `user/42/${FileType.IDENTIFICATION}/passport.pdf`; + const expectedHash = sha256(data).toString('hex'); + + beforeEach(async () => { + jest.clearAllMocks(); + + kycFileService = createMock(); + archiveService = createMock(); + + (kycFileService.createKycFile as jest.Mock).mockResolvedValue({ id: 7 } as KycFile); + uploadBlobMock.mockResolvedValue('https://storage/blob-url'); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + KycDocumentService, + { provide: KycFileService, useValue: kycFileService }, + { provide: ArchiveService, useValue: archiveService }, + ], + }).compile(); + + service = module.get(KycDocumentService); + }); + + it('records the uploaded blob hash in the kyc bucket after a successful upload', async () => { + const result = await service.uploadUserFile( + userData, + FileType.IDENTIFICATION, + 'passport.pdf', + data, + ContentType.PDF, + true, + ); + + // upload happened, then recordHash with the deterministic blob name + sha256 of the data + expect(uploadBlobMock).toHaveBeenCalledTimes(1); + expect(uploadBlobMock.mock.calls[0][0]).toBe(expectedBlobName); + + expect(archiveService.recordHash).toHaveBeenCalledTimes(1); + expect(archiveService.recordHash).toHaveBeenCalledWith('kyc', expectedBlobName, expectedHash); + + expect(result).toEqual({ file: { id: 7 }, url: 'https://storage/blob-url' }); + }); + + it('does NOT roll back the upload when hash recording fails (best-effort side-booking)', async () => { + (archiveService.recordHash as jest.Mock).mockRejectedValue(new Error('archive db down')); + + const result = await service.uploadUserFile( + userData, + FileType.IDENTIFICATION, + 'passport.pdf', + data, + ContentType.PDF, + true, + ); + + // the recordHash failure was swallowed: the method still returns the uploaded file + url + expect(archiveService.recordHash).toHaveBeenCalledTimes(1); + expect(result).toEqual({ file: { id: 7 }, url: 'https://storage/blob-url' }); + }); + + it('rejects unsupported media types before any upload or hash recording', async () => { + await expect( + service.uploadUserFile(userData, FileType.IDENTIFICATION, 'note.txt', data, 'text/plain' as ContentType, true), + ).rejects.toThrow('Supported file types'); + + expect(uploadBlobMock).not.toHaveBeenCalled(); + expect(archiveService.recordHash).not.toHaveBeenCalled(); + }); +}); diff --git a/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts b/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts index 3611edafc4..7b8b8c95bd 100644 --- a/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts +++ b/src/subdomains/generic/kyc/services/integration/kyc-document.service.ts @@ -1,5 +1,9 @@ import { Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; -import { AzureStorageService, BlobContent } from 'src/integration/infrastructure/azure-storage.service'; +import { ArchiveService } from 'src/integration/infrastructure/storage/anchoring/archive.service'; +import { sha256 } from 'src/integration/infrastructure/storage/anchoring/merkle'; +import { createStorageService } from 'src/integration/infrastructure/storage/storage.factory'; +import { BlobContent, StorageService } from 'src/integration/infrastructure/storage/storage.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { AccountType } from 'src/subdomains/generic/user/models/user-data/account-type.enum'; import { UserData } from 'src/subdomains/generic/user/models/user-data/user-data.entity'; import { FileSubType, FileType, KycFileBlob } from '../../dto/kyc-file.dto'; @@ -11,10 +15,17 @@ import { KycFileService } from '../kyc-file.service'; @Injectable() export class KycDocumentService { - private readonly storageService: AzureStorageService; + private static readonly bucket = 'kyc'; - constructor(private readonly kycFileService: KycFileService) { - this.storageService = new AzureStorageService('kyc'); + private readonly logger = new DfxLogger(KycDocumentService); + + private readonly storageService: StorageService; + + constructor( + private readonly kycFileService: KycFileService, + private readonly archiveService: ArchiveService, + ) { + this.storageService = createStorageService(KycDocumentService.bucket); } async getAllUserDocuments(userDataId: number, accountType = AccountType.PERSONAL): Promise { @@ -95,12 +106,20 @@ export class KycDocumentService { kycStep, }); - const url = await this.storageService.uploadBlob( - this.toFileId(FileCategory.USER, userData.id, type, name), - data, - contentType, - metadata, - ); + const blobName = this.toFileId(FileCategory.USER, userData.id, type, name); + + const url = await this.storageService.uploadBlob(blobName, data, contentType, metadata); + + // GeBüV anchoring (Stage 3): record the content hash of the just-uploaded KYC document + // (a retention-relevant compliance bucket) so it can later be Merkle-batched and anchored. + // This is a best-effort side-booking: the upload above has already succeeded and must not + // be rolled back if hash recording fails, so failures are logged (never silently swallowed) + // but not rethrown. + try { + await this.archiveService.recordHash(KycDocumentService.bucket, blobName, sha256(data).toString('hex')); + } catch (e) { + this.logger.error(`GeBüV anchoring failed to record hash for ${KycDocumentService.bucket}/${blobName}:`, e); + } return { file, url }; } diff --git a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts index 496c5fa2cf..442a055085 100644 --- a/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts +++ b/src/subdomains/supporting/fiat-output/__tests__/fiat-output-job.service.spec.ts @@ -1,8 +1,23 @@ +// Stub the heavy `opentimestamps` library (pulled in transitively via ArchiveService) so its +// eager network/`request` deps never load at jest runtime; ArchiveService is mocked in this spec. +jest.mock('opentimestamps', () => ({})); + +// generateReports resolves a per-merchant EP2 container at runtime via createStorageService(); +// mock the factory so uploadBlob is a spy and no real storage backend is touched. +const ep2UploadBlobMock = jest.fn(); +jest.mock('src/integration/infrastructure/storage/storage.factory', () => ({ + createStorageService: jest.fn(() => ({ + uploadBlob: (...args: any[]) => ep2UploadBlobMock(...args), + })), +})); + import { createMock } from '@golevelup/ts-jest'; import { Test, TestingModule } from '@nestjs/testing'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; +import { ArchiveService } from 'src/integration/infrastructure/storage/anchoring/archive.service'; +import { sha256 } from 'src/integration/infrastructure/storage/anchoring/merkle'; import { createCustomAsset, createDefaultAsset } from 'src/shared/models/asset/__mocks__/asset.entity.mock'; import { AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; @@ -49,8 +64,10 @@ describe('FiatOutputJobService', () => { let olkypayService: OlkypayService; let virtualIbanService: VirtualIbanService; let scryptService: ScryptService; + let archiveService: ArchiveService; beforeEach(async () => { + ep2UploadBlobMock.mockReset(); fiatOutputRepo = createMock(); bankTxService = createMock(); ep2ReportService = createMock(); @@ -64,6 +81,7 @@ describe('FiatOutputJobService', () => { olkypayService = createMock(); virtualIbanService = createMock(); scryptService = createMock(); + archiveService = createMock(); jest.spyOn(processServiceModule, 'DisabledProcess').mockReturnValue(false); // Default mock: no virtual IBANs @@ -88,6 +106,7 @@ describe('FiatOutputJobService', () => { { provide: OlkypayService, useValue: olkypayService }, { provide: VirtualIbanService, useValue: virtualIbanService }, { provide: ScryptService, useValue: scryptService }, + { provide: ArchiveService, useValue: archiveService }, TestUtil.provideConfig(), ], @@ -448,4 +467,94 @@ describe('FiatOutputJobService', () => { expect(fiatOutputRepo.update).not.toHaveBeenCalled(); }); }); + + describe('generateReports - GeBüV hash recording', () => { + // A FiatOutput whose buyFiat resolves the container, route id and userData the method needs. + // The getter chain (paymentLinkPayment.link.linkConfigObj, paymentLinksConfigObj) is stubbed + // directly on plain objects so we don't have to assemble the full entity graph. + function reportableEntity() { + const buyFiat: any = { + sell: { id: 555 }, + userData: { paymentLinksConfigObj: { ep2ReportContainer: 'ep2-merchant-bucket' } }, + paymentLinkPayment: { link: { linkConfigObj: { payoutRouteId: 777 } } }, + }; + + return { + id: 1, + created: new Date('2024-03-01T10:00:00Z'), + buyFiats: [buyFiat], + } as any; + } + + beforeEach(() => { + ep2UploadBlobMock.mockResolvedValue(undefined); + (ep2ReportService.generateReport as jest.Mock).mockReturnValue(''); + }); + + it('uploads the report, then sets reportCreated and records the report hash', async () => { + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([reportableEntity()]); + + await service['generateReports'](); + + const fileName = ep2UploadBlobMock.mock.calls[0][0]; + expect(ep2UploadBlobMock).toHaveBeenCalledTimes(1); + expect(fileName).toMatch(/^settlement_.*_777\.ep2$/); + + // reportCreated is flipped before the best-effort anchoring runs + expect(fiatOutputRepo.update).toHaveBeenCalledWith(1, { reportCreated: true }); + + // the recorded hash is the sha256 of the uploaded report buffer + const expectedHash = sha256(Buffer.from('')).toString('hex'); + expect(archiveService.recordHash).toHaveBeenCalledWith('ep2-merchant-bucket', fileName, expectedHash); + + // Load-bearing ordering: uploadBlob (WORM PUT) < update(reportCreated=true) < recordHash. + // reportCreated MUST be persisted before the best-effort recordHash, otherwise a recordHash + // failure would leave reportCreated=false and the next run would re-PUT the same fileName + // into the immutable WORM bucket and deadlock. Asserting the call order makes the test break + // if someone reorders recordHash ahead of the reportCreated update. + const uploadOrder = ep2UploadBlobMock.mock.invocationCallOrder[0]; + const updateOrder = (fiatOutputRepo.update as jest.Mock).mock.invocationCallOrder[0]; + const recordHashOrder = (archiveService.recordHash as jest.Mock).mock.invocationCallOrder[0]; + expect(uploadOrder).toBeLessThan(updateOrder); + expect(updateOrder).toBeLessThan(recordHashOrder); + }); + + it('does NOT prevent reportCreated when hash recording fails (best-effort, runs after the flag)', async () => { + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([reportableEntity()]); + (archiveService.recordHash as jest.Mock).mockRejectedValue(new Error('archive db down')); + + await service['generateReports'](); + + // upload + reportCreated still happened despite the recordHash failure + expect(ep2UploadBlobMock).toHaveBeenCalledTimes(1); + expect(fiatOutputRepo.update).toHaveBeenCalledWith(1, { reportCreated: true }); + expect(archiveService.recordHash).toHaveBeenCalledTimes(1); + }); + + it('does not set reportCreated when the upload itself fails', async () => { + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([reportableEntity()]); + ep2UploadBlobMock.mockRejectedValue(new Error('WORM bucket unreachable')); + + await service['generateReports'](); + + expect(fiatOutputRepo.update).not.toHaveBeenCalled(); + expect(archiveService.recordHash).not.toHaveBeenCalled(); + }); + + it('falls back to the sell route id for the file name when no payoutRouteId is configured', async () => { + // linkConfigObj has no payoutRouteId => the `routeId ?? buyFiat.sell.id` fallback kicks in + // and the file name must carry the sell id (555) instead of a payout route id. + const entity = reportableEntity(); + entity.buyFiats[0].paymentLinkPayment.link.linkConfigObj = {}; + jest.spyOn(fiatOutputRepo, 'find').mockResolvedValue([entity]); + + await service['generateReports'](); + + const fileName = ep2UploadBlobMock.mock.calls[0][0]; + expect(fileName).toMatch(/^settlement_.*_555\.ep2$/); + + const expectedHash = sha256(Buffer.from('')).toString('hex'); + expect(archiveService.recordHash).toHaveBeenCalledWith('ep2-merchant-bucket', fileName, expectedHash); + }); + }); }); diff --git a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts index e2f85eb799..b6305636e0 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output-job.service.ts @@ -7,7 +7,9 @@ import { Pain001Payment } from 'src/integration/bank/services/iso20022.service'; import { OlkypayService } from 'src/integration/bank/services/olkypay.service'; import { YapealService } from 'src/integration/bank/services/yapeal.service'; import { ScryptService } from 'src/integration/exchange/services/scrypt.service'; -import { AzureStorageService } from 'src/integration/infrastructure/azure-storage.service'; +import { ArchiveService } from 'src/integration/infrastructure/storage/anchoring/archive.service'; +import { sha256 } from 'src/integration/infrastructure/storage/anchoring/merkle'; +import { createStorageService } from 'src/integration/infrastructure/storage/storage.factory'; import { AssetType } from 'src/shared/models/asset/asset.entity'; import { AssetService } from 'src/shared/models/asset/asset.service'; import { Country } from 'src/shared/models/country/country.entity'; @@ -54,6 +56,7 @@ export class FiatOutputJobService { private readonly olkypayService: OlkypayService, private readonly virtualIbanService: VirtualIbanService, private readonly scryptService: ScryptService, + private readonly archiveService: ArchiveService, ) {} @DfxCron(CronExpression.EVERY_MINUTE, { process: Process.FIAT_OUTPUT, timeout: 1800 }) @@ -110,10 +113,24 @@ export class FiatOutputJobService { const container = buyFiat.userData.paymentLinksConfigObj.ep2ReportContainer; const routeId = buyFiat.paymentLinkPayment.link.linkConfigObj?.payoutRouteId ?? buyFiat.sell.id; const fileName = `settlement_${Util.isoDateTime(entity.created)}_${routeId}.ep2`; + const reportBuffer = Buffer.from(report); - await new AzureStorageService(container).uploadBlob(fileName, Buffer.from(report), 'text/xml'); + await createStorageService(container).uploadBlob(fileName, reportBuffer, 'text/xml'); + // Mark the report as created as soon as the WORM upload succeeded, BEFORE the best-effort + // anchoring below. A recordHash failure must not leave reportCreated=false, otherwise the + // next run would re-PUT the same fileName into the immutable WORM bucket and block forever. await this.fiatOutputRepo.update(entity.id, { reportCreated: true }); + + // GeBüV anchoring (Stage 3): record the content hash of the just-uploaded EP2 settlement + // report (a retention-relevant compliance bucket) for later Merkle-batching and anchoring. + // Best-effort side-booking: the upload already succeeded, so a failure here is logged + // (never silently swallowed) but does not roll back the upload or the reportCreated flag. + try { + await this.archiveService.recordHash(container, fileName, sha256(reportBuffer).toString('hex')); + } catch (e) { + this.logger.error(`GeBüV anchoring failed to record hash for ${container}/${fileName}:`, e); + } } catch (e) { this.logger.error(`Failed to generate EP2 report for fiat output ${entity.id}:`, e); } diff --git a/src/subdomains/supporting/fiat-output/fiat-output.module.ts b/src/subdomains/supporting/fiat-output/fiat-output.module.ts index 9406cc452a..b6ab661420 100644 --- a/src/subdomains/supporting/fiat-output/fiat-output.module.ts +++ b/src/subdomains/supporting/fiat-output/fiat-output.module.ts @@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BankIntegrationModule } from 'src/integration/bank/bank.module'; import { ExchangeModule } from 'src/integration/exchange/exchange.module'; +import { ArchiveModule } from 'src/integration/infrastructure/storage/anchoring/archive.module'; import { SharedModule } from 'src/shared/shared.module'; import { BuyCryptoRepository } from 'src/subdomains/core/buy-crypto/process/repositories/buy-crypto.repository'; import { LiquidityManagementModule } from 'src/subdomains/core/liquidity-management/liquidity-management.module'; @@ -27,6 +28,7 @@ import { FiatOutputJobService } from './fiat-output-job.service'; ExchangeModule, forwardRef(() => LiquidityManagementModule), LogModule, + ArchiveModule, ], controllers: [FiatOutputController], diff --git a/src/subdomains/supporting/support-issue/services/support-document.service.ts b/src/subdomains/supporting/support-issue/services/support-document.service.ts index 1d33c068b7..0f971ed5e6 100644 --- a/src/subdomains/supporting/support-issue/services/support-document.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-document.service.ts @@ -1,5 +1,6 @@ import { Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; -import { AzureStorageService, Blob, BlobContent } from 'src/integration/infrastructure/azure-storage.service'; +import { createStorageService } from 'src/integration/infrastructure/storage/storage.factory'; +import { Blob, BlobContent, StorageService } from 'src/integration/infrastructure/storage/storage.service'; import { ContentType } from 'src/subdomains/generic/kyc/enums/content-type.enum'; export interface SupportFile extends Blob { @@ -10,10 +11,10 @@ export interface SupportFile extends Blob { @Injectable() export class SupportDocumentService { - private readonly storageService: AzureStorageService; + private readonly storageService: StorageService; constructor() { - this.storageService = new AzureStorageService('support'); + this.storageService = createStorageService('support'); } async listFilesByPrefix(prefix: string): Promise { diff --git a/src/subdomains/supporting/support-issue/services/support-issue.service.ts b/src/subdomains/supporting/support-issue/services/support-issue.service.ts index 457c727e21..3a10358a34 100644 --- a/src/subdomains/supporting/support-issue/services/support-issue.service.ts +++ b/src/subdomains/supporting/support-issue/services/support-issue.service.ts @@ -6,7 +6,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { Config } from 'src/config/config'; -import { BlobContent } from 'src/integration/infrastructure/azure-storage.service'; +import { BlobContent } from 'src/integration/infrastructure/storage/storage.service'; import { UserRole } from 'src/shared/auth/user-role.enum'; import { SettingService } from 'src/shared/models/setting/setting.service'; import { Util } from 'src/shared/utils/util'; diff --git a/src/subdomains/supporting/support-issue/support-issue.controller.ts b/src/subdomains/supporting/support-issue/support-issue.controller.ts index be6a3ffaee..b23a73e552 100644 --- a/src/subdomains/supporting/support-issue/support-issue.controller.ts +++ b/src/subdomains/supporting/support-issue/support-issue.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBadRequestResponse, ApiBearerAuth, ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; -import { BlobContent } from 'src/integration/infrastructure/azure-storage.service'; +import { BlobContent } from 'src/integration/infrastructure/storage/storage.service'; import { GetJwt } from 'src/shared/auth/get-jwt.decorator'; import { JwtPayload } from 'src/shared/auth/jwt-payload.interface'; import { OptionalJwtAuthGuard } from 'src/shared/auth/optional.guard';