- 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>
114 lines
3.5 KiB
TypeScript
114 lines
3.5 KiB
TypeScript
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;
|
||
} |