feat: Cloudflare Turnstile on auth, CSP fixes, admin/SEO/analytics additions

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>
This commit is contained in:
Leon Serfaty
2026-09-07 11:10:55 -04:00
co-authored by Claude Opus 5
parent 35379212fb
commit 3e9ba07175
96 changed files with 3982 additions and 576 deletions
+25 -4
View File
@@ -10,12 +10,19 @@ import {
ArrowRight,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { JsonLd } from "@/components/seo/json-ld";
import { breadcrumbSchema, graph, webPageSchema } from "@/lib/schema";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = {
/** Shared by the page metadata and this page's structured data. */
const DESCRIPTION =
"Podcast Distribution AI is an AI studio that turns a single idea into a finished, publishable podcast — script, voices, and cover art — in minutes. Learn why we built it and what we believe.";
export const metadata: Metadata = pageMetadata({
title: "About",
description:
"Podcast Distribution AI is an AI studio that turns a single idea into a finished, publishable podcast — script, voices, and cover art — in minutes. Learn why we built it and what we believe.",
};
description: DESCRIPTION,
path: "/about",
});
const STATS = [
{ value: "3", label: "AI models in one workflow" },
@@ -60,6 +67,20 @@ const VALUES = [
export default function AboutPage() {
return (
<>
<JsonLd
data={graph(
{
...webPageSchema({
path: "/about",
name: "About",
description: DESCRIPTION,
}),
"@type": "AboutPage",
},
breadcrumbSchema([{ name: "About", path: "/about" }])
)}
/>
{/* Hero */}
<section className="bg-hero-wash">
<div className="container max-w-4xl py-24 text-center md:py-32">
+13 -1
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next";
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { title: "Acceptable Use Policy" };
/** Shared by the page metadata and the document's structured data. */
const PATH = "/acceptable-use";
const DESCRIPTION =
"What you may and may not create with Podcast Distribution AI — prohibited content, voice and likeness rules, rate limits, and how we enforce them.";
export const metadata: Metadata = pageMetadata({
title: "Acceptable Use Policy",
description: DESCRIPTION,
path: PATH,
});
const UPDATED = "June 7, 2026";
@@ -60,6 +70,8 @@ export default function AcceptableUsePage() {
updated={UPDATED}
intro="We want Podcast Distribution AI to be a safe, trustworthy place to create. This policy describes the content and conduct that are not allowed on the platform."
sections={SECTIONS}
path={PATH}
description={DESCRIPTION}
/>
);
}
+13 -1
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next";
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { title: "Cookie Policy" };
/** Shared by the page metadata and the document's structured data. */
const PATH = "/cookies";
const DESCRIPTION =
"Which cookies Podcast Distribution AI sets and what each one does. We use only the cookies required to keep you signed in and the service secure.";
export const metadata: Metadata = pageMetadata({
title: "Cookie Policy",
description: DESCRIPTION,
path: PATH,
});
const UPDATED = "June 7, 2026";
@@ -52,6 +62,8 @@ export default function CookiePolicyPage() {
updated={UPDATED}
intro="This Cookie Policy explains how Podcast Distribution AI uses cookies and similar technologies, and the choices available to you. It should be read together with our Privacy Policy."
sections={SECTIONS}
path={PATH}
description={DESCRIPTION}
/>
);
}
+19 -4
View File
@@ -2,12 +2,19 @@ import type { Metadata } from "next";
import Link from "next/link";
import { ChevronDown, ArrowRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { JsonLd } from "@/components/seo/json-ld";
import { breadcrumbSchema, faqPageSchema, graph, webPageSchema } from "@/lib/schema";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = {
/** Shared by the page metadata and this page's structured data. */
const DESCRIPTION =
"Answers to common questions about creating AI podcasts with Podcast Distribution AI — generation, voices, languages, plans, billing, repurposing, the API, and teams.";
export const metadata: Metadata = pageMetadata({
title: "FAQ",
description:
"Answers to common questions about creating AI podcasts with Podcast Distribution AI — generation, voices, languages, plans, billing, repurposing, the API, and teams.",
};
description: DESCRIPTION,
path: "/faq",
});
interface QA {
q: string;
@@ -129,6 +136,14 @@ const FAQ: Category[] = [
export default function FaqPage() {
return (
<div className="bg-hero-wash">
<JsonLd
data={graph(
webPageSchema({ path: "/faq", name: "FAQ", description: DESCRIPTION }),
breadcrumbSchema([{ name: "FAQ", path: "/faq" }]),
// Flattened across categories — FAQPage takes one list of questions.
faqPageSchema(FAQ.flatMap((category) => category.items))
)}
/>
<div className="container max-w-3xl py-20 md:py-28">
<div className="text-center">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Support</p>
+39 -8
View File
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import {
ArrowRight,
@@ -33,12 +34,19 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { JsonLd } from "@/components/seo/json-ld";
import { breadcrumbSchema, graph, softwareApplicationSchema, webPageSchema } from "@/lib/schema";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = {
/** Shared by the page metadata and this page's structured data. */
const DESCRIPTION =
"Everything Podcast Distribution AI does — AI scriptwriting, realistic multi-voice audio, cover art, repurposing, a season generator, 13+ languages, team white-label, an API, and more.";
export const metadata: Metadata = pageMetadata({
title: "Features",
description:
"Everything Podcast Distribution AI does — AI scriptwriting, realistic multi-voice audio, cover art, repurposing, a season generator, 13+ languages, team white-label, an API, and more.",
};
description: DESCRIPTION,
path: "/features",
});
const HERO_IMG =
"https://images.unsplash.com/photo-1590602847861-f357a9332bbc?auto=format&fit=crop&w=1600&q=80";
@@ -50,6 +58,18 @@ const TEAM_IMG =
export default function FeaturesPage() {
return (
<>
<JsonLd
data={graph(
webPageSchema({
path: "/features",
name: "Features",
description: DESCRIPTION,
}),
breadcrumbSchema([{ name: "Features", path: "/features" }]),
softwareApplicationSchema()
)}
/>
{/* 1 — Hero */}
<section className="relative overflow-hidden bg-hero-wash">
<div className="container grid items-center gap-12 py-20 md:grid-cols-2 md:py-28">
@@ -79,13 +99,16 @@ export default function FeaturesPage() {
</div>
<div className="relative">
<div className="overflow-hidden rounded-3xl border border-border shadow-xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{/* Above the fold — the LCP element on this page, so it is
fetched eagerly at high priority rather than lazily. */}
<Image
src={HERO_IMG}
alt="Studio condenser microphone"
className="h-full w-full object-cover"
width={1600}
height={1067}
sizes="(max-width: 768px) 100vw, 50vw"
priority
/>
</div>
<div className="absolute -bottom-5 -left-5 hidden rounded-2xl border border-border bg-card p-4 shadow-lg sm:block">
@@ -534,8 +557,16 @@ function FeatureBand({
<div className={reverse ? "md:order-1" : ""}>
{image ? (
<div className="overflow-hidden rounded-3xl border border-border shadow-xl">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={image} alt={imageAlt ?? ""} className="h-full w-full object-cover" width={1400} height={933} />
{/* Below the fold — lazy by default, and served in a modern
format at the size the column actually renders at. */}
<Image
src={image}
alt={imageAlt ?? ""}
className="h-full w-full object-cover"
width={1400}
height={933}
sizes="(max-width: 768px) 100vw, 50vw"
/>
</div>
) : (
visual
+5
View File
@@ -1,9 +1,14 @@
import { SiteHeader } from "@/components/marketing/site-header";
import { SiteFooter } from "@/components/marketing/site-footer";
import { JsonLd } from "@/components/seo/json-ld";
import { graph, organizationSchema, websiteSchema } from "@/lib/schema";
export default function MarketingLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
{/* Publisher identity, emitted once for every public marketing page. Pages
add their own nodes (WebPage, BreadcrumbList, FAQPage…) on top. */}
<JsonLd data={graph(organizationSchema(), websiteSchema())} />
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
+21
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import {
ArrowRight,
@@ -14,12 +15,32 @@ import {
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { JsonLd } from "@/components/seo/json-ld";
import { PLAN_ORDER, PLANS } from "@/lib/billing/plans";
import { graph, softwareApplicationSchema, webPageSchema } from "@/lib/schema";
import { SITE_DESCRIPTION, SITE_TAGLINE, absoluteUrl } from "@/lib/seo";
import { formatPrice } from "@/lib/utils";
export const metadata: Metadata = {
// The homepage keeps the root layout's title/description (they are already
// written for it); it only needs an explicit self-referencing canonical.
alternates: { canonical: absoluteUrl("/") },
};
export default function LandingPage() {
return (
<>
<JsonLd
data={graph(
webPageSchema({
path: "/",
name: SITE_TAGLINE,
description: SITE_DESCRIPTION,
}),
softwareApplicationSchema()
)}
/>
{/* Hero */}
<section className="relative overflow-hidden bg-hero-wash">
<div className="container flex flex-col items-center gap-7 py-24 text-center md:py-36">
+22 -3
View File
@@ -6,15 +6,34 @@ import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { PLAN_ORDER, PLANS } from "@/lib/billing/plans";
import { formatPrice } from "@/lib/utils";
import { JsonLd } from "@/components/seo/json-ld";
import { breadcrumbSchema, graph, softwareApplicationSchema, webPageSchema } from "@/lib/schema";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = {
/** Shared by the page metadata and this page's structured data. */
const DESCRIPTION =
"Simple plans for every podcaster — start free with 3 scripts a month and upgrade for unlimited scripts, longer episodes, an API, and a white-label team workspace.";
export const metadata: Metadata = pageMetadata({
title: "Pricing",
description: "Simple plans for every podcaster — start free and upgrade as you grow.",
};
description: DESCRIPTION,
path: "/pricing",
});
export default function PricingPage() {
return (
<div className="bg-hero-wash">
<JsonLd
data={graph(
webPageSchema({
path: "/pricing",
name: "Pricing",
description: DESCRIPTION,
}),
breadcrumbSchema([{ name: "Pricing", path: "/pricing" }]),
softwareApplicationSchema()
)}
/>
<div className="container py-24 md:py-28">
<div className="mx-auto max-w-2xl text-center">
<p className="text-[13px] font-semibold uppercase tracking-[0.04em] text-brand">Pricing</p>
+13 -1
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next";
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { title: "Privacy Policy" };
/** Shared by the page metadata and the document's structured data. */
const PATH = "/privacy";
const DESCRIPTION =
"How Podcast Distribution AI collects, uses, stores and protects your personal data and generated content — and the rights you have over it.";
export const metadata: Metadata = pageMetadata({
title: "Privacy Policy",
description: DESCRIPTION,
path: PATH,
});
const UPDATED = "June 7, 2026";
@@ -102,6 +112,8 @@ export default function PrivacyPage() {
updated={UPDATED}
intro="This Privacy Policy explains what information Podcast Distribution AI collects, how we use it, who we share it with, and the choices you have. It applies to your use of the Podcast Distribution AI website and application."
sections={SECTIONS}
path={PATH}
description={DESCRIPTION}
/>
);
}
+13 -1
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next";
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { title: "Refund & Cancellation Policy" };
/** Shared by the page metadata and the document's structured data. */
const PATH = "/refunds";
const DESCRIPTION =
"How subscriptions, renewals, cancellations and refunds work at Podcast Distribution AI, including statutory withdrawal rights and billing disputes.";
export const metadata: Metadata = pageMetadata({
title: "Refund & Cancellation Policy",
description: DESCRIPTION,
path: PATH,
});
const UPDATED = "June 7, 2026";
@@ -61,6 +71,8 @@ export default function RefundsPage() {
updated={UPDATED}
intro="This policy explains how billing, renewals, cancellations, and refunds work for Podcast Distribution AI subscriptions. It forms part of our Terms of Service."
sections={SECTIONS}
path={PATH}
description={DESCRIPTION}
/>
);
}
+13 -1
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next";
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { title: "Subprocessors" };
/** Shared by the page metadata and the document's structured data. */
const PATH = "/subprocessors";
const DESCRIPTION =
"The third-party providers that process data on behalf of Podcast Distribution AI — AI generation, payments, transactional email and hosting.";
export const metadata: Metadata = pageMetadata({
title: "Subprocessors",
description: DESCRIPTION,
path: PATH,
});
const UPDATED = "June 7, 2026";
@@ -60,6 +70,8 @@ export default function SubprocessorsPage() {
updated={UPDATED}
intro="This page lists the third-party providers Podcast Distribution AI relies on to deliver the service and the data each one processes. It supports our Privacy Policy and is provided for transparency."
sections={SECTIONS}
path={PATH}
description={DESCRIPTION}
/>
);
}
+13 -1
View File
@@ -1,7 +1,17 @@
import type { Metadata } from "next";
import { LegalDoc, type LegalSection } from "@/components/marketing/legal-doc";
import { pageMetadata } from "@/lib/seo";
export const metadata: Metadata = { title: "Terms of Service" };
/** Shared by the page metadata and the document's structured data. */
const PATH = "/terms";
const DESCRIPTION =
"The Terms of Service governing your use of Podcast Distribution AI — accounts, plans and billing, acceptable use, content ownership, warranties and liability.";
export const metadata: Metadata = pageMetadata({
title: "Terms of Service",
description: DESCRIPTION,
path: PATH,
});
const UPDATED = "June 7, 2026";
@@ -101,6 +111,8 @@ export default function TermsPage() {
updated={UPDATED}
intro="These Terms of Service govern your access to and use of Podcast Distribution AI. Please read them carefully — they include important information about your rights, billing, acceptable use, and the limits of our liability."
sections={SECTIONS}
path={PATH}
description={DESCRIPTION}
/>
);
}