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
58 lines
1.8 KiB
TypeScript
58 lines
1.8 KiB
TypeScript
/**
|
|
* Device-Lock Auto-Release Cron
|
|
*
|
|
* Läuft alle 24h. Findet alle UserDevice-Rows die:
|
|
* - boundToPlan gesetzt haben (Lock aktiv)
|
|
* - lastSeenAt < NOW() - 30 Tage (Gerät inaktiv)
|
|
*
|
|
* → setzt boundToPlan + releaseRequestedAt zurück auf NULL.
|
|
*
|
|
* Begründung 30-Tage-Limit: schützt vor verlorenen/verkauften/defekten
|
|
* Geräten ohne dass User in Customer-Support muss. Nach 30 Tagen ohne
|
|
* authentifizierten API-Call ist das Gerät faktisch nicht mehr in Benutzung.
|
|
*
|
|
* Zusätzlich wird hier der "abgelaufene Release-Request" Lazy-Check
|
|
* nicht ersetzt — der läuft in findActiveDeviceLock() inline. Dieser
|
|
* Cron ist nur für den 30-Tage-Inaktivitäts-Case.
|
|
*/
|
|
import { consola } from "consola";
|
|
import { autoReleaseInactiveDevices } from "../db/devices";
|
|
|
|
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
|
|
|
|
export default defineNitroPlugin((nitro) => {
|
|
if (import.meta.dev) {
|
|
consola.info("[device-lock-cron] Skipping cron in dev mode");
|
|
return;
|
|
}
|
|
|
|
consola.info("[device-lock-cron] Starting (24h interval)");
|
|
|
|
// Erster Lauf nach 2 Minuten (Server-Boot-Phase abwarten)
|
|
const initialTimer = setTimeout(() => {
|
|
runAutoRelease().catch(() => {});
|
|
}, 2 * 60 * 1000);
|
|
|
|
const interval = setInterval(() => {
|
|
runAutoRelease().catch(() => {});
|
|
}, TWENTY_FOUR_HOURS);
|
|
|
|
nitro.hooks.hook("close", () => {
|
|
clearTimeout(initialTimer);
|
|
clearInterval(interval);
|
|
});
|
|
});
|
|
|
|
async function runAutoRelease() {
|
|
try {
|
|
const released = await autoReleaseInactiveDevices();
|
|
if (released > 0) {
|
|
consola.success(`[device-lock-cron] Auto-released ${released} inactive device locks (30d)`);
|
|
} else {
|
|
consola.info("[device-lock-cron] No inactive device locks to release");
|
|
}
|
|
} catch (err: any) {
|
|
consola.error("[device-lock-cron] run failed:", err?.message ?? err);
|
|
}
|
|
}
|