- 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>
90 lines
2.7 KiB
TypeScript
90 lines
2.7 KiB
TypeScript
import { randomBytes } from "crypto";
|
|
import { getProfile } from "../../db/profile";
|
|
import { getPlanLimits } from "../../utils/plan-features";
|
|
import {
|
|
countActiveProtectedDevices,
|
|
createProtectedDevice,
|
|
} from "../../db/protectedDevices";
|
|
|
|
/**
|
|
* POST /api/devices/enroll
|
|
*
|
|
* Legend-only. User klickt "Mac hinzufügen" in der App.
|
|
* Legt ein ProtectedDevice (status=pending) an und gibt die Download-URL
|
|
* für das mobileconfig-Profil zurück.
|
|
*
|
|
* Body: { platform: "mac" | "windows" | "ios" | "android", label: string }
|
|
* Response: { deviceId, dnsToken, downloadUrl }
|
|
*/
|
|
export default defineEventHandler(async (event) => {
|
|
const user = await requireUser(event);
|
|
|
|
const profile = await getProfile(user.id);
|
|
const limits = getPlanLimits(profile?.plan ?? "free");
|
|
|
|
// maxProtectedDevices=0 → Feature nicht verfügbar (free/pro)
|
|
if (limits.maxProtectedDevices === 0) {
|
|
throw createError({
|
|
statusCode: 403,
|
|
data: { error: "LEGEND_REQUIRED" },
|
|
});
|
|
}
|
|
|
|
const body = await readBody(event);
|
|
const platform = body?.platform as string | undefined;
|
|
const label = body?.label as string | undefined;
|
|
|
|
const VALID_PLATFORMS = ["mac", "windows", "ios", "android"];
|
|
if (!platform || !VALID_PLATFORMS.includes(platform)) {
|
|
throw createError({
|
|
statusCode: 400,
|
|
data: { error: "INVALID_PLATFORM", validValues: VALID_PLATFORMS },
|
|
});
|
|
}
|
|
if (!label || typeof label !== "string" || label.trim().length === 0) {
|
|
throw createError({ statusCode: 400, data: { error: "LABEL_REQUIRED" } });
|
|
}
|
|
const trimmedLabel = label.trim().slice(0, 100);
|
|
|
|
// Limit: max. maxProtectedDevices active+pending Devices
|
|
const activeCount = await countActiveProtectedDevices(user.id);
|
|
if (activeCount >= limits.maxProtectedDevices) {
|
|
throw createError({
|
|
statusCode: 409,
|
|
data: {
|
|
error: "plan_limit",
|
|
resource: "protected_devices",
|
|
current: activeCount,
|
|
limit: limits.maxProtectedDevices,
|
|
},
|
|
});
|
|
}
|
|
|
|
// 32-char hex token — kryptografisch sicher
|
|
const dnsToken = randomBytes(16).toString("hex");
|
|
|
|
const device = await createProtectedDevice({
|
|
userId: user.id,
|
|
dnsToken,
|
|
platform,
|
|
label: trimmedLabel,
|
|
});
|
|
|
|
const config = useRuntimeConfig(event);
|
|
const apiBase =
|
|
(config.public as any)?.apiBase ?? "https://api.rebreak.org";
|
|
|
|
// Platform-aware download URL: Windows gets .reg, everything else .mobileconfig
|
|
const profileExt = platform === "windows" ? "reg" : "mobileconfig";
|
|
const downloadUrl = `${apiBase}/api/devices/${device.id}/profile.${profileExt}`;
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
deviceId: device.id,
|
|
dnsToken: device.dnsToken,
|
|
downloadUrl,
|
|
},
|
|
};
|
|
});
|