mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
feat(api): device management endpoints for native + web settings
Adds GET /api/auth/me/devices to list a user's paired devices (label, lastSeen, channels, isCurrent marker) plus matching DELETE routes at /api/auth/me/devices/[id] and /api/devices/[id]. Revoke cascades: every refresh token bound to the device is killed alongside its paired access tokens, then the device row is deleted. Cross-user attempts return 404 (no enumeration leak). The duplicated /api/devices/[id] endpoint is the native-friendly URL the iOS client calls on APNs token rotation, mirroring the W3 open question. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
05fe89575f
commit
1bb5a45521
6 files changed
+624
No files matched your search
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
device: {
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
refreshToken: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/me/devices/dev-x", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/devices/[id]", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the device doesn't exist", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue(null);
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the device belongs to another user (no enumeration)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-x",
|
||||
userId: "user-2",
|
||||
model: "iPhone",
|
||||
bundleId: "io.healthlog.app",
|
||||
} as never);
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) });
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.device.delete)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("revokes refresh tokens + access tokens + deletes the device row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-1",
|
||||
userId: "user-1",
|
||||
model: "iPhone",
|
||||
bundleId: "io.healthlog.app",
|
||||
} as never);
|
||||
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([
|
||||
{ accessTokenHash: "hash:a1" },
|
||||
{ accessTokenHash: "hash:a2" },
|
||||
{ accessTokenHash: null },
|
||||
] as never);
|
||||
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 3 } as never);
|
||||
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 2 } as never);
|
||||
vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never);
|
||||
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.revoked).toBe(true);
|
||||
expect(body.data.refreshTokensRevoked).toBe(3);
|
||||
expect(body.data.accessTokensRevoked).toBe(2);
|
||||
expect(vi.mocked(prisma.device.delete)).toHaveBeenCalledWith({
|
||||
where: { id: "dev-1" },
|
||||
});
|
||||
// The refresh-token revoke filter MUST scope to the deviceId, not
|
||||
// every refresh row the user has.
|
||||
const updateCall = vi.mocked(prisma.refreshToken.updateMany).mock.calls[0]?.[0];
|
||||
expect(updateCall?.where).toMatchObject({
|
||||
userId: "user-1",
|
||||
deviceId: "dev-1",
|
||||
revokedAt: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* DELETE /api/auth/me/devices/[id]
|
||||
*
|
||||
* v1.4.23 W4 F7b — revoke a single device.
|
||||
*
|
||||
* Behaviour:
|
||||
* 1. Confirms the device row belongs to the current user (ownership
|
||||
* boundary — 404 on cross-user attempts so callers can't probe
|
||||
* another tenant's device ids).
|
||||
* 2. Revokes every active refresh token bound to this `deviceId` and
|
||||
* the access tokens those refresh rows pair with.
|
||||
* 3. Deletes the `Device` row outright. Future re-pairing will create
|
||||
* a fresh row.
|
||||
*
|
||||
* Note: this route does NOT touch the user's browser session cookie —
|
||||
* the session lives on `Session.id`, not `Device.id`. Browser logouts
|
||||
* keep using /api/auth/logout. The two surfaces address different
|
||||
* blast radii.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, model: true, bundleId: true },
|
||||
});
|
||||
|
||||
if (!device || device.userId !== user.id) {
|
||||
// 404 on cross-user attempts — leaking "this id exists but isn't
|
||||
// yours" would let an attacker enumerate device ids.
|
||||
return apiError("Device not found", 404);
|
||||
}
|
||||
|
||||
// Revoke every refresh token bound to this device + its paired
|
||||
// access tokens.
|
||||
const liveRefreshTokens = await prisma.refreshToken.findMany({
|
||||
where: { userId: user.id, deviceId: device.id, revokedAt: null },
|
||||
select: { accessTokenHash: true },
|
||||
});
|
||||
const revokedAt = new Date();
|
||||
await prisma.refreshToken.updateMany({
|
||||
where: { userId: user.id, deviceId: device.id, revokedAt: null },
|
||||
data: { revokedAt },
|
||||
});
|
||||
const accessHashes = liveRefreshTokens
|
||||
.map((r) => r.accessTokenHash)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
if (accessHashes.length > 0) {
|
||||
await prisma.apiToken.updateMany({
|
||||
where: { tokenHash: { in: accessHashes }, revoked: false },
|
||||
data: { revoked: true },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.device.delete({ where: { id: device.id } });
|
||||
|
||||
await auditLog("devices.revoke", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
deviceId: device.id,
|
||||
label: device.model ?? device.bundleId ?? null,
|
||||
refreshTokensRevoked: liveRefreshTokens.length,
|
||||
accessTokensRevoked: accessHashes.length,
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "auth.me.devices.revoke", entity_type: "device", entity_id: device.id },
|
||||
meta: {
|
||||
refresh_tokens_revoked: liveRefreshTokens.length,
|
||||
access_tokens_revoked: accessHashes.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
id: device.id,
|
||||
revoked: true,
|
||||
refreshTokensRevoked: liveRefreshTokens.length,
|
||||
accessTokensRevoked: accessHashes.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
device: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(deviceIdHeader?: string): NextRequest {
|
||||
const headers: Record<string, string> = {};
|
||||
if (deviceIdHeader) headers["x-device-id"] = deviceIdHeader;
|
||||
return new NextRequest("http://localhost/api/auth/me/devices", {
|
||||
method: "GET",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/devices", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("lists devices owned by the current user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "dev-1",
|
||||
userId: "user-1",
|
||||
platform: "ios",
|
||||
token: "tok-1",
|
||||
bundleId: "io.healthlog.app",
|
||||
locale: "en-US",
|
||||
appVersion: "1.0",
|
||||
model: "iPhone 14 Pro",
|
||||
apnsToken: "aaaa",
|
||||
apnsEnvironment: "production",
|
||||
lastSeen: new Date("2026-05-10T12:00:00Z"),
|
||||
createdAt: new Date("2026-05-01T12:00:00Z"),
|
||||
updatedAt: new Date("2026-05-10T12:00:00Z"),
|
||||
},
|
||||
{
|
||||
id: "dev-2",
|
||||
userId: "user-1",
|
||||
platform: "ios",
|
||||
token: "tok-2",
|
||||
bundleId: "io.healthlog.app",
|
||||
locale: null,
|
||||
appVersion: null,
|
||||
model: "iPad Pro",
|
||||
apnsToken: null,
|
||||
apnsEnvironment: null,
|
||||
lastSeen: new Date("2026-05-09T12:00:00Z"),
|
||||
createdAt: new Date("2026-04-01T12:00:00Z"),
|
||||
updatedAt: new Date("2026-05-09T12:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.devices).toHaveLength(2);
|
||||
expect(body.data.devices[0].id).toBe("dev-1");
|
||||
expect(body.data.devices[0].channels).toEqual(["web_push", "apns"]);
|
||||
expect(body.data.devices[1].channels).toEqual(["web_push"]);
|
||||
});
|
||||
|
||||
it("marks the matching device as isCurrent when X-Device-Id is present", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "dev-1",
|
||||
userId: "user-1",
|
||||
platform: "ios",
|
||||
token: "tok-1",
|
||||
bundleId: "io.healthlog.app",
|
||||
locale: null,
|
||||
appVersion: null,
|
||||
model: "iPhone",
|
||||
apnsToken: null,
|
||||
apnsEnvironment: null,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "dev-2",
|
||||
userId: "user-1",
|
||||
platform: "ios",
|
||||
token: "tok-2",
|
||||
bundleId: "io.healthlog.app",
|
||||
locale: null,
|
||||
appVersion: null,
|
||||
model: "iPad",
|
||||
apnsToken: null,
|
||||
apnsEnvironment: null,
|
||||
lastSeen: new Date(),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await GET(req("dev-2"));
|
||||
const body = await res.json();
|
||||
const current = body.data.devices.find((d: { isCurrent: boolean; id: string }) => d.isCurrent);
|
||||
expect(current?.id).toBe("dev-2");
|
||||
});
|
||||
|
||||
it("only queries devices for the authenticated user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
await GET(req());
|
||||
const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where;
|
||||
expect(where).toEqual({ userId: "user-1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* GET /api/auth/me/devices
|
||||
*
|
||||
* v1.4.23 W4 F7b — list active devices for the current user. Drives the
|
||||
* settings → security → devices surface in the v1.5 iOS app and the
|
||||
* matching web view planned for a follow-up. The list ships
|
||||
* device-identifying metadata only (label, lastSeen, channels) — no
|
||||
* tokens, no APNs identifiers, no IPs.
|
||||
*
|
||||
* The "current device" marker is best-effort: the route inspects the
|
||||
* X-Device-Id header (which the native client always sends) and marks
|
||||
* the matching row with `isCurrent: true`. Browser callers don't have a
|
||||
* device row and therefore see `isCurrent: false` across the board.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
interface DeviceDTO {
|
||||
id: string;
|
||||
label: string;
|
||||
platform: string;
|
||||
appVersion: string | null;
|
||||
locale: string | null;
|
||||
channels: Array<"web_push" | "apns">;
|
||||
lastSeenAt: string;
|
||||
createdAt: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const currentDeviceId = request.headers.get("x-device-id");
|
||||
|
||||
const devices = await prisma.device.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { lastSeen: "desc" },
|
||||
});
|
||||
|
||||
const result: DeviceDTO[] = devices.map((d) => {
|
||||
const channels: DeviceDTO["channels"] = [];
|
||||
// The legacy `token` column always carries the generic device
|
||||
// identifier — for v1.4.23 it doubles as the web-push channel
|
||||
// anchor when a VAPID subscription exists.
|
||||
if (d.token) channels.push("web_push");
|
||||
if (d.apnsToken) channels.push("apns");
|
||||
return {
|
||||
id: d.id,
|
||||
// Prefer the `model` field as the label; fall back to bundleId or
|
||||
// a generic platform string so the row never paints blank.
|
||||
label: d.model ?? d.bundleId ?? d.platform,
|
||||
platform: d.platform,
|
||||
appVersion: d.appVersion,
|
||||
locale: d.locale,
|
||||
channels,
|
||||
lastSeenAt: d.lastSeen.toISOString(),
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
isCurrent: currentDeviceId !== null && d.id === currentDeviceId,
|
||||
};
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.devices.list" },
|
||||
meta: { device_count: result.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ devices: result });
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
device: {
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
refreshToken: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/devices/dev-x", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
});
|
||||
|
||||
describe("DELETE /api/devices/[id] (iOS token-rotation endpoint)", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 on cross-user attempt", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-x",
|
||||
userId: "user-2",
|
||||
model: "iPhone",
|
||||
bundleId: "io.healthlog.app",
|
||||
} as never);
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-x" }) });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("revokes the device + paired tokens", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.device.findUnique).mockResolvedValue({
|
||||
id: "dev-1",
|
||||
userId: "user-1",
|
||||
model: "iPhone",
|
||||
bundleId: "io.healthlog.app",
|
||||
} as never);
|
||||
vi.mocked(prisma.refreshToken.findMany).mockResolvedValue([
|
||||
{ accessTokenHash: "hash:a1" },
|
||||
] as never);
|
||||
vi.mocked(prisma.device.delete).mockResolvedValue({ id: "dev-1" } as never);
|
||||
|
||||
const res = await DELETE(req(), { params: Promise.resolve({ id: "dev-1" }) });
|
||||
expect(res.status).toBe(200);
|
||||
expect(vi.mocked(prisma.device.delete)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* DELETE /api/devices/[id]
|
||||
*
|
||||
* v1.4.23 W4 F7b alternate iOS endpoint — mirror of
|
||||
* `DELETE /api/auth/me/devices/[id]` for the v1.5 iOS app's
|
||||
* token-rotation cleanup path. Same ownership boundary, same audit
|
||||
* trail, same cascade.
|
||||
*
|
||||
* The duplication is deliberate: the W3 open question called out
|
||||
* needing a native-friendly URL for the iOS client's APNs rotation
|
||||
* loop, and the `/api/auth/me/...` path is built for the settings-
|
||||
* surface listing. Two URLs, one implementation: the underlying
|
||||
* Prisma calls are identical to keep the audit trail consistent.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await context.params;
|
||||
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, userId: true, model: true, bundleId: true },
|
||||
});
|
||||
|
||||
if (!device || device.userId !== user.id) {
|
||||
return apiError("Device not found", 404);
|
||||
}
|
||||
|
||||
const liveRefreshTokens = await prisma.refreshToken.findMany({
|
||||
where: { userId: user.id, deviceId: device.id, revokedAt: null },
|
||||
select: { accessTokenHash: true },
|
||||
});
|
||||
const revokedAt = new Date();
|
||||
await prisma.refreshToken.updateMany({
|
||||
where: { userId: user.id, deviceId: device.id, revokedAt: null },
|
||||
data: { revokedAt },
|
||||
});
|
||||
const accessHashes = liveRefreshTokens
|
||||
.map((r) => r.accessTokenHash)
|
||||
.filter((v): v is string => Boolean(v));
|
||||
if (accessHashes.length > 0) {
|
||||
await prisma.apiToken.updateMany({
|
||||
where: { tokenHash: { in: accessHashes }, revoked: false },
|
||||
data: { revoked: true },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.device.delete({ where: { id: device.id } });
|
||||
|
||||
await auditLog("devices.revoke", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
deviceId: device.id,
|
||||
label: device.model ?? device.bundleId ?? null,
|
||||
refreshTokensRevoked: liveRefreshTokens.length,
|
||||
accessTokensRevoked: accessHashes.length,
|
||||
via: "ios.rotation",
|
||||
},
|
||||
});
|
||||
annotate({
|
||||
action: { name: "devices.revoke", entity_type: "device", entity_id: device.id },
|
||||
meta: {
|
||||
refresh_tokens_revoked: liveRefreshTokens.length,
|
||||
access_tokens_revoked: accessHashes.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
id: device.id,
|
||||
revoked: true,
|
||||
refreshTokensRevoked: liveRefreshTokens.length,
|
||||
accessTokensRevoked: accessHashes.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user