Files
HealthLog/src/app/api/devices/route.ts
T
Marc-André Bombeck 55a6287069 fix(audit): file every refused request through auditLog()
Twelve routes wrote their <surface>.validation-failed row with a bare
prisma.auditLog.create, so they skipped the two decisions auditLog makes:
an owner's own refused request stays on the wide event
(meta.rejected_attempt) instead of becoming a row, and a delegate's names
the delegate as the actor. The bulk medication and mood ingests, the
layout, overlay, device, health-sync, metric and ingest routes all go
through auditLog now, and a structural guard keeps raw writes to the one
writer and the briefing failure marker. The unit tests assert the auditLog
call, and the bulk walks are re-pinned without the owner's refusal row.
2026-10-23 16:54:57 +02:00

312 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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:`. 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
* 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, sanitiseZodIssues } from "@/lib/api-response";
import { parseJsonBody } from "@/lib/api/body";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { checkRateLimit } from "@/lib/rate-limit";
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(),
// v1.7.0 — per-device medication-delivery override. NULL (or
// omitted) inherits the user-level roaming default. "server" forces
// server APNs for this device; "client" forces local. Stored +
// echoed; cron suppression stays user-level.
medicationDelivery: z.enum(["server", "client"]).nullable().optional(),
// v1.17.1 (#22) — ActivityKit issues a per-Activity push token distinct
// from the device APNs token. The iOS client registers the current one
// here so the server can address a Live Activity update / end push on a
// medication-intake mutation; it sends `null` to clear the token when no
// Activity is running. Hex, like the APNs token. Only touched when the
// field is present so an ordinary re-register keeps the prior value.
liveActivityPushToken: z
.string()
.min(8)
.max(256)
.regex(/^[A-Fa-f0-9]+$/, "liveActivityPushToken must be hex")
.nullable()
.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();
// Per-user rate cap on device registration. The handler returns a
// distinct 409 (`apns_token_owned_by_other_user` /
// `token_owned_by_other_user`) when an `apnsToken` is already
// bound to a different account — useful for the legitimate
// cross-user-hijack guard, but it also means an authenticated user
// could enumerate token ownership across accounts unboundedly. 20
// device-mutations per 15 minutes is well outside any realistic
// client behaviour (the iOS app registers once on login + on token
// rotation; not 20× / window) while capping enumeration cost.
const rl = await checkRateLimit(
`devices:register:${user.id}`,
20,
15 * 60 * 1000,
);
if (!rl.allowed) {
annotate({
action: { name: "devices.register.rate-limited" },
meta: { userId: user.id, resetAt: rl.resetAt },
});
return apiError("Too many device registrations, please slow down", 429);
}
// v1.4.43 — iOS device-registration multi-issue 422 + audit
// breadcrumb. APNs-token + bundleId + environment + model + locale
// can all fail simultaneously on a misconfigured client build.
const parsed = await parseJsonBody(request, deviceSchema, {
maxBytes: 64 * 1024,
action: "devices.register.validation-failed",
countIssues: true,
onInvalid: (error) => {
// v1.4.49 — strip `message` from the audit-ledger row. Device
// registration carries `token` + `apnsToken`; the default Zod
// message for `invalid_format` echoes the offending value, which
// would persist an APNs token (or a forged near-miss) into the
// audit ledger.
const auditIssues = sanitiseZodIssues(error.issues, {
stripValuesFromMessage: true,
});
auditLog("devices.register.validation-failed", {
userId: user.id,
details: { issues: auditIssues },
}).catch(() => {
/* swallow — 422 response is the contract */
});
},
});
if (!parsed.ok) return parsed.error;
const {
token,
bundleId,
locale,
appVersion,
model,
apnsToken,
apnsEnvironment,
medicationDelivery,
liveActivityPushToken,
} = parsed.data;
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,
},
}),
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 },
});
// Keep channel reconciliation under the same identity locks as the device
// row. Moving it after commit lets two first-time registrations race the
// compound unique key and intermittently turns one valid request into 500.
if (apnsToken) {
await tx.notificationChannel.upsert({
where: { userId_type: { userId: user.id, type: "APNS" } },
create: {
userId: user.id,
type: "APNS",
enabled: true,
config: encrypt("{}"),
},
update: {},
});
}
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 { id } = registration;
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);
});