From 72587848adaca088480e8a7cac405e50b2520bd9 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 3 Oct 2025 19:19:01 +0200 Subject: [PATCH 01/51] Prisma model update --- prisma/schema.prisma | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ba69367d..34dbf143 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -176,3 +176,32 @@ model InviteCodeRequest { @@index([inviteCodeId]) @@index([requesterId]) } + +// v2 Push Notification Models +model PushTokenRegistration { + deviceId String @id + pushToken String @db.Text + tokenType 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[] + + @@index([pushToken]) +} + +model ClientIdentifier { + id String @id @default(cuid()) + clientIdentifier String @unique + deviceId String + device PushTokenRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade) + addedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([clientIdentifier]) + @@index([deviceId]) +} From e9d03eb70be5ca0ebd88f57cb9598cd4e0102cc2 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 3 Oct 2025 19:27:26 +0200 Subject: [PATCH 02/51] Data model migration --- .../migration.sql | 34 +++++++++++++++++++ prisma/schema.prisma | 14 ++++---- 2 files changed, 40 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql 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..99d62660 --- /dev/null +++ b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "PushTokenRegistration" ( + "deviceId" TEXT NOT NULL, + "pushToken" TEXT NOT NULL, + "tokenType" "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 "PushTokenRegistration_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 "PushTokenRegistration_pushToken_idx" ON "PushTokenRegistration"("pushToken"); + +-- CreateIndex +CREATE INDEX "ClientIdentifier_deviceId_idx" ON "ClientIdentifier"("deviceId"); + +-- AddForeignKey +ALTER TABLE "ClientIdentifier" ADD CONSTRAINT "ClientIdentifier_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "PushTokenRegistration"("deviceId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 34dbf143..cdf85daa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -195,13 +195,11 @@ model PushTokenRegistration { } model ClientIdentifier { - id String @id @default(cuid()) - clientIdentifier String @unique - deviceId String - device PushTokenRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade) - addedAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - @@index([clientIdentifier]) + id String @id + deviceId String + device PushTokenRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade) + addedAt DateTime @default(now()) + updatedAt DateTime @updatedAt + @@index([deviceId]) } From 79bf2d80784a0a12f6b77acecd28476fe83c427e Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 8 Oct 2025 17:57:59 +0200 Subject: [PATCH 03/51] Data model migration --- .../migration.sql | 8 ++++---- prisma/schema.prisma | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql index 99d62660..f880e8dc 100644 --- a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql +++ b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql @@ -1,5 +1,5 @@ -- CreateTable -CREATE TABLE "PushTokenRegistration" ( +CREATE TABLE "DeviceRegistration" ( "deviceId" TEXT NOT NULL, "pushToken" TEXT NOT NULL, "tokenType" "PushTokenType" NOT NULL DEFAULT 'apns', @@ -11,7 +11,7 @@ CREATE TABLE "PushTokenRegistration" ( "lastSentAt" TIMESTAMP(3), "lastFailureAt" TIMESTAMP(3), - CONSTRAINT "PushTokenRegistration_pkey" PRIMARY KEY ("deviceId") + CONSTRAINT "DeviceRegistration_pkey" PRIMARY KEY ("deviceId") ); -- CreateTable @@ -25,10 +25,10 @@ CREATE TABLE "ClientIdentifier" ( ); -- CreateIndex -CREATE INDEX "PushTokenRegistration_pushToken_idx" ON "PushTokenRegistration"("pushToken"); +CREATE INDEX "DeviceRegistration_pushToken_idx" ON "DeviceRegistration"("pushToken"); -- CreateIndex CREATE INDEX "ClientIdentifier_deviceId_idx" ON "ClientIdentifier"("deviceId"); -- AddForeignKey -ALTER TABLE "ClientIdentifier" ADD CONSTRAINT "ClientIdentifier_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "PushTokenRegistration"("deviceId") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ClientIdentifier" ADD CONSTRAINT "ClientIdentifier_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "DeviceRegistration"("deviceId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cdf85daa..5c2f2c67 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,7 +178,7 @@ model InviteCodeRequest { } // v2 Push Notification Models -model PushTokenRegistration { +model DeviceRegistration { deviceId String @id pushToken String @db.Text tokenType PushTokenType @default(apns) @@ -195,11 +195,11 @@ model PushTokenRegistration { } model ClientIdentifier { - id String @id + id String @id deviceId String - device PushTokenRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade) - addedAt DateTime @default(now()) - updatedAt DateTime @updatedAt + device DeviceRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade) + addedAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([deviceId]) } From 85ff2896036ade6e8c2f3547e25d93cc3343e50d Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 8 Oct 2025 18:49:37 +0200 Subject: [PATCH 04/51] Update prisma and prisma client --- bun.lock | 60 +++++++++++++++++++++++++++++++++++++++++++--------- package.json | 4 ++-- 2 files changed, 52 insertions(+), 12 deletions(-) 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..752e8ce7 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", @@ -79,7 +79,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" From a1f9304b00ccffaf3579c31b9fc14ec41d3f7881 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 14:05:17 +0200 Subject: [PATCH 05/51] Implement JWT v2 --- src/utils/v2/jwt.ts | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/utils/v2/jwt.ts diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts new file mode 100644 index 00000000..dd1e3d8e --- /dev/null +++ b/src/utils/v2/jwt.ts @@ -0,0 +1,76 @@ +import * as jose from "jose"; +import { AppError } from "@/utils/errors"; +import logger from "@/utils/logger"; +import { tryCatch } from "@/utils/try-catch"; + +export type V2JWTMetadata = { + notificationExtensionOnly?: boolean; + gatewayAuthorized?: boolean; +}; + +export type V2JWTPayload = { + clientIdentifier: string; + deviceId: string; + metadata?: V2JWTMetadata; +}; + +export const createV2JwtToken = async (args: { + clientIdentifier: string; + deviceId: string; + metadata?: V2JWTMetadata; + expirationTime?: string; +}) => { + if (!process.env.JWT_SECRET) { + throw new AppError(500, "JWT_SECRET is not set"); + } + + const payload: V2JWTPayload = { + clientIdentifier: args.clientIdentifier, + 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(new TextEncoder().encode(process.env.JWT_SECRET)), + ); + + if (jwtError) { + logger.error(jwtError); + throw new AppError(500, "Failed to create JWT token", jwtError); + } + + return jwt; +}; + +export const verifyV2JwtToken = async (args: { token: string }) => { + if (!process.env.JWT_SECRET) { + throw new AppError(500, "JWT_SECRET is not set"); + } + + const { data: verified, error: verifyError } = await tryCatch( + jose.jwtVerify( + args.token, + new TextEncoder().encode(process.env.JWT_SECRET), + ), + ); + + if (verifyError) { + logger.error("V2 JWT verification failed", verifyError); + throw new AppError(401, "Invalid or expired token", verifyError); + } + + return verified.payload as V2JWTPayload; +}; + +export const isNotificationExtensionOnlyToken = (payload: V2JWTPayload) => { + return payload.metadata?.notificationExtensionOnly === true; +}; From 849d6e6c1c38438b7904e51abc2f8bd269d147b9 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 14:10:21 +0200 Subject: [PATCH 06/51] Implement auth and notifications handlers --- src/api/v2/auth/handlers/generate-token.ts | 58 +++++++++++++++++++ src/api/v2/notifications/handlers/register.ts | 47 +++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/api/v2/auth/handlers/generate-token.ts create mode 100644 src/api/v2/notifications/handlers/register.ts 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..5bfc9a55 --- /dev/null +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -0,0 +1,58 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { prisma } from "@/utils/prisma"; +import { createV2JwtToken } from "@/utils/v2/jwt"; + +const generateTokenRequestSchema = z.object({ + clientIdentifier: z.string(), + deviceId: z.string(), +}); + +export type IGenerateTokenRequestBody = z.infer< + typeof generateTokenRequestSchema +>; + +export async function generateToken( + req: Request, + res: Response, +) { + try { + const body = generateTokenRequestSchema.parse(req.body); + + // Validate client exists and belongs to device + const client = await prisma.clientIdentifier.findUnique({ + where: { id: body.clientIdentifier }, + include: { device: true }, + }); + + if (!client || client.deviceId !== body.deviceId) { + res.status(404).json({ error: "Client not found" }); + return; + } + + // Check if device is disabled + if (client.device.disabled) { + res.status(403).json({ error: "Device is disabled" }); + return; + } + + // Generate JWT + const token = await createV2JwtToken({ + clientIdentifier: body.clientIdentifier, + deviceId: body.deviceId, + expirationTime: "15m", // Short-lived for app-generated requests + metadata: { + gatewayAuthorized: true, + }, + }); + + res.json({ token }); + } catch (error) { + if (error instanceof z.ZodError) { + 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" }); + } +} diff --git a/src/api/v2/notifications/handlers/register.ts b/src/api/v2/notifications/handlers/register.ts new file mode 100644 index 00000000..0eb7b9cd --- /dev/null +++ b/src/api/v2/notifications/handlers/register.ts @@ -0,0 +1,47 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { prisma } from "@/utils/prisma"; +import { ApnsEnvironmentSchema, PushTokenTypeSchema } from "../../../../../prisma/generated/zod"; + +const registerRequestSchema = z.object({ + deviceId: z.string(), + pushToken: z.string(), + tokenType: 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); + + await prisma.deviceRegistration.upsert({ + where: { deviceId: body.deviceId }, + create: { + deviceId: body.deviceId, + pushToken: body.pushToken, + tokenType: body.tokenType ?? "apns", + apnsEnv: body.apnsEnv ?? null, + }, + update: { + pushToken: body.pushToken, + tokenType: body.tokenType ?? "apns", + apnsEnv: body.apnsEnv ?? null, + updatedAt: new Date(), + }, + }); + + res.status(200).send(); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid request body" }); + return; + } + req.log.error({ error }, "Failed to register device"); + res.status(500).json({ error: "Failed to register device" }); + } +} From 5778b6f181e039fafd09d8b28ae6d08e50897468 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 14:31:43 +0200 Subject: [PATCH 07/51] WIP notifications handlers --- .../handlers/handle-xmtp-notification.ts | 121 +++++++++++++++++- src/api/v2/auth/auth.router.ts | 17 +++ src/api/v2/index.ts | 4 + .../v2/notifications/handlers/subscribe.ts | 100 +++++++++++++++ .../v2/notifications/handlers/unregister.ts | 50 ++++++++ .../v2/notifications/handlers/unsubscribe.ts | 49 +++++++ .../v2/notifications/notifications.router.ts | 20 +++ src/middleware/v2/auth.ts | 45 +++++++ 8 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 src/api/v2/auth/auth.router.ts create mode 100644 src/api/v2/notifications/handlers/subscribe.ts create mode 100644 src/api/v2/notifications/handlers/unregister.ts create mode 100644 src/api/v2/notifications/handlers/unsubscribe.ts create mode 100644 src/api/v2/notifications/notifications.router.ts create mode 100644 src/middleware/v2/auth.ts diff --git a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts index 1fa55272..d41e534e 100644 --- a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts +++ b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts @@ -1,11 +1,14 @@ import type { Request, Response } from "express"; +import type { ClientIdentifier, DeviceRegistration } from "@prisma/client"; import { createNotificationClient, type WebhookNotificationBody, } from "@/notifications/client"; import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; import { prisma } from "@/utils/prisma"; +import { createV2JwtToken } from "@/utils/v2/jwt"; import { getPushNotificationService } from "../services/push-notification.service"; +import { createApnsService } from "../services/apns-push.service"; const notificationClient = createNotificationClient(); const pushNotificationService = getPushNotificationService(); @@ -51,6 +54,24 @@ export async function handleXmtpNotification(req: Request, res: Response) { return; } + // TRY V2 FIRST (clientIdentifier lookup) + const v2Client = await prisma.clientIdentifier.findUnique({ + where: { id: notification.installation.id }, + include: { device: true }, + }); + + if (v2Client) { + req.log.info("Processing v2 notification"); + const result = 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, @@ -63,14 +84,16 @@ export async function handleXmtpNotification(req: Request, res: Response) { if (!identityOnDevice || !identityOnDevice.xmtpInstallationId) { req.log.error( - `IdentityOnDevice not found for xmtpInstallationId ${notification.installation.id}`, + `Installation not found (v1 or v2) for installationId ${notification.installation.id}`, ); res.status(400).json({ - error: `IdentityOnDevice not found for xmtpInstallationId ${notification.installation.id}`, + error: `Installation not found for installationId ${notification.installation.id}`, }); return; } + req.log.info("Processing v1 notification"); + identityOnDeviceToCleanup = { xmtpInstallationId: identityOnDevice.xmtpInstallationId, deviceId: identityOnDevice.deviceId, @@ -154,3 +177,97 @@ async function cleanupFailedInstallation(args: { ); } } + +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 >= 10) { + req.log.warn( + `Device ${client.deviceId} is disabled or has too many failures. Skipping notification.`, + ); + return { success: false }; + } + + // Generate JWT for NSE to use + const apiJWT = await createV2JwtToken({ + clientIdentifier: client.id, + deviceId: client.deviceId, + expirationTime: "72h", + metadata: { + notificationExtensionOnly: true, + }, + }); + + // Create APNS service + const apnsService = createApnsService(); + + if (!apnsService) { + req.log.error("APNS service not configured"); + return { success: false }; + } + + // Send push notification + const result = await apnsService.sendPushNotification({ + device: { + id: client.deviceId, + pushToken: client.device.pushToken, + pushTokenType: client.device.tokenType, + apnsEnv: client.device.apnsEnv, + pushFailures: client.device.pushFailures, + } as any, + notification: { + clientIdentifier: client.id, + apiJWT, + notificationType: "Protocol", + notificationData: { + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + encryptedMessage: notification.message.message, + timestamp: notification.message.timestamp_ns, + }, + } as any, + }); + + // Track success/failure + if (result.success) { + await prisma.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { + pushFailures: 0, + lastSentAt: new Date(), + }, + }); + req.log.info(`Successfully sent v2 push notification to ${client.deviceId}`); + } else { + const newFailureCount = client.device.pushFailures + 1; + await prisma.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { + pushFailures: newFailureCount, + lastFailureAt: new Date(), + disabled: newFailureCount >= 10, + }, + }); + req.log.warn( + `Failed to send v2 push notification to ${client.deviceId}. Failure count: ${newFailureCount}`, + ); + + // Cleanup if unrecoverable error + if (result.error === "DeviceNotRegistered" || result.error === "BadDeviceToken") { + req.log.info(`Cleaning up v2 client ${client.id} due to unrecoverable error`); + await notificationClient.deleteInstallation({ + installationId: client.id, + }); + await prisma.clientIdentifier.delete({ + where: { id: client.id }, + }); + } + } + + return result; +} diff --git a/src/api/v2/auth/auth.router.ts b/src/api/v2/auth/auth.router.ts new file mode 100644 index 00000000..7d1ff1ae --- /dev/null +++ b/src/api/v2/auth/auth.router.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; +import { APPCHECK_HEADER, authV2Middleware } from "@/middleware/v2/auth"; +import { generateToken } from "./handlers/generate-token"; + +const authRouter = Router(); + +// Token generation requires AppCheck (main app only) +authRouter.post("/token", async (req, res, next) => { + const appCheckToken = req.header(APPCHECK_HEADER); + if (!appCheckToken) { + res.status(401).json({ error: "AppCheck required for token generation" }); + return; + } + await authV2Middleware(req, res, next); +}, generateToken); + +export { authRouter }; diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 0087ad52..d5e37c38 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,8 +1,12 @@ import { Router } from "express"; import invitesV2Router from "./invites/invites.router"; +import { authRouter } from "./auth/auth.router"; +import { notificationsRouter } from "./notifications/notifications.router"; const v2Router = Router(); v2Router.use("/invites", invitesV2Router); +v2Router.use("/auth", authRouter); +v2Router.use("/notifications", notificationsRouter); export default v2Router; diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts new file mode 100644 index 00000000..fcbee67a --- /dev/null +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -0,0 +1,100 @@ +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({ + clientIdentifier: z.string(), + topics: z.array( + z.object({ + topic: z.string(), + hmacKeys: z.array( + z.object({ + thirtyDayPeriodsSinceEpoch: z.number(), + key: z.string(), + }), + ), + }), + ), +}); + +export type ISubscribeRequestBody = z.infer; + +const notificationClient = createNotificationClient(); + +export async function subscribe( + req: Request, + res: Response, +) { + try { + const body = subscribeRequestSchema.parse(req.body); + + // Look up client and device + const client = await prisma.clientIdentifier.findUnique({ + where: { id: body.clientIdentifier }, + include: { device: true }, + }); + + if (!client) { + res.status(404).json({ error: "Client not found" }); + return; + } + + if (client.device.disabled) { + res.status(403).json({ error: "Device is disabled" }); + 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 + await notificationClient.registerInstallation({ + installationId: body.clientIdentifier, + deliveryMechanism: { + deliveryMechanismType: { + case: + client.device.tokenType === "apns" + ? "apnsDeviceToken" + : "firebaseDeviceToken", + value: client.device.pushToken, + }, + }, + }); + + // Subscribe to topics + await notificationClient.subscribeWithMetadata({ + installationId: body.clientIdentifier, + subscriptions, + }); + + // Create or update client identifier record + await prisma.clientIdentifier.upsert({ + where: { id: body.clientIdentifier }, + create: { + id: body.clientIdentifier, + deviceId: client.deviceId, + }, + update: { + updatedAt: new Date(), + }, + }); + + res.status(200).send(); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: "Invalid request body" }); + return; + } + req.log.error({ error }, "Failed to subscribe to topics"); + res.status(500).json({ error: "Failed to subscribe to topics" }); + } +} diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts new file mode 100644 index 00000000..1424c41f --- /dev/null +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -0,0 +1,50 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { createNotificationClient } from "@/notifications/client"; +import { prisma } from "@/utils/prisma"; + +const unregisterParamsSchema = z.object({ + clientIdentifier: z.string(), +}); + +export type IUnregisterParams = z.infer; + +const notificationClient = createNotificationClient(); + +export async function unregister( + req: Request, + res: Response, +) { + try { + const params = unregisterParamsSchema.parse(req.params); + + // Look up client + const client = await prisma.clientIdentifier.findUnique({ + where: { id: params.clientIdentifier }, + }); + + if (!client) { + res.status(404).json({ error: "Client not found" }); + return; + } + + // Delete installation from notification server + await notificationClient.deleteInstallation({ + installationId: params.clientIdentifier, + }); + + // Delete client from database + await prisma.clientIdentifier.delete({ + where: { id: params.clientIdentifier }, + }); + + res.status(200).send(); + } catch (error) { + if (error instanceof z.ZodError) { + 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" }); + } +} diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts new file mode 100644 index 00000000..9afea4ac --- /dev/null +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -0,0 +1,49 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import { createNotificationClient } from "@/notifications/client"; +import { prisma } from "@/utils/prisma"; + +const unsubscribeRequestSchema = z.object({ + clientIdentifier: z.string(), + topics: z.array(z.string()), +}); + +export type IUnsubscribeRequestBody = z.infer< + typeof unsubscribeRequestSchema +>; + +const notificationClient = createNotificationClient(); + +export async function unsubscribe( + req: Request, + res: Response, +) { + try { + const body = unsubscribeRequestSchema.parse(req.body); + + // Look up client + const client = await prisma.clientIdentifier.findUnique({ + where: { id: body.clientIdentifier }, + }); + + if (!client) { + res.status(404).json({ error: "Client not found" }); + return; + } + + // Unsubscribe from topics + await notificationClient.unsubscribe({ + installationId: body.clientIdentifier, + topics: body.topics, + }); + + res.status(200).send(); + } catch (error) { + if (error instanceof z.ZodError) { + 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" }); + } +} diff --git a/src/api/v2/notifications/notifications.router.ts b/src/api/v2/notifications/notifications.router.ts new file mode 100644 index 00000000..44c652dd --- /dev/null +++ b/src/api/v2/notifications/notifications.router.ts @@ -0,0 +1,20 @@ +import { Router } from "express"; +import { authV2Middleware } from "@/middleware/v2/auth"; +import { register } from "./handlers/register"; +import { subscribe } from "./handlers/subscribe"; +import { unsubscribe } from "./handlers/unsubscribe"; +import { unregister } from "./handlers/unregister"; + +const notificationsRouter = Router(); + +// All routes require auth (AppCheck or JWT) +notificationsRouter.post("/register", authV2Middleware, register); +notificationsRouter.post("/subscribe", authV2Middleware, subscribe); +notificationsRouter.post("/unsubscribe", authV2Middleware, unsubscribe); +notificationsRouter.delete( + "/unregister/:clientIdentifier", + authV2Middleware, + unregister, +); + +export { notificationsRouter }; diff --git a/src/middleware/v2/auth.ts b/src/middleware/v2/auth.ts new file mode 100644 index 00000000..8e36f4e4 --- /dev/null +++ b/src/middleware/v2/auth.ts @@ -0,0 +1,45 @@ +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 authV2Middleware = async ( + req: Request, + res: Response, + next: NextFunction, +) => { + const appCheckToken = req.header(APPCHECK_HEADER); + const authToken = req.header(AUTH_HEADER); + + // Try AppCheck first (main app) + if (appCheckToken) { + try { + await verifyAppCheckToken(appCheckToken); + return next(); + } 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.clientIdentifier = payload.clientIdentifier; + res.locals.deviceId = payload.deviceId; + res.locals.jwtMetadata = payload.metadata; + return next(); + } catch (error) { + req.log.error({ error }, "V2 JWT verification failed"); + res.status(401).json({ error: "Invalid auth token" }); + return; + } + } + + res.status(401).json({ error: "Missing authentication" }); +}; From fd1fbe6d03d27c0f38ea1b04460c2c65fdadb1c4 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 14:44:38 +0200 Subject: [PATCH 08/51] Add path alias for prisma-zod --- src/api/v1/devices/handlers/create-device.handler.ts | 2 +- src/api/v1/devices/handlers/update-device.handler.ts | 2 +- src/api/v1/invites/handlers/update-invite-code.ts | 2 +- src/api/v1/notifications/handlers/register-installation.ts | 2 +- src/api/v2/notifications/handlers/register.ts | 2 +- src/api/v2/notifications/handlers/unsubscribe.ts | 4 +--- tsconfig.json | 3 ++- 7 files changed, 8 insertions(+), 9 deletions(-) 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/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/register-installation.ts b/src/api/v1/notifications/handlers/register-installation.ts index 3ef7556d..0edf5ddc 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(), diff --git a/src/api/v2/notifications/handlers/register.ts b/src/api/v2/notifications/handlers/register.ts index 0eb7b9cd..a719fe80 100644 --- a/src/api/v2/notifications/handlers/register.ts +++ b/src/api/v2/notifications/handlers/register.ts @@ -1,7 +1,7 @@ +import { ApnsEnvironmentSchema, PushTokenTypeSchema } from "@prisma-zod/index"; import type { Request, Response } from "express"; import { z } from "zod"; import { prisma } from "@/utils/prisma"; -import { ApnsEnvironmentSchema, PushTokenTypeSchema } from "../../../../../prisma/generated/zod"; const registerRequestSchema = z.object({ deviceId: z.string(), diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts index 9afea4ac..a5ed1633 100644 --- a/src/api/v2/notifications/handlers/unsubscribe.ts +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -8,9 +8,7 @@ const unsubscribeRequestSchema = z.object({ topics: z.array(z.string()), }); -export type IUnsubscribeRequestBody = z.infer< - typeof unsubscribeRequestSchema ->; +export type IUnsubscribeRequestBody = z.infer; const notificationClient = createNotificationClient(); 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": [ From 0b26361d8dc66215546053eb90b7edc477446732 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 14:47:47 +0200 Subject: [PATCH 09/51] Fix type and formatting --- .../handlers/handle-xmtp-notification.ts | 17 ++++++++++++----- src/api/v2/auth/auth.router.ts | 11 ++--------- src/api/v2/index.ts | 2 +- .../v2/notifications/notifications.router.ts | 2 +- 4 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts index d41e534e..d154aeb0 100644 --- a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts +++ b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts @@ -1,5 +1,5 @@ -import type { Request, Response } from "express"; import type { ClientIdentifier, DeviceRegistration } from "@prisma/client"; +import type { Request, Response } from "express"; import { createNotificationClient, type WebhookNotificationBody, @@ -7,8 +7,8 @@ import { import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; import { prisma } from "@/utils/prisma"; import { createV2JwtToken } from "@/utils/v2/jwt"; -import { getPushNotificationService } from "../services/push-notification.service"; import { createApnsService } from "../services/apns-push.service"; +import { getPushNotificationService } from "../services/push-notification.service"; const notificationClient = createNotificationClient(); const pushNotificationService = getPushNotificationService(); @@ -242,7 +242,9 @@ async function handleV2Notification(args: { lastSentAt: new Date(), }, }); - req.log.info(`Successfully sent v2 push notification to ${client.deviceId}`); + req.log.info( + `Successfully sent v2 push notification to ${client.deviceId}`, + ); } else { const newFailureCount = client.device.pushFailures + 1; await prisma.deviceRegistration.update({ @@ -258,8 +260,13 @@ async function handleV2Notification(args: { ); // Cleanup if unrecoverable error - if (result.error === "DeviceNotRegistered" || result.error === "BadDeviceToken") { - req.log.info(`Cleaning up v2 client ${client.id} due to unrecoverable error`); + if ( + result.error === "DeviceNotRegistered" || + result.error === "BadDeviceToken" + ) { + req.log.info( + `Cleaning up v2 client ${client.id} due to unrecoverable error`, + ); await notificationClient.deleteInstallation({ installationId: client.id, }); diff --git a/src/api/v2/auth/auth.router.ts b/src/api/v2/auth/auth.router.ts index 7d1ff1ae..bb1680fd 100644 --- a/src/api/v2/auth/auth.router.ts +++ b/src/api/v2/auth/auth.router.ts @@ -1,17 +1,10 @@ import { Router } from "express"; -import { APPCHECK_HEADER, authV2Middleware } from "@/middleware/v2/auth"; +import { authV2Middleware } from "@/middleware/v2/auth"; import { generateToken } from "./handlers/generate-token"; const authRouter = Router(); // Token generation requires AppCheck (main app only) -authRouter.post("/token", async (req, res, next) => { - const appCheckToken = req.header(APPCHECK_HEADER); - if (!appCheckToken) { - res.status(401).json({ error: "AppCheck required for token generation" }); - return; - } - await authV2Middleware(req, res, next); -}, generateToken); +authRouter.post("/token", authV2Middleware, generateToken); export { authRouter }; diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index d5e37c38..e9e47290 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import invitesV2Router from "./invites/invites.router"; import { authRouter } from "./auth/auth.router"; +import invitesV2Router from "./invites/invites.router"; import { notificationsRouter } from "./notifications/notifications.router"; const v2Router = Router(); diff --git a/src/api/v2/notifications/notifications.router.ts b/src/api/v2/notifications/notifications.router.ts index 44c652dd..16aef969 100644 --- a/src/api/v2/notifications/notifications.router.ts +++ b/src/api/v2/notifications/notifications.router.ts @@ -2,8 +2,8 @@ import { Router } from "express"; import { authV2Middleware } from "@/middleware/v2/auth"; import { register } from "./handlers/register"; import { subscribe } from "./handlers/subscribe"; -import { unsubscribe } from "./handlers/unsubscribe"; import { unregister } from "./handlers/unregister"; +import { unsubscribe } from "./handlers/unsubscribe"; const notificationsRouter = Router(); From c27648f1ef616e39c4b67d0281eecdb8f885a2b1 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 14:58:44 +0200 Subject: [PATCH 10/51] Fix lint and types --- .../handlers/handle-xmtp-notification.ts | 56 ++++++++++++------- .../services/notifications-types.ts | 8 +++ src/middleware/v2/auth.ts | 6 +- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts index d154aeb0..d14907d4 100644 --- a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts +++ b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts @@ -8,6 +8,10 @@ import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; import { prisma } from "@/utils/prisma"; import { createV2JwtToken } from "@/utils/v2/jwt"; import { createApnsService } from "../services/apns-push.service"; +import type { + NotificationPayloadWithJWTToken, + V2NotificationPayload, +} from "../services/notifications-types"; import { getPushNotificationService } from "../services/push-notification.service"; const notificationClient = createNotificationClient(); @@ -62,7 +66,7 @@ export async function handleXmtpNotification(req: Request, res: Response) { if (v2Client) { req.log.info("Processing v2 notification"); - const result = await handleV2Notification({ + await handleV2Notification({ notification, client: v2Client, req, @@ -211,26 +215,38 @@ async function handleV2Notification(args: { return { success: false }; } - // Send push notification + // Send push notification with v2 types + const v2Notification: V2NotificationPayload = { + clientIdentifier: client.id, + apiJWT, + notificationType: "Protocol", + notificationData: { + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + encryptedMessage: notification.message.message, + timestamp: notification.message.timestamp_ns, + }, + }; + + // Create a device-like object for APNS service + const deviceForApns = { + id: client.deviceId, + pushToken: client.device.pushToken, + pushTokenType: client.device.tokenType, + 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: { - id: client.deviceId, - pushToken: client.device.pushToken, - pushTokenType: client.device.tokenType, - apnsEnv: client.device.apnsEnv, - pushFailures: client.device.pushFailures, - } as any, - notification: { - clientIdentifier: client.id, - apiJWT, - notificationType: "Protocol", - notificationData: { - contentTopic: notification.message.content_topic, - messageType: notification.message_context.message_type, - encryptedMessage: notification.message.message, - timestamp: notification.message.timestamp_ns, - }, - } as any, + device: deviceForApns, + notification: v2Notification as unknown as NotificationPayloadWithJWTToken, }); // Track success/failure diff --git a/src/api/v1/notifications/services/notifications-types.ts b/src/api/v1/notifications/services/notifications-types.ts index 6fac21e8..b8e4721b 100644 --- a/src/api/v1/notifications/services/notifications-types.ts +++ b/src/api/v1/notifications/services/notifications-types.ts @@ -54,3 +54,11 @@ export type NotificationPayloadFor = export type NotificationPayloadWithJWTToken = NotificationPayload & { apiJWT: string; }; + +// V2 notification types +export type V2NotificationPayload = { + clientIdentifier: string; + apiJWT: string; + notificationType: "Protocol"; + notificationData: ProtocolNotificationData; +}; diff --git a/src/middleware/v2/auth.ts b/src/middleware/v2/auth.ts index 8e36f4e4..fcd8934c 100644 --- a/src/middleware/v2/auth.ts +++ b/src/middleware/v2/auth.ts @@ -17,7 +17,8 @@ export const authV2Middleware = async ( if (appCheckToken) { try { await verifyAppCheckToken(appCheckToken); - return next(); + next(); + return; } catch (error) { req.log.error({ error }, "AppCheck verification failed"); res.status(401).json({ error: "Invalid AppCheck token" }); @@ -33,7 +34,8 @@ export const authV2Middleware = async ( res.locals.clientIdentifier = payload.clientIdentifier; res.locals.deviceId = payload.deviceId; res.locals.jwtMetadata = payload.metadata; - return next(); + next(); + return; } catch (error) { req.log.error({ error }, "V2 JWT verification failed"); res.status(401).json({ error: "Invalid auth token" }); From 79f8ef254c6422b022a2db2e1f4639d0562b9b21 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 15:18:43 +0200 Subject: [PATCH 11/51] Share the webhook between v1 and v2 API versions --- scripts/simple-push-test.ts | 2 +- scripts/test-push-notification.ts | 2 +- .../services/apns-push.service.ts | 0 .../services/notifications-types.ts | 0 .../services/push-notification.service.ts | 0 .../shared/notifications/webhook-handler.ts | 296 +++++++++++++++++ .../shared/notifications/webhook.router.ts | 9 + src/api/v1/index.ts | 8 +- .../v1/invites/handlers/request-to-join.ts | 4 +- .../handlers/handle-xmtp-notification.ts | 299 +----------------- .../v1/notifications/notifications.router.ts | 7 +- src/api/v2/index.ts | 4 + tests/notifications-invites.test.ts | 8 +- tests/notifications-service.test.ts | 4 +- 14 files changed, 326 insertions(+), 317 deletions(-) rename src/api/{v1 => shared}/notifications/services/apns-push.service.ts (100%) rename src/api/{v1 => shared}/notifications/services/notifications-types.ts (100%) rename src/api/{v1 => shared}/notifications/services/push-notification.service.ts (100%) create mode 100644 src/api/shared/notifications/webhook-handler.ts create mode 100644 src/api/shared/notifications/webhook.router.ts 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/v1/notifications/services/apns-push.service.ts b/src/api/shared/notifications/services/apns-push.service.ts similarity index 100% rename from src/api/v1/notifications/services/apns-push.service.ts rename to src/api/shared/notifications/services/apns-push.service.ts diff --git a/src/api/v1/notifications/services/notifications-types.ts b/src/api/shared/notifications/services/notifications-types.ts similarity index 100% rename from src/api/v1/notifications/services/notifications-types.ts rename to src/api/shared/notifications/services/notifications-types.ts diff --git a/src/api/v1/notifications/services/push-notification.service.ts b/src/api/shared/notifications/services/push-notification.service.ts similarity index 100% rename from src/api/v1/notifications/services/push-notification.service.ts rename to src/api/shared/notifications/services/push-notification.service.ts diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts new file mode 100644 index 00000000..09ed067e --- /dev/null +++ b/src/api/shared/notifications/webhook-handler.ts @@ -0,0 +1,296 @@ +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 { + NotificationPayloadWithJWTToken, + V2NotificationPayload, +} from "@/api/shared/notifications/services/notifications-types"; +import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service"; +import { + createNotificationClient, + type WebhookNotificationBody, +} from "@/notifications/client"; +import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; +import { prisma } from "@/utils/prisma"; +import { createV2JwtToken } from "@/utils/v2/jwt"; + +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; + } + + // TRY V2 FIRST (clientIdentifier lookup) + const v2Client = await prisma.clientIdentifier.findUnique({ + where: { id: notification.installation.id }, + include: { device: true }, + }); + + if (v2Client) { + req.log.info("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 (v1 or v2) for installationId ${notification.installation.id}`, + ); + res.status(400).json({ + error: `Installation not found for installationId ${notification.installation.id}`, + }); + return; + } + + req.log.info("Processing v1 notification"); + + 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", + ); + } +} + +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 >= 10) { + req.log.warn( + `Device ${client.deviceId} is disabled or has too many failures. Skipping notification.`, + ); + return { success: false }; + } + + // Generate JWT for NSE to use + const apiJWT = await createV2JwtToken({ + clientIdentifier: client.id, + deviceId: client.deviceId, + expirationTime: "72h", + metadata: { + notificationExtensionOnly: true, + }, + }); + + // Create APNS service + const apnsService = createApnsService(); + + if (!apnsService) { + req.log.error("APNS service not configured"); + return { success: false }; + } + + // Send push notification with v2 types + const v2Notification: V2NotificationPayload = { + clientIdentifier: client.id, + apiJWT, + notificationType: "Protocol", + notificationData: { + contentTopic: notification.message.content_topic, + messageType: notification.message_context.message_type, + encryptedMessage: notification.message.message, + timestamp: notification.message.timestamp_ns, + }, + }; + + // Create a device-like object for APNS service + const deviceForApns = { + id: client.deviceId, + pushToken: client.device.pushToken, + pushTokenType: client.device.tokenType, + 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 as unknown as NotificationPayloadWithJWTToken, + }); + + // Track success/failure + if (result.success) { + await prisma.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { + pushFailures: 0, + lastSentAt: new Date(), + }, + }); + req.log.info( + `Successfully sent v2 push notification to ${client.deviceId}`, + ); + } else { + const newFailureCount = client.device.pushFailures + 1; + await prisma.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { + pushFailures: newFailureCount, + lastFailureAt: new Date(), + disabled: newFailureCount >= 10, + }, + }); + req.log.warn( + `Failed to send v2 push notification to ${client.deviceId}. Failure count: ${newFailureCount}`, + ); + + // Cleanup if unrecoverable error + if ( + result.error === "DeviceNotRegistered" || + result.error === "BadDeviceToken" + ) { + req.log.info( + `Cleaning up v2 client ${client.id} due to unrecoverable error`, + ); + await notificationClient.deleteInstallation({ + installationId: client.id, + }); + await prisma.clientIdentifier.delete({ + where: { id: client.id }, + }); + } + } + + 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..b2ed8be5 --- /dev/null +++ b/src/api/shared/notifications/webhook.router.ts @@ -0,0 +1,9 @@ +import { Router } from "express"; +import { handleXmtpNotification } from "./webhook-handler"; + +const webhookRouter = Router(); + +// XMTP webhook handler (shared between v1 and v2) +webhookRouter.post("/handle-notification", handleXmtpNotification); + +export { webhookRouter }; 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/notifications/handlers/handle-xmtp-notification.ts b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts index d14907d4..cd7e1286 100644 --- a/src/api/v1/notifications/handlers/handle-xmtp-notification.ts +++ b/src/api/v1/notifications/handlers/handle-xmtp-notification.ts @@ -1,296 +1,3 @@ -import type { ClientIdentifier, DeviceRegistration } from "@prisma/client"; -import type { Request, Response } from "express"; -import { - createNotificationClient, - type WebhookNotificationBody, -} from "@/notifications/client"; -import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; -import { prisma } from "@/utils/prisma"; -import { createV2JwtToken } from "@/utils/v2/jwt"; -import { createApnsService } from "../services/apns-push.service"; -import type { - NotificationPayloadWithJWTToken, - V2NotificationPayload, -} from "../services/notifications-types"; -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; - } - - // TRY V2 FIRST (clientIdentifier lookup) - const v2Client = await prisma.clientIdentifier.findUnique({ - where: { id: notification.installation.id }, - include: { device: true }, - }); - - if (v2Client) { - req.log.info("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 (v1 or v2) for installationId ${notification.installation.id}`, - ); - res.status(400).json({ - error: `Installation not found for installationId ${notification.installation.id}`, - }); - return; - } - - req.log.info("Processing v1 notification"); - - 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", - ); - } -} - -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 >= 10) { - req.log.warn( - `Device ${client.deviceId} is disabled or has too many failures. Skipping notification.`, - ); - return { success: false }; - } - - // Generate JWT for NSE to use - const apiJWT = await createV2JwtToken({ - clientIdentifier: client.id, - deviceId: client.deviceId, - expirationTime: "72h", - metadata: { - notificationExtensionOnly: true, - }, - }); - - // Create APNS service - const apnsService = createApnsService(); - - if (!apnsService) { - req.log.error("APNS service not configured"); - return { success: false }; - } - - // Send push notification with v2 types - const v2Notification: V2NotificationPayload = { - clientIdentifier: client.id, - apiJWT, - notificationType: "Protocol", - notificationData: { - contentTopic: notification.message.content_topic, - messageType: notification.message_context.message_type, - encryptedMessage: notification.message.message, - timestamp: notification.message.timestamp_ns, - }, - }; - - // Create a device-like object for APNS service - const deviceForApns = { - id: client.deviceId, - pushToken: client.device.pushToken, - pushTokenType: client.device.tokenType, - 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 as unknown as NotificationPayloadWithJWTToken, - }); - - // Track success/failure - if (result.success) { - await prisma.deviceRegistration.update({ - where: { deviceId: client.deviceId }, - data: { - pushFailures: 0, - lastSentAt: new Date(), - }, - }); - req.log.info( - `Successfully sent v2 push notification to ${client.deviceId}`, - ); - } else { - const newFailureCount = client.device.pushFailures + 1; - await prisma.deviceRegistration.update({ - where: { deviceId: client.deviceId }, - data: { - pushFailures: newFailureCount, - lastFailureAt: new Date(), - disabled: newFailureCount >= 10, - }, - }); - req.log.warn( - `Failed to send v2 push notification to ${client.deviceId}. Failure count: ${newFailureCount}`, - ); - - // Cleanup if unrecoverable error - if ( - result.error === "DeviceNotRegistered" || - result.error === "BadDeviceToken" - ) { - req.log.info( - `Cleaning up v2 client ${client.id} due to unrecoverable error`, - ); - await notificationClient.deleteInstallation({ - installationId: client.id, - }); - await prisma.clientIdentifier.delete({ - where: { id: client.id }, - }); - } - } - - return result; -} +// 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/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/index.ts b/src/api/v2/index.ts index e9e47290..8f15ef3e 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import { webhookRouter } from "@/api/shared/notifications/webhook.router"; import { authRouter } from "./auth/auth.router"; import invitesV2Router from "./invites/invites.router"; import { notificationsRouter } from "./notifications/notifications.router"; @@ -9,4 +10,7 @@ v2Router.use("/invites", invitesV2Router); v2Router.use("/auth", authRouter); v2Router.use("/notifications", notificationsRouter); +// XMTP webhook (shared with v1) +v2Router.use("/notifications/xmtp", webhookRouter); + export default v2Router; 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..1af7bf80 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 From edf1d48d1d5e68b840d5d5cb06480e20c27104e3 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 15:36:07 +0200 Subject: [PATCH 12/51] Validate hex string --- src/api/v1/notifications/handlers/subscribe-to-topics.ts | 2 +- src/api/v2/notifications/handlers/subscribe.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index fcbee67a..54c3d649 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -12,7 +12,7 @@ const subscribeRequestSchema = z.object({ hmacKeys: z.array( z.object({ thirtyDayPeriodsSinceEpoch: z.number(), - key: z.string(), + key: z.string().regex(/^[0-9a-fA-F]+$/, "Invalid hex string"), }), ), }), From a539618b486443fa22d43c61094f9b1e9df896d0 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 15:43:10 +0200 Subject: [PATCH 13/51] Fixes --- src/api/shared/notifications/webhook-handler.ts | 10 +++------- src/api/v2/auth/auth.router.ts | 10 ++++++++-- src/index.ts | 10 ++++++++++ src/middleware/rateLimit.ts | 11 ++++++++++- src/utils/v2/jwt.ts | 8 -------- 5 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 09ed067e..7d0ec5cc 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -17,10 +17,6 @@ import { createV2JwtToken } from "@/utils/v2/jwt"; 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 * @@ -58,7 +54,7 @@ export async function handleXmtpNotification(req: Request, res: Response) { return; } - // TRY V2 FIRST (clientIdentifier lookup) + // Try v2 first (clientIdentifier lookup) const v2Client = await prisma.clientIdentifier.findUnique({ where: { id: notification.installation.id }, include: { device: true }, @@ -88,7 +84,7 @@ export async function handleXmtpNotification(req: Request, res: Response) { if (!identityOnDevice || !identityOnDevice.xmtpInstallationId) { req.log.error( - `Installation not found (v1 or v2) for installationId ${notification.installation.id}`, + `Installation not found for installationId ${notification.installation.id}`, ); res.status(400).json({ error: `Installation not found for installationId ${notification.installation.id}`, @@ -201,7 +197,7 @@ async function handleV2Notification(args: { const apiJWT = await createV2JwtToken({ clientIdentifier: client.id, deviceId: client.deviceId, - expirationTime: "72h", + expirationTime: "24h", metadata: { notificationExtensionOnly: true, }, diff --git a/src/api/v2/auth/auth.router.ts b/src/api/v2/auth/auth.router.ts index bb1680fd..271f578b 100644 --- a/src/api/v2/auth/auth.router.ts +++ b/src/api/v2/auth/auth.router.ts @@ -1,10 +1,16 @@ import { Router } from "express"; import { authV2Middleware } from "@/middleware/v2/auth"; +import { authRateLimitMiddleware } from "@/middleware/rateLimit"; import { generateToken } from "./handlers/generate-token"; const authRouter = Router(); -// Token generation requires AppCheck (main app only) -authRouter.post("/token", authV2Middleware, generateToken); +// Token generation requires AppCheck (main app only) with strict rate limiting +authRouter.post( + "/token", + authRateLimitMiddleware, + authV2Middleware, + generateToken, +); export { authRouter }; diff --git a/src/index.ts b/src/index.ts index 30b0212c..651d24bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,16 @@ import { rateLimitMiddleware } from "./middleware/rateLimit"; import healthcheckRouter from "./routes/healthcheck"; import logger from "./utils/logger"; +if (!process.env.JWT_SECRET) { + logger.error("JWT_SECRET is not set"); + process.exit(1); +} + +if (!process.env.XMTP_NOTIFICATION_SECRET) { + logger.error("XMTP_NOTIFICATION_SECRET is not set"); + process.exit(1); +} + const getLocalIpAddresses = () => { const interfaces = os.networkInterfaces(); const addresses: string[] = []; diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index db024028..d7657041 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 300 requests per 5 minutes export const rateLimitMiddleware = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes limit: 300, legacyHeaders: false, standardHeaders: "draft-8", }); + +// Stricter rate limiting for auth endpoints (JWT generation) +export const authRateLimitMiddleware = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + limit: 10, // 10 requests per 15 minutes per IP + legacyHeaders: false, + standardHeaders: "draft-8", + message: "Too many authentication requests, please try again later", +}); diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index dd1e3d8e..616ba4c5 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -20,10 +20,6 @@ export const createV2JwtToken = async (args: { metadata?: V2JWTMetadata; expirationTime?: string; }) => { - if (!process.env.JWT_SECRET) { - throw new AppError(500, "JWT_SECRET is not set"); - } - const payload: V2JWTPayload = { clientIdentifier: args.clientIdentifier, deviceId: args.deviceId, @@ -52,10 +48,6 @@ export const createV2JwtToken = async (args: { }; export const verifyV2JwtToken = async (args: { token: string }) => { - if (!process.env.JWT_SECRET) { - throw new AppError(500, "JWT_SECRET is not set"); - } - const { data: verified, error: verifyError } = await tryCatch( jose.jwtVerify( args.token, From 6676e11e18164e55957655bf5a3c9af58101b48e Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 15:52:27 +0200 Subject: [PATCH 14/51] Fixes --- .../migration.sql | 3 +++ prisma/schema.prisma | 1 + src/api/shared/notifications/constants.ts | 9 +++++++++ src/api/shared/notifications/webhook-handler.ts | 8 ++++++-- src/api/v2/auth/auth.router.ts | 2 +- src/api/v2/auth/handlers/generate-token.ts | 16 ++++++++++++++++ src/utils/v2/jwt.ts | 12 ++++++++++++ 7 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 src/api/shared/notifications/constants.ts diff --git a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql index f880e8dc..f9536e1b 100644 --- a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql +++ b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql @@ -27,6 +27,9 @@ CREATE TABLE "ClientIdentifier" ( -- 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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5c2f2c67..9fbc4c07 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -192,6 +192,7 @@ model DeviceRegistration { clientIdentifiers ClientIdentifier[] @@index([pushToken]) + @@index([disabled, pushFailures]) } model ClientIdentifier { 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/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 7d0ec5cc..5464d787 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -13,6 +13,7 @@ import { import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; import { prisma } from "@/utils/prisma"; import { createV2JwtToken } from "@/utils/v2/jwt"; +import { MAX_PUSH_FAILURES } from "./constants"; const notificationClient = createNotificationClient(); const pushNotificationService = getPushNotificationService(); @@ -186,7 +187,10 @@ async function handleV2Notification(args: { const { notification, client, req } = args; // Check if device is disabled or has too many failures - if (client.device.disabled || client.device.pushFailures >= 10) { + 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.`, ); @@ -264,7 +268,7 @@ async function handleV2Notification(args: { data: { pushFailures: newFailureCount, lastFailureAt: new Date(), - disabled: newFailureCount >= 10, + disabled: newFailureCount >= MAX_PUSH_FAILURES, }, }); req.log.warn( diff --git a/src/api/v2/auth/auth.router.ts b/src/api/v2/auth/auth.router.ts index 271f578b..9a9ab68a 100644 --- a/src/api/v2/auth/auth.router.ts +++ b/src/api/v2/auth/auth.router.ts @@ -1,6 +1,6 @@ import { Router } from "express"; -import { authV2Middleware } from "@/middleware/v2/auth"; import { authRateLimitMiddleware } from "@/middleware/rateLimit"; +import { authV2Middleware } from "@/middleware/v2/auth"; import { generateToken } from "./handlers/generate-token"; const authRouter = Router(); diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index 5bfc9a55..1df9a900 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -3,6 +3,22 @@ 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 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 - if a device + * passes AppCheck, it's trusted to request tokens for any client identifier + * associated with that device. + * 3. The clientIdentifier->deviceId mapping is validated in the database to + * ensure the client belongs to the requesting device. + * 4. Rate limiting (10 requests per 15 minutes) prevents token exhaustion attacks. + * 5. Tokens are short-lived (15 minutes) to limit exposure window. + */ + const generateTokenRequestSchema = z.object({ clientIdentifier: z.string(), deviceId: z.string(), diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index 616ba4c5..94ee31c0 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -1,4 +1,5 @@ import * as jose from "jose"; +import { MAX_JWT_METADATA_SIZE } from "@/api/shared/notifications/constants"; import { AppError } from "@/utils/errors"; import logger from "@/utils/logger"; import { tryCatch } from "@/utils/try-catch"; @@ -20,6 +21,17 @@ export const createV2JwtToken = async (args: { metadata?: V2JWTMetadata; expirationTime?: string; }) => { + // Validate metadata size to prevent JWT bloat + if (args.metadata) { + const metadataSize = 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 = { clientIdentifier: args.clientIdentifier, deviceId: args.deviceId, From b33e2d996def4bcc1c9c52c87da569d00657d079 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Thu, 9 Oct 2025 16:05:44 +0200 Subject: [PATCH 15/51] Fixes --- package.json | 9 ++-- .../shared/notifications/webhook-handler.ts | 17 +++++- src/api/v2/notifications/handlers/register.ts | 1 + src/notifications/client.ts | 54 ++++++++++--------- src/utils/v2/jwt.ts | 8 +++ 5 files changed, 59 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 752e8ce7..fa8bd945 100644 --- a/package.json +++ b/package.json @@ -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", @@ -82,7 +81,7 @@ "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/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 5464d787..17ecc8c0 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -8,6 +8,7 @@ import type { import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service"; import { createNotificationClient, + webhookNotificationBodySchema, type WebhookNotificationBody, } from "@/notifications/client"; import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; @@ -32,7 +33,21 @@ export async function handleXmtpNotification(req: Request, res: Response) { } | null = null; try { - const notification = req.body as WebhookNotificationBody; + // 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( diff --git a/src/api/v2/notifications/handlers/register.ts b/src/api/v2/notifications/handlers/register.ts index a719fe80..35f8ab99 100644 --- a/src/api/v2/notifications/handlers/register.ts +++ b/src/api/v2/notifications/handlers/register.ts @@ -36,6 +36,7 @@ export async function register( }); res.status(200).send(); + return; } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ error: "Invalid request body" }); diff --git a/src/notifications/client.ts b/src/notifications/client.ts index 37adaa92..ad21c4cf 100644 --- a/src/notifications/client.ts +++ b/src/notifications/client.ts @@ -2,6 +2,7 @@ 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 { Notifications, Subscription_HmacKeySchema, @@ -25,30 +26,35 @@ 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(), + timestamp_ns: z.string(), + 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/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index 94ee31c0..b55e8a9d 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -21,6 +21,10 @@ export const createV2JwtToken = async (args: { metadata?: V2JWTMetadata; expirationTime?: string; }) => { + if (!process.env.JWT_SECRET) { + throw new AppError(500, "JWT_SECRET is not configured"); + } + // Validate metadata size to prevent JWT bloat if (args.metadata) { const metadataSize = JSON.stringify(args.metadata).length; @@ -60,6 +64,10 @@ export const createV2JwtToken = async (args: { }; export const verifyV2JwtToken = async (args: { token: string }) => { + if (!process.env.JWT_SECRET) { + throw new AppError(500, "JWT_SECRET is not configured"); + } + const { data: verified, error: verifyError } = await tryCatch( jose.jwtVerify( args.token, From 42b9637eba2697add667f673e434707d0f777291 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 10:41:50 +0200 Subject: [PATCH 16/51] Fixes --- src/api/v2/auth/handlers/generate-token.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index 1df9a900..f9e8afd6 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -63,6 +63,7 @@ export async function generateToken( }); res.json({ token }); + return; } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ error: "Invalid request body" }); @@ -70,5 +71,6 @@ export async function generateToken( } req.log.error({ error }, "Failed to generate token"); res.status(500).json({ error: "Failed to generate token" }); + return; } } From 92230baea454c363a2a622547da70c6957a80e07 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 10:44:04 +0200 Subject: [PATCH 17/51] Changed pushFailures to use atomic reset --- .../shared/notifications/webhook-handler.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 17ecc8c0..da7c8a22 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -264,12 +264,12 @@ async function handleV2Notification(args: { notification: v2Notification as unknown as NotificationPayloadWithJWTToken, }); - // Track success/failure + // Track success/failure using atomic operations to prevent race conditions if (result.success) { await prisma.deviceRegistration.update({ where: { deviceId: client.deviceId }, data: { - pushFailures: 0, + pushFailures: { set: 0 }, lastSentAt: new Date(), }, }); @@ -277,17 +277,25 @@ async function handleV2Notification(args: { `Successfully sent v2 push notification to ${client.deviceId}`, ); } else { - const newFailureCount = client.device.pushFailures + 1; - await prisma.deviceRegistration.update({ + // Use atomic increment and fetch the result to check threshold + const updated = await prisma.deviceRegistration.update({ where: { deviceId: client.deviceId }, data: { - pushFailures: newFailureCount, + pushFailures: { increment: 1 }, lastFailureAt: new Date(), - disabled: newFailureCount >= MAX_PUSH_FAILURES, }, }); + + // Check if we've hit the threshold and need to disable + if (updated.pushFailures >= MAX_PUSH_FAILURES && !updated.disabled) { + await prisma.deviceRegistration.update({ + where: { deviceId: client.deviceId }, + data: { disabled: true }, + }); + } + req.log.warn( - `Failed to send v2 push notification to ${client.deviceId}. Failure count: ${newFailureCount}`, + `Failed to send v2 push notification to ${client.deviceId}. Failure count: ${updated.pushFailures}`, ); // Cleanup if unrecoverable error From 9c6a812d81ff3e8410be2f8db0281b7554591304 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 12:42:33 +0200 Subject: [PATCH 18/51] Add transactions --- .../shared/notifications/webhook-handler.ts | 28 ++++++++--- .../v2/notifications/handlers/subscribe.ts | 47 ++++++++++++------- .../v2/notifications/handlers/unregister.ts | 32 ++++++++----- 3 files changed, 73 insertions(+), 34 deletions(-) diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index da7c8a22..ff622583 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -304,14 +304,28 @@ async function handleV2Notification(args: { result.error === "BadDeviceToken" ) { req.log.info( - `Cleaning up v2 client ${client.id} due to unrecoverable error`, + `Cleaning up v2 notification client ${client.id} due to unrecoverable error`, ); - await notificationClient.deleteInstallation({ - installationId: client.id, - }); - await prisma.clientIdentifier.delete({ - where: { id: client.id }, - }); + try { + await notificationClient.deleteInstallation({ + installationId: client.id, + }); + + await prisma.clientIdentifier.delete({ + where: { id: client.id }, + }); + + 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 + } } } diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 54c3d649..b70a1fbb 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -57,24 +57,39 @@ export async function subscribe( })); // Register installation with notification server - await notificationClient.registerInstallation({ - installationId: body.clientIdentifier, - deliveryMechanism: { - deliveryMechanismType: { - case: - client.device.tokenType === "apns" - ? "apnsDeviceToken" - : "firebaseDeviceToken", - value: client.device.pushToken, + try { + await notificationClient.registerInstallation({ + installationId: body.clientIdentifier, + deliveryMechanism: { + deliveryMechanismType: { + case: + client.device.tokenType === "apns" + ? "apnsDeviceToken" + : "firebaseDeviceToken", + value: client.device.pushToken, + }, }, - }, - }); + }); - // Subscribe to topics - await notificationClient.subscribeWithMetadata({ - installationId: body.clientIdentifier, - subscriptions, - }); + // Subscribe to topics + await notificationClient.subscribeWithMetadata({ + installationId: body.clientIdentifier, + subscriptions, + }); + } catch (remoteErr) { + // Compensate: best-effort delete installation to avoid orphaned state + try { + await notificationClient.deleteInstallation({ + installationId: body.clientIdentifier, + }); + } catch (cleanupErr) { + req.log.warn( + { error: cleanupErr, installationId: body.clientIdentifier }, + "Failed to cleanup installation after subscription failure", + ); + } + throw remoteErr; + } // Create or update client identifier record await prisma.clientIdentifier.upsert({ diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts index 1424c41f..5e4fcfa6 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -28,17 +28,27 @@ export async function unregister( return; } - // Delete installation from notification server - await notificationClient.deleteInstallation({ - installationId: params.clientIdentifier, - }); - - // Delete client from database - await prisma.clientIdentifier.delete({ - where: { id: params.clientIdentifier }, - }); - - res.status(200).send(); + try { + await notificationClient.deleteInstallation({ + installationId: params.clientIdentifier, + }); + + await prisma.clientIdentifier.delete({ + where: { id: params.clientIdentifier }, + }); + + req.log.info( + { clientId: params.clientIdentifier }, + "Successfully cleaned up v2 client", + ); + res.status(200).send(); + } catch (cleanupError) { + req.log.error( + { error: cleanupError, clientId: params.clientIdentifier }, + "Failed to cleanup v2 notification subscriptions during unregister()", + ); + throw cleanupError; + } } catch (error) { if (error instanceof z.ZodError) { res.status(400).json({ error: "Invalid request parameters" }); From d44594dbf9012731f4bb68131d4d45acd9c8d73f Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 13:01:16 +0200 Subject: [PATCH 19/51] Cache most used environment variables --- src/config.ts | 28 ++++++++++++++++++++++++++++ src/index.ts | 13 ++----------- src/instrumentation.ts | 3 ++- src/middleware/errorHandler.ts | 3 ++- src/notifications/client.ts | 6 ++---- src/notifications/utils.ts | 7 +++---- src/routes/healthcheck.ts | 14 +++++--------- src/utils/v2/jwt.ts | 16 +++------------- 8 files changed, 47 insertions(+), 43 deletions(-) create mode 100644 src/config.ts diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 00000000..3b3678f9 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,28 @@ +/** + * 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; +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 = NODE_ENV === "production"; +export const IS_DEVELOPMENT = NODE_ENV === "development"; diff --git a/src/index.ts b/src/index.ts index 651d24bc..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"; @@ -11,16 +12,6 @@ import { rateLimitMiddleware } from "./middleware/rateLimit"; import healthcheckRouter from "./routes/healthcheck"; import logger from "./utils/logger"; -if (!process.env.JWT_SECRET) { - logger.error("JWT_SECRET is not set"); - process.exit(1); -} - -if (!process.env.XMTP_NOTIFICATION_SECRET) { - logger.error("XMTP_NOTIFICATION_SECRET is not set"); - process.exit(1); -} - const getLocalIpAddresses = () => { const interfaces = os.networkInterfaces(); const addresses: string[] = []; @@ -66,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/notifications/client.ts b/src/notifications/client.ts index ad21c4cf..6c6ac5d1 100644 --- a/src/notifications/client.ts +++ b/src/notifications/client.ts @@ -3,6 +3,7 @@ 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, @@ -11,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); diff --git a/src/notifications/utils.ts b/src/notifications/utils.ts index 2605146a..adbd4975 100644 --- a/src/notifications/utils.ts +++ b/src/notifications/utils.ts @@ -1,6 +1,5 @@ +import { XMTP_NOTIFICATION_SECRET } from "@/config"; + export function getHttpDeliveryNotificationAuthHeader() { - if (!process.env.XMTP_NOTIFICATION_SECRET) { - throw new Error("XMTP_NOTIFICATION_SECRET is not set"); - } - 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 index b55e8a9d..a3159513 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -1,5 +1,6 @@ import * as jose from "jose"; 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"; @@ -21,10 +22,6 @@ export const createV2JwtToken = async (args: { metadata?: V2JWTMetadata; expirationTime?: string; }) => { - if (!process.env.JWT_SECRET) { - throw new AppError(500, "JWT_SECRET is not configured"); - } - // Validate metadata size to prevent JWT bloat if (args.metadata) { const metadataSize = JSON.stringify(args.metadata).length; @@ -52,7 +49,7 @@ export const createV2JwtToken = async (args: { .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() .setExpirationTime(args.expirationTime ?? "15m") - .sign(new TextEncoder().encode(process.env.JWT_SECRET)), + .sign(JWT_SECRET_BYTES), ); if (jwtError) { @@ -64,15 +61,8 @@ export const createV2JwtToken = async (args: { }; export const verifyV2JwtToken = async (args: { token: string }) => { - if (!process.env.JWT_SECRET) { - throw new AppError(500, "JWT_SECRET is not configured"); - } - const { data: verified, error: verifyError } = await tryCatch( - jose.jwtVerify( - args.token, - new TextEncoder().encode(process.env.JWT_SECRET), - ), + jose.jwtVerify(args.token, JWT_SECRET_BYTES), ); if (verifyError) { From 0ed3839da9c2695848b40a2bb12030bfb290b4f8 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 13:05:56 +0200 Subject: [PATCH 20/51] Remove gatewayAuthorized --- src/api/v2/auth/handlers/generate-token.ts | 7 ++----- src/utils/v2/jwt.ts | 1 - 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index f9e8afd6..bafae7ec 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -52,14 +52,11 @@ export async function generateToken( return; } - // Generate JWT + // Generate JWT (short-lived for app-generated Gateway requests) const token = await createV2JwtToken({ clientIdentifier: body.clientIdentifier, deviceId: body.deviceId, - expirationTime: "15m", // Short-lived for app-generated requests - metadata: { - gatewayAuthorized: true, - }, + expirationTime: "15m", }); res.json({ token }); diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index a3159513..b4de9624 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -7,7 +7,6 @@ import { tryCatch } from "@/utils/try-catch"; export type V2JWTMetadata = { notificationExtensionOnly?: boolean; - gatewayAuthorized?: boolean; }; export type V2JWTPayload = { From bd456b5772b64785acdd29c121d76dc9f37da881 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 14:47:41 +0200 Subject: [PATCH 21/51] Webhook handler header check as middleware --- .../shared/notifications/webhook-handler.ts | 22 +++------------- .../shared/notifications/webhook.router.ts | 7 +++++- src/api/v2/auth/handlers/generate-token.ts | 2 +- src/middleware/xmtpWebhookAuth.ts | 25 +++++++++++++++++++ 4 files changed, 36 insertions(+), 20 deletions(-) create mode 100644 src/middleware/xmtpWebhookAuth.ts diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index ff622583..6db863fc 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -11,7 +11,6 @@ import { webhookNotificationBodySchema, type WebhookNotificationBody, } from "@/notifications/client"; -import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; import { prisma } from "@/utils/prisma"; import { createV2JwtToken } from "@/utils/v2/jwt"; import { MAX_PUSH_FAILURES } from "./constants"; @@ -22,9 +21,8 @@ const pushNotificationService = getPushNotificationService(); /** * 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. + * 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: { @@ -58,18 +56,6 @@ export async function handleXmtpNotification(req: Request, res: Response) { "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; - } - // Try v2 first (clientIdentifier lookup) const v2Client = await prisma.clientIdentifier.findUnique({ where: { id: notification.installation.id }, @@ -212,10 +198,10 @@ async function handleV2Notification(args: { return { success: false }; } - // Generate JWT for NSE to use + // Generate JWT for NSE to use (24h expiration for security) const apiJWT = await createV2JwtToken({ - clientIdentifier: client.id, deviceId: client.deviceId, + clientIdentifier: client.id, expirationTime: "24h", metadata: { notificationExtensionOnly: true, diff --git a/src/api/shared/notifications/webhook.router.ts b/src/api/shared/notifications/webhook.router.ts index b2ed8be5..ce499d77 100644 --- a/src/api/shared/notifications/webhook.router.ts +++ b/src/api/shared/notifications/webhook.router.ts @@ -1,9 +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", handleXmtpNotification); +webhookRouter.post( + "/handle-notification", + xmtpWebhookAuthMiddleware, + handleXmtpNotification, +); export { webhookRouter }; diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index bafae7ec..20a87fa9 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -54,8 +54,8 @@ export async function generateToken( // Generate JWT (short-lived for app-generated Gateway requests) const token = await createV2JwtToken({ - clientIdentifier: body.clientIdentifier, deviceId: body.deviceId, + clientIdentifier: body.clientIdentifier, expirationTime: "15m", }); diff --git a/src/middleware/xmtpWebhookAuth.ts b/src/middleware/xmtpWebhookAuth.ts new file mode 100644 index 00000000..106bf418 --- /dev/null +++ b/src/middleware/xmtpWebhookAuth.ts @@ -0,0 +1,25 @@ +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, +) => { + const authHeader = req.headers.authorization; + const expectedAuthHeader = getHttpDeliveryNotificationAuthHeader(); + + if (!authHeader || authHeader !== expectedAuthHeader) { + req.log.error("Invalid or missing XMTP webhook authorization header"); + res.status(401).json({ + error: "Unauthorized: Invalid authentication token", + }); + return; + } + + next(); +}; From 2b8698f2ab502ddcfd55effb715e89f0a4781812 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 14:57:09 +0200 Subject: [PATCH 22/51] Fixes --- src/api/shared/notifications/webhook-handler.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 6db863fc..714b0319 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -208,7 +208,9 @@ async function handleV2Notification(args: { }, }); - // Create APNS service + // NOTE: v2 currently only supports APNS/iOS push notifications + // Android/FCM support can be added when needed by using pushNotificationService + // and deriving OS from tokenType (see v1 implementation above) const apnsService = createApnsService(); if (!apnsService) { @@ -230,6 +232,7 @@ async function handleV2Notification(args: { }; // Create a device-like object for APNS service + // NOTE: os is hardcoded to "ios" since v2 only supports APNS for now const deviceForApns = { id: client.deviceId, pushToken: client.device.pushToken, From 929d1ed1e5b2578dbde8e1e0d1433c75831119d3 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 14:59:00 +0200 Subject: [PATCH 23/51] Fixes --- .../shared/notifications/webhook-handler.ts | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 714b0319..4880be39 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -266,22 +266,28 @@ async function handleV2Notification(args: { `Successfully sent v2 push notification to ${client.deviceId}`, ); } else { - // Use atomic increment and fetch the result to check threshold - const updated = await prisma.deviceRegistration.update({ - where: { deviceId: client.deviceId }, - data: { - pushFailures: { increment: 1 }, - lastFailureAt: new Date(), - }, - }); - - // Check if we've hit the threshold and need to disable - if (updated.pushFailures >= MAX_PUSH_FAILURES && !updated.disabled) { - await prisma.deviceRegistration.update({ + // 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}`, From 0554fbdc65014d7650cd43bbbdcb5cbf08068ad0 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 15:37:27 +0200 Subject: [PATCH 24/51] Fixes --- src/api/v2/notifications/handlers/register.ts | 1 - .../v2/notifications/handlers/subscribe.ts | 25 +++++++++---------- src/middleware/xmtpWebhookAuth.ts | 12 ++++++--- src/utils/v2/jwt.ts | 18 ++++++++++++- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/api/v2/notifications/handlers/register.ts b/src/api/v2/notifications/handlers/register.ts index 35f8ab99..1f669671 100644 --- a/src/api/v2/notifications/handlers/register.ts +++ b/src/api/v2/notifications/handlers/register.ts @@ -31,7 +31,6 @@ export async function register( pushToken: body.pushToken, tokenType: body.tokenType ?? "apns", apnsEnv: body.apnsEnv ?? null, - updatedAt: new Date(), }, }); diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index b70a1fbb..5a0dcb19 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -6,6 +6,7 @@ import { prisma } from "@/utils/prisma"; const subscribeRequestSchema = z.object({ clientIdentifier: z.string(), + deviceId: z.string(), topics: z.array( z.object({ topic: z.string(), @@ -30,18 +31,17 @@ export async function subscribe( try { const body = subscribeRequestSchema.parse(req.body); - // Look up client and device - const client = await prisma.clientIdentifier.findUnique({ - where: { id: body.clientIdentifier }, - include: { device: true }, + // Verify device exists and is not disabled + const device = await prisma.deviceRegistration.findUnique({ + where: { deviceId: body.deviceId }, }); - if (!client) { - res.status(404).json({ error: "Client not found" }); + if (!device) { + res.status(404).json({ error: "Device not found" }); return; } - if (client.device.disabled) { + if (device.disabled) { res.status(403).json({ error: "Device is disabled" }); return; } @@ -63,10 +63,10 @@ export async function subscribe( deliveryMechanism: { deliveryMechanismType: { case: - client.device.tokenType === "apns" + device.tokenType === "apns" ? "apnsDeviceToken" : "firebaseDeviceToken", - value: client.device.pushToken, + value: device.pushToken, }, }, }); @@ -96,11 +96,10 @@ export async function subscribe( where: { id: body.clientIdentifier }, create: { id: body.clientIdentifier, - deviceId: client.deviceId, - }, - update: { - updatedAt: new Date(), + deviceId: body.deviceId, }, + // Refresh updatedAt + update: {}, }); res.status(200).send(); diff --git a/src/middleware/xmtpWebhookAuth.ts b/src/middleware/xmtpWebhookAuth.ts index 106bf418..5f37d34d 100644 --- a/src/middleware/xmtpWebhookAuth.ts +++ b/src/middleware/xmtpWebhookAuth.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "crypto"; import type { NextFunction, Request, Response } from "express"; import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils"; @@ -10,10 +11,15 @@ export const xmtpWebhookAuthMiddleware = ( res: Response, next: NextFunction, ) => { - const authHeader = req.headers.authorization; - const expectedAuthHeader = getHttpDeliveryNotificationAuthHeader(); + const authHeader = req.headers.authorization?.trim() ?? ""; + const expectedAuthHeader = getHttpDeliveryNotificationAuthHeader().trim(); - if (!authHeader || authHeader !== expectedAuthHeader) { + 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 or missing XMTP webhook authorization header"); res.status(401).json({ error: "Unauthorized: Invalid authentication token", diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index b4de9624..bd4e95e7 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -1,4 +1,5 @@ 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"; @@ -15,6 +16,16 @@ export type V2JWTPayload = { metadata?: V2JWTMetadata; }; +const v2JWTPayloadSchema = z.object({ + clientIdentifier: z.string(), + deviceId: z.string(), + metadata: z + .object({ + notificationExtensionOnly: z.boolean().optional(), + }) + .optional(), +}); + export const createV2JwtToken = async (args: { clientIdentifier: string; deviceId: string; @@ -69,7 +80,12 @@ export const verifyV2JwtToken = async (args: { token: string }) => { throw new AppError(401, "Invalid or expired token", verifyError); } - return verified.payload as V2JWTPayload; + 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) => { From 03ff700d956640237f2eaba039c2a0724ab7ab0d Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 10 Oct 2025 20:41:41 +0200 Subject: [PATCH 25/51] Fixes and adding debug logs --- .../migration.sql | 2 +- prisma/schema.prisma | 2 +- .../services/notifications-types.ts | 2 +- .../shared/notifications/webhook-handler.ts | 7 +- src/api/v2/auth/handlers/generate-token.ts | 45 ++++++------ src/api/v2/index.ts | 10 ++- src/api/v2/notifications/handlers/register.ts | 71 +++++++++++++++---- .../v2/notifications/handlers/subscribe.ts | 44 +++++++++--- .../v2/notifications/handlers/unregister.ts | 22 ++++-- .../v2/notifications/handlers/unsubscribe.ts | 20 +++++- .../v2/notifications/notifications.router.ts | 15 ++-- src/globals.d.ts | 9 +++ src/middleware/v2/auth.ts | 20 +++++- src/utils/v2/jwt.ts | 4 -- 14 files changed, 197 insertions(+), 76 deletions(-) diff --git a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql index f9536e1b..34f5f55c 100644 --- a/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql +++ b/prisma/migrations/20251003172324_add_v2_push_notification_models/migration.sql @@ -2,7 +2,7 @@ CREATE TABLE "DeviceRegistration" ( "deviceId" TEXT NOT NULL, "pushToken" TEXT NOT NULL, - "tokenType" "PushTokenType" NOT NULL DEFAULT 'apns', + "pushTokenType" "PushTokenType" NOT NULL DEFAULT 'apns', "apnsEnv" "ApnsEnvironment", "pushFailures" INTEGER NOT NULL DEFAULT 0, "disabled" BOOLEAN NOT NULL DEFAULT false, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9fbc4c07..9773c05d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -181,7 +181,7 @@ model InviteCodeRequest { model DeviceRegistration { deviceId String @id pushToken String @db.Text - tokenType PushTokenType @default(apns) + pushTokenType PushTokenType @default(apns) apnsEnv ApnsEnvironment? pushFailures Int @default(0) disabled Boolean @default(false) diff --git a/src/api/shared/notifications/services/notifications-types.ts b/src/api/shared/notifications/services/notifications-types.ts index b8e4721b..49a568c4 100644 --- a/src/api/shared/notifications/services/notifications-types.ts +++ b/src/api/shared/notifications/services/notifications-types.ts @@ -57,7 +57,7 @@ export type NotificationPayloadWithJWTToken = NotificationPayload & { // V2 notification types export type V2NotificationPayload = { - clientIdentifier: string; + clientId: string; apiJWT: string; notificationType: "Protocol"; notificationData: ProtocolNotificationData; diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 4880be39..727a6c0a 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -56,7 +56,7 @@ export async function handleXmtpNotification(req: Request, res: Response) { "received notification", ); - // Try v2 first (clientIdentifier lookup) + // Try v2 first (clientId lookup) const v2Client = await prisma.clientIdentifier.findUnique({ where: { id: notification.installation.id }, include: { device: true }, @@ -201,7 +201,6 @@ async function handleV2Notification(args: { // Generate JWT for NSE to use (24h expiration for security) const apiJWT = await createV2JwtToken({ deviceId: client.deviceId, - clientIdentifier: client.id, expirationTime: "24h", metadata: { notificationExtensionOnly: true, @@ -220,7 +219,7 @@ async function handleV2Notification(args: { // Send push notification with v2 types const v2Notification: V2NotificationPayload = { - clientIdentifier: client.id, + clientId: client.id, apiJWT, notificationType: "Protocol", notificationData: { @@ -236,7 +235,7 @@ async function handleV2Notification(args: { const deviceForApns = { id: client.deviceId, pushToken: client.device.pushToken, - pushTokenType: client.device.tokenType, + pushTokenType: client.device.pushTokenType, apnsEnv: client.device.apnsEnv, pushFailures: client.device.pushFailures, name: null, diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index 20a87fa9..b016319e 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -6,21 +6,19 @@ import { createV2JwtToken } from "@/utils/v2/jwt"; /** * Token Generation Security Model * - * This endpoint generates short-lived JWT tokens for Gateway authentication: + * 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 - if a device - * passes AppCheck, it's trusted to request tokens for any client identifier - * associated with that device. - * 3. The clientIdentifier->deviceId mapping is validated in the database to - * ensure the client belongs to the requesting device. - * 4. Rate limiting (10 requests per 15 minutes) prevents token exhaustion attacks. - * 5. Tokens are short-lived (15 minutes) to limit exposure window. + * 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 (10 requests per 15 minutes) 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({ - clientIdentifier: z.string(), deviceId: z.string(), }); @@ -35,34 +33,37 @@ export async function generateToken( try { const body = generateTokenRequestSchema.parse(req.body); - // Validate client exists and belongs to device - const client = await prisma.clientIdentifier.findUnique({ - where: { id: body.clientIdentifier }, - include: { device: true }, - }); + req.log.info({ deviceId: body.deviceId }, "Generating token"); - if (!client || client.deviceId !== body.deviceId) { - res.status(404).json({ error: "Client not found" }); - return; - } + // Check if device is registered and disabled + const device = await prisma.deviceRegistration.findUnique({ + where: { deviceId: body.deviceId }, + }); - // Check if device is disabled - if (client.device.disabled) { + if (device?.disabled) { + req.log.warn({ deviceId: body.deviceId }, "Device is disabled"); res.status(403).json({ error: "Device is disabled" }); return; } - // Generate JWT (short-lived for app-generated Gateway requests) + // Generate JWT, this works even if device not registered yet const token = await createV2JwtToken({ deviceId: body.deviceId, - clientIdentifier: body.clientIdentifier, 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; } diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 8f15ef3e..4e1391db 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,5 +1,6 @@ import { Router } from "express"; import { webhookRouter } from "@/api/shared/notifications/webhook.router"; +import { authV2Middleware } from "@/middleware/v2/auth"; import { authRouter } from "./auth/auth.router"; import invitesV2Router from "./invites/invites.router"; import { notificationsRouter } from "./notifications/notifications.router"; @@ -8,9 +9,16 @@ const v2Router = Router(); v2Router.use("/invites", invitesV2Router); v2Router.use("/auth", authRouter); -v2Router.use("/notifications", notificationsRouter); +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/notifications/handlers/register.ts b/src/api/v2/notifications/handlers/register.ts index 1f669671..e82156f7 100644 --- a/src/api/v2/notifications/handlers/register.ts +++ b/src/api/v2/notifications/handlers/register.ts @@ -1,12 +1,13 @@ 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(), - pushToken: z.string(), - tokenType: PushTokenTypeSchema.optional(), + pushToken: z.string().optional(), + pushTokenType: PushTokenTypeSchema.optional(), apnsEnv: ApnsEnvironmentSchema.nullable().optional(), }); @@ -19,25 +20,69 @@ export async function register( try { const body = registerRequestSchema.parse(req.body); - await prisma.deviceRegistration.upsert({ - where: { deviceId: body.deviceId }, - create: { + req.log.info( + { deviceId: body.deviceId, - pushToken: body.pushToken, - tokenType: body.tokenType ?? "apns", - apnsEnv: body.apnsEnv ?? null, - }, - update: { - pushToken: body.pushToken, - tokenType: body.tokenType ?? "apns", - apnsEnv: body.apnsEnv ?? null, + hasPushToken: !!body.pushToken, + pushTokenType: body.pushTokenType ?? "apns", + apnsEnv: body.apnsEnv, }, + "Registering device", + ); + + // Check if device exists + const existingDevice = await prisma.deviceRegistration.findUnique({ + where: { deviceId: body.deviceId }, }); + if (existingDevice) { + // Update existing device - only update pushToken if provided + const updateData: { + pushTokenType?: PushTokenType; + apnsEnv?: ApnsEnvironment | null; + pushToken?: string; + } = { + pushTokenType: body.pushTokenType ?? "apns", + apnsEnv: body.apnsEnv ?? null, + }; + + if (body.pushToken) { + updateData.pushToken = body.pushToken; + } + + await prisma.deviceRegistration.update({ + where: { deviceId: body.deviceId }, + data: updateData, + }); + + req.log.info( + { deviceId: body.deviceId, updatedPushToken: !!body.pushToken }, + "Device updated successfully", + ); + } else { + // Create new device - pushToken can be empty initially + await prisma.deviceRegistration.create({ + data: { + deviceId: body.deviceId, + pushToken: body.pushToken ?? "", + pushTokenType: body.pushTokenType ?? "apns", + apnsEnv: body.apnsEnv ?? null, + }, + }); + + 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; } diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 5a0dcb19..c3ff3870 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -5,8 +5,8 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const subscribeRequestSchema = z.object({ - clientIdentifier: z.string(), deviceId: z.string(), + clientId: z.string(), topics: z.array( z.object({ topic: z.string(), @@ -31,17 +31,31 @@ export async function subscribe( 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; } @@ -59,11 +73,11 @@ export async function subscribe( // Register installation with notification server try { await notificationClient.registerInstallation({ - installationId: body.clientIdentifier, + installationId: body.clientId, deliveryMechanism: { deliveryMechanismType: { case: - device.tokenType === "apns" + device.pushTokenType === "apns" ? "apnsDeviceToken" : "firebaseDeviceToken", value: device.pushToken, @@ -73,18 +87,18 @@ export async function subscribe( // Subscribe to topics await notificationClient.subscribeWithMetadata({ - installationId: body.clientIdentifier, + installationId: body.clientId, subscriptions, }); } catch (remoteErr) { // Compensate: best-effort delete installation to avoid orphaned state try { await notificationClient.deleteInstallation({ - installationId: body.clientIdentifier, + installationId: body.clientId, }); } catch (cleanupErr) { req.log.warn( - { error: cleanupErr, installationId: body.clientIdentifier }, + { error: cleanupErr, installationId: body.clientId }, "Failed to cleanup installation after subscription failure", ); } @@ -93,19 +107,31 @@ export async function subscribe( // Create or update client identifier record await prisma.clientIdentifier.upsert({ - where: { id: body.clientIdentifier }, + where: { id: body.clientId }, create: { - id: body.clientIdentifier, + id: body.clientId, deviceId: body.deviceId, }, // Refresh updatedAt update: {}, }); + req.log.info( + { deviceId: body.deviceId, clientId: body.clientId }, + "Subscribed successfully", + ); res.status(200).send(); } catch (error) { if (error instanceof z.ZodError) { - res.status(400).json({ error: "Invalid request body" }); + req.log.warn( + { errors: error.errors, body: req.body }, + "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"); diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts index 5e4fcfa6..4391b025 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unregisterParamsSchema = z.object({ - clientIdentifier: z.string(), + clientId: z.string(), }); export type IUnregisterParams = z.infer; @@ -18,39 +18,49 @@ export async function unregister( 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.clientIdentifier }, + 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.clientIdentifier, + installationId: params.clientId, }); await prisma.clientIdentifier.delete({ - where: { id: params.clientIdentifier }, + where: { id: params.clientId }, }); req.log.info( - { clientId: params.clientIdentifier }, + { clientId: params.clientId }, "Successfully cleaned up v2 client", ); res.status(200).send(); } catch (cleanupError) { req.log.error( - { error: cleanupError, clientId: params.clientIdentifier }, + { 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; } diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts index a5ed1633..0fb059cb 100644 --- a/src/api/v2/notifications/handlers/unsubscribe.ts +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unsubscribeRequestSchema = z.object({ - clientIdentifier: z.string(), + clientId: z.string(), topics: z.array(z.string()), }); @@ -19,25 +19,39 @@ export async function unsubscribe( 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.clientIdentifier }, + 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.clientIdentifier, + installationId: body.clientId, topics: body.topics, }); + req.log.info({ clientId: body.clientId }, "Unsubscribed successfully"); res.status(200).send(); } 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; } diff --git a/src/api/v2/notifications/notifications.router.ts b/src/api/v2/notifications/notifications.router.ts index 16aef969..c9bc6e37 100644 --- a/src/api/v2/notifications/notifications.router.ts +++ b/src/api/v2/notifications/notifications.router.ts @@ -1,5 +1,4 @@ import { Router } from "express"; -import { authV2Middleware } from "@/middleware/v2/auth"; import { register } from "./handlers/register"; import { subscribe } from "./handlers/subscribe"; import { unregister } from "./handlers/unregister"; @@ -7,14 +6,10 @@ import { unsubscribe } from "./handlers/unsubscribe"; const notificationsRouter = Router(); -// All routes require auth (AppCheck or JWT) -notificationsRouter.post("/register", authV2Middleware, register); -notificationsRouter.post("/subscribe", authV2Middleware, subscribe); -notificationsRouter.post("/unsubscribe", authV2Middleware, unsubscribe); -notificationsRouter.delete( - "/unregister/:clientIdentifier", - authV2Middleware, - unregister, -); +// All routes require auth (AppCheck or JWT) - applied at mount point in v2/index.ts +notificationsRouter.post("/register", register); +notificationsRouter.post("/subscribe", subscribe); +notificationsRouter.post("/unsubscribe", unsubscribe); +notificationsRouter.delete("/unregister/:clientId", unregister); export { notificationsRouter }; 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/middleware/v2/auth.ts b/src/middleware/v2/auth.ts index fcd8934c..11350681 100644 --- a/src/middleware/v2/auth.ts +++ b/src/middleware/v2/auth.ts @@ -13,10 +13,21 @@ export const authV2Middleware = async ( 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) { @@ -31,9 +42,15 @@ export const authV2Middleware = async ( try { const payload = await verifyV2JwtToken({ token: authToken }); // Store payload for handlers if needed - res.locals.clientIdentifier = payload.clientIdentifier; 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) { @@ -43,5 +60,6 @@ export const authV2Middleware = async ( } } + req.log.warn("No authentication headers provided"); res.status(401).json({ error: "Missing authentication" }); }; diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index bd4e95e7..9d164ec1 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -11,13 +11,11 @@ export type V2JWTMetadata = { }; export type V2JWTPayload = { - clientIdentifier: string; deviceId: string; metadata?: V2JWTMetadata; }; const v2JWTPayloadSchema = z.object({ - clientIdentifier: z.string(), deviceId: z.string(), metadata: z .object({ @@ -27,7 +25,6 @@ const v2JWTPayloadSchema = z.object({ }); export const createV2JwtToken = async (args: { - clientIdentifier: string; deviceId: string; metadata?: V2JWTMetadata; expirationTime?: string; @@ -44,7 +41,6 @@ export const createV2JwtToken = async (args: { } const payload: V2JWTPayload = { - clientIdentifier: args.clientIdentifier, deviceId: args.deviceId, }; From fa467fc2704022410d3ef7e071b1a8ef0cce3b6d Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 15 Oct 2025 17:06:02 +0200 Subject: [PATCH 26/51] Middleware update, better device registration --- src/api/v2/auth/auth.router.ts | 6 +- src/api/v2/device/device.router.ts | 10 +++ .../handlers/register.ts | 73 ++++++++----------- src/api/v2/index.ts | 4 +- .../v2/notifications/notifications.router.ts | 3 +- src/middleware/v2/auth.ts | 32 ++++++++ 6 files changed, 81 insertions(+), 47 deletions(-) create mode 100644 src/api/v2/device/device.router.ts rename src/api/v2/{notifications => device}/handlers/register.ts (54%) diff --git a/src/api/v2/auth/auth.router.ts b/src/api/v2/auth/auth.router.ts index 9a9ab68a..df55772d 100644 --- a/src/api/v2/auth/auth.router.ts +++ b/src/api/v2/auth/auth.router.ts @@ -1,15 +1,15 @@ import { Router } from "express"; import { authRateLimitMiddleware } from "@/middleware/rateLimit"; -import { authV2Middleware } from "@/middleware/v2/auth"; +import { appCheckOnlyMiddleware } from "@/middleware/v2/auth"; import { generateToken } from "./handlers/generate-token"; const authRouter = Router(); -// Token generation requires AppCheck (main app only) with strict rate limiting +// Token generation requires AppCheck only (main app only) with strict rate limiting authRouter.post( "/token", authRateLimitMiddleware, - authV2Middleware, + appCheckOnlyMiddleware, generateToken, ); 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/notifications/handlers/register.ts b/src/api/v2/device/handlers/register.ts similarity index 54% rename from src/api/v2/notifications/handlers/register.ts rename to src/api/v2/device/handlers/register.ts index e82156f7..c3115bf4 100644 --- a/src/api/v2/notifications/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -24,57 +24,48 @@ export async function register( { deviceId: body.deviceId, hasPushToken: !!body.pushToken, - pushTokenType: body.pushTokenType ?? "apns", + pushTokenType: body.pushTokenType, apnsEnv: body.apnsEnv, }, "Registering device", ); - // Check if device exists - const existingDevice = await prisma.deviceRegistration.findUnique({ - where: { deviceId: body.deviceId }, - }); + // Build update data - only include fields that were explicitly provided + const updateData: { + pushTokenType?: PushTokenType; + apnsEnv?: ApnsEnvironment | null; + pushToken?: string; + } = {}; - if (existingDevice) { - // Update existing device - only update pushToken if provided - const updateData: { - pushTokenType?: PushTokenType; - apnsEnv?: ApnsEnvironment | null; - pushToken?: string; - } = { + if (body.pushToken !== undefined) { + updateData.pushToken = body.pushToken; + } + if (body.pushTokenType !== undefined) { + updateData.pushTokenType = body.pushTokenType; + } + if (body.apnsEnv !== undefined) { + updateData.apnsEnv = body.apnsEnv; + } + + // Use upsert to avoid race conditions + // On create: use defaults for missing fields + // On update: only update fields that were provided + await prisma.deviceRegistration.upsert({ + where: { deviceId: body.deviceId }, + create: { + deviceId: body.deviceId, + pushToken: body.pushToken ?? "", pushTokenType: body.pushTokenType ?? "apns", apnsEnv: body.apnsEnv ?? null, - }; - - if (body.pushToken) { - updateData.pushToken = body.pushToken; - } - - await prisma.deviceRegistration.update({ - where: { deviceId: body.deviceId }, - data: updateData, - }); + }, + update: updateData, + }); - req.log.info( - { deviceId: body.deviceId, updatedPushToken: !!body.pushToken }, - "Device updated successfully", - ); - } else { - // Create new device - pushToken can be empty initially - await prisma.deviceRegistration.create({ - data: { - deviceId: body.deviceId, - pushToken: body.pushToken ?? "", - pushTokenType: body.pushTokenType ?? "apns", - apnsEnv: body.apnsEnv ?? null, - }, - }); + req.log.info( + { deviceId: body.deviceId, hasPushToken: !!body.pushToken }, + "Device registered successfully", + ); - req.log.info( - { deviceId: body.deviceId, hasPushToken: !!body.pushToken }, - "Device registered successfully", - ); - } res.status(200).send(); return; } catch (error) { diff --git a/src/api/v2/index.ts b/src/api/v2/index.ts index 4e1391db..18be4fec 100644 --- a/src/api/v2/index.ts +++ b/src/api/v2/index.ts @@ -1,7 +1,8 @@ import { Router } from "express"; import { webhookRouter } from "@/api/shared/notifications/webhook.router"; -import { authV2Middleware } from "@/middleware/v2/auth"; +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"; @@ -9,6 +10,7 @@ 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) diff --git a/src/api/v2/notifications/notifications.router.ts b/src/api/v2/notifications/notifications.router.ts index c9bc6e37..cb70c65e 100644 --- a/src/api/v2/notifications/notifications.router.ts +++ b/src/api/v2/notifications/notifications.router.ts @@ -1,13 +1,12 @@ import { Router } from "express"; -import { register } from "./handlers/register"; 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("/register", register); notificationsRouter.post("/subscribe", subscribe); notificationsRouter.post("/unsubscribe", unsubscribe); notificationsRouter.delete("/unregister/:clientId", unregister); diff --git a/src/middleware/v2/auth.ts b/src/middleware/v2/auth.ts index 11350681..6e83c6b1 100644 --- a/src/middleware/v2/auth.ts +++ b/src/middleware/v2/auth.ts @@ -5,6 +5,38 @@ 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, From 28ad06091820d080ebf469ea68e1e2038fa0b305 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 15 Oct 2025 17:40:31 +0200 Subject: [PATCH 27/51] Friendlier rate limiting --- src/middleware/rateLimit.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index d7657041..acd14b78 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -10,8 +10,8 @@ export const rateLimitMiddleware = rateLimit({ // Stricter rate limiting for auth endpoints (JWT generation) export const authRateLimitMiddleware = rateLimit({ - windowMs: 15 * 60 * 1000, // 15 minutes - limit: 10, // 10 requests per 15 minutes per IP + windowMs: 5 * 60 * 1000, // 5 minutes + limit: 50, // 50 requests per 5 minutes per IP legacyHeaders: false, standardHeaders: "draft-8", message: "Too many authentication requests, please try again later", From f65f03b8dfce96bf3a5c2436ca93950efa77918a Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 15 Oct 2025 17:55:16 +0200 Subject: [PATCH 28/51] Fix timestamp_ns (protobuf int64 compatibility) --- src/notifications/client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/notifications/client.ts b/src/notifications/client.ts index 6c6ac5d1..a9d939b5 100644 --- a/src/notifications/client.ts +++ b/src/notifications/client.ts @@ -29,7 +29,10 @@ export const webhookNotificationBodySchema = z.object({ idempotency_key: z.string(), message: z.object({ content_topic: z.string(), - timestamp_ns: 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({ From 98f0e050cc7ef8a660348e26068d97302d943194 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Wed, 15 Oct 2025 18:22:08 +0200 Subject: [PATCH 29/51] Make v1 and v2 notifications payload live together during the migration to the new invites system --- .../notifications/services/notifications-types.ts | 3 ++- .../services/push-notification.service.ts | 9 +++++++++ src/api/shared/notifications/webhook-handler.ts | 12 +++++++++--- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/api/shared/notifications/services/notifications-types.ts b/src/api/shared/notifications/services/notifications-types.ts index 49a568c4..5ac58302 100644 --- a/src/api/shared/notifications/services/notifications-types.ts +++ b/src/api/shared/notifications/services/notifications-types.ts @@ -37,7 +37,8 @@ export type NotificationTypeToData = { }; type NotificationPayloadBase = { - inboxId: string; + inboxId?: string; // Optional for v1→v2 transition + clientId?: string; // Optional for v1→v2 transition notificationType: T; notificationData: NotificationTypeToData[T]; }; diff --git a/src/api/shared/notifications/services/push-notification.service.ts b/src/api/shared/notifications/services/push-notification.service.ts index adb28a00..90d96dd8 100644 --- a/src/api/shared/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({ diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 727a6c0a..46e4ffa9 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -63,7 +63,10 @@ export async function handleXmtpNotification(req: Request, res: Response) { }); if (v2Client) { - req.log.info("Processing v2 notification"); + req.log.info( + { clientId: notification.installation.id }, + "Processing v2 notification", + ); await handleV2Notification({ notification, client: v2Client, @@ -94,7 +97,10 @@ export async function handleXmtpNotification(req: Request, res: Response) { return; } - req.log.info("Processing v1 notification"); + req.log.info( + { installationId: notification.installation.id }, + "Processing v1 notification", + ); identityOnDeviceToCleanup = { xmtpInstallationId: identityOnDevice.xmtpInstallationId, @@ -219,7 +225,7 @@ async function handleV2Notification(args: { // Send push notification with v2 types const v2Notification: V2NotificationPayload = { - clientId: client.id, + clientId: notification.installation.id, apiJWT, notificationType: "Protocol", notificationData: { From 06775d6cea7c2cf1ea2d221f62d560535ad50571 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 15:27:04 +0200 Subject: [PATCH 30/51] Don't send encryptedMessage with welcomes over APNS --- .../services/notifications-types.ts | 2 +- .../shared/notifications/webhook-handler.ts | 49 ++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/api/shared/notifications/services/notifications-types.ts b/src/api/shared/notifications/services/notifications-types.ts index 5ac58302..6e1c7dbd 100644 --- a/src/api/shared/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; }; diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index 46e4ffa9..fccc9e60 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -18,6 +18,21 @@ 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 * @@ -109,6 +124,18 @@ export async function handleXmtpNotification(req: Request, res: Response) { 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: { @@ -117,7 +144,11 @@ export async function handleXmtpNotification(req: Request, res: Response) { notificationData: { contentTopic: notification.message.content_topic, messageType: notification.message_context.message_type, - encryptedMessage: notification.message.message, + // 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, }, }, @@ -223,6 +254,19 @@ async function handleV2Notification(args: { 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, @@ -231,7 +275,8 @@ async function handleV2Notification(args: { notificationData: { contentTopic: notification.message.content_topic, messageType: notification.message_context.message_type, - encryptedMessage: notification.message.message, + // Omit encryptedMessage for welcome messages (too large for APNS 4KB limit) + ...(isWelcome ? {} : { encryptedMessage: notification.message.message }), timestamp: notification.message.timestamp_ns, }, }; From d5b45a755b5cbfd86d31be3e5714c7ce13832931 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:25:55 +0200 Subject: [PATCH 31/51] Enforce maxlength and non-empty value on deviceId and clientId --- src/api/v2/auth/handlers/generate-token.ts | 2 +- src/api/v2/device/handlers/register.ts | 2 +- src/api/v2/notifications/handlers/subscribe.ts | 4 ++-- src/api/v2/notifications/handlers/unregister.ts | 2 +- src/api/v2/notifications/handlers/unsubscribe.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index b016319e..30604921 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -19,7 +19,7 @@ import { createV2JwtToken } from "@/utils/v2/jwt"; */ const generateTokenRequestSchema = z.object({ - deviceId: z.string(), + deviceId: z.string().max(255).nonempty(), }); export type IGenerateTokenRequestBody = z.infer< diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index c3115bf4..4b54a6e9 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { prisma } from "@/utils/prisma"; const registerRequestSchema = z.object({ - deviceId: z.string(), + deviceId: z.string().max(255).nonempty(), pushToken: z.string().optional(), pushTokenType: PushTokenTypeSchema.optional(), apnsEnv: ApnsEnvironmentSchema.nullable().optional(), diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index c3ff3870..f26827a1 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -5,8 +5,8 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const subscribeRequestSchema = z.object({ - deviceId: z.string(), - clientId: z.string(), + deviceId: z.string().max(255).nonempty(), + clientId: z.string().max(255).nonempty(), topics: z.array( z.object({ topic: z.string(), diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts index 4391b025..6ce8487a 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unregisterParamsSchema = z.object({ - clientId: z.string(), + clientId: z.string().max(255).nonempty(), }); export type IUnregisterParams = z.infer; diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts index 0fb059cb..a0de44f7 100644 --- a/src/api/v2/notifications/handlers/unsubscribe.ts +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unsubscribeRequestSchema = z.object({ - clientId: z.string(), + clientId: z.string().max(255).nonempty(), topics: z.array(z.string()), }); From 4875e8287b018bcda084456d6d54307b1426bd5d Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:28:13 +0200 Subject: [PATCH 32/51] Validate metadata size to prevent JWT bloat --- src/utils/v2/jwt.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/v2/jwt.ts b/src/utils/v2/jwt.ts index 9d164ec1..90e9c7c1 100644 --- a/src/utils/v2/jwt.ts +++ b/src/utils/v2/jwt.ts @@ -31,7 +31,9 @@ export const createV2JwtToken = async (args: { }) => { // Validate metadata size to prevent JWT bloat if (args.metadata) { - const metadataSize = JSON.stringify(args.metadata).length; + const metadataSize = new TextEncoder().encode( + JSON.stringify(args.metadata), + ).length; if (metadataSize > MAX_JWT_METADATA_SIZE) { throw new AppError( 400, From 52f966ce6bd527ddb1dd7fcae26a415205b4e449 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:29:43 +0200 Subject: [PATCH 33/51] Refresh updatedAt by updating deviceId --- src/api/v2/notifications/handlers/subscribe.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index f26827a1..14fffca7 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -112,8 +112,8 @@ export async function subscribe( id: body.clientId, deviceId: body.deviceId, }, - // Refresh updatedAt - update: {}, + // Refresh updatedAt by updating deviceId + update: { deviceId: body.deviceId }, }); req.log.info( From 53f79891600073e13a24be0692b276fe7283a09e Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:32:04 +0200 Subject: [PATCH 34/51] Cleanup notification server when needed --- .../v2/notifications/handlers/subscribe.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 14fffca7..3ec7da9b 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -106,15 +106,30 @@ export async function subscribe( } // Create or update client identifier record - await prisma.clientIdentifier.upsert({ - where: { id: body.clientId }, - create: { - id: body.clientId, - deviceId: body.deviceId, - }, - // Refresh updatedAt by updating deviceId - update: { deviceId: body.deviceId }, - }); + 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 }, From 43a96f603979270df79cbdc68e0a47144370442a Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:35:18 +0200 Subject: [PATCH 35/51] Better transactional consistency for cleanup of v2 notifications --- .../shared/notifications/webhook-handler.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/api/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index fccc9e60..dbdeebd0 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -352,14 +352,24 @@ async function handleV2Notification(args: { `Cleaning up v2 notification client ${client.id} due to unrecoverable error`, ); try { - await notificationClient.deleteInstallation({ - installationId: client.id, - }); - + // 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", From dd6ccf0df7fc80815c645208723f0f8cb124a0d1 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:39:15 +0200 Subject: [PATCH 36/51] Better type safety for v1 and v2 notification types using union hat can handle both v1 and v2 --- .../notifications/services/apns-push.service.ts | 4 ++-- .../services/notifications-types.ts | 16 ++++++++++++---- src/api/shared/notifications/webhook-handler.ts | 7 ++----- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/api/shared/notifications/services/apns-push.service.ts b/src/api/shared/notifications/services/apns-push.service.ts index a6180179..a2e1ef48 100644 --- a/src/api/shared/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; diff --git a/src/api/shared/notifications/services/notifications-types.ts b/src/api/shared/notifications/services/notifications-types.ts index 6e1c7dbd..2e6ebd95 100644 --- a/src/api/shared/notifications/services/notifications-types.ts +++ b/src/api/shared/notifications/services/notifications-types.ts @@ -36,12 +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; // Optional for v1→v2 transition - clientId?: string; // Optional for v1→v2 transition notificationType: T; notificationData: NotificationTypeToData[T]; -}; +} & ( + | { inboxId: string; clientId?: never } + | { clientId: string; inboxId?: never } +); // Discriminated union over notificationType export type NotificationPayload = { @@ -56,10 +59,15 @@ export type NotificationPayloadWithJWTToken = NotificationPayload & { apiJWT: string; }; -// V2 notification types +// 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/shared/notifications/webhook-handler.ts b/src/api/shared/notifications/webhook-handler.ts index dbdeebd0..c4977a93 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -1,10 +1,7 @@ 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 { - NotificationPayloadWithJWTToken, - V2NotificationPayload, -} from "@/api/shared/notifications/services/notifications-types"; +import type { V2NotificationPayload } from "@/api/shared/notifications/services/notifications-types"; import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service"; import { createNotificationClient, @@ -300,7 +297,7 @@ async function handleV2Notification(args: { const result = await apnsService.sendPushNotification({ device: deviceForApns, - notification: v2Notification as unknown as NotificationPayloadWithJWTToken, + notification: v2Notification, }); // Track success/failure using atomic operations to prevent race conditions From 3ab931e883986ad0f39a7ee74307d39f7789d80a Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:41:43 +0200 Subject: [PATCH 37/51] Fix and protect webhook endpoint against configuration-based auth bypass --- src/middleware/xmtpWebhookAuth.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/middleware/xmtpWebhookAuth.ts b/src/middleware/xmtpWebhookAuth.ts index 5f37d34d..c0b622fd 100644 --- a/src/middleware/xmtpWebhookAuth.ts +++ b/src/middleware/xmtpWebhookAuth.ts @@ -11,8 +11,27 @@ export const xmtpWebhookAuthMiddleware = ( res: Response, next: NextFunction, ) => { + // Fail closed: reject if webhook secret is not configured + const expectedAuthHeaderRaw = getHttpDeliveryNotificationAuthHeader(); + if (!expectedAuthHeaderRaw || expectedAuthHeaderRaw.trim().length === 0) { + req.log.error("XMTP webhook secret not configured - rejecting request"); + res.status(500).json({ + error: "Server configuration error", + }); + return; + } + const expectedAuthHeader = expectedAuthHeaderRaw.trim(); + const authHeader = req.headers.authorization?.trim() ?? ""; - const expectedAuthHeader = getHttpDeliveryNotificationAuthHeader().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"); @@ -20,7 +39,7 @@ export const xmtpWebhookAuthMiddleware = ( provided.length === expected.length && timingSafeEqual(provided, expected); if (!valid) { - req.log.error("Invalid or missing XMTP webhook authorization header"); + req.log.error("Invalid XMTP webhook authorization header"); res.status(401).json({ error: "Unauthorized: Invalid authentication token", }); From bed066fee8828bbad14d04ac67faac7a041a5fd9 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 18:43:15 +0200 Subject: [PATCH 38/51] Protect against configuration-based auth bypass --- src/notifications/utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/notifications/utils.ts b/src/notifications/utils.ts index adbd4975..6ea4259c 100644 --- a/src/notifications/utils.ts +++ b/src/notifications/utils.ts @@ -1,5 +1,10 @@ import { XMTP_NOTIFICATION_SECRET } from "@/config"; export function getHttpDeliveryNotificationAuthHeader() { + if (!XMTP_NOTIFICATION_SECRET) { + throw new Error( + "XMTP_NOTIFICATION_SECRET is not configured - webhook authentication cannot proceed", + ); + } return XMTP_NOTIFICATION_SECRET; } From 7f2213d5ddb956ccfc14f1f38494d5f783f71609 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 19:03:34 +0200 Subject: [PATCH 39/51] Fixes and more generous rate limiting to accomodate multiple XMTP clients at the same time --- src/api/v2/auth/handlers/generate-token.ts | 4 +-- src/api/v2/device/handlers/register.ts | 2 +- .../v2/notifications/handlers/subscribe.ts | 29 ++++++++++--------- .../v2/notifications/handlers/unregister.ts | 2 +- .../v2/notifications/handlers/unsubscribe.ts | 5 ++-- src/middleware/rateLimit.ts | 6 ++-- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index 30604921..2e94bf76 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -13,13 +13,13 @@ import { createV2JwtToken } from "@/utils/v2/jwt"; * 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 (10 requests per 15 minutes) prevents token exhaustion attacks. + * 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().max(255).nonempty(), + deviceId: z.string().min(1).max(255), }); export type IGenerateTokenRequestBody = z.infer< diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index 4b54a6e9..6b4a8103 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { prisma } from "@/utils/prisma"; const registerRequestSchema = z.object({ - deviceId: z.string().max(255).nonempty(), + deviceId: z.string().min(1).max(255), pushToken: z.string().optional(), pushTokenType: PushTokenTypeSchema.optional(), apnsEnv: ApnsEnvironmentSchema.nullable().optional(), diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 3ec7da9b..13bcc2ef 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -5,19 +5,22 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const subscribeRequestSchema = z.object({ - deviceId: z.string().max(255).nonempty(), - clientId: z.string().max(255).nonempty(), - 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"), - }), - ), - }), - ), + deviceId: z.string().min(1).max(255), + clientId: z.string().min(1).max(255), + 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; diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts index 6ce8487a..f5a948ee 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unregisterParamsSchema = z.object({ - clientId: z.string().max(255).nonempty(), + clientId: z.string().min(1).max(255), }); export type IUnregisterParams = z.infer; diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts index a0de44f7..9086b0a9 100644 --- a/src/api/v2/notifications/handlers/unsubscribe.ts +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -4,8 +4,8 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unsubscribeRequestSchema = z.object({ - clientId: z.string().max(255).nonempty(), - topics: z.array(z.string()), + clientId: z.string().min(1).max(255), + topics: z.array(z.string()).min(1).max(100), }); export type IUnsubscribeRequestBody = z.infer; @@ -46,6 +46,7 @@ export async function unsubscribe( req.log.info({ clientId: body.clientId }, "Unsubscribed successfully"); res.status(200).send(); + return; } catch (error) { if (error instanceof z.ZodError) { req.log.warn( diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index acd14b78..23410686 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -1,9 +1,9 @@ import { rateLimit } from "express-rate-limit"; -// General rate limit for 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", }); @@ -11,7 +11,7 @@ export const rateLimitMiddleware = rateLimit({ // Stricter rate limiting for auth endpoints (JWT generation) export const authRateLimitMiddleware = rateLimit({ windowMs: 5 * 60 * 1000, // 5 minutes - limit: 50, // 50 requests per 5 minutes per IP + limit: 100, // 100 requests per 5 minutes per IP legacyHeaders: false, standardHeaders: "draft-8", message: "Too many authentication requests, please try again later", From ce6416cd699d8d47bd6e74176445f55b90a629a9 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 19:36:58 +0200 Subject: [PATCH 40/51] Make pushToken nullable and unique --- .../migration.sql | 8 ++++++++ prisma/schema.prisma | 3 ++- src/api/v2/device/handlers/register.ts | 2 +- src/api/v2/notifications/handlers/subscribe.ts | 9 +++++++++ 4 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 prisma/migrations/20251017193536_add_unique_constraint_to_push_tokens/migration.sql 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 9773c05d..f9a99277 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -180,7 +180,7 @@ model InviteCodeRequest { // v2 Push Notification Models model DeviceRegistration { deviceId String @id - pushToken String @db.Text + pushToken String? @db.Text pushTokenType PushTokenType @default(apns) apnsEnv ApnsEnvironment? pushFailures Int @default(0) @@ -191,6 +191,7 @@ model DeviceRegistration { lastFailureAt DateTime? clientIdentifiers ClientIdentifier[] + @@unique([pushTokenType, apnsEnv, pushToken]) @@index([pushToken]) @@index([disabled, pushFailures]) } diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index 6b4a8103..b9c5fe74 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -54,7 +54,7 @@ export async function register( where: { deviceId: body.deviceId }, create: { deviceId: body.deviceId, - pushToken: body.pushToken ?? "", + pushToken: body.pushToken ?? null, pushTokenType: body.pushTokenType ?? "apns", apnsEnv: body.apnsEnv ?? null, }, diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 13bcc2ef..2efb7cb4 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -63,6 +63,15 @@ export async function subscribe( 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, From 03ad38084cc7720ee2e592b78b05feaa5aaa25d2 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 19:40:50 +0200 Subject: [PATCH 41/51] Fix webhook auth, add return statements after errors --- .../handlers/register-installation.ts | 1 + src/api/v2/device/handlers/register.ts | 1 + src/api/v2/notifications/handlers/subscribe.ts | 1 + src/api/v2/notifications/handlers/unregister.ts | 1 + src/api/v2/notifications/handlers/unsubscribe.ts | 1 + src/middleware/xmtpWebhookAuth.ts | 16 ++++++++++++---- 6 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/api/v1/notifications/handlers/register-installation.ts b/src/api/v1/notifications/handlers/register-installation.ts index 0edf5ddc..d182d3ba 100644 --- a/src/api/v1/notifications/handlers/register-installation.ts +++ b/src/api/v1/notifications/handlers/register-installation.ts @@ -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/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index b9c5fe74..6457d327 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -79,5 +79,6 @@ export async function register( } req.log.error({ error }, "Failed to register device"); res.status(500).json({ error: "Failed to register device" }); + return; } } diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 2efb7cb4..8955a3f4 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -163,5 +163,6 @@ export async function subscribe( } 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 index f5a948ee..e9fee2d3 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -66,5 +66,6 @@ export async function unregister( } 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 index 9086b0a9..9ced8ba2 100644 --- a/src/api/v2/notifications/handlers/unsubscribe.ts +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -58,5 +58,6 @@ export async function unsubscribe( } req.log.error({ error }, "Failed to unsubscribe from topics"); res.status(500).json({ error: "Failed to unsubscribe from topics" }); + return; } } diff --git a/src/middleware/xmtpWebhookAuth.ts b/src/middleware/xmtpWebhookAuth.ts index c0b622fd..c4230b7e 100644 --- a/src/middleware/xmtpWebhookAuth.ts +++ b/src/middleware/xmtpWebhookAuth.ts @@ -12,15 +12,23 @@ export const xmtpWebhookAuthMiddleware = ( next: NextFunction, ) => { // Fail closed: reject if webhook secret is not configured - const expectedAuthHeaderRaw = getHttpDeliveryNotificationAuthHeader(); - if (!expectedAuthHeaderRaw || expectedAuthHeaderRaw.trim().length === 0) { - req.log.error("XMTP webhook secret not configured - rejecting request"); + 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 expectedAuthHeader = expectedAuthHeaderRaw.trim(); const authHeader = req.headers.authorization?.trim() ?? ""; From 7aed79b1607a2d95fdf173b82768d759f1c7e196 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 20:06:00 +0200 Subject: [PATCH 42/51] Fixes --- prisma/schema.prisma | 3 ++- .../notifications/services/apns-push.service.ts | 3 ++- .../services/push-notification.service.ts | 6 +++--- src/api/shared/notifications/webhook-handler.ts | 7 +++---- src/api/v2/device/handlers/register.ts | 13 +++++++++++++ src/api/v2/notifications/handlers/subscribe.ts | 2 +- tests/notifications-service.test.ts | 2 +- 7 files changed, 25 insertions(+), 11 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9a99277..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 { diff --git a/src/api/shared/notifications/services/apns-push.service.ts b/src/api/shared/notifications/services/apns-push.service.ts index a2e1ef48..04f950df 100644 --- a/src/api/shared/notifications/services/apns-push.service.ts +++ b/src/api/shared/notifications/services/apns-push.service.ts @@ -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/shared/notifications/services/push-notification.service.ts b/src/api/shared/notifications/services/push-notification.service.ts index 90d96dd8..867872fc 100644 --- a/src/api/shared/notifications/services/push-notification.service.ts +++ b/src/api/shared/notifications/services/push-notification.service.ts @@ -156,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 index c4977a93..24a1eac6 100644 --- a/src/api/shared/notifications/webhook-handler.ts +++ b/src/api/shared/notifications/webhook-handler.ts @@ -241,9 +241,8 @@ async function handleV2Notification(args: { }, }); - // NOTE: v2 currently only supports APNS/iOS push notifications - // Android/FCM support can be added when needed by using pushNotificationService - // and deriving OS from tokenType (see v1 implementation above) + // NOTE: v2 only supports APNS/iOS push notifications + // We do not support FCM/Android const apnsService = createApnsService(); if (!apnsService) { @@ -279,7 +278,7 @@ async function handleV2Notification(args: { }; // Create a device-like object for APNS service - // NOTE: os is hardcoded to "ios" since v2 only supports APNS for now + // NOTE: os is hardcoded to "ios" since v2 only supports APNS (no FCM/Android support) const deviceForApns = { id: client.deviceId, pushToken: client.device.pushToken, diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index 6457d327..e8768749 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -20,6 +20,19 @@ export async function register( 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, diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index 8955a3f4..ebfe8496 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -151,7 +151,7 @@ export async function subscribe( } catch (error) { if (error instanceof z.ZodError) { req.log.warn( - { errors: error.errors, body: req.body }, + { errors: error.errors }, "Invalid request body for subscribe", ); res.status(400).json({ diff --git a/tests/notifications-service.test.ts b/tests/notifications-service.test.ts index 1af7bf80..33bd8736 100644 --- a/tests/notifications-service.test.ts +++ b/tests/notifications-service.test.ts @@ -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, From 9cb34aed9805e8bb5d8d89b4ed4934daddc9b6a7 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 20:17:21 +0200 Subject: [PATCH 43/51] Handle the case when a push token is already registered to a different device --- src/api/v2/device/handlers/register.ts | 83 ++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index e8768749..146de5a0 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -60,18 +60,58 @@ export async function register( updateData.apnsEnv = body.apnsEnv; } - // Use upsert to avoid race conditions - // On create: use defaults for missing fields - // On update: only update fields that were provided - await prisma.deviceRegistration.upsert({ - where: { deviceId: body.deviceId }, - create: { - deviceId: body.deviceId, - pushToken: body.pushToken ?? null, - pushTokenType: body.pushTokenType ?? "apns", - apnsEnv: body.apnsEnv ?? null, - }, - update: updateData, + // 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( @@ -90,6 +130,25 @@ export async function 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; From 6518628245115673b7047e294ad5c3190cdeeca3 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 20:19:33 +0200 Subject: [PATCH 44/51] Validate pushToken, allow null but not empty string --- src/api/v2/device/handlers/register.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index 146de5a0..92ce8b5b 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -6,7 +6,7 @@ import { prisma } from "@/utils/prisma"; const registerRequestSchema = z.object({ deviceId: z.string().min(1).max(255), - pushToken: z.string().optional(), + pushToken: z.string().min(1).optional(), pushTokenType: PushTokenTypeSchema.optional(), apnsEnv: ApnsEnvironmentSchema.nullable().optional(), }); From cd880c86ad99ae9ab6b24b66ffa2ae8f1136b34b Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 20:21:15 +0200 Subject: [PATCH 45/51] Add missing return --- src/api/v2/notifications/handlers/unregister.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts index e9fee2d3..97611be0 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -48,6 +48,7 @@ export async function unregister( "Successfully cleaned up v2 client", ); res.status(200).send(); + return; } catch (cleanupError) { req.log.error( { error: cleanupError, clientId: params.clientId }, From 8388fa9bea261afc07fb9470de766e35a425a9dd Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 20:31:29 +0200 Subject: [PATCH 46/51] Normalize pushToken empty string to null --- src/api/v2/device/handlers/register.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index 92ce8b5b..cde80817 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -6,7 +6,11 @@ import { prisma } from "@/utils/prisma"; const registerRequestSchema = z.object({ deviceId: z.string().min(1).max(255), - pushToken: z.string().min(1).optional(), + pushToken: z + .string() + .min(1) + .optional() + .transform((val) => (val === "" ? undefined : val)), // Normalize empty strings pushTokenType: PushTokenTypeSchema.optional(), apnsEnv: ApnsEnvironmentSchema.nullable().optional(), }); @@ -47,11 +51,12 @@ export async function register( const updateData: { pushTokenType?: PushTokenType; apnsEnv?: ApnsEnvironment | null; - pushToken?: string; + pushToken?: string | null; } = {}; if (body.pushToken !== undefined) { - updateData.pushToken = body.pushToken; + // Normalize: undefined or empty string -> null + updateData.pushToken = body.pushToken || null; } if (body.pushTokenType !== undefined) { updateData.pushTokenType = body.pushTokenType; From aec0b5c3449987c2b42880ba84b9f5eeb8e19770 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 20:45:25 +0200 Subject: [PATCH 47/51] Enforce UUID for clientId and deviceId as primary keys of our db --- src/api/v2/auth/handlers/generate-token.ts | 2 +- src/api/v2/device/handlers/register.ts | 5 ++--- src/api/v2/notifications/handlers/subscribe.ts | 4 ++-- src/api/v2/notifications/handlers/unregister.ts | 2 +- src/api/v2/notifications/handlers/unsubscribe.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/api/v2/auth/handlers/generate-token.ts b/src/api/v2/auth/handlers/generate-token.ts index 2e94bf76..cecbb5e0 100644 --- a/src/api/v2/auth/handlers/generate-token.ts +++ b/src/api/v2/auth/handlers/generate-token.ts @@ -19,7 +19,7 @@ import { createV2JwtToken } from "@/utils/v2/jwt"; */ const generateTokenRequestSchema = z.object({ - deviceId: z.string().min(1).max(255), + deviceId: z.string().uuid(), }); export type IGenerateTokenRequestBody = z.infer< diff --git a/src/api/v2/device/handlers/register.ts b/src/api/v2/device/handlers/register.ts index cde80817..152b4e0e 100644 --- a/src/api/v2/device/handlers/register.ts +++ b/src/api/v2/device/handlers/register.ts @@ -5,12 +5,11 @@ import { z } from "zod"; import { prisma } from "@/utils/prisma"; const registerRequestSchema = z.object({ - deviceId: z.string().min(1).max(255), + deviceId: z.string().uuid(), pushToken: z .string() - .min(1) .optional() - .transform((val) => (val === "" ? undefined : val)), // Normalize empty strings + .transform((val) => (!val || val.trim() === "" ? undefined : val)), pushTokenType: PushTokenTypeSchema.optional(), apnsEnv: ApnsEnvironmentSchema.nullable().optional(), }); diff --git a/src/api/v2/notifications/handlers/subscribe.ts b/src/api/v2/notifications/handlers/subscribe.ts index ebfe8496..432ee04d 100644 --- a/src/api/v2/notifications/handlers/subscribe.ts +++ b/src/api/v2/notifications/handlers/subscribe.ts @@ -5,8 +5,8 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const subscribeRequestSchema = z.object({ - deviceId: z.string().min(1).max(255), - clientId: z.string().min(1).max(255), + deviceId: z.string().uuid(), + clientId: z.string().uuid(), topics: z .array( z.object({ diff --git a/src/api/v2/notifications/handlers/unregister.ts b/src/api/v2/notifications/handlers/unregister.ts index 97611be0..9978d46c 100644 --- a/src/api/v2/notifications/handlers/unregister.ts +++ b/src/api/v2/notifications/handlers/unregister.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unregisterParamsSchema = z.object({ - clientId: z.string().min(1).max(255), + clientId: z.string().uuid(), }); export type IUnregisterParams = z.infer; diff --git a/src/api/v2/notifications/handlers/unsubscribe.ts b/src/api/v2/notifications/handlers/unsubscribe.ts index 9ced8ba2..bd7e6955 100644 --- a/src/api/v2/notifications/handlers/unsubscribe.ts +++ b/src/api/v2/notifications/handlers/unsubscribe.ts @@ -4,7 +4,7 @@ import { createNotificationClient } from "@/notifications/client"; import { prisma } from "@/utils/prisma"; const unsubscribeRequestSchema = z.object({ - clientId: z.string().min(1).max(255), + clientId: z.string().uuid(), topics: z.array(z.string()).min(1).max(100), }); From 277d6fabd809d24efc9eba40a00a3dbfe2318798 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 23:17:59 +0200 Subject: [PATCH 48/51] Invite proto update --- proto/invite/v2/invite.proto | 9 +++++++++ .../v2/invites/handlers/decode-invite-slug.ts | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) 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/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, }, }; From 6e0c0f6fed940c7c43a237355df576eff8c4c50a Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 23:19:10 +0200 Subject: [PATCH 49/51] Update invite v2 test with new proto --- tests/invites-v2.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/invites-v2.test.ts b/tests/invites-v2.test.ts index df5a67d6..c9625d25 100644 --- a/tests/invites-v2.test.ts +++ b/tests/invites-v2.test.ts @@ -89,7 +89,7 @@ describe("V2 Invite API Tests", () => { // Pre-generated valid SignedInvite slug // Contains: conversationId, inviteId, signature; no name/description/imageURL const validSlug = - "CqQBClRBZjBJY3ZNSmo5TW9UcFZEc2x0YnNSakNwaldvcTA2am9iYmRWcVRZb2l4RUQzNVN1X1BJWmNaRmN0MkM5eDFqdEQydE4wZXM0Tkx4MDZMRkJhS3USQDYyZTFmMDIwNTc4YmRjNjMxMDZkZTJmYmFkODUzMzVjM2VkYzRhNzlhNWIyMWVhNjgxMjE2OGQxZjY3MTNlMjUaClVhN2RSRlFqdmESQcEhHVsmCTay20THnnQlEUDVGfhG9OnyHqgbtTFa9WBFat7aUl_22_SPdWfKSZuFUw3N90jc2vtWHkr2zb8eNrEB"; + "CqoBClRBWWt1ZEJFY21YZDJuaTRUUlpqVXFCV0lKMWFhaXRHZEprTEI3N3prME1YT0Z1Q0dacXVzQ2VpcWNIaTl0eE9jcXVfZTU4bGlSa2VpRXdIQUlzZWcSQDgwYzViZDk4MzNmYTU4ZjMxYzRlYTYwMWIxYjQ0NmU1NjVhYzdmYTQ4ZTM5Y2UxY2Y4ZDE0ZjI3OTAyNzE4ZTkaCllZb1gycjROR2UiACoAMgASQfN77AHiMDWy2Aal8xoG0UJ"; const response = await fetch(`${baseURL}/api/v2/invites/${validSlug}`, { method: "GET", @@ -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); }); }); From 9c17fdd1ca6a206b1bb684244b9b1a65de6d843a Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 23:24:11 +0200 Subject: [PATCH 50/51] Update invite slug --- tests/invites-v2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/invites-v2.test.ts b/tests/invites-v2.test.ts index c9625d25..a89150cc 100644 --- a/tests/invites-v2.test.ts +++ b/tests/invites-v2.test.ts @@ -89,7 +89,7 @@ describe("V2 Invite API Tests", () => { // Pre-generated valid SignedInvite slug // Contains: conversationId, inviteId, signature; no name/description/imageURL const validSlug = - "CqoBClRBWWt1ZEJFY21YZDJuaTRUUlpqVXFCV0lKMWFhaXRHZEprTEI3N3prME1YT0Z1Q0dacXVzQ2VpcWNIaTl0eE9jcXVfZTU4bGlSa2VpRXdIQUlzZWcSQDgwYzViZDk4MzNmYTU4ZjMxYzRlYTYwMWIxYjQ0NmU1NjVhYzdmYTQ4ZTM5Y2UxY2Y4ZDE0ZjI3OTAyNzE4ZTkaCllZb1gycjROR2UiACoAMgASQfN77AHiMDWy2Aal8xoG0UJ"; + "CqQBClRBZjBJY3ZNSmo5TW9UcFZEc2x0YnNSakNwaldvcTA2am9iYmRWcVRZb2l4RUQzNVN1X1BJWmNaRmN0MkM5eDFqdEQydE4wZXM0Tkx4MDZMRkJhS3USQDYyZTFmMDIwNTc4YmRjNjMxMDZkZTJmYmFkODUzMzVjM2VkYzRhNzlhNWIyMWVhNjgxMjE2OGQxZjY3MTNlMjUaClVhN2RSRlFqdmESQcEhHVsmCTay20THnnQlEUDVGfhG9OnyHqgbtTFa9WBFat7aUl_22_SPdWfKSZuFUw3N90jc2vtWHkr2zb8eNrEB"; const response = await fetch(`${baseURL}/api/v2/invites/${validSlug}`, { method: "GET", From 11ab9dc39dbcedc544e2f089e442f3d43b32d0c5 Mon Sep 17 00:00:00 2001 From: Louis Rouffineau Date: Fri, 17 Oct 2025 23:27:21 +0200 Subject: [PATCH 51/51] Config update --- src/config.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index 3b3678f9..a6dd03b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,8 +21,15 @@ if (!process.env.NOTIFICATION_SERVER_URL) { // 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 = NODE_ENV === "production"; -export const IS_DEVELOPMENT = NODE_ENV === "development"; +export const IS_PRODUCTION = process.env.NODE_ENV === "production"; +export const IS_DEVELOPMENT = process.env.NODE_ENV === "development";