Skip to content

Commit f642613

Browse files
committed
Share the webhook between v1 and v2 API versions
1 parent 9c2a4b1 commit f642613

14 files changed

Lines changed: 326 additions & 317 deletions

scripts/simple-push-test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env bun
2-
import { createApnsService } from "@/api/v1/notifications/services/apns-push.service";
2+
import { createApnsService } from "@/api/shared/notifications/services/apns-push.service";
33
import { prisma } from "@/utils/prisma";
44

55
// Simple version - just provide an XMTP ID and it will send a basic test notification

scripts/test-push-notification.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env bun
2-
import { createApnsService } from "@/api/v1/notifications/services/apns-push.service";
2+
import { createApnsService } from "@/api/shared/notifications/services/apns-push.service";
33
import { prisma } from "@/utils/prisma";
44

55
interface TestPushArgs {

src/api/v1/notifications/services/apns-push.service.ts renamed to src/api/shared/notifications/services/apns-push.service.ts

File renamed without changes.

src/api/v1/notifications/services/notifications-types.ts renamed to src/api/shared/notifications/services/notifications-types.ts

File renamed without changes.

src/api/v1/notifications/services/push-notification.service.ts renamed to src/api/shared/notifications/services/push-notification.service.ts

File renamed without changes.
Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import type { ClientIdentifier, DeviceRegistration } from "@prisma/client";
2+
import type { Request, Response } from "express";
3+
import { createApnsService } from "@/api/shared/notifications/services/apns-push.service";
4+
import type {
5+
NotificationPayloadWithJWTToken,
6+
V2NotificationPayload,
7+
} from "@/api/shared/notifications/services/notifications-types";
8+
import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service";
9+
import {
10+
createNotificationClient,
11+
type WebhookNotificationBody,
12+
} from "@/notifications/client";
13+
import { getHttpDeliveryNotificationAuthHeader } from "@/notifications/utils";
14+
import { prisma } from "@/utils/prisma";
15+
import { createV2JwtToken } from "@/utils/v2/jwt";
16+
17+
const notificationClient = createNotificationClient();
18+
const pushNotificationService = getPushNotificationService();
19+
20+
if (!process.env.XMTP_NOTIFICATION_SECRET) {
21+
throw new Error("XMTP_NOTIFICATION_SECRET is not set");
22+
}
23+
24+
/**
25+
* Webhook handler for XMTP notifications
26+
*
27+
* This endpoint uses custom header-based authentication instead of the standard authMiddleware.
28+
* It validates the request using the XMTP_NOTIFICATION_SECRET to verify the webhook is coming
29+
* from the authorized XMTP server.
30+
*/
31+
export async function handleXmtpNotification(req: Request, res: Response) {
32+
let identityOnDeviceToCleanup: {
33+
xmtpInstallationId: string | null;
34+
deviceId: string;
35+
} | null = null;
36+
37+
try {
38+
const notification = req.body as WebhookNotificationBody;
39+
40+
// Log the notification for debugging
41+
req.log.info(
42+
{
43+
contentTopic: notification.message.content_topic,
44+
installationId: notification.installation.id,
45+
},
46+
"received notification",
47+
);
48+
49+
// Verify the authorization header
50+
const authHeader = req.headers.authorization;
51+
const expectedAuthHeader = getHttpDeliveryNotificationAuthHeader();
52+
53+
if (!authHeader || authHeader !== expectedAuthHeader) {
54+
req.log.error("Invalid or missing authorization header");
55+
res.status(401).json({
56+
error: "Unauthorized: Invalid authentication token",
57+
});
58+
return;
59+
}
60+
61+
// TRY V2 FIRST (clientIdentifier lookup)
62+
const v2Client = await prisma.clientIdentifier.findUnique({
63+
where: { id: notification.installation.id },
64+
include: { device: true },
65+
});
66+
67+
if (v2Client) {
68+
req.log.info("Processing v2 notification");
69+
await handleV2Notification({
70+
notification,
71+
client: v2Client,
72+
req,
73+
});
74+
res.status(200).end();
75+
return;
76+
}
77+
78+
// FALLBACK TO V1 (xmtpInstallationId lookup)
79+
const identityOnDevice = await prisma.identitiesOnDevice.findUnique({
80+
where: {
81+
xmtpInstallationId: notification.installation.id,
82+
},
83+
include: {
84+
device: true,
85+
identity: true,
86+
},
87+
});
88+
89+
if (!identityOnDevice || !identityOnDevice.xmtpInstallationId) {
90+
req.log.error(
91+
`Installation not found (v1 or v2) for installationId ${notification.installation.id}`,
92+
);
93+
res.status(400).json({
94+
error: `Installation not found for installationId ${notification.installation.id}`,
95+
});
96+
return;
97+
}
98+
99+
req.log.info("Processing v1 notification");
100+
101+
identityOnDeviceToCleanup = {
102+
xmtpInstallationId: identityOnDevice.xmtpInstallationId,
103+
deviceId: identityOnDevice.deviceId,
104+
};
105+
106+
const { device, identity } = identityOnDevice;
107+
108+
const result = await pushNotificationService.sendPushNotification({
109+
identityOnDevice,
110+
notification: {
111+
inboxId: identity.xmtpId,
112+
notificationType: "Protocol",
113+
notificationData: {
114+
contentTopic: notification.message.content_topic,
115+
messageType: notification.message_context.message_type,
116+
encryptedMessage: notification.message.message,
117+
timestamp: notification.message.timestamp_ns,
118+
},
119+
},
120+
});
121+
122+
if (!result.success && result.shouldCleanup) {
123+
req.log.info(
124+
`Push notification failed with unrecoverable error for device ${device.id}. Initiating cleanup.`,
125+
);
126+
if (identityOnDeviceToCleanup.xmtpInstallationId) {
127+
await cleanupFailedInstallation({
128+
xmtpInstallationId: identityOnDeviceToCleanup.xmtpInstallationId,
129+
deviceId: identityOnDeviceToCleanup.deviceId,
130+
req,
131+
});
132+
}
133+
}
134+
135+
res.status(200).end();
136+
return;
137+
} catch (error) {
138+
req.log.error({ error }, "Outer error processing notification");
139+
res.status(500).json({ error: "Internal server error" });
140+
}
141+
}
142+
143+
async function cleanupFailedInstallation(args: {
144+
xmtpInstallationId: string;
145+
deviceId: string;
146+
req: Request;
147+
}) {
148+
const { xmtpInstallationId, deviceId, req } = args;
149+
150+
try {
151+
req.log.info(
152+
`Cleaning up installation: ${xmtpInstallationId} for device: ${deviceId}`,
153+
);
154+
await prisma.$transaction([
155+
prisma.identitiesOnDevice.updateMany({
156+
where: { xmtpInstallationId: xmtpInstallationId },
157+
data: { xmtpInstallationId: null },
158+
}),
159+
prisma.device.update({
160+
where: { id: deviceId },
161+
data: {
162+
pushToken: null,
163+
pushFailures: { increment: 1 },
164+
},
165+
}),
166+
]);
167+
req.log.info(
168+
`Successfully cleaned xmtpInstallationId ${xmtpInstallationId} and tokens for device ${deviceId} from local DB.`,
169+
);
170+
171+
await notificationClient.deleteInstallation({
172+
installationId: xmtpInstallationId,
173+
});
174+
req.log.info(
175+
`Successfully requested deletion of xmtpInstallationId ${xmtpInstallationId} from XMTP server.`,
176+
);
177+
} catch (cleanupError) {
178+
req.log.error(
179+
{ error: cleanupError, xmtpInstallationId, deviceId },
180+
"Failed during cleanup of installation",
181+
);
182+
}
183+
}
184+
185+
async function handleV2Notification(args: {
186+
notification: WebhookNotificationBody;
187+
client: ClientIdentifier & { device: DeviceRegistration };
188+
req: Request;
189+
}) {
190+
const { notification, client, req } = args;
191+
192+
// Check if device is disabled or has too many failures
193+
if (client.device.disabled || client.device.pushFailures >= 10) {
194+
req.log.warn(
195+
`Device ${client.deviceId} is disabled or has too many failures. Skipping notification.`,
196+
);
197+
return { success: false };
198+
}
199+
200+
// Generate JWT for NSE to use
201+
const apiJWT = await createV2JwtToken({
202+
clientIdentifier: client.id,
203+
deviceId: client.deviceId,
204+
expirationTime: "72h",
205+
metadata: {
206+
notificationExtensionOnly: true,
207+
},
208+
});
209+
210+
// Create APNS service
211+
const apnsService = createApnsService();
212+
213+
if (!apnsService) {
214+
req.log.error("APNS service not configured");
215+
return { success: false };
216+
}
217+
218+
// Send push notification with v2 types
219+
const v2Notification: V2NotificationPayload = {
220+
clientIdentifier: client.id,
221+
apiJWT,
222+
notificationType: "Protocol",
223+
notificationData: {
224+
contentTopic: notification.message.content_topic,
225+
messageType: notification.message_context.message_type,
226+
encryptedMessage: notification.message.message,
227+
timestamp: notification.message.timestamp_ns,
228+
},
229+
};
230+
231+
// Create a device-like object for APNS service
232+
const deviceForApns = {
233+
id: client.deviceId,
234+
pushToken: client.device.pushToken,
235+
pushTokenType: client.device.tokenType,
236+
apnsEnv: client.device.apnsEnv,
237+
pushFailures: client.device.pushFailures,
238+
name: null,
239+
os: "ios" as const,
240+
appVersion: null,
241+
appBuildNumber: null,
242+
createdAt: client.device.addedAt,
243+
updatedAt: client.device.updatedAt,
244+
lastPushSuccessAt: client.device.lastSentAt,
245+
};
246+
247+
const result = await apnsService.sendPushNotification({
248+
device: deviceForApns,
249+
notification: v2Notification as unknown as NotificationPayloadWithJWTToken,
250+
});
251+
252+
// Track success/failure
253+
if (result.success) {
254+
await prisma.deviceRegistration.update({
255+
where: { deviceId: client.deviceId },
256+
data: {
257+
pushFailures: 0,
258+
lastSentAt: new Date(),
259+
},
260+
});
261+
req.log.info(
262+
`Successfully sent v2 push notification to ${client.deviceId}`,
263+
);
264+
} else {
265+
const newFailureCount = client.device.pushFailures + 1;
266+
await prisma.deviceRegistration.update({
267+
where: { deviceId: client.deviceId },
268+
data: {
269+
pushFailures: newFailureCount,
270+
lastFailureAt: new Date(),
271+
disabled: newFailureCount >= 10,
272+
},
273+
});
274+
req.log.warn(
275+
`Failed to send v2 push notification to ${client.deviceId}. Failure count: ${newFailureCount}`,
276+
);
277+
278+
// Cleanup if unrecoverable error
279+
if (
280+
result.error === "DeviceNotRegistered" ||
281+
result.error === "BadDeviceToken"
282+
) {
283+
req.log.info(
284+
`Cleaning up v2 client ${client.id} due to unrecoverable error`,
285+
);
286+
await notificationClient.deleteInstallation({
287+
installationId: client.id,
288+
});
289+
await prisma.clientIdentifier.delete({
290+
where: { id: client.id },
291+
});
292+
}
293+
}
294+
295+
return result;
296+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Router } from "express";
2+
import { handleXmtpNotification } from "./webhook-handler";
3+
4+
const webhookRouter = Router();
5+
6+
// XMTP webhook handler (shared between v1 and v2)
7+
webhookRouter.post("/handle-notification", handleXmtpNotification);
8+
9+
export { webhookRouter };

src/api/v1/index.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { Router } from "express";
2+
import { webhookRouter } from "@/api/shared/notifications/webhook.router";
23
import appConfigRouter from "@/api/v1/appConfig";
34
import authenticateRouter from "@/api/v1/authenticate";
4-
import {
5-
notificationsRouter,
6-
xmtpNotificationsRouter,
7-
} from "@/api/v1/notifications/notifications.router";
5+
import { notificationsRouter } from "@/api/v1/notifications/notifications.router";
86
import publicProfilesRouter from "@/api/v1/profiles/profiles-public.router";
97
import profilesRouter from "@/api/v1/profiles/profiles.router";
108
import { authMiddleware } from "@/middleware/auth";
@@ -24,7 +22,7 @@ v1Router.use("/app-config", appConfigRouter);
2422
v1Router.use("/authenticate", authenticateRouter);
2523

2624
// mount xmtp notification webhook (no auth middleware)
27-
v1Router.use("/notifications/xmtp", xmtpNotificationsRouter);
25+
v1Router.use("/notifications/xmtp", webhookRouter);
2826

2927
// create and manage turnkey wallets/suborgs
3028
v1Router.use("/wallets", walletsRouter);

src/api/v1/invites/handlers/request-to-join.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import type { InviteCode } from "@prisma/client";
22
import type { Request, Response } from "express";
33
import { z } from "zod";
4+
import type { InviteJoinRequestNotificationData } from "@/api/shared/notifications/services/notifications-types";
5+
import { getPushNotificationService } from "@/api/shared/notifications/services/push-notification.service";
46
import { getInviteLink } from "@/utils/invites";
57
import { prisma } from "@/utils/prisma";
6-
import type { InviteJoinRequestNotificationData } from "../../notifications/services/notifications-types";
7-
import { getPushNotificationService } from "../../notifications/services/push-notification.service";
88

99
const requestToJoinSchema = z.object({
1010
inviteId: z.string().min(1, "Invite ID is required"),

0 commit comments

Comments
 (0)