Files
HealthLog/src/lib/devices/revoke.ts
T
Marc-André BombeckandClaude Opus 4.7 d25e50eaf3 fix(devices): wrap revoke cascade in a single transaction
Both DELETE /api/devices/[id] and DELETE /api/auth/me/devices/[id]
performed the same four-write cascade (refresh-token lookup, refresh
revoke, access-token revoke, device delete) as separate Prisma calls.
A partial failure between the third and fourth write left the device
row alive with all its tokens revoked - the user's iPad showed in the
device list as "still registered" but every request returned 401.

Extract the cascade into a shared revokeDeviceCascade() helper and
wrap the writes in prisma.$transaction so they commit atomically.
Both routes now call the helper; the audit-log via tag and the wide
event action name stay route-local.

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

88 lines
3.1 KiB
TypeScript

/**
* v1.4.23 W6 (HIGH 6 / S-01) — shared device-revoke cascade.
*
* Both `DELETE /api/auth/me/devices/[id]` and `DELETE /api/devices/[id]`
* historically performed the same four-write cascade (refresh-token
* lookup → refresh revoke → access-token revoke → device delete) as
* separate Prisma calls. Without a transaction, a partial failure
* (Postgres connection blip, app crash) left the device row alive
* with all its tokens revoked — the user's iPad showed in the device
* list as "still registered" but every request returned 401, and the
* only fix was admin intervention.
*
* `revokeDeviceCascade` wraps the four writes in `prisma.$transaction`
* so the cascade is atomic. Both routes call this helper and only
* differ in the audit-log `details.via` slot + the wide-event action
* name they emit afterwards (those stay route-local because they
* carry surface-specific context).
*
* Returns `null` when the device doesn't exist or belongs to another
* user (the route turns this into a 404 — leaking "this id exists but
* isn't yours" would let an attacker enumerate device ids).
*/
import { prisma } from "@/lib/db";
export interface RevokeDeviceCascadeResult {
id: string;
label: string | null;
refreshTokensRevoked: number;
accessTokensRevoked: number;
}
export async function revokeDeviceCascade(
userId: string,
deviceId: string,
): Promise<RevokeDeviceCascadeResult | null> {
// Ownership lookup outside the transaction — the cascade only runs
// for an owned, existing device, so resolving the row first keeps
// the transactional window tight.
const device = await prisma.device.findUnique({
where: { id: deviceId },
select: { id: true, userId: true, model: true, bundleId: true },
});
if (!device || device.userId !== userId) {
return null;
}
const liveRefreshTokens = await prisma.refreshToken.findMany({
where: { userId, deviceId: device.id, revokedAt: null },
select: { accessTokenHash: true },
});
const accessHashes = liveRefreshTokens
.map((r) => r.accessTokenHash)
.filter((v): v is string => Boolean(v));
const revokedAt = new Date();
// Build the write set as a `$transaction` array so all writes
// commit together or none of them do. Avoids the "tokens revoked
// but device row alive" half-state the W6 review flagged.
// The array uses `unknown` because Prisma's overloaded
// $transaction type narrows on the literal array shape; the
// operations are still strongly typed at construction.
const writes: unknown[] = [
prisma.refreshToken.updateMany({
where: { userId, deviceId: device.id, revokedAt: null },
data: { revokedAt },
}),
];
if (accessHashes.length > 0) {
writes.push(
prisma.apiToken.updateMany({
where: { tokenHash: { in: accessHashes }, revoked: false },
data: { revoked: true },
}),
);
}
writes.push(prisma.device.delete({ where: { id: device.id } }));
await prisma.$transaction(writes as never);
return {
id: device.id,
label: device.model ?? device.bundleId ?? null,
refreshTokensRevoked: liveRefreshTokens.length,
accessTokensRevoked: accessHashes.length,
};
}