diff --git a/bun.lock b/bun.lock index 6bf1fc54..bad89f1c 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ "@opentelemetry/instrumentation-http": "^0.200.0", "@opentelemetry/instrumentation-pino": "^0.47.0", "@opentelemetry/sdk-node": "^0.200.0", - "@prisma/client": "^6.12.0", + "@prisma/client": "^6.17.0", "@prisma/instrumentation": "^6.7.0", "@turnkey/sdk-server": "^3.0.1", "@xmtp/node-sdk": "^2.0.8", @@ -57,7 +57,7 @@ "globals": "^15.14.0", "prettier": "^3.4.2", "prettier-plugin-packagejson": "^2.5.7", - "prisma": "^6.12.0", + "prisma": "^6.17.0", "rimraf": "^6.0.1", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0", @@ -499,21 +499,21 @@ "@pkgr/core": ["@pkgr/core@0.1.1", "", {}, "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA=="], - "@prisma/client": ["@prisma/client@6.12.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-wn98bJ3Cj6edlF4jjpgXwbnQIo/fQLqqQHPk2POrZPxTlhY3+n90SSIF3LMRVa8VzRFC/Gec3YKJRxRu+AIGVA=="], + "@prisma/client": ["@prisma/client@6.17.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-b42mTLOdLEZ6e/igu8CLdccAUX9AwHknQQ1+pHOftnzDP2QoyZyFvcANqSLs5ockimFKJnV7Ljf+qrhNYf6oAg=="], - "@prisma/config": ["@prisma/config@6.12.0", "", { "dependencies": { "jiti": "2.4.2" } }, "sha512-HovZWzhWEMedHxmjefQBRZa40P81N7/+74khKFz9e1AFjakcIQdXgMWKgt20HaACzY+d1LRBC+L4tiz71t9fkg=="], + "@prisma/config": ["@prisma/config@6.17.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ=="], - "@prisma/debug": ["@prisma/debug@6.12.0", "", {}, "sha512-plbz6z72orcqr0eeio7zgUrZj5EudZUpAeWkFTA/DDdXEj28YHDXuiakvR6S7sD6tZi+jiwQEJAPeV6J6m/tEQ=="], + "@prisma/debug": ["@prisma/debug@6.17.0", "", {}, "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g=="], - "@prisma/engines": ["@prisma/engines@6.12.0", "", { "dependencies": { "@prisma/debug": "6.12.0", "@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc", "@prisma/fetch-engine": "6.12.0", "@prisma/get-platform": "6.12.0" } }, "sha512-4BRZZUaAuB4p0XhTauxelvFs7IllhPmNLvmla0bO1nkECs8n/o1pUvAVbQ/VOrZR5DnF4HED0PrGai+rIOVePA=="], + "@prisma/engines": ["@prisma/engines@6.17.0", "", { "dependencies": { "@prisma/debug": "6.17.0", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", "@prisma/fetch-engine": "6.17.0", "@prisma/get-platform": "6.17.0" } }, "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw=="], - "@prisma/engines-version": ["@prisma/engines-version@6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc", "", {}, "sha512-70vhecxBJlRr06VfahDzk9ow4k1HIaSfVUT3X0/kZoHCMl9zbabut4gEXAyzJZxaCGi5igAA7SyyfBI//mmkbQ=="], + "@prisma/engines-version": ["@prisma/engines-version@6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", "", {}, "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw=="], - "@prisma/fetch-engine": ["@prisma/fetch-engine@6.12.0", "", { "dependencies": { "@prisma/debug": "6.12.0", "@prisma/engines-version": "6.12.0-15.8047c96bbd92db98a2abc7c9323ce77c02c89dbc", "@prisma/get-platform": "6.12.0" } }, "sha512-EamoiwrK46rpWaEbLX9aqKDPOd8IyLnZAkiYXFNuq0YsU0Z8K09/rH8S7feOWAVJ3xzeSgcEJtBlVDrajM9Sag=="], + "@prisma/fetch-engine": ["@prisma/fetch-engine@6.17.0", "", { "dependencies": { "@prisma/debug": "6.17.0", "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", "@prisma/get-platform": "6.17.0" } }, "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg=="], "@prisma/generator-helper": ["@prisma/generator-helper@6.5.0", "", { "dependencies": { "@prisma/debug": "6.5.0" } }, "sha512-71ELYxnSE4soeV0BlWJEMgO4KkCowuzHsPY3o7quFOtlcmds5ZX190VZK/k9HMJWdPQ893HooBv3BkKvieR7vA=="], - "@prisma/get-platform": ["@prisma/get-platform@6.12.0", "", { "dependencies": { "@prisma/debug": "6.12.0" } }, "sha512-nRerTGhTlgyvcBlyWgt8OLNIV7QgJS2XYXMJD1hysorMCuLAjuDDuoxmVt7C2nLxbuxbWPp7OuFRHC23HqD9dA=="], + "@prisma/get-platform": ["@prisma/get-platform@6.17.0", "", { "dependencies": { "@prisma/debug": "6.17.0" } }, "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ=="], "@prisma/instrumentation": ["@prisma/instrumentation@6.7.0", "", { "dependencies": { "@opentelemetry/instrumentation": "^0.52.0 || ^0.53.0 || ^0.54.0 || ^0.55.0 || ^0.56.0 || ^0.57.0" }, "peerDependencies": { "@opentelemetry/api": "^1.8" } }, "sha512-3NuxWlbzYNevgPZbV0ktA2z6r0bfh0g22ONTxcK09a6+6MdIPjHsYx1Hnyu4yOq+j7LmupO5J69hhuOnuvj8oQ=="], @@ -727,6 +727,8 @@ "@stablelib/x25519": ["@stablelib/x25519@1.0.3", "", { "dependencies": { "@stablelib/keyagreement": "^1.0.1", "@stablelib/random": "^1.0.2", "@stablelib/wipe": "^1.0.1" } }, "sha512-KnTbKmUhPhHavzobclVJQG5kuivH+qDLpe84iRqX3CLrKp881cF160JvXJ+hjn1aMyCwYOKeIZefIH/P5cJoRw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], @@ -959,6 +961,8 @@ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], @@ -973,6 +977,8 @@ "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], @@ -997,6 +1003,10 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -1029,6 +1039,8 @@ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], @@ -1051,6 +1063,8 @@ "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], @@ -1061,10 +1075,14 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="], + "elliptic": ["elliptic@6.6.1", "", { "dependencies": { "bn.js": "^4.11.9", "brorand": "^1.1.0", "hash.js": "^1.0.0", "hmac-drbg": "^1.0.1", "inherits": "^2.0.4", "minimalistic-assert": "^1.0.1", "minimalistic-crypto-utils": "^1.0.1" } }, "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "encode-utf8": ["encode-utf8@1.0.3", "", {}, "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -1123,10 +1141,14 @@ "express-rate-limit": ["express-rate-limit@7.5.0", "", { "peerDependencies": { "express": "^4.11 || 5 || ^5.0.0-beta.1" } }, "sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg=="], + "exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], "farmhash-modern": ["farmhash-modern@1.1.0", "", {}, "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-copy": ["fast-copy@3.0.2", "", {}, "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1197,6 +1219,8 @@ "get-stdin": ["get-stdin@9.0.0", "", {}, "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA=="], + "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + "git-hooks-list": ["git-hooks-list@3.2.0", "", {}, "sha512-ZHG9a1gEhUMX1TvGrLdyWb9kDopCBbTnI8z4JgRMYxsijWipgjSEYoPWqBuIB0DnRnvqlQSEeVmzpeuPm7NdFQ=="], "glob": ["glob@11.0.1", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^4.0.1", "minimatch": "^10.0.0", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-zrQDm8XPnYEKawJScsnM0QzobJxlT/kHOOlRTio8IH/GrmxRE5fjllkzdaHclIuNjUQTJYH2xHNIGfdpJkDJUw=="], @@ -1435,6 +1459,8 @@ "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "nypm": ["nypm@0.6.2", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], @@ -1443,6 +1469,8 @@ "ofetch": ["ofetch@1.4.1", "", { "dependencies": { "destr": "^2.0.3", "node-fetch-native": "^1.6.4", "ufo": "^1.5.4" } }, "sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], @@ -1479,6 +1507,10 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], @@ -1493,6 +1525,8 @@ "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], @@ -1507,7 +1541,7 @@ "prettier-plugin-packagejson": ["prettier-plugin-packagejson@2.5.10", "", { "dependencies": { "sort-package-json": "2.15.1", "synckit": "0.9.2" }, "peerDependencies": { "prettier": ">= 1.16.0" }, "optionalPeers": ["prettier"] }, "sha512-LUxATI5YsImIVSaaLJlJ3aE6wTD+nvots18U3GuQMJpUyClChaZlQrqx3dBnbhF20OnKWZyx8EgyZypQtBDtgQ=="], - "prisma": ["prisma@6.12.0", "", { "dependencies": { "@prisma/config": "6.12.0", "@prisma/engines": "6.12.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-pmV7NEqQej9WjizN6RSNIwf7Y+jeh9mY1JEX2WjGxJi4YZWexClhde1yz/FuvAM+cTwzchcMytu2m4I6wPkIzg=="], + "prisma": ["prisma@6.17.0", "", { "dependencies": { "@prisma/config": "6.17.0", "@prisma/engines": "6.17.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw=="], "process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="], @@ -1527,6 +1561,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qrcode": ["qrcode@1.5.3", "", { "dependencies": { "dijkstrajs": "^1.0.1", "encode-utf8": "^1.0.3", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg=="], "qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], @@ -1541,6 +1577,8 @@ "raw-body": ["raw-body@3.0.0", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.6.3", "unpipe": "1.0.0" } }, "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g=="], + "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="], "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], @@ -1683,6 +1721,8 @@ "three": ["three@0.146.0", "", {}, "sha512-1lvNfLezN6OJ9NaFAhfX4sm5e9YCzHtaRgZ1+B4C+Hv6TibRMsuBAM5/wVKzxjpYIlMymvgsHEFrrigEfXnb2A=="], + "tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], + "tinyglobby": ["tinyglobby@0.2.12", "", { "dependencies": { "fdir": "^6.4.3", "picomatch": "^4.0.2" } }, "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], diff --git a/package.json b/package.json index d78a2d0b..fa8bd945 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@opentelemetry/instrumentation-http": "^0.200.0", "@opentelemetry/instrumentation-pino": "^0.47.0", "@opentelemetry/sdk-node": "^0.200.0", - "@prisma/client": "^6.12.0", + "@prisma/client": "^6.17.0", "@prisma/instrumentation": "^6.7.0", "@turnkey/sdk-server": "^3.0.1", "@xmtp/node-sdk": "^2.0.8", @@ -58,8 +58,7 @@ "uint8array-extras": "^1.4.0", "uuid": "^11.0.5", "viem": "^2.23.2", - "zod": "^3.24.1", - "zod-prisma-types": "^3.2.4" + "zod": "^3.24.1" }, "devDependencies": { "@bufbuild/buf": "^1.50.0", @@ -79,10 +78,10 @@ "globals": "^15.14.0", "prettier": "^3.4.2", "prettier-plugin-packagejson": "^2.5.7", - "prisma": "^6.12.0", + "prisma": "^6.17.0", "rimraf": "^6.0.1", "typescript": "^5.7.3", - "typescript-eslint": "^8.20.0" - }, - "packageManager": "yarn@4.6.0+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728" + "typescript-eslint": "^8.20.0", + "zod-prisma-types": "^3.2.4" + } } diff --git a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql new file mode 100644 index 00000000..34f5f55c --- /dev/null +++ b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "DeviceRegistration" ( + "deviceId" TEXT NOT NULL, + "pushToken" TEXT NOT NULL, + "pushTokenType" "PushTokenType" NOT NULL DEFAULT 'apns', + "apnsEnv" "ApnsEnvironment", + "pushFailures" INTEGER NOT NULL DEFAULT 0, + "disabled" BOOLEAN NOT NULL DEFAULT false, + "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "lastSentAt" TIMESTAMP(3), + "lastFailureAt" TIMESTAMP(3), + + CONSTRAINT "DeviceRegistration_pkey" PRIMARY KEY ("deviceId") +); + +-- CreateTable +CREATE TABLE "ClientIdentifier" ( + "id" TEXT NOT NULL, + "deviceId" TEXT NOT NULL, + "addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ClientIdentifier_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "DeviceRegistration_pushToken_idx" ON "DeviceRegistration"("pushToken"); + +-- CreateIndex +CREATE INDEX "DeviceRegistration_disabled_pushFailures_idx" ON "DeviceRegistration"("disabled", "pushFailures"); + +-- CreateIndex +CREATE INDEX "ClientIdentifier_deviceId_idx" ON "ClientIdentifier"("deviceId"); + +-- AddForeignKey +ALTER TABLE "ClientIdentifier" ADD CONSTRAINT "ClientIdentifier_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "DeviceRegistration"("deviceId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251017193536_add_unique_constraint_to_push_tokens/migration.sql b/prisma/migrations/20251017193536_add_unique_constraint_to_push_tokens/migration.sql new file mode 100644 index 00000000..d01b8ee6 --- /dev/null +++ b/prisma/migrations/20251017193536_add_unique_constraint_to_push_tokens/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable: Make pushToken nullable +ALTER TABLE "DeviceRegistration" ALTER COLUMN "pushToken" DROP NOT NULL; + +-- Update any empty strings to NULL (clean up existing data) +UPDATE "DeviceRegistration" SET "pushToken" = NULL WHERE "pushToken" = ''; + +-- CreateIndex: Add unique constraint on push token combination +CREATE UNIQUE INDEX "DeviceRegistration_pushTokenType_apnsEnv_pushToken_key" ON "DeviceRegistration"("pushTokenType", "apnsEnv", "pushToken"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba69367d..0e783ef3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,9 +24,10 @@ enum DeviceOS { macos } +// NOTE: Only APNS is supported. FCM is not supported. enum PushTokenType { apns - fcm + fcm // Not supported - kept for database compatibility only } enum ApnsEnvironment { @@ -176,3 +177,32 @@ model InviteCodeRequest { @@index([inviteCodeId]) @@index([requesterId]) } + +// v2 Push Notification Models +model DeviceRegistration { + deviceId String @id + pushToken String? @db.Text + pushTokenType PushTokenType @default(apns) + apnsEnv ApnsEnvironment? + pushFailures Int @default(0) + disabled Boolean @default(false) + addedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastSentAt DateTime? + lastFailureAt DateTime? + clientIdentifiers ClientIdentifier[] + + @@unique([pushTokenType, apnsEnv, pushToken]) + @@index([pushToken]) + @@index([disabled, pushFailures]) +} + +model ClientIdentifier { + id String @id + deviceId String + device DeviceRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade) + addedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([deviceId]) +} diff --git a/proto/invite/v2/invite.proto b/proto/invite/v2/invite.proto index 0333c3db..188965da 100644 --- a/proto/invite/v2/invite.proto +++ b/proto/invite/v2/invite.proto @@ -1,5 +1,7 @@ syntax = "proto3"; +import "google/protobuf/timestamp.proto"; + package invite.v2; // InvitePayload represents the core invite data @@ -17,6 +19,13 @@ message InvitePayload { optional string name = 4; optional string description = 5; optional string imageURL = 6; + optional google.protobuf.Timestamp conversationExpiresAt = 7; + + // optional invite expiration + optional google.protobuf.Timestamp expiresAt = 8; + + // Whether the invite should expire after being used once + bool expiresAfterUse = 9; } // SignedInvite represents an invite with its cryptographic signature diff --git a/scripts/simple-push-test.ts b/scripts/simple-push-test.ts index 2ffbaaee..b967b844 100755 --- a/scripts/simple-push-test.ts +++ b/scripts/simple-push-test.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { createApnsService } from "@/api/v1/notifications/services/apns-push.service"; +import { createApnsService } from "@/api/shared/notifications/services/apns-push.service"; import { prisma } from "@/utils/prisma"; // Simple version - just provide an XMTP ID and it will send a basic test notification diff --git a/scripts/test-push-notification.ts b/scripts/test-push-notification.ts index ea8728ea..1b8d338f 100755 --- a/scripts/test-push-notification.ts +++ b/scripts/test-push-notification.ts @@ -1,5 +1,5 @@ #!/usr/bin/env bun -import { createApnsService } from "@/api/v1/notifications/services/apns-push.service"; +import { createApnsService } from "@/api/shared/notifications/services/apns-push.service"; import { prisma } from "@/utils/prisma"; interface TestPushArgs { diff --git a/src/api/shared/notifications/constants.ts b/src/api/shared/notifications/constants.ts new file mode 100644 index 00000000..093c7a2d --- /dev/null +++ b/src/api/shared/notifications/constants.ts @@ -0,0 +1,9 @@ +/** + * Maximum number of consecutive push notification failures before disabling a device + */ +export const MAX_PUSH_FAILURES = 10; + +/** + * Maximum size in bytes for JWT metadata to prevent token bloat + */ +export const MAX_JWT_METADATA_SIZE = 1024; // 1KB diff --git a/src/api/v1/notifications/services/apns-push.service.ts b/src/api/shared/notifications/services/apns-push.service.ts similarity index 97% rename from src/api/v1/notifications/services/apns-push.service.ts rename to src/api/shared/notifications/services/apns-push.service.ts index a6180179..04f950df 100644 --- a/src/api/v1/notifications/services/apns-push.service.ts +++ b/src/api/shared/notifications/services/apns-push.service.ts @@ -3,8 +3,8 @@ import type { Device } from "@prisma/client"; import jwt from "jsonwebtoken"; import logger from "@/utils/logger"; import type { + AnyNotificationPayloadWithJWT, NotificationPayload, - NotificationPayloadWithJWTToken, } from "./notifications-types"; export interface ApnsConfig { @@ -75,7 +75,7 @@ export class ApnsPushService { async sendPushNotification(args: { device: Device; - notification: NotificationPayloadWithJWTToken; + notification: AnyNotificationPayloadWithJWT; isSilent?: boolean; }): Promise<{ success: boolean; error?: string }> { const { device, notification, isSilent } = args; @@ -137,10 +137,11 @@ export class ApnsPushService { "content-type": "application/json", }; + const safeHeaders = { ...headers, authorization: "[REDACTED]" }; logger.info( { url: `https://${hostname}/3/device/${device.pushToken}`, - headers, + headers: safeHeaders, payload, deviceId: device.id, verbose: true, diff --git a/src/api/v1/notifications/services/notifications-types.ts b/src/api/shared/notifications/services/notifications-types.ts similarity index 66% rename from src/api/v1/notifications/services/notifications-types.ts rename to src/api/shared/notifications/services/notifications-types.ts index 6fac21e8..2e6ebd95 100644 --- a/src/api/v1/notifications/services/notifications-types.ts +++ b/src/api/shared/notifications/services/notifications-types.ts @@ -3,7 +3,7 @@ export type NotificationType = "Protocol" | "InviteJoinRequest"; export type ProtocolNotificationData = { contentTopic: string; messageType: string; - encryptedMessage: string; + encryptedMessage?: string; timestamp: string; }; @@ -36,11 +36,15 @@ export type NotificationTypeToData = { InviteJoinRequest: InviteJoinRequestNotificationData; }; +// Base notification payload with XOR semantics for v1/v2 transition +// Ensures exactly one of inboxId (v1) or clientId (v2) is present type NotificationPayloadBase = { - inboxId: string; notificationType: T; notificationData: NotificationTypeToData[T]; -}; +} & ( + | { inboxId: string; clientId?: never } + | { clientId: string; inboxId?: never } +); // Discriminated union over notificationType export type NotificationPayload = { @@ -54,3 +58,16 @@ export type NotificationPayloadFor = export type NotificationPayloadWithJWTToken = NotificationPayload & { apiJWT: string; }; + +// V2 notification types (structurally compatible with v2 branch of NotificationPayloadWithJWTToken) +export type V2NotificationPayload = { + clientId: string; + apiJWT: string; + notificationType: "Protocol"; + notificationData: ProtocolNotificationData; +}; + +// Union type for push services that can handle both v1 and v2 +export type AnyNotificationPayloadWithJWT = + | NotificationPayloadWithJWTToken + | V2NotificationPayload; diff --git a/src/api/v1/notifications/services/push-notification.service.ts b/src/api/shared/notifications/services/push-notification.service.ts similarity index 93% rename from src/api/v1/notifications/services/push-notification.service.ts rename to src/api/shared/notifications/services/push-notification.service.ts index adb28a00..867872fc 100644 --- a/src/api/v1/notifications/services/push-notification.service.ts +++ b/src/api/shared/notifications/services/push-notification.service.ts @@ -105,6 +105,15 @@ export class PushNotificationService { const xmtpInstallationId = identityOnDevice.xmtpInstallationId; + // V1 notifications always have inboxId + if (!notification.inboxId) { + logger.error( + { deviceId: device.id }, + "Missing inboxId for v1 notification", + ); + return { success: false }; + } + // We add an JWT token to the notification payload to be used by the client // So the notification extension is able to communicate with our backend (App Attest not supported in extensions so we can't call authenticate) const apiJWT = await createJwtToken({ @@ -147,13 +156,13 @@ export class PushNotificationService { break; case "fcm": - logger.warn( - `FCM push notifications not yet implemented for device ${device.id}`, + logger.error( + `FCM push notifications are not supported. Only APNS is supported for device ${device.id}`, ); return { success: false }; default: - logger.warn(`No valid push token type for device ${device.id}`); + logger.warn(`Invalid push token type for device ${device.id}`); return { success: false }; } diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts new file mode 100644 index 00000000..24a1eac6 --- /dev/null +++ b/src/api/shared/notifications/webhook-handler.ts @@ -0,0 +1,384 @@ +import type { ClientIdentifier, DeviceRegistration } from "@prisma/client"; +import type { Request, Response } from "express"; +import { createApnsService } from "@/api/shared/notifications/services/apns-push.service"; +import type { V2NotificationPayload } from "@/api/shared/notifications/services/notifications-types"; +import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service"; +import { + createNotificationClient, + webhookNotificationBodySchema, + type WebhookNotificationBody, +} from "@/notifications/client"; +import { prisma } from "@/utils/prisma"; +import { createV2JwtToken } from "@/utils/v2/jwt"; +import { MAX_PUSH_FAILURES } from "./constants"; + +const notificationClient = createNotificationClient(); +const pushNotificationService = getPushNotificationService(); + +/** + * Detect if a message is a welcome message (XMTP MLS protocol message for group joins) + * Welcome messages are too large (~5-8KB) for APNS payload limit (4KB) + * + * Detection methods: + * 1. Content topic contains '/w-' (welcome topic pattern) + * 2. Message type is 'v3-welcome' + */ +function isWelcomeMessage(args: { + contentTopic: string; + messageType: string; +}): boolean { + return args.contentTopic.includes("/w-") || args.messageType === "v3-welcome"; +} + +/** + * Webhook handler for XMTP notifications + * + * Authentication is handled by xmtpWebhookAuthMiddleware which validates the + * XMTP_NOTIFICATION_SECRET header to verify the request is from the authorized XMTP server. + */ +export async function handleXmtpNotification(req: Request, res: Response) { + let identityOnDeviceToCleanup: { + xmtpInstallationId: string | null; + deviceId: string; + } | null = null; + + try { + // Validate webhook body structure + const parseResult = webhookNotificationBodySchema.safeParse(req.body); + if (!parseResult.success) { + req.log.error( + { errors: parseResult.error.errors }, + "Invalid webhook payload", + ); + res.status(400).json({ + error: "Invalid webhook payload", + details: parseResult.error.errors, + }); + return; + } + + const notification = parseResult.data; + + // Log the notification for debugging + req.log.info( + { + contentTopic: notification.message.content_topic, + installationId: notification.installation.id, + }, + "received notification", + ); + + // Try v2 first (clientId lookup) + const v2Client = await prisma.clientIdentifier.findUnique({ + where: { id: notification.installation.id }, + include: { device: true }, + }); + + if (v2Client) { + req.log.info( + { clientId: notification.installation.id }, + "Processing v2 notification", + ); + await handleV2Notification({ + notification, + client: v2Client, + req, + }); + res.status(200).end(); + return; + } + + // FALLBACK TO V1 (xmtpInstallationId lookup) + const identityOnDevice = await prisma.identitiesOnDevice.findUnique({ + where: { + xmtpInstallationId: notification.installation.id, + }, + include: { + device: true, + identity: true, + }, + }); + + if (!identityOnDevice || !identityOnDevice.xmtpInstallationId) { + req.log.error( + `Installation not found for installationId ${notification.installation.id}`, + ); + res.status(400).json({ + error: `Installation not found for installationId ${notification.installation.id}`, + }); + return; + } + + req.log.info( + { installationId: notification.installation.id }, + "Processing v1 notification", + ); + + identityOnDeviceToCleanup = { + xmtpInstallationId: identityOnDevice.xmtpInstallationId, + deviceId: identityOnDevice.deviceId, + }; + + const { device, identity } = identityOnDevice; + + const isWelcome = isWelcomeMessage({ + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + }); + + if (isWelcome) { + req.log.info( + { contentTopic: notification.message.content_topic }, + "Detected welcome message - omitting encrypted content to avoid APNS payload limit", + ); + } + + const result = await pushNotificationService.sendPushNotification({ + identityOnDevice, + notification: { + inboxId: identity.xmtpId, + notificationType: "Protocol", + notificationData: { + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + // Omit encryptedMessage for welcome messages (too large for APNS 4KB limit) + // iOS NSE will handle notification display; app syncs from XMTP network + ...(isWelcome + ? {} + : { encryptedMessage: notification.message.message }), + timestamp: notification.message.timestamp_ns, + }, + }, + }); + + if (!result.success && result.shouldCleanup) { + req.log.info( + `Push notification failed with unrecoverable error for device ${device.id}. Initiating cleanup.`, + ); + if (identityOnDeviceToCleanup.xmtpInstallationId) { + await cleanupFailedInstallation({ + xmtpInstallationId: identityOnDeviceToCleanup.xmtpInstallationId, + deviceId: identityOnDeviceToCleanup.deviceId, + req, + }); + } + } + + res.status(200).end(); + return; + } catch (error) { + req.log.error({ error }, "Outer error processing notification"); + res.status(500).json({ error: "Internal server error" }); + } +} + +async function cleanupFailedInstallation(args: { + xmtpInstallationId: string; + deviceId: string; + req: Request; +}) { + const { xmtpInstallationId, deviceId, req } = args; + + try { + req.log.info( + `Cleaning up installation: ${xmtpInstallationId} for device: ${deviceId}`, + ); + await prisma.$transaction([ + prisma.identitiesOnDevice.updateMany({ + where: { xmtpInstallationId: xmtpInstallationId }, + data: { xmtpInstallationId: null }, + }), + prisma.device.update({ + where: { id: deviceId }, + data: { + pushToken: null, + pushFailures: { increment: 1 }, + }, + }), + ]); + req.log.info( + `Successfully cleaned xmtpInstallationId ${xmtpInstallationId} and tokens for device ${deviceId} from local DB.`, + ); + + await notificationClient.deleteInstallation({ + installationId: xmtpInstallationId, + }); + req.log.info( + `Successfully requested deletion of xmtpInstallationId ${xmtpInstallationId} from XMTP server.`, + ); + } catch (cleanupError) { + req.log.error( + { error: cleanupError, xmtpInstallationId, deviceId }, + "Failed during cleanup of installation", + ); + } +} + +async function handleV2Notification(args: { + notification: WebhookNotificationBody; + client: ClientIdentifier & { device: DeviceRegistration }; + req: Request; +}) { + const { notification, client, req } = args; + + // Check if device is disabled or has too many failures + if ( + client.device.disabled || + client.device.pushFailures >= MAX_PUSH_FAILURES + ) { + req.log.warn( + `Device ${client.deviceId} is disabled or has too many failures. Skipping notification.`, + ); + return { success: false }; + } + + // Generate JWT for NSE to use (24h expiration for security) + const apiJWT = await createV2JwtToken({ + deviceId: client.deviceId, + expirationTime: "24h", + metadata: { + notificationExtensionOnly: true, + }, + }); + + // NOTE: v2 only supports APNS/iOS push notifications + // We do not support FCM/Android + const apnsService = createApnsService(); + + if (!apnsService) { + req.log.error("APNS service not configured"); + return { success: false }; + } + + // Check if this is a welcome message (too large for APNS) + const isWelcome = isWelcomeMessage({ + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + }); + + if (isWelcome) { + req.log.info( + { contentTopic: notification.message.content_topic }, + "Detected welcome message - omitting encrypted content to avoid APNS payload limit", + ); + } + + // Send push notification with v2 types + const v2Notification: V2NotificationPayload = { + clientId: notification.installation.id, + apiJWT, + notificationType: "Protocol", + notificationData: { + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + // Omit encryptedMessage for welcome messages (too large for APNS 4KB limit) + ...(isWelcome ? {} : { encryptedMessage: notification.message.message }), + timestamp: notification.message.timestamp_ns, + }, + }; + + // Create a device-like object for APNS service + // NOTE: os is hardcoded to "ios" since v2 only supports APNS (no FCM/Android support) + const deviceForApns = { + id: client.deviceId, + pushToken: client.device.pushToken, + pushTokenType: client.device.pushTokenType, + apnsEnv: client.device.apnsEnv, + pushFailures: client.device.pushFailures, + name: null, + os: "ios" as const, + appVersion: null, + appBuildNumber: null, + createdAt: client.device.addedAt, + updatedAt: client.device.updatedAt, + lastPushSuccessAt: client.device.lastSentAt, + }; + + const result = await apnsService.sendPushNotification({ + device: deviceForApns, + notification: v2Notification, + }); + + // Track success/failure using atomic operations to prevent race conditions + if (result.success) { + await prisma.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { + pushFailures: { set: 0 }, + lastSentAt: new Date(), + }, + }); + req.log.info( + `Successfully sent v2 push notification to ${client.deviceId}`, + ); + } else { + // Use transaction to atomically increment failures and conditionally disable + const updated = await prisma.$transaction(async (tx) => { + const u = await tx.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { + pushFailures: { increment: 1 }, + lastFailureAt: new Date(), + }, + }); + + // Atomic conditional disable - only disables if not already disabled and threshold reached + await tx.deviceRegistration.updateMany({ + where: { + deviceId: client.deviceId, + disabled: false, + pushFailures: { gte: MAX_PUSH_FAILURES }, + }, + data: { disabled: true }, + }); + + return u; + }); + + req.log.warn( + `Failed to send v2 push notification to ${client.deviceId}. Failure count: ${updated.pushFailures}`, + ); + + // Cleanup if unrecoverable error + if ( + result.error === "DeviceNotRegistered" || + result.error === "BadDeviceToken" + ) { + req.log.info( + `Cleaning up v2 notification client ${client.id} due to unrecoverable error`, + ); + try { + // Delete from local DB first to ensure we don't retry on failure + await prisma.clientIdentifier.delete({ + where: { id: client.id }, + }); + + // Then attempt notification server cleanup + try { + await notificationClient.deleteInstallation({ + installationId: client.id, + }); + } catch (xmtpError) { + // Log but don't fail - DB is authoritative, orphaned XMTP installation is harmless + req.log.warn( + { error: xmtpError, clientId: client.id }, + "Failed to delete XMTP installation, but local DB is clean", + ); + } + + req.log.info( + { clientId: client.id }, + "Successfully cleaned up v2 notifications", + ); + } catch (cleanupError) { + req.log.error( + { error: cleanupError, clientId: client.id }, + "Failed to cleanup v2 notification subscriptions after push failure", + ); + // Don't throw here - this is already in error handling path + } + } + } + + return result; +} diff --git a/src/api/shared/notifications/webhook.router.ts b/src/api/shared/notifications/webhook.router.ts new file mode 100644 index 00000000..ce499d77 --- /dev/null +++ b/src/api/shared/notifications/webhook.router.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { xmtpWebhookAuthMiddleware } from "@/middleware/xmtpWebhookAuth"; +import { handleXmtpNotification } from "./webhook-handler"; + +const webhookRouter = Router(); + +// XMTP webhook handler (shared between v1 and v2) +webhookRouter.post( + "/handle-notification", + xmtpWebhookAuthMiddleware, + handleXmtpNotification, +); + +export { webhookRouter }; diff --git a/src/api/v1/devices/handlers/create-device.handler.ts b/src/api/v1/devices/handlers/create-device.handler.ts index 8a7ff707..bba374d9 100644 --- a/src/api/v1/devices/handlers/create-device.handler.ts +++ b/src/api/v1/devices/handlers/create-device.handler.ts @@ -1,7 +1,7 @@ +import { DeviceSchema } from "@prisma-zod/index"; import { type Request, type Response } from "express"; import { z } from "zod"; import { prisma } from "@/utils/prisma"; -import { DeviceSchema } from "../../../../../prisma/generated/zod"; export const DeviceInputSchema = DeviceSchema.pick({ id: true, diff --git a/src/api/v1/devices/handlers/update-device.handler.ts b/src/api/v1/devices/handlers/update-device.handler.ts index f9f793af..23b0e25d 100644 --- a/src/api/v1/devices/handlers/update-device.handler.ts +++ b/src/api/v1/devices/handlers/update-device.handler.ts @@ -1,8 +1,8 @@ +import { DeviceSchema } from "@prisma-zod/index"; import { type Request, type Response } from "express"; import { z } from "zod"; import { AppError, logError } from "@/utils/errors"; import { prisma } from "@/utils/prisma"; -import { DeviceSchema } from "../../../../../prisma/generated/zod"; export type UpdateDeviceRequestParams = { deviceId: string; diff --git a/src/api/v1/index.ts b/src/api/v1/index.ts index 29fd1e4d..824145e0 100644 --- a/src/api/v1/index.ts +++ b/src/api/v1/index.ts @@ -1,10 +1,8 @@ import { Router } from "express"; +import { webhookRouter } from "@/api/shared/notifications/webhook.router"; import appConfigRouter from "@/api/v1/appConfig"; import authenticateRouter from "@/api/v1/authenticate"; -import { - notificationsRouter, - xmtpNotificationsRouter, -} from "@/api/v1/notifications/notifications.router"; +import { notificationsRouter } from "@/api/v1/notifications/notifications.router"; import publicProfilesRouter from "@/api/v1/profiles/profiles-public.router"; import profilesRouter from "@/api/v1/profiles/profiles.router"; import { authMiddleware } from "@/middleware/auth"; @@ -24,7 +22,7 @@ v1Router.use("/app-config", appConfigRouter); v1Router.use("/authenticate", authenticateRouter); // mount xmtp notification webhook (no auth middleware) -v1Router.use("/notifications/xmtp", xmtpNotificationsRouter); +v1Router.use("/notifications/xmtp", webhookRouter); // create and manage turnkey wallets/suborgs v1Router.use("/wallets", walletsRouter); diff --git a/src/api/v1/invites/handlers/request-to-join.ts b/src/api/v1/invites/handlers/request-to-join.ts index b2983641..8567ae03 100644 --- a/src/api/v1/invites/handlers/request-to-join.ts +++ b/src/api/v1/invites/handlers/request-to-join.ts @@ -1,10 +1,10 @@ import type { InviteCode } from "@prisma/client"; import type { Request, Response } from "express"; import { z } from "zod"; +import type { InviteJoinRequestNotificationData } from "@/api/shared/notifications/services/notifications-types"; +import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service"; import { getInviteLink } from "@/utils/invites"; import { prisma } from "@/utils/prisma"; -import type { InviteJoinRequestNotificationData } from "../../notifications/services/notifications-types"; -import { getPushNotificationService } from "../../notifications/services/push-notification.service"; const requestToJoinSchema = z.object({ inviteId: z.string().min(1, "Invite ID is required"), diff --git a/src/api/v1/invites/handlers/update-invite-code.ts b/src/api/v1/invites/handlers/update-invite-code.ts index 737d2b5a..f5bf4676 100644 --- a/src/api/v1/invites/handlers/update-invite-code.ts +++ b/src/api/v1/invites/handlers/update-invite-code.ts @@ -1,9 +1,9 @@ +import { InviteCodeStatusSchema } from "@prisma-zod/index"; import type { InviteCodeStatus } from "@prisma/client"; import type { Request, Response } from "express"; import { z } from "zod"; import { getInviteLink } from "@/utils/invites"; import { prisma } from "@/utils/prisma"; -import { InviteCodeStatusSchema } from "../../../../../prisma/generated/zod"; const paramsSchema = z.object({ inviteId: z.string().min(1, "Invite ID is required"), diff --git a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts index 1fa55272..cd7e1286 100644 --- a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts +++ b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts @@ -1,156 +1,3 @@ -import type { Request, Response } from "express"; -import { - createNotificationClient, - type WebhookNotificationBody, -} from "@/notifications/client"; -import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; -import { prisma } from "@/utils/prisma"; -import { getPushNotificationService } from "../services/push-notification.service"; - -const notificationClient = createNotificationClient(); -const pushNotificationService = getPushNotificationService(); - -if (!process.env.XMTP_NOTIFICATION_SECRET) { - throw new Error("XMTP_NOTIFICATION_SECRET is not set"); -} - -/** - * Webhook handler for XMTP notifications - * - * This endpoint uses custom header-based authentication instead of the standard authMiddleware. - * It validates the request using the XMTP_NOTIFICATION_SECRET to verify the webhook is coming - * from the authorized XMTP server. - */ -export async function handleXmtpNotification(req: Request, res: Response) { - let identityOnDeviceToCleanup: { - xmtpInstallationId: string | null; - deviceId: string; - } | null = null; - - try { - const notification = req.body as WebhookNotificationBody; - - // Log the notification for debugging - req.log.info( - { - contentTopic: notification.message.content_topic, - installationId: notification.installation.id, - }, - "received notification", - ); - - // Verify the authorization header - const authHeader = req.headers.authorization; - const expectedAuthHeader = getHttpDeliveryNotificationAuthHeader(); - - if (!authHeader || authHeader !== expectedAuthHeader) { - req.log.error("Invalid or missing authorization header"); - res.status(401).json({ - error: "Unauthorized: Invalid authentication token", - }); - return; - } - - const identityOnDevice = await prisma.identitiesOnDevice.findUnique({ - where: { - xmtpInstallationId: notification.installation.id, - }, - include: { - device: true, - identity: true, - }, - }); - - if (!identityOnDevice || !identityOnDevice.xmtpInstallationId) { - req.log.error( - `IdentityOnDevice not found for xmtpInstallationId ${notification.installation.id}`, - ); - res.status(400).json({ - error: `IdentityOnDevice not found for xmtpInstallationId ${notification.installation.id}`, - }); - return; - } - - identityOnDeviceToCleanup = { - xmtpInstallationId: identityOnDevice.xmtpInstallationId, - deviceId: identityOnDevice.deviceId, - }; - - const { device, identity } = identityOnDevice; - - const result = await pushNotificationService.sendPushNotification({ - identityOnDevice, - notification: { - inboxId: identity.xmtpId, - notificationType: "Protocol", - notificationData: { - contentTopic: notification.message.content_topic, - messageType: notification.message_context.message_type, - encryptedMessage: notification.message.message, - timestamp: notification.message.timestamp_ns, - }, - }, - }); - - if (!result.success && result.shouldCleanup) { - req.log.info( - `Push notification failed with unrecoverable error for device ${device.id}. Initiating cleanup.`, - ); - if (identityOnDeviceToCleanup.xmtpInstallationId) { - await cleanupFailedInstallation({ - xmtpInstallationId: identityOnDeviceToCleanup.xmtpInstallationId, - deviceId: identityOnDeviceToCleanup.deviceId, - req, - }); - } - } - - res.status(200).end(); - return; - } catch (error) { - req.log.error({ error }, "Outer error processing notification"); - res.status(500).json({ error: "Internal server error" }); - } -} - -async function cleanupFailedInstallation(args: { - xmtpInstallationId: string; - deviceId: string; - req: Request; -}) { - const { xmtpInstallationId, deviceId, req } = args; - - try { - req.log.info( - `Cleaning up installation: ${xmtpInstallationId} for device: ${deviceId}`, - ); - await prisma.$transaction([ - prisma.identitiesOnDevice.updateMany({ - where: { xmtpInstallationId: xmtpInstallationId }, - data: { xmtpInstallationId: null }, - }), - prisma.device.update({ - where: { id: deviceId }, - data: { - pushToken: null, - pushFailures: { increment: 1 }, - }, - }), - ]); - req.log.info( - `Successfully cleaned xmtpInstallationId ${xmtpInstallationId} and tokens for device ${deviceId} from local DB.`, - ); - - await notificationClient.deleteInstallation({ - installationId: xmtpInstallationId, - }); - req.log.info( - `Successfully requested deletion of xmtpInstallationId ${xmtpInstallationId} from XMTP server.`, - ); - } catch (cleanupError) { - req.log.error( - { error: cleanupError, xmtpInstallationId, deviceId }, - "Failed during cleanup of installation", - ); - } -} +// This file is deprecated - use @/api/shared/notifications/webhook-handler instead +// Re-export for backwards compatibility +export { handleXmtpNotification } from "@/api/shared/notifications/webhook-handler"; diff --git a/src/api/v1/notifications/handlers/register-installation.ts b/src/api/v1/notifications/handlers/register-installation.ts index 3ef7556d..d182d3ba 100644 --- a/src/api/v1/notifications/handlers/register-installation.ts +++ b/src/api/v1/notifications/handlers/register-installation.ts @@ -1,9 +1,9 @@ +import { ApnsEnvironmentSchema } from "@prisma-zod/index"; import { PushTokenType } from "@prisma/client"; import type { Request, Response } from "express"; import { z } from "zod"; import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; -import { ApnsEnvironmentSchema } from "../../../../../prisma/generated/zod"; const installationItemSchema = z.object({ identityId: z.string(), @@ -81,6 +81,7 @@ export async function registerInstallation( } res.status(400).json({ error: "Invalid request body" }); + return; } async function handleCurrentRegistration(args: { diff --git a/src/api/v1/notifications/handlers/subscribe-to-topics.ts b/src/api/v1/notifications/handlers/subscribe-to-topics.ts index dc4c502c..f2667b9b 100644 --- a/src/api/v1/notifications/handlers/subscribe-to-topics.ts +++ b/src/api/v1/notifications/handlers/subscribe-to-topics.ts @@ -19,7 +19,7 @@ const subscribeWithMetadataRequestBodySchema = z.object({ hmacKeys: z.array( z.object({ thirtyDayPeriodsSinceEpoch: z.number(), - key: z.string(), + key: z.string().regex(/^[0-9a-fA-F]+$/, "Invalid hex string"), }), ), }), diff --git a/src/api/v1/notifications/notifications.router.ts b/src/api/v1/notifications/notifications.router.ts index 94a9a49a..1a0acab0 100644 --- a/src/api/v1/notifications/notifications.router.ts +++ b/src/api/v1/notifications/notifications.router.ts @@ -1,5 +1,4 @@ import { Router } from "express"; -import { handleXmtpNotification } from "@/api/v1/notifications/handlers/handle-xmtp-notification"; import { registerInstallation } from "@/api/v1/notifications/handlers/register-installation"; import { subscribeToTopics } from "@/api/v1/notifications/handlers/subscribe-to-topics"; import { unregisterInstallation } from "@/api/v1/notifications/handlers/unregister-installation"; @@ -15,8 +14,4 @@ notificationsRouter.delete( unregisterInstallation, ); -const xmtpNotificationsRouter = Router(); - -xmtpNotificationsRouter.post("/handle-notification", handleXmtpNotification); - -export { notificationsRouter, xmtpNotificationsRouter }; +export { notificationsRouter }; diff --git a/src/api/v2/auth/auth.router.ts b/src/api/v2/auth/auth.router.ts new file mode 100644 index 00000000..df55772d --- /dev/null +++ b/src/api/v2/auth/auth.router.ts @@ -0,0 +1,16 @@ +import { Router } from "express"; +import { authRateLimitMiddleware } from "@/middleware/rateLimit"; +import { appCheckOnlyMiddleware } from "@/middleware/v2/auth"; +import { generateToken } from "./handlers/generate-token"; + +const authRouter = Router(); + +// Token generation requires AppCheck only (main app only) with strict rate limiting +authRouter.post( + "/token", + authRateLimitMiddleware, + appCheckOnlyMiddleware, + generateToken, +); + +export { authRouter }; diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts new file mode 100644 index 00000000..cecbb5e0 --- /dev/null +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -0,0 +1,74 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { prisma } from "@/utils/prisma"; +import { createV2JwtToken } from "@/utils/v2/jwt"; + +/** + * Token Generation Security Model + * + * This endpoint generates short-lived JWT tokens for NSE/Gateway authentication: + * + * 1. The outer authV2Middleware validates the request using Firebase AppCheck, + * which verifies the request originates from a legitimate app instance. + * 2. AppCheck validation is sufficient to prove device ownership. + * 3. Device does NOT need to be registered yet - token generation works independently. + * 4. If device is registered and disabled, token generation is rejected. + * 5. Rate limiting prevents token exhaustion attacks. + * 6. Tokens are short-lived (15 minutes) to limit exposure window. + * 7. The JWT contains only deviceId - handlers receive clientId in request bodies. + */ + +const generateTokenRequestSchema = z.object({ + deviceId: z.string().uuid(), +}); + +export type IGenerateTokenRequestBody = z.infer< + typeof generateTokenRequestSchema +>; + +export async function generateToken( + req: Request, + res: Response, +) { + try { + const body = generateTokenRequestSchema.parse(req.body); + + req.log.info({ deviceId: body.deviceId }, "Generating token"); + + // Check if device is registered and disabled + const device = await prisma.deviceRegistration.findUnique({ + where: { deviceId: body.deviceId }, + }); + + if (device?.disabled) { + req.log.warn({ deviceId: body.deviceId }, "Device is disabled"); + res.status(403).json({ error: "Device is disabled" }); + return; + } + + // Generate JWT, this works even if device not registered yet + const token = await createV2JwtToken({ + deviceId: body.deviceId, + expirationTime: "15m", + }); + + req.log.info( + { deviceId: body.deviceId, deviceRegistered: !!device }, + "Token generated successfully", + ); + res.json({ token }); + return; + } catch (error) { + if (error instanceof z.ZodError) { + req.log.warn( + { errors: error.errors }, + "Invalid request body for generate-token", + ); + res.status(400).json({ error: "Invalid request body" }); + return; + } + req.log.error({ error }, "Failed to generate token"); + res.status(500).json({ error: "Failed to generate token" }); + return; + } +} diff --git a/src/api/v2/device/device.router.ts b/src/api/v2/device/device.router.ts new file mode 100644 index 00000000..764acb7a --- /dev/null +++ b/src/api/v2/device/device.router.ts @@ -0,0 +1,10 @@ +import { Router } from "express"; +import { register } from "./handlers/register"; + +const deviceRouter = Router(); + +// Device management routes +// All routes require auth (AppCheck or JWT) - applied at mount point in v2/index.ts +deviceRouter.post("/register", register); + +export { deviceRouter }; diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts new file mode 100644 index 00000000..152b4e0e --- /dev/null +++ b/src/api/v2/device/handlers/register.ts @@ -0,0 +1,160 @@ +import { ApnsEnvironmentSchema, PushTokenTypeSchema } from "@prisma-zod/index"; +import type { ApnsEnvironment, PushTokenType } from "@prisma/client"; +import type { Request, Response } from "express"; +import { z } from "zod"; +import { prisma } from "@/utils/prisma"; + +const registerRequestSchema = z.object({ + deviceId: z.string().uuid(), + pushToken: z + .string() + .optional() + .transform((val) => (!val || val.trim() === "" ? undefined : val)), + pushTokenType: PushTokenTypeSchema.optional(), + apnsEnv: ApnsEnvironmentSchema.nullable().optional(), +}); + +export type IRegisterRequestBody = z.infer; + +export async function register( + req: Request, + res: Response, +) { + try { + const body = registerRequestSchema.parse(req.body); + + // Reject FCM as it is not supported + if (body.pushTokenType === "fcm") { + req.log.warn( + { deviceId: body.deviceId }, + "FCM push token type is not supported", + ); + res.status(400).json({ + error: + "FCM push notifications are not supported. Only APNS is supported.", + }); + return; + } + + req.log.info( + { + deviceId: body.deviceId, + hasPushToken: !!body.pushToken, + pushTokenType: body.pushTokenType, + apnsEnv: body.apnsEnv, + }, + "Registering device", + ); + + // Build update data - only include fields that were explicitly provided + const updateData: { + pushTokenType?: PushTokenType; + apnsEnv?: ApnsEnvironment | null; + pushToken?: string | null; + } = {}; + + if (body.pushToken !== undefined) { + // Normalize: undefined or empty string -> null + updateData.pushToken = body.pushToken || null; + } + if (body.pushTokenType !== undefined) { + updateData.pushTokenType = body.pushTokenType; + } + if (body.apnsEnv !== undefined) { + updateData.apnsEnv = body.apnsEnv; + } + + // Use transaction to handle push token conflicts + // If this push token is already registered to a different device, + // we need to transfer ownership to the new device + await prisma.$transaction(async (tx) => { + // If a push token is provided, check if it's registered to another device + if (body.pushToken) { + const pushTokenType = body.pushTokenType ?? "apns"; + const apnsEnv = body.apnsEnv ?? null; + + // Find any other device with the same push token combination + const existingDevice = await tx.deviceRegistration.findFirst({ + where: { + pushToken: body.pushToken, + pushTokenType, + apnsEnv, + deviceId: { not: body.deviceId }, + }, + }); + + if (existingDevice) { + req.log.info( + { + oldDeviceId: existingDevice.deviceId, + newDeviceId: body.deviceId, + pushToken: body.pushToken, + }, + "Push token moving from old device to new device - clearing old registration", + ); + + // Clear the push token from the old device to avoid unique constraint violation + await tx.deviceRegistration.update({ + where: { deviceId: existingDevice.deviceId }, + data: { + pushToken: null, + pushTokenType: "apns", + apnsEnv: null, + }, + }); + } + } + + // Now upsert the new device registration + await tx.deviceRegistration.upsert({ + where: { deviceId: body.deviceId }, + create: { + deviceId: body.deviceId, + pushToken: body.pushToken ?? null, + pushTokenType: body.pushTokenType ?? "apns", + apnsEnv: body.apnsEnv ?? null, + }, + update: updateData, + }); + }); + + req.log.info( + { deviceId: body.deviceId, hasPushToken: !!body.pushToken }, + "Device registered successfully", + ); + + res.status(200).send(); + return; + } catch (error) { + if (error instanceof z.ZodError) { + req.log.warn( + { errors: error.errors }, + "Invalid request body for register", + ); + res.status(400).json({ error: "Invalid request body" }); + return; + } + + // Handle unexpected Prisma unique constraint violations (should be rare due to transaction logic) + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "P2002" + ) { + req.log.error( + { deviceId: req.body.deviceId, error }, + "Unexpected unique constraint violation - possible race condition", + ); + res.status(409).json({ + error: + "Push token already registered. Please retry or contact support.", + }); + return; + } + + req.log.error({ error }, "Failed to register device"); + res.status(500).json({ error: "Failed to register device" }); + return; + } +} diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 0087ad52..18be4fec 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,8 +1,26 @@ import { Router } from "express"; +import { webhookRouter } from "@/api/shared/notifications/webhook.router"; +import { appCheckOnlyMiddleware, authV2Middleware } from "@/middleware/v2/auth"; +import { authRouter } from "./auth/auth.router"; +import { deviceRouter } from "./device/device.router"; import invitesV2Router from "./invites/invites.router"; +import { notificationsRouter } from "./notifications/notifications.router"; const v2Router = Router(); v2Router.use("/invites", invitesV2Router); +v2Router.use("/auth", authRouter); +v2Router.use("/device", appCheckOnlyMiddleware, deviceRouter); +v2Router.use("/notifications", authV2Middleware, notificationsRouter); + +// XMTP webhook (shared with v1) +v2Router.use("/notifications/xmtp", webhookRouter); + +// Simple auth check endpoint (AppCheck or JWT) +v2Router.get("/auth-check", authV2Middleware, (req, res) => { + res.status(200).json({ + success: true, + }); +}); export default v2Router; diff --git a/src/api/v2/invites/handlers/decode-invite-slug.ts b/src/api/v2/invites/handlers/decode-invite-slug.ts index 24fc2a36..f7101d33 100644 --- a/src/api/v2/invites/handlers/decode-invite-slug.ts +++ b/src/api/v2/invites/handlers/decode-invite-slug.ts @@ -1,5 +1,6 @@ import { createHash } from "crypto"; import { fromBinary, toBinary } from "@bufbuild/protobuf"; +import { timestampDate } from "@bufbuild/protobuf/wkt"; import type { Request, Response } from "express"; import * as secp256k1 from "secp256k1"; import { z } from "zod"; @@ -25,6 +26,9 @@ type DecodedInvite = Pick< | "name" | "description" | "imageURL" + | "conversationExpiresAt" + | "expiresAt" + | "expiresAfterUse" >; // Maximum slug length to prevent DoS (browser URL limit is ~2048 chars) @@ -97,6 +101,9 @@ function decodeInviteSlug(slug: string): DecodedInvite { name: payload.name, description: payload.description, imageURL: payload.imageURL, + conversationExpiresAt: payload.conversationExpiresAt, + expiresAt: payload.expiresAt, + expiresAfterUse: payload.expiresAfterUse, }; } catch (error) { throw new Error( @@ -111,6 +118,9 @@ export type DecodeInviteSlugResponse = { name: string | null; description: string | null; imageURL: string | null; + conversationExpiresAt: string | null; + expiresAt: string | null; + expiresAfterUse: boolean; }; error?: string; message?: string; @@ -131,6 +141,13 @@ export async function decodeInviteSlugHandler( name: decoded.name ?? null, description: decoded.description ?? null, imageURL: decoded.imageURL ?? null, + conversationExpiresAt: decoded.conversationExpiresAt + ? timestampDate(decoded.conversationExpiresAt).toISOString() + : null, + expiresAt: decoded.expiresAt + ? timestampDate(decoded.expiresAt).toISOString() + : null, + expiresAfterUse: decoded.expiresAfterUse, }, }; diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts new file mode 100644 index 00000000..432ee04d --- /dev/null +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -0,0 +1,168 @@ +import type { Request, Response } from "express"; +import { hexToUint8Array } from "uint8array-extras"; +import { z } from "zod"; +import { createNotificationClient } from "@/notifications/client"; +import { prisma } from "@/utils/prisma"; + +const subscribeRequestSchema = z.object({ + deviceId: z.string().uuid(), + clientId: z.string().uuid(), + topics: z + .array( + z.object({ + topic: z.string(), + hmacKeys: z.array( + z.object({ + thirtyDayPeriodsSinceEpoch: z.number(), + key: z.string().regex(/^[0-9a-fA-F]+$/, "Invalid hex string"), + }), + ), + }), + ) + .min(1) + .max(100), +}); + +export type ISubscribeRequestBody = z.infer; + +const notificationClient = createNotificationClient(); + +export async function subscribe( + req: Request, + res: Response, +) { + try { + const body = subscribeRequestSchema.parse(req.body); + + req.log.info( + { + deviceId: body.deviceId, + clientId: body.clientId, + topicCount: body.topics.length, + }, + "Subscribing to topics", + ); + + // Verify device exists and is not disabled + const device = await prisma.deviceRegistration.findUnique({ + where: { deviceId: body.deviceId }, + }); + + if (!device) { + req.log.warn( + { deviceId: body.deviceId }, + "Device not found for subscribe", + ); + res.status(404).json({ error: "Device not found" }); + return; + } + + if (device.disabled) { + req.log.warn({ deviceId: body.deviceId }, "Device is disabled"); + res.status(403).json({ error: "Device is disabled" }); + return; + } + + if (!device.pushToken) { + req.log.warn( + { deviceId: body.deviceId }, + "Device has no push token registered", + ); + res.status(400).json({ error: "Device has no push token registered" }); + return; + } + + // Convert HMAC keys to Uint8Array + const subscriptions = body.topics.map((topic) => ({ + topic: topic.topic, + isSilent: false, + hmacKeys: topic.hmacKeys.map((key) => ({ + thirtyDayPeriodsSinceEpoch: key.thirtyDayPeriodsSinceEpoch, + key: hexToUint8Array(key.key), + })), + })); + + // Register installation with notification server + try { + await notificationClient.registerInstallation({ + installationId: body.clientId, + deliveryMechanism: { + deliveryMechanismType: { + case: + device.pushTokenType === "apns" + ? "apnsDeviceToken" + : "firebaseDeviceToken", + value: device.pushToken, + }, + }, + }); + + // Subscribe to topics + await notificationClient.subscribeWithMetadata({ + installationId: body.clientId, + subscriptions, + }); + } catch (remoteErr) { + // Compensate: best-effort delete installation to avoid orphaned state + try { + await notificationClient.deleteInstallation({ + installationId: body.clientId, + }); + } catch (cleanupErr) { + req.log.warn( + { error: cleanupErr, installationId: body.clientId }, + "Failed to cleanup installation after subscription failure", + ); + } + throw remoteErr; + } + + // Create or update client identifier record + try { + await prisma.clientIdentifier.upsert({ + where: { id: body.clientId }, + create: { + id: body.clientId, + deviceId: body.deviceId, + }, + // Refresh updatedAt by updating deviceId + update: { deviceId: body.deviceId }, + }); + } catch (dbErr) { + // Compensate: delete installation to maintain consistency + try { + await notificationClient.deleteInstallation({ + installationId: body.clientId, + }); + } catch (cleanupErr) { + req.log.warn( + { error: cleanupErr, installationId: body.clientId }, + "Failed to cleanup installation after DB failure", + ); + } + throw dbErr; + } + + req.log.info( + { deviceId: body.deviceId, clientId: body.clientId }, + "Subscribed successfully", + ); + res.status(200).send(); + } catch (error) { + if (error instanceof z.ZodError) { + req.log.warn( + { errors: error.errors }, + "Invalid request body for subscribe", + ); + res.status(400).json({ + error: "Invalid request body", + details: error.errors, + hint: "topics must be an array of objects with { topic: string, hmacKeys: [{ thirtyDayPeriodsSinceEpoch: number, key: string }] }", + }); + return; + } + req.log.error({ error }, "Failed to subscribe to topics"); + res.status(500).json({ error: "Failed to subscribe to topics" }); + return; + } +} diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts new file mode 100644 index 00000000..9978d46c --- /dev/null +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -0,0 +1,72 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { createNotificationClient } from "@/notifications/client"; +import { prisma } from "@/utils/prisma"; + +const unregisterParamsSchema = z.object({ + clientId: z.string().uuid(), +}); + +export type IUnregisterParams = z.infer; + +const notificationClient = createNotificationClient(); + +export async function unregister( + req: Request, + res: Response, +) { + try { + const params = unregisterParamsSchema.parse(req.params); + + req.log.info({ clientId: params.clientId }, "Unregistering client"); + + // Look up client + const client = await prisma.clientIdentifier.findUnique({ + where: { id: params.clientId }, + }); + + if (!client) { + req.log.warn( + { clientId: params.clientId }, + "Client not found for unregister", + ); + res.status(404).json({ error: "Client not found" }); + return; + } + + try { + await notificationClient.deleteInstallation({ + installationId: params.clientId, + }); + + await prisma.clientIdentifier.delete({ + where: { id: params.clientId }, + }); + + req.log.info( + { clientId: params.clientId }, + "Successfully cleaned up v2 client", + ); + res.status(200).send(); + return; + } catch (cleanupError) { + req.log.error( + { error: cleanupError, clientId: params.clientId }, + "Failed to cleanup v2 notification subscriptions during unregister()", + ); + throw cleanupError; + } + } catch (error) { + if (error instanceof z.ZodError) { + req.log.warn( + { errors: error.errors }, + "Invalid request parameters for unregister", + ); + res.status(400).json({ error: "Invalid request parameters" }); + return; + } + req.log.error({ error }, "Failed to unregister client"); + res.status(500).json({ error: "Failed to unregister client" }); + return; + } +} diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts new file mode 100644 index 00000000..bd7e6955 --- /dev/null +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -0,0 +1,63 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { createNotificationClient } from "@/notifications/client"; +import { prisma } from "@/utils/prisma"; + +const unsubscribeRequestSchema = z.object({ + clientId: z.string().uuid(), + topics: z.array(z.string()).min(1).max(100), +}); + +export type IUnsubscribeRequestBody = z.infer; + +const notificationClient = createNotificationClient(); + +export async function unsubscribe( + req: Request, + res: Response, +) { + try { + const body = unsubscribeRequestSchema.parse(req.body); + + req.log.info( + { clientId: body.clientId, topicCount: body.topics.length }, + "Unsubscribing from topics", + ); + + // Look up client + const client = await prisma.clientIdentifier.findUnique({ + where: { id: body.clientId }, + }); + + if (!client) { + req.log.warn( + { clientId: body.clientId }, + "Client not found for unsubscribe", + ); + res.status(404).json({ error: "Client not found" }); + return; + } + + // Unsubscribe from topics + await notificationClient.unsubscribe({ + installationId: body.clientId, + topics: body.topics, + }); + + req.log.info({ clientId: body.clientId }, "Unsubscribed successfully"); + res.status(200).send(); + return; + } catch (error) { + if (error instanceof z.ZodError) { + req.log.warn( + { errors: error.errors }, + "Invalid request body for unsubscribe", + ); + res.status(400).json({ error: "Invalid request body" }); + return; + } + req.log.error({ error }, "Failed to unsubscribe from topics"); + res.status(500).json({ error: "Failed to unsubscribe from topics" }); + return; + } +} diff --git a/src/api/v2/notifications/notifications.router.ts b/src/api/v2/notifications/notifications.router.ts new file mode 100644 index 00000000..cb70c65e --- /dev/null +++ b/src/api/v2/notifications/notifications.router.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { subscribe } from "./handlers/subscribe"; +import { unregister } from "./handlers/unregister"; +import { unsubscribe } from "./handlers/unsubscribe"; + +const notificationsRouter = Router(); + +// Push notification management routes +// All routes require auth (AppCheck or JWT) - applied at mount point in v2/index.ts +notificationsRouter.post("/subscribe", subscribe); +notificationsRouter.post("/unsubscribe", unsubscribe); +notificationsRouter.delete("/unregister/:clientId", unregister); + +export { notificationsRouter }; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..a6dd03b9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +/** + * Application Configuration + * + * Environment variables are cached at module load time for performance. + * This avoids repeated process.env lookups and provides a single source of truth. + */ + +// Validate required environment variables +if (!process.env.XMTP_NOTIFICATION_SECRET) { + throw new Error("XMTP_NOTIFICATION_SECRET is not configured"); +} + +if (!process.env.JWT_SECRET) { + throw new Error("JWT_SECRET is not configured"); +} + +if (!process.env.NOTIFICATION_SERVER_URL) { + throw new Error("NOTIFICATION_SERVER_URL is not configured"); +} + +// Cache environment variables +export const XMTP_NOTIFICATION_SECRET = process.env.XMTP_NOTIFICATION_SECRET; +export const JWT_SECRET = process.env.JWT_SECRET; + +// Validate JWT_SECRET is a non-empty string before encoding +if (typeof JWT_SECRET !== "string" || JWT_SECRET.trim().length === 0) { + throw new Error( + "Missing `JWT_SECRET`: set a non-empty string in environment before starting the app", + ); +} +export const JWT_SECRET_BYTES = new TextEncoder().encode(JWT_SECRET); +export const NOTIFICATION_SERVER_URL = process.env.NOTIFICATION_SERVER_URL; +export const NODE_ENV = process.env.NODE_ENV || "development"; +export const IS_PRODUCTION = process.env.NODE_ENV === "production"; +export const IS_DEVELOPMENT = process.env.NODE_ENV === "development"; diff --git a/src/globals.d.ts b/src/globals.d.ts index c42aeffe..8ad9b02b 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -1,6 +1,15 @@ +// TypeScript declaration merging to add types for Express res.locals +// This provides type safety when middleware sets values on res.locals declare namespace Express { interface Locals { + // API v1 auth middleware xmtpId: string; xmtpInstallationId: string; + + // api v2 auth middleware + deviceId: string; + jwtMetadata?: { + notificationExtensionOnly?: boolean; + }; } } diff --git a/src/index.ts b/src/index.ts index 30b0212c..83d8dc40 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import cors from "cors"; import express from "express"; import helmet from "helmet"; import apiRouter from "./api"; +import { IS_DEVELOPMENT } from "./config"; import { errorHandlerMiddleware } from "./middleware/errorHandler"; import { jsonMiddleware } from "./middleware/json"; import { noRouteMiddleware } from "./middleware/noRoute"; @@ -56,7 +57,7 @@ const port = process.env.PORT || 4000; const server = app.listen(port, () => { logger.info(`Convos API service is running on port ${port}`); - if (process.env.NODE_ENV == "development") { + if (IS_DEVELOPMENT) { const localIps = getLocalIpAddresses(); logger.info(`Available at: http://localhost:${port}`); localIps.forEach((ip) => { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 5dc73534..ef091f9f 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -10,6 +10,7 @@ import { } from "@opentelemetry/sdk-trace-base"; import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions"; import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { IS_PRODUCTION } from "./config"; // Use the default config for the OTLP trace exporter // This will connect to localhost:4317 @@ -21,7 +22,7 @@ const sdk = new NodeSDK({ traceExporter, spanProcessors: [ // Use a batch processor in production to avoid overloading the collector - process.env.NODE_ENV === "production" + IS_PRODUCTION ? new BatchSpanProcessor(traceExporter) : new SimpleSpanProcessor(traceExporter), ], diff --git a/src/middleware/errorHandler.ts b/src/middleware/errorHandler.ts index f1f5261b..7bce7488 100644 --- a/src/middleware/errorHandler.ts +++ b/src/middleware/errorHandler.ts @@ -1,6 +1,7 @@ import type express from "express"; import { type NextFunction, type Request, type Response } from "express"; import { ZodError } from "zod"; +import { IS_DEVELOPMENT } from "@/config"; import { AppError } from "../utils/errors"; // Express requires error handling middleware to have exactly 4 parameters @@ -37,7 +38,7 @@ function errorHandler( // Handle unknown errors return res.status(500).json({ error: "Internal Server Error", - ...(process.env.NODE_ENV === "development" && { + ...(IS_DEVELOPMENT && { message: err.message, }), }); diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index db024028..23410686 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -1,9 +1,18 @@ import { rateLimit } from "express-rate-limit"; -// limit API to 300 requests per 5 minutes +// General rate limit for API to 1000 requests per 5 minutes export const rateLimitMiddleware = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes - limit: 300, + limit: 1000, legacyHeaders: false, standardHeaders: "draft-8", }); + +// Stricter rate limiting for auth endpoints (JWT generation) +export const authRateLimitMiddleware = rateLimit({ + windowMs: 5 * 60 * 1000, // 5 minutes + limit: 100, // 100 requests per 5 minutes per IP + legacyHeaders: false, + standardHeaders: "draft-8", + message: "Too many authentication requests, please try again later", +}); diff --git a/src/middleware/v2/auth.ts b/src/middleware/v2/auth.ts new file mode 100644 index 00000000..6e83c6b1 --- /dev/null +++ b/src/middleware/v2/auth.ts @@ -0,0 +1,97 @@ +import type { NextFunction, Request, Response } from "express"; +import { verifyAppCheckToken } from "@/utils/firebase"; +import { verifyV2JwtToken } from "@/utils/v2/jwt"; + +export const AUTH_HEADER = "X-Convos-AuthToken"; +export const APPCHECK_HEADER = "X-Firebase-AppCheck"; + +export const appCheckOnlyMiddleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + const appCheckToken = req.header(APPCHECK_HEADER); + + req.log.info( + { + path: req.path, + method: req.method, + hasAppCheck: !!appCheckToken, + }, + "AppCheck-only middleware - incoming request", + ); + + if (!appCheckToken) { + req.log.warn("No AppCheck token provided"); + res.status(401).json({ error: "Missing AppCheck token" }); + return; + } + + try { + await verifyAppCheckToken(appCheckToken); + req.log.info("AppCheck verification successful"); + next(); + } catch (error) { + req.log.error({ error }, "AppCheck verification failed"); + res.status(401).json({ error: "Invalid AppCheck token" }); + } +}; + +export const authV2Middleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + const appCheckToken = req.header(APPCHECK_HEADER); + const authToken = req.header(AUTH_HEADER); + + req.log.info( + { + path: req.path, + method: req.method, + hasAppCheck: !!appCheckToken, + hasAuthToken: !!authToken, + }, + "V2 auth middleware - incoming request", + ); + + // Try AppCheck first (main app) + if (appCheckToken) { + try { + await verifyAppCheckToken(appCheckToken); + req.log.info("AppCheck verification successful"); + next(); + return; + } catch (error) { + req.log.error({ error }, "AppCheck verification failed"); + res.status(401).json({ error: "Invalid AppCheck token" }); + return; + } + } + + // Try JWT (NSE or Gateway) + if (authToken) { + try { + const payload = await verifyV2JwtToken({ token: authToken }); + // Store payload for handlers if needed + res.locals.deviceId = payload.deviceId; + res.locals.jwtMetadata = payload.metadata; + req.log.info( + { + deviceId: payload.deviceId, + isNSEOnly: payload.metadata?.notificationExtensionOnly, + }, + "V2 JWT verification successful", + ); + next(); + return; + } catch (error) { + req.log.error({ error }, "V2 JWT verification failed"); + res.status(401).json({ error: "Invalid auth token" }); + return; + } + } + + req.log.warn("No authentication headers provided"); + res.status(401).json({ error: "Missing authentication" }); +}; diff --git a/src/middleware/xmtpWebhookAuth.ts b/src/middleware/xmtpWebhookAuth.ts new file mode 100644 index 00000000..c4230b7e --- /dev/null +++ b/src/middleware/xmtpWebhookAuth.ts @@ -0,0 +1,58 @@ +import { timingSafeEqual } from "crypto"; +import type { NextFunction, Request, Response } from "express"; +import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; + +/** + * Middleware to verify XMTP webhook authorization header + * Validates that the request comes from the authorized XMTP notification server + */ +export const xmtpWebhookAuthMiddleware = ( + req: Request, + res: Response, + next: NextFunction, +) => { + // Fail closed: reject if webhook secret is not configured + let expectedAuthHeader: string; + try { + const expectedAuthHeaderRaw = getHttpDeliveryNotificationAuthHeader(); + if (!expectedAuthHeaderRaw || expectedAuthHeaderRaw.trim().length === 0) { + throw new Error("Webhook secret is empty"); + } + expectedAuthHeader = expectedAuthHeaderRaw.trim(); + } catch (error) { + req.log.error( + { error }, + "XMTP webhook secret not configured - rejecting request", + ); + res.status(500).json({ + error: "Server configuration error", + }); + return; + } + + const authHeader = req.headers.authorization?.trim() ?? ""; + + // Reject if no authorization header provided + if (authHeader.length === 0) { + req.log.error("Missing XMTP webhook authorization header"); + res.status(401).json({ + error: "Unauthorized: Invalid authentication token", + }); + return; + } + + const provided = Buffer.from(authHeader, "utf8"); + const expected = Buffer.from(expectedAuthHeader, "utf8"); + const valid = + provided.length === expected.length && timingSafeEqual(provided, expected); + + if (!valid) { + req.log.error("Invalid XMTP webhook authorization header"); + res.status(401).json({ + error: "Unauthorized: Invalid authentication token", + }); + return; + } + + next(); +}; diff --git a/src/notifications/client.ts b/src/notifications/client.ts index 37adaa92..a9d939b5 100644 --- a/src/notifications/client.ts +++ b/src/notifications/client.ts @@ -2,6 +2,8 @@ import { create } from "@bufbuild/protobuf"; import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-node"; import { type HmacKey } from "@xmtp/node-sdk"; +import { z } from "zod"; +import { NOTIFICATION_SERVER_URL } from "@/config"; import { Notifications, Subscription_HmacKeySchema, @@ -10,11 +12,8 @@ import { } from "@/gen/notifications/v1/service_pb"; export function createNotificationClient() { - if (!process.env.NOTIFICATION_SERVER_URL) { - throw new Error("NOTIFICATION_SERVER_URL is not set"); - } const transport = createConnectTransport({ - baseUrl: process.env.NOTIFICATION_SERVER_URL, + baseUrl: NOTIFICATION_SERVER_URL, httpVersion: "1.1", }); return createClient(Notifications, transport); @@ -25,30 +24,38 @@ export type Topic = { hmacKeys: HmacKey[]; }; -export type WebhookNotificationBody = { - idempotency_key: string; - message: { - content_topic: string; - timestamp_ns: string; - message: string; - }; - message_context: { - message_type: string; - should_push?: boolean; - }; - installation: { - id: string; - delivery_mechanism: { - kind: string; - token: string; - }; - }; - subscription: { - created_at: string; - topic: string; - is_silent: boolean; - }; -}; +// Zod schema for webhook notification validation +export const webhookNotificationBodySchema = z.object({ + idempotency_key: z.string(), + message: z.object({ + content_topic: z.string(), + // Accept both string and number for timestamp_ns (protobuf int64 compatibility) + timestamp_ns: z + .union([z.string(), z.number()]) + .transform((val) => (typeof val === "number" ? val.toString() : val)), + message: z.string(), + }), + message_context: z.object({ + message_type: z.string(), + should_push: z.boolean().optional(), + }), + installation: z.object({ + id: z.string(), + delivery_mechanism: z.object({ + kind: z.string(), + token: z.string(), + }), + }), + subscription: z.object({ + created_at: z.string(), + topic: z.string(), + is_silent: z.boolean(), + }), +}); + +export type WebhookNotificationBody = z.infer< + typeof webhookNotificationBodySchema +>; export async function subscribeToTopics( // The installationId we want to apply the subscription to diff --git a/src/notifications/utils.ts b/src/notifications/utils.ts index 2605146a..6ea4259c 100644 --- a/src/notifications/utils.ts +++ b/src/notifications/utils.ts @@ -1,6 +1,10 @@ +import { XMTP_NOTIFICATION_SECRET } from "@/config"; + export function getHttpDeliveryNotificationAuthHeader() { - if (!process.env.XMTP_NOTIFICATION_SECRET) { - throw new Error("XMTP_NOTIFICATION_SECRET is not set"); + if (!XMTP_NOTIFICATION_SECRET) { + throw new Error( + "XMTP_NOTIFICATION_SECRET is not configured - webhook authentication cannot proceed", + ); } - return process.env.XMTP_NOTIFICATION_SECRET; + return XMTP_NOTIFICATION_SECRET; } diff --git a/src/routes/healthcheck.ts b/src/routes/healthcheck.ts index 479bc2b8..5ac72608 100644 --- a/src/routes/healthcheck.ts +++ b/src/routes/healthcheck.ts @@ -58,16 +58,12 @@ router.get("/details", async (req: Request, res: Response): Promise => { logger.error("XMTP health check failed:", xmtpHealth.error); } - // Check notification service connectivity (optional, only if configured) + // Check notification service connectivity try { - if (process.env.NOTIFICATION_SERVER_URL) { - createNotificationClient(); - // We can't easily test the notification client without making a real call, - // so we just verify it was created successfully - checks.services.notifications.status = "healthy"; - } else { - checks.services.notifications.status = "not_configured"; - } + createNotificationClient(); + // We can't easily test the notification client without making a real call, + // so we just verify it was created successfully + checks.services.notifications.status = "healthy"; } catch (error) { checks.services.notifications.status = "unhealthy"; checks.services.notifications.error = diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts new file mode 100644 index 00000000..90e9c7c1 --- /dev/null +++ b/src/utils/v2/jwt.ts @@ -0,0 +1,91 @@ +import * as jose from "jose"; +import { z } from "zod"; +import { MAX_JWT_METADATA_SIZE } from "@/api/shared/notifications/constants"; +import { JWT_SECRET_BYTES } from "@/config"; +import { AppError } from "@/utils/errors"; +import logger from "@/utils/logger"; +import { tryCatch } from "@/utils/try-catch"; + +export type V2JWTMetadata = { + notificationExtensionOnly?: boolean; +}; + +export type V2JWTPayload = { + deviceId: string; + metadata?: V2JWTMetadata; +}; + +const v2JWTPayloadSchema = z.object({ + deviceId: z.string(), + metadata: z + .object({ + notificationExtensionOnly: z.boolean().optional(), + }) + .optional(), +}); + +export const createV2JwtToken = async (args: { + deviceId: string; + metadata?: V2JWTMetadata; + expirationTime?: string; +}) => { + // Validate metadata size to prevent JWT bloat + if (args.metadata) { + const metadataSize = new TextEncoder().encode( + JSON.stringify(args.metadata), + ).length; + if (metadataSize > MAX_JWT_METADATA_SIZE) { + throw new AppError( + 400, + `JWT metadata exceeds maximum size of ${MAX_JWT_METADATA_SIZE} bytes`, + ); + } + } + + const payload: V2JWTPayload = { + deviceId: args.deviceId, + }; + + // Add metadata if provided + if (args.metadata) { + payload.metadata = args.metadata; + } + + // Create JWT token + const { data: jwt, error: jwtError } = await tryCatch( + new jose.SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime(args.expirationTime ?? "15m") + .sign(JWT_SECRET_BYTES), + ); + + if (jwtError) { + logger.error(jwtError); + throw new AppError(500, "Failed to create JWT token", jwtError); + } + + return jwt; +}; + +export const verifyV2JwtToken = async (args: { token: string }) => { + const { data: verified, error: verifyError } = await tryCatch( + jose.jwtVerify(args.token, JWT_SECRET_BYTES), + ); + + if (verifyError) { + logger.error("V2 JWT verification failed", verifyError); + throw new AppError(401, "Invalid or expired token", verifyError); + } + + const parseResult = v2JWTPayloadSchema.safeParse(verified.payload); + if (!parseResult.success) { + throw new AppError(401, "Invalid JWT payload structure"); + } + + return parseResult.data; +}; + +export const isNotificationExtensionOnlyToken = (payload: V2JWTPayload) => { + return payload.metadata?.notificationExtensionOnly === true; +}; diff --git a/tests/invites-v2.test.ts b/tests/invites-v2.test.ts index df5a67d6..a89150cc 100644 --- a/tests/invites-v2.test.ts +++ b/tests/invites-v2.test.ts @@ -102,6 +102,9 @@ describe("V2 Invite API Tests", () => { name: string | null; description: string | null; imageURL: string | null; + conversationExpiresAt: string | null; + expiresAt: string | null; + expiresAfterUse: boolean; }; }; expect(data.success).toBe(true); @@ -109,9 +112,15 @@ describe("V2 Invite API Tests", () => { expect(data.data).toHaveProperty("name"); expect(data.data).toHaveProperty("description"); expect(data.data).toHaveProperty("imageURL"); + expect(data.data).toHaveProperty("conversationExpiresAt"); + expect(data.data).toHaveProperty("expiresAt"); + expect(data.data).toHaveProperty("expiresAfterUse"); expect(data.data.name).toBeNull(); expect(data.data.description).toBeNull(); expect(data.data.imageURL).toBeNull(); + expect(data.data.conversationExpiresAt).toBeNull(); + expect(data.data.expiresAt).toBeNull(); + expect(data.data.expiresAfterUse).toBe(false); }); }); diff --git a/tests/notifications-invites.test.ts b/tests/notifications-invites.test.ts index eb9c4ccb..c67f26f9 100644 --- a/tests/notifications-invites.test.ts +++ b/tests/notifications-invites.test.ts @@ -13,13 +13,13 @@ import { } from "bun:test"; import express from "express"; import { rimrafSync } from "rimraf"; -import type { RequestToJoinRequestBody } from "@/api/v1/invites/handlers/request-to-join"; -import invitesRouter from "@/api/v1/invites/invites.router"; import type { InviteJoinRequestNotificationData, NotificationPayload, -} from "@/api/v1/notifications/services/notifications-types"; -import * as PushService from "@/api/v1/notifications/services/push-notification.service"; +} from "@/api/shared/notifications/services/notifications-types"; +import * as PushService from "@/api/shared/notifications/services/push-notification.service"; +import type { RequestToJoinRequestBody } from "@/api/v1/invites/handlers/request-to-join"; +import invitesRouter from "@/api/v1/invites/invites.router"; import { jsonMiddleware } from "@/middleware/json"; import { pinoMiddleware } from "@/middleware/pino"; import { prisma } from "@/utils/prisma"; diff --git a/tests/notifications-service.test.ts b/tests/notifications-service.test.ts index 49f4b251..33bd8736 100644 --- a/tests/notifications-service.test.ts +++ b/tests/notifications-service.test.ts @@ -13,11 +13,11 @@ import { rimrafSync } from "rimraf"; import type { NotificationPayload, ProtocolNotificationData, -} from "@/api/v1/notifications/services/notifications-types"; +} from "@/api/shared/notifications/services/notifications-types"; import { getPushNotificationService, PushNotificationService, -} from "@/api/v1/notifications/services/push-notification.service"; +} from "@/api/shared/notifications/services/push-notification.service"; import { prisma } from "@/utils/prisma"; // Mock functions for APNS service @@ -170,7 +170,7 @@ describe("PushNotificationService", () => { expect(mockSendPushNotification).not.toHaveBeenCalled(); }); - test("handles FCM push token type (not implemented)", async () => { + test("rejects FCM push token type (not supported)", async () => { const fcmDevice = { ...testDevice, pushTokenType: "fcm" as const, diff --git a/tsconfig.json b/tsconfig.json index 72bb2c9f..72325f33 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,8 @@ "strict": true, "skipLibCheck": true, "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@prisma-zod/*": ["./prisma/generated/zod/*"] } }, "include": [