diff --git a/prisma/migrations/0041_apns_token_unique_partial/migration.sql b/prisma/migrations/0041_apns_token_unique_partial/migration.sql new file mode 100644 index 000000000..5fee87cd6 --- /dev/null +++ b/prisma/migrations/0041_apns_token_unique_partial/migration.sql @@ -0,0 +1,33 @@ +-- v1.4.23 W6 reconcile — global uniqueness for `devices.apns_token`. +-- +-- Apple's APNs contract guarantees one token per (device, app) — two +-- Device rows pointing at the same `apns_token` would mean the same +-- physical iPhone is registered twice and `sendViaApns()` would fan a +-- single notification out twice, double-charging APNs quota and +-- spamming the user. +-- +-- Pair this DDL with the route-side fix in `POST /api/devices` that +-- drops `NOT: { userId }` from the cross-user-hijack guard so the +-- same-user collision path returns the existing 200 idempotent upsert +-- response, and the cross-user collision path keeps its existing 409. +-- +-- Strictly additive at the rolling-upgrade boundary: +-- * The new partial unique index covers ONLY `apns_token IS NOT NULL`, +-- so the historical reality of multiple Device rows with NULL +-- `apns_token` (legacy iOS builds without the APNs handshake) stays +-- valid. +-- * The old non-unique `devices_apns_token_idx` is dropped because +-- the partial unique index supersedes it for the dispatcher's +-- per-user lookup. +-- * The CHECK from migration 0037 (token + environment paired) is +-- unaffected. +-- +-- If a duplicate row exists in production at upgrade time the migration +-- will fail loudly — that's the intended signal that an iOS install +-- collision is already corrupting deliveries. + +CREATE UNIQUE INDEX "devices_apns_token_unique" + ON "devices" ("apns_token") + WHERE "apns_token" IS NOT NULL; + +DROP INDEX IF EXISTS "devices_apns_token_idx"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e7c7115d..ab00a3afd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -833,7 +833,11 @@ model Device { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) - @@index([apnsToken]) + // `devices_apns_token_unique` is a partial UNIQUE index over + // `apns_token IS NOT NULL` — see migration 0041. Prisma can't express + // a partial index in the schema language, so it's introduced via raw + // SQL and intentionally omitted here. Do not re-add `@@index([apnsToken])` + // (the non-unique sibling) — the partial unique index supersedes it. @@map("devices") } diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts index 04af899b5..43156a482 100644 --- a/src/app/api/devices/route.ts +++ b/src/app/api/devices/route.ts @@ -84,16 +84,29 @@ export const POST = apiHandler(async (request: NextRequest) => { apnsEnvironment, } = parsed.data; - // APNs-token cross-user-hijack guard: APNs tokens aren't secrets, so - // accepting one already owned by another user would let any client - // who learns one redirect that user's pushes. Reject before any - // upsert so we never accidentally transfer ownership. + // 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, NOT: { userId: user.id } }, - select: { id: true }, + where: { apnsToken }, + select: { id: true, userId: true }, }); - if (existingApns) { + if (existingApns && existingApns.userId !== user.id) { await auditLog("device.register.denied", { userId: user.id, details: {