chahinebrini a95e66560d feat(magic): Hard-Lock + Geräte-UX (Push, Realtime, Detail-Sheet, Offline-Removal)
Devices/Magic:
- Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im
  Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic
- Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar)
- Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung
- Realtime auf user_devices → Settings aktualisiert Magic-Bindings live
- Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut

Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie):
- magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill)
- Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook),
  per Resend-Mail + in-Response
- Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert)

Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 22:26:25 +02:00

157 lines
5.0 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 Stripe from "stripe";
import { usePrisma } from "../../utils/prisma";
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
import type { Plan } from "../../utils/plan-features";
import { listMagicRemovalCredentials } from "../../db/devices";
import { sendMagicRemovalEmail } from "../../utils/magic-removal-email";
import { serverSupabaseServiceRole } from "../../utils/useSupabase";
/**
* POST /api/stripe/webhook
* Stripe Webhook verarbeitet Subscription-Events.
* Aktualisiert profiles.plan + stripe_* Felder + triggert Downgrade-Reconciliation.
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig();
const stripe = new Stripe(config.stripeSecretKey);
const body = await readRawBody(event);
const sig = getHeader(event, "stripe-signature");
if (!body || !sig) {
throw createError({
statusCode: 400,
message: "Missing body or signature",
});
}
let stripeEvent: Stripe.Event;
try {
stripeEvent = stripe.webhooks.constructEvent(
body,
sig,
config.stripeWebhookSecret,
);
} catch (err: any) {
throw createError({
statusCode: 400,
message: `Webhook Error: ${err.message}`,
});
}
const db = usePrisma();
switch (stripeEvent.type) {
case "checkout.session.completed": {
const session = stripeEvent.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.user_id || session.client_reference_id;
const plan = session.metadata?.plan || "legend";
const newPlan = (
plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free"
) as Plan;
if (userId) {
const current = await db.profile.findUnique({
where: { id: userId },
select: { plan: true },
});
const fromPlan = (current?.plan ?? "free") as Plan;
await db.profile.update({
where: { id: userId },
data: {
plan: newPlan,
stripeCustomerId: session.customer as string,
stripeSubId: session.subscription as string,
},
});
await runDowngradeReconciliation(userId, fromPlan, newPlan).catch(
(err) => console.error("[stripe-webhook] reconciliation error:", err),
);
}
break;
}
case "customer.subscription.updated": {
const sub = stripeEvent.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const profile = await db.profile.findFirst({
where: { stripeCustomerId: customerId },
select: { id: true, plan: true },
});
if (profile) {
const isActive = ["active", "trialing"].includes(sub.status);
const newPlan = (isActive ? profile.plan : "free") as Plan;
const fromPlan = profile.plan as Plan;
await db.profile.update({
where: { id: profile.id },
data: {
plan: newPlan,
premiumUntil: sub.current_period_end
? new Date(sub.current_period_end * 1000)
: null,
},
});
await runDowngradeReconciliation(profile.id, fromPlan, newPlan).catch(
(err) => console.error("[stripe-webhook] reconciliation error:", err),
);
}
break;
}
case "customer.subscription.deleted": {
const sub = stripeEvent.data.object as Stripe.Subscription;
const customerId = sub.customer as string;
const profile = await db.profile.findFirst({
where: { stripeCustomerId: customerId },
select: { id: true, plan: true, nickname: true },
});
if (profile) {
const fromPlan = profile.plan as Plan;
await db.profile.update({
where: { id: profile.id },
data: { plan: "free", premiumUntil: null },
});
await runDowngradeReconciliation(profile.id, fromPlan, "free").catch(
(err) => console.error("[stripe-webhook] reconciliation error:", err),
);
// Magic Hard-Lock Reveal bei Kündigung: Removal-Passwörter per Mail,
// damit der User die gesperrten Mac/Windows-Profile entfernen kann.
try {
const creds = await listMagicRemovalCredentials(profile.id);
if (creds.length > 0) {
const config = useRuntimeConfig(event);
const resendApiKey = (config as any).resendApiKey as string | undefined;
const supabase = serverSupabaseServiceRole(event);
const { data } = await supabase.auth.admin.getUserById(profile.id);
const email = data?.user?.email;
if (email && resendApiKey) {
await sendMagicRemovalEmail({
recipientEmail: email,
recipientNickname: profile.nickname,
credentials: creds,
reason: "cancellation",
resendApiKey,
}).catch(() => {});
}
}
} catch (err) {
console.error("[stripe-webhook] magic removal reveal failed:", err);
}
}
break;
}
}
return { received: true };
});