From e6641f3169cf5cb467e57ae295191d986f219100 Mon Sep 17 00:00:00 2001 From: githoboman Date: Mon, 1 Jun 2026 06:13:07 +0100 Subject: [PATCH] feat: implement initial user entity and define Prisma schema for sybil resistance and wallet management --- package-lock.json | 75 ++++++-- prisma/schema.prisma | 69 ++++--- src/entities/user.entity.spec.ts | 83 +++++++++ src/entities/user.entity.ts | 29 +-- src/generated/client/internal/class.ts | 15 ++ .../worldcoin/worldcoin.service.spec.ts | 169 +++++++++++++++--- src/modules/users/entities/user.entity.ts | 15 +- 7 files changed, 350 insertions(+), 105 deletions(-) create mode 100644 src/entities/user.entity.spec.ts diff --git a/package-lock.json b/package-lock.json index c15f957..e7c6858 100644 --- a/package-lock.json +++ b/package-lock.json @@ -260,6 +260,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -846,7 +847,8 @@ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz", "integrity": "sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ==", "devOptional": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.0.20", @@ -2626,6 +2628,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -2796,6 +2799,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.14.tgz", "integrity": "sha512-IN/tlqd7Nl9gl6f0jsWEuOrQDaCI9vHzxv0fisHysfBQzfQIkqlv5A7w4Qge02BUQyczXT9HHPgHtWHCxhjRng==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2855,6 +2859,7 @@ "integrity": "sha512-7OXPPMoDr6z+5NkoQKu4hOhfjz/YYqM3bNilPqv1WVFWrzSmuNXxvhbX69YMmNmRYascPXiwESqf5jJdjKXEww==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2938,6 +2943,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.14.tgz", "integrity": "sha512-Fs+/j+mBSBSXErOQJ/YdUn/HqJGSJ4pGfiJyYOyz04l42uNVnqEakvu1kXLbxMabR6vd6/h9d6Bi4tso9p7o4Q==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.6", "express": "5.2.1", @@ -3637,6 +3643,7 @@ "integrity": "sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^13.0.5", @@ -3719,9 +3726,10 @@ "version": "1.15.11", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.11.tgz", "integrity": "sha512-iLmLTodbYxU39HhMPaMUooPwO/zqJWvsqkrXv1ZI38rMb048p6N7qtAtTp37sw9NzSrvH6oli8EdDygo09IZ/w==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" @@ -3761,6 +3769,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3777,6 +3786,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3793,6 +3803,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -3809,6 +3820,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3825,6 +3837,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3841,6 +3854,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3857,6 +3871,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3873,6 +3888,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3889,6 +3905,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3905,6 +3922,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -3918,14 +3936,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.25", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -4084,6 +4102,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -4245,6 +4264,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.11.tgz", "integrity": "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4414,6 +4434,7 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5264,6 +5285,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5359,6 +5381,7 @@ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5968,6 +5991,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6390,6 +6414,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -6456,13 +6481,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -6937,8 +6964,7 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", @@ -7487,6 +7513,7 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7547,6 +7574,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -7916,6 +7944,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9004,6 +9033,7 @@ "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9261,6 +9291,7 @@ "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.9.3.tgz", "integrity": "sha512-VI5tMCdeoxZWU5vjHWsiE/Su76JGhBvWF1MJnV9ZtGltHk9BmD48oDq8Tj8haZ85aceXZMxLNDQZRVo5QKNgXA==", "license": "MIT", + "peer": true, "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", @@ -9652,6 +9683,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -11383,6 +11415,7 @@ "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -11926,6 +11959,7 @@ "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", "license": "MIT", + "peer": true, "dependencies": { "passport-strategy": "1.x.x", "pause": "0.0.1", @@ -12071,6 +12105,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.11.0", "pg-pool": "^3.11.0", @@ -12180,6 +12215,7 @@ "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", "license": "MIT", + "peer": true, "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", @@ -12211,6 +12247,7 @@ "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", "license": "MIT", + "peer": true, "dependencies": { "get-caller-file": "^2.0.5", "pino": "^9.0.0", @@ -12505,6 +12542,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -12563,6 +12601,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.4.1", "@prisma/dev": "0.20.0", @@ -12945,7 +12984,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/regexp-to-ast": { "version": "0.5.0", @@ -13196,6 +13236,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -13257,8 +13298,7 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "devOptional": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/schema-utils": { "version": "3.3.0", @@ -13769,6 +13809,7 @@ "integrity": "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog==", "hasInstallScript": true, "license": "BSD-3-Clause", + "peer": true, "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", @@ -14316,6 +14357,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -14699,6 +14741,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -14871,6 +14914,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -15088,6 +15132,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15869,7 +15914,6 @@ "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^8.0.0" }, @@ -15888,7 +15932,6 @@ "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -15902,7 +15945,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -15917,7 +15959,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -15927,8 +15968,7 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/webpack/node_modules/schema-utils": { "version": "4.3.3", @@ -15936,7 +15976,6 @@ "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -16089,6 +16128,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -16223,6 +16263,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 71c3fc5..5577aaf 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -16,14 +16,19 @@ model User { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + + // Primary wallet address — canonical identifier for the user + walletAddress String @unique + reputation Int @default(0) // Sybil Resistance - worldcoinVerified Boolean @default(false) - sybilScores SybilScore[] + worldcoinVerified Boolean @default(false) + worldcoinVerifiedAt DateTime? - wallets Wallet[] + wallets Wallet[] + sybilScores SybilScore[] + worldIdVerifications WorldIdVerification[] } model Wallet { @@ -31,12 +36,12 @@ model Wallet { address String chain String linkedAt DateTime @default(now()) - - userId String - user User @relation(fields: [userId], references: [id]) + + userId String + user User @relation(fields: [userId], references: [id]) @@unique([address, chain]) - // We will enforce "one address -> one user" logic in the service layer + // We will enforce "one address -> one user" logic in the service layer // to handle multi-chain address reuse scenarios properly. } @@ -44,22 +49,24 @@ model SybilScore { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + // Component scores (0-1 normalized) - worldcoinScore Float @default(0.0) // Binary verification signal (0 or 1) - walletAgeScore Float @default(0.0) // Based on wallet age - stakingScore Float @default(0.0) // Historical participation in staking - accuracyScore Float @default(0.0) // Claim accuracy from verification history - + worldcoinScore Float @default(0.0) // Binary verification signal (0 or 1) + walletAgeScore Float @default(0.0) // Based on wallet age + stakingScore Float @default(0.0) // Historical participation in staking + accuracyScore Float @default(0.0) // Claim accuracy from verification history + // Final composite score (0-1) - compositeScore Float @default(0.0) // Final Sybil resistance score - + compositeScore Float @default(0.0) // Final Sybil resistance score + // Metadata - calculationDetails String? // JSON string for explainability - + calculationDetails String? // JSON string for explainability + + explanation SybilExplanation? + @@unique([userId, createdAt]) @@index([userId]) @@index([compositeScore]) @@ -72,20 +79,24 @@ model SybilExplanation { createdAt DateTime @default(now()) sybilScore SybilScore @relation(fields: [sybilScoreId], references: [id], onDelete: Cascade) +} + model WorldIdVerification { - id String @id @default(uuid()) - verifiedAt DateTime @default(now()) - - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - nullifierHash String @unique + id String @id @default(uuid()) + verifiedAt DateTime @default(now()) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + nullifierHash String @unique verificationLevel String worldcoinAppId String worldcoinAction String merkleRoot String? proof Json? - + @@index([userId]) @@index([nullifierHash]) + + @@map("world_id_verifications") } diff --git a/src/entities/user.entity.spec.ts b/src/entities/user.entity.spec.ts new file mode 100644 index 0000000..049f69f --- /dev/null +++ b/src/entities/user.entity.spec.ts @@ -0,0 +1,83 @@ +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { User } from './user.entity'; +import { UserEntity } from '../modules/users/entities/user.entity'; + +describe('User entity schema sync (BE-203)', () => { + it('canonical User entity and re-exported UserEntity should reference the same class', () => { + expect(UserEntity).toBe(User); + }); + + it('User entity maps to the "users" table', () => { + const tableMetadata = getMetadataArgsStorage().tables.find( + (t) => t.target === User, + ); + expect(tableMetadata?.name).toBe('users'); + }); + + describe('field coverage — TypeORM ↔ Prisma sync', () => { + let columnNames: string[]; + + beforeAll(() => { + columnNames = getMetadataArgsStorage() + .columns.filter((c) => c.target === User) + .map((c) => c.propertyName as string); + }); + + // Fields that must exist in both TypeORM entity and Prisma User model + const requiredFields = [ + 'id', + 'walletAddress', + 'reputation', + 'worldcoinVerified', + 'worldcoinVerifiedAt', + 'createdAt', + 'updatedAt', + ]; + + for (const field of requiredFields) { + it(`should declare column "${field}"`, () => { + expect(columnNames).toContain(field); + }); + } + + it('walletAddress should be marked unique', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'walletAddress', + ); + expect(col?.options?.unique).toBe(true); + }); + + it('worldcoinVerified should default to false', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'worldcoinVerified', + ); + expect(col?.options?.default).toBe(false); + }); + + it('worldcoinVerifiedAt should be nullable', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'worldcoinVerifiedAt', + ); + expect(col?.options?.nullable).toBe(true); + }); + + it('reputation should default to 0', () => { + const col = getMetadataArgsStorage().columns.find( + (c) => c.target === User && c.propertyName === 'reputation', + ); + expect(col?.options?.default).toBe(0); + }); + }); + + describe('relation coverage', () => { + it('User entity has a wallets OneToMany relation', () => { + const relations = getMetadataArgsStorage().relations.filter( + (r) => r.target === User, + ); + const walletsRelation = relations.find((r) => r.propertyName === 'wallets'); + expect(walletsRelation).toBeDefined(); + expect(walletsRelation?.relationType).toBe('one-to-many'); + }); + }); +}); diff --git a/src/entities/user.entity.ts b/src/entities/user.entity.ts index 50c8b26..d5b6ce0 100644 --- a/src/entities/user.entity.ts +++ b/src/entities/user.entity.ts @@ -8,40 +8,25 @@ import { Index, } from 'typeorm'; -/** - * User Entity - * - * Represents a verified user in the TruthBounty protocol. - * Users can link multiple wallets across different chains. - * Reputation is tracked to weight verification votes. - */ @Entity('users') export class User { @PrimaryGeneratedColumn('uuid') id: string; - /** - * Primary wallet address for the user - * This is the canonical identifier for the user - */ @Column({ unique: true }) @Index() walletAddress: string; - /** - * User's reputation score (0-100) - * Used to weight verification votes - * Increases with accurate verifications, decreases with inaccurate ones - */ @Column({ type: 'int', default: 0 }) reputation: number; - /** - * All wallets linked to this user across different chains - */ - @OneToMany('Wallet', 'user', { - cascade: true, - }) + @Column({ default: false }) + worldcoinVerified: boolean; + + @Column({ type: 'datetime', nullable: true }) + worldcoinVerifiedAt: Date | null; + + @OneToMany('Wallet', 'user', { cascade: true }) wallets: any[]; @CreateDateColumn() diff --git a/src/generated/client/internal/class.ts b/src/generated/client/internal/class.ts index e9333db..c617b22 100644 --- a/src/generated/client/internal/class.ts +++ b/src/generated/client/internal/class.ts @@ -213,6 +213,21 @@ export interface PrismaClient< * ``` */ get sybilScore(): Prisma.SybilScoreDelegate; + + /** + * `prisma.sybilExplanation`: Exposes CRUD operations for the **SybilExplanation** model. + */ + get sybilExplanation(): any; + + /** + * `prisma.worldIdVerification`: Exposes CRUD operations for the **WorldIdVerification** model. + * Example usage: + * ```ts + * // Fetch zero or more WorldIdVerifications + * const verifications = await prisma.worldIdVerification.findMany() + * ``` + */ + get worldIdVerification(): any; } export function getPrismaClientClass(): PrismaClientConstructor { diff --git a/src/identity/worldcoin/worldcoin.service.spec.ts b/src/identity/worldcoin/worldcoin.service.spec.ts index f890cb6..d95c5da 100644 --- a/src/identity/worldcoin/worldcoin.service.spec.ts +++ b/src/identity/worldcoin/worldcoin.service.spec.ts @@ -4,11 +4,21 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { WorldcoinService } from './worldcoin.service'; import { WorldIdVerification } from './entities/world-id-verification.entity'; +import { PrismaService } from '../../prisma/prisma.service'; +import { SybilResistanceService } from '../../sybil-resistance/sybil-resistance.service'; + +// Prevent native @libsql binaries from loading during unit tests +jest.mock('../../prisma/prisma.service', () => ({ PrismaService: jest.fn() })); +jest.mock('../../sybil-resistance/sybil-resistance.service', () => ({ + SybilResistanceService: jest.fn(), +})); describe('WorldcoinService', () => { let service: WorldcoinService; let repository: jest.Mocked>; let configService: jest.Mocked; + let prisma: any; + let sybilResistanceService: jest.Mocked; let fetchMock: jest.Mock; const mockRepository = { @@ -21,6 +31,21 @@ describe('WorldcoinService', () => { get: jest.fn(), }; + const mockPrisma = { + worldIdVerification: { + findUnique: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + }, + user: { + update: jest.fn(), + }, + }; + + const mockSybilResistanceService = { + recordSybilScore: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -33,21 +58,30 @@ describe('WorldcoinService', () => { provide: ConfigService, useValue: mockConfigService, }, + { + provide: PrismaService, + useValue: mockPrisma, + }, + { + provide: SybilResistanceService, + useValue: mockSybilResistanceService, + }, ], }).compile(); service = module.get(WorldcoinService); repository = module.get(getRepositoryToken(WorldIdVerification)); configService = module.get(ConfigService); + prisma = module.get(PrismaService); + sybilResistanceService = module.get(SybilResistanceService) as jest.Mocked; fetchMock = jest.fn(); global.fetch = fetchMock as typeof fetch; - // Setup default config values configService.get.mockImplementation((key: string) => { - const config = { - 'WORLDCOIN_APP_ID': 'test-app-id', - 'WORLDCOIN_ACTION': 'test-action', - 'WORLDCOIN_VERIFY_BASE_URL': 'https://developer.worldcoin.org/api/v2/verify', + const config: Record = { + WORLDCOIN_APP_ID: 'test-app-id', + WORLDCOIN_ACTION: 'test-action', + WORLDCOIN_VERIFY_BASE_URL: 'https://developer.worldcoin.org/api/v2/verify', }; return config[key]; }); @@ -59,6 +93,10 @@ describe('WorldcoinService', () => { }); }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(service).toBeDefined(); }); @@ -77,10 +115,9 @@ describe('WorldcoinService', () => { }; it('should successfully verify a valid Worldcoin proof', async () => { - // Mock no existing verification repository.findOne.mockResolvedValue(null); - - // Mock created verification + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + const mockVerification = { id: 'verification-id', userId, @@ -90,9 +127,12 @@ describe('WorldcoinService', () => { worldcoinAction: verifyDto.action, verifiedAt: new Date(), }; - + repository.create.mockReturnValue(mockVerification); repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); const result = await service.verifyProof(userId, verifyDto); @@ -101,9 +141,7 @@ describe('WorldcoinService', () => { 'https://developer.worldcoin.org/api/v2/verify/test-app-id', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...verifyDto.proof, action: verifyDto.action, @@ -125,21 +163,96 @@ describe('WorldcoinService', () => { }); }); - it('should throw ConflictException for duplicate nullifier hash', async () => { - // Mock existing verification + it('should write verification to both TypeORM and Prisma (dual-write sync)', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + + const mockVerification = { + id: 'ver-1', + userId, + nullifierHash: verifyDto.proof.nullifier_hash, + verifiedAt: new Date(), + } as unknown as WorldIdVerification; + + repository.create.mockReturnValue(mockVerification); + repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); + + await service.verifyProof(userId, verifyDto); + + // Verify both stores were written + expect(repository.save).toHaveBeenCalledTimes(1); + expect(prisma.worldIdVerification.create).toHaveBeenCalledTimes(1); + expect(prisma.worldIdVerification.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId, + nullifierHash: verifyDto.proof.nullifier_hash, + }), + }), + ); + }); + + it('should set worldcoinVerified=true on User via Prisma after successful verification', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + + const mockVerification = { id: 'ver-1', userId, nullifierHash: verifyDto.proof.nullifier_hash } as unknown as WorldIdVerification; + repository.create.mockReturnValue(mockVerification); + repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); + + await service.verifyProof(userId, verifyDto); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: userId }, + data: { worldcoinVerified: true }, + }); + }); + + it('should trigger sybil score recalculation after verification', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); + + const mockVerification = { id: 'ver-1', userId } as unknown as WorldIdVerification; + repository.create.mockReturnValue(mockVerification); + repository.save.mockResolvedValue(mockVerification); + prisma.worldIdVerification.create.mockResolvedValue(mockVerification); + prisma.user.update.mockResolvedValue({}); + sybilResistanceService.recordSybilScore.mockResolvedValue({ compositeScore: 0.5 }); + + await service.verifyProof(userId, verifyDto); + + expect(sybilResistanceService.recordSybilScore).toHaveBeenCalledWith(userId); + }); + + it('should throw ConflictException when nullifier hash exists in TypeORM store', async () => { repository.findOne.mockResolvedValue({ - id: 'existing-verification', + id: 'existing', userId: 'other-user', nullifierHash: verifyDto.proof.nullifier_hash, } as WorldIdVerification); + prisma.worldIdVerification.findUnique.mockResolvedValue(null); await expect(service.verifyProof(userId, verifyDto)).rejects.toThrow( 'This Worldcoin proof has already been used', ); + }); - expect(repository.findOne).toHaveBeenCalledWith({ - where: { nullifierHash: verifyDto.proof.nullifier_hash }, + it('should throw ConflictException when nullifier hash exists in Prisma store', async () => { + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findUnique.mockResolvedValue({ + id: 'existing-prisma', + nullifierHash: verifyDto.proof.nullifier_hash, }); + + await expect(service.verifyProof(userId, verifyDto)).rejects.toThrow( + 'This Worldcoin proof has already been used', + ); }); it('should throw BadRequestException for invalid proof', async () => { @@ -148,7 +261,6 @@ describe('WorldcoinService', () => { status: 200, json: jest.fn().mockResolvedValue({ success: false }), }); - repository.findOne.mockResolvedValue(null); await expect(service.verifyProof(userId, verifyDto)).rejects.toThrow( @@ -160,10 +272,7 @@ describe('WorldcoinService', () => { repository.findOne.mockResolvedValue(null); await expect( - service.verifyProof(userId, { - ...verifyDto, - action: 'unexpected-action', - }), + service.verifyProof(userId, { ...verifyDto, action: 'unexpected-action' }), ).rejects.toThrow('Invalid Worldcoin proof'); expect(fetchMock).not.toHaveBeenCalled(); @@ -193,18 +302,30 @@ describe('WorldcoinService', () => { }); describe('isUserVerified', () => { - it('should return true for verified user', async () => { + it('should return true when TypeORM store has a verification record', async () => { const userId = 'test-user-123'; repository.findOne.mockResolvedValue({ id: 'verification-id' } as WorldIdVerification); + prisma.worldIdVerification.findFirst.mockResolvedValue(null); + + const result = await service.isUserVerified(userId); + + expect(result).toBe(true); + }); + + it('should return true when Prisma store has a verification record', async () => { + const userId = 'test-user-123'; + repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findFirst.mockResolvedValue({ id: 'prisma-ver' }); const result = await service.isUserVerified(userId); expect(result).toBe(true); }); - it('should return false for unverified user', async () => { + it('should return false when neither store has a verification record', async () => { const userId = 'test-user-123'; repository.findOne.mockResolvedValue(null); + prisma.worldIdVerification.findFirst.mockResolvedValue(null); const result = await service.isUserVerified(userId); diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index a035818..2672156 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,13 +1,2 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity('users') -export class UserEntity { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ default: false }) - worldcoinVerified: boolean; - - @Column({ type: 'timestamp', nullable: true }) - worldcoinVerifiedAt: Date | null; -} \ No newline at end of file +// Re-export the canonical User entity so module-local imports keep working. +export { User as UserEntity } from '../../../entities/user.entity';