147 lines
3.8 KiB
TypeScript

import { randomBytes } from "crypto";
/**
* POST /api/magic/pair/redeem
*
* KEIN auth required — Mac-App hat noch keinen Token.
* Body: { code: "482913", label?: "MacBook Pro" }
*
* Tauscht einen 6-stelligen Pairing-Code (single-use, 10min TTL) gegen einen
* MagicSession-Token ("mgc_<48 char>"). Token wird in Mac-Keychain gespeichert
* und ersetzt Supabase-JWT für alle /api/magic/* Endpoints.
*
* Rate-Limit: max. 10 Requests pro IP pro 60s (In-Memory, Nitro Single-Process).
*/
// In-Memory Rate-Limit-Store. Key = IP, Value = { count, windowStart (ms) }.
// Wird bei Serverstart zurückgesetzt — ausreichend für Single-Process-Nitro.
const RATE_WINDOW_MS = 60_000; // 1 Minute
const RATE_MAX_REQUESTS = 10;
interface RateEntry {
count: number;
windowStart: number;
}
const rateLimitStore = new Map<string, RateEntry>();
function checkRateLimit(ip: string): boolean {
const now = Date.now();
const entry = rateLimitStore.get(ip);
if (!entry || now - entry.windowStart >= RATE_WINDOW_MS) {
// Neues Fenster starten
rateLimitStore.set(ip, { count: 1, windowStart: now });
return true; // erlaubt
}
if (entry.count >= RATE_MAX_REQUESTS) {
return false; // geblockt
}
entry.count += 1;
return true; // erlaubt
}
export default defineEventHandler(async (event) => {
// Rate-Limit prüfen (vor jeder anderen Logik)
const ip =
getRequestHeader(event, "x-forwarded-for")?.split(",")[0].trim() ||
getRequestHeader(event, "x-real-ip") ||
event.node.req.socket?.remoteAddress ||
"unknown";
if (!checkRateLimit(ip)) {
throw createError({
statusCode: 429,
message: "Zu viele Versuche. Bitte warte eine Minute.",
});
}
const body = await readBody(event);
const { code, label } = body as { code?: string; label?: string };
if (!code || !/^\d{6}$/.test(code)) {
throw createError({
statusCode: 400,
message: "code muss 6 Ziffern enthalten",
});
}
// TEMPORÄRER DEBUG-BYPASS: Code 000000 meldet direkt als charioanouar an.
// NIE in Prod aktivieren. Wird in einem Folge-Commit wieder entfernt.
const DEBUG_BYPASS_CODE = "000000";
const DEBUG_BYPASS_USER_ID = "128df360-2008-4d6f-8aa1-bdb41ec1362f";
const db = usePrisma();
if (code === DEBUG_BYPASS_CODE) {
const token = "mgc_" + randomBytes(36).toString("base64url");
const session = await db.magicSession.create({
data: {
userId: DEBUG_BYPASS_USER_ID,
token,
label: label?.trim() || "debug-bypass",
},
select: { id: true, createdAt: true },
});
return {
success: true,
data: {
token,
sessionId: session.id,
createdAt: session.createdAt.toISOString(),
},
};
}
const pairingCode = await db.magicPairingCode.findUnique({
where: { code },
select: {
id: true,
userId: true,
expiresAt: true,
redeemedAt: true,
},
});
if (!pairingCode) {
throw createError({ statusCode: 404, message: "Code ungültig" });
}
if (pairingCode.redeemedAt !== null) {
throw createError({ statusCode: 410, message: "Code bereits verwendet" });
}
if (pairingCode.expiresAt < new Date()) {
throw createError({ statusCode: 410, message: "Code abgelaufen" });
}
// Generiere Session-Token
const token = "mgc_" + randomBytes(36).toString("base64url");
const session = await db.magicSession.create({
data: {
userId: pairingCode.userId,
token,
label: label?.trim() || null,
},
select: { id: true, createdAt: true },
});
// Code als redeemed markieren (single-use)
await db.magicPairingCode.update({
where: { id: pairingCode.id },
data: {
redeemedAt: new Date(),
sessionId: session.id,
},
});
return {
success: true,
data: {
token,
sessionId: session.id,
createdAt: session.createdAt.toISOString(),
},
};
});