Turnstile bot protection (sign-in, sign-up, password-reset): - Register Better Auth's captcha plugin with the cloudflare-turnstile provider; endpoints listed explicitly rather than relying on defaults. /reset-password is intentionally excluded — it is reached only via a single-use emailed token. - Add an explicit-render Turnstile widget component. Tokens are single-use, so each form resets the challenge after a failed submit; submit stays disabled until a token is held. - Read the site key server-side and pass it down as a prop, so rotating it does not require a rebuild. - Fail fast in production when TURNSTILE_SECRET_KEY is missing, and when a secret is set without a site key (that combination would demand a token no form can produce, locking every user out). - Pass a throwaway secret during `next build` in the Dockerfile, mirroring the existing BETTER_AUTH_SECRET treatment, so image builds don't need it. CSP fixes in middleware (these blocked Turnstile entirely): - Add frame-src for challenges.cloudflare.com. Without it the widget's iframe fell back to default-src 'self' and was blocked outright. - Allow 'unsafe-eval' and websockets in DEVELOPMENT only. `next dev` compiles with eval(), so the strict policy threw EvalError and killed hydration — no client JS ran at all, which also meant form submit handlers never fired. Production policy is unchanged and still strict. Also included (concurrent work in the tree): - Admin organizations pages and lib/admin/orgs. - Episode moderation migration, SEO metadata (sitemap, robots, JSON-LD, OG/Twitter images, manifest), Umami analytics, not-found page. Local dev database: docker-compose.dev.yml provisions Postgres 18 on port 5443 (5432-5442 are in use by other local projects). Note: `npx tsc --noEmit` currently fails in app/(app)/team/page.tsx — an `invitations` prop the component does not accept. This predates the commit and will fail `next build` until fixed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
83 lines
3.2 KiB
TypeScript
83 lines
3.2 KiB
TypeScript
import type Stripe from "stripe";
|
|
import { stripe } from "../stripe";
|
|
import { upsertSubscription } from "../subscription";
|
|
import { planFromStripePrice } from "../catalog";
|
|
import { PLAN_ORDER, type PlanKey } from "../plans";
|
|
|
|
/** Narrow attacker-influenceable metadata to a known PlanKey, else null. */
|
|
function planFromMetadata(value?: string | null): PlanKey | null {
|
|
return value && (PLAN_ORDER as string[]).includes(value) ? (value as PlanKey) : null;
|
|
}
|
|
|
|
function normalizeStatus(status: Stripe.Subscription.Status): string {
|
|
switch (status) {
|
|
case "active":
|
|
case "trialing":
|
|
case "past_due":
|
|
case "paused":
|
|
return status;
|
|
default:
|
|
return "canceled"; // canceled | incomplete | incomplete_expired | unpaid
|
|
}
|
|
}
|
|
|
|
async function syncStripeSubscription(
|
|
sub: Stripe.Subscription,
|
|
metadata?: Stripe.Metadata | null
|
|
) {
|
|
const item = sub.items.data[0];
|
|
const priceId = item?.price?.id;
|
|
const mapped = priceId ? planFromStripePrice(priceId) : null;
|
|
// The PRICE is authoritative: it is what the customer is actually charged, and
|
|
// Stripe updates it on every plan change. metadata.plan is only written once at
|
|
// checkout (lib/billing/stripe.ts) and is NOT rewritten when a customer switches
|
|
// plans in the Billing Portal — trusting it first would let a downgraded customer
|
|
// keep the higher tier's entitlements. Metadata is a fallback for the case where
|
|
// a price is missing or unmapped, and is still narrowed to a known PlanKey.
|
|
const plan: PlanKey = mapped?.plan ?? planFromMetadata(metadata?.plan) ?? "free";
|
|
const referenceId = metadata?.subjectId || sub.metadata?.subjectId;
|
|
if (!referenceId || referenceId.trim() === "") {
|
|
console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id);
|
|
return;
|
|
}
|
|
|
|
const interval =
|
|
mapped?.interval ?? (item?.price?.recurring?.interval === "year" ? "year" : "month");
|
|
|
|
await upsertSubscription({
|
|
provider: "stripe",
|
|
referenceId,
|
|
plan,
|
|
status: normalizeStatus(sub.status),
|
|
billingInterval: interval,
|
|
stripeCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
|
|
stripeSubscriptionId: sub.id,
|
|
periodStart: sub.current_period_start ? new Date(sub.current_period_start * 1000) : null,
|
|
periodEnd: sub.current_period_end ? new Date(sub.current_period_end * 1000) : null,
|
|
cancelAtPeriodEnd: sub.cancel_at_period_end,
|
|
});
|
|
}
|
|
|
|
export async function handleStripeEvent(event: Stripe.Event): Promise<void> {
|
|
switch (event.type) {
|
|
case "checkout.session.completed": {
|
|
const session = event.data.object as Stripe.Checkout.Session;
|
|
if (session.mode !== "subscription" || !session.subscription) break;
|
|
const subId =
|
|
typeof session.subscription === "string" ? session.subscription : session.subscription.id;
|
|
const sub = await stripe().subscriptions.retrieve(subId);
|
|
await syncStripeSubscription(sub, session.metadata);
|
|
break;
|
|
}
|
|
case "customer.subscription.created":
|
|
case "customer.subscription.updated":
|
|
case "customer.subscription.deleted": {
|
|
const sub = event.data.object as Stripe.Subscription;
|
|
await syncStripeSubscription(sub, sub.metadata);
|
|
break;
|
|
}
|
|
default:
|
|
break;
|
|
}
|
|
}
|