diff --git a/.dockerignore b/.dockerignore index 02b98be..c7e8ccb 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,7 @@ Dockerfile .dockerignore README.md deploy + +# database dumps / backups — must never enter the image (contain user PII + password hashes) +*.sql +!prisma/migrations/**/*.sql diff --git a/.env.example b/.env.example index e22f3b7..2ce2759 100644 --- a/.env.example +++ b/.env.example @@ -5,12 +5,43 @@ DATABASE_URL="postgresql://user:password@host:5432/podcast-distribution-ai?schem # Better Auth — generate a strong secret: `openssl rand -base64 32` BETTER_AUTH_SECRET="change-me" BETTER_AUTH_URL="http://localhost:3000" +# The canonical public origin. Also the SEO source of truth: canonical tags, the +# sitemap, robots.txt and Open Graph URLs are all built from it, so it must be the +# exact production origin (https, correct www/non-www, no trailing slash). +# It is a NEXT_PUBLIC_* var, so it is inlined at BUILD time — set it as a build arg +# too (see Dockerfile). A production build fails fast if it is missing, rather than +# shipping canonicals that point at localhost. NEXT_PUBLIC_APP_URL="http://localhost:3000" +# ─────────────────────────── SEO / search consoles ────────── +# Optional site-ownership tokens. Leave empty to omit the verification meta tags. +NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION="" +NEXT_PUBLIC_BING_SITE_VERIFICATION="" + +# ─────────────────────────── Analytics (Umami) ────────────── +# Self-hosted Umami. Leave either value empty to disable analytics entirely +# (the default in development) — no script is rendered and no rewrite is added. +# The tracker is proxied through this app at /_a, so the CSP stays untouched and +# content blockers leave it alone; nothing needs allowlisting. +# Both are read at BUILD time by statically rendered pages, so they must also be +# passed as build args (see Dockerfile). +UMAMI_HOST_URL="https://fickanalytics.phluit.net" +UMAMI_WEBSITE_ID="98f83e69-cea1-435d-a002-6facea09764a" + # ─────────────────────────── OAuth ────────────────────────── GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" +# ─────────────────────────── Bot protection ───────────────── +# Cloudflare Turnstile guards the sign-in, sign-up and password-reset endpoints. +# Create a widget at https://dash.cloudflare.com → Turnstile. +# REQUIRED in production — the app refuses to boot without the secret key. +# Leave both blank in dev to skip the challenge entirely. +# The site key is read at RUNTIME (server-rendered into the page), so it does not +# need to be a Docker build arg. +NEXT_PUBLIC_TURNSTILE_SITE_KEY="" +TURNSTILE_SECRET_KEY="" + # ─────────────────────────── AI providers ─────────────────── OPENAI_API_KEY="" ELEVENLABS_API_KEY="" diff --git a/.gitignore b/.gitignore index 9b9aea0..6cfd421 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ yarn-error.log* .env .env*.local .env.production +.env.*.bak # editor / os .vscode/* @@ -39,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo + +# database dumps / backups — never commit (contain user PII + password hashes) +*.sql +!prisma/migrations/**/*.sql diff --git a/Dockerfile b/Dockerfile index be37a07..03f823b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Includes ffmpeg (audio stitching) + the full node_modules so the worker can run # via tsx and `prisma migrate deploy` can run on web startup. -FROM node:20-bookworm-slim AS base +FROM node:22-bookworm-slim AS base RUN apt-get update \ && apt-get install -y --no-install-recommends ffmpeg openssl ca-certificates \ && rm -rf /var/lib/apt/lists/* @@ -23,13 +23,31 @@ COPY . . # provided as build args (Dokploy passes them from the env — see docker-compose.yml). ARG NEXT_PUBLIC_APP_URL ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ARG NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION +ARG NEXT_PUBLIC_BING_SITE_VERIFICATION +ARG UMAMI_HOST_URL +ARG UMAMI_WEBSITE_ID +ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=$NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY +ENV NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION=$NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION +ENV NEXT_PUBLIC_BING_SITE_VERIFICATION=$NEXT_PUBLIC_BING_SITE_VERIFICATION +# Not NEXT_PUBLIC_, but still needed at build time: the root layout is statically +# rendered, so the website id is baked into the prerendered HTML. +ENV UMAMI_HOST_URL=$UMAMI_HOST_URL +ENV UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID +ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY # A throwaway BETTER_AUTH_SECRET, scoped to THIS command only (not a persisted ENV # layer), satisfies the prod-secret guard in lib/auth/auth.ts during `next build`. # Must be >= 32 chars (and not a known placeholder) to pass that guard; the real # secret is injected at run time and is never baked into the bundle. -RUN BETTER_AUTH_SECRET=build-time-placeholder-not-a-real-secret npm run build +# TURNSTILE_SECRET_KEY gets the same treatment for the Turnstile guard in +# lib/auth/auth.ts: a throwaway value satisfies it during `next build`, while the +# real secret is injected at run time and never baked into the image. (The SITE +# key is not a secret and is a real build arg above.) +RUN BETTER_AUTH_SECRET=build-time-placeholder-not-a-real-secret \ + TURNSTILE_SECRET_KEY=build-time-placeholder-not-a-real-secret \ + npm run build # ---- runtime ---- FROM base AS runner @@ -46,6 +64,10 @@ COPY --from=build /app ./ # unaffected: `next start` serves prebuilt output and never relies on the throw. RUN cp node_modules/server-only/empty.js node_modules/server-only/index.js RUN mkdir -p /app/storage/mp3 /app/storage/art /app/storage/exports +# Drop root. `node` (uid 1000) ships with the official image. /app/storage is the +# only path written at run time, so it (and the Next.js cache) must be owned by it. +RUN chown -R node:node /app/storage /app/.next +USER node EXPOSE 3000 # Default = web; the worker service overrides this command in docker-compose.yml. CMD ["npm", "run", "start"] diff --git a/app/(admin)/admin/actions.ts b/app/(admin)/admin/actions.ts index 79c33bb..bd32254 100644 --- a/app/(admin)/admin/actions.ts +++ b/app/(admin)/admin/actions.ts @@ -55,6 +55,123 @@ export async function setRoleAction(userId: string, role: "admin" | "user"): Pro return { ok: true }; } +/** + * Permanently delete a user and everything they own (GDPR erasure request). + * + * Guards mirror the ban/demote ones: an admin cannot delete themselves, and + * cannot delete another admin — demote them first, so removing a privileged + * account is always a deliberate two-step action. + * + * The User row cascades to sessions, accounts, episodes, scripts, media rows, + * API keys, usage and memberships (see onDelete: Cascade in schema.prisma). + * Generated MP3/PNG files on disk are NOT removed here — see the note below. + */ +export async function deleteUserAction(userId: string): Promise { + const s = await adminSession(); + if (!s) return { ok: false, error: "Not allowed." }; + if (userId === s.user.id) return { ok: false, error: "You can't delete your own account here." }; + + const target = await prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, email: true, role: true }, + }); + if (!target) return { ok: false, error: "User not found." }; + if (target.role === "admin") { + return { ok: false, error: "Demote this admin before deleting the account." }; + } + + // Audit BEFORE the delete: the row references the actor, not the target, so it + // survives the cascade — but writing it first means a failed delete still + // leaves a record of the attempt. + await audit(s.user.id, "user.delete", target.id, { email: target.email }); + await prisma.user.delete({ where: { id: target.id } }); + + revalidatePath("/admin/users"); + return { ok: true }; +} + +/** + * Export everything held about one user, for a GDPR access request an admin is + * fulfilling on their behalf. Mirrors the self-serve export in + * app/(app)/settings/actions.ts and likewise excludes credentials. + */ +export async function exportUserDataAction( + userId: string +): Promise<{ ok: boolean; error?: string; json?: string }> { + const s = await adminSession(); + if (!s) return { ok: false, error: "Not allowed." }; + + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + emailVerified: true, + createdAt: true, + role: true, + banned: true, + }, + }); + if (!user) return { ok: false, error: "User not found." }; + + const [preferences, episodes, series, subscriptions, usage, apiKeys, memberships] = + await Promise.all([ + prisma.userPreferences.findUnique({ where: { userId } }), + prisma.episode.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + title: true, + topic: true, + status: true, + language: true, + shareId: true, + createdAt: true, + script: { select: { content: true } }, + audioAsset: { select: { storageKey: true, durationSec: true } }, + coverArt: { select: { storageKey: true } }, + repurposed: { select: { type: true, content: true, createdAt: true } }, + }, + }), + prisma.series.findMany({ where: { userId } }), + prisma.subscription.findMany({ where: { referenceId: userId } }), + prisma.usageRecord.findMany({ where: { ownerId: userId, ownerType: "user" } }), + prisma.apiKey.findMany({ + where: { userId }, + select: { id: true, name: true, prefix: true, createdAt: true, revokedAt: true }, + }), + prisma.member.findMany({ + where: { userId }, + select: { role: true, createdAt: true, organization: { select: { id: true, name: true } } }, + }), + ]); + + await audit(s.user.id, "user.export", userId, { email: user.email }); + + return { + ok: true, + json: JSON.stringify( + { + exportedAt: new Date().toISOString(), + exportedBy: s.user.email, + note: "Credentials and authentication tokens are intentionally excluded. Media is referenced by storage key.", + user, + preferences, + episodes, + series, + subscriptions, + usage, + apiKeys, + memberships, + }, + null, + 2 + ), + }; +} + export async function toggleFeatureFlagAction( key: string, enabled: boolean @@ -68,14 +185,70 @@ export async function toggleFeatureFlagAction( return { ok: true }; } +/** + * Resolve a content flag. + * + * "reviewed" clears the flag and leaves the episode alone. "removed" is a real + * takedown: it stamps Episode.moderatedAt and clears shareId, which together + * block the public page, the media routes, export and re-sharing (all of them + * check moderatedAt). Previously this only changed the flag row, so a + * destructive-looking "Remove" left violating audio publicly streamable. + */ export async function reviewContentFlagAction( flagId: string, status: "reviewed" | "removed" ): Promise<{ ok: boolean; error?: string }> { const s = await adminSession(); if (!s) return { ok: false, error: "Not allowed." }; - await prisma.contentFlag.update({ where: { id: flagId }, data: { status, reviewedBy: s.user.id } }); - await audit(s.user.id, "content.review", flagId, { status }); + + const parsedStatus = z.enum(["reviewed", "removed"]).safeParse(status); + if (!parsedStatus.success) return { ok: false, error: "Invalid status." }; + + const flag = await prisma.contentFlag.findUnique({ + where: { id: flagId }, + select: { id: true, episodeId: true }, + }); + if (!flag) return { ok: false, error: "Flag not found." }; + + await prisma.$transaction(async (tx) => { + await tx.contentFlag.update({ + where: { id: flag.id }, + data: { status: parsedStatus.data, reviewedBy: s.user.id }, + }); + if (parsedStatus.data === "removed") { + await tx.episode.update({ + where: { id: flag.episodeId }, + data: { moderatedAt: new Date(), moderatedBy: s.user.id, shareId: null, sharedAt: null }, + }); + } + }); + + await audit(s.user.id, "content.review", flagId, { + status: parsedStatus.data, + episodeId: flag.episodeId, + }); + revalidatePath("/admin/moderation"); + return { ok: true }; +} + +/** Reinstate an episode taken down in error. */ +export async function restoreEpisodeAction(episodeId: string): Promise { + const s = await adminSession(); + if (!s) return { ok: false, error: "Not allowed." }; + + const episode = await prisma.episode.findUnique({ + where: { id: episodeId }, + select: { id: true, moderatedAt: true }, + }); + if (!episode) return { ok: false, error: "Episode not found." }; + if (!episode.moderatedAt) return { ok: false, error: "That episode is not removed." }; + + // shareId is deliberately NOT restored — the owner re-shares if they want to. + await prisma.episode.update({ + where: { id: episode.id }, + data: { moderatedAt: null, moderatedBy: null }, + }); + await audit(s.user.id, "content.restore", episodeId); revalidatePath("/admin/moderation"); return { ok: true }; } diff --git a/app/(admin)/admin/ai-usage/page.tsx b/app/(admin)/admin/ai-usage/page.tsx index 2707c83..c2fcea3 100644 --- a/app/(admin)/admin/ai-usage/page.tsx +++ b/app/(admin)/admin/ai-usage/page.tsx @@ -10,6 +10,7 @@ import { BarSeries } from "@/components/admin/ui/charts"; import { RangePicker } from "@/components/admin/ui/table-controls"; import { DataTable, type Column } from "@/components/admin/ui/data-table"; import { CHART } from "@/components/admin/ui/chart-theme"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · AI usage" }; @@ -20,6 +21,11 @@ export default async function AdminAiUsagePage({ }: { searchParams: Promise<{ range?: string }>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const range = parseRange((await searchParams).range); const [breakdown, series] = await Promise.all([getCostBreakdown(range), getAiCostSeries(range)]); const usd = (n: number) => `$${n.toFixed(2)}`; diff --git a/app/(admin)/admin/audit/page.tsx b/app/(admin)/admin/audit/page.tsx index 69da8b2..608e623 100644 --- a/app/(admin)/admin/audit/page.tsx +++ b/app/(admin)/admin/audit/page.tsx @@ -6,6 +6,7 @@ import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/tab import { AuditExport } from "@/components/admin/audit-export"; import { AuditMetaViewer } from "@/components/admin/audit-meta-viewer"; import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Audit log" }; @@ -16,6 +17,11 @@ export default async function AdminAuditPage({ }: { searchParams: Promise>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const sp = await searchParams; const page = Math.max(1, Number(sp.page ?? "1")); const [{ rows, total }, actions] = await Promise.all([ diff --git a/app/(admin)/admin/flags/page.tsx b/app/(admin)/admin/flags/page.tsx index 57fa9b2..56094d0 100644 --- a/app/(admin)/admin/flags/page.tsx +++ b/app/(admin)/admin/flags/page.tsx @@ -2,10 +2,16 @@ import type { Metadata } from "next"; import { PageHeader } from "@/components/app/page-header"; import { FlagsClient } from "@/components/admin/flags-client"; import { getAdminFlags } from "@/lib/admin/flags"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Feature flags" }; export default async function AdminFlagsPage() { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const flags = await getAdminFlags(); const serialized = flags.map((f) => ({ ...f, diff --git a/app/(admin)/admin/health/page.tsx b/app/(admin)/admin/health/page.tsx index ee020f1..39b2059 100644 --- a/app/(admin)/admin/health/page.tsx +++ b/app/(admin)/admin/health/page.tsx @@ -9,10 +9,16 @@ import { ChartCard } from "@/components/admin/ui/chart-card"; import { AutoRefresh } from "@/components/admin/ui/auto-refresh"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · System health" }; export default async function AdminHealthPage() { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const t0 = Date.now(); await prisma.$queryRawUnsafe("SELECT 1"); const dbMs = Date.now() - t0; diff --git a/app/(admin)/admin/jobs/page.tsx b/app/(admin)/admin/jobs/page.tsx index 2949021..ed94acb 100644 --- a/app/(admin)/admin/jobs/page.tsx +++ b/app/(admin)/admin/jobs/page.tsx @@ -8,6 +8,7 @@ import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls"; import { JobRowActions } from "@/components/admin/job-row-actions"; import { Badge, type BadgeProps } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Jobs" }; @@ -30,6 +31,11 @@ export default async function AdminJobsPage({ }: { searchParams: Promise>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const sp = await searchParams; const page = Math.max(1, Number(sp.page ?? "1")); const [{ rows, total }, counts] = await Promise.all([ diff --git a/app/(admin)/admin/moderation/page.tsx b/app/(admin)/admin/moderation/page.tsx index 81545a2..5e3d7f0 100644 --- a/app/(admin)/admin/moderation/page.tsx +++ b/app/(admin)/admin/moderation/page.tsx @@ -1,11 +1,13 @@ import type { Metadata } from "next"; import Link from "next/link"; import { ShieldCheck } from "lucide-react"; -import { getModerationQueue } from "@/lib/admin/ops"; +import { getModerationQueue, MODERATION_PAGE_SIZE } from "@/lib/admin/ops"; +import { Pagination } from "@/components/admin/ui/table-controls"; import { PageHeader } from "@/components/app/page-header"; import { Card, CardContent } from "@/components/ui/card"; import { Badge, type BadgeProps } from "@/components/ui/badge"; import { ModerationActions } from "@/components/admin/moderation-actions"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Moderation" }; @@ -15,14 +17,25 @@ const SEVERITY: Record = { low: "secondary", }; -export default async function AdminModerationPage() { - const flags = await getModerationQueue(); +export default async function AdminModerationPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + + const sp = await searchParams; + const page = Math.max(1, Number(sp.page ?? "1")); + const { rows: flags, total } = await getModerationQueue({ page }); return ( <> {flags.length === 0 ? ( @@ -55,6 +68,7 @@ export default async function AdminModerationPage() { ))} + )} diff --git a/app/(admin)/admin/organizations/[id]/page.tsx b/app/(admin)/admin/organizations/[id]/page.tsx new file mode 100644 index 0000000..baa6011 --- /dev/null +++ b/app/(admin)/admin/organizations/[id]/page.tsx @@ -0,0 +1,151 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Building2, Users, Mic, Palette } from "lucide-react"; +import { getOrgDetail } from "@/lib/admin/orgs"; +import { PageHeader } from "@/components/app/page-header"; +import { StatCard } from "@/components/admin/ui/stat-card"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; + +export const metadata: Metadata = { title: "Admin · Organization" }; + +export default async function AdminOrgDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + await requireAdmin(); + + const { id } = await params; + const org = await getOrgDetail(id); + if (!org) notFound(); + + const overCapacity = org.members.length > org.seats; + + return ( + <> + + +
+ + + + +
+ +
+ + + Members + + + {org.members.length === 0 ? ( +

No members.

+ ) : ( +
+ {org.members.map((m) => ( +
+
+ + {m.name} + +

{m.email}

+
+ {m.banned ? banned : null} + + {m.role} + +
+ ))} +
+ )} + + {org.invitations.length > 0 ? ( +
+

+ Pending invitations ({org.invitations.length}) — each holds a seat +

+
+ {org.invitations.map((i) => ( +
+ {i.email} + + expires {i.expiresAt.toLocaleDateString()} + +
+ ))} +
+
+ ) : null} +
+
+ + + + Recent episodes + + + {org.recentEpisodes.length === 0 ? ( +

No episodes billed to this workspace yet.

+ ) : ( +
+ {org.recentEpisodes.map((e) => ( +
+
+

{e.title}

+

+ {e.createdAt.toLocaleDateString()} +

+
+ {e.status} +
+ ))} +
+ )} +
+
+
+ + {org.branding ? ( + + + Branding + + + + + + + + + ) : null} + + ); +} + +function Field({ label, value }: { label: string; value: string | null }) { + return ( +
+

{label}

+

{value ?? "—"}

+
+ ); +} diff --git a/app/(admin)/admin/organizations/page.tsx b/app/(admin)/admin/organizations/page.tsx new file mode 100644 index 0000000..d3b3bc9 --- /dev/null +++ b/app/(admin)/admin/organizations/page.tsx @@ -0,0 +1,109 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { listOrganizations, ORGS_PAGE_SIZE, type AdminOrgRow } from "@/lib/admin/orgs"; +import { PageHeader } from "@/components/app/page-header"; +import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table"; +import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls"; +import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; + +export const metadata: Metadata = { title: "Admin · Organizations" }; + +export default async function AdminOrganizationsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + await requireAdmin(); + + const sp = await searchParams; + const page = Math.max(1, Number(sp.page ?? "1")); + const { rows, total } = await listOrganizations({ + search: sp.q, + plan: sp.plan, + sort: sp.sort, + page, + }); + + const columns: Column[] = [ + { + key: "org", + header: "Organization", + sortKey: "name", + cell: (o) => ( +
+ + {o.name} + +

{o.slug ?? o.id}

+
+ ), + }, + { key: "plan", header: "Plan", cell: (o) => {o.plan} }, + { + key: "seats", + header: "Seats", + cell: (o) => ( + o.seats ? "font-medium text-destructive" : undefined}> + {o.memberCount} / {o.seats} + + ), + }, + { + key: "status", + header: "Status", + cell: (o) => + o.status === "active" || o.status === "trialing" ? ( + {o.status} + ) : o.status ? ( + {o.status} + ) : ( + none + ), + }, + { + key: "whiteLabel", + header: "White-label", + cell: (o) => + o.whiteLabel ? on : , + }, + { + key: "createdAt", + header: "Created", + sortKey: "createdAt", + cell: (o) => {o.createdAt.toLocaleDateString()}, + }, + ]; + + return ( + <> + + + + + + o.id} + empty="No organizations match your filters." + /> +
+ +
+ + ); +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx index 304c77d..e443fdd 100644 --- a/app/(admin)/admin/page.tsx +++ b/app/(admin)/admin/page.tsx @@ -11,6 +11,7 @@ import { ChartCard } from "@/components/admin/ui/chart-card"; import { BarSeries, Donut } from "@/components/admin/ui/charts"; import { RangePicker } from "@/components/admin/ui/table-controls"; import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Overview" }; @@ -19,6 +20,11 @@ export default async function AdminOverviewPage({ }: { searchParams: Promise<{ range?: string }>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const range = parseRange((await searchParams).range); const [m, signups, revenue] = await Promise.all([ getOverview(range), diff --git a/app/(admin)/admin/revenue/page.tsx b/app/(admin)/admin/revenue/page.tsx index f078fbb..8a73460 100644 --- a/app/(admin)/admin/revenue/page.tsx +++ b/app/(admin)/admin/revenue/page.tsx @@ -13,6 +13,7 @@ import { RangePicker } from "@/components/admin/ui/table-controls"; import { DataTable, type Column } from "@/components/admin/ui/data-table"; import { CHART, TIER_COLORS } from "@/components/admin/ui/chart-theme"; import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Revenue" }; @@ -23,6 +24,11 @@ export default async function AdminRevenuePage({ }: { searchParams: Promise<{ range?: string }>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const range = parseRange((await searchParams).range); const [m, revenue, extras] = await Promise.all([ getOverview(range), diff --git a/app/(admin)/admin/settings/page.tsx b/app/(admin)/admin/settings/page.tsx index 674244c..db20ac1 100644 --- a/app/(admin)/admin/settings/page.tsx +++ b/app/(admin)/admin/settings/page.tsx @@ -4,6 +4,7 @@ import { prisma } from "@/lib/db"; import { PLANS, PLAN_ORDER, type PlanKey, type PlanLimits } from "@/lib/billing/plans"; import { PageHeader } from "@/components/app/page-header"; import { PlanEditor, type EditablePlan, type PlanLimitsValue } from "@/components/admin/plan-editor"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Settings" }; @@ -25,6 +26,11 @@ function coerceLimits(raw: unknown, fallback: PlanLimits): PlanLimitsValue { } export default async function AdminSettingsPage() { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const dbPlans = await prisma.plan.findMany(); const byKey = new Map(dbPlans.map((p) => [p.key, p])); diff --git a/app/(admin)/admin/subscriptions/page.tsx b/app/(admin)/admin/subscriptions/page.tsx index 1252b57..3b43cf4 100644 --- a/app/(admin)/admin/subscriptions/page.tsx +++ b/app/(admin)/admin/subscriptions/page.tsx @@ -9,6 +9,7 @@ import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls"; import { SubscriptionRowActions } from "@/components/admin/subscription-row-actions"; import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Subscriptions" }; @@ -17,6 +18,11 @@ export default async function AdminSubscriptionsPage({ }: { searchParams: Promise>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const sp = await searchParams; const page = Math.max(1, Number(sp.page ?? "1")); const [m, { rows, total }] = await Promise.all([ diff --git a/app/(admin)/admin/users/[id]/page.tsx b/app/(admin)/admin/users/[id]/page.tsx index 630f8ce..b8e3d4c 100644 --- a/app/(admin)/admin/users/[id]/page.tsx +++ b/app/(admin)/admin/users/[id]/page.tsx @@ -13,6 +13,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · User detail" }; @@ -42,6 +43,11 @@ export default async function AdminUserDetailPage({ }: { params: Promise<{ id: string }>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const { id } = await params; const detail = await getUserDetail(id); if (!detail) notFound(); diff --git a/app/(admin)/admin/users/page.tsx b/app/(admin)/admin/users/page.tsx index a09ec53..b99a314 100644 --- a/app/(admin)/admin/users/page.tsx +++ b/app/(admin)/admin/users/page.tsx @@ -5,6 +5,7 @@ import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data import { SearchInput, FilterSelect, Pagination } from "@/components/admin/ui/table-controls"; import { UserRowActions } from "@/components/admin/user-row-actions"; import { Badge } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Users" }; @@ -13,6 +14,11 @@ export default async function AdminUsersPage({ }: { searchParams: Promise>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const sp = await searchParams; const page = Math.max(1, Number(sp.page ?? "1")); const { rows, total } = await listUsers({ diff --git a/app/(admin)/admin/webhooks/page.tsx b/app/(admin)/admin/webhooks/page.tsx index 352b3cb..419055f 100644 --- a/app/(admin)/admin/webhooks/page.tsx +++ b/app/(admin)/admin/webhooks/page.tsx @@ -6,6 +6,7 @@ import { StatCard } from "@/components/admin/ui/stat-card"; import { DataTable, TableToolbar, type Column } from "@/components/admin/ui/data-table"; import { FilterSelect, Pagination } from "@/components/admin/ui/table-controls"; import { Badge, type BadgeProps } from "@/components/ui/badge"; +import { requireAdmin } from "@/lib/auth/guards"; export const metadata: Metadata = { title: "Admin · Webhooks" }; @@ -22,6 +23,11 @@ export default async function AdminWebhooksPage({ }: { searchParams: Promise>; }) { + // Defence in depth: the (admin) layout also guards, but a layout is not an + // authorization boundary — pages render concurrently with it, and a route moved + // out of the group would silently lose its only check. + await requireAdmin(); + const sp = await searchParams; const page = Math.max(1, Number(sp.page ?? "1")); const { rows, total, recentFailures, recentTotal } = await listWebhookEvents({ diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index fd0dc0d..e0d716a 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; import { requireAdmin } from "@/lib/auth/guards"; @@ -6,10 +7,15 @@ import { AdminMobileNav } from "@/components/admin/admin-mobile-nav"; import { UserMenu } from "@/components/app/user-menu"; import { Button } from "@/components/ui/button"; import { Logo } from "@/components/ui/logo"; +import { NO_INDEX } from "@/lib/seo"; // Authed, DB-backed admin surface — never statically prerender. export const dynamic = "force-dynamic"; +// Authenticated surface — never indexable. Metadata merges down, so every route +// in this group inherits `noindex, nofollow` unless it explicitly overrides it. +export const metadata: Metadata = NO_INDEX; + export default async function AdminLayout({ children }: { children: React.ReactNode }) { const session = await requireAdmin(); diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index 9f740fe..3e48e10 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { Mic2, Plus, Sparkles, ArrowRight, Mic, Gauge, Crown, Infinity as InfinityIcon } from "lucide-react"; import { requireAuth } from "@/lib/auth/guards"; @@ -21,6 +22,8 @@ const METRIC_LABELS: Record = { repurpose: "Repurposed content", }; +export const metadata: Metadata = { title: "Dashboard" }; + export default async function DashboardPage() { const session = await requireAuth(); const { plan, key, subjectId } = await getEffectivePlan( diff --git a/app/(app)/episodes/[id]/page.tsx b/app/(app)/episodes/[id]/page.tsx index f4e96fd..b0c4710 100644 --- a/app/(app)/episodes/[id]/page.tsx +++ b/app/(app)/episodes/[id]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; import { Mic2, Repeat } from "lucide-react"; @@ -12,6 +13,17 @@ import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import type { StructuredScript } from "@/lib/ai/types"; +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await params; + // Title only — this is an authed page and the route group is already noindex. + const episode = await prisma.episode.findUnique({ where: { id }, select: { title: true } }); + return { title: episode?.title ?? "Episode" }; +} + export default async function EpisodePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const session = await requireAuth(); @@ -33,12 +45,24 @@ export default async function EpisodePage({ params }: { params: Promise<{ id: st title={episode.title} description={`${episode.format.replace("_", "-").toLowerCase()} · ${episode.language.toUpperCase()} · ${episode.targetLengthMin} min`} action={ - !inProgress ? ( + !inProgress && !episode.moderatedAt ? ( ) : undefined } /> + {episode.moderatedAt ? ( +
+

This episode was removed

+

+ It was reviewed against our Acceptable Use Policy and taken down on{" "} + {episode.moderatedAt.toLocaleDateString()}. Its public link and downloads are + disabled. If you think this was a mistake, reply to your support thread and we + will take another look. +

+
+ ) : null} + {episode.status === "FAILED" || inProgress ? ( }) { const { id } = await params; const session = await requireAuth(); diff --git a/app/(app)/episodes/actions.ts b/app/(app)/episodes/actions.ts index a817571..e553ada 100644 --- a/app/(app)/episodes/actions.ts +++ b/app/(app)/episodes/actions.ts @@ -432,13 +432,20 @@ export async function setEpisodeShareAction( const episode = await prisma.episode.findUnique({ where: { id: episodeId }, - select: { userId: true, shareId: true, status: true }, + select: { userId: true, shareId: true, status: true, moderatedAt: true }, }); if (!episode || (episode.userId !== session.user.id && session.user.role !== "admin")) { return { ok: false, error: "Not allowed." }; } if (enabled) { + // An admin takedown must not be reversible by the owner simply re-sharing. + if (episode.moderatedAt) { + return { + ok: false, + error: "This episode was removed for a policy violation and can't be shared.", + }; + } if (episode.status !== "READY") { return { ok: false, error: "Finish generating the episode before sharing it." }; } diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index 01e0327..8dbaa9c 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { Plus, Wrench } from "lucide-react"; import { requireAuth } from "@/lib/auth/guards"; @@ -12,10 +13,15 @@ import { ImpersonationBanner } from "@/components/app/impersonation-banner"; import { ThemeProvider } from "@/components/providers/theme-provider"; import { Button } from "@/components/ui/button"; import { Logo } from "@/components/ui/logo"; +import { NO_INDEX } from "@/lib/seo"; // Authed, DB-backed dashboard — never statically prerender. export const dynamic = "force-dynamic"; +// Authenticated surface — never indexable. Metadata merges down, so every route +// in this group inherits `noindex, nofollow` unless it explicitly overrides it. +export const metadata: Metadata = NO_INDEX; + export default async function AppLayout({ children }: { children: React.ReactNode }) { const session = await requireAuth(); const activeOrgId = session.session.activeOrganizationId; diff --git a/app/(app)/series/[id]/page.tsx b/app/(app)/series/[id]/page.tsx index 16e29ba..dc31168 100644 --- a/app/(app)/series/[id]/page.tsx +++ b/app/(app)/series/[id]/page.tsx @@ -1,3 +1,4 @@ +import type { Metadata } from "next"; import { notFound } from "next/navigation"; import Link from "next/link"; import { ArrowLeft } from "lucide-react"; @@ -9,6 +10,17 @@ import { EpisodeStatusBadge } from "@/components/app/episode-status-badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +export async function generateMetadata({ + params, +}: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await params; + // Title only — this is an authed page and the route group is already noindex. + const series = await prisma.series.findUnique({ where: { id }, select: { title: true } }); + return { title: series?.title ?? "Series" }; +} + export default async function SeriesDetailPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const session = await requireAuth(); diff --git a/app/(app)/series/actions.ts b/app/(app)/series/actions.ts index 495f4f8..6fff0f3 100644 --- a/app/(app)/series/actions.ts +++ b/app/(app)/series/actions.ts @@ -13,6 +13,7 @@ import type { UsageMetric } from "@/lib/billing/plans"; import { FORMAT_SPEAKERS } from "@/lib/episodes/options"; import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices"; import { isFlagEnabled } from "@/lib/flags"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; const createSchema = z.object({ theme: z.string().min(5).max(500), @@ -30,6 +31,16 @@ export async function createSeriesAction( if (!(await subjectHasFeature(session.user.id, "series_generator", session.session.activeOrganizationId))) { return { ok: false, error: "The series generator requires the Pro plan." }; } + + // planSeason() below is an uncapped GPT-4o completion that is NOT metered + // against a monthly quota (unlike script/audio/art), so the rate limit is the + // only thing bounding AI spend here. Keep it tight — a season plan is a rare, + // deliberate action, so an hourly bucket is the right shape. + const rl = await rateLimit("series-plan", session.user.id, LIMITS.seriesPlan); + if (!rl.ok) { + return { ok: false, error: `Too many series plans. Try again in ${Math.ceil(rl.retryAfterSec! / 60)}m.` }; + } + if (!(await isFlagEnabled("episode_generation_enabled"))) { return { ok: false, error: "Generation is temporarily paused. Please try again shortly." }; } @@ -60,6 +71,11 @@ export async function generateFromSeriesAction( const session = await getServerSession(); if (!session) return { ok: false, error: "You must be signed in." }; + const rl = await rateLimit("generation", session.user.id, LIMITS.generation); + if (!rl.ok) { + return { ok: false, error: `Too many requests. Try again in ${rl.retryAfterSec}s.` }; + } + if (!(await isFlagEnabled("episode_generation_enabled"))) { return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." }; } diff --git a/app/(app)/settings/actions.ts b/app/(app)/settings/actions.ts index c112924..f68b869 100644 --- a/app/(app)/settings/actions.ts +++ b/app/(app)/settings/actions.ts @@ -75,6 +75,193 @@ export async function savePreferencesAction( * cascades to sessions, accounts, episodes, series, usage and preferences. The * client signs out after a successful response. */ +export interface ActiveSession { + id: string; + createdAt: string; + expiresAt: string; + ipAddress: string | null; + userAgent: string | null; + current: boolean; +} + +/** + * List the signed-in devices for the current user. + * + * Sessions are read straight from the DB (not the cookie cache) so a revoked + * session disappears immediately rather than lingering for the 60s cache window. + */ +export async function listSessionsAction(): Promise<{ + ok: boolean; + error?: string; + sessions?: ActiveSession[]; +}> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const rows = await prisma.session.findMany({ + where: { userId: session.user.id, expiresAt: { gt: new Date() } }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + token: true, + createdAt: true, + expiresAt: true, + ipAddress: true, + userAgent: true, + }, + }); + + return { + ok: true, + sessions: rows.map((r) => ({ + id: r.id, + createdAt: r.createdAt.toISOString(), + expiresAt: r.expiresAt.toISOString(), + ipAddress: r.ipAddress, + userAgent: r.userAgent, + // Compare on the session token, never on the id: the token is what the + // cookie actually carries, so this is the reliable "this device" marker. + current: r.token === session.session.token, + })), + }; +} + +/** + * Revoke one of the current user's sessions (sign out that device). + * + * Scoped by userId so a session id belonging to someone else can never be + * revoked, and the current session is protected — signing yourself out from + * here would be indistinguishable from a bug. Use the normal sign-out for that. + */ +export async function revokeSessionAction( + sessionId: string +): Promise<{ ok: boolean; error?: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const target = await prisma.session.findFirst({ + where: { id: sessionId, userId: session.user.id }, + select: { id: true, token: true }, + }); + if (!target) return { ok: false, error: "Session not found." }; + if (target.token === session.session.token) { + return { ok: false, error: "That's your current device — use Sign out instead." }; + } + + await prisma.session.delete({ where: { id: target.id } }); + revalidatePath("/settings"); + return { ok: true }; +} + +/** Sign out every other device, keeping the current one. */ +export async function revokeOtherSessionsAction(): Promise<{ + ok: boolean; + error?: string; + count?: number; +}> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + + const res = await prisma.session.deleteMany({ + where: { userId: session.user.id, token: { not: session.session.token } }, + }); + revalidatePath("/settings"); + return { ok: true, count: res.count }; +} + +/** + * Export everything we hold about the current user, as JSON (GDPR access + * request, self-serve). + * + * Deliberately excludes credentials: the `account` table holds password hashes + * and OAuth tokens, and nothing there is user-facing data. Media is referenced + * by storage key rather than inlined so the payload stays a reasonable size. + */ +export async function exportMyDataAction(): Promise<{ + ok: boolean; + error?: string; + json?: string; +}> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + const userId = session.user.id; + + const [user, preferences, episodes, series, subscriptions, usage, apiKeys, memberships] = + await Promise.all([ + prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + name: true, + email: true, + emailVerified: true, + image: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.userPreferences.findUnique({ where: { userId } }), + prisma.episode.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + title: true, + topic: true, + tone: true, + format: true, + language: true, + targetLengthMin: true, + status: true, + shareId: true, + createdAt: true, + script: { select: { content: true } }, + audioAsset: { select: { storageKey: true, durationSec: true, format: true } }, + coverArt: { select: { storageKey: true } }, + repurposed: { select: { type: true, content: true, createdAt: true } }, + }, + }), + prisma.series.findMany({ where: { userId } }), + prisma.subscription.findMany({ + where: { referenceId: userId }, + select: { + plan: true, + status: true, + billingInterval: true, + provider: true, + periodStart: true, + periodEnd: true, + cancelAtPeriodEnd: true, + createdAt: true, + }, + }), + prisma.usageRecord.findMany({ where: { ownerId: userId, ownerType: "user" } }), + prisma.apiKey.findMany({ + where: { userId }, + select: { id: true, name: true, createdAt: true, revokedAt: true }, + }), + prisma.member.findMany({ + where: { userId }, + select: { role: true, createdAt: true, organization: { select: { name: true } } }, + }), + ]); + + const payload = { + exportedAt: new Date().toISOString(), + note: "Credentials and authentication tokens are intentionally excluded. Audio and cover art are referenced by storage key; download them from each episode page.", + user, + preferences, + episodes, + series, + subscriptions, + usage, + apiKeys, + memberships, + }; + + return { ok: true, json: JSON.stringify(payload, null, 2) }; +} + export async function deleteAccountAction( confirmEmail: string ): Promise<{ ok: boolean; error?: string }> { diff --git a/app/(app)/team/actions.ts b/app/(app)/team/actions.ts index 8b7196f..195ab1f 100644 --- a/app/(app)/team/actions.ts +++ b/app/(app)/team/actions.ts @@ -93,6 +93,107 @@ export async function inviteMemberAction( return { ok: true }; } +/** + * Shared authorization for every workspace mutation: the caller must be an + * owner/admin of THIS organization. Returns the caller's role, or an error. + */ +async function requireOrgAdmin( + organizationId: string +): Promise<{ ok: true; userId: string; role: string } | { ok: false; error: string }> { + const session = await getServerSession(); + if (!session) return { ok: false, error: "You must be signed in." }; + const member = await prisma.member.findFirst({ + where: { organizationId, userId: session.user.id }, + select: { role: true }, + }); + if (!member || !["owner", "admin"].includes(member.role)) { + return { ok: false, error: "Only workspace owners can manage members." }; + } + return { ok: true, userId: session.user.id, role: member.role }; +} + +/** + * Remove a member, freeing their seat. + * + * Guards: only owner/admin may remove; nobody may remove themselves (that would + * orphan the workspace from the UI); and the last remaining owner is protected, + * otherwise a workspace can be left with no one able to administer it. + */ +export async function removeMemberAction( + organizationId: string, + memberId: string +): Promise<{ ok: boolean; error?: string }> { + const auth = await requireOrgAdmin(organizationId); + if (!auth.ok) return { ok: false, error: auth.error }; + + const target = await prisma.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, userId: true, role: true }, + }); + if (!target) return { ok: false, error: "Member not found." }; + if (target.userId === auth.userId) { + return { ok: false, error: "You can't remove yourself from the workspace." }; + } + if (target.role === "owner") { + const owners = await prisma.member.count({ where: { organizationId, role: "owner" } }); + if (owners <= 1) return { ok: false, error: "The workspace must keep at least one owner." }; + } + + await prisma.member.delete({ where: { id: target.id } }); + revalidatePath("/team"); + return { ok: true }; +} + +/** Change a member's workspace role. Same last-owner protection as removal. */ +export async function updateMemberRoleAction( + organizationId: string, + memberId: string, + role: "owner" | "admin" | "member" +): Promise<{ ok: boolean; error?: string }> { + const auth = await requireOrgAdmin(organizationId); + if (!auth.ok) return { ok: false, error: auth.error }; + + // `role` is client-supplied and only TS-typed — validate it at runtime. + const parsedRole = z.enum(["owner", "admin", "member"]).safeParse(role); + if (!parsedRole.success) return { ok: false, error: "Invalid role." }; + + const target = await prisma.member.findFirst({ + where: { id: memberId, organizationId }, + select: { id: true, userId: true, role: true }, + }); + if (!target) return { ok: false, error: "Member not found." }; + if (target.role === "owner" && parsedRole.data !== "owner") { + const owners = await prisma.member.count({ where: { organizationId, role: "owner" } }); + if (owners <= 1) return { ok: false, error: "The workspace must keep at least one owner." }; + } + + await prisma.member.update({ where: { id: target.id }, data: { role: parsedRole.data } }); + revalidatePath("/team"); + return { ok: true }; +} + +/** + * Revoke a pending invitation, freeing the seat it was holding. + * Scoped to the organization so an id from another workspace can't be cancelled. + */ +export async function revokeInvitationAction( + organizationId: string, + invitationId: string +): Promise<{ ok: boolean; error?: string }> { + const auth = await requireOrgAdmin(organizationId); + if (!auth.ok) return { ok: false, error: auth.error }; + + const invite = await prisma.invitation.findFirst({ + where: { id: invitationId, organizationId, status: "pending" }, + select: { id: true }, + }); + if (!invite) return { ok: false, error: "Invitation not found." }; + + await prisma.invitation.update({ where: { id: invite.id }, data: { status: "canceled" } }); + revalidatePath("/team"); + return { ok: true }; +} + export async function saveBrandingAction( organizationId: string, data: z.infer diff --git a/app/(app)/team/page.tsx b/app/(app)/team/page.tsx index ed0ba17..181a3f1 100644 --- a/app/(app)/team/page.tsx +++ b/app/(app)/team/page.tsx @@ -43,7 +43,31 @@ export default async function TeamPage() { }); const org = membership?.organization ?? null; const members = - org?.members.map((m) => ({ id: m.id, name: m.user.name, email: m.user.email, role: m.role })) ?? []; + org?.members.map((m) => ({ + id: m.id, + userId: m.userId, + name: m.user.name, + email: m.user.email, + role: m.role, + })) ?? []; + + // Pending invitations hold a seat until accepted or revoked, so they belong on + // this screen next to members — otherwise a workspace can look under-capacity + // while every seat is actually spoken for. + const invitations = org + ? ( + await prisma.invitation.findMany({ + where: { organizationId: org.id, status: "pending" }, + orderBy: { expiresAt: "desc" }, + select: { id: true, email: true, role: true, expiresAt: true }, + }) + ).map((i) => ({ + id: i.id, + email: i.email, + role: i.role ?? "member", + expiresAt: i.expiresAt.toISOString(), + })) + : []; return ( <> @@ -51,6 +75,8 @@ export default async function TeamPage() { ; + return ; } diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx index 8c291f7..5b0718d 100644 --- a/app/(auth)/layout.tsx +++ b/app/(auth)/layout.tsx @@ -1,5 +1,12 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { Logo } from "@/components/ui/logo"; +import { NO_INDEX } from "@/lib/seo"; + +// Sign-in / sign-up / password-reset screens. Thin, duplicate-prone, and the +// middleware appends a ?redirect= query to /sign-in for every gated URL — which +// would otherwise generate unlimited indexable variants of the same page. +export const metadata: Metadata = NO_INDEX; export default function AuthLayout({ children }: { children: React.ReactNode }) { return ( diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx index bd83c9f..350d77f 100644 --- a/app/(auth)/sign-in/page.tsx +++ b/app/(auth)/sign-in/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { redirect } from "next/navigation"; import { getServerSession } from "@/lib/auth/guards"; import { SignInForm } from "@/components/auth/sign-in-form"; +import { getTurnstileSiteKey } from "@/lib/auth/turnstile"; export const metadata: Metadata = { title: "Sign in" }; @@ -12,7 +13,7 @@ export default async function SignInPage() { const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); return ( - + ); } diff --git a/app/(auth)/sign-up/page.tsx b/app/(auth)/sign-up/page.tsx index fb98dda..ab624d9 100644 --- a/app/(auth)/sign-up/page.tsx +++ b/app/(auth)/sign-up/page.tsx @@ -4,6 +4,7 @@ import { redirect } from "next/navigation"; import { getServerSession } from "@/lib/auth/guards"; import { isFlagEnabled } from "@/lib/flags"; import { SignUpForm } from "@/components/auth/sign-up-form"; +import { getTurnstileSiteKey } from "@/lib/auth/turnstile"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; @@ -32,5 +33,5 @@ export default async function SignUpPage() { } const googleEnabled = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); - return ; + return ; } diff --git a/app/(marketing)/about/page.tsx b/app/(marketing)/about/page.tsx index d186507..13bb81c 100644 --- a/app/(marketing)/about/page.tsx +++ b/app/(marketing)/about/page.tsx @@ -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 ( <> + + {/* Hero */}
diff --git a/app/(marketing)/acceptable-use/page.tsx b/app/(marketing)/acceptable-use/page.tsx index e180783..2b0f80b 100644 --- a/app/(marketing)/acceptable-use/page.tsx +++ b/app/(marketing)/acceptable-use/page.tsx @@ -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} /> ); } diff --git a/app/(marketing)/cookies/page.tsx b/app/(marketing)/cookies/page.tsx index 2df3a20..796858a 100644 --- a/app/(marketing)/cookies/page.tsx +++ b/app/(marketing)/cookies/page.tsx @@ -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} /> ); } diff --git a/app/(marketing)/faq/page.tsx b/app/(marketing)/faq/page.tsx index 2018353..059c5fe 100644 --- a/app/(marketing)/faq/page.tsx +++ b/app/(marketing)/faq/page.tsx @@ -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 (
+ category.items)) + )} + />

Support

diff --git a/app/(marketing)/features/page.tsx b/app/(marketing)/features/page.tsx index cdbd43e..d9e6da3 100644 --- a/app/(marketing)/features/page.tsx +++ b/app/(marketing)/features/page.tsx @@ -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 ( <> + + {/* 1 — Hero */}
@@ -79,13 +99,16 @@ export default function FeaturesPage() {
- {/* eslint-disable-next-line @next/next/no-img-element */} - Studio condenser microphone
@@ -534,8 +557,16 @@ function FeatureBand({
{image ? (
- {/* eslint-disable-next-line @next/next/no-img-element */} - {imageAlt + {/* Below the fold — lazy by default, and served in a modern + format at the size the column actually renders at. */} + {imageAlt
) : ( visual diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx index 138d594..227c82e 100644 --- a/app/(marketing)/layout.tsx +++ b/app/(marketing)/layout.tsx @@ -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 (
+ {/* Publisher identity, emitted once for every public marketing page. Pages + add their own nodes (WebPage, BreadcrumbList, FAQPage…) on top. */} +
{children}
diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index de313e2..f1641bc 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -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 ( <> + + {/* Hero */}
diff --git a/app/(marketing)/pricing/page.tsx b/app/(marketing)/pricing/page.tsx index 0d72399..23c3320 100644 --- a/app/(marketing)/pricing/page.tsx +++ b/app/(marketing)/pricing/page.tsx @@ -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 (
+

Pricing

diff --git a/app/(marketing)/privacy/page.tsx b/app/(marketing)/privacy/page.tsx index b973197..233a3fa 100644 --- a/app/(marketing)/privacy/page.tsx +++ b/app/(marketing)/privacy/page.tsx @@ -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} /> ); } diff --git a/app/(marketing)/refunds/page.tsx b/app/(marketing)/refunds/page.tsx index 96efce4..adba8dc 100644 --- a/app/(marketing)/refunds/page.tsx +++ b/app/(marketing)/refunds/page.tsx @@ -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} /> ); } diff --git a/app/(marketing)/subprocessors/page.tsx b/app/(marketing)/subprocessors/page.tsx index 5775223..0e12edb 100644 --- a/app/(marketing)/subprocessors/page.tsx +++ b/app/(marketing)/subprocessors/page.tsx @@ -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} /> ); } diff --git a/app/(marketing)/terms/page.tsx b/app/(marketing)/terms/page.tsx index 8062615..7a33b3a 100644 --- a/app/(marketing)/terms/page.tsx +++ b/app/(marketing)/terms/page.tsx @@ -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} /> ); } diff --git a/app/(public)/p/[shareId]/page.tsx b/app/(public)/p/[shareId]/page.tsx index 0aae091..469e3f4 100644 --- a/app/(public)/p/[shareId]/page.tsx +++ b/app/(public)/p/[shareId]/page.tsx @@ -8,17 +8,28 @@ import { WaveformPlayer } from "@/components/app/waveform-player"; import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Logo } from "@/components/ui/logo"; +import { SITE_NAME, absoluteUrl } from "@/lib/seo"; import type { StructuredScript } from "@/lib/ai/types"; export const dynamic = "force-dynamic"; +/** Trim to `max` characters on a word boundary, for meta descriptions. */ +function truncate(text: string, max: number): string { + const collapsed = text.replace(/\s+/g, " ").trim(); + if (collapsed.length <= max) return collapsed; + const cut = collapsed.slice(0, max - 1); + const lastSpace = cut.lastIndexOf(" "); + return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`; +} + async function loadShared(shareId: string) { const episode = await prisma.episode.findUnique({ where: { shareId }, include: { audioAsset: true, coverArt: true, script: true, speakers: true }, }); // 404 when no episode, sharing disabled, or not finished. - if (!episode || !episode.shareId || episode.status !== "READY") return null; + // moderatedAt = taken down by an admin: treat exactly like a missing share. + if (!episode || !episode.shareId || episode.moderatedAt || episode.status !== "READY") return null; return episode; } @@ -29,11 +40,33 @@ export async function generateMetadata({ }): Promise { const { shareId } = await params; const episode = await loadShared(shareId); - if (!episode) return { title: "Episode not found" }; + if (!episode) return { title: "Episode not found", robots: { index: false, follow: false } }; + + const url = absoluteUrl(`/p/${shareId}`); + const description = truncate(episode.topic, 160); + // Share links are unlisted by design, so they stay out of the index — but they + // are made to be pasted into chat and social, so the unfurl has to be complete. + const cover = episode.coverArt ? absoluteUrl(`/api/public/episodes/${shareId}/cover`) : undefined; + return { title: episode.title, - description: episode.topic.slice(0, 160), - robots: { index: false }, + description, + robots: { index: false, follow: false, nocache: true }, + openGraph: { + type: "article", + title: episode.title, + description, + url, + siteName: SITE_NAME, + ...(cover ? { images: [{ url: cover, alt: `Cover art for ${episode.title}` }] } : {}), + }, + twitter: { + // The cover is square, so the compact card frames it better than a wide one. + card: cover ? "summary" : "summary_large_image", + title: episode.title, + description, + ...(cover ? { images: [cover] } : {}), + }, }; } diff --git a/app/api/assets/[...key]/route.ts b/app/api/assets/[...key]/route.ts index 944b75a..43ce4ed 100644 --- a/app/api/assets/[...key]/route.ts +++ b/app/api/assets/[...key]/route.ts @@ -33,15 +33,25 @@ export async function GET( // Resolve the owning episode from the asset record so we can authorize. const [audio, art] = await Promise.all([ - prisma.audioAsset.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }), - prisma.coverArt.findFirst({ where: { storageKey: key }, select: { episode: { select: { userId: true } } } }), + prisma.audioAsset.findFirst({ + where: { storageKey: key }, + select: { episode: { select: { userId: true, moderatedAt: true } } }, + }), + prisma.coverArt.findFirst({ + where: { storageKey: key }, + select: { episode: { select: { userId: true, moderatedAt: true } } }, + }), ]); - const ownerId = audio?.episode.userId ?? art?.episode.userId; - if (!ownerId) return new Response("Not found", { status: 404 }); + const owningEpisode = audio?.episode ?? art?.episode; + if (!owningEpisode) return new Response("Not found", { status: 404 }); - const isOwner = ownerId === session.user.id; + const isOwner = owningEpisode.userId === session.user.id; const isAdmin = session.user.role === "admin"; if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 }); + // Admins keep access to removed media for appeals; the owner does not. + if (owningEpisode.moderatedAt && !isAdmin) { + return new Response("This episode was removed for a policy violation.", { status: 451 }); + } // Stream off disk instead of buffering the whole file into memory. const total = await storage().size(key); diff --git a/app/api/episodes/[id]/export/route.ts b/app/api/episodes/[id]/export/route.ts index 0324295..905c4a3 100644 --- a/app/api/episodes/[id]/export/route.ts +++ b/app/api/episodes/[id]/export/route.ts @@ -3,6 +3,7 @@ import JSZip from "jszip"; import { getServerSession } from "@/lib/auth/guards"; import { prisma } from "@/lib/db"; import { storage } from "@/lib/storage"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; import type { StructuredScript } from "@/lib/ai/types"; export const dynamic = "force-dynamic"; @@ -25,6 +26,16 @@ export async function GET( const session = await getServerSession(); if (!session) return new Response("Unauthorized", { status: 401 }); + // This handler buffers the full MP3 + cover into a JSZip in memory, so an + // authenticated user looping it is a cheap way to exhaust the heap. + const rl = await rateLimit("export", session.user.id, LIMITS.export); + if (!rl.ok) { + return new Response("Too many exports. Please slow down.", { + status: 429, + headers: { "Retry-After": String(rl.retryAfterSec ?? 60) }, + }); + } + const episode = await prisma.episode.findUnique({ where: { id }, include: { @@ -38,6 +49,11 @@ export async function GET( if (episode.userId !== session.user.id && session.user.role !== "admin") { return new Response("Forbidden", { status: 403 }); } + // Removed content stays downloadable for admins (evidence/appeals) but not + // for the owner, who would otherwise just re-publish it elsewhere. + if (episode.moderatedAt && session.user.role !== "admin") { + return new Response("This episode was removed for a policy violation.", { status: 451 }); + } const speakerNames: Record = {}; for (const s of episode.speakers) speakerNames[s.speakerKey] = s.displayName; diff --git a/app/api/public/episodes/[shareId]/audio/route.ts b/app/api/public/episodes/[shareId]/audio/route.ts index 87a2689..bc8ca28 100644 --- a/app/api/public/episodes/[shareId]/audio/route.ts +++ b/app/api/public/episodes/[shareId]/audio/route.ts @@ -37,7 +37,9 @@ export async function GET( const { shareId } = await params; const episode = await prisma.episode.findUnique({ - where: { shareId }, + // A removed episode must be unreachable even if a share link is still + // circulating; moderatedAt is the takedown marker. + where: { shareId, moderatedAt: null }, select: { audioAsset: { select: { storageKey: true } } }, }); const key = episode?.audioAsset?.storageKey; diff --git a/app/api/public/episodes/[shareId]/cover/route.ts b/app/api/public/episodes/[shareId]/cover/route.ts index df23bab..0e851a3 100644 --- a/app/api/public/episodes/[shareId]/cover/route.ts +++ b/app/api/public/episodes/[shareId]/cover/route.ts @@ -42,7 +42,9 @@ export async function GET( const { shareId } = await params; const episode = await prisma.episode.findUnique({ - where: { shareId }, + // A removed episode must be unreachable even if a share link is still + // circulating; moderatedAt is the takedown marker. + where: { shareId, moderatedAt: null }, select: { coverArt: { select: { storageKey: true } } }, }); const key = episode?.coverArt?.storageKey; diff --git a/app/api/v1/episodes/route.ts b/app/api/v1/episodes/route.ts index 095592a..b6f54e2 100644 --- a/app/api/v1/episodes/route.ts +++ b/app/api/v1/episodes/route.ts @@ -2,7 +2,7 @@ import { NextRequest } from "next/server"; import { z } from "zod"; import { verifyApiKey, bearerKey } from "@/lib/apikeys"; import { prisma } from "@/lib/db"; -import { getEffectivePlan } from "@/lib/billing/subscription"; +import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription"; import { reserveLimit, LimitExceededError } from "@/lib/usage/limits"; import { refundUsage } from "@/lib/usage/meter"; import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; @@ -19,11 +19,30 @@ async function authorize(req: NextRequest) { return verifyApiKey(bearerKey(req.headers.get("authorization"))); } +/** + * Re-check the `api_access` entitlement on every request. + * + * Key CREATION gates on this feature, but a key outlives the subscription that + * minted it: without this check a user could subscribe, mint a key, downgrade to + * Free, and keep programmatic access to a paid feature indefinitely. + * Returns a 402 Response when the entitlement is gone, else null. + */ +async function requireApiAccess(userId: string): Promise { + if (await subjectHasFeature(userId, "api_access")) return null; + return Response.json( + { error: "API access requires an active Pro or Agency plan." }, + { status: 402 } + ); +} + /** GET /api/v1/episodes — list the caller's episodes. */ export async function GET(req: NextRequest) { const auth = await authorize(req); if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 }); + const denied = await requireApiAccess(auth.userId); + if (denied) return denied; + const rl = await rateLimit("read", auth.userId, LIMITS.read); if (!rl.ok) { return Response.json( @@ -56,6 +75,9 @@ export async function POST(req: NextRequest) { const auth = await authorize(req); if (!auth) return Response.json({ error: "Invalid API key" }, { status: 401 }); + const denied = await requireApiAccess(auth.userId); + if (denied) return denied; + const rl = await rateLimit("api", auth.userId, LIMITS.api); if (!rl.ok) { return Response.json( diff --git a/app/api/webhooks/paypal/route.ts b/app/api/webhooks/paypal/route.ts index 2f14fd7..cd89581 100644 --- a/app/api/webhooks/paypal/route.ts +++ b/app/api/webhooks/paypal/route.ts @@ -2,9 +2,20 @@ import { NextRequest } from "next/server"; import { verifyPaypalWebhook } from "@/lib/billing/paypal"; import { handlePaypalEvent } from "@/lib/billing/webhooks/paypal"; import { alreadyProcessed, logWebhook } from "@/lib/billing/webhook-log"; +import { rateLimit, LIMITS } from "@/lib/ratelimit"; export const dynamic = "force-dynamic"; +/** Best-effort client IP for anonymous rate limiting. */ +function clientKey(req: NextRequest): string { + const fwd = req.headers.get("x-forwarded-for"); + if (fwd) return fwd.split(",")[0].trim(); + return req.headers.get("x-real-ip") ?? "anon"; +} + +// PayPal webhook bodies are small; anything larger is not a real event. +const MAX_BODY_BYTES = 1_000_000; + const SIG_HEADERS = [ "paypal-auth-algo", "paypal-cert-url", @@ -14,9 +25,44 @@ const SIG_HEADERS = [ ]; export async function POST(req: NextRequest) { - const body = await req.text(); + // This endpoint is unauthenticated and verification is done by CALLING PayPal, + // so every request costs us an outbound API call. Throttle per IP, and reject + // obviously-bogus requests before spending anything. + const rl = await rateLimit("paypal-webhook", clientKey(req), LIMITS.webhook); + if (!rl.ok) { + return new Response("Too many requests", { + status: 429, + headers: { "Retry-After": String(rl.retryAfterSec ?? 60) }, + }); + } + const headers: Record = {}; for (const h of SIG_HEADERS) headers[h] = req.headers.get(h) ?? undefined; + // A genuine PayPal delivery always carries all five signature headers. Bailing + // here costs nothing, whereas verifyPaypalWebhook() would hit the network. + if (SIG_HEADERS.some((h) => !headers[h])) { + return new Response("Invalid signature", { status: 400 }); + } + + // Check the declared size BEFORE reading, so an oversized body is never + // materialized in memory. The post-read check still backstops a missing or + // lying Content-Length. + const declared = Number(req.headers.get("content-length") ?? "0"); + if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) { + return new Response("Payload too large", { status: 413 }); + } + + const body = await req.text(); + if (body.length > MAX_BODY_BYTES) { + return new Response("Payload too large", { status: 413 }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + return new Response("Invalid payload", { status: 400 }); + } const verified = await verifyPaypalWebhook(headers, body).catch(() => false); if (!verified) { @@ -24,7 +70,7 @@ export async function POST(req: NextRequest) { return new Response("Invalid signature", { status: 400 }); } - const event = JSON.parse(body) as { id?: string; event_type?: string }; + const event = parsed as { id?: string; event_type?: string }; const eventId = event.id ?? `paypal_${Date.now()}`; if (event.id && (await alreadyProcessed(eventId))) return new Response("ok (duplicate)"); diff --git a/app/apple-icon.png b/app/apple-icon.png index f459076..50668b6 100644 Binary files a/app/apple-icon.png and b/app/apple-icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx index 08850d0..d1df765 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,9 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Wix_Madefor_Text, Wix_Madefor_Display } from "next/font/google"; import { Toaster } from "sonner"; +import { UmamiAnalytics } from "@/components/analytics/umami"; +import { umamiConfig } from "@/lib/analytics"; +import { SITE_DESCRIPTION, SITE_NAME, SITE_TAGLINE, SITE_URL, absoluteUrl } from "@/lib/seo"; import "./globals.css"; // Wix Madefor — the platform typeface (body + UI) @@ -18,21 +21,78 @@ const madeforDisplay = Wix_Madefor_Display({ }); export const metadata: Metadata = { + metadataBase: new URL(SITE_URL), title: { - default: "Podcast Distribution AI — From topic idea to published podcast in minutes", - template: "%s · Podcast Distribution AI", + default: `${SITE_NAME} — ${SITE_TAGLINE}`, + template: `%s · ${SITE_NAME}`, }, - description: - "Podcast Distribution AI is an all-in-one AI platform that writes your script, records realistic multi-voice audio, and designs cover art — turning a topic into a finished episode in minutes.", - metadataBase: new URL(process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"), + description: SITE_DESCRIPTION, + applicationName: SITE_NAME, + authors: [{ name: SITE_NAME, url: absoluteUrl("/") }], + creator: SITE_NAME, + publisher: SITE_NAME, + category: "technology", + keywords: [ + "AI podcast generator", + "podcast script generator", + "AI voice over", + "text to speech podcast", + "podcast cover art generator", + "AI podcast production", + "content repurposing", + "multi-voice AI audio", + ], + // Every route gets a self-referencing canonical; pages override this with their + // own absolute URL via `pageMetadata()` so query strings never fragment a page. + alternates: { canonical: absoluteUrl("/") }, openGraph: { - title: "Podcast Distribution AI", - description: "Create scripted, narrated, illustrated podcasts with AI — no recording gear required.", type: "website", + siteName: SITE_NAME, + title: `${SITE_NAME} — ${SITE_TAGLINE}`, + description: SITE_DESCRIPTION, + url: absoluteUrl("/"), + locale: "en_US", + }, + twitter: { + card: "summary_large_image", + title: `${SITE_NAME} — ${SITE_TAGLINE}`, + description: SITE_DESCRIPTION, + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-image-preview": "large", + "max-snippet": -1, + "max-video-preview": -1, + }, + }, + formatDetection: { telephone: false, address: false, email: false }, + // Search-console ownership tokens; unset values are simply omitted. + verification: { + google: process.env.NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION, + other: process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION + ? { "msvalidate.01": process.env.NEXT_PUBLIC_BING_SITE_VERIFICATION } + : {}, }, }; +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#ffffff" }, + { media: "(prefers-color-scheme: dark)", color: "#0d0d0d" }, + ], + colorScheme: "light dark", + width: "device-width", + initialScale: 1, +}; + export default function RootLayout({ children }: { children: React.ReactNode }) { + // Absent config (the default in development) renders nothing at all. + const umami = umamiConfig(); + return ( {children} + {umami && } ); diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..f28736c --- /dev/null +++ b/app/manifest.ts @@ -0,0 +1,25 @@ +import type { MetadataRoute } from "next"; +import { BRAND_HEX, SITE_DESCRIPTION, SITE_NAME } from "@/lib/seo"; + +/** Web app manifest — installability + correct branding in browser UI. */ +export default function manifest(): MetadataRoute.Manifest { + return { + name: SITE_NAME, + short_name: "Podcast AI", + description: SITE_DESCRIPTION, + start_url: "/dashboard", + scope: "/", + display: "standalone", + background_color: "#ffffff", + theme_color: BRAND_HEX, + categories: ["productivity", "music", "business"], + // Both files live in app/ as Next icon conventions, so these URLs are stable. + icons: [ + { src: "/icon.png", sizes: "512x512", type: "image/png", purpose: "any" }, + // Maskable: Android crops to a circle/squircle, and the brand mark has + // enough padding inside the 512 canvas to survive that crop. + { src: "/icon.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + { src: "/apple-icon.png", sizes: "180x180", type: "image/png", purpose: "any" }, + ], + }; +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..5753c93 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,65 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { SiteFooter } from "@/components/marketing/site-footer"; +import { SiteHeader } from "@/components/marketing/site-header"; +import { Button } from "@/components/ui/button"; +import { NO_INDEX } from "@/lib/seo"; + +// A 404 already carries the right status code; the meta tag is belt-and-braces +// for crawlers that reach this shell through a soft link. +export const metadata: Metadata = { title: "Page not found", ...NO_INDEX }; + +/** Popular destinations, so a bad URL still routes the visitor somewhere useful. */ +const LINKS: [string, string][] = [ + ["Features", "/features"], + ["Pricing", "/pricing"], + ["FAQ", "/faq"], + ["About", "/about"], +]; + +export default function NotFound() { + return ( +
+ +
+
+

+ Error 404 +

+

+ We couldn't find that page +

+

+ The link may be broken, or the page may have moved. Here are a few places to + pick things back up. +

+ +
+ + +
+ + +
+
+ +
+ ); +} diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx new file mode 100644 index 0000000..c33af83 --- /dev/null +++ b/app/opengraph-image.tsx @@ -0,0 +1,89 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { ImageResponse } from "next/og"; +import { BRAND_HEX, SITE_NAME } from "@/lib/seo"; + +/** + * The default social card for every route that does not define its own. + * Rendered at build/request time by Satori — no binary asset to keep in sync. + */ +export const alt = `${SITE_NAME} — from a topic idea to a finished podcast in minutes`; +export const size = { width: 1200, height: 630 }; +export const contentType = "image/png"; + +/** + * The real brand mark, inlined as a data URI. Satori cannot fetch a relative URL + * (there is no origin while rendering), so the PNG is read off disk and embedded. + */ +async function brandMarkDataUri() { + const file = await readFile(join(process.cwd(), "app", "icon.png")); + return `data:image/png;base64,${file.toString("base64")}`; +} + +export default async function OpengraphImage() { + const mark = await brandMarkDataUri(); + + return new ImageResponse( + ( +
+ {/* Brand accent — a soft corner glow. Satori has no blur filter, so the + falloff is a radial gradient rather than an opaque disc, which would + otherwise cut a hard edge straight through the headline. */} +
+ +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + +
+ {SITE_NAME} +
+
+ +
+
+ From a topic idea to a finished podcast in minutes +
+
+ AI writes the script, records multi-voice audio, and designs the cover art. +
+
+ +
+ Script · Voice · Cover art — one workflow +
+
+ ), + size + ); +} diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..ecde4aa --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,42 @@ +import type { MetadataRoute } from "next"; +import { SITE_URL, absoluteUrl } from "@/lib/seo"; + +/** + * /robots.txt — generated so the host always matches the deployment origin. + * + * Everything behind auth (the app shell, admin, auth screens), the API surface, + * and unlisted share links are disallowed. Those routes also emit a `noindex` + * meta tag; robots.txt keeps crawlers from spending budget on them at all. + */ +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: "/", + disallow: [ + "/api/", + "/admin", + "/dashboard", + "/episodes", + "/series", + "/usage", + "/billing", + "/team", + "/api-keys", + "/settings", + "/sign-in", + "/sign-up", + "/forgot-password", + "/reset-password", + // Unlisted, per-episode share links — shareable, never indexable. + "/p/", + // Proxied analytics tracker + beacon (see next.config.mjs). + "/_a/", + ], + }, + ], + sitemap: absoluteUrl("/sitemap.xml"), + host: SITE_URL, + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..7845d61 --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,40 @@ +import type { MetadataRoute } from "next"; +import { absoluteUrl } from "@/lib/seo"; + +/** + * /sitemap.xml — the public, indexable surface only. + * + * Authed routes, auth screens and unlisted /p/ share pages are + * deliberately absent: they are `noindex` and disallowed in robots.ts, and a + * sitemap that lists non-indexable URLs is a Search Console error. + */ + +/** Legal pages change rarely; keep their stamp tied to the published revision. */ +const LEGAL_UPDATED = new Date("2026-06-07T00:00:00.000Z"); + +type Entry = { + path: string; + priority: number; + changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; + lastModified: Date; +}; + +const NOW = new Date(); + +const ROUTES: Entry[] = [ + { path: "/", priority: 1.0, changeFrequency: "weekly", lastModified: NOW }, + { path: "/features", priority: 0.9, changeFrequency: "monthly", lastModified: NOW }, + { path: "/pricing", priority: 0.9, changeFrequency: "monthly", lastModified: NOW }, + { path: "/faq", priority: 0.8, changeFrequency: "monthly", lastModified: NOW }, + { path: "/about", priority: 0.6, changeFrequency: "yearly", lastModified: NOW }, + { path: "/terms", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED }, + { path: "/privacy", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED }, + { path: "/cookies", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED }, + { path: "/acceptable-use", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED }, + { path: "/refunds", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED }, + { path: "/subprocessors", priority: 0.3, changeFrequency: "yearly", lastModified: LEGAL_UPDATED }, +]; + +export default function sitemap(): MetadataRoute.Sitemap { + return ROUTES.map(({ path, ...rest }) => ({ url: absoluteUrl(path), ...rest })); +} diff --git a/app/twitter-image.tsx b/app/twitter-image.tsx new file mode 100644 index 0000000..76d5636 --- /dev/null +++ b/app/twitter-image.tsx @@ -0,0 +1 @@ +export { default, alt, size, contentType } from "./opengraph-image"; diff --git a/components/admin/admin-sidebar.tsx b/components/admin/admin-sidebar.tsx index 3ed4e1e..443586f 100644 --- a/components/admin/admin-sidebar.tsx +++ b/components/admin/admin-sidebar.tsx @@ -2,20 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { - LayoutDashboard, - TrendingUp, - BarChart3, - Users, - CreditCard, - ListChecks, - Activity, - Webhook, - ShieldAlert, - Flag, - ScrollText, - Settings, -} from "lucide-react"; +import { LayoutDashboard, TrendingUp, BarChart3, Users, CreditCard, ListChecks, Activity, Webhook, ShieldAlert, Flag, ScrollText, Settings, Building2 } from "lucide-react"; import { cn } from "@/lib/utils"; interface Item { @@ -38,6 +25,7 @@ const GROUPS: { label: string; items: Item[] }[] = [ label: "Operations", items: [ { label: "Users", href: "/admin/users", icon: Users }, + { label: "Organizations", href: "/admin/organizations", icon: Building2 }, { label: "Subscriptions", href: "/admin/subscriptions", icon: CreditCard }, { label: "Jobs", href: "/admin/jobs", icon: ListChecks }, { label: "System health", href: "/admin/health", icon: Activity }, diff --git a/components/admin/user-detail-actions.tsx b/components/admin/user-detail-actions.tsx index 4c5c519..299a4df 100644 --- a/components/admin/user-detail-actions.tsx +++ b/components/admin/user-detail-actions.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { LogIn, ShieldCheck, ShieldOff, Ban, UserCheck, Gift } from "lucide-react"; +import { LogIn, ShieldCheck, ShieldOff, Ban, UserCheck, Gift, Download, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -19,6 +19,8 @@ import { banUserAction, setRoleAction, compPlanAction, + deleteUserAction, + exportUserDataAction, } from "@/app/(admin)/admin/actions"; type CompPlan = "creator" | "pro" | "agency"; @@ -31,6 +33,7 @@ export function UserDetailActions({ }) { const router = useRouter(); const [impersonating, setImpersonating] = useState(false); + const [exporting, setExporting] = useState(false); const [compPlan, setCompPlan] = useState("pro"); const [compInterval, setCompInterval] = useState("month"); @@ -44,6 +47,23 @@ export function UserDetailActions({ } } + async function exportData() { + setExporting(true); + const res = await exportUserDataAction(user.id); + setExporting(false); + if (!res.ok || !res.json) { + toast.error(res.error ?? "Could not export"); + return; + } + const url = URL.createObjectURL(new Blob([res.json], { type: "application/json" })); + const a = document.createElement("a"); + a.href = url; + a.download = `user-${user.id}-export.json`; + a.click(); + URL.revokeObjectURL(url); + toast.success("Export downloaded"); + } + async function impersonate() { setImpersonating(true); try { @@ -159,6 +179,29 @@ export function UserDetailActions({ onConfirm={() => banUserAction(user.id, true)} /> )} + + + + + Delete + + } + title="Permanently delete this user?" + description="Erases the account and every episode, script, series, API key and usage record it owns. This cannot be undone — export their data first if this is a GDPR request." + confirmLabel="Delete permanently" + successMessage="User deleted" + onConfirm={async () => { + const res = await deleteUserAction(user.id); + // The user page no longer exists once the row is gone. + if (res.ok) router.push("/admin/users"); + return res; + }} + />
); } diff --git a/components/analytics/umami.tsx b/components/analytics/umami.tsx new file mode 100644 index 0000000..1816fde --- /dev/null +++ b/components/analytics/umami.tsx @@ -0,0 +1,51 @@ +"use client"; + +import Script from "next/script"; +import { ANALYTICS_PROXY_PATH, redactPayload, type UmamiPayload } from "@/lib/analytics"; + +/** Name of the global the tracker's `data-before-send` hook resolves. */ +const BEFORE_SEND = "__umamiBeforeSend"; + +declare global { + interface Window { + [BEFORE_SEND]?: (type: string, payload: UmamiPayload) => UmamiPayload; + } +} + +// Registered at module scope rather than in an effect: the tracker reads +// `window[BEFORE_SEND]` at send time, and this client chunk is evaluated before +// next/script injects the tag, so there is no window in which an unredacted +// event could slip out. +if (typeof window !== "undefined") { + window[BEFORE_SEND] = (_type, payload) => redactPayload(payload); +} + +/** + * Self-hosted Umami analytics. + * + * The tracker and its beacon are both served from this origin via the + * `/_a` rewrite in next.config.mjs. That matters for more than ad-blockers: the + * CSP in middleware.ts uses `'strict-dynamic'`, which makes browsers ignore host + * allowlists in `script-src` entirely — so allowlisting the Umami domain there + * would not have worked, and `connect-src 'self'` would still have blocked the + * beacon. Proxying keeps both same-origin and the policy unrelaxed. + */ +export function UmamiAnalytics({ websiteId }: { websiteId: string }) { + return ( + " + * cannot break out of the block. + */ +export function JsonLd({ data }: { data: Record | Record[] }) { + return ( +