chahinebrini 77edd67cbe fix(magic): explicit imports + staging defaults + sheet height
- backend/api/magic/register: explicit import of MAGIC_DEVICE_LIMIT
  and createAdGuardClient (Nitro auto-import was missing them
  → ReferenceError → HTTP 500 on /api/magic/register)
- mac-app: default backendBaseUrl falls back to staging.rebreak.org
  (app.rebreak.org serves wrong TLS cert)
- native MagicSheet: fallback download/dmg URLs point to staging
- native settings: Magic sheet capped at detents=[0.85] so AppHeader
  stays visible
- bundles all in-flight Magic feature work (pair create/redeem,
  device endpoints, schema, adguard utils, mac-app, locales)
2026-06-03 08:25:02 +02:00

507 lines
14 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
// ─────────────────────────────────────────────────────────────────────────────
/** Hard-Limit für Magic-Bindings pro User (Plan-unabhängig für MVP). */
export const MAGIC_DEVICE_LIMIT = 3;
export interface MagicDeviceRecord {
deviceId: string;
hostname: string | null;
model: string | null;
osVersion: string | null;
magicEnrolledAt: Date;
releaseRequestedAt: 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,
},
});
return devices.map((d) => ({
deviceId: d.deviceId,
hostname: d.magicHostname,
model: d.model,
osVersion: d.osVersion,
magicEnrolledAt: d.magicEnrolledAt!,
releaseRequestedAt: d.releaseRequestedAt,
}));
}
/**
* 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 }) | null> {
const db = usePrisma();
const device = await db.userDevice.findUnique({
where: {
magicDnsToken: token,
},
select: {
...DEVICE_SELECT,
magicDnsToken: true,
magicEnrolledAt: true,
magicRevokedAt: true,
magicHostname: true,
},
});
if (!device) return null;
if (device.magicRevokedAt) return null; // Token invalidiert
return {
...device,
magicDnsToken: device.magicDnsToken!,
};
}