rebreak-monorepo/backend/server/db/protectedDevices.ts
chahinebrini 335945fe2c feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub,
  TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split
  (legend maxProtectedDevices: 2); mail 1/3/Infinity
- limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll
  ({ error:'plan_limit', resource, current, limit }); approved-own-submissions already
  excluded from custom-domain count (slot frees on approval)
- server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade
  reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts
  (isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final
  sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles
  (status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d;
  custom domains grandfathered
- set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change;
  set-plan accepts { foundingMember } for testing
- GET /api/plan/change-preview?to=<plan>: gains/keeps/changes per resource (8 axes),
  founding-member → direction 'same'
- me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block
- blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full')
- db: countMailConnections/getMailConnections exclude paused; getAllMailConnections;
  getDeviceBlocklistMode (active|grace|passthrough|revoked)
- migration 20260511_tier_system_phase2 (profiles.founding_member +
  global_blocklist_grace_until; mail_connections.paused_at/paused_reason;
  protected_devices.degraded_at). prisma generate + build:backend clean.

TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP
final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the
stub into the blocklist response for free users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:23:02 +02:00

164 lines
4.4 KiB
TypeScript

import { usePrisma } from "../utils/prisma";
export interface ProtectedDeviceRecord {
id: string;
platform: string;
label: string;
status: string;
installedAt: Date | null;
degradedAt: Date | null;
createdAt: Date;
}
export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord {
dnsToken: string;
userId: string;
}
const DEVICE_SELECT = {
id: true,
platform: true,
label: true,
status: true,
installedAt: true,
degradedAt: true,
createdAt: true,
} as const;
const DEVICE_SELECT_WITH_TOKEN = {
...DEVICE_SELECT,
dnsToken: true,
userId: true,
} as const;
/** Alle nicht-revoked Devices eines Users, neueste zuerst. */
export async function listProtectedDevices(
userId: string,
): Promise<ProtectedDeviceRecord[]> {
const db = usePrisma();
return db.protectedDevice.findMany({
where: { userId, status: { not: "revoked" } },
orderBy: { createdAt: "desc" },
select: DEVICE_SELECT,
});
}
/** Anzahl der aktiven+pending Devices für Limit-Check (degraded zählt NICHT — Slot freigegeben). */
export async function countActiveProtectedDevices(
userId: string,
): Promise<number> {
const db = usePrisma();
return db.protectedDevice.count({
where: { userId, status: { in: ["active", "pending"] } },
});
}
/** Lookup by id — inkl. dnsToken und userId (für mobileconfig-Generation + ownership-check). */
export async function getProtectedDevice(
id: string,
): Promise<ProtectedDeviceWithToken | null> {
const db = usePrisma();
return db.protectedDevice.findUnique({
where: { id },
select: DEVICE_SELECT_WITH_TOKEN,
});
}
/** Lookup by dnsToken — für DoH-Blocklist-Endpoint (Token aus URL). */
export async function getProtectedDeviceByToken(
dnsToken: string,
): Promise<ProtectedDeviceWithToken | null> {
const db = usePrisma();
return db.protectedDevice.findUnique({
where: { dnsToken },
select: DEVICE_SELECT_WITH_TOKEN,
});
}
/** Anlegen eines neuen ProtectedDevice (status=pending). */
export async function createProtectedDevice(opts: {
userId: string;
dnsToken: string;
platform: string;
label: string;
}): Promise<ProtectedDeviceWithToken> {
const db = usePrisma();
return db.protectedDevice.create({
data: {
userId: opts.userId,
dnsToken: opts.dnsToken,
platform: opts.platform,
label: opts.label,
status: "pending",
},
select: DEVICE_SELECT_WITH_TOKEN,
});
}
/** User bestätigt Installation — setzt installedAt + status=active. */
export async function confirmProtectedDeviceInstalled(
id: string,
userId: string,
): Promise<ProtectedDeviceRecord | null> {
const db = usePrisma();
const device = await db.protectedDevice.findFirst({
where: { id, userId, status: { not: "revoked" } },
});
if (!device) return null;
return db.protectedDevice.update({
where: { id },
data: {
status: "active",
installedAt: new Date(),
},
select: DEVICE_SELECT,
});
}
/** Soft-delete: setzt status=revoked + revokedAt. Ownership-check via userId. */
export async function revokeProtectedDevice(
id: string,
userId: string,
): Promise<boolean> {
const db = usePrisma();
const device = await db.protectedDevice.findFirst({
where: { id, userId, status: { not: "revoked" } },
});
if (!device) return false;
await db.protectedDevice.update({
where: { id },
data: { status: "revoked", revokedAt: new Date() },
});
return true;
}
/**
* Prüft ob ein Token nach der 14-Tage-Grace-Period in Passthrough-Modus ist.
* Wird vom DoH-Blocklist-Endpoint aufgerufen.
*
* Returns:
* 'active' → volle Blocklist liefern
* 'grace' → volle Blocklist liefern (innerhalb 14-Tage-Grace)
* 'passthrough' → nur minimale/leere Liste liefern
* 'revoked' → Token unbekannt oder revoked → Passthrough
*/
export async function getDeviceBlocklistMode(
dnsToken: string,
): Promise<"active" | "grace" | "passthrough" | "revoked"> {
const device = await getProtectedDeviceByToken(dnsToken);
if (!device) return "revoked";
if (device.status === "revoked") return "revoked";
if (device.status === "active" || device.status === "pending") return "active";
if (device.status === "degraded") {
const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
const gracedAt = device.degradedAt
? device.degradedAt.getTime() + GRACE_MS
: 0;
if (Date.now() <= gracedAt) return "grace";
return "passthrough";
}
return "passthrough";
}