rebreak-monorepo/backend/server/api/devices/[id]/request-release.post.ts
chahinebrini 1bc38e0732 feat(backend): device-account binding for pro/legend users
Closes the bypass loophole where a Pro/Legend user could log out in a
craving moment, sign in with a fresh Free account on the same iPhone,
and watch the NEFilter blocklist shrink from 208k Casino domains to
the curated 30-domain stub. The user is the patient — the addiction
itself is the attacker.

When a Pro/Legend account signs in via x-device-id, the device is
bound to that user_id (UserDevice.boundToPlan = 'pro'|'legend' …).
A subsequent login attempt from a different account on the same
device returns 409 DEVICE_LOCKED. The original user gets a Resend
email naming the nickname only (no firstName / email leaked per
the anonymity rule) with a link to either confirm the foreign attempt
or release the device.

Release flow:
  - POST /api/devices/:id/request-release schedules releaseAt = now + 24h
  - POST /api/devices/:id/cancel-release reverts it
  - a Nitro plugin cron sweeps both (24h-requested releases AND
    30-day-idle auto-releases) hourly

Free -> Free swaps stay unrestricted so onboarding on a second-hand
iPhone keeps working. Free -> Pro upgrade binds going forward; a
Pro -> Free downgrade keeps the existing lock so the bypass vector
stays closed.

Lock check runs BEFORE Supabase auth in /api/auth/login to avoid
giving a timing oracle for account enumeration. The dummy-UUID filter
in findActiveDeviceLock is the trick: it queries "someone else's
lock" with a userId that can never match.

DSGVO: ON DELETE CASCADE on UserDevice means an Art-17 deletion of
the original user releases all their locks automatically (Hans-Mueller
hand-off noted in the migration SQL).

24 vitest cases cover bind / lock / request-release-24h /
cancel-release / 30-day-idle-release / email rate-limit (1 per 6h) /
DSGVO cascade / multi-device Legend.

Migration to deploy after push:
  infisical run -- npx prisma migrate deploy --schema backend/prisma/schema.prisma

Frontend follow-up (separate task):
  - Sign-In: handle 409 DEVICE_LOCKED with a dedicated error UI
  - Settings/Devices page: "Release device" button + 24h countdown
  - GET /api/devices to include boundToPlan + releaseRequestedAt
2026-05-16 00:29:35 +02:00

36 lines
1014 B
TypeScript

import { requestDeviceRelease } from "../../../db/devices";
/**
* POST /api/devices/:id/request-release
*
* Original-User markiert sein eigenes Device für Freigabe.
* Freigabe wird aktiv nach 24h (Cooldown schützt gegen impulsiven Release im Drang-Window).
*
* Auth: eingeloggter User, ownership-check via userId im DB-Query.
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event, { skipDeviceCheck: true });
const id = getRouterParam(event, "id");
if (!id) {
throw createError({ statusCode: 400, data: { error: "MISSING_ID" } });
}
const updated = await requestDeviceRelease(user.id, id);
if (!updated) {
// Device nicht gefunden, gehört nicht dem User, oder hat kein boundToPlan
throw createError({
statusCode: 404,
data: { error: "DEVICE_NOT_FOUND_OR_NOT_BOUND" },
});
}
const releaseAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
return {
success: true,
releaseAt: releaseAt.toISOString(),
};
});