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

114 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { createClient } from '@supabase/supabase-js';
import type { H3Event } from 'h3';
import { isAdminUser } from '../db/admin';
import { findUserDevice, registerDevice, touchDevice } from '../db/devices';
import { getProfile } from '../db/profile';
import { getPlanLimits } from './plan-features';
const TOUCH_THROTTLE_MS = 60_000; // touch lastSeenAt höchstens 1×/min pro Device
export interface RequireUserOptions {
/** Bootstrap-Endpoints (z.B. /api/devices/register) brauchen kein Device-Binding */
skipDeviceCheck?: boolean;
}
export async function requireUser(
event: H3Event,
opts: RequireUserOptions = {},
) {
const authHeader = getHeader(event, 'authorization');
let token = authHeader?.replace('Bearer ', '');
if (!token) {
const query = getQuery(event);
token = query.token as string;
}
if (!token) {
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
}
const config = useRuntimeConfig(event);
const supabaseCfg =
(config as any).public?.supabase ?? (config as any).supabase;
const client = createClient(
supabaseCfg.url as string,
supabaseCfg.key as string,
{ global: { headers: { Authorization: `Bearer ${token}` } } },
);
const {
data: { user },
error,
} = await client.auth.getUser();
if (error || !user) {
throw createError({ statusCode: 401, message: 'Nicht eingeloggt' });
}
if (opts.skipDeviceCheck) return user;
// Device-Binding: nur enforced wenn Client einen x-device-id Header schickt.
// Web-Clients ohne Header laufen weiter wie bisher.
const deviceId = getHeader(event, 'x-device-id');
if (!deviceId) return user;
const existing = await findUserDevice(user.id, deviceId);
if (existing) {
// Touch lastSeenAt, throttled auf 1×/min — fire-and-forget
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
touchDevice(user.id, deviceId).catch(() => {});
}
return user;
}
// Device unbekannt — Auto-Register (ohne Frontend-explicit-call)
// Plan-Limit holen
const profile = await getProfile(user.id);
const limits = getPlanLimits(profile?.plan ?? 'free');
const platform = getHeader(event, 'x-platform') ?? 'unknown';
try {
await registerDevice({
userId: user.id,
deviceId,
platform,
maxDevices: limits.maxAppDevices,
});
return user;
} catch (err: any) {
if (err.code === 'DEVICE_LIMIT_REACHED') {
// Devices-Liste mitschicken damit das Frontend-Modal die Geräte
// anzeigen + Freigeben-Buttons rendern kann (auch wenn der 403
// nicht vom register-Endpoint sondern vom auth-Middleware kommt).
const { listUserDevices } = await import('../db/devices');
const devices = await listUserDevices(user.id);
throw createError({
statusCode: 403,
statusMessage: 'device_limit_reached',
data: {
error: 'device_limit_reached',
max: limits.maxAppDevices,
plan: profile?.plan ?? 'free',
devices,
},
});
}
throw err;
}
}
/**
* requireAdmin — wirft 401 wenn nicht eingeloggt, 403 wenn nicht in admin_users.
* Wraps requireUser mit skipDeviceCheck=true (Admin-App hat kein Device-Binding).
*/
export async function requireAdmin(event: H3Event) {
const user = await requireUser(event, { skipDeviceCheck: true });
const admin = await isAdminUser(user.id);
if (!admin) {
throw createError({ statusCode: 403, message: 'Kein Admin-Zugang' });
}
return user;
}