Files
HealthLog/src/app/api/devices/route.ts
T
Marc-André BombeckandClaude Opus 4.7 13977bcc70 fix(devices): auto-upsert APNS NotificationChannel on registration
The dispatcher's APNS branch only fires when a NotificationChannel of
type APNS exists for the user, but no production code path created one.
Every iOS install needed a manual settings toggle that doesn't exist,
making the v1.4.23 W3 APNs cascade unreachable in real traffic.

When `POST /api/devices` accepts an apnsToken, eagerly upsert the
channel row with an encrypted empty config — the per-device token and
environment live on the Device row by design, so the channel only
exists to flip the dispatcher's gate. Mirrors the legacy Telegram
on-first-dispatch auto-migration but eager so the very first reminder
after registration routes through APNs.

Adds an integration test that registers a device with apnsToken and
asserts the channel row exists, registers without one and asserts
absence, and re-registers the same apnsToken and asserts idempotency
(channel count stays at one).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 02:12:02 +02:00

200 lines
6.4 KiB
TypeScript

/**
* POST /api/devices
*
* 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).
*
* Cross-user-hijack guard:
* * A device `token` belongs to exactly one user. Re-registering the
* same value under a different account returns 409. APNs tokens
* aren't secrets, so trusting wire input would let anyone who
* learns one redirect another user's pushes.
* * The same guard applies to `apnsToken` — supplying an `apnsToken`
* that's already registered to a different user returns 409 with
* reason `apns_token_owned_by_other_user`.
*
* APNs registration:
* * `apnsToken` and `apnsEnvironment` are paired — supplying one
* without the other returns 422. The iOS client picks the gateway
* (`sandbox` for Debug builds, `production` for Release / TestFlight)
* because the server has no way to tell from the token alone.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
const deviceSchema = z
.object({
token: z
.string()
.min(8)
.max(512)
.regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"),
bundleId: z.string().min(1).max(128),
locale: z.string().min(2).max(16).optional(),
appVersion: z.string().min(1).max(32).optional(),
model: z.string().min(1).max(64).optional(),
// APNs-specific pair — the iOS client populates these once it has
// an APNs token from the OS callback. Hex string per Apple's spec
// (64 chars on most devices; cap at 256 for forward compat).
apnsToken: z
.string()
.min(8)
.max(256)
.regex(/^[A-Fa-f0-9]+$/, "apnsToken must be hex")
.optional(),
apnsEnvironment: z.enum(["sandbox", "production"]).optional(),
})
.refine(
(v) =>
(v.apnsToken === undefined && v.apnsEnvironment === undefined) ||
(v.apnsToken !== undefined && v.apnsEnvironment !== undefined),
{
message:
"apnsToken and apnsEnvironment must be supplied together or both omitted",
path: ["apnsEnvironment"],
},
);
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const parsed = deviceSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const {
token,
bundleId,
locale,
appVersion,
model,
apnsToken,
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.
if (apnsToken) {
const existingApns = await prisma.device.findFirst({
where: { apnsToken, NOT: { userId: user.id } },
select: { id: true },
});
if (existingApns) {
await auditLog("device.register.denied", {
userId: user.id,
details: {
reason: "apns_token_owned_by_other_user",
deviceId: existingApns.id,
},
});
return apiError(
"APNs 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,
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,
},
select: { id: true },
});
id = created.id;
}
// 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.
if (apnsToken) {
await prisma.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
create: {
userId: user.id,
type: "APNS",
enabled: true,
config: encrypt("{}"),
},
update: {},
});
}
await auditLog("devices.register", {
userId: user.id,
details: {
deviceId: id,
bundleId,
model: model ?? null,
hasApnsToken: Boolean(apnsToken),
apnsEnvironment: apnsEnvironment ?? null,
},
});
annotate({
action: { name: "devices.register", entity_type: "device", entity_id: id },
});
return apiSuccess({ id }, 201);
});