chahinebrini 60f608d891 fix(backend): backfill device name/model/osVersion on touch + auto-register
Bug: existing devices registered before Frontend started sending x-device-name/
x-device-model/x-device-os headers stayed with NULL fields forever — DeviceLimit
sheet shows only platform label ("iPhone" without iOS version, no name).

Fix:
- touchDevice() now accepts optional { name, model, osVersion } and updates
  these fields when headers are provided (existing-row backfill).
- requireUser auth middleware reads URL-encoded x-device-* headers + passes
  them to both touchDevice() (existing) and registerDevice() (auto-register).

After deploy: next authenticated request from updated client backfills the
device record automatically (throttled per TOUCH_THROTTLE_MS = 1×/min).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 21:27:58 +02:00

132 lines
4.1 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;
// Frontend sendet name/model/osVersion als x-device-* Headers (URL-encoded)
function readEncoded(name: string): string | null {
const raw = getHeader(event, name);
if (!raw) return null;
try { return decodeURIComponent(raw); } catch { return raw; }
}
const deviceName = readEncoded('x-device-name');
const deviceModel = readEncoded('x-device-model');
const deviceOs = readEncoded('x-device-os');
const existing = await findUserDevice(user.id, deviceId);
if (existing) {
// Touch lastSeenAt + Backfill name/model/osVersion falls noch nicht gesetzt,
// throttled auf 1×/min — fire-and-forget
if (Date.now() - existing.lastSeenAt.getTime() > TOUCH_THROTTLE_MS) {
touchDevice(user.id, deviceId, {
name: deviceName,
model: deviceModel,
osVersion: deviceOs,
}).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,
name: deviceName,
model: deviceModel,
osVersion: deviceOs,
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;
}