Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
7258784
Prisma model update
lourou Oct 3, 2025
e9d03eb
Data model migration
lourou Oct 3, 2025
79bf2d8
Data model migration
lourou Oct 8, 2025
85ff289
Update prisma and prisma client
lourou Oct 8, 2025
a1f9304
Implement JWT v2
lourou Oct 9, 2025
849d6e6
Implement auth and notifications handlers
lourou Oct 9, 2025
5778b6f
WIP notifications handlers
lourou Oct 9, 2025
fd1fbe6
Add path alias for prisma-zod
lourou Oct 9, 2025
0b26361
Fix type and formatting
lourou Oct 9, 2025
c27648f
Fix lint and types
lourou Oct 9, 2025
79f8ef2
Share the webhook between v1 and v2 API versions
lourou Oct 9, 2025
edf1d48
Validate hex string
lourou Oct 9, 2025
a539618
Fixes
lourou Oct 9, 2025
6676e11
Fixes
lourou Oct 9, 2025
b33e2d9
Fixes
lourou Oct 9, 2025
42b9637
Fixes
lourou Oct 10, 2025
92230ba
Changed pushFailures to use atomic reset
lourou Oct 10, 2025
9c6a812
Add transactions
lourou Oct 10, 2025
d44594d
Cache most used environment variables
lourou Oct 10, 2025
0ed3839
Remove gatewayAuthorized
lourou Oct 10, 2025
bd456b5
Webhook handler header check as middleware
lourou Oct 10, 2025
2b8698f
Fixes
lourou Oct 10, 2025
929d1ed
Fixes
lourou Oct 10, 2025
0554fbd
Fixes
lourou Oct 10, 2025
03ff700
Fixes and adding debug logs
lourou Oct 10, 2025
fa467fc
Middleware update, better device registration
lourou Oct 15, 2025
28ad060
Friendlier rate limiting
lourou Oct 15, 2025
f65f03b
Fix timestamp_ns (protobuf int64 compatibility)
lourou Oct 15, 2025
98f0e05
Make v1 and v2 notifications payload live together during the migrati…
lourou Oct 15, 2025
06775d6
Don't send encryptedMessage with welcomes over APNS
lourou Oct 17, 2025
d5b45a7
Enforce maxlength and non-empty value on deviceId and clientId
lourou Oct 17, 2025
4875e82
Validate metadata size to prevent JWT bloat
lourou Oct 17, 2025
52f966c
Refresh updatedAt by updating deviceId
lourou Oct 17, 2025
53f7989
Cleanup notification server when needed
lourou Oct 17, 2025
43a96f6
Better transactional consistency for cleanup of v2 notifications
lourou Oct 17, 2025
dd6ccf0
Better type safety for v1 and v2 notification types using union hat c…
lourou Oct 17, 2025
3ab931e
Fix and protect webhook endpoint against configuration-based auth bypass
lourou Oct 17, 2025
bed066f
Protect against configuration-based auth bypass
lourou Oct 17, 2025
7f2213d
Fixes and more generous rate limiting to accomodate multiple XMTP cli…
lourou Oct 17, 2025
ce6416c
Make pushToken nullable and unique
lourou Oct 17, 2025
03ad380
Fix webhook auth, add return statements after errors
lourou Oct 17, 2025
7aed79b
Fixes
lourou Oct 17, 2025
9cb34ae
Handle the case when a push token is already registered to a differen…
lourou Oct 17, 2025
6518628
Validate pushToken, allow null but not empty string
lourou Oct 17, 2025
cd880c8
Add missing return
lourou Oct 17, 2025
8388fa9
Normalize pushToken empty string to null
lourou Oct 17, 2025
aec0b5c
Enforce UUID for clientId and deviceId as primary keys of our db
lourou Oct 17, 2025
277d6fa
Invite proto update
lourou Oct 17, 2025
6e0c0f6
Update invite v2 test with new proto
lourou Oct 17, 2025
9c17fdd
Update invite slug
lourou Oct 17, 2025
11ab9dc
Config update
lourou Oct 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 50 additions & 10 deletions bun.lock

Large diffs are not rendered by default.

13 changes: 6 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
lourou marked this conversation as resolved.
"@prisma/instrumentation": "^6.7.0",
"@turnkey/sdk-server": "^3.0.1",
"@xmtp/node-sdk": "^2.0.8",
Expand All @@ -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",
Expand All @@ -79,10 +78,10 @@
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-packagejson": "^2.5.7",
"prisma": "^6.12.0",
"prisma": "^6.17.0",
"rimraf": "^6.0.1",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"packageManager": "yarn@4.6.0+sha512.5383cc12567a95f1d668fbe762dfe0075c595b4bfff433be478dbbe24e05251a8e8c3eb992a986667c1d53b6c3a9c85b8398c35a960587fbd9fa3a0915406728"
"typescript-eslint": "^8.20.0",
"zod-prisma-types": "^3.2.4"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "DeviceRegistration" (
"deviceId" TEXT NOT NULL,
"pushToken" TEXT NOT NULL,
"pushTokenType" "PushTokenType" NOT NULL DEFAULT 'apns',
"apnsEnv" "ApnsEnvironment",
"pushFailures" INTEGER NOT NULL DEFAULT 0,
"disabled" BOOLEAN NOT NULL DEFAULT false,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"lastSentAt" TIMESTAMP(3),
"lastFailureAt" TIMESTAMP(3),

CONSTRAINT "DeviceRegistration_pkey" PRIMARY KEY ("deviceId")
);

-- CreateTable
CREATE TABLE "ClientIdentifier" (
"id" TEXT NOT NULL,
"deviceId" TEXT NOT NULL,
"addedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "ClientIdentifier_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "DeviceRegistration_pushToken_idx" ON "DeviceRegistration"("pushToken");

-- CreateIndex
CREATE INDEX "DeviceRegistration_disabled_pushFailures_idx" ON "DeviceRegistration"("disabled", "pushFailures");

-- CreateIndex
CREATE INDEX "ClientIdentifier_deviceId_idx" ON "ClientIdentifier"("deviceId");

-- AddForeignKey
ALTER TABLE "ClientIdentifier" ADD CONSTRAINT "ClientIdentifier_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "DeviceRegistration"("deviceId") ON DELETE CASCADE ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -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");
29 changes: 29 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,32 @@ model InviteCodeRequest {
@@index([inviteCodeId])
@@index([requesterId])
}

// v2 Push Notification Models
model DeviceRegistration {
deviceId String @id
pushToken String? @db.Text
pushTokenType PushTokenType @default(apns)
apnsEnv ApnsEnvironment?
pushFailures Int @default(0)
disabled Boolean @default(false)
addedAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastSentAt DateTime?
lastFailureAt DateTime?
clientIdentifiers ClientIdentifier[]

@@unique([pushTokenType, apnsEnv, pushToken])
@@index([pushToken])
@@index([disabled, pushFailures])
}

model ClientIdentifier {
id String @id
deviceId String
device DeviceRegistration @relation(fields: [deviceId], references: [deviceId], onDelete: Cascade)
addedAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([deviceId])
}
Comment thread
lourou marked this conversation as resolved.
2 changes: 1 addition & 1 deletion scripts/simple-push-test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-push-notification.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions src/api/shared/notifications/constants.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export type NotificationType = "Protocol" | "InviteJoinRequest";
export type ProtocolNotificationData = {
contentTopic: string;
messageType: string;
encryptedMessage: string;
encryptedMessage?: string;
timestamp: string;
};

Expand Down Expand Up @@ -36,11 +36,15 @@ export type NotificationTypeToData = {
InviteJoinRequest: InviteJoinRequestNotificationData;
};

// Base notification payload with XOR semantics for v1/v2 transition
// Ensures exactly one of inboxId (v1) or clientId (v2) is present
type NotificationPayloadBase<T extends NotificationType> = {
inboxId: string;
notificationType: T;
notificationData: NotificationTypeToData[T];
};
} & (
| { inboxId: string; clientId?: never }
| { clientId: string; inboxId?: never }
);

// Discriminated union over notificationType
export type NotificationPayload = {
Expand All @@ -54,3 +58,16 @@ export type NotificationPayloadFor<T extends NotificationType> =
export type NotificationPayloadWithJWTToken = NotificationPayload & {
apiJWT: string;
};

// V2 notification types (structurally compatible with v2 branch of NotificationPayloadWithJWTToken)
export type V2NotificationPayload = {
clientId: string;
apiJWT: string;
notificationType: "Protocol";
notificationData: ProtocolNotificationData;
};

// Union type for push services that can handle both v1 and v2
export type AnyNotificationPayloadWithJWT =
| NotificationPayloadWithJWTToken
| V2NotificationPayload;
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading