fix(devices): reconcile APNs registrations

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:25:48 +02:00
1 parent 452d1a2ae3
commit 7b515f4f82
3 files changed
+437 -147

No files matched your search

+69 -8
View File
@@ -8,7 +8,13 @@ vi.mock("@/lib/db", () => ({
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
deleteMany: vi.fn(),
},
refreshToken: {
updateMany: vi.fn(),
},
$queryRaw: vi.fn(),
$transaction: vi.fn(),
notificationChannel: {
upsert: vi.fn(),
},
@@ -76,6 +82,12 @@ beforeEach(() => {
vi.mocked(prisma.device.findFirst).mockResolvedValue(null);
vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never);
vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never);
vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.$queryRaw).mockResolvedValue([] as never);
vi.mocked(prisma.$transaction).mockImplementation(
async (callback) => callback(prisma as never),
);
vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({
id: "ch-1",
} as never);
@@ -228,27 +240,76 @@ describe("POST /api/devices", () => {
expect(prisma.device.update).not.toHaveBeenCalled();
});
it("falls through idempotently when the apnsToken already belongs to the same user", async () => {
// v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used
// to filter `NOT: { userId }` so it skipped same-user collisions
// entirely, letting `findMany({ apnsToken })` fan a single push
// out to two Device rows. The guard now reads `userId` and only
// rejects cross-user collisions; same-user collisions fall
// through to the legacy-token upsert path.
it("reconciles a changed legacy token onto the same user's APNs device", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findFirst).mockResolvedValue({
id: "self-dev",
userId: "user-1",
token: "original-legacy-token",
} as never);
const res = await POST(
req({
token: "abcd1234efgh5678",
token: "changed-legacy-token",
bundleId: "io.healthlog.app",
apnsToken: "deadbeef".repeat(8),
apnsEnvironment: "sandbox",
}),
);
expect(res.status).toBe(201);
expect(prisma.device.create).not.toHaveBeenCalled();
expect(prisma.device.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "self-dev", userId: "user-1" },
data: expect.objectContaining({ token: "changed-legacy-token" }),
}),
);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", deviceId: "original-legacy-token" },
data: { deviceId: "changed-legacy-token" },
});
});
it("merges two same-user identities and migrates owned refresh tokens", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "legacy-dev",
userId: "user-1",
token: "changed-legacy-token",
medicationDelivery: "client",
liveActivityPushToken: "cafebabe".repeat(8),
} as never);
vi.mocked(prisma.device.findFirst).mockResolvedValue({
id: "apns-dev",
userId: "user-1",
token: "original-legacy-token",
} as never);
const res = await POST(
req({
token: "changed-legacy-token",
bundleId: "io.healthlog.app",
apnsToken: "deadbeef".repeat(8),
apnsEnvironment: "production",
}),
);
expect(res.status).toBe(201);
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
where: { userId: "user-1", deviceId: "original-legacy-token" },
data: { deviceId: "changed-legacy-token" },
});
expect(prisma.device.deleteMany).toHaveBeenCalledWith({
where: { id: "legacy-dev", userId: "user-1" },
});
expect(prisma.device.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "apns-dev", userId: "user-1" },
data: expect.objectContaining({
medicationDelivery: "client",
liveActivityPushToken: "cafebabe".repeat(8),
}),
}),
);
});
// v1.17.1 (#22) — Live Activity push-token registration.
+134 -100
View File
@@ -3,8 +3,8 @@
*
* Native-client device registration. The iOS app calls this on login,
* on APNs token rotation, and whenever a fresh `apnsToken` arrives from
* `application:didRegisterForRemoteNotificationsWithDeviceToken:`. We
* upsert by `token` (the legacy generic identifier).
* `application:didRegisterForRemoteNotificationsWithDeviceToken:`. Both the
* legacy `token` and `apnsToken` resolve to one canonical device.
*
* Cross-user-hijack guard:
* * A device `token` belongs to exactly one user. Re-registering the
@@ -162,109 +162,143 @@ export const POST = apiHandler(async (request: NextRequest) => {
liveActivityPushToken,
} = parsed.data;
// APNs-token collision lookup. Two cases:
// * A row for ANOTHER user owns this token → 409 hijack guard.
// APNs tokens aren't secrets, so accepting wire input that
// references another user's token would let anyone who learns
// one redirect that user's pushes.
// * A row for THIS user already owns it → idempotent fall-through.
// The upsert below either updates the matching legacy `token`
// row in place, or creates a fresh legacy `token` row that the
// migration-0041 partial unique index now blocks (the upsert
// branch will hit the unique constraint and the request will
// fail with a 500 — but that's a programmer-error path the
// iOS client should never trigger; same-user re-registration
// keeps the same legacy `token`).
// The `NOT: { userId }` clause was removed in v1.4.23 W6 reconcile —
// it skipped the same-user collision and let `findMany({ apnsToken })`
// fan a single push out to two Device rows, double-charging the APNs
// quota.
if (apnsToken) {
const existingApns = await prisma.device.findFirst({
where: { apnsToken },
select: { id: true, userId: true },
});
if (existingApns && existingApns.userId !== user.id) {
await auditLog("device.register.denied", {
userId: user.id,
details: {
reason: "apns_token_owned_by_other_user",
deviceId: existingApns.id,
const registration = await prisma.$transaction(async (tx) => {
// Token ownership is decided from one database snapshot. Advisory
// transaction locks serialize registrations that share either identity,
// including cross-user attempts and the first-create race. Sorting avoids
// deadlocks when concurrent requests present the same pair in reverse.
const lockKeys = [
`device-token:${token}`,
...(apnsToken ? [`device-apns:${apnsToken}`] : []),
].sort();
for (const lockKey of lockKeys) {
// `pg_advisory_xact_lock` returns void, which Prisma cannot deserialize
// directly. Selecting through FROM yields a plain integer row.
await tx.$queryRaw`
SELECT 1 AS locked
FROM pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))
`;
}
const [legacyDevice, apnsDevice] = await Promise.all([
tx.device.findUnique({
where: { token },
select: {
id: true,
userId: true,
token: true,
medicationDelivery: true,
liveActivityPushToken: true,
},
});
return apiError("APNs token already registered to another account", 409);
}),
apnsToken
? tx.device.findFirst({
where: { apnsToken },
select: {
id: true,
userId: true,
token: true,
medicationDelivery: true,
liveActivityPushToken: true,
},
})
: null,
]);
if (apnsDevice && apnsDevice.userId !== user.id) {
return {
conflict: "apns_token_owned_by_other_user" as const,
deviceId: apnsDevice.id,
};
}
if (legacyDevice && legacyDevice.userId !== user.id) {
return {
conflict: "token_owned_by_other_user" as const,
deviceId: legacyDevice.id,
};
}
// RefreshToken.deviceId stores the client-supplied Device.token value,
// not the database row id. Keep this user's token family aligned when
// APNs identifies an existing device under a changed legacy token.
if (apnsDevice && apnsDevice.token !== token) {
await tx.refreshToken.updateMany({
where: { userId: user.id, deviceId: apnsDevice.token },
data: { deviceId: token },
});
}
// An APNs match is the canonical physical-device identity. If the newly
// presented legacy token already has a second same-user row, carry its
// explicit per-device state (including null clears), remove it, and update
// the APNs row with the newly presented metadata.
let canonicalDevice = apnsDevice ?? legacyDevice;
let duplicateDevice: typeof legacyDevice = null;
if (
apnsDevice &&
legacyDevice &&
apnsDevice.id !== legacyDevice.id
) {
duplicateDevice = legacyDevice;
await tx.device.deleteMany({
where: { id: legacyDevice.id, userId: user.id },
});
canonicalDevice = apnsDevice;
}
const deviceData = {
platform: "ios",
token,
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
...(medicationDelivery !== undefined
? { medicationDelivery }
: duplicateDevice
? { medicationDelivery: duplicateDevice.medicationDelivery }
: {}),
...(liveActivityPushToken !== undefined
? { liveActivityPushToken }
: duplicateDevice
? { liveActivityPushToken: duplicateDevice.liveActivityPushToken }
: {}),
};
const device = canonicalDevice
? await tx.device.update({
where: { id: canonicalDevice.id, userId: user.id },
data: { ...deviceData, lastSeen: new Date() },
select: { id: true },
})
: await tx.device.create({
data: { userId: user.id, ...deviceData },
select: { id: true },
});
return { id: device.id };
});
if ("conflict" in registration) {
await auditLog("device.register.denied", {
userId: user.id,
details: {
reason: registration.conflict,
deviceId: registration.deviceId,
},
});
return registration.conflict === "apns_token_owned_by_other_user"
? apiError("APNs token already registered to another account", 409)
: apiError("Device token already registered to another account", 409);
}
const existing = await prisma.device.findUnique({ where: { token } });
let id: string;
if (existing) {
// Cross-user-hijack guard: a device-token belongs to exactly one user.
// Re-registering the same token under a different account is rejected
// — APNs tokens aren't a secret, so trusting the wire input would let
// anyone who learns/guesses a token redirect another user's pushes.
if (existing.userId !== user.id) {
await auditLog("device.register.denied", {
userId: user.id,
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
});
return apiError(
"Device token already registered to another account",
409,
);
}
const updated = await prisma.device.update({
where: { token },
data: {
platform: "ios",
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — only touch the override when the client sends the
// field, so a re-register that omits it keeps the prior value.
...(medicationDelivery !== undefined && { medicationDelivery }),
// v1.17.1 (#22) — same omit-keeps-prior rule; `null` explicitly
// clears the token when the Activity ends.
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
lastSeen: new Date(),
},
select: { id: true },
});
id = updated.id;
} else {
const created = await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token,
bundleId,
locale: locale ?? null,
appVersion: appVersion ?? null,
model: model ?? null,
apnsToken: apnsToken ?? null,
apnsEnvironment: apnsEnvironment ?? null,
// v1.7.0 — per-device delivery override (null = inherit default).
...(medicationDelivery !== undefined && { medicationDelivery }),
// v1.17.1 (#22) — Live Activity push token (null = no Activity).
...(liveActivityPushToken !== undefined && { liveActivityPushToken }),
},
select: { id: true },
});
id = created.id;
}
const { id } = registration;
// Auto-create the APNS NotificationChannel row when the device
// registers with an apnsToken. Without this row the dispatcher's APNS
// branch (`dispatcher.ts:41-49`) never fires for the user — every
// production iOS install would otherwise need a manual settings
// toggle that doesn't exist. Mirrors the legacy Telegram on-first-
// dispatch auto-migration, but eager so the very first reminder
// after device registration already routes through APNs. Config is
// an encrypted empty record by design — the per-device token +
// environment live on the Device row.
// Reconcile APNs channel state only after the canonical device transaction
// commits. Config is an encrypted empty record by design — the per-device
// token and environment live on the Device row.
if (apnsToken) {
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
+234 -39
View File
@@ -10,12 +10,12 @@
* 2. Registering without an `apnsToken` does NOT create the channel —
* the iOS client may upsert device metadata before APNs grants a
* token, and we must not enable APNs delivery prematurely.
* 3. Registering a SECOND device with the same `apnsToken` for the
* same user is idempotent — the channel row stays at one. The
* same-user collision path returns 200 (upsert) rather than 409
* (the cross-user hijack guard).
* 3. Re-registering the same `apnsToken` is idempotent, including when the
* legacy token changes: the route returns 201 and keeps one canonical
* device and one channel. Foreign ownership still returns 409.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { NextRequest } from "next/server";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
@@ -75,23 +75,26 @@ function buildRequest(body: Record<string, unknown>) {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
}) as unknown as import("next/server").NextRequest;
}) as unknown as NextRequest;
}
async function registerDevice(body: Record<string, unknown>) {
// Load after the test env and next/headers mock are installed.
const { POST } = await import("@/app/api/devices/route");
return (POST as (request: NextRequest) => Promise<Response>)(
buildRequest(body),
);
}
describe("POST /api/devices", () => {
it("auto-upserts a NotificationChannel { type: APNS } when apnsToken is supplied", async () => {
const user = await seedSession("device-apns-channel");
const { POST } = await import("@/app/api/devices/route");
const req = buildRequest({
const res = await registerDevice({
token: "legacy-token-1",
bundleId: "io.healthlog.ios",
apnsToken: "abcdef0123456789",
apnsEnvironment: "sandbox",
});
const res = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(req);
expect(res.status).toBe(201);
const channel = await getPrismaClient().notificationChannel.findFirst({
@@ -103,15 +106,10 @@ describe("POST /api/devices", () => {
it("does NOT create an APNS channel when apnsToken is omitted", async () => {
const user = await seedSession("device-no-apns");
const { POST } = await import("@/app/api/devices/route");
const req = buildRequest({
const res = await registerDevice({
token: "legacy-token-2",
bundleId: "io.healthlog.ios",
});
const res = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(req);
expect(res.status).toBe(201);
const channel = await getPrismaClient().notificationChannel.findFirst({
@@ -122,30 +120,20 @@ describe("POST /api/devices", () => {
it("re-registering the same apnsToken for the same user keeps the channel row at one", async () => {
const user = await seedSession("device-apns-idempotent");
const { POST } = await import("@/app/api/devices/route");
const first = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(
buildRequest({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
}),
);
const first = await registerDevice({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
});
expect(first.status).toBe(201);
const second = await (
POST as (r: import("next/server").NextRequest) => Promise<Response>
)(
buildRequest({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
}),
);
const second = await registerDevice({
token: "legacy-token-3",
bundleId: "io.healthlog.ios",
apnsToken: "deadbeef",
apnsEnvironment: "sandbox",
});
expect(second.status).toBe(201);
const channels = await getPrismaClient().notificationChannel.findMany({
@@ -153,4 +141,211 @@ describe("POST /api/devices", () => {
});
expect(channels).toHaveLength(1);
});
it("reconciles a changed legacy token onto the same user's APNs device", async () => {
const user = await seedSession("device-changed-token");
const prisma = getPrismaClient();
const first = await registerDevice({
token: "legacy-token-old",
bundleId: "io.healthlog.ios",
apnsToken: "aaaabbbbccccdddd",
apnsEnvironment: "sandbox",
appVersion: "1.0.0",
});
expect(first.status).toBe(201);
const firstBody = (await first.json()) as { data: { id: string } };
const refreshToken = await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: "changed-token-refresh-hash",
deviceId: "legacy-token-old",
expiresAt: new Date(Date.now() + 60_000),
},
});
const second = await registerDevice({
token: "legacy-token-new",
bundleId: "io.healthlog.ios.beta",
apnsToken: "aaaabbbbccccdddd",
apnsEnvironment: "production",
appVersion: "2.0.0",
});
expect(second.status).toBe(201);
const secondBody = (await second.json()) as { data: { id: string } };
expect(secondBody.data.id).toBe(firstBody.data.id);
const devices = await prisma.device.findMany({ where: { userId: user.id } });
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({
id: firstBody.data.id,
token: "legacy-token-new",
bundleId: "io.healthlog.ios.beta",
apnsToken: "aaaabbbbccccdddd",
apnsEnvironment: "production",
appVersion: "2.0.0",
});
await expect(
prisma.refreshToken.findUniqueOrThrow({
where: { id: refreshToken.id },
select: { deviceId: true },
}),
).resolves.toEqual({ deviceId: "legacy-token-new" });
});
it("merges same-user device identities and migrates only owned refresh-token bindings", async () => {
const user = await seedSession("device-identity-merge");
const prisma = getPrismaClient();
const otherUser = await prisma.user.create({
data: {
username: "device-identity-merge-other",
email: "device-identity-merge-other@example.test",
},
});
const apnsDevice = await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token: "legacy-token-original",
bundleId: "io.healthlog.ios",
apnsToken: "1111222233334444",
apnsEnvironment: "sandbox",
medicationDelivery: "server",
liveActivityPushToken: "feedface".repeat(8),
},
});
const legacyDevice = await prisma.device.create({
data: {
userId: user.id,
platform: "ios",
token: "legacy-token-current",
bundleId: "io.healthlog.ios",
medicationDelivery: null,
liveActivityPushToken: null,
},
});
const ownedRefreshToken = await prisma.refreshToken.create({
data: {
userId: user.id,
tokenHash: "owned-refresh-token-hash",
deviceId: "legacy-token-original",
expiresAt: new Date(Date.now() + 60_000),
},
});
const foreignRefreshToken = await prisma.refreshToken.create({
data: {
userId: otherUser.id,
tokenHash: "foreign-refresh-token-hash",
deviceId: "legacy-token-original",
expiresAt: new Date(Date.now() + 60_000),
},
});
const response = await registerDevice({
token: "legacy-token-current",
bundleId: "io.healthlog.ios.updated",
apnsToken: "1111222233334444",
apnsEnvironment: "production",
model: "iPhone17,1",
});
expect(response.status).toBe(201);
const body = (await response.json()) as { data: { id: string } };
expect(body.data.id).toBe(apnsDevice.id);
const devices = await prisma.device.findMany({ where: { userId: user.id } });
expect(devices).toHaveLength(1);
expect(devices[0]).toMatchObject({
id: apnsDevice.id,
token: "legacy-token-current",
bundleId: "io.healthlog.ios.updated",
apnsToken: "1111222233334444",
apnsEnvironment: "production",
model: "iPhone17,1",
medicationDelivery: null,
liveActivityPushToken: null,
});
await expect(
prisma.refreshToken.findUniqueOrThrow({
where: { id: ownedRefreshToken.id },
select: { deviceId: true },
}),
).resolves.toEqual({ deviceId: "legacy-token-current" });
await expect(
prisma.refreshToken.findUniqueOrThrow({
where: { id: foreignRefreshToken.id },
select: { deviceId: true },
}),
).resolves.toEqual({ deviceId: "legacy-token-original" });
});
it("rejects an APNs token owned by another user without mutating either account", async () => {
const user = await seedSession("device-apns-conflict");
const prisma = getPrismaClient();
const owner = await prisma.user.create({
data: {
username: "device-apns-owner",
email: "device-apns-owner@example.test",
},
});
const ownedDevice = await prisma.device.create({
data: {
userId: owner.id,
platform: "ios",
token: "foreign-legacy-token",
bundleId: "io.healthlog.ios",
apnsToken: "9999aaaabbbbcccc",
apnsEnvironment: "sandbox",
},
});
const response = await registerDevice({
token: "requesting-user-token",
bundleId: "io.healthlog.ios.changed",
apnsToken: "9999aaaabbbbcccc",
apnsEnvironment: "production",
});
expect(response.status).toBe(409);
await expect(
prisma.device.findUniqueOrThrow({ where: { id: ownedDevice.id } }),
).resolves.toMatchObject({
userId: owner.id,
token: "foreign-legacy-token",
bundleId: "io.healthlog.ios",
apnsEnvironment: "sandbox",
});
await expect(
prisma.device.count({ where: { userId: user.id } }),
).resolves.toBe(0);
});
it("serializes concurrent registration into one canonical device and channel", async () => {
const user = await seedSession("device-concurrent-register");
const payload = {
token: "concurrent-legacy-token",
bundleId: "io.healthlog.ios",
apnsToken: "abcdabcdabcdabcd",
apnsEnvironment: "sandbox",
};
const responses = await Promise.all([
registerDevice(payload),
registerDevice(payload),
]);
expect(responses.map((response) => response.status)).toEqual([201, 201]);
const bodies = (await Promise.all(
responses.map((response) => response.json()),
)) as Array<{ data: { id: string } }>;
expect(bodies[0].data.id).toBe(bodies[1].data.id);
const prisma = getPrismaClient();
await expect(
prisma.device.count({ where: { userId: user.id } }),
).resolves.toBe(1);
await expect(
prisma.notificationChannel.count({
where: { userId: user.id, type: "APNS" },
}),
).resolves.toBe(1);
});
});