diff --git a/.gitignore b/.gitignore index 4c4a60c..2ae4263 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,13 @@ CLAUDE.md # docs docs/ + +# graphify knowledge-graph build output (regenerable; rebuilt by the post-commit hook) +graphify-out/ + +# SDD progress ledger (scratch) +.superpowers/ + + +# Claude +.claude/ \ No newline at end of file diff --git a/Procfile b/Procfile index e8f79ea..8617827 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,2 @@ -web: npm start \ No newline at end of file +web: node dist/index.js +reconcile-docs: node dist/cron/publishReconcile.js diff --git a/package-lock.json b/package-lock.json index 5a74967..2d4cf43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@socket.io/redis-adapter": "^8.3.0", "@ucans/ucans": "^0.12.0", "@waku/sdk": "^0.0.34", + "agenda": "^5.0.0", "base64-js": "^1.5.1", "compression": "^1.7.4", "cors": "^2.8.5", @@ -76,6 +77,319 @@ "node": ">=6.0.0" } }, + "node_modules/@aws-sdk/core": { + "version": "3.975.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.975.1.tgz", + "integrity": "sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "^3.974.0", + "@aws-sdk/xml-builder": "^3.972.34", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.29.2", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.56.tgz", + "integrity": "sha512-nedaZz6Wz2FGUBMsWhlvBPGIkTMfRuMJ99YDHJI4CaC31JS8WPyTavEAio74cfd5nGhi0A8XASB4fZA5VqTdOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.57.tgz", + "integrity": "sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.59.tgz", + "integrity": "sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.1.tgz", + "integrity": "sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-login": "^3.972.63", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.63.tgz", + "integrity": "sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.66", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.66.tgz", + "integrity": "sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-ini": "^3.973.1", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.57", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.57.tgz", + "integrity": "sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.1.tgz", + "integrity": "sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/token-providers": "3.1083.0", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.63", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.63.tgz", + "integrity": "sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1084.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1084.0.tgz", + "integrity": "sha512-6MT9xigrduBftpOEq2HKzGqp2Up0Jwe8dK5W3GyBpePywzgMK6029XEYXkCC5wRYwXwhSPzpjnjRlWUA7K/KNQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.56", + "@aws-sdk/credential-provider-env": "^3.972.57", + "@aws-sdk/credential-provider-http": "^3.972.59", + "@aws-sdk/credential-provider-ini": "^3.973.1", + "@aws-sdk/credential-provider-login": "^3.972.63", + "@aws-sdk/credential-provider-node": "^3.972.66", + "@aws-sdk/credential-provider-process": "^3.972.57", + "@aws-sdk/credential-provider-sso": "^3.973.1", + "@aws-sdk/credential-provider-web-identity": "^3.972.63", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/credential-provider-imds": "^4.4.7", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.31.tgz", + "integrity": "sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.39", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/fetch-http-handler": "^5.6.4", + "@smithy/node-http-handler": "^4.9.4", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.39.tgz", + "integrity": "sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/types": "^3.974.0", + "@smithy/signature-v4": "^5.6.3", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1083.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1083.0.tgz", + "integrity": "sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@aws-sdk/core": "^3.975.1", + "@aws-sdk/nested-clients": "^3.997.31", + "@aws-sdk/types": "^3.974.0", + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.0.tgz", + "integrity": "sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.34.tgz", + "integrity": "sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -1846,6 +2160,93 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@smithy/core": { + "version": "3.29.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.29.2.tgz", + "integrity": "sha512-DXUk6yU0C1Q1tYvJh1VCtl8QOBcSoZpKwjTPkxT6A4MUQYHvgeKGByL8mrEdxnvhdf9nq5GyzmRb5n/vPgu3Lw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.7.tgz", + "integrity": "sha512-UEMLOoA0Fl4uYBxh6l0uN0H6EJe/A89OGeDNTteQeXpJ20BcpfIr4wlCY9pel1jEAUHAxaYwuqrYlrKdXE1GKQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.4.tgz", + "integrity": "sha512-psnst7NZWdAEvJvyW8YZEE7xNVMyLrQFfHtyrVFrxNyy+dKWkQ+rqC6oI5ZhxThpUy9RSfEshgm34zqbOxzsRw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.9.4", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.4.tgz", + "integrity": "sha512-BNTop/fSOptmoVk8g+efwHCofFh37g70OWGAFES1TeAAJja1K5aAI8rTE26ETSc5k8IQuWY2kAIoPla01NgYrA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.3.tgz", + "integrity": "sha512-8qVKKzqh7naF27ePmx0SkUfnGP/wBI9dyaeAmhHvopnbIlItUAmB/e6PkPCU3rRb2v9BY8D4EZXSoydSibatvw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@smithy/core": "^3.29.2", + "@smithy/types": "^4.16.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.16.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.0.tgz", + "integrity": "sha512-aVUabzlBBmY0PfvVgLKQSOGFIL5/7R54JE3uD9a5Ay/jSED61SkuAcCYENNXJzYUvJ1NPrWO0P+rAXHCkbBUKw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@socket.io/component-emitter": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", @@ -2826,6 +3227,139 @@ "node": ">=0.4.0" } }, + "node_modules/agenda": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/agenda/-/agenda-5.0.0.tgz", + "integrity": "sha512-jOoa7PvARpst/y2PI8h0wph4NmcjYJ/4wzFhQcHUbNgN+Hte/9h/MzKE0ZmHfIwdsSlnv3rhbBQ3Zd/gwFkThg==", + "license": "MIT", + "dependencies": { + "cron-parser": "^3.5.0", + "date.js": "~0.3.3", + "debug": "~4.3.4", + "human-interval": "~2.0.1", + "moment-timezone": "~0.5.37", + "mongodb": "^4.11.0" + }, + "engines": { + "node": ">=12.9.0" + } + }, + "node_modules/agenda/node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, + "node_modules/agenda/node_modules/bson": { + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bson/-/bson-4.7.2.tgz", + "integrity": "sha512-Ry9wCtIZ5kGqkJoi6aD8KjxFZEx78guTQDnpXWiNthsxzrxAK/i8E6pCHAIZTbaEFWcOCvbecMukfK7XUvyLpQ==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "^5.6.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/agenda/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/agenda/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agenda/node_modules/mongodb": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-4.17.2.tgz", + "integrity": "sha512-mLV7SEiov2LHleRJPMPrK2PMyhXFZt2UQLC4VD4pnth3jMjYKHhtqfwwkkvS/NXuo/Fp3vbhaNcXrIDaLRb9Tg==", + "license": "Apache-2.0", + "dependencies": { + "bson": "^4.7.2", + "mongodb-connection-string-url": "^2.6.0", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "optionalDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "@mongodb-js/saslprep": "^1.1.0" + } + }, + "node_modules/agenda/node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/agenda/node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/agenda/node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2994,6 +3528,13 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT", + "optional": true + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -3085,6 +3626,24 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3351,6 +3910,19 @@ "node": ">= 0.10" } }, + "node_modules/cron-parser": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-3.5.0.tgz", + "integrity": "sha512-wyVZtbRs6qDfFd8ap457w3XVntdvqcwBGxBoTvJQH9KGVKL/fB+h2k3C8AqiVxvUQKN1Ps/Ns46CNViOpVDhfQ==", + "license": "MIT", + "dependencies": { + "is-nan": "^1.3.2", + "luxon": "^1.26.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3392,6 +3964,30 @@ "it-take": "^3.0.8" } }, + "node_modules/date.js": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/date.js/-/date.js-0.3.3.tgz", + "integrity": "sha512-HgigOS3h3k6HnW011nAb43c5xx5rBXk8P2v/WIT9Zv4koIaVXiH2BURguI78VVp+5Qc076T7OR378JViCnZtBw==", + "license": "MIT", + "dependencies": { + "debug": "~3.1.0" + } + }, + "node_modules/date.js/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/date.js/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3427,6 +4023,40 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delay": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/delay/-/delay-6.0.0.tgz", @@ -4450,6 +5080,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4517,6 +5159,15 @@ "node": ">= 0.8" } }, + "node_modules/human-interval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/human-interval/-/human-interval-2.0.1.tgz", + "integrity": "sha512-r4Aotzf+OtKIGQCB3odUowy4GfUDTy3aTWTfLd7ZF2gBCy3XW3v/dJLRefZnOFFnjqs5B1TypvS8WarpBkYUNQ==", + "license": "MIT", + "dependencies": { + "numbered": "^1.1.0" + } + }, "node_modules/human-signals": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", @@ -4654,6 +5305,15 @@ "url": "https://opencollective.com/ioredis" } }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4708,6 +5368,22 @@ "integrity": "sha512-26POf2KRCno/KTNL5Q0b/9TYnL00xEsSaLfiFRmjM7m7Lw7ZMmFybzzuX4CcsLAluZGd+niLUiMRxEooVE3aqg==", "license": "MIT" }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-network-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.1.0.tgz", @@ -5342,6 +6018,15 @@ "dev": true, "license": "ISC" }, + "node_modules/luxon": { + "version": "1.28.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-1.28.1.tgz", + "integrity": "sha512-gYHAa180mKrNIUJCbwpmD0aTu9kV0dREDrwNnuyFAsO1Wt0EVYSZelPnJlbj9HplzXX/YWXHFTL45kvZ53M0pw==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/magic-string": { "version": "0.30.18", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz", @@ -5553,6 +6238,27 @@ "ufo": "^1.6.1" } }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "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": "*" + } + }, "node_modules/mongodb": { "version": "6.18.0", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", @@ -5815,6 +6521,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/numbered": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/numbered/-/numbered-1.1.0.tgz", + "integrity": "sha512-pv/ue2Odr7IfYOO0byC1KgBI10wo5YDauLhxY6/saNzAdAs0r1SotGCPzzCLNPL0xtrAwWRialLu23AAu9xO1g==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5836,6 +6548,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6701,6 +7422,23 @@ "node": ">= 0.8.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6828,6 +7566,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/socket.io": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", @@ -6869,6 +7617,20 @@ "node": ">=10.0.0" } }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -7369,6 +8131,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsup": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.0.tgz", diff --git a/package.json b/package.json index c62d3e4..e36351f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "debug:watch": "tsx --inspect watch src/index.ts", "build": "tsup", "start": "node dist/index.js", + "start:publish-reconcile": "node dist/cron/publishReconcile.js", + "dev:publish-reconcile": "tsx watch src/cron/publishReconcile.ts", "clean": "rm -rf dist", "format": "prettier --write .", "lint": "eslint src/**/*.ts", @@ -35,6 +37,7 @@ "@socket.io/redis-adapter": "^8.3.0", "@ucans/ucans": "^0.12.0", "@waku/sdk": "^0.0.34", + "agenda": "^5.0.0", "base64-js": "^1.5.1", "compression": "^1.7.4", "cors": "^2.8.5", diff --git a/src/abi/identity-module-abi.ts b/src/abi/identity-module-abi.ts new file mode 100644 index 0000000..d25ba88 --- /dev/null +++ b/src/abi/identity-module-abi.ts @@ -0,0 +1,23 @@ +// getIdentityModulePublicDetails — copied from ddocs.new/data/identity-module-abi.ts:376-410. +// Returns a struct { salt, signingDid, accountPublicKey, agentAddress }; we read signingDid. +export const IDENTITY_MODULE_ABI = [ + { + inputs: [], + name: "getIdentityModulePublicDetails", + outputs: [ + { + components: [ + { internalType: "uint256", name: "salt", type: "uint256" }, + { internalType: "string", name: "signingDid", type: "string" }, + { internalType: "bytes", name: "accountPublicKey", type: "bytes" }, + { internalType: "address", name: "agentAddress", type: "address" }, + ], + internalType: "struct IIdentityModule.IdentityPublicDetails", + name: "", + type: "tuple", + }, + ], + stateMutability: "view", + type: "function", + }, +] as const; diff --git a/src/config/index.ts b/src/config/index.ts index 641003b..d46a45a 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -33,4 +33,11 @@ export const config: ServerConfig = { rpcURL: process.env.RPC_URL || "https://rpc.ankr.com/eth", wsURL: process.env.WS_URL || "wss://0.0.0.0:5001", nodeEnv: process.env.NODE_ENV || "development", + publishReconcile: { + interval: process.env.PUBLISH_RECONCILE_INTERVAL || "15 minutes", + batchSize: parseInt(process.env.PUBLISH_RECONCILE_BATCH || "500"), + }, + agenda: { + concurrency: parseInt(process.env.AGENDA_DEFAULT_CONCURRENCY || "1"), + }, }; \ No newline at end of file diff --git a/src/cron/agenda.ts b/src/cron/agenda.ts new file mode 100644 index 0000000..24cdf03 --- /dev/null +++ b/src/cron/agenda.ts @@ -0,0 +1,11 @@ +import Agenda from "agenda"; +import { config } from "../config/index.js"; + +export const agenda = new Agenda({ + db: { address: config.database.uri }, + defaultConcurrency: config.agenda.concurrency, + defaultLockLimit: config.agenda.concurrency, +}); + +agenda.on("start", (job) => console.log("Job %s starting", job.attrs.name)); +agenda.on("complete", (job) => console.log(`Job ${job.attrs.name} finished`)); diff --git a/src/cron/jobs/publishReconcileJob.ts b/src/cron/jobs/publishReconcileJob.ts new file mode 100644 index 0000000..5b41b66 --- /dev/null +++ b/src/cron/jobs/publishReconcileJob.ts @@ -0,0 +1,30 @@ +import type { Job } from "agenda"; +import { agenda } from "../agenda.js"; +import { config } from "../../config/index.js"; +import { mongodbStore } from "../../services/mongodb-store.js"; +import { resolvePublishedDocumentIds } from "../../utils/contract.js"; +import { reconcilePublishedDocuments } from "../../services/published-reconciler.js"; + +const jobName = "PUBLISH_RECONCILE"; + +async function jobDefinition(job: Job, done: (err?: Error) => void) { + try { + const { scanned, published } = await reconcilePublishedDocuments({ + mongodbStore, + resolvePublishedDocumentIds, + batchSize: config.publishReconcile.batchSize, + }); + console.log(`[${jobName}] scanned=${scanned} published=${published}`); + done(); + } catch (err: any) { + console.error(`[${jobName}] ${err?.message ?? err}`); + done(err); + } +} + +async function setupJob() { + agenda.define(jobName, jobDefinition); + await agenda.every(config.publishReconcile.interval, jobName); +} + +export default { setupJob }; diff --git a/src/cron/publishReconcile.ts b/src/cron/publishReconcile.ts new file mode 100644 index 0000000..70d910a --- /dev/null +++ b/src/cron/publishReconcile.ts @@ -0,0 +1,26 @@ +import { agenda } from "./agenda.js"; +import { databaseService } from "../database/index.js"; +import publishReconcileJob from "./jobs/publishReconcileJob.js"; + +async function graceful() { + await agenda.stop(); + await databaseService.disconnect(); + process.exit(0); +} + +(async () => { + try { + // The job queries mongoose models, so this worker needs its own DB connection + // (Agenda's Mongo connection only backs the jobs collection). + await databaseService.connect(); + await agenda.start(); + await publishReconcileJob.setupJob(); + console.log("publish-reconcile worker started"); + } catch (err) { + console.error(err); + await graceful(); + } +})(); + +process.on("SIGTERM", graceful); +process.on("SIGINT", graceful); diff --git a/src/database/models/counter.ts b/src/database/models/counter.ts new file mode 100644 index 0000000..959c64d --- /dev/null +++ b/src/database/models/counter.ts @@ -0,0 +1,14 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +interface ICounter extends MongooseDocument { + _id: string; // documentId + seq: number; +} + +const CounterSchema = new Schema({ + _id: { type: String, required: true }, + seq: { type: Number, required: true, default: 0 }, +}); + +export const CounterModel = mongoose.model("Counter", CounterSchema); +export type { ICounter }; diff --git a/src/database/models/document-meta.ts b/src/database/models/document-meta.ts new file mode 100644 index 0000000..a0e33f6 --- /dev/null +++ b/src/database/models/document-meta.ts @@ -0,0 +1,34 @@ +import mongoose, { Schema, Document as MongooseDocument } from "mongoose"; + +interface IDocumentMeta extends MongooseDocument { + _id: string; // documentId + sessionDid: string; + ownerDid: string | null; + ownerIdentityDid: string | null; + portalAddress: string | null; + editLock: string | null; // roomKey-wrapped + title: string | null; // roomKey-encrypted + updatedAt: number; + isPublished: boolean; // set by the publish reconciler once the doc is on-chain +} + +const DocumentMetaSchema = new Schema({ + _id: { type: String, required: true }, + sessionDid: { type: String, required: true }, + ownerDid: { type: String, default: null }, + ownerIdentityDid: { type: String, default: null }, + portalAddress: { type: String, default: null }, + editLock: { type: String, default: null }, + title: { type: String, default: null }, + updatedAt: { type: Number, required: true }, + isPublished: { type: Boolean, default: false }, +}); + +// Discovery: "all docs bound to this identity / portal owner" for list-my-documents. +// Compound with isPublished so both the (unpublished-only) list read and the +// reconciler's candidate scan are index-served. +DocumentMetaSchema.index({ ownerIdentityDid: 1, isPublished: 1 }, { background: true }); +DocumentMetaSchema.index({ ownerDid: 1 }, { background: true }); + +export const DocumentMetaModel = mongoose.model("DocumentMeta", DocumentMetaSchema); +export type { IDocumentMeta }; diff --git a/src/database/models/document-update.ts b/src/database/models/document-update.ts index 7726cce..1751c1d 100644 --- a/src/database/models/document-update.ts +++ b/src/database/models/document-update.ts @@ -10,6 +10,9 @@ interface IDocumentUpdate extends MongooseDocument { createdAt: number; sessionDid: string; appType: "ddoc" | "dsheet"; + seq: number; + publishedMarker: string | null; + floorSeq: number | null; } const DocumentUpdateSchema = new Schema({ @@ -23,8 +26,19 @@ const DocumentUpdateSchema = new Schema({ sessionDid: { type: String, required: true }, // Which Fileverse app produced this update. Absent ⇒ "ddoc" (legacy). appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" }, + seq: { type: Number, required: true }, + publishedMarker: { type: String, default: null }, + // Snapshot rows only: the author's contiguous floor this snapshot is complete up to. + floorSeq: { type: Number, default: null }, }); +// Unique only on seq-bearing rows: legacy rows predate seq, so excluding them lets +// this index build on a populated collection instead of colliding on (documentId, null). +DocumentUpdateSchema.index( + { documentId: 1, seq: 1 }, + { unique: true, background: true, partialFilterExpression: { seq: { $exists: true } } } +); + DocumentUpdateSchema.index({ documentId: 1, committed: 1, createdAt: 1 }, { background: true }); DocumentUpdateSchema.index( diff --git a/src/database/models/index.ts b/src/database/models/index.ts index 25c1cdc..eaf1e52 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -1,3 +1,5 @@ export { DocumentUpdateModel, type IDocumentUpdate } from "./document-update"; export { DocumentCommitModel, type IDocumentCommit } from "./document-commit"; export { SessionModel, type ISession } from "./session"; +export { CounterModel, type ICounter } from "./counter"; +export { DocumentMetaModel, type IDocumentMeta } from "./document-meta"; diff --git a/src/database/models/session.ts b/src/database/models/session.ts index ff24ec9..2948afb 100644 --- a/src/database/models/session.ts +++ b/src/database/models/session.ts @@ -8,6 +8,9 @@ export interface ISession extends MongooseDocument { state: "active" | "inactive" | "terminated"; roomInfo: string; appType: "ddoc" | "dsheet"; + ownerIdentityDid: string | null; + portalAddress: string | null; + collabJoinEnabled?: boolean; } const SessionSchema = new Schema({ @@ -19,6 +22,10 @@ const SessionSchema = new Schema({ roomInfo: { type: String, default: null }, // Which Fileverse app owns this session. Absent ⇒ "ddoc" (legacy). appType: { type: String, enum: ["ddoc", "dsheet"], default: "ddoc" }, + // R3 owner-identity binding: written once via $setOnInsert, never overwritten. + ownerIdentityDid: { type: String, default: null }, + portalAddress: { type: String, default: null }, + collabJoinEnabled: { type: Boolean }, // intentionally no default }); SessionSchema.index({ documentId: 1, createdAt: 1, sessionDid: 1 }); diff --git a/src/index.ts b/src/index.ts index 7e8eea2..a9c9fe1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,6 @@ import express from "express"; import mongoose from "mongoose"; import { Server } from "socket.io"; -import { WebSocketServer } from "ws"; -import { createAdapter } from "@socket.io/redis-adapter"; -import { Redis } from "ioredis"; import cors from "cors"; import helmet from "helmet"; import compression from "compression"; @@ -12,11 +9,17 @@ import { config } from "./config"; import { authService } from "./services/auth"; import { registerEventHandlers } from "./services/socket-handlers"; import { authMiddleware } from "./services/auth-middleware"; -import { LegacyWebSocketHandler } from "./services/legacy-ws-handler"; -import { BroadcastBridge } from "./services/broadcast-bridge"; +import { sessionManager } from "./services/session-manager"; +import { mongodbStore } from "./services/mongodb-store"; +import { createRedisAdapter } from "./redis"; import { databaseService } from "./database"; +import { + createCollabJoinEnabledHandler, + createListMyDocumentsHandler, + createDeleteDocumentHandler, +} from "./services/owner-op-routes"; +import { createFlushHandler } from "./services/flush-route"; import { createLightNode } from "@waku/sdk"; -import { sessionManager } from "./services/session-manager"; import protobuf from "protobufjs"; import { generateKeyPairFromSeed } from "@libp2p/crypto/keys"; import crypto from "crypto"; @@ -28,20 +31,19 @@ import { AppServer, } from "./types/index"; +const ORPHAN_GRACE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const ORPHAN_GC_INTERVAL_MS = 6 * 60 * 60 * 1000; // every 6h + class CollaborationServer { private app: express.Application; private server: any; private io: AppServer | null = null; - private wss: WebSocketServer | null = null; - private legacyHandler: LegacyWebSocketHandler; - private bridge: BroadcastBridge | null = null; private waku: any; + private orphanGcInterval: ReturnType | null = null; constructor() { this.app = express(); - this.legacyHandler = new LegacyWebSocketHandler(); this.setupMiddleware(); - this.setupRoutes(); } private setupMiddleware() { @@ -75,6 +77,8 @@ class CollaborationServer { } private setupRoutes() { + if (!this.io) throw new Error("io must be created before routes are mounted"); + // Health check with MongoDB connectivity this.app.get("/health", async (req, res) => { const mongoOk = mongoose.connection.readyState === 1; @@ -87,6 +91,17 @@ class CollaborationServer { }); }); + this.app.post( + "/documents/:documentId/collab-join-enabled", + createCollabJoinEnabledHandler({ authService, sessionManager }, this.io) + ); + this.app.post("/flush", createFlushHandler({ authService, mongodbStore })); + this.app.post("/list-my-documents", createListMyDocumentsHandler({ authService, mongodbStore })); + this.app.delete( + "/documents/:documentId", + createDeleteDocumentHandler({ authService, sessionManager, mongodbStore }) + ); + // 404 handler this.app.use("*", (req, res) => { res.status(404).json({ @@ -132,48 +147,28 @@ class CollaborationServer { SocketData >(this.server, socketIOOptions); - // Redis adapter (prepared but disabled by default) + // Redis adapter is only needed to scale beyond a single instance. if (config.redis.enabled) { - const pubClient = new Redis(config.redis.url); - const subClient = pubClient.duplicate(); - this.io.adapter(createAdapter(pubClient, subClient)); - console.log("Redis adapter enabled for cross-instance communication"); + this.io.adapter(createRedisAdapter()); } // Socket.IO middlewares gets executed for every incoming connection. this.io.use(authMiddleware); - // Legacy WebSocket server (noServer mode — manual upgrade routing) - this.wss = new WebSocketServer({ noServer: true }); - - // Cross-protocol bridge - this.bridge = new BroadcastBridge(this.io, this.legacyHandler); - this.legacyHandler.setBridge(this.bridge); + registerEventHandlers(this.io); - // Wire up legacy connections - this.wss.on("connection", (ws) => this.legacyHandler.handleConnection(ws)); + this.setupRoutes(); - // Pass bridge to Socket.IO handlers - registerEventHandlers(this.io, this.bridge); - - // Route HTTP upgrade events by path: - // Socket.IO (via Engine.IO) has its own upgrade listener for /socket.io/ paths. - // We handle all other paths (root /) for legacy raw WebSocket clients. - this.server.on("upgrade", (request: any, socket: any, head: any) => { - const pathname = new URL(request.url || "", `http://${request.headers.host}`).pathname; - - if (!pathname.startsWith("/socket.io")) { - this.wss!.handleUpgrade(request, socket, head, (ws) => { - this.wss!.emit("connection", ws, request); - }); - } - }); + // Orphan GC sweep + this.orphanGcInterval = setInterval( + () => mongodbStore.collectOrphans(ORPHAN_GRACE_MS).catch((e) => console.error("orphan-GC error:", e)), + ORPHAN_GC_INTERVAL_MS + ); // Start the server this.server.listen(config.port, config.host, () => { console.log(`Collaboration server running on ${config.host}:${config.port}`); console.log(`Socket.IO endpoint: http://${config.host}:${config.port}/socket.io/`); - console.log(`Legacy WS endpoint: ws://${config.host}:${config.port}/`); console.log(`Server DID: ${authService.getServerDid()}`); console.log(`CORS origins: ${config.corsOrigins.join(", ")}`); }); @@ -190,12 +185,8 @@ class CollaborationServer { private shutdown(signal: string) { console.log(`\n Received ${signal}. Shutting down gracefully...`); - // Close legacy WebSocket connections - this.legacyHandler.closeAll(); - if (this.wss) { - this.wss.close(() => { - console.log("Legacy WebSocket server closed"); - }); + if (this.orphanGcInterval) { + clearInterval(this.orphanGcInterval); } if (this.io) { @@ -305,7 +296,7 @@ class CollaborationServer { } } -// Start the server +// Start the collaboration server const server = new CollaborationServer(); server .start() diff --git a/src/redis.ts b/src/redis.ts new file mode 100644 index 0000000..712f411 --- /dev/null +++ b/src/redis.ts @@ -0,0 +1,10 @@ +import { Redis } from "ioredis"; +import { createAdapter } from "@socket.io/redis-adapter"; +import { config } from "./config"; + +export function createRedisAdapter() { + if (!config.redis.url) throw new Error("Redis URL required to enable the multi-instance adapter (set REDISCLOUD_URL)"); + const pubClient = new Redis(config.redis.url); + const subClient = pubClient.duplicate(); + return createAdapter(pubClient, subClient); +} diff --git a/src/services/auth.ts b/src/services/auth.ts index 09606c2..4313f31 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,5 +1,5 @@ import * as ucans from "@ucans/ucans"; -import { getOwnerDid } from "../utils/contract"; +import { getIdentitySigningDid, getOwnerDid } from "../utils/contract"; import { Hex } from "viem"; import NodeCache from "node-cache"; @@ -44,7 +44,7 @@ export class AuthService { } async verifyCollaborationToken(token: string, sessionDid: string, documentId: string) { - const cacheKey = token; + const cacheKey = `${token}:${sessionDid}:${documentId}`; const cachedResult = this.collaborationTokenCache.get(cacheKey); if (cachedResult !== undefined) { return cachedResult; @@ -74,6 +74,67 @@ export class AuthService { return false; } } + + async verifyIdentityToken( + token: string, + identityContractAddress: Hex, + ddocId: string + ): Promise { + try { + const signingDid = await getIdentitySigningDid(identityContractAddress); + if (!signingDid) return null; + + const result = await ucans.verify(token, { + audience: this.serverDid, + requiredCapabilities: [ + { + capability: { + with: { scheme: "storage", hierPart: ddocId }, + can: { namespace: "collaboration", segments: ["OWN"] }, + }, + rootIssuer: signingDid, + }, + ], + }); + return result.ok ? signingDid : null; + } catch (error) { + console.error("Identity token verification error:", error); + return null; + } + } + + async verifyOwnerOp(params: { + ddocId: string; + boundOwnerIdentityDid: string | null; + boundOwnerDid: string | null; + identityToken?: string; + identityContractAddress?: Hex; + ownerToken?: string; + ownerAddress?: Hex; + portalAddress?: Hex; + }): Promise { + // Path 1 — bound identity (creator). The `=== boundOwnerIdentityDid` compare is MANDATORY. + if (params.identityToken && params.identityContractAddress && params.boundOwnerIdentityDid) { + const signingDid = await this.verifyIdentityToken( + params.identityToken, + params.identityContractAddress, + params.ddocId + ); + if (signingDid && signingDid === params.boundOwnerIdentityDid) return true; + } + + // Path 2 — portal owner-of-record (team creator-left fallback): ⚠ DEFERRED in v1, NOT authorized here. + // The only portal proof available today is verifyOwnerToken → getOwnerDid → `collaboratorKeys`, which + // for a TEAM/workspace portal returns the SHARED `workspaceCollabDid` — and EVERY member holds its + // secret (`workspaceCollabSecret`, distributed in the invite payload). So any member could satisfy + // `== boundOwnerDid`, reopening H2/B5 for teams (code-verified 2026-07-08: + // mint-team-portal.ts:219-246, buildJoinerSlice.ts:149-156, keystore/helpers.ts:137-138). A correct + // portal-owner proof must pin `portal.owner()` (the ASA Safe, ERC-1271-capable) via an owner-signed + // message — net-new client + server work. Until then owner-ops authorize on the (per-creator-distinct, + // unforgeable) IDENTITY proof ONLY; a team doc whose creator has left cannot be owner-op'd in v1. + + return false; + } } export const authService = new AuthService( diff --git a/src/services/broadcast-bridge.ts b/src/services/broadcast-bridge.ts deleted file mode 100644 index 6d1e5c2..0000000 --- a/src/services/broadcast-bridge.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { WebSocket } from "ws"; -import { - AppServer, - WebSocketEvent, - ContentUpdatePayload, - AwarenessUpdatePayload, - MembershipChangePayload, - SessionTerminatedPayload, - ServerErrorPayload, - ErrorCode, -} from "../types/index"; -import type { LegacyWebSocketHandler } from "./legacy-ws-handler"; - -function getRoomName(documentId: string, sessionDid: string): string { - return `session::${documentId}__${sessionDid}`; -} - -/** - * Coordinates cross-protocol broadcasting between Socket.IO and legacy WebSocket clients. - * - * - broadcastFromSocketIO: called after Socket.IO emits to its room; sends to legacy WS clients only - * - broadcastFromLegacy: called from legacy handler; sends to both other legacy clients AND Socket.IO room - * - getCombinedPeers: returns peer list from both protocols - * - terminateRoom: force-disconnects clients of both protocols - */ -export class BroadcastBridge { - constructor( - private io: AppServer, - private legacyHandler: LegacyWebSocketHandler - ) {} - - /** - * Called after Socket.IO has already emitted to its room. - * Translates the Socket.IO event to legacy WS format and sends to legacy clients only. - */ - broadcastFromSocketIO( - documentId: string, - sessionDid: string, - event: string, - payload: any, - excludeSocketId?: string - ): void { - const legacyEvent = this.socketIOToLegacy(event, payload, documentId); - if (!legacyEvent) return; - - // Send to legacy clients only (Socket.IO already handled its own) - this.legacyHandler.broadcastToLegacyClients( - documentId, - sessionDid, - legacyEvent - ); - } - - /** - * Called from legacy handler after processing a command. - * Sends to other legacy clients AND emits to the Socket.IO room. - */ - broadcastFromLegacy( - documentId: string, - sessionDid: string, - legacyEvent: WebSocketEvent, - excludeClientId?: string - ): void { - // Send to other legacy clients - this.legacyHandler.broadcastToLegacyClients( - documentId, - sessionDid, - legacyEvent, - excludeClientId - ); - - // Translate and emit to Socket.IO room - const roomName = getRoomName(documentId, sessionDid); - this.emitToSocketIORoom(roomName, legacyEvent, documentId); - } - - /** - * Returns combined peer list from both protocols. - */ - async getCombinedPeers(documentId: string, sessionDid: string): Promise { - const roomName = getRoomName(documentId, sessionDid); - - // Socket.IO peers - const sockets = await this.io.in(roomName).fetchSockets(); - const socketIOPeers = sockets.map((s) => s.id); - - // Legacy WS peers - const legacyPeers = this.legacyHandler.getLegacyPeers(documentId, sessionDid); - - return [...socketIOPeers, ...legacyPeers]; - } - - /** - * Force-disconnects clients of both protocols in a room. - */ - async terminateRoom( - documentId: string, - sessionDid: string, - excludeClientId?: string - ): Promise { - // Disconnect legacy clients - this.legacyHandler.disconnectClientsInRoom(documentId, sessionDid, excludeClientId); - - // Disconnect Socket.IO clients - const roomName = getRoomName(documentId, sessionDid); - const sockets = await this.io.in(roomName).fetchSockets(); - for (const s of sockets) { - if (excludeClientId && s.id === excludeClientId) continue; - s.data.authenticated = false; - s.leave(roomName); - } - } - - /** - * Disconnects only legacy WS clients in a room (used when Socket.IO side handles its own cleanup). - */ - disconnectLegacyClientsInRoom( - documentId: string, - sessionDid: string, - excludeClientId?: string - ): void { - this.legacyHandler.disconnectClientsInRoom(documentId, sessionDid, excludeClientId); - } - - /** - * Emits a /server/error event to the Socket.IO room so clients know why their session ended. - * Legacy WS clients don't use this event — it's Socket.IO-only. - */ - emitServerError( - documentId: string, - sessionDid: string, - message: string - ): void { - const roomName = getRoomName(documentId, sessionDid); - this.io.to(roomName).emit("/server/error", { - errorCode: ErrorCode.SESSION_TERMINATED, - message, - roomId: documentId, - } as ServerErrorPayload); - } - - /** - * Translates a Socket.IO event to legacy WebSocketEvent format. - */ - private socketIOToLegacy( - event: string, - payload: any, - documentId: string - ): WebSocketEvent | null { - switch (event) { - case "/document/content_update": - return { - type: "CONTENT_UPDATE", - event_type: "CONTENT_UPDATE", - event: { - data: { - id: payload.id, - data: payload.data, - createdAt: payload.createdAt, - }, - roomId: documentId, - }, - }; - - case "/document/awareness_update": - return { - type: "AWARENESS_UPDATE", - event_type: "AWARENESS_UPDATE", - event: { - data: payload.data, - roomId: documentId, - }, - }; - - case "/room/membership_change": - return { - type: "ROOM_UPDATE", - event_type: "ROOM_MEMBERSHIP_CHANGE", - event: { - data: { - action: payload.action, - user: payload.user, - }, - roomId: documentId, - }, - }; - - case "/session/terminated": - return { - type: "SESSION_TERMINATED", - event_type: "SESSION_TERMINATED", - event: { - data: null, - roomId: documentId, - }, - }; - - default: - return null; - } - } - - /** - * Translates a legacy WebSocketEvent and emits to the Socket.IO room. - */ - private emitToSocketIORoom( - roomName: string, - legacyEvent: WebSocketEvent, - documentId: string - ): void { - switch (legacyEvent.event_type) { - case "CONTENT_UPDATE": - this.io.to(roomName).emit("/document/content_update", { - id: legacyEvent.event.data?.id, - data: legacyEvent.event.data?.data, - createdAt: legacyEvent.event.data?.createdAt, - roomId: documentId, - } as ContentUpdatePayload); - break; - - case "AWARENESS_UPDATE": - this.io.to(roomName).emit("/document/awareness_update", { - data: legacyEvent.event.data, - roomId: documentId, - } as AwarenessUpdatePayload); - break; - - case "ROOM_MEMBERSHIP_CHANGE": - this.io.to(roomName).emit("/room/membership_change", { - action: legacyEvent.event.data?.action, - user: legacyEvent.event.data?.user, - roomId: documentId, - } as MembershipChangePayload); - break; - - case "SESSION_TERMINATED": - this.io.to(roomName).emit("/session/terminated", { - roomId: documentId, - } as SessionTerminatedPayload); - break; - } - } -} diff --git a/src/services/flush-route.ts b/src/services/flush-route.ts new file mode 100644 index 0000000..2b02e51 --- /dev/null +++ b/src/services/flush-route.ts @@ -0,0 +1,47 @@ +import type { Request, Response } from "express"; +import { v4 as uuidv4 } from "uuid"; +import type { AuthService } from "./auth"; +import type { MongoDBStore } from "./mongodb-store"; + +export const FLUSH_MAX_BYTES = 2 * 1024 * 1024; // a final delta is small; cap well under the 10MB body limit + +export interface FlushDeps { + authService: Pick; + mongodbStore: Pick; +} + +export function createFlushHandler(deps: FlushDeps) { + return async (req: Request, res: Response): Promise => { + const { documentId, sessionDid, collaborationToken, data } = req.body || {}; + if (!documentId || !sessionDid || !collaborationToken || !data) { + res.status(400).json({ error: "documentId, sessionDid, collaborationToken and data are required" }); + return; + } + if (typeof data !== "string" || data.length > FLUSH_MAX_BYTES) { + res.status(413).json({ error: "Flush payload too large" }); + return; + } + + const verified = await deps.authService.verifyCollaborationToken(collaborationToken, sessionDid, documentId); + if (!verified) { + res.status(401).json({ error: "Authentication failed" }); + return; + } + + // Same chokepoint as the socket path: createUpdate applies seq + the durable-write gate; a row is + // persisted only for a bound, non-terminated room. Zero-knowledge — the server never reads `data`. + await deps.mongodbStore.createUpdate({ + id: uuidv4(), + documentId, + data, + updateType: "yjs_update", + committed: false, + commitCid: null, + createdAt: Date.now(), + sessionDid, + appType: "ddoc", + }); + + res.status(200).json({ ok: true }); + }; +} diff --git a/src/services/legacy-ws-handler.ts b/src/services/legacy-ws-handler.ts deleted file mode 100644 index c5ff707..0000000 --- a/src/services/legacy-ws-handler.ts +++ /dev/null @@ -1,757 +0,0 @@ -import { WebSocket } from "ws"; -import { v4 as uuidv4 } from "uuid"; -import { - AuthenticatedWebSocket, - WebSocketMessage, - WebSocketResponse, - WebSocketEvent, -} from "../types/index"; -import { authService } from "./auth"; -import { mongodbStore } from "./mongodb-store"; -import { sessionManager } from "./session-manager"; -import type { BroadcastBridge } from "./broadcast-bridge"; - -/** - * Handles legacy raw WebSocket connections from old clients (pre-Socket.IO). - * Ported from prod's WebSocketManager, adapted to use the BroadcastBridge - * for cross-protocol communication instead of Redis pub/sub. - */ -export class LegacyWebSocketHandler { - private connections = new Map(); - private bridge: BroadcastBridge | null = null; - - setBridge(bridge: BroadcastBridge): void { - this.bridge = bridge; - } - - handleConnection(ws: WebSocket): void { - const clientId = uuidv4(); - const authWs = ws as AuthenticatedWebSocket; - authWs.clientId = clientId; - authWs.authenticated = false; - - this.connections.set(clientId, authWs); - console.log(`[Legacy WS] New connection: ${clientId}`); - - // Send handshake with server DID - this.sendMessage(authWs, { - status: true, - statusCode: 200, - seqId: null, - is_handshake_response: true, - data: { - server_did: authService.getServerDid(), - message: "Connected to collaboration server", - }, - }); - - authWs.on("message", async (data: Buffer) => { - try { - const message: WebSocketMessage = JSON.parse(data.toString()); - await this.handleMessage(authWs, message); - } catch (error) { - console.error(`[Legacy WS] Error handling message from ${clientId}:`, error); - this.sendError(authWs, null, "Invalid message format", 400); - } - }); - - authWs.on("close", () => { - this.handleDisconnection(clientId).catch((error) => { - console.error(`[Legacy WS] Error during disconnection cleanup for ${clientId}:`, error); - }); - }); - - authWs.on("error", (error) => { - console.error(`[Legacy WS] WebSocket error for ${clientId}:`, error); - this.handleDisconnection(clientId).catch((err) => { - console.error(`[Legacy WS] Error during disconnection cleanup for ${clientId}:`, err); - }); - }); - } - - /** - * Called by bridge to dispatch events to legacy clients in a room. - */ - broadcastToLegacyClients( - documentId: string, - sessionDid: string, - event: WebSocketEvent, - excludeClientId?: string - ): void { - const clients = sessionManager.getLocalClients(documentId, sessionDid); - if (!clients) return; - - const message = JSON.stringify(event); - - clients.forEach((clientId: string) => { - if (clientId === excludeClientId) return; - - const ws = this.connections.get(clientId); - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(message); - } - }); - } - - /** - * Returns list of legacy client IDs in a room. - */ - getLegacyPeers(documentId: string, sessionDid: string): string[] { - const clients = sessionManager.getLocalClients(documentId, sessionDid); - if (!clients) return []; - - const peers: string[] = []; - clients.forEach((clientId: string) => { - if (this.connections.has(clientId)) { - peers.push(clientId); - } - }); - return peers; - } - - /** - * Force-disconnects all legacy clients in a room. - */ - disconnectClientsInRoom( - documentId: string, - sessionDid: string, - excludeClientId?: string - ): void { - const clients = sessionManager.getLocalClients(documentId, sessionDid); - if (!clients) return; - - // Only close connections — callers are responsible for broadcasting - // SESSION_TERMINATED before calling this method. - clients.forEach((clientId: string) => { - if (clientId === excludeClientId) return; - - const ws = this.connections.get(clientId); - if (ws) { - ws.close(); - } - }); - } - - /** - * Closes all legacy WebSocket connections. - */ - closeAll(): void { - for (const [, ws] of this.connections) { - ws.close(); - } - this.connections.clear(); - } - - // ─── Internal message handling ─────────────────────────── - - private async handleMessage(ws: AuthenticatedWebSocket, message: WebSocketMessage): Promise { - const { cmd, args, seqId } = message; - - try { - switch (cmd) { - case "/auth": - await this.handleAuth(ws, args, seqId); - break; - case "/documents/update": - await this.handleDocumentUpdate(ws, args, seqId); - break; - case "/documents/commit": - await this.handleDocumentCommit(ws, args, seqId); - break; - case "/documents/commit/history": - await this.handleCommitHistory(ws, args, seqId); - break; - case "/documents/update/history": - await this.handleUpdateHistory(ws, args, seqId); - break; - case "/documents/peers/list": - await this.handlePeersList(ws, args, seqId); - break; - case "/documents/awareness": - await this.handleAwareness(ws, args, seqId); - break; - case "/documents/terminate": - await this.handleTerminateSession(ws, args, seqId); - break; - default: - this.sendError(ws, seqId, `Unknown command: ${cmd}`, 404); - } - } catch (error) { - console.error(`[Legacy WS] Error handling command ${cmd}:`, error); - this.sendError(ws, seqId, "Internal server error", 500); - } - } - - private async handleAuth(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - const { collaborationToken, documentId, sessionDid } = args; - - if (!collaborationToken) { - this.sendError(ws, seqId, "Username and token are required", 400); - return; - } - - if (!documentId) { - this.sendError(ws, seqId, "Document ID is required", 400); - return; - } - - if (!sessionDid) { - this.sendError(ws, seqId, "Session DID is required", 400); - return; - } - - ws.documentId = documentId; - - let sessionSetupResponse = { - isVerified: false, - message: "", - statusCode: 0, - }; - - const existingSession = await sessionManager.getSession(documentId, sessionDid); - - if (!existingSession && args.ownerToken) { - sessionSetupResponse = await this.setupSession(ws, args); - } else { - sessionSetupResponse = await this.handleJoinSession(ws, args, existingSession); - } - - if (!sessionSetupResponse.isVerified) { - this.sendError(ws, seqId, sessionSetupResponse.message, sessionSetupResponse.statusCode); - return; - } - - // Notify other users about membership change via bridge - if (this.bridge) { - this.bridge.broadcastFromLegacy( - documentId, - ws.sessionDid!, - { - type: "ROOM_UPDATE", - event_type: "ROOM_MEMBERSHIP_CHANGE", - event: { - data: { - action: "user_joined", - user: { role: ws.role }, - }, - roomId: documentId, - }, - }, - ws.clientId - ); - } - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: true, - data: { - message: "Authentication successful", - role: ws.role, - sessionType: existingSession ? "existing" : "new", - roomInfo: existingSession?.roomInfo, - }, - }); - } - - private async setupSession(ws: AuthenticatedWebSocket, args: any) { - const { documentId, ownerToken, ownerAddress, contractAddress, sessionDid } = args; - - if (!documentId || !ownerToken || !sessionDid) { - return { - isVerified: false, - message: "Document ID, owner token, and session DID are required", - statusCode: 400, - }; - } - - const ownerDid = await authService.verifyOwnerToken(ownerToken, contractAddress, ownerAddress); - - if (!ownerDid) { - return { - isVerified: false, - message: "Authentication failed", - statusCode: 401, - }; - } - - ws.authenticated = true; - ws.role = "owner"; - ws.sessionDid = sessionDid; - - // Terminate other sessions (with bridge notification) - const otherSessions = await sessionManager.getOtherActiveSessions( - documentId, - ownerDid, - sessionDid - ); - for (const oldSession of otherSessions) { - if (this.bridge) { - // Notify Socket.IO clients with error reason before termination - this.bridge.emitServerError( - oldSession.documentId, - oldSession.sessionDid, - "Session terminated by owner creating a new session" - ); - // Notify both protocols about termination - this.bridge.broadcastFromLegacy( - oldSession.documentId, - oldSession.sessionDid, - { - type: "SESSION_TERMINATED", - event_type: "SESSION_TERMINATED", - event: { data: null, roomId: oldSession.documentId }, - } - ); - await this.bridge.terminateRoom(oldSession.documentId, oldSession.sessionDid); - } - await sessionManager.terminateSession(oldSession.documentId, oldSession.sessionDid); - console.log( - `[Legacy WS] Terminated old session: ${oldSession.sessionDid} for document: ${documentId}` - ); - } - - await sessionManager.createSession({ - documentId, - sessionDid, - ownerDid, - roomInfo: args.roomInfo, - }); - - await sessionManager.addClientToSession(documentId, sessionDid, ws.clientId!); - console.log("[Legacy WS] SETUP DONE", documentId); - return { - isVerified: true, - message: "Session setup successful", - statusCode: 200, - }; - } - - private async handleJoinSession(ws: AuthenticatedWebSocket, args: any, session: any) { - if (!session) { - return { - isVerified: false, - message: "Session not found", - statusCode: 404, - }; - } - - const { - documentId, - collaborationToken, - ownerToken, - ownerAddress, - contractAddress, - sessionDid, - } = args; - - if (!documentId || !collaborationToken || !sessionDid) { - return { - isVerified: false, - message: "Document ID, collaboration token, and session DID are required", - statusCode: 400, - }; - } - - const userDid = await authService.verifyCollaborationToken( - collaborationToken, - session.sessionDid, - documentId - ); - - if (!userDid) { - return { - isVerified: false, - message: "Authentication failed", - statusCode: 401, - }; - } - - let ownerDid = null; - if (ownerToken && ownerAddress && contractAddress) { - ownerDid = await authService.verifyOwnerToken(ownerToken, contractAddress, ownerAddress); - } - - ws.authenticated = true; - ws.role = ownerDid === session.ownerDid ? "owner" : "editor"; - ws.documentId = documentId; - ws.sessionDid = session.sessionDid; - - if (ws.role === "owner" && args.roomInfo) { - await sessionManager.updateRoomInfo( - documentId, - session.sessionDid, - session.ownerDid, - args.roomInfo - ); - } - - await sessionManager.addClientToSession(documentId, session.sessionDid, ws.clientId!); - - console.log("[Legacy WS] JOINED SESSION", documentId, ws.role); - return { - isVerified: true, - message: "Session joined successfully", - statusCode: 200, - }; - } - - private async handleDocumentUpdate(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - if (!ws.authenticated || !ws.documentId || !ws.sessionDid) { - this.sendError(ws, seqId, "Not authenticated or session not found", 401); - return; - } - - const { data, collaborationToken } = args; - const documentId = args.documentId || ws.documentId; - - if (!data) { - this.sendError(ws, seqId, "Update data is required", 400); - return; - } - - const session = await sessionManager.getRuntimeSession(documentId, ws.sessionDid); - const sessionDid = session?.sessionDid; - - if (!sessionDid) { - this.sendError(ws, seqId, "Session not found", 404); - return; - } - - const isVerified = await authService.verifyCollaborationToken( - collaborationToken, - sessionDid, - documentId - ); - - if (!isVerified) { - this.sendError(ws, seqId, "Authentication failed", 401); - return; - } - - // Create update record - const update = await mongodbStore.createUpdate({ - id: uuidv4(), - documentId, - data, - updateType: "yjs_update", - committed: false, - commitCid: null, - createdAt: Date.now(), - sessionDid, - }); - - // Broadcast update to other clients via bridge (both protocols) - if (this.bridge) { - this.bridge.broadcastFromLegacy( - documentId, - ws.sessionDid!, - { - type: "CONTENT_UPDATE", - event_type: "CONTENT_UPDATE", - event: { - data: { - id: update.id, - data: update.data, - createdAt: update.createdAt, - }, - roomId: documentId, - }, - }, - ws.clientId - ); - } - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { - commitCid: update.commitCid, - createdAt: update.createdAt, - data: update.data, - documentId: update.documentId, - id: update.id, - updateType: update.updateType, - }, - }); - } - - private async handleDocumentCommit(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - if (!ws.authenticated || !ws.documentId || !ws.sessionDid) { - this.sendError(ws, seqId, "Not authenticated or session not found", 401); - return; - } - - if (ws.role !== "owner") { - this.sendError(ws, seqId, "Only owners can create commits", 403); - return; - } - - const { updates, cid, ownerToken } = args; - const documentId = args.documentId || ws.documentId; - - const session = await sessionManager.getRuntimeSession(documentId, ws.sessionDid); - const sessionDid = session?.sessionDid; - - if (!sessionDid) { - this.sendError(ws, seqId, "Session not found", 404); - return; - } - - if (!updates || !Array.isArray(updates) || !cid) { - this.sendError(ws, seqId, "Updates array and CID are required", 400); - return; - } - - const isVerified = await authService.verifyOwnerToken( - ownerToken, - args.contractAddress, - args.ownerAddress - ); - - if (!isVerified) { - this.sendError(ws, seqId, "Authentication failed", 401); - return; - } - - // Create commit record - const commit = await mongodbStore.createCommit({ - id: uuidv4(), - documentId, - cid, - updates, - createdAt: Date.now(), - sessionDid, - }); - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { - cid: commit.cid, - createdAt: commit.createdAt, - documentId: commit.documentId, - updates: commit.updates, - }, - }); - } - - private async handleCommitHistory(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - if (!ws.authenticated || !ws.documentId || !ws.sessionDid) { - this.sendError(ws, seqId, "Not authenticated", 401); - return; - } - - const documentId = args.documentId || ws.documentId; - const { offset = 0, limit = 10, sort = "desc" } = args; - - const commits = await mongodbStore.getCommitsByDocument( - { documentId, sessionDid: ws.sessionDid }, - { offset, limit, sort } - ); - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { - history: commits, - total: commits.length, - }, - }); - } - - private async handleUpdateHistory(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - if (!ws.authenticated || !ws.documentId || !ws.sessionDid) { - this.sendError(ws, seqId, "Not authenticated", 401); - return; - } - - const documentId = args.documentId || ws.documentId; - const { offset = 0, limit = 100, sort = "desc", filters = {} } = args; - - const updates = await mongodbStore.getUpdatesByDocument( - { documentId, sessionDid: ws.sessionDid }, - { offset, limit, sort, committed: filters.committed } - ); - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { - history: updates, - total: updates.length, - }, - }); - } - - private async handlePeersList(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - if (!ws.authenticated || !ws.documentId || !ws.sessionDid) { - this.sendError(ws, seqId, "Not authenticated or session not found", 401); - return; - } - - const documentId = args.documentId || ws.documentId; - - // Use bridge to get combined peers from both protocols - let peers: string[] = []; - if (this.bridge) { - peers = await this.bridge.getCombinedPeers(documentId, ws.sessionDid); - } else { - const session = await sessionManager.getSession(documentId, ws.sessionDid); - peers = Array.from(session?.clients || []); - } - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { peers }, - }); - } - - private async handleAwareness(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - if (!ws.authenticated || !ws.documentId || !ws.sessionDid) { - this.sendError(ws, seqId, "Not authenticated or session not found", 401); - return; - } - - const documentId = args.documentId || ws.documentId; - const { data } = args; - - // Broadcast awareness update via bridge (both protocols) - if (this.bridge) { - this.bridge.broadcastFromLegacy( - documentId, - ws.sessionDid!, - { - type: "AWARENESS_UPDATE", - event_type: "AWARENESS_UPDATE", - event: { - data, - roomId: documentId, - }, - }, - ws.clientId - ); - } - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { message: "Awareness update broadcasted" }, - }); - } - - private async handleTerminateSession(ws: AuthenticatedWebSocket, args: any, seqId: string): Promise { - const { documentId, ownerToken, ownerAddress, contractAddress, sessionDid } = args; - console.log("[Legacy WS] TERMINATING SESSION", documentId); - - if (!sessionDid) { - this.sendError(ws, seqId, "Session DID is required", 400); - return; - } - - const session = await sessionManager.getSession(documentId, sessionDid); - if (!session) { - this.sendError(ws, seqId, "Session not found", 404); - return; - } - - const ownerDid = await authService.verifyOwnerToken(ownerToken, contractAddress, ownerAddress); - - if (ownerDid !== session.ownerDid) { - this.sendError(ws, seqId, "Unauthorized", 401); - return; - } - - // Broadcast termination to both protocols via bridge - if (this.bridge) { - this.bridge.broadcastFromLegacy( - documentId, - session.sessionDid, - { - type: "SESSION_TERMINATED", - event_type: "SESSION_TERMINATED", - event: { data: null, roomId: documentId }, - }, - ws.clientId - ); - // Also disconnect all clients in both protocols - await this.bridge.terminateRoom(documentId, session.sessionDid, ws.clientId); - } - - await sessionManager.terminateSession(documentId, session.sessionDid); - - this.sendMessage(ws, { - status: true, - statusCode: 200, - seqId, - is_handshake_response: false, - data: { message: "Session terminated" }, - }); - } - - private async handleDisconnection(clientId: string): Promise { - const ws = this.connections.get(clientId); - if (ws && ws.authenticated && ws.documentId && ws.sessionDid) { - // Notify other users about membership change via bridge - if (this.bridge) { - this.bridge.broadcastFromLegacy( - ws.documentId, - ws.sessionDid, - { - type: "ROOM_UPDATE", - event_type: "ROOM_MEMBERSHIP_CHANGE", - event: { - data: { - action: "user_left", - user: { role: ws.role }, - }, - roomId: ws.documentId, - }, - }, - clientId - ); - } - - // Remove from session and handle session cleanup - await sessionManager.removeClientFromSession(ws.documentId, ws.sessionDid, clientId); - } - - this.connections.delete(clientId); - console.log(`[Legacy WS] Disconnected: ${clientId}`); - } - - private sendMessage(ws: AuthenticatedWebSocket, response: WebSocketResponse): void { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(response)); - } - } - - private sendError( - ws: AuthenticatedWebSocket, - seqId: string | null, - error: string, - statusCode: number - ): void { - this.sendMessage(ws, { - status: false, - statusCode, - seqId, - is_handshake_response: false, - err: error, - }); - } -} diff --git a/src/services/mongodb-store.ts b/src/services/mongodb-store.ts index b5ea54e..cff692e 100644 --- a/src/services/mongodb-store.ts +++ b/src/services/mongodb-store.ts @@ -1,14 +1,29 @@ import { DocumentUpdate, DocumentCommit } from "../types/index"; -import { DocumentUpdateModel, DocumentCommitModel } from "../database/models"; +import { DocumentUpdateModel, DocumentCommitModel, CounterModel, SessionModel, DocumentMetaModel } from "../database/models"; export class MongoDBStore { // Update management async createUpdate(update: DocumentUpdate): Promise { try { + const session: any = await SessionModel.findOne({ + documentId: update.documentId, + sessionDid: update.sessionDid, + }); + if (!session || session.state === "terminated") { + return update; // ephemeral relay — no durable row, no seq burned + } + + const counter = await CounterModel.findOneAndUpdate( + { _id: update.documentId }, + { $inc: { seq: 1 } }, + { new: true, upsert: true } + ); + const seq = counter!.seq; + const mongoUpdate = new DocumentUpdateModel({ _id: update.id, documentId: update.documentId, - + seq, data: update.data, updateType: update.updateType, committed: update.committed, @@ -19,7 +34,7 @@ export class MongoDBStore { }); await mongoUpdate.save(); - return update; + return { ...update, seq }; } catch (error) { console.error("Error creating update:", error); throw error; @@ -40,6 +55,7 @@ export class MongoDBStore { commitCid: update.commitCid, createdAt: update.createdAt, sessionDid: update.sessionDid, + seq: update.seq, }; } catch (error) { console.error("Error getting update:", error); @@ -87,6 +103,7 @@ export class MongoDBStore { commitCid: update.commitCid, createdAt: update.createdAt, sessionDid: update.sessionDid, + seq: update.seq, })); } catch (error) { console.error("Error getting updates by document:", error); @@ -94,10 +111,10 @@ export class MongoDBStore { } } - async markUpdatesAsCommitted(updateIds: string[], commitId: string) { + async markUpdatesAsCommitted(updateIds: string[], commitId: string, documentId: string) { try { await DocumentUpdateModel.updateMany( - { _id: { $in: updateIds } }, + { _id: { $in: updateIds }, documentId }, { committed: true, commitCid: commitId, @@ -109,6 +126,151 @@ export class MongoDBStore { } } + async createSnapshot(snapshot: DocumentUpdate): Promise { + const counter = await CounterModel.findOneAndUpdate( + { _id: snapshot.documentId }, + { $inc: { seq: 1 } }, + { new: true, upsert: true } + ); + const seq = counter!.seq; + + const doc = new DocumentUpdateModel({ + _id: snapshot.id, + documentId: snapshot.documentId, + seq, + data: snapshot.data, + updateType: "snapshot", + publishedMarker: snapshot.publishedMarker ?? null, + floorSeq: snapshot.floorSeq ?? null, + committed: false, + commitCid: null, + createdAt: snapshot.createdAt, + sessionDid: snapshot.sessionDid, + appType: snapshot.appType, + }); + await doc.save({ w: "majority", j: true }); + + // Keep-latest: a superseded snapshot is unconditionally safe to drop. + await DocumentUpdateModel.deleteMany({ + documentId: snapshot.documentId, + sessionDid: snapshot.sessionDid, + updateType: "snapshot", + seq: { $lt: seq }, + }); + + return { ...snapshot, seq, updateType: "snapshot" }; + } + + async getHydrationRange( + documentId: string, + sessionDid: string, + options: { sinceSeq?: number; maxBytes?: number } = {} + ): Promise<{ snapshot: DocumentUpdate | null; updates: DocumentUpdate[]; nextSeq: number | null; hasMore: boolean }> { + const maxBytes = options.maxBytes ?? 9 * 1024 * 1024; // headroom under the 10MB socket buffer + + const snapshotDoc: any = await DocumentUpdateModel.findOne({ + documentId, sessionDid, updateType: "snapshot", + }).sort({ seq: -1 }).lean(); + + // Cut the tail at the snapshot's authorship FLOOR, not its own seq: the snapshot is + // only provably complete up to floorSeq, so seq > floorSeq re-serves any update the + // author never applied (persist-before-fanout race) instead of orphaning it below the + // snapshot's seq. Legacy floor-less snapshots fall back to seq (pre-fix behavior). + const baseSeq = snapshotDoc ? (snapshotDoc.floorSeq ?? snapshotDoc.seq) : 0; + const includeSnapshot = !(options.sinceSeq !== undefined && options.sinceSeq >= baseSeq); + const fromSeq = includeSnapshot ? baseSeq : options.sinceSeq!; + + const rows: any[] = await DocumentUpdateModel.find({ + documentId, sessionDid, updateType: { $ne: "snapshot" }, seq: { $gt: fromSeq }, + }).sort({ seq: 1 }).lean(); + + const updates: DocumentUpdate[] = []; + let bytes = includeSnapshot && snapshotDoc ? (snapshotDoc.data?.length ?? 0) : 0; + let hasMore = false; + let nextSeq: number | null = null; + for (const r of rows) { + bytes += r.data?.length ?? 0; + if (updates.length > 0 && bytes > maxBytes) { hasMore = true; nextSeq = updates[updates.length - 1].seq!; break; } + updates.push({ id: r._id, documentId: r.documentId, seq: r.seq, data: r.data, updateType: r.updateType, committed: r.committed, commitCid: r.commitCid, createdAt: r.createdAt, sessionDid: r.sessionDid, publishedMarker: r.publishedMarker }); + } + + const snapshot: DocumentUpdate | null = includeSnapshot && snapshotDoc + ? { id: snapshotDoc._id, documentId: snapshotDoc.documentId, seq: snapshotDoc.seq, data: snapshotDoc.data, updateType: "snapshot", committed: false, commitCid: null, createdAt: snapshotDoc.createdAt, sessionDid: snapshotDoc.sessionDid, publishedMarker: snapshotDoc.publishedMarker, floorSeq: snapshotDoc.floorSeq ?? null } + : null; + + return { snapshot, updates, nextSeq, hasMore }; + } + + // Document metadata (pre-publish recovery: owner-authored editLock + title) + async upsertDocumentMeta(meta: { + documentId: string; + sessionDid: string; + ownerDid: string | null; + ownerIdentityDid: string | null; + portalAddress: string | null; + editLock: string | null; + title: string | null; + }): Promise { + await DocumentMetaModel.findOneAndUpdate( + { _id: meta.documentId }, + { + $set: { + sessionDid: meta.sessionDid, + ownerDid: meta.ownerDid, + ownerIdentityDid: meta.ownerIdentityDid, + portalAddress: meta.portalAddress, + editLock: meta.editLock, + title: meta.title, + updatedAt: Date.now(), + }, + }, + { upsert: true, new: true, writeConcern: { w: "majority", j: true } } + ); + } + + // Discovery: docs bound to the proven owner (identity DID or portal owner DID) — recovery for a wiped device. + async listDocumentsForOwner( + by: { ownerIdentityDid?: string; ownerDid?: string } + ): Promise> { + const filter: Record = {}; + if (by.ownerIdentityDid) filter.ownerIdentityDid = by.ownerIdentityDid; + else if (by.ownerDid) filter.ownerDid = by.ownerDid; + else return []; + // Published docs are discovered via the indexer; the collab server only lists + // unpublished durable docs (the publish reconciler flips this flag). + filter.isPublished = { $ne: true }; + + const metas: any[] = await DocumentMetaModel.find(filter).select("editLock title").lean(); + return metas.map((m) => ({ + documentId: m._id, + editLock: m.editLock ?? null, + title: m.title ?? null, + })); + } + + // Publish-reconciler candidate set: unpublished durable docs that have a portal to + // resolve on-chain. Rows without a portalAddress can't be checked, so they are skipped. + async listUnpublishedMetaRefs( + limit: number + ): Promise> { + const rows: any[] = await DocumentMetaModel.find({ + isPublished: { $ne: true }, + portalAddress: { $ne: null }, + }) + .select("portalAddress") + .limit(limit) + .lean(); + return rows.map((r) => ({ documentId: r._id, portalAddress: r.portalAddress })); + } + + async markDocumentsPublished(documentIds: string[]): Promise { + if (documentIds.length === 0) return; + await DocumentMetaModel.updateMany( + { _id: { $in: documentIds } }, + { $set: { isPublished: true } } + ); + } + // Commit management async createCommit(commit: DocumentCommit): Promise { try { @@ -126,7 +288,7 @@ export class MongoDBStore { await mongoCommit.save(); // Mark associated updates as committed - await this.markUpdatesAsCommitted(commit.updates, commit.cid); + await this.markUpdatesAsCommitted(commit.updates, commit.cid, commit.documentId); return commit; } catch (error) { @@ -241,6 +403,39 @@ export class MongoDBStore { } } + // Owner-only permanent delete: wipes every collection scoped to this document. + async purgeDocument(documentId: string): Promise { + await Promise.all([ + DocumentUpdateModel.deleteMany({ documentId }), + DocumentCommitModel.deleteMany({ documentId }), + DocumentMetaModel.deleteOne({ _id: documentId }), + SessionModel.deleteMany({ documentId }), + CounterModel.deleteOne({ _id: documentId }), + ]); + } + + // Conservative orphan GC: terminated ddoc streams with no editLock and no snapshot row. + // A real draft always has an editLock or a snapshot, so it is never swept. + async collectOrphans(graceMs: number): Promise { + const cutoff = new Date(Date.now() - graceMs); + const terminated: any[] = await SessionModel.find({ + appType: "ddoc", state: "terminated", createdAt: { $lt: cutoff }, + }).lean(); + + let purged = 0; + for (const s of terminated) { + const meta: any = await DocumentMetaModel.findById(s.documentId).lean(); + if (meta?.editLock) continue; // real draft — never sweep + const snap: any = await DocumentUpdateModel.findOne({ documentId: s.documentId, updateType: "snapshot" }).sort({ seq: -1 }).lean(); + if (snap) continue; // has a hydration base — never sweep + await DocumentUpdateModel.deleteMany({ documentId: s.documentId }); + await SessionModel.deleteMany({ documentId: s.documentId }); + await CounterModel.deleteOne({ _id: s.documentId }); + purged++; + } + return purged; + } + // Clear all data (useful for testing) async clear() { try { diff --git a/src/services/owner-op-routes.ts b/src/services/owner-op-routes.ts new file mode 100644 index 0000000..f74843c --- /dev/null +++ b/src/services/owner-op-routes.ts @@ -0,0 +1,118 @@ +import type { Request, Response } from "express"; +import type { AppServer } from "../types/index"; +import type { AuthService } from "./auth"; +import type { SessionManager } from "./session-manager"; +import type { MongoDBStore } from "./mongodb-store"; +import { getRoomName } from "./socket-handlers"; +import { Hex } from "viem"; + +export interface OwnerOpDeps { + authService: Pick; + sessionManager: Pick; +} + +export function createCollabJoinEnabledHandler(deps: OwnerOpDeps, io: AppServer) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, enabled, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: (session as any).ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, identityContractAddress: identityContractAddress as Hex, + ownerToken, ownerAddress: ownerAddress as Hex, portalAddress: portalAddress as Hex, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + await deps.sessionManager.setCollabJoinEnabled(documentId, sessionDid, !!enabled); + + if (enabled === false) { + // Force-drop live non-owner sockets in the room (stop-share teeth). `io` is the app's + // Socket.IO server; `fetchSockets()` reaches every socket in the room (across instances too + // when the Redis adapter is enabled), so `.disconnect(true)` drops it wherever it lives. The + // returned `.data` is a read-only snapshot — we disconnect rather than mutate it. + const roomName = getRoomName(documentId, sessionDid); + const sockets = await io.in(roomName).fetchSockets(); + for (const s of sockets) { + if (s.data.role !== "owner") s.disconnect(true); + } + } + + res.status(200).json({ ok: true }); + }; +} + +export interface ListMyDocumentsDeps { + authService: Pick; + mongodbStore: Pick; +} + +export function createListMyDocumentsHandler(deps: ListMyDocumentsDeps) { + return async (req: Request, res: Response): Promise => { + const { identityToken, identityContractAddress, portalAddress } = req.body || {}; + + // Path 1 — identity: the capability binds the portalAddress (a discovery scope, not a single ddocId). + if (identityToken && identityContractAddress && portalAddress) { + const signingDid = await deps.authService.verifyIdentityToken(identityToken, identityContractAddress, portalAddress); + if (signingDid) { + const documents = await deps.mongodbStore.listDocumentsForOwner({ ownerIdentityDid: signingDid }); + res.status(200).json({ documents }); + return; + } + } + + // Path 2 — portal owner: ⚠ DEFERRED in v1 (same member-forgeable `collaboratorKeys` flaw as + // verifyOwnerOp path 2 — a shared workspaceCollabDid every member holds). Recovery is identity-scoped + // in v1: a caller recovers the docs bound to their OWN signingDid. Workspace-wide portal-owner recovery + // waits on the ASA-owner proof. + + res.status(401).json({ error: "Authentication failed" }); + }; +} + +export function createDeleteDocumentHandler(deps: { + authService: Pick; + sessionManager: Pick; + mongodbStore: Pick; +}) { + return async (req: Request, res: Response): Promise => { + const documentId = req.params.documentId; + const { sessionDid, identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress } = req.body || {}; + + const session = await deps.sessionManager.getSession(documentId, sessionDid); + if (!session) { + res.status(404).json({ error: "Session not found" }); + return; + } + + // Delete disabled without an owner-of-record. + if (!session.ownerIdentityDid && !session.ownerDid) { + res.status(403).json({ error: "No owner-of-record; delete disabled" }); + return; + } + + const authorized = await deps.authService.verifyOwnerOp({ + ddocId: documentId, + boundOwnerIdentityDid: session.ownerIdentityDid ?? null, + boundOwnerDid: session.ownerDid ?? null, + identityToken, identityContractAddress, ownerToken, ownerAddress, portalAddress, + }); + if (!authorized) { + res.status(403).json({ error: "Not the document owner" }); + return; + } + + await deps.mongodbStore.purgeDocument(documentId); + res.status(200).json({ ok: true }); + }; +} diff --git a/src/services/published-reconciler.ts b/src/services/published-reconciler.ts new file mode 100644 index 0000000..169167c --- /dev/null +++ b/src/services/published-reconciler.ts @@ -0,0 +1,22 @@ +import type { MongoDBStore } from "./mongodb-store"; + +// Flips DocumentMeta.isPublished for the unpublished candidate docs that are now +// on-chain. Best-effort: the caller (Agenda job) wraps this and retries next pass. +export const reconcilePublishedDocuments = async (deps: { + mongodbStore: Pick; + resolvePublishedDocumentIds: ( + refs: Array<{ documentId: string; portalAddress: string }> + ) => Promise>; + batchSize: number; +}): Promise<{ scanned: number; published: number }> => { + const { mongodbStore, resolvePublishedDocumentIds, batchSize } = deps; + + const refs = await mongodbStore.listUnpublishedMetaRefs(batchSize); + if (refs.length === 0) return { scanned: 0, published: 0 }; + + const published = await resolvePublishedDocumentIds(refs); + const ids = [...published]; + if (ids.length > 0) await mongodbStore.markDocumentsPublished(ids); + + return { scanned: refs.length, published: ids.length }; +}; diff --git a/src/services/session-manager.ts b/src/services/session-manager.ts index 5f86f88..1e8ac69 100644 --- a/src/services/session-manager.ts +++ b/src/services/session-manager.ts @@ -7,6 +7,9 @@ interface RuntimeSession { clients: Set; roomInfo?: string; appType?: "ddoc" | "dsheet"; + ownerIdentityDid?: string; + portalAddress?: string; + collabJoinEnabled?: boolean; } export class SessionManager { @@ -31,7 +34,6 @@ export class SessionManager { /** * Returns the local client set for a session, or undefined if not in memory. - * Used by LegacyWebSocketHandler to iterate clients without accessing private internals. */ getLocalClients(documentId: string, sessionDid: string): Set | undefined { const key = this.getSessionKey(documentId, sessionDid); @@ -48,18 +50,23 @@ export class SessionManager { const sessionKey = this.getSessionKey(sessionData.documentId, sessionData.sessionDid); this.inMemorySessions.set(sessionKey, runtimeSession); - // Persist in MongoDB for durability + // Persist in MongoDB for durability. Identity binding is first-writer-immutable: + // written once via $setOnInsert and never overwritten by later calls. try { await SessionModel.findOneAndUpdate( + { documentId: sessionData.documentId, sessionDid: sessionData.sessionDid }, { - documentId: sessionData.documentId, - sessionDid: sessionData.sessionDid, - ownerDid: sessionData.ownerDid, - }, - { - state: "active", - roomInfo: sessionData.roomInfo, - appType: sessionData.appType ?? "ddoc", + $setOnInsert: { + ownerDid: sessionData.ownerDid, + ownerIdentityDid: sessionData.ownerIdentityDid ?? null, + portalAddress: sessionData.portalAddress ?? null, + collabJoinEnabled: sessionData.collabJoinEnabled ?? false, + }, + $set: { + state: "active", + roomInfo: sessionData.roomInfo, + appType: sessionData.appType ?? "ddoc", + }, }, { upsert: true, new: true } ); @@ -93,6 +100,9 @@ export class SessionManager { clients: new Set(), roomInfo: dbSession.roomInfo, appType: dbSession.appType, + ownerIdentityDid: dbSession.ownerIdentityDid ?? undefined, + portalAddress: dbSession.portalAddress ?? undefined, + collabJoinEnabled: dbSession.collabJoinEnabled, }; // Store in memory @@ -101,6 +111,22 @@ export class SessionManager { return runtimeSession; } + async getCollabJoinEnabled(documentId: string, sessionDid: string): Promise { + const doc = await SessionModel.findOne({ documentId, sessionDid }, { collabJoinEnabled: 1 }).lean(); + return (doc as any)?.collabJoinEnabled; + } + + async setCollabJoinEnabled(documentId: string, sessionDid: string, enabled: boolean): Promise { + const sessionKey = this.getSessionKey(documentId, sessionDid); + const session = this.inMemorySessions.get(sessionKey); + if (session) session.collabJoinEnabled = enabled; + try { + await SessionModel.findOneAndUpdate({ documentId, sessionDid }, { collabJoinEnabled: enabled }); + } catch (error) { + console.error("Error setting collabJoinEnabled:", error); + } + } + async getRuntimeSession( documentId: string, sessionDid: string @@ -162,7 +188,11 @@ export class SessionManager { } } - async terminateSession(documentId: string, sessionDid: string): Promise { + async terminateSession( + documentId: string, + sessionDid: string, + appType: "ddoc" | "dsheet" = "ddoc" + ): Promise { const sessionKey = this.getSessionKey(documentId, sessionDid); this.inMemorySessions.delete(sessionKey); @@ -172,8 +202,10 @@ export class SessionManager { { state: "terminated", roomInfo: null } ); - await DocumentUpdateModel.deleteMany({ documentId, sessionDid }); - await DocumentCommitModel.deleteMany({ documentId, sessionDid }); + if (appType === "dsheet") { + await DocumentUpdateModel.deleteMany({ documentId, sessionDid }); + await DocumentCommitModel.deleteMany({ documentId, sessionDid }); + } } catch (error) { console.error("Error terminating session in database:", error); } @@ -183,14 +215,18 @@ export class SessionManager { documentId: string, ownerDid: string, excludeSessionDid?: string - ): Promise> { + ): Promise> { try { const query: Record = { documentId, ownerDid, state: "active" }; if (excludeSessionDid) { query.sessionDid = { $ne: excludeSessionDid }; } const sessions = await SessionModel.find(query); - return sessions.map((s) => ({ documentId: s.documentId, sessionDid: s.sessionDid })); + return sessions.map((s) => ({ + documentId: s.documentId, + sessionDid: s.sessionDid, + appType: s.appType, + })); } catch (error) { console.error("Error getting other active sessions:", error); return []; diff --git a/src/services/socket-handlers.ts b/src/services/socket-handlers.ts index 8709a78..a2fcb51 100644 --- a/src/services/socket-handlers.ts +++ b/src/services/socket-handlers.ts @@ -9,11 +9,13 @@ import { DocumentCommitResponseData, CommitHistoryArgs, UpdateHistoryArgs, + UpdateHistoryResponseData, + SnapshotArgs, + DocumentMetaArgs, PeersListArgs, AwarenessArgs, TerminateSessionArgs, DocumentCommit, - DocumentUpdate, AppServer, AppSocket, AppType, @@ -24,7 +26,6 @@ import { authService } from "./auth"; import { mongodbStore } from "./mongodb-store"; import { sessionManager } from "./session-manager"; import { Hex, isAddress } from "viem"; -import type { BroadcastBridge } from "./broadcast-bridge"; import type { SocketHandlerDeps } from "./socket-handlers.deps"; const defaultDeps: SocketHandlerDeps = { @@ -33,8 +34,6 @@ const defaultDeps: SocketHandlerDeps = { mongodbStore, }; -let bridge: BroadcastBridge | null = null; - function validateHexAddress(address: string | undefined, fieldName: string): address is Hex { if (!address || !isAddress(address)) { return false; @@ -55,11 +54,7 @@ function normalizeAppType(value: unknown): AppType { return value === "dsheet" ? "dsheet" : "ddoc"; } -export function registerEventHandlers(io: AppServer, broadcastBridge?: BroadcastBridge): void { - if (broadcastBridge) { - bridge = broadcastBridge; - } - +export function registerEventHandlers(io: AppServer): void { io.on("connection", (socket: AppSocket) => { console.log(`New Socket.IO connection: ${socket.id}`); @@ -83,6 +78,12 @@ export function registerEventHandlers(io: AppServer, broadcastBridge?: Broadcast socket.on("/documents/update/history", (args, callback) => handleUpdateHistory(defaultDeps, socket, args, callback) ); + socket.on("/documents/snapshot", (args, callback) => + handleSnapshot(defaultDeps, socket, args, callback) + ); + socket.on("/documents/meta", (args, callback) => + handleSetDocumentMeta(defaultDeps, socket, args, callback) + ); socket.on("/documents/peers/list", (args, callback) => handlePeersList(io, socket, args, callback)); socket.on("/documents/awareness", (args) => handleAwareness(io, socket, args)); socket.on("/documents/terminate", (args, callback) => @@ -201,12 +202,6 @@ export async function handleAuth( roomId: oldSession.documentId, }); - // Bridge: notify legacy WS clients and disconnect them - if (bridge) { - bridge.broadcastFromSocketIO(oldSession.documentId, oldSession.sessionDid, "/session/terminated", { roomId: oldSession.documentId }); - bridge.disconnectLegacyClientsInRoom(oldSession.documentId, oldSession.sessionDid); - } - // Force-leave all sockets and reset auth const socketsInOldRoom = await io.in(oldRoomName).fetchSockets(); for (const s of socketsInOldRoom) { @@ -214,8 +209,12 @@ export async function handleAuth( s.leave(oldRoomName); } - // Now clean up DB - await sessionManager.terminateSession(oldSession.documentId, oldSession.sessionDid); + // Now clean up DB — use the terminated session's own appType, not the new connection's claimed one + await sessionManager.terminateSession( + oldSession.documentId, + oldSession.sessionDid, + oldSession.appType ?? "ddoc" + ); console.log( `[Auth] Terminated old session: ${oldSession.sessionDid} for document: ${documentId}` ); @@ -225,6 +224,9 @@ export async function handleAuth( documentId, sessionDid, ownerDid, + ownerIdentityDid: args.ownerIdentityDid, + portalAddress: args.contractAddress, + collabJoinEnabled: false, roomInfo: args.roomInfo, appType: claimedAppType, }); @@ -283,6 +285,22 @@ export async function handleAuth( role = ownerDid === existingSession.ownerDid ? "owner" : "editor"; + // collabJoinEnabled gate (R5-b): ddoc-ONLY (dsheet must never break; new dsheet rooms also get + // collabJoinEnabled:false from createSession, so an unscoped gate would kill all dsheet collaboration). + // Read the flag FRESH FROM MONGO (HTTP process flips it in Task 13; in-memory copy can be stale). + // Only an explicit `false` closes a ddoc room to non-owners; undefined (legacy) stays open; owners bypass. + if (storedAppType === "ddoc" && role === "editor") { + const joinEnabled = await sessionManager.getCollabJoinEnabled(documentId, existingSession.sessionDid); + if (joinEnabled === false) { + return callback({ + status: false, + statusCode: 403, + error: "Link sharing is disabled for this document", + errorCode: ErrorCode.JOIN_DISABLED, + }); + } + } + if (role === "owner" && args.roomInfo) { await sessionManager.updateRoomInfo( documentId, @@ -328,11 +346,6 @@ export async function handleAuth( }; socket.to(roomName).emit("/room/membership_change", membershipPayload); - // Bridge: notify legacy WS clients - if (bridge) { - bridge.broadcastFromSocketIO(documentId, sessionDid, "/room/membership_change", membershipPayload, socket.id); - } - callback({ status: true, statusCode: 200, @@ -424,11 +437,6 @@ export async function handleDocumentUpdate( }; socket.to(roomName).emit("/document/content_update", contentPayload); - // Bridge: notify legacy WS clients - if (bridge) { - bridge.broadcastFromSocketIO(documentId, socket.data.sessionDid, "/document/content_update", contentPayload, socket.id); - } - // Persist to DB — sender's ACK waits for this const update = await mongodbStore.createUpdate({ id: updateId, @@ -621,7 +629,7 @@ export async function handleUpdateHistory( deps: SocketHandlerDeps, socket: AppSocket, args: UpdateHistoryArgs, - callback: (response: AckResponse<{ history: DocumentUpdate[]; total: number }>) => void + callback: (response: AckResponse) => void ): Promise { try { const { mongodbStore } = deps; @@ -635,21 +643,17 @@ export async function handleUpdateHistory( } const documentId = args.documentId || socket.data.documentId; - const { offset = 0, limit = 100, sort = "desc", filters = {} } = args; - - const filterParams = { documentId, sessionDid: socket.data.sessionDid }; - const [updates, total] = await Promise.all([ - mongodbStore.getUpdatesByDocument(filterParams, { committed: filters.committed }), - mongodbStore.countUpdatesByDocument(filterParams, { committed: filters.committed }), - ]); + const { snapshot, updates, nextSeq, hasMore } = await mongodbStore.getHydrationRange( + documentId, + socket.data.sessionDid, + { sinceSeq: args.sinceSeq } + ); + const history = snapshot ? [snapshot, ...updates] : updates; callback({ status: true, statusCode: 200, - data: { - history: updates, - total, - }, + data: { history, total: history.length, snapshot, nextSeq, hasMore }, }); } catch (error) { console.error("Error in update history handler:", error); @@ -662,6 +666,177 @@ export async function handleUpdateHistory( } } +export async function handleSnapshot( + deps: SocketHandlerDeps, + socket: AppSocket, + args: SnapshotArgs, + callback: (response: AckResponse<{ id: string; seq: number }>) => void +): Promise { + try { + const { authService, sessionManager, mongodbStore } = deps; + if (!requireAuth(socket)) { + return callback({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + } + + if (socket.data.role !== "owner") { + return callback({ + status: false, + statusCode: 403, + error: "Only owners can write snapshots", + errorCode: ErrorCode.COMMIT_UNAUTHORIZED, + }); + } + + const { data, collaborationToken, publishedMarker, floorSeq } = args; + const documentId = args.documentId || socket.data.documentId; + + if (!data) { + return callback({ + status: false, + statusCode: 400, + error: "Snapshot data is required", + errorCode: ErrorCode.UPDATE_DATA_MISSING, + }); + } + + // floorSeq is load-bearing for gapless hydration (§3.7): the tail is served as + // seq > floorSeq. A snapshot without a proven floor would let the server serve + // seq > snapshot.seq and silently orphan a concurrent writer's update, so reject it. + if (typeof floorSeq !== "number" || !Number.isInteger(floorSeq) || floorSeq < 0) { + return callback({ + status: false, + statusCode: 400, + error: "Snapshot floorSeq (non-negative integer) is required", + errorCode: ErrorCode.UPDATE_DATA_MISSING, + }); + } + + const session = await sessionManager.getRuntimeSession(documentId, socket.data.sessionDid); + const sessionDid = session?.sessionDid; + + if (!sessionDid) { + return callback({ + status: false, + statusCode: 404, + error: "Session not found", + errorCode: ErrorCode.SESSION_NOT_FOUND, + }); + } + + const isVerified = await authService.verifyCollaborationToken( + collaborationToken, + sessionDid, + documentId + ); + + if (!isVerified) { + return callback({ + status: false, + statusCode: 401, + error: "Authentication failed", + errorCode: ErrorCode.AUTH_TOKEN_INVALID, + }); + } + + const snapshot = await mongodbStore.createSnapshot({ + id: uuidv4(), + documentId, + data, + updateType: "snapshot", + committed: false, + commitCid: null, + createdAt: Date.now(), + sessionDid, + appType: socket.data.appType, + publishedMarker: publishedMarker ?? null, + floorSeq, + }); + + callback({ + status: true, + statusCode: 200, + data: { id: snapshot.id, seq: snapshot.seq! }, + }); + } catch (error) { + console.error("Error in snapshot handler:", error); + callback({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: ErrorCode.INTERNAL_ERROR, + }); + } +} + +export async function handleSetDocumentMeta( + deps: SocketHandlerDeps, + socket: AppSocket, + args: DocumentMetaArgs, + callback: (response: AckResponse<{ ok: true }>) => void +): Promise { + try { + const { sessionManager, mongodbStore } = deps; + if (!requireAuth(socket)) { + return callback({ + status: false, + statusCode: 401, + error: "Not authenticated", + errorCode: ErrorCode.NOT_AUTHENTICATED, + }); + } + + if (socket.data.role !== "owner") { + return callback({ + status: false, + statusCode: 403, + error: "Only owners can write document metadata", + errorCode: ErrorCode.COMMIT_UNAUTHORIZED, + }); + } + + const documentId = args.documentId || socket.data.documentId; + const session = await sessionManager.getRuntimeSession(documentId, socket.data.sessionDid); + + if (!session) { + return callback({ + status: false, + statusCode: 404, + error: "Session not found", + errorCode: ErrorCode.SESSION_NOT_FOUND, + }); + } + + await mongodbStore.upsertDocumentMeta({ + documentId, + sessionDid: session.sessionDid, + ownerDid: session.ownerDid ?? null, + ownerIdentityDid: session.ownerIdentityDid ?? null, + portalAddress: session.portalAddress ?? null, + editLock: args.editLock, + title: args.title, + }); + + callback({ + status: true, + statusCode: 200, + data: { ok: true }, + }); + } catch (error) { + console.error("Error in set-document-meta handler:", error); + callback({ + status: false, + statusCode: 500, + error: "Internal server error", + errorCode: ErrorCode.INTERNAL_ERROR, + }); + } +} + export async function handlePeersList( io: AppServer, socket: AppSocket, @@ -680,15 +855,9 @@ export async function handlePeersList( const documentId = args.documentId || socket.data.documentId; - // Use bridge for combined peer list (Socket.IO + legacy WS), fallback to Socket.IO only - let peers: string[]; - if (bridge) { - peers = await bridge.getCombinedPeers(documentId, socket.data.sessionDid); - } else { - const roomName = getRoomName(documentId, socket.data.sessionDid); - const sockets = await io.in(roomName).fetchSockets(); - peers = sockets.map((s) => s.id); - } + const roomName = getRoomName(documentId, socket.data.sessionDid); + const sockets = await io.in(roomName).fetchSockets(); + const peers = sockets.map((s) => s.id); callback({ status: true, @@ -723,11 +892,6 @@ export async function handleAwareness( const roomName = getRoomName(documentId, socket.data.sessionDid); const awarenessPayload = { data, roomId: documentId }; socket.to(roomName).emit("/document/awareness_update", awarenessPayload); - - // Bridge: notify legacy WS clients - if (bridge) { - bridge.broadcastFromSocketIO(documentId, socket.data.sessionDid, "/document/awareness_update", awarenessPayload, socket.id); - } } catch (error) { console.error("Error in awareness handler:", error); } @@ -799,12 +963,6 @@ export async function handleTerminateSession( const terminatePayload = { roomId: documentId }; socket.to(roomName).emit("/session/terminated", terminatePayload); - // Bridge: notify legacy WS clients and disconnect them - if (bridge) { - bridge.broadcastFromSocketIO(documentId, session.sessionDid, "/session/terminated", terminatePayload, socket.id); - bridge.disconnectLegacyClientsInRoom(documentId, session.sessionDid, socket.id); - } - // 3. Deauth and force-leave all sockets (blocks new handlers) for (const s of socketsInRoom) { s.data.authenticated = false; @@ -815,7 +973,7 @@ export async function handleTerminateSession( await sessionManager.deactivateSession(documentId, session.sessionDid); // 5. Clean up DB - await sessionManager.terminateSession(documentId, session.sessionDid); + await sessionManager.terminateSession(documentId, session.sessionDid, session.appType ?? "ddoc"); callback({ status: true, @@ -854,11 +1012,6 @@ export async function handleDisconnecting( }; socket.to(roomName).emit("/room/membership_change", departurePayload); - // Bridge: notify legacy WS clients - if (bridge) { - bridge.broadcastFromSocketIO(socket.data.documentId, socket.data.sessionDid, "/room/membership_change", departurePayload, socket.id); - } - // Remove from session tracking (handles deactivation if last client) await sessionManager.removeClientFromSession( socket.data.documentId, diff --git a/src/tests/services/auth.test.ts b/src/tests/services/auth.test.ts new file mode 100644 index 0000000..760a67e --- /dev/null +++ b/src/tests/services/auth.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as ucans from "@ucans/ucans"; +import { AuthService } from "../../services/auth"; +import { getIdentitySigningDid, getOwnerDid } from "../../utils/contract"; + +vi.mock("@ucans/ucans", () => ({ verify: vi.fn() })); +vi.mock("../../utils/contract", () => ({ + getIdentitySigningDid: vi.fn(), + getOwnerDid: vi.fn(), +})); + +describe("verifyCollaborationToken cache (R4a)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("does NOT serve a cache hit across a different (sessionDid, documentId)", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + const auth = new AuthService("did:server"); + + await auth.verifyCollaborationToken("tok", "sessionA", "docA"); + await auth.verifyCollaborationToken("tok", "sessionB", "docB"); // same bearer token, different scope + + expect(ucans.verify).toHaveBeenCalledTimes(2); + }); + + it("serves a cache hit for the identical (token, sessionDid, documentId)", async () => { + (ucans.verify as any).mockResolvedValue({ ok: true }); + const auth = new AuthService("did:server"); + + await auth.verifyCollaborationToken("tok", "sessionA", "docA"); + await auth.verifyCollaborationToken("tok", "sessionA", "docA"); + + expect(ucans.verify).toHaveBeenCalledTimes(1); + }); +}); + +describe("verifyIdentityToken", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("verifies the UCAN with rootIssuer = on-chain signingDid and a ddocId-bound capability", async () => { + (getIdentitySigningDid as any).mockResolvedValue("did:key:zOwner"); + (ucans.verify as any).mockResolvedValue({ ok: true }); + const auth = new AuthService("did:server"); + + const did = await auth.verifyIdentityToken("tok", "0xIdentity" as any, "ddoc-A"); + + expect(ucans.verify).toHaveBeenCalledWith("tok", expect.objectContaining({ + audience: "did:server", + requiredCapabilities: [expect.objectContaining({ + capability: expect.objectContaining({ + with: { scheme: "storage", hierPart: "ddoc-A" }, + can: { namespace: "collaboration", segments: ["OWN"] }, + }), + rootIssuer: "did:key:zOwner", + })], + })); + expect(did).toBe("did:key:zOwner"); + }); + + it("returns null when the on-chain DID cannot be resolved", async () => { + (getIdentitySigningDid as any).mockResolvedValue(null); + const auth = new AuthService("did:server"); + expect(await auth.verifyIdentityToken("tok", "0xIdentity" as any, "ddoc-A")).toBeNull(); + }); +}); + +describe("verifyOwnerOp (OR of two bound proofs)", () => { + let auth: AuthService; + beforeEach(() => { + vi.clearAllMocks(); + auth = new AuthService("did:server"); + }); + + it("authorizes via the identity path when signingDid == bound ownerIdentityDid", async () => { + vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue("did:key:zOwner"); + const ok = await auth.verifyOwnerOp({ + ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", + identityToken: "it", identityContractAddress: "0xIdentity" as any, + }); + expect(ok).toBe(true); + }); + + // v1: the portal path is DEFERRED (member-forgeable for teams). A valid portal token alone must NOT authorize. + it("does NOT authorize via the portal path in v1 (deferred — collaboratorKeys is member-forgeable)", async () => { + vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue(null); + const ownerTokenSpy = vi.spyOn(auth, "verifyOwnerToken").mockResolvedValue("did:portal:owner"); + const ok = await auth.verifyOwnerOp({ + ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", + ownerToken: "ot", ownerAddress: "0xOwner" as any, portalAddress: "0xPortal" as any, + }); + expect(ok).toBe(false); + expect(ownerTokenSpy).not.toHaveBeenCalled(); + }); + + // ATTACK 1: a valid identity UCAN from an identity that is NOT the bound owner must be rejected. + it("REJECTS a valid identity UCAN whose signingDid != bound ownerIdentityDid (attacker's own contract)", async () => { + vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue("did:key:zAttacker"); // validly signed, wrong identity + const ok = await auth.verifyOwnerOp({ + ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", + identityToken: "it", identityContractAddress: "0xAttacker" as any, + }); + expect(ok).toBe(false); + }); + + // ATTACK 2: doc-scoping — verifyIdentityToken is called with THIS ddocId; a token minted for doc B fails. + it("passes the caller's ddocId into verifyIdentityToken so a doc-B token can't authorize doc-A", async () => { + const spy = vi.spyOn(auth, "verifyIdentityToken").mockResolvedValue(null); + await auth.verifyOwnerOp({ + ddocId: "ddoc-A", boundOwnerIdentityDid: "did:key:zOwner", boundOwnerDid: "did:portal:owner", + identityToken: "it", identityContractAddress: "0xIdentity" as any, + }); + expect(spy).toHaveBeenCalledWith("it", "0xIdentity", "ddoc-A"); + }); +}); diff --git a/src/tests/services/broadcast-bridge.test.ts b/src/tests/services/broadcast-bridge.test.ts deleted file mode 100644 index 574dc13..0000000 --- a/src/tests/services/broadcast-bridge.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { BroadcastBridge } from "../../services/broadcast-bridge"; -import type { AppServer, WebSocketEvent } from "../../types"; -import { ErrorCode } from "../../types"; -import type { LegacyWebSocketHandler } from "../../services/legacy-ws-handler"; - -function createFakeIO(options?: { - broadcastOperator?: { emit: ReturnType }; - fetchSockets?: ReturnType; -}): AppServer { - const op = options?.broadcastOperator ?? { emit: vi.fn() }; - const fetchSockets = options?.fetchSockets ?? vi.fn().mockResolvedValue([]); - return { - to: vi.fn(() => op), - in: vi.fn(() => ({ fetchSockets })), - } as unknown as AppServer; -} - -function createFakeLegacyHandler(): LegacyWebSocketHandler { - return { - broadcastToLegacyClients: vi.fn(), - getLegacyPeers: vi.fn().mockReturnValue([]), - disconnectClientsInRoom: vi.fn(), - } as unknown as LegacyWebSocketHandler; -} - -describe("BroadcastBridge", () => { - let bridge: BroadcastBridge; - let fakeLegacyHandler: LegacyWebSocketHandler; - let fakeBroadcastOperator: { emit: ReturnType }; - let fakeIO: AppServer; - - beforeEach(() => { - fakeBroadcastOperator = { emit: vi.fn() }; - fakeIO = createFakeIO({ broadcastOperator: fakeBroadcastOperator }); - fakeLegacyHandler = createFakeLegacyHandler(); - bridge = new BroadcastBridge(fakeIO, fakeLegacyHandler); - vi.clearAllMocks(); - }); - - // broadcastFromSocketIO - - it("translates /document/content_update and broadcasts to legacy clients", () => { - const payload = { id: "update-1", data: "encrypted-data", createdAt: 1000, roomId: "doc-1" }; - - bridge.broadcastFromSocketIO("doc-1", "session-1", "/document/content_update", payload); - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - { - type: "CONTENT_UPDATE", - event_type: "CONTENT_UPDATE", - event: { - data: { id: payload.id, data: payload.data, createdAt: payload.createdAt }, - roomId: "doc-1", - }, - } - ); - }); - - it("translates /document/awareness_update and broadcasts to legacy clients", () => { - const payload = { data: { position: "abc" }, roomId: "doc-1" }; - - bridge.broadcastFromSocketIO("doc-1", "session-1", "/document/awareness_update", payload); - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - { - type: "AWARENESS_UPDATE", - event_type: "AWARENESS_UPDATE", - event: { - data: payload.data, - roomId: "doc-1", - }, - } - ); - }); - - it("translates /room/membership_change and broadcasts to legacy clients", () => { - const payload = { action: "user_joined", user: { role: "editor" }, roomId: "doc-1" }; - - bridge.broadcastFromSocketIO("doc-1", "session-1", "/room/membership_change", payload); - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - { - type: "ROOM_UPDATE", - event_type: "ROOM_MEMBERSHIP_CHANGE", - event: { - data: { action: payload.action, user: payload.user }, - roomId: "doc-1", - }, - } - ); - }); - - it("translates /session/terminated and broadcasts to legacy clients", () => { - const payload = { roomId: "doc-1" }; - - bridge.broadcastFromSocketIO("doc-1", "session-1", "/session/terminated", payload); - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - { - type: "SESSION_TERMINATED", - event_type: "SESSION_TERMINATED", - event: { - data: null, - roomId: "doc-1", - }, - } - ); - }); - - it("does not broadcast to legacy clients when the event has no legacy translation", () => { - bridge.broadcastFromSocketIO("doc-1", "session-1", "/unknown/event", {}); - - expect(fakeLegacyHandler.broadcastToLegacyClients).not.toHaveBeenCalled(); - }); - - // broadcastFromLegacy - - it("forwards CONTENT_UPDATE to legacy clients and emits to the Socket.IO room", () => { - const legacyEvent: WebSocketEvent = { - type: "CONTENT_UPDATE", - event_type: "CONTENT_UPDATE", - event: { - data: { id: "update-1", data: "encrypted-data", createdAt: 1000 }, - roomId: "doc-1", - }, - }; - - bridge.broadcastFromLegacy("doc-1", "session-1", legacyEvent, "legacy-client-1"); - - const roomName = "session::doc-1__session-1"; - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - legacyEvent, - "legacy-client-1" - ); - expect(fakeIO.to).toHaveBeenCalledWith(roomName); - expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/document/content_update", { - id: legacyEvent.event.data.id, - data: legacyEvent.event.data.data, - createdAt: legacyEvent.event.data.createdAt, - roomId: "doc-1", - }); - }); - - it("forwards AWARENESS_UPDATE to legacy clients and emits to the Socket.IO room", () => { - const legacyEvent: WebSocketEvent = { - type: "AWARENESS_UPDATE", - event_type: "AWARENESS_UPDATE", - event: { - data: { position: "abc" }, - roomId: "doc-1", - }, - }; - - bridge.broadcastFromLegacy("doc-1", "session-1", legacyEvent); - - const roomName = "session::doc-1__session-1"; - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - legacyEvent, - undefined - ); - expect(fakeIO.to).toHaveBeenCalledWith(roomName); - expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/document/awareness_update", { - data: legacyEvent.event.data, - roomId: "doc-1", - }); - }); - - it("forwards ROOM_MEMBERSHIP_CHANGE to legacy clients and emits to the Socket.IO room", () => { - const legacyEvent: WebSocketEvent = { - type: "ROOM_UPDATE", - event_type: "ROOM_MEMBERSHIP_CHANGE", - event: { - data: { action: "user_left", user: { role: "editor" } }, - roomId: "doc-1", - }, - }; - - bridge.broadcastFromLegacy("doc-1", "session-1", legacyEvent); - - const roomName = "session::doc-1__session-1"; - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - legacyEvent, - undefined - ); - expect(fakeIO.to).toHaveBeenCalledWith(roomName); - expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/room/membership_change", { - action: legacyEvent.event.data.action, - user: legacyEvent.event.data.user, - roomId: "doc-1", - }); - }); - - it("forwards SESSION_TERMINATED to legacy clients and emits to the Socket.IO room", () => { - const legacyEvent: WebSocketEvent = { - type: "SESSION_TERMINATED", - event_type: "SESSION_TERMINATED", - event: { - data: null, - roomId: "doc-1", - }, - }; - - bridge.broadcastFromLegacy("doc-1", "session-1", legacyEvent); - - const roomName = "session::doc-1__session-1"; - - expect(fakeLegacyHandler.broadcastToLegacyClients).toHaveBeenCalledWith( - "doc-1", - "session-1", - legacyEvent, - undefined - ); - expect(fakeIO.to).toHaveBeenCalledWith(roomName); - expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/session/terminated", { - roomId: "doc-1", - }); - }); - - // getCombinedPeers - - it("returns combined peer list from both Socket.IO and legacy clients", async () => { - const fakeSockets = [{ id: "socket-1" }, { id: "socket-2" }]; - const fetchSockets = vi.fn().mockResolvedValue(fakeSockets); - const localFakeIO = createFakeIO({ fetchSockets }); - const localFakeLegacyHandler = createFakeLegacyHandler(); - vi.mocked(localFakeLegacyHandler.getLegacyPeers).mockReturnValue(["legacy-1"]); - const localBridge = new BroadcastBridge(localFakeIO, localFakeLegacyHandler); - - const peers = await localBridge.getCombinedPeers("doc-1", "session-1"); - - const roomName = "session::doc-1__session-1"; - expect(localFakeIO.in).toHaveBeenCalledWith(roomName); - expect(fetchSockets).toHaveBeenCalled(); - expect(localFakeLegacyHandler.getLegacyPeers).toHaveBeenCalledWith("doc-1", "session-1"); - expect(peers).toEqual(["socket-1", "socket-2", "legacy-1"]); - }); - - // terminateRoom - - it("disconnects legacy clients and all Socket.IO clients in the room", async () => { - const fakeSockets = [ - { id: "socket-1", data: { authenticated: true }, leave: vi.fn() }, - { id: "socket-2", data: { authenticated: true }, leave: vi.fn() }, - ]; - const fetchSockets = vi.fn().mockResolvedValue(fakeSockets); - const localFakeIO = createFakeIO({ fetchSockets }); - const localFakeLegacyHandler = createFakeLegacyHandler(); - const localBridge = new BroadcastBridge(localFakeIO, localFakeLegacyHandler); - - await localBridge.terminateRoom("doc-1", "session-1"); - - const roomName = "session::doc-1__session-1"; - - expect(localFakeLegacyHandler.disconnectClientsInRoom).toHaveBeenCalledWith( - "doc-1", - "session-1", - undefined - ); - expect(localFakeIO.in).toHaveBeenCalledWith(roomName); - expect(fetchSockets).toHaveBeenCalled(); - - // First socket - expect(fakeSockets[0].data.authenticated).toBe(false); - expect(fakeSockets[0].leave).toHaveBeenCalledWith(roomName); - // Second socket - expect(fakeSockets[1].data.authenticated).toBe(false); - expect(fakeSockets[1].leave).toHaveBeenCalledWith(roomName); - // Aggregate - expect(fakeSockets[0].leave).toHaveBeenCalledTimes(1); - expect(fakeSockets[1].leave).toHaveBeenCalledTimes(1); - }); - - it("skips the excluded client when terminating the room", async () => { - const fakeSockets = [ - { id: "socket-1", data: { authenticated: true }, leave: vi.fn() }, - { id: "socket-2", data: { authenticated: true }, leave: vi.fn() }, - ]; - const fetchSockets = vi.fn().mockResolvedValue(fakeSockets); - const localFakeIO = createFakeIO({ fetchSockets }); - const localFakeLegacyHandler = createFakeLegacyHandler(); - const localBridge = new BroadcastBridge(localFakeIO, localFakeLegacyHandler); - - await localBridge.terminateRoom("doc-1", "session-1", "socket-1"); - - const roomName = "session::doc-1__session-1"; - - expect(localFakeLegacyHandler.disconnectClientsInRoom).toHaveBeenCalledWith( - "doc-1", - "session-1", - "socket-1" - ); - expect(localFakeIO.in).toHaveBeenCalledWith(roomName); - expect(fetchSockets).toHaveBeenCalled(); - expect(fakeSockets[0].data.authenticated).toBe(true); - expect(fakeSockets[0].leave).not.toHaveBeenCalled(); - expect(fakeSockets[1].data.authenticated).toBe(false); - expect(fakeSockets[1].leave).toHaveBeenCalledWith(roomName); - }); - - // disconnectLegacyClientsInRoom - - it("delegates to the legacy handler to disconnect clients in the room", () => { - bridge.disconnectLegacyClientsInRoom("doc-1", "session-1", "client-1"); - - expect(fakeLegacyHandler.disconnectClientsInRoom).toHaveBeenCalledWith( - "doc-1", - "session-1", - "client-1" - ); - }); - - // emitServerError - - it("emits a server error to the Socket.IO room", () => { - bridge.emitServerError("doc-1", "session-1", "Session terminated by owner"); - - const roomName = "session::doc-1__session-1"; - - expect(fakeIO.to).toHaveBeenCalledWith(roomName); - expect(fakeBroadcastOperator.emit).toHaveBeenCalledWith("/server/error", { - errorCode: ErrorCode.SESSION_TERMINATED, - message: "Session terminated by owner", - roomId: "doc-1", - }); - }); -}); diff --git a/src/tests/services/flush-route.test.ts b/src/tests/services/flush-route.test.ts new file mode 100644 index 0000000..e8f8ab9 --- /dev/null +++ b/src/tests/services/flush-route.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createFlushHandler, FLUSH_MAX_BYTES } from "../../services/flush-route"; + +function res() { + const r: any = {}; + r.status = vi.fn(() => r); + r.json = vi.fn(() => r); + return r; +} + +describe("POST /flush", () => { + let deps: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyCollaborationToken: vi.fn() }, + mongodbStore: { createUpdate: vi.fn().mockResolvedValue({ id: "u1", seq: 5 }) }, + }; + }); + + it("401s on an invalid collaboration token", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(false); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct" } } as any, r); + expect(r.status).toHaveBeenCalledWith(401); + expect(deps.mongodbStore.createUpdate).not.toHaveBeenCalled(); + }); + + it("413s when the payload exceeds the size cap", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "x".repeat(FLUSH_MAX_BYTES + 1) } } as any, r); + expect(r.status).toHaveBeenCalledWith(413); + expect(deps.mongodbStore.createUpdate).not.toHaveBeenCalled(); + }); + + it("persists a verified delta through createUpdate (seq + durable gate apply there)", async () => { + deps.authService.verifyCollaborationToken.mockResolvedValue(true); + const r = res(); + await createFlushHandler(deps)({ body: { documentId: "d", sessionDid: "s", collaborationToken: "t", data: "ct" } } as any, r); + expect(deps.authService.verifyCollaborationToken).toHaveBeenCalledWith("t", "s", "d"); + expect(deps.mongodbStore.createUpdate).toHaveBeenCalledWith(expect.objectContaining({ + documentId: "d", data: "ct", updateType: "yjs_update", sessionDid: "s", appType: "ddoc", + })); + expect(r.status).toHaveBeenCalledWith(200); + }); +}); diff --git a/src/tests/services/mongodb-store.test.ts b/src/tests/services/mongodb-store.test.ts new file mode 100644 index 0000000..10472bb --- /dev/null +++ b/src/tests/services/mongodb-store.test.ts @@ -0,0 +1,447 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { DocumentUpdateModel } from "../../database/models/document-update"; +import { CounterModel } from "../../database/models/counter"; +import { MongoDBStore } from "../../services/mongodb-store"; + +vi.mock("../../database/models", () => { + const save = vi.fn().mockResolvedValue(undefined); + const DocumentUpdateModel: any = vi.fn().mockImplementation((doc) => ({ ...doc, save })); + DocumentUpdateModel.find = vi.fn(); + DocumentUpdateModel.findById = vi.fn(); + DocumentUpdateModel.findOne = vi.fn(); + DocumentUpdateModel.updateMany = vi.fn().mockResolvedValue(undefined); + DocumentUpdateModel.deleteMany = vi.fn().mockResolvedValue(undefined); + DocumentUpdateModel.countDocuments = vi.fn().mockResolvedValue(0); + + const DocumentCommitModel: any = vi.fn(); + DocumentCommitModel.deleteMany = vi.fn().mockResolvedValue(undefined); + + return { + DocumentUpdateModel, + DocumentCommitModel, + CounterModel: { findOneAndUpdate: vi.fn(), deleteOne: vi.fn().mockResolvedValue(undefined) }, + SessionModel: { findOne: vi.fn(), find: vi.fn(), deleteMany: vi.fn().mockResolvedValue(undefined) }, + DocumentMetaModel: { findOneAndUpdate: vi.fn(), find: vi.fn(), findById: vi.fn(), updateMany: vi.fn().mockResolvedValue(undefined), deleteOne: vi.fn().mockResolvedValue(undefined) }, + }; +}); + +describe("createUpdate: seq assignment", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("increments the per-document counter atomically and stamps the returned seq", async () => { + const { CounterModel, SessionModel } = await import("../../database/models"); + (SessionModel.findOne as any).mockResolvedValue({ state: "active" }); + (CounterModel.findOneAndUpdate as any).mockResolvedValue({ _id: "doc-1", seq: 7 }); + + const store = new MongoDBStore(); + const result = await store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ciphertext", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 123, sessionDid: "room-did", appType: "ddoc", + }); + + // Asserts the correct PRIMITIVE ($inc, upsert). Atomicity itself is DB-provided. + expect(CounterModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "doc-1" }, + { $inc: { seq: 1 } }, + { new: true, upsert: true } + ); + expect(result.seq).toBe(7); + }); +}); + +describe("createUpdate: durable-write gate", () => { + beforeEach(async () => { + vi.clearAllMocks(); + const { CounterModel, SessionModel } = await import("../../database/models"); + (CounterModel.findOneAndUpdate as any).mockResolvedValue({ _id: "doc-1", seq: 1 }); + (SessionModel.findOne as any).mockResolvedValue({ state: "active", ownerDid: "did:o" }); + }); + + it("relays without persisting when no non-terminated session backs the room", async () => { + const { SessionModel, DocumentUpdateModel } = await import("../../database/models"); + (SessionModel.findOne as any).mockResolvedValue(null); + const save = vi.fn(); + (DocumentUpdateModel as any).mockImplementation((doc: any) => ({ ...doc, save })); + + const store = new MongoDBStore(); + const result = await store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", + }); + + expect(save).not.toHaveBeenCalled(); + expect(result.seq).toBeUndefined(); + }); + + it("persists when a live session backs the room", async () => { + const store = new MongoDBStore(); + const result = await store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", + }); + expect(result.seq).toBe(1); + }); + + it("relays without persisting when the backing session is terminated", async () => { + const { CounterModel, SessionModel } = await import("../../database/models"); + (SessionModel.findOne as any).mockResolvedValue({ state: "terminated" }); + + const store = new MongoDBStore(); + const result = await store.createUpdate({ + id: "u1", documentId: "doc-1", data: "ct", updateType: "yjs_update", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", appType: "ddoc", + }); + + expect(CounterModel.findOneAndUpdate).not.toHaveBeenCalled(); + expect(result.seq).toBeUndefined(); + }); +}); + +describe("schema: seq ordering", () => { + it("DocumentUpdate declares a required numeric seq path", () => { + const path = DocumentUpdateModel.schema.path("seq"); + expect(path).toBeDefined(); + expect(path.instance).toBe("Number"); + expect(path.isRequired).toBe(true); + }); + + // DB-provided guarantee — the unit test only asserts the index is DECLARED unique; + // actual collision-rejection is enforced by Mongo, integration/deploy-verified. + it("DocumentUpdate declares a unique {documentId, seq} index", () => { + const hasUnique = DocumentUpdateModel.schema.indexes().some( + ([spec, opts]) => + spec.documentId === 1 && spec.seq === 1 && opts && opts.unique === true + ); + expect(hasUnique).toBe(true); + }); + + it("Counter is keyed by string _id with a numeric seq", () => { + expect(CounterModel.schema.path("seq").instance).toBe("Number"); + }); +}); + +describe("createSnapshot: keep-latest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("writes the snapshot with majority write-concern then drops superseded snapshots", async () => { + const { CounterModel, DocumentUpdateModel } = await import("../../database/models"); + (CounterModel.findOneAndUpdate as any).mockResolvedValue({ _id: "doc-1", seq: 12 }); + const save = vi.fn().mockResolvedValue(undefined); + (DocumentUpdateModel as any).mockImplementation((doc: any) => ({ ...doc, save })); + + const store = new MongoDBStore(); + const result = await store.createSnapshot({ + id: "s1", documentId: "doc-1", data: "ct", updateType: "snapshot", + committed: false, commitCid: null, createdAt: 1, sessionDid: "room-did", + appType: "ddoc", publishedMarker: "blk-42", floorSeq: 5, + }); + + expect(result.seq).toBe(12); + // The authorship floor is persisted on the row so hydration can cut the tail at it. + expect(DocumentUpdateModel).toHaveBeenCalledWith(expect.objectContaining({ floorSeq: 5 })); + expect(result.floorSeq).toBe(5); + expect(save).toHaveBeenCalledWith({ w: "majority", j: true }); + expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ + documentId: "doc-1", sessionDid: "room-did", updateType: "snapshot", seq: { $lt: 12 }, + }); + }); +}); + +describe("upsertDocumentMeta", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("upserts with majority write-concern", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + (DocumentMetaModel.findOneAndUpdate as any).mockResolvedValue({ _id: "doc-1" }); + + const store = new MongoDBStore(); + await store.upsertDocumentMeta({ + documentId: "doc-1", sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", + portalAddress: "0xP", editLock: "el", title: "t", + }); + + expect(DocumentMetaModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: "doc-1" }, + expect.objectContaining({ + $set: expect.objectContaining({ + sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", + portalAddress: "0xP", editLock: "el", title: "t", + }), + }), + expect.objectContaining({ upsert: true, writeConcern: { w: "majority", j: true } }) + ); + }); +}); + +describe("listDocumentsForOwner", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns docs bound to the owner identity DID", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + (DocumentMetaModel.find as any).mockReturnValue({ + select: vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { _id: "doc-1", editLock: "el-1", title: "t1" }, + { _id: "doc-2", editLock: "el-2", title: "t2" }, + ]), + }), + }); + + const store = new MongoDBStore(); + const result = await store.listDocumentsForOwner({ ownerIdentityDid: "did:key:zOwner" }); + + expect(DocumentMetaModel.find).toHaveBeenCalledWith({ + ownerIdentityDid: "did:key:zOwner", + isPublished: { $ne: true }, + }); + expect(result).toEqual([ + { documentId: "doc-1", editLock: "el-1", title: "t1" }, + { documentId: "doc-2", editLock: "el-2", title: "t2" }, + ]); + }); + + it("lists unpublished refs with a portalAddress, projected + limited", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + const limit = vi.fn().mockReturnValue({ + lean: vi.fn().mockResolvedValue([ + { _id: "d1", portalAddress: "0xP" }, + { _id: "d2", portalAddress: "0xQ" }, + ]), + }); + (DocumentMetaModel.find as any).mockReturnValue({ select: vi.fn().mockReturnValue({ limit }) }); + + const store = new MongoDBStore(); + const refs = await store.listUnpublishedMetaRefs(500); + + expect(DocumentMetaModel.find).toHaveBeenCalledWith({ + isPublished: { $ne: true }, + portalAddress: { $ne: null }, + }); + expect(limit).toHaveBeenCalledWith(500); + expect(refs).toEqual([ + { documentId: "d1", portalAddress: "0xP" }, + { documentId: "d2", portalAddress: "0xQ" }, + ]); + }); + + it("markDocumentsPublished updateMany sets isPublished for the given ids", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + const store = new MongoDBStore(); + await store.markDocumentsPublished(["d1", "d2"]); + expect(DocumentMetaModel.updateMany).toHaveBeenCalledWith( + { _id: { $in: ["d1", "d2"] } }, + { $set: { isPublished: true } } + ); + }); + + it("markDocumentsPublished no-ops on empty input", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + const store = new MongoDBStore(); + await store.markDocumentsPublished([]); + expect(DocumentMetaModel.updateMany).not.toHaveBeenCalled(); + }); + + it("returns an empty list when neither ownerIdentityDid nor ownerDid is given", async () => { + const { DocumentMetaModel } = await import("../../database/models"); + const store = new MongoDBStore(); + const result = await store.listDocumentsForOwner({}); + + expect(result).toEqual([]); + expect(DocumentMetaModel.find).not.toHaveBeenCalled(); + }); +}); + +describe("getHydrationRange", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function mockFind(rows: any[]) { + return { sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(rows) }) }; + } + + it("serves latest snapshot + tail after its floor when the client has no cursor", async () => { + const { DocumentUpdateModel } = await import("../../database/models"); + (DocumentUpdateModel.findOne as any).mockReturnValue({ + sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "s1", documentId: "doc-1", seq: 10, floorSeq: 10, updateType: "snapshot", data: "ct", sessionDid: "room-did" }) }), + }); + (DocumentUpdateModel.find as any).mockReturnValue( + mockFind([{ _id: "u11", documentId: "doc-1", seq: 11, data: "ct", updateType: "yjs_update", sessionDid: "room-did" }]) + ); + + const store = new MongoDBStore(); + const res = await store.getHydrationRange("doc-1", "room-did"); + + expect(res.snapshot?.seq).toBe(10); + expect(DocumentUpdateModel.find).toHaveBeenCalledWith( + expect.objectContaining({ documentId: "doc-1", sessionDid: "room-did", seq: { $gt: 10 } }) + ); + expect(res.updates.map((u) => u.seq)).toEqual([11]); + expect(res.hasMore).toBe(false); + }); + + it("cuts the tail at the snapshot's floorSeq, not its own seq (gap-B: concurrent-writer orphan guard)", async () => { + const { DocumentUpdateModel } = await import("../../database/models"); + // Snapshot's own seq is 12, but it is only provably complete up to floorSeq 7. + (DocumentUpdateModel.findOne as any).mockReturnValue({ + sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "s1", documentId: "doc-1", seq: 12, floorSeq: 7, updateType: "snapshot", data: "ct", sessionDid: "room-did" }) }), + }); + (DocumentUpdateModel.find as any).mockReturnValue( + mockFind([{ _id: "u8", documentId: "doc-1", seq: 8, data: "ct", updateType: "yjs_update", sessionDid: "room-did" }]) + ); + + const store = new MongoDBStore(); + const res = await store.getHydrationRange("doc-1", "room-did"); + + // Must resume at > floorSeq (7), re-serving seq 8 — an update below the snapshot's own + // seq (12) that the author may never have applied. Serving > snapshot.seq would orphan it. + expect(res.snapshot?.seq).toBe(12); + expect(DocumentUpdateModel.find).toHaveBeenCalledWith( + expect.objectContaining({ documentId: "doc-1", sessionDid: "room-did", seq: { $gt: 7 } }) + ); + expect(res.updates.map((u) => u.seq)).toEqual([8]); + }); + + it("trims an applied tail when sinceSeq is at or past the snapshot (no snapshot re-sent)", async () => { + const { DocumentUpdateModel } = await import("../../database/models"); + (DocumentUpdateModel.findOne as any).mockReturnValue({ + sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "s1", seq: 10, floorSeq: 10, updateType: "snapshot", data: "ct" }) }), + }); + (DocumentUpdateModel.find as any).mockReturnValue(mockFind([])); + + const store = new MongoDBStore(); + const res = await store.getHydrationRange("doc-1", "room-did", { sinceSeq: 15 }); + + expect(res.snapshot).toBeNull(); + expect(DocumentUpdateModel.find).toHaveBeenCalledWith( + expect.objectContaining({ seq: { $gt: 15 } }) + ); + }); + + it("re-sends the snapshot and resumes at the floor when sinceSeq is behind the snapshot", async () => { + const { DocumentUpdateModel } = await import("../../database/models"); + (DocumentUpdateModel.findOne as any).mockReturnValue({ + sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "s1", documentId: "doc-1", seq: 12, floorSeq: 10, updateType: "snapshot", data: "ct", sessionDid: "room-did" }) }), + }); + (DocumentUpdateModel.find as any).mockReturnValue(mockFind([])); + + const store = new MongoDBStore(); + const res = await store.getHydrationRange("doc-1", "room-did", { sinceSeq: 5 }); + + // Client is BEHIND the snapshot's floor → snapshot is NOT suppressed, and the tail resumes after the floor (10), not sinceSeq (5). + expect(res.snapshot?.seq).toBe(12); + expect(DocumentUpdateModel.find).toHaveBeenCalledWith( + expect.objectContaining({ seq: { $gt: 10 } }) + ); + }); + + it("stops at the byte budget: returns hasMore + nextSeq at the last admitted update", async () => { + const { DocumentUpdateModel } = await import("../../database/models"); + (DocumentUpdateModel.findOne as any).mockReturnValue({ + sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }), // no snapshot + }); + const big = "x".repeat(100); + (DocumentUpdateModel.find as any).mockReturnValue(mockFind([ + { _id: "u1", documentId: "doc-1", seq: 1, data: big, updateType: "yjs_update", sessionDid: "room-did" }, + { _id: "u2", documentId: "doc-1", seq: 2, data: big, updateType: "yjs_update", sessionDid: "room-did" }, + ])); + + const store = new MongoDBStore(); + const res = await store.getHydrationRange("doc-1", "room-did", { maxBytes: 150 }); + + // u1 (100B) admitted; u2 pushes total to 200 > 150 → break. First row always admitted (no stall). + expect(res.updates.map((u) => u.seq)).toEqual([1]); + expect(res.hasMore).toBe(true); + expect(res.nextSeq).toBe(1); + }); +}); + +describe("purgeDocument", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("wipes all five collections for the documentId", async () => { + const { DocumentUpdateModel, DocumentCommitModel, DocumentMetaModel, SessionModel, CounterModel } = + await import("../../database/models"); + + const store = new MongoDBStore(); + await store.purgeDocument("doc-1"); + + expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(DocumentCommitModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(DocumentMetaModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); + expect(SessionModel.deleteMany).toHaveBeenCalledWith({ documentId: "doc-1" }); + expect(CounterModel.deleteOne).toHaveBeenCalledWith({ _id: "doc-1" }); + }); +}); + +describe("collectOrphans", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("purges a terminated ddoc stream with no editLock and no snapshot", async () => { + const { SessionModel, DocumentMetaModel, DocumentUpdateModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "orphan", sessionDid: "s" }]) }); + (DocumentMetaModel.findById as any) = vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }); // no editLock + (DocumentUpdateModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); // no snapshot + + const store = new MongoDBStore(); + const n = await store.collectOrphans(60_000); + + expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ documentId: "orphan" }); + expect(n).toBe(1); + }); + + it("keeps a terminated stream that still has an editLock (real draft)", async () => { + const { SessionModel, DocumentMetaModel, DocumentUpdateModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "draft", sessionDid: "s" }]) }); + (DocumentMetaModel.findById as any) = vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ editLock: "el" }) }); + (DocumentUpdateModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }) }); + + const store = new MongoDBStore(); + const n = await store.collectOrphans(60_000); + expect(DocumentUpdateModel.deleteMany).not.toHaveBeenCalled(); + expect(n).toBe(0); + }); + + it("keeps a terminated stream that has no editLock but still has a snapshot (hydration base)", async () => { + const { SessionModel, DocumentMetaModel, DocumentUpdateModel, CounterModel } = await import("../../database/models"); + (SessionModel.find as any).mockReturnValue({ lean: vi.fn().mockResolvedValue([{ documentId: "snapdraft", sessionDid: "s" }]) }); + (DocumentMetaModel.findById as any) = vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(null) }); // no editLock + (DocumentUpdateModel.findOne as any).mockReturnValue({ sort: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue({ _id: "snap1", documentId: "snapdraft", seq: 5 }) }) }); // snapshot exists + + const store = new MongoDBStore(); + const n = await store.collectOrphans(60_000); + + expect(DocumentUpdateModel.deleteMany).not.toHaveBeenCalled(); + expect(SessionModel.deleteMany).not.toHaveBeenCalled(); + expect(CounterModel.deleteOne).not.toHaveBeenCalled(); + expect(n).toBe(0); + }); +}); + +describe("markUpdatesAsCommitted scoping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("scopes the update to the documentId (no cross-doc commit-flip)", async () => { + const { DocumentUpdateModel } = await import("../../database/models"); + const store = new MongoDBStore(); + await store.markUpdatesAsCommitted(["u1", "u2"], "cid-1", "doc-1"); + expect(DocumentUpdateModel.updateMany).toHaveBeenCalledWith( + { _id: { $in: ["u1", "u2"] }, documentId: "doc-1" }, + { committed: true, commitCid: "cid-1" } + ); + }); +}); diff --git a/src/tests/services/owner-op-routes.test.ts b/src/tests/services/owner-op-routes.test.ts new file mode 100644 index 0000000..1f239d9 --- /dev/null +++ b/src/tests/services/owner-op-routes.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createCollabJoinEnabledHandler, createListMyDocumentsHandler, createDeleteDocumentHandler } from "../../services/owner-op-routes"; + +function res() { + const r: any = {}; + r.status = vi.fn(() => r); + r.json = vi.fn(() => r); + return r; +} + +describe("POST collab-join-enabled", () => { + let deps: any, io: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { getSession: vi.fn(), setCollabJoinEnabled: vi.fn() }, + }; + io = { in: vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([]) })) }; + }); + + it("403s when verifyOwnerOp rejects", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createCollabJoinEnabledHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: true } } as any, r + ); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.sessionManager.setCollabJoinEnabled).not.toHaveBeenCalled(); + }); + + it("sets the flag for the two-proof owner and force-drops non-owner sockets on disable", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const ownerSock: any = { data: { role: "owner" }, disconnect: vi.fn() }; + const editorSock: any = { data: { role: "editor" }, disconnect: vi.fn() }; + io.in = vi.fn(() => ({ fetchSockets: vi.fn().mockResolvedValue([ownerSock, editorSock]) })); + const r = res(); + + await createCollabJoinEnabledHandler(deps, io)( + { params: { documentId: "doc-1" }, body: { sessionDid: "s", enabled: false } } as any, r + ); + + expect(deps.sessionManager.setCollabJoinEnabled).toHaveBeenCalledWith("doc-1", "s", false); + expect(editorSock.disconnect).toHaveBeenCalledWith(true); + expect(ownerSock.disconnect).not.toHaveBeenCalled(); + expect(r.status).toHaveBeenCalledWith(200); + }); +}); + +describe("POST /list-my-documents", () => { + let deps: any; + beforeEach(() => { + vi.clearAllMocks(); + deps = { + authService: { verifyIdentityToken: vi.fn(), verifyOwnerToken: vi.fn() }, + mongodbStore: { listDocumentsForOwner: vi.fn().mockResolvedValue([{ documentId: "d1", editLock: "el", title: "t" }]) }, + }; + }); + + it("returns docs bound to the proven identity signingDid", async () => { + deps.authService.verifyIdentityToken.mockResolvedValue("did:key:zOwner"); + const r = res(); + await createListMyDocumentsHandler(deps)( + { body: { identityToken: "it", identityContractAddress: "0xI", portalAddress: "0xP" } } as any, r + ); + expect(deps.mongodbStore.listDocumentsForOwner).toHaveBeenCalledWith({ ownerIdentityDid: "did:key:zOwner" }); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("401s when the fields are missing (short-circuits before verifyIdentityToken)", async () => { + deps.authService.verifyIdentityToken.mockResolvedValue(null); + deps.authService.verifyOwnerToken.mockResolvedValue(null); + const r = res(); + await createListMyDocumentsHandler(deps)({ body: {} } as any, r); + expect(r.status).toHaveBeenCalledWith(401); + }); + + it("401s when all fields are present but verifyIdentityToken resolves null (signingDid branch)", async () => { + deps.authService.verifyIdentityToken.mockResolvedValue(null); + const r = res(); + await createListMyDocumentsHandler(deps)( + { body: { identityToken: "it", identityContractAddress: "0xI", portalAddress: "0xP" } } as any, r + ); + expect(r.status).toHaveBeenCalledWith(401); + expect(deps.mongodbStore.listDocumentsForOwner).not.toHaveBeenCalled(); + }); +}); + +describe("DELETE /documents/:id", () => { + let deps: any; + beforeEach(() => { + deps = { + authService: { verifyOwnerOp: vi.fn() }, + sessionManager: { getSession: vi.fn() }, + mongodbStore: { purgeDocument: vi.fn() }, + }; + }); + + it("403s when there is no owner-of-record", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: null, ownerIdentityDid: null }); + const r = res(); + await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + }); + + it("purges everything for the two-proof owner", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(true); + const r = res(); + await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); + expect(deps.mongodbStore.purgeDocument).toHaveBeenCalledWith("d"); + expect(r.status).toHaveBeenCalledWith(200); + }); + + it("403s when there IS an owner-of-record but verifyOwnerOp rejects (no crypto proof)", async () => { + deps.sessionManager.getSession.mockResolvedValue({ sessionDid: "s", ownerDid: "od", ownerIdentityDid: "oid" }); + deps.authService.verifyOwnerOp.mockResolvedValue(false); + const r = res(); + await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); + expect(r.status).toHaveBeenCalledWith(403); + expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + }); + + it("404s when there is no session for the document", async () => { + deps.sessionManager.getSession.mockResolvedValue(undefined); + const r = res(); + await createDeleteDocumentHandler(deps)({ params: { documentId: "d" }, body: { sessionDid: "s" } } as any, r); + expect(r.status).toHaveBeenCalledWith(404); + expect(deps.mongodbStore.purgeDocument).not.toHaveBeenCalled(); + }); +}); diff --git a/src/tests/services/published-reconciler.test.ts b/src/tests/services/published-reconciler.test.ts new file mode 100644 index 0000000..1ca7902 --- /dev/null +++ b/src/tests/services/published-reconciler.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi } from "vitest"; +import { reconcilePublishedDocuments } from "../../services/published-reconciler"; + +const makeStore = (refs: any[]) => ({ + listUnpublishedMetaRefs: vi.fn().mockResolvedValue(refs), + markDocumentsPublished: vi.fn().mockResolvedValue(undefined), +}); + +describe("reconcilePublishedDocuments", () => { + it("marks exactly the published subset and reports counts", async () => { + const refs = [ + { documentId: "d1", portalAddress: "0xP" }, + { documentId: "d2", portalAddress: "0xP" }, + { documentId: "d3", portalAddress: "0xP" }, + ]; + const store = makeStore(refs); + const resolve = vi.fn().mockResolvedValue(new Set(["d1", "d3"])); + + const out = await reconcilePublishedDocuments({ + mongodbStore: store as any, + resolvePublishedDocumentIds: resolve, + batchSize: 500, + }); + + expect(store.listUnpublishedMetaRefs).toHaveBeenCalledWith(500); + expect(resolve).toHaveBeenCalledWith(refs); + expect(store.markDocumentsPublished).toHaveBeenCalledWith(["d1", "d3"]); + expect(out).toEqual({ scanned: 3, published: 2 }); + }); + + it("no-ops when there are no candidates", async () => { + const store = makeStore([]); + const resolve = vi.fn(); + const out = await reconcilePublishedDocuments({ + mongodbStore: store as any, + resolvePublishedDocumentIds: resolve, + batchSize: 500, + }); + expect(resolve).not.toHaveBeenCalled(); + expect(store.markDocumentsPublished).not.toHaveBeenCalled(); + expect(out).toEqual({ scanned: 0, published: 0 }); + }); + + it("does not call markDocumentsPublished when nothing resolved published", async () => { + const store = makeStore([{ documentId: "d1", portalAddress: "0xP" }]); + const resolve = vi.fn().mockResolvedValue(new Set()); + const out = await reconcilePublishedDocuments({ + mongodbStore: store as any, + resolvePublishedDocumentIds: resolve, + batchSize: 500, + }); + expect(store.markDocumentsPublished).not.toHaveBeenCalled(); + expect(out).toEqual({ scanned: 1, published: 0 }); + }); +}); diff --git a/src/tests/services/session-manager.test.ts b/src/tests/services/session-manager.test.ts index 50471f5..b11026c 100644 --- a/src/tests/services/session-manager.test.ts +++ b/src/tests/services/session-manager.test.ts @@ -148,7 +148,7 @@ describe("SessionManager", () => { // terminateSession it("removes the session from memory and cleans up all related data in the database", async () => { - await sessionManager.terminateSession("doc-1", "session-1"); + await sessionManager.terminateSession("doc-1", "session-1", "dsheet"); expect(sessionManager.getLocalClients("doc-1", "session-1")).toBeUndefined(); expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( @@ -165,12 +165,36 @@ describe("SessionManager", () => { }); }); + it("ddoc terminate marks the session terminated but keeps updates + commits", async () => { + await sessionManager.terminateSession("doc-1", "session-1", "ddoc"); + + expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( + { documentId: "doc-1", sessionDid: "session-1" }, + { state: "terminated", roomInfo: null } + ); + expect(DocumentUpdateModel.deleteMany).not.toHaveBeenCalled(); + expect(DocumentCommitModel.deleteMany).not.toHaveBeenCalled(); + }); + + it("dsheet terminate keeps the cascade delete", async () => { + await sessionManager.terminateSession("doc-1", "session-1", "dsheet"); + + expect(DocumentUpdateModel.deleteMany).toHaveBeenCalledWith({ + documentId: "doc-1", + sessionDid: "session-1", + }); + expect(DocumentCommitModel.deleteMany).toHaveBeenCalledWith({ + documentId: "doc-1", + sessionDid: "session-1", + }); + }); + // getOtherActiveSessions it("returns other active sessions from the database excluding the given sessionDid", async () => { vi.mocked(SessionModel.find).mockResolvedValue([ - { documentId: "doc-1", sessionDid: "other-session-1" }, - { documentId: "doc-1", sessionDid: "other-session-2" }, + { documentId: "doc-1", sessionDid: "other-session-1", appType: "ddoc" }, + { documentId: "doc-1", sessionDid: "other-session-2", appType: "dsheet" }, ] as any); const sessions = await sessionManager.getOtherActiveSessions("doc-1", "owner-did", "session-1"); @@ -182,8 +206,8 @@ describe("SessionManager", () => { sessionDid: { $ne: "session-1" }, }); expect(sessions).toEqual([ - { documentId: "doc-1", sessionDid: "other-session-1" }, - { documentId: "doc-1", sessionDid: "other-session-2" }, + { documentId: "doc-1", sessionDid: "other-session-1", appType: "ddoc" }, + { documentId: "doc-1", sessionDid: "other-session-2", appType: "dsheet" }, ]); }); @@ -252,8 +276,8 @@ describe("SessionManager", () => { }); expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( - { documentId: "doc-2", sessionDid: "session-2", ownerDid: "owner-did" }, - expect.objectContaining({ appType: "dsheet" }), + { documentId: "doc-2", sessionDid: "session-2" }, + expect.objectContaining({ $set: expect.objectContaining({ appType: "dsheet" }) }), { upsert: true, new: true } ); const session = await freshManager.getSession("doc-2", "session-2"); @@ -272,14 +296,55 @@ describe("SessionManager", () => { }); expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( - { documentId: "doc-3", sessionDid: "session-3", ownerDid: "owner-did" }, - expect.objectContaining({ appType: "ddoc" }), + { documentId: "doc-3", sessionDid: "session-3" }, + expect.objectContaining({ $set: expect.objectContaining({ appType: "ddoc" }) }), + { upsert: true, new: true } + ); + + await freshManager.destroy(); + }); + + // R3 owner-identity binding + + it("binds ownerIdentityDid + portalAddress via $setOnInsert (immutable) and mutable fields via $set", async () => { + const freshManager = new SessionManager(); + + await freshManager.createSession({ + documentId: "doc-bind", sessionDid: "sess-bind", ownerDid: "owner-did", + ownerIdentityDid: "did:identity:owner", portalAddress: "0xPortal", + roomInfo: "room", appType: "ddoc", collabJoinEnabled: false, + }); + + // Asserts the correct PRIMITIVE ($setOnInsert). First-writer-immutability itself is DB-provided. + expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( + { documentId: "doc-bind", sessionDid: "sess-bind" }, + { + $setOnInsert: { + ownerDid: "owner-did", + ownerIdentityDid: "did:identity:owner", + portalAddress: "0xPortal", + collabJoinEnabled: false, + }, + $set: { state: "active", roomInfo: "room", appType: "ddoc" }, + }, { upsert: true, new: true } ); await freshManager.destroy(); }); + // setCollabJoinEnabled + + it("setCollabJoinEnabled updates the DB and the in-memory session", async () => { + await sessionManager.setCollabJoinEnabled("doc-1", "session-1", true); + expect(SessionModel.findOneAndUpdate).toHaveBeenCalledWith( + { documentId: "doc-1", sessionDid: "session-1" }, + { collabJoinEnabled: true } + ); + const session = await sessionManager.getSession("doc-1", "session-1"); + expect(session?.collabJoinEnabled).toBe(true); + }); + it("hydrates appType from the database on fallback", async () => { const freshManager = new SessionManager(); vi.mocked(SessionModel.findOne).mockResolvedValue({ diff --git a/src/tests/services/socket-handlers.handleAuth.test.ts b/src/tests/services/socket-handlers.handleAuth.test.ts index f41f912..fdf39b8 100644 --- a/src/tests/services/socket-handlers.handleAuth.test.ts +++ b/src/tests/services/socket-handlers.handleAuth.test.ts @@ -55,6 +55,7 @@ describe("handleAuth", () => { createSession: vi.fn(), updateRoomInfo: vi.fn(), addClientToSession: vi.fn(), + getCollabJoinEnabled: vi.fn(), }; const fakeMongoDBStore = {} as any; @@ -169,6 +170,9 @@ describe("handleAuth", () => { documentId: fakeArgs.documentId, sessionDid: fakeArgs.sessionDid, ownerDid: "owner-did", + ownerIdentityDid: undefined, + portalAddress: fakeArgs.contractAddress, + collabJoinEnabled: false, roomInfo: fakeArgs.roomInfo, appType: "ddoc", }); @@ -225,8 +229,8 @@ describe("handleAuth", () => { const callback = vi.fn(); const otherSessions = [ - { documentId: fakeArgs.documentId, sessionDid: "old-session-1" }, - { documentId: fakeArgs.documentId, sessionDid: "old-session-2" }, + { documentId: fakeArgs.documentId, sessionDid: "old-session-1", appType: "ddoc" as const }, + { documentId: fakeArgs.documentId, sessionDid: "old-session-2", appType: "ddoc" as const }, ]; const oldRoomName1 = getRoomName(fakeArgs.documentId, otherSessions[0].sessionDid); @@ -282,7 +286,8 @@ describe("handleAuth", () => { expect(oldSocket1.leave).toHaveBeenCalledWith(oldRoomName1); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( otherSessions[0].documentId, - otherSessions[0].sessionDid + otherSessions[0].sessionDid, + "ddoc" ); // Second other session (old-session-2) @@ -302,7 +307,8 @@ describe("handleAuth", () => { expect(oldSocket2.leave).toHaveBeenCalledWith(oldRoomName2); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( otherSessions[1].documentId, - otherSessions[1].sessionDid + otherSessions[1].sessionDid, + "ddoc" ); // Aggregate call-count assertions after verifying per-iteration sequence @@ -341,6 +347,49 @@ describe("handleAuth", () => { }); }); + it("terminates other sessions using their OWN appType, not the new connection's claimed appType", async () => { + const fakeIO = createFakeIO(); + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + const fakeArgs: AuthArgs = { + documentId: "doc-1", + sessionDid: "session-1", + collaborationToken: "collab-token", + ownerToken: "owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + roomInfo: "room-info", + // The new connection claims "dsheet" for this document... + appType: "dsheet", + }; + const callback = vi.fn(); + + // ...but the session being terminated is actually a "ddoc" session. + const otherSession = { + documentId: fakeArgs.documentId, + sessionDid: "old-session-1", + appType: "ddoc" as const, + }; + + fakeSessionManager.getSession.mockResolvedValue(undefined); + fakeAuthService.verifyOwnerToken.mockResolvedValue("owner-did"); + fakeSessionManager.getOtherActiveSessions.mockResolvedValue([otherSession]); + fakeSessionManager.terminateSession.mockResolvedValue(undefined); + fakeSessionManager.createSession.mockResolvedValue(undefined); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + // terminateSession must use the terminated session's own appType ("ddoc"), never + // the claimed appType of the new connection ("dsheet") — otherwise a dsheet + // re-auth would cascade-delete a ddoc's durable rows. + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + otherSession.documentId, + otherSession.sessionDid, + "ddoc" + ); + }); + it("joins an existing session as editor when collaboration token is valid", async () => { const fakeIO = createFakeIO(); const fakeBroadcastOperator = { emit: vi.fn() }; @@ -682,6 +731,9 @@ describe("handleAuth", () => { documentId: fakeArgs.documentId, sessionDid: fakeArgs.sessionDid, ownerDid: "owner-did", + ownerIdentityDid: undefined, + portalAddress: fakeArgs.contractAddress, + collabJoinEnabled: false, roomInfo: fakeArgs.roomInfo, appType: "dsheet", }); @@ -828,5 +880,51 @@ describe("handleAuth", () => { }, }); }); + + it("rejects a non-owner join when collabJoinEnabled is explicitly false", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const fakeArgs: AuthArgs = { documentId: "doc-1", sessionDid: "session-1", collaborationToken: "collab-token" }; + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r" }); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(false); // fresh Mongo read + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(callback).toHaveBeenCalledWith({ status: false, statusCode: 403, error: "Link sharing is disabled for this document", errorCode: ErrorCode.JOIN_DISABLED }); + expect(fakeSocket.join).not.toHaveBeenCalled(); + expect(fakeSessionManager.addClientToSession).not.toHaveBeenCalled(); + }); + + it("admits a non-owner join when collabJoinEnabled is true", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const fakeArgs: AuthArgs = { documentId: "doc-1", sessionDid: "session-1", collaborationToken: "collab-token" }; + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r" }); + fakeSessionManager.getCollabJoinEnabled.mockResolvedValue(true); // fresh Mongo read + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSocket.join).toHaveBeenCalled(); + }); + + // Invariant #3 — the gate must NOT fire for dsheet, even with collabJoinEnabled:false. + it("admits a dsheet editor even when collabJoinEnabled is false (ddoc-only gate)", async () => { + const fakeIO = createFakeIO(); + const fakeSocket = createFakeSocket({ emit: vi.fn() }); + const fakeArgs: AuthArgs = { documentId: "doc-1", sessionDid: "session-1", collaborationToken: "collab-token", appType: "dsheet" }; + const callback = vi.fn(); + fakeSessionManager.getSession.mockResolvedValue({ sessionDid: "session-1", ownerDid: "owner-did", roomInfo: "r", appType: "dsheet", collabJoinEnabled: false }); + fakeAuthService.verifyCollaborationToken.mockResolvedValue("user-did"); + fakeSessionManager.addClientToSession.mockResolvedValue(undefined); + + await handleAuth(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSocket.join).toHaveBeenCalled(); + }); }); diff --git a/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts new file mode 100644 index 0000000..ebc7970 --- /dev/null +++ b/src/tests/services/socket-handlers.handleSetDocumentMeta.test.ts @@ -0,0 +1,39 @@ +import { describe, vi, it, expect, beforeEach } from "vitest"; +import { handleSetDocumentMeta } from "../../services/socket-handlers"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import type { AppSocket } from "../../types"; +import { ErrorCode } from "../../types"; + +function fakeSocket(role: "owner" | "editor"): AppSocket { + return { id: "s1", data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc" } } as unknown as AppSocket; +} + +describe("handleSetDocumentMeta", () => { + const upsertDocumentMeta = vi.fn(); + const getRuntimeSession = vi.fn().mockResolvedValue({ sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", portalAddress: "0xP" }); + const deps: SocketHandlerDeps = { + authService: {} as any, + sessionManager: { getRuntimeSession } as any, + mongodbStore: { upsertDocumentMeta } as any, + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("rejects a non-owner", async () => { + const cb = vi.fn(); + await handleSetDocumentMeta(deps, fakeSocket("editor"), { editLock: "el", title: "t" }, cb); + expect(upsertDocumentMeta).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 403, errorCode: ErrorCode.COMMIT_UNAUTHORIZED })); + }); + + it("persists editLock + title for the owner", async () => { + const cb = vi.fn(); + await handleSetDocumentMeta(deps, fakeSocket("owner"), { editLock: "el", title: "t" }, cb); + expect(upsertDocumentMeta).toHaveBeenCalledWith(expect.objectContaining({ + documentId: "doc-1", sessionDid: "room-did", ownerDid: "od", ownerIdentityDid: "oid", editLock: "el", title: "t", + })); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); + }); +}); diff --git a/src/tests/services/socket-handlers.handleSnapshot.test.ts b/src/tests/services/socket-handlers.handleSnapshot.test.ts new file mode 100644 index 0000000..86878b5 --- /dev/null +++ b/src/tests/services/socket-handlers.handleSnapshot.test.ts @@ -0,0 +1,51 @@ +import { describe, vi, it, expect, beforeEach } from "vitest"; +import { handleSnapshot } from "../../services/socket-handlers"; +import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; +import type { AppSocket } from "../../types"; +import { ErrorCode } from "../../types"; + +function fakeSocket(role: "owner" | "editor"): AppSocket { + return { + id: "socket-1", + data: { authenticated: true, documentId: "doc-1", sessionDid: "room-did", role, appType: "ddoc" }, + } as unknown as AppSocket; +} + +describe("handleSnapshot", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createSnapshot = vi.fn(); + const verifyCollaborationToken = vi.fn().mockResolvedValue(true); + const getRuntimeSession = vi.fn().mockResolvedValue({ sessionDid: "room-did" }); + const deps: SocketHandlerDeps = { + authService: { verifyCollaborationToken } as any, + sessionManager: { getRuntimeSession } as any, + mongodbStore: { createSnapshot } as any, + }; + + it("rejects a non-owner with 403", async () => { + const cb = vi.fn(); + await handleSnapshot(deps, fakeSocket("editor"), { data: "ct", collaborationToken: "t", floorSeq: 1 }, cb); + expect(createSnapshot).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, statusCode: 403, errorCode: ErrorCode.COMMIT_UNAUTHORIZED })); + }); + + it("persists an owner snapshot and acks", async () => { + createSnapshot.mockResolvedValue({ id: "s1", documentId: "doc-1", seq: 9 }); + const cb = vi.fn(); + await handleSnapshot(deps, fakeSocket("owner"), { data: "ct", collaborationToken: "t", publishedMarker: "blk-1", floorSeq: 4 }, cb); + expect(createSnapshot).toHaveBeenCalledWith(expect.objectContaining({ documentId: "doc-1", data: "ct", updateType: "snapshot", sessionDid: "room-did", publishedMarker: "blk-1", floorSeq: 4 })); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: true, statusCode: 200 })); + }); + + it("rejects an owner snapshot missing a valid floorSeq with 400", async () => { + const cb = vi.fn(); + // floorSeq omitted — a snapshot with no proven floor would let hydration serve + // seq > snapshot.seq and orphan a concurrent writer's update, so it must be rejected. + await handleSnapshot(deps, fakeSocket("owner"), { data: "ct", collaborationToken: "t" } as any, cb); + expect(createSnapshot).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(expect.objectContaining({ status: false, statusCode: 400 })); + }); +}); diff --git a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts index fa08855..7efcc0f 100644 --- a/src/tests/services/socket-handlers.handleUpdateHistory.test.ts +++ b/src/tests/services/socket-handlers.handleUpdateHistory.test.ts @@ -1,7 +1,7 @@ import { describe, vi, it, expect, beforeEach } from "vitest"; import { handleUpdateHistory } from "../../services/socket-handlers"; import type { SocketHandlerDeps } from "../../services/socket-handlers.deps"; -import type { AppSocket, DocumentUpdate, UpdateHistoryArgs } from "../../types"; +import type { AppSocket, UpdateHistoryArgs } from "../../types"; import { ErrorCode } from "../../types"; function createFakeSocket( @@ -34,10 +34,7 @@ describe("updateHistory", () => { vi.clearAllMocks(); }); - const fakeMongodbStore = { - getUpdatesByDocument: vi.fn(), - countUpdatesByDocument: vi.fn(), - }; + const fakeMongodbStore = { getHydrationRange: vi.fn() }; const deps: SocketHandlerDeps = { authService: {} as any, @@ -94,105 +91,17 @@ describe("updateHistory", () => { }); }); - it("returns update history successfully, with fallback argument values", async () => { - const fakeSocket: AppSocket = createFakeSocket(undefined, { - documentId: "test-document-id", - }); - const fakeArgs: UpdateHistoryArgs = { - documentId: "test-document-id", - }; - const fakeCallback = vi.fn(); - const fakeResponse: DocumentUpdate[] = []; - const documentId = fakeArgs.documentId || fakeSocket.data.documentId; - - fakeMongodbStore.getUpdatesByDocument.mockResolvedValue(fakeResponse); - fakeMongodbStore.countUpdatesByDocument.mockResolvedValue(fakeResponse.length); + it("serves snapshot + tail as an ordered history array", async () => { + const fakeSocket = createFakeSocket(undefined, { documentId: "doc-1" }); + const snapshot = { id: "s1", documentId: "doc-1", seq: 10, data: "ct", updateType: "snapshot", committed: false, commitCid: null, createdAt: 1, sessionDid: "test-session-did" }; + const updates = [{ id: "u11", documentId: "doc-1", seq: 11, data: "ct", updateType: "yjs_update", committed: false, commitCid: null, createdAt: 2, sessionDid: "test-session-did" }]; + fakeMongodbStore.getHydrationRange.mockResolvedValue({ snapshot, updates, nextSeq: null, hasMore: false }); + const cb = vi.fn(); - await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); - - expect(fakeMongodbStore.getUpdatesByDocument).toHaveBeenCalledWith( - { - documentId, - sessionDid: fakeSocket.data.sessionDid, - }, - { - committed: undefined, - } - ); - expect(fakeMongodbStore.countUpdatesByDocument).toHaveBeenCalledWith( - { - documentId, - sessionDid: fakeSocket.data.sessionDid, - }, - { committed: undefined }, - ); + await handleUpdateHistory(deps, fakeSocket, { documentId: "doc-1" }, cb); - expect(fakeCallback).toHaveBeenCalledWith({ - status: true, - statusCode: 200, - data: { - history: [], - total: fakeResponse.length, - }, - }); - }); - - it("returns update history successfully with proper argument values set", async () => { - const fakeSocket: AppSocket = createFakeSocket(); - const fakeArgs: UpdateHistoryArgs = { - documentId: "test-document-id", - limit: 1000, - offset: 0, - filters: { - committed: false, - }, - sort: "desc", - }; - const fakeCallback = vi.fn(); - const fakeResponse: DocumentUpdate[] = [ - { - id: "test-id", - documentId: "test-document-id", - data: "test-encrypted-data", - updateType: "yjs_update", - committed: false, - commitCid: null, - createdAt: 1772181495470, - sessionDid: "test-session-did", - }, - ]; - const documentId = fakeArgs.documentId || fakeSocket.data.documentId; - - fakeMongodbStore.getUpdatesByDocument.mockResolvedValue(fakeResponse); - fakeMongodbStore.countUpdatesByDocument.mockResolvedValue(fakeResponse.length); - - await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); - - expect(fakeMongodbStore.getUpdatesByDocument).toHaveBeenCalledWith( - { - documentId, - sessionDid: fakeSocket.data.sessionDid, - }, - { - committed: fakeArgs.filters?.committed, - } - ); - expect(fakeMongodbStore.countUpdatesByDocument).toHaveBeenCalledWith( - { - documentId, - sessionDid: fakeSocket.data.sessionDid, - }, - { committed: fakeArgs.filters?.committed }, - ); - - expect(fakeCallback).toHaveBeenCalledWith({ - status: true, - statusCode: 200, - data: { - history: fakeResponse, - total: fakeResponse.length, - }, - }); + expect(fakeMongodbStore.getHydrationRange).toHaveBeenCalledWith("doc-1", "test-session-did", { sinceSeq: undefined }); + expect(cb).toHaveBeenCalledWith({ status: true, statusCode: 200, data: { history: [snapshot, ...updates], total: 2, snapshot, nextSeq: null, hasMore: false } }); }); it("returns 500 due to db operation error", async () => { @@ -207,23 +116,11 @@ describe("updateHistory", () => { sort: "desc", }; const fakeCallback = vi.fn(); - const documentId = fakeArgs.documentId || fakeSocket.data.documentId; - fakeMongodbStore.getUpdatesByDocument.mockRejectedValue(new Error("db error")); - fakeMongodbStore.countUpdatesByDocument.mockResolvedValue(0); + fakeMongodbStore.getHydrationRange.mockRejectedValue(new Error("db error")); await handleUpdateHistory(deps, fakeSocket, fakeArgs, fakeCallback); - expect(fakeMongodbStore.getUpdatesByDocument).toHaveBeenCalledWith( - { - documentId, - sessionDid: fakeSocket.data.sessionDid, - }, - { - committed: false, - } - ); - expect(fakeCallback).toHaveBeenCalledWith({ status: false, statusCode: 500, diff --git a/src/tests/services/socket-handlers.terminateSession.test.ts b/src/tests/services/socket-handlers.terminateSession.test.ts index a030256..a6730b8 100644 --- a/src/tests/services/socket-handlers.terminateSession.test.ts +++ b/src/tests/services/socket-handlers.terminateSession.test.ts @@ -28,6 +28,7 @@ function createFakeSocket(broadcastOperator?: { emit: ReturnType } documentId: "doc-1", sessionDid: "session-1", role: "owner" as const, + appType: "ddoc" as const, }, to: vi.fn(() => toReturn), } as unknown as AppSocket; @@ -210,6 +211,7 @@ describe("handleTerminateSession", () => { const fakeSessionResponse = { sessionDid: fakeArgs.sessionDid, ownerDid: "match-owner-did", + appType: "ddoc" as const, }; const ownerDidResponse = "match-owner-did"; @@ -245,7 +247,8 @@ describe("handleTerminateSession", () => { expect(fetchSocketsResponse[0].data.authenticated).toBe(false); expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( fakeArgs.documentId, - fakeSessionResponse.sessionDid + fakeSessionResponse.sessionDid, + "ddoc" ); const callbackResponse = { @@ -256,6 +259,55 @@ describe("handleTerminateSession", () => { expect(callback).toHaveBeenCalledWith(callbackResponse); }); + it("uses the TARGET session's appType, not the calling socket's appType, when terminating cross-appType", async () => { + // Regression test: a socket last-authenticated to a "dsheet" session must not be able to + // use its own socket.data.appType when terminating a DIFFERENT target session/document + // that is actually a "ddoc". The cascade delete must be scoped to the target session's + // own stored appType, not whatever the calling socket happens to be. + const fetchSocketsResponse = [ + { + id: "peer-1", + data: { authenticated: true }, + leave: vi.fn(), + }, + ]; + const fakeIO = createFakeIO(fetchSocketsResponse); + + const fakeBroadcastOperator = { emit: vi.fn() }; + const fakeSocket = createFakeSocket(fakeBroadcastOperator); + // Calling socket is authenticated as a "dsheet" session. + (fakeSocket.data as { appType: "ddoc" | "dsheet" }).appType = "dsheet"; + + const fakeArgs = { + documentId: "test-document-id", + sessionDid: "test-session-did", + ownerToken: "test-owner-token", + ownerAddress: "0x0000000000000000000000000000000000000001", + contractAddress: "0x0000000000000000000000000000000000000002", + }; + const callback = vi.fn(); + + // Target session (the one actually being terminated) is a "ddoc" session. + const fakeSessionResponse = { + sessionDid: fakeArgs.sessionDid, + ownerDid: "match-owner-did", + appType: "ddoc" as const, + }; + const ownerDidResponse = "match-owner-did"; + + fakeSessionManager.getSession.mockResolvedValue(fakeSessionResponse); + fakeAuthService.verifyOwnerToken.mockResolvedValue(ownerDidResponse); + fakeSessionManager.terminateSession.mockResolvedValue(undefined); + + await handleTerminateSession(deps, fakeIO, fakeSocket, fakeArgs, callback); + + expect(fakeSessionManager.terminateSession).toHaveBeenCalledWith( + fakeArgs.documentId, + fakeSessionResponse.sessionDid, + "ddoc" + ); + }); + it("returns 500 when an unexpected error occurs in terminate session handler", async () => { const fakeIO = createFakeIO(); const fakeSocket = createFakeSocket(); diff --git a/src/tests/utils/contract.published.test.ts b/src/tests/utils/contract.published.test.ts new file mode 100644 index 0000000..41ca0b9 --- /dev/null +++ b/src/tests/utils/contract.published.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { publicClient, resolvePublishedDocumentIds } from "../../utils/contract"; + +describe("resolvePublishedDocumentIds", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("returns [] for empty input without calling the chain", async () => { + const spy = vi.spyOn(publicClient, "multicall"); + const out = await resolvePublishedDocumentIds([]); + expect(out.size).toBe(0); + expect(spy).not.toHaveBeenCalled(); + }); + + it("marks published incl. the 0-indexed first file, excludes unpublished mapping-to-0", async () => { + // Phase 1: appFileIdToFileId -> fileId. A -> 0 (first file), B -> 3, C -> 0 (default; NOT published). + // Phase 2: files(fileId).appFileId (tuple index 0). files(0) belongs to A ("da"); files(3) to B ("db"). + const spy = vi + .spyOn(publicClient, "multicall") + .mockResolvedValueOnce([ + { status: "success", result: 0n }, + { status: "success", result: 3n }, + { status: "success", result: 0n }, + ] as any) + .mockResolvedValueOnce([ + { status: "success", result: ["da", 0, "m", "c", "g", 1n, "0x0"] }, + { status: "success", result: ["db", 0, "m", "c", "g", 1n, "0x0"] }, + { status: "success", result: ["da", 0, "m", "c", "g", 1n, "0x0"] }, + ] as any); + + const refs = [ + { documentId: "da", portalAddress: "0xP" }, + { documentId: "db", portalAddress: "0xP" }, + { documentId: "dc", portalAddress: "0xP" }, + ]; + const out = await resolvePublishedDocumentIds(refs); + + expect(spy).toHaveBeenCalledTimes(2); + expect([...out].sort()).toEqual(["da", "db"]); + }); + + it("treats a failed phase-1 sub-call as unpublished", async () => { + vi.spyOn(publicClient, "multicall") + .mockResolvedValueOnce([{ status: "failure", error: new Error("rpc") }] as any) + .mockResolvedValueOnce([] as any); + const out = await resolvePublishedDocumentIds([{ documentId: "dx", portalAddress: "0xP" }]); + expect(out.size).toBe(0); + }); +}); diff --git a/src/tests/utils/contract.test.ts b/src/tests/utils/contract.test.ts new file mode 100644 index 0000000..1d86421 --- /dev/null +++ b/src/tests/utils/contract.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import * as contract from "../../utils/contract"; + +describe("getIdentitySigningDid", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("reads signingDid from getIdentityModulePublicDetails at the given address", async () => { + const spy = vi.spyOn(contract.publicClient, "readContract").mockResolvedValue({ + salt: 1n, + signingDid: "did:key:zOwner", + accountPublicKey: "0x", + agentAddress: "0xabc", + } as any); + + const did = await contract.getIdentitySigningDid("0x000000000000000000000000000000000000dEaD"); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ + address: "0x000000000000000000000000000000000000dEaD", + functionName: "getIdentityModulePublicDetails", + }) + ); + expect(did).toBe("did:key:zOwner"); + }); + + it("returns null on a read failure", async () => { + vi.spyOn(contract.publicClient, "readContract").mockRejectedValue(new Error("revert")); + const did = await contract.getIdentitySigningDid("0x000000000000000000000000000000000000bEEf"); + expect(did).toBeNull(); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index d105d7c..e83756b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,44 +1,4 @@ import { Server, Socket } from "socket.io"; -import { WebSocket } from "ws"; - -// *************************************** -// Legacy WebSocket Types (backward compat) -// *************************************** - -export interface WebSocketMessage { - cmd?: string; - event?: string; - args: Record; - seqId: string; -} - -export interface WebSocketResponse { - status: boolean; - statusCode: number; - seqId: string | null; - is_handshake_response: boolean; - data?: Record; - error?: string; - err?: string; - err_detail?: Record | null; -} - -export interface WebSocketEvent { - type: string; - event_type: string; - event: { - data: any; - roomId: string; - }; -} - -export interface AuthenticatedWebSocket extends WebSocket { - documentId?: string; - sessionDid?: string; - role?: "owner" | "editor"; - authenticated?: boolean; - clientId?: string; -} // *************************************** // Domain Models (unchanged) @@ -61,6 +21,12 @@ export interface DocumentUpdate { createdAt: number; sessionDid: string; appType?: AppType; + seq?: number; + publishedMarker?: string | null; + // Snapshot rows only: the author's contiguous range-read floor. Hydration serves + // seq > floorSeq (NOT > the snapshot's own seq) so a concurrent writer's update that + // the snapshot author never applied is still re-served instead of orphaned. + floorSeq?: number | null; } export interface DocumentCommit { @@ -114,6 +80,7 @@ export enum ErrorCode { INVALID_ADDRESS = "INVALID_ADDRESS", NOT_AUTHENTICATED = "NOT_AUTHENTICATED", APP_MISMATCH = "APP_MISMATCH", + JOIN_DISABLED = "JOIN_DISABLED", DB_ERROR = "DB_ERROR", INTERNAL_ERROR = "INTERNAL_ERROR", } @@ -143,6 +110,7 @@ export interface AuthArgs { contractAddress?: string; roomInfo?: string; appType?: AppType; + ownerIdentityDid?: string; } export interface AuthResponseData { @@ -167,6 +135,22 @@ export interface DocumentUpdateResponseData { createdAt: number; } +export interface SnapshotArgs { + documentId?: string; + data: string; + collaborationToken: string; + publishedMarker?: string | null; + // The author's contiguous range-read floor at authorship time — the seq up to which + // this full-state snapshot is provably complete. Hydration cuts the tail here. + floorSeq: number; +} + +export interface DocumentMetaArgs { + documentId?: string; + editLock: string | null; + title: string | null; +} + export interface DocumentCommitArgs { documentId?: string; updates: string[]; @@ -196,6 +180,7 @@ export interface UpdateHistoryArgs { limit?: number; sort?: "asc" | "desc"; filters?: { committed?: boolean }; + sinceSeq?: number; } export interface PeersListArgs { @@ -224,6 +209,9 @@ export interface CommitHistoryResponseData { export interface UpdateHistoryResponseData { history: DocumentUpdate[]; total: number; + snapshot: DocumentUpdate | null; + nextSeq: number | null; + hasMore: boolean; } export interface PeersListResponseData { @@ -286,6 +274,8 @@ export interface ClientToServerEvents { "/documents/commit": ClientEventHandler; "/documents/commit/history": ClientEventHandler; "/documents/update/history": ClientEventHandler; + "/documents/snapshot": ClientEventHandler; + "/documents/meta": ClientEventHandler; "/documents/peers/list": ClientEventHandler; "/documents/awareness": ClientEventHandler; "/documents/terminate": ClientEventHandler; @@ -367,4 +357,11 @@ export interface ServerConfig { rpcURL: string; wsURL: string; nodeEnv: string; + publishReconcile: { + interval: string; + batchSize: number; + }; + agenda: { + concurrency: number; + }; } \ No newline at end of file diff --git a/src/utils/contract.ts b/src/utils/contract.ts index 5d4e814..7781969 100644 --- a/src/utils/contract.ts +++ b/src/utils/contract.ts @@ -1,6 +1,7 @@ import { createPublicClient, Hex, http } from "viem"; import { sepolia } from "viem/chains"; import { PORTAL_CONTRACT_ABI } from "../abi/portal-contract-abi"; +import { IDENTITY_MODULE_ABI } from "../abi/identity-module-abi"; import MemberCreds from "node-cache"; import { config } from "../config"; const cache = new MemberCreds({ @@ -88,3 +89,97 @@ export const getOwnerDid = async (contractAddress: Hex, collaboratorAddress: Hex return did; }; + +export const getIdentitySigningDid = async (identityContractAddress: Hex): Promise => { + const cacheKey = `identity-did-${identityContractAddress}`; + const cached = cache.get(cacheKey); + if (cached) return cached as string; + + try { + const details = (await publicClient.readContract({ + address: identityContractAddress, + abi: IDENTITY_MODULE_ABI, + functionName: "getIdentityModulePublicDetails", + })) as { salt: bigint; signingDid: string; accountPublicKey: Hex; agentAddress: Hex }; + + if (details?.signingDid) cache.set(cacheKey, details.signingDid); + return details?.signingDid ?? null; + } catch (error) { + console.error("Error reading identity signingDid:", error); + return null; + } +}; + +// App-key (FileverseApp) reads for publish detection. The shared PORTAL_CONTRACT_ABI +// is the legacy shape (files → [metadataIPFSHash, …], no appFileId / appFileIdToFileId), +// so the reconciler carries its own minimal app-key fragment. +const PUBLISHED_CHECK_ABI = [ + { + inputs: [{ internalType: "string", name: "", type: "string" }], + name: "appFileIdToFileId", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "", type: "uint256" }], + name: "files", + outputs: [ + { internalType: "string", name: "appFileId", type: "string" }, + { internalType: "enum FileverseApp.FileType", name: "fileType", type: "uint8" }, + { internalType: "string", name: "metadataIPFSHash", type: "string" }, + { internalType: "string", name: "contentIPFSHash", type: "string" }, + { internalType: "string", name: "gateIPFSHash", type: "string" }, + { internalType: "uint256", name: "version", type: "uint256" }, + { internalType: "address", name: "owner", type: "address" }, + ], + stateMutability: "view", + type: "function", + }, +] as const; + +// A doc is published iff files(appFileIdToFileId(ddocId)).appFileId === ddocId. +// fileIds are 0-indexed on-chain, so appFileIdToFileId==0 is ambiguous (the first +// file, or unset) — the files() read disambiguates. Two batched round-trips. +export const resolvePublishedDocumentIds = async ( + refs: Array<{ documentId: string; portalAddress: string }> +): Promise> => { + const published = new Set(); + if (refs.length === 0) return published; + + const phase1 = await publicClient.multicall({ + allowFailure: true, + contracts: refs.map((r) => ({ + address: r.portalAddress as Hex, + abi: PUBLISHED_CHECK_ABI, + functionName: "appFileIdToFileId", + args: [r.documentId], + })), + }); + + const resolved = refs + .map((ref, i) => ({ ref, res: phase1[i] })) + .filter((x) => x.res.status === "success") + .map((x) => ({ ref: x.ref, fileId: x.res.result as unknown as bigint })); + + if (resolved.length === 0) return published; + + const phase2 = await publicClient.multicall({ + allowFailure: true, + contracts: resolved.map((x) => ({ + address: x.ref.portalAddress as Hex, + abi: PUBLISHED_CHECK_ABI, + functionName: "files", + args: [x.fileId], + })), + }); + + resolved.forEach((x, i) => { + const res = phase2[i]; + if (res.status === "success" && (res.result as readonly unknown[])[0] === x.ref.documentId) { + published.add(x.ref.documentId); + } + }); + + return published; +}; diff --git a/tsup.config.ts b/tsup.config.ts index a6f8045..afff76a 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/cron/publishReconcile.ts"], format: ["esm"], target: "es2022", outDir: "dist",