577 lines
16 KiB
TypeScript
577 lines
16 KiB
TypeScript
import { usePrisma } from "../utils/prisma";
|
|
|
|
/**
|
|
* Device-Binding pro User. Free=1, Pro=1, Legend=3 (siehe plan-features.maxDevices).
|
|
* deviceId kommt vom Frontend via Capacitor Device.getId() (persistent UUID).
|
|
*/
|
|
|
|
export interface DeviceRecord {
|
|
id: string;
|
|
userId: string;
|
|
deviceId: string;
|
|
platform: string;
|
|
model: string | null;
|
|
name: string | null;
|
|
osVersion: string | null;
|
|
lastSeenAt: Date;
|
|
createdAt: Date;
|
|
// Device-Account-Lock
|
|
boundToPlan: string | null;
|
|
releaseRequestedAt: Date | null;
|
|
lockNotifiedAt: Date | null;
|
|
}
|
|
|
|
/** Pläne die einen Device-Account-Lock aktivieren. Free-User binden nie. */
|
|
const LOCKING_PLANS = new Set(["pro", "legend", "standard", "premium"]);
|
|
|
|
/** Ist ein Plan ein "locking" Plan (Pro/Legend inkl. Legacy-Namen)? */
|
|
export function isLockingPlan(plan: string | null | undefined): boolean {
|
|
if (!plan) return false;
|
|
return LOCKING_PLANS.has(plan.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* Prüft ob ein gegebenes deviceId bereits an einen anderen User gebunden ist
|
|
* (Lock aktiv). Gibt die bound Row zurück wenn ja, null wenn frei.
|
|
*
|
|
* "Gebunden" = boundToPlan ist gesetzt (isLockingPlan) UND kein Release
|
|
* abgelaufen (releaseRequestedAt + 24h <= NOW() = released).
|
|
*/
|
|
export async function findActiveDeviceLock(
|
|
deviceId: string,
|
|
requestingUserId: string,
|
|
): Promise<DeviceRecord | null> {
|
|
const db = usePrisma();
|
|
|
|
const row = await db.userDevice.findFirst({
|
|
where: {
|
|
deviceId,
|
|
// Gebunden an einen anderen User
|
|
NOT: { userId: requestingUserId },
|
|
// Binding existiert (Pro/Legend-Account)
|
|
boundToPlan: { not: null },
|
|
},
|
|
select: {
|
|
...DEVICE_SELECT_WITH_LOCK,
|
|
},
|
|
});
|
|
|
|
if (!row) return null;
|
|
|
|
// Kein Lock wenn boundToPlan kein Locking-Plan (Sicherheitsnetz, eigentlich
|
|
// schon durch oben gefiltert aber explizit prüfen)
|
|
if (!isLockingPlan(row.boundToPlan)) return null;
|
|
|
|
// Wenn Release-Request existiert und 24h abgelaufen → Lock ist released
|
|
if (row.releaseRequestedAt) {
|
|
const releaseAt = new Date(
|
|
row.releaseRequestedAt.getTime() + 24 * 60 * 60 * 1000,
|
|
);
|
|
if (releaseAt <= new Date()) return null;
|
|
}
|
|
|
|
// Lock ist aktiv
|
|
return row;
|
|
}
|
|
|
|
/**
|
|
* Bindet ein Device an den User (setzt boundToPlan).
|
|
* Wird nach erfolgreichem Login aufgerufen wenn user.plan ein Locking-Plan ist.
|
|
* Idempotent: wenn bereits gebunden → kein Update.
|
|
*/
|
|
export async function bindDeviceToUser(
|
|
userId: string,
|
|
deviceId: string,
|
|
plan: string,
|
|
): Promise<void> {
|
|
if (!isLockingPlan(plan)) return; // Free-User binden nicht
|
|
|
|
const db = usePrisma();
|
|
await db.userDevice.updateMany({
|
|
where: {
|
|
userId,
|
|
deviceId,
|
|
boundToPlan: null, // Nur setzen wenn noch nicht gebunden
|
|
},
|
|
data: {
|
|
boundToPlan: plan,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Request-Release: Original-User setzt release_requested_at = NOW().
|
|
* Nach 24h ist der Lock automatisch gelöst (Lazy-Check in findActiveDeviceLock).
|
|
* Gibt false zurück wenn Device nicht gefunden oder nicht dem User gehört.
|
|
*/
|
|
export async function requestDeviceRelease(
|
|
userId: string,
|
|
deviceId: string, // row-id (UUID), nicht deviceId-String
|
|
): Promise<boolean> {
|
|
const db = usePrisma();
|
|
const result = await db.userDevice.updateMany({
|
|
where: {
|
|
id: deviceId,
|
|
userId, // Ownership-Check
|
|
boundToPlan: { not: null }, // Muss gebunden sein um freizugeben
|
|
},
|
|
data: {
|
|
releaseRequestedAt: new Date(),
|
|
},
|
|
});
|
|
return result.count > 0;
|
|
}
|
|
|
|
/**
|
|
* Cancel-Release: User zieht den Release-Request zurück.
|
|
* Setzt release_requested_at zurück auf NULL.
|
|
*/
|
|
export async function cancelDeviceRelease(
|
|
userId: string,
|
|
deviceId: string, // row-id
|
|
): Promise<boolean> {
|
|
const db = usePrisma();
|
|
const result = await db.userDevice.updateMany({
|
|
where: {
|
|
id: deviceId,
|
|
userId,
|
|
releaseRequestedAt: { not: null }, // Muss offener Request sein
|
|
},
|
|
data: {
|
|
releaseRequestedAt: null,
|
|
},
|
|
});
|
|
return result.count > 0;
|
|
}
|
|
|
|
/**
|
|
* Setzt lockNotifiedAt für Rate-Limiting der E-Mail-Notifications.
|
|
*/
|
|
export async function markDeviceLockNotified(rowId: string): Promise<void> {
|
|
const db = usePrisma();
|
|
await db.userDevice
|
|
.update({
|
|
where: { id: rowId },
|
|
data: { lockNotifiedAt: new Date() },
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
/**
|
|
* Auto-Release: Alle Devices die 30 Tage nicht gesehen wurden UND noch
|
|
* boundToPlan gesetzt haben → boundToPlan zurücksetzen.
|
|
* Wird vom Cron-Plugin (device-lock-cron.ts) aufgerufen.
|
|
*
|
|
* Schützt vor verlorenen/verkauften/defekten Geräten ohne Customer-Support.
|
|
*/
|
|
export async function autoReleaseInactiveDevices(): Promise<number> {
|
|
const db = usePrisma();
|
|
const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
|
|
const result = await db.userDevice.updateMany({
|
|
where: {
|
|
boundToPlan: { not: null },
|
|
lastSeenAt: { lt: cutoff },
|
|
// Nur wenn kein Release-Request bereits pending (würde in 24h ohnehin ablaufen)
|
|
},
|
|
data: {
|
|
boundToPlan: null,
|
|
releaseRequestedAt: null,
|
|
},
|
|
});
|
|
|
|
return result.count;
|
|
}
|
|
|
|
const DEVICE_SELECT = {
|
|
id: true,
|
|
userId: true,
|
|
deviceId: true,
|
|
platform: true,
|
|
model: true,
|
|
name: true,
|
|
osVersion: true,
|
|
lastSeenAt: true,
|
|
createdAt: true,
|
|
boundToPlan: true,
|
|
releaseRequestedAt: true,
|
|
lockNotifiedAt: true,
|
|
} as const;
|
|
|
|
// Alias — identisch mit DEVICE_SELECT, explizit für Lock-Queries
|
|
const DEVICE_SELECT_WITH_LOCK = DEVICE_SELECT;
|
|
|
|
/** Liste aller Devices eines Users, aktuellstes zuerst.
|
|
* Deterministic sort: lastSeenAt DESC, createdAt DESC, id ASC — stellt sicher
|
|
* dass iPad + iPhone die GLEICHE Reihenfolge sehen wenn lastSeenAt identisch ist. */
|
|
export async function listUserDevices(userId: string): Promise<DeviceRecord[]> {
|
|
const db = usePrisma();
|
|
return db.userDevice.findMany({
|
|
where: { userId },
|
|
orderBy: [{ lastSeenAt: "desc" }, { createdAt: "desc" }, { id: "asc" }],
|
|
select: DEVICE_SELECT,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Löscht Phantom-Devices: >14 Tage nicht gesehen + nicht an Plan gebunden.
|
|
* Wird von GET /api/devices + register-Limit-Check aufgerufen damit alte
|
|
* IDFV-Reset-Artifacts nicht das Device-Limit blockieren oder in der UI
|
|
* herumgeistern.
|
|
*
|
|
* Returnt Anzahl gelöschter Rows (für Logging).
|
|
*/
|
|
export async function cleanupStaleDevices(userId: string): Promise<number> {
|
|
const db = usePrisma();
|
|
const cutoff = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
|
|
const res = await db.userDevice.deleteMany({
|
|
where: {
|
|
userId,
|
|
lastSeenAt: { lt: cutoff },
|
|
boundToPlan: null,
|
|
},
|
|
});
|
|
return res.count;
|
|
}
|
|
|
|
/** Gibt das Device zurück wenn registriert; sonst null. */
|
|
export async function findUserDevice(
|
|
userId: string,
|
|
deviceId: string,
|
|
): Promise<DeviceRecord | null> {
|
|
const db = usePrisma();
|
|
return db.userDevice.findUnique({
|
|
where: { userId_deviceId: { userId, deviceId } },
|
|
select: DEVICE_SELECT,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sucht nach einem "Merge-Kandidaten": existierendes Device des Users mit
|
|
* identischem name + model das zuletzt innerhalb der letzten 7 Tage gesehen
|
|
* wurde. Tritt auf wenn iOS IDFV sich nach Recovery-Restore ändert → neuer
|
|
* deviceId, aber gleicher name ("iPhone von Chahine") + gleiches model
|
|
* ("iPhone18,4").
|
|
*
|
|
* 2026-06-01: Cutoff 30d → 7d (verhindert False-Merges nach längerer Inaktivität).
|
|
*/
|
|
async function findMergeCandidate(
|
|
userId: string,
|
|
name: string | null | undefined,
|
|
model: string | null | undefined,
|
|
): Promise<DeviceRecord | null> {
|
|
if (!name || !model) return null;
|
|
|
|
const db = usePrisma();
|
|
const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
const candidate = await db.userDevice.findFirst({
|
|
where: {
|
|
userId,
|
|
name,
|
|
model,
|
|
lastSeenAt: { gte: cutoff },
|
|
},
|
|
orderBy: { lastSeenAt: "desc" },
|
|
select: DEVICE_SELECT,
|
|
});
|
|
|
|
return candidate;
|
|
}
|
|
|
|
/**
|
|
* Idempotente Registrierung. Wenn Device bereits existiert: Touch lastSeenAt.
|
|
* Wenn nicht existiert UND gleicher name+model innerhalb 30d: Merge (IDFV-Reset-Heuristik).
|
|
* Wenn nicht existiert UND Limit erreicht: throw mit Liste der existierenden Devices.
|
|
*/
|
|
export async function registerDevice(opts: {
|
|
userId: string;
|
|
deviceId: string;
|
|
platform: string;
|
|
model?: string | null;
|
|
name?: string | null;
|
|
osVersion?: string | null;
|
|
maxDevices: number;
|
|
}): Promise<{
|
|
device: DeviceRecord;
|
|
created: boolean;
|
|
merged?: boolean;
|
|
}> {
|
|
const db = usePrisma();
|
|
|
|
// Idempotent: existiert das Device schon?
|
|
const existing = await findUserDevice(opts.userId, opts.deviceId);
|
|
if (existing) {
|
|
// model/name/osVersion beim Re-Register aktualisieren — User-Agent oder OS-Version
|
|
// kann sich geändert haben (App-Update, OS-Upgrade, iPad-Detection-Fix).
|
|
const updated = await db.userDevice.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
lastSeenAt: new Date(),
|
|
...(opts.model !== undefined && { model: opts.model }),
|
|
...(opts.name !== undefined && { name: opts.name }),
|
|
...(opts.osVersion !== undefined && { osVersion: opts.osVersion }),
|
|
},
|
|
select: DEVICE_SELECT,
|
|
});
|
|
return { device: updated, created: false };
|
|
}
|
|
|
|
// Merge-Heuristik: IDFV-Reset nach iOS Recovery-Restore.
|
|
// Wenn name+model matchen und Device zuletzt < 30 Tage gesehen → merge statt insert.
|
|
const mergeTarget = await findMergeCandidate(
|
|
opts.userId,
|
|
opts.name,
|
|
opts.model,
|
|
);
|
|
if (mergeTarget) {
|
|
const merged = await db.userDevice.update({
|
|
where: { id: mergeTarget.id },
|
|
data: {
|
|
deviceId: opts.deviceId, // neue IDFV übernehmen
|
|
lastSeenAt: new Date(),
|
|
...(opts.osVersion !== undefined && { osVersion: opts.osVersion }),
|
|
},
|
|
select: DEVICE_SELECT,
|
|
});
|
|
return { device: merged, created: false, merged: true };
|
|
}
|
|
|
|
// Neues Device — Limit prüfen.
|
|
// 2026-06-01 Auto-Cleanup: alte Phantom-Devices (z.B. nach iOS-Reset mit
|
|
// geändertem Namen, die durch Merge-Heuristik durchgerutscht sind) blockieren
|
|
// sonst das Limit.
|
|
await cleanupStaleDevices(opts.userId);
|
|
|
|
const count = await db.userDevice.count({ where: { userId: opts.userId } });
|
|
if (count >= opts.maxDevices) {
|
|
throw Object.assign(new Error("device_limit_reached"), {
|
|
code: "DEVICE_LIMIT_REACHED",
|
|
currentCount: count,
|
|
max: opts.maxDevices,
|
|
});
|
|
}
|
|
|
|
const created = await db.userDevice.create({
|
|
data: {
|
|
userId: opts.userId,
|
|
deviceId: opts.deviceId,
|
|
platform: opts.platform,
|
|
model: opts.model ?? null,
|
|
name: opts.name ?? null,
|
|
osVersion: opts.osVersion ?? null,
|
|
},
|
|
select: DEVICE_SELECT,
|
|
});
|
|
return { device: created, created: true };
|
|
}
|
|
|
|
/** Touch lastSeenAt — wird in der Auth-Middleware bei jedem Request aufgerufen.
|
|
* Optional: backfillt name/model/osVersion wenn Headers da sind (für Devices
|
|
* die unter altem Build registriert wurden ohne diese Info). */
|
|
export async function touchDevice(
|
|
userId: string,
|
|
deviceId: string,
|
|
info?: {
|
|
name?: string | null;
|
|
model?: string | null;
|
|
osVersion?: string | null;
|
|
},
|
|
): Promise<void> {
|
|
const db = usePrisma();
|
|
const data: {
|
|
lastSeenAt: Date;
|
|
name?: string;
|
|
model?: string;
|
|
osVersion?: string;
|
|
} = {
|
|
lastSeenAt: new Date(),
|
|
};
|
|
if (info?.name) data.name = info.name;
|
|
if (info?.model) data.model = info.model;
|
|
if (info?.osVersion) data.osVersion = info.osVersion;
|
|
await db.userDevice
|
|
.updateMany({
|
|
where: { userId, deviceId },
|
|
data,
|
|
})
|
|
.catch(() => {
|
|
/* race-safe: wenn Device gerade gelöscht wurde */
|
|
});
|
|
}
|
|
|
|
/** User entfernt ein eigenes Device — gibt Slot frei. */
|
|
export async function deleteUserDevice(
|
|
userId: string,
|
|
id: string,
|
|
): Promise<void> {
|
|
const db = usePrisma();
|
|
await db.userDevice.deleteMany({ where: { id, userId } });
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// RebreakMagic DNS-Device-Binding
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// Magic-Binding-Limit ist plan-gated (plan-features.maxProtectedDevices:
|
|
// Pro 1 / Legend 2) — geprüft in api/magic/register.post.ts.
|
|
|
|
export interface MagicDeviceRecord {
|
|
deviceId: string;
|
|
hostname: string | null;
|
|
model: string | null;
|
|
osVersion: string | null;
|
|
magicEnrolledAt: Date;
|
|
releaseRequestedAt: Date | null;
|
|
magicRevokedAt: Date | null;
|
|
magicCooldownUntil: Date | null;
|
|
lastSeenAt: Date | null;
|
|
}
|
|
|
|
/**
|
|
* Listet alle aktiven Magic-Bindings eines Users.
|
|
* Aktiv = magicEnrolledAt != null AND magicRevokedAt == null.
|
|
*/
|
|
export async function listMagicDevices(
|
|
userId: string,
|
|
): Promise<MagicDeviceRecord[]> {
|
|
const db = usePrisma();
|
|
const devices = await db.userDevice.findMany({
|
|
where: {
|
|
userId,
|
|
magicEnrolledAt: { not: null },
|
|
magicRevokedAt: null,
|
|
},
|
|
orderBy: { magicEnrolledAt: "desc" },
|
|
select: {
|
|
deviceId: true,
|
|
magicHostname: true,
|
|
model: true,
|
|
osVersion: true,
|
|
magicEnrolledAt: true,
|
|
releaseRequestedAt: true,
|
|
magicRevokedAt: true,
|
|
magicCooldownUntil: true,
|
|
lastSeenAt: true,
|
|
},
|
|
});
|
|
|
|
return devices.map((d) => ({
|
|
deviceId: d.deviceId,
|
|
hostname: d.magicHostname,
|
|
model: d.model,
|
|
osVersion: d.osVersion,
|
|
magicEnrolledAt: d.magicEnrolledAt!,
|
|
releaseRequestedAt: d.releaseRequestedAt,
|
|
magicRevokedAt: d.magicRevokedAt,
|
|
magicCooldownUntil: d.magicCooldownUntil,
|
|
lastSeenAt: d.lastSeenAt,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Zählt aktive Magic-Bindings für Limit-Check.
|
|
*/
|
|
export async function countActiveMagicBindings(
|
|
userId: string,
|
|
): Promise<number> {
|
|
const db = usePrisma();
|
|
return db.userDevice.count({
|
|
where: {
|
|
userId,
|
|
magicEnrolledAt: { not: null },
|
|
magicRevokedAt: null,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Findet Device anhand DNS-Token. Nur aktive Tokens (nicht revoked).
|
|
*/
|
|
export async function findMagicDeviceByToken(
|
|
token: string,
|
|
): Promise<
|
|
| (DeviceRecord & {
|
|
magicDnsToken: string;
|
|
magicEnrolledAt: Date | null;
|
|
magicRevokedAt: Date | null;
|
|
magicCooldownUntil: Date | null;
|
|
magicRemovalPassword: string | null;
|
|
})
|
|
| null
|
|
> {
|
|
const db = usePrisma();
|
|
const device = await db.userDevice.findUnique({
|
|
where: {
|
|
magicDnsToken: token,
|
|
},
|
|
select: {
|
|
...DEVICE_SELECT,
|
|
magicDnsToken: true,
|
|
magicEnrolledAt: true,
|
|
magicRevokedAt: true,
|
|
magicCooldownUntil: true,
|
|
magicHostname: true,
|
|
magicRemovalPassword: true,
|
|
},
|
|
});
|
|
|
|
if (!device) return null;
|
|
if (device.magicRevokedAt) return null; // Token invalidiert
|
|
|
|
return {
|
|
...device,
|
|
magicDnsToken: device.magicDnsToken!,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Setzt ein Removal-Passwort nachträglich (Lazy-Backfill für Magic-Devices,
|
|
* die vor dem Hard-Lock gebunden wurden). Idempotent über das WHERE-Guard.
|
|
*/
|
|
export async function ensureMagicRemovalPassword(
|
|
rowId: string,
|
|
password: string,
|
|
): Promise<void> {
|
|
const db = usePrisma();
|
|
await db.userDevice.updateMany({
|
|
where: { id: rowId, magicRemovalPassword: null },
|
|
data: { magicRemovalPassword: password },
|
|
});
|
|
}
|
|
|
|
export interface MagicRemovalCredential {
|
|
hostname: string | null;
|
|
model: string | null;
|
|
removalPassword: string;
|
|
}
|
|
|
|
/**
|
|
* Removal-Credentials aller aktiven Magic-Devices eines Users — NUR für den
|
|
* Reveal bei Kündigung/Account-Löschung (Offboarding). Liefert das server-
|
|
* gehaltene Passwort, damit der User die gesperrten Profile entfernen kann.
|
|
*/
|
|
export async function listMagicRemovalCredentials(
|
|
userId: string,
|
|
): Promise<MagicRemovalCredential[]> {
|
|
const db = usePrisma();
|
|
const devices = await db.userDevice.findMany({
|
|
where: {
|
|
userId,
|
|
magicEnrolledAt: { not: null },
|
|
magicRevokedAt: null,
|
|
magicRemovalPassword: { not: null },
|
|
},
|
|
select: {
|
|
magicHostname: true,
|
|
model: true,
|
|
magicRemovalPassword: true,
|
|
},
|
|
});
|
|
return devices.map((d) => ({
|
|
hostname: d.magicHostname,
|
|
model: d.model,
|
|
removalPassword: d.magicRemovalPassword!,
|
|
}));
|
|
}
|