mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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>
88 lines
3.1 KiB
TypeScript
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,
|
|
};
|
|
}
|