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

Turnstile bot protection (sign-in, sign-up, password-reset):
- Register Better Auth's captcha plugin with the cloudflare-turnstile
  provider; endpoints listed explicitly rather than relying on defaults.
  /reset-password is intentionally excluded — it is reached only via a
  single-use emailed token.
- Add an explicit-render Turnstile widget component. Tokens are single-use,
  so each form resets the challenge after a failed submit; submit stays
  disabled until a token is held.
- Read the site key server-side and pass it down as a prop, so rotating it
  does not require a rebuild.
- Fail fast in production when TURNSTILE_SECRET_KEY is missing, and when a
  secret is set without a site key (that combination would demand a token
  no form can produce, locking every user out).
- Pass a throwaway secret during `next build` in the Dockerfile, mirroring
  the existing BETTER_AUTH_SECRET treatment, so image builds don't need it.

CSP fixes in middleware (these blocked Turnstile entirely):
- Add frame-src for challenges.cloudflare.com. Without it the widget's
  iframe fell back to default-src 'self' and was blocked outright.
- Allow 'unsafe-eval' and websockets in DEVELOPMENT only. `next dev`
  compiles with eval(), so the strict policy threw EvalError and killed
  hydration — no client JS ran at all, which also meant form submit
  handlers never fired. Production policy is unchanged and still strict.

Also included (concurrent work in the tree):
- Admin organizations pages and lib/admin/orgs.
- Episode moderation migration, SEO metadata (sitemap, robots, JSON-LD,
  OG/Twitter images, manifest), Umami analytics, not-found page.

Local dev database: docker-compose.dev.yml provisions Postgres 18 on port
5443 (5432-5442 are in use by other local projects).

Note: `npx tsc --noEmit` currently fails in app/(app)/team/page.tsx — an
`invitations` prop the component does not accept. This predates the commit
and will fail `next build` until fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-09-07 11:10:55 -04:00
co-authored by Claude Opus 5
parent 35379212fb
commit 3e9ba07175
96 changed files with 3982 additions and 576 deletions
+24 -6
View File
@@ -33,12 +33,30 @@ export async function getEpisodeStatusCounts() {
return groups.map((g) => ({ status: g.status as string, count: g._count }));
}
export async function getModerationQueue() {
return prisma.contentFlag.findMany({
where: { status: "open" },
orderBy: { createdAt: "desc" },
include: { episode: { select: { id: true, title: true } } },
});
export const MODERATION_PAGE_SIZE = 25;
/**
* Open content flags, newest first.
*
* Paginated: this used to load every open flag, so a moderation backlog grew
* the page (and its memory cost) without bound. Every other admin list is
* paginated for the same reason.
*/
export async function getModerationQueue(params: { page?: number; pageSize?: number } = {}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = params.pageSize ?? MODERATION_PAGE_SIZE;
const where = { status: "open" };
const [rows, total] = await Promise.all([
prisma.contentFlag.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: { episode: { select: { id: true, title: true, userId: true } } },
}),
prisma.contentFlag.count({ where }),
]);
return { rows, total };
}
export async function listWebhookEvents(params: {
+193
View File
@@ -0,0 +1,193 @@
import type { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
import { PLANS, type PlanKey } from "@/lib/billing/plans";
export const ORGS_PAGE_SIZE = 25;
export interface AdminOrgRow {
id: string;
name: string;
slug: string | null;
memberCount: number;
seats: number;
plan: string;
status: string | null;
whiteLabel: boolean;
createdAt: Date;
}
function orderBy(sort?: string): Prisma.OrganizationOrderByWithRelationInput {
const [key, dir] = (sort ?? "createdAt.desc").split(".");
const d = dir === "asc" ? "asc" : "desc";
if (key === "name") return { name: d };
return { createdAt: d };
}
/**
* Organization list for the admin console.
*
* Subscriptions are keyed by `referenceId`, which holds either a user id or an
* organization id — so an org's plan is looked up by its own id, in one batched
* query rather than per row.
*/
export async function listOrganizations(params: {
search?: string;
plan?: string;
sort?: string;
page: number;
pageSize?: number;
}): Promise<{ rows: AdminOrgRow[]; total: number }> {
const pageSize = params.pageSize ?? ORGS_PAGE_SIZE;
const where: Prisma.OrganizationWhereInput = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: "insensitive" } },
{ slug: { contains: params.search, mode: "insensitive" } },
];
}
const [orgs, total] = await Promise.all([
prisma.organization.findMany({
where,
orderBy: orderBy(params.sort),
skip: (params.page - 1) * pageSize,
take: pageSize,
include: {
branding: { select: { removePoweredBy: true } },
_count: { select: { members: true } },
},
}),
prisma.organization.count({ where }),
]);
const subs = await prisma.subscription.findMany({
where: {
referenceId: { in: orgs.map((o) => o.id) },
status: { in: ["active", "trialing", "past_due"] },
},
select: { referenceId: true, plan: true, status: true, seats: true },
});
const subByOrg = new Map(subs.map((s) => [s.referenceId, s]));
const rows = orgs
.map((o) => {
const sub = subByOrg.get(o.id);
const planKey = (sub?.plan ?? "free") as PlanKey;
return {
id: o.id,
name: o.name,
slug: o.slug,
memberCount: o._count.members,
seats: sub?.seats ?? PLANS[planKey]?.limits.seats ?? 1,
plan: planKey,
status: sub?.status ?? null,
whiteLabel: o.branding?.removePoweredBy ?? false,
createdAt: o.createdAt,
};
})
// Plan lives on the subscription table, not the org row, so this filter is
// applied after the join rather than in the SQL WHERE.
.filter((r) => !params.plan || r.plan === params.plan);
return { rows, total };
}
export interface AdminOrgDetail {
id: string;
name: string;
slug: string | null;
createdAt: Date;
plan: string;
status: string | null;
seats: number;
members: {
memberId: string;
userId: string;
name: string;
email: string;
role: string;
banned: boolean;
joinedAt: Date;
}[];
invitations: { id: string; email: string; role: string | null; expiresAt: Date }[];
branding: {
brandName: string | null;
primaryColor: string | null;
logoUrl: string | null;
removePoweredBy: boolean;
customDomain: string | null;
} | null;
episodeCount: number;
recentEpisodes: { id: string; title: string; status: string; createdAt: Date }[];
}
/** Full detail for one organization, or null when it does not exist. */
export async function getOrgDetail(orgId: string): Promise<AdminOrgDetail | null> {
const org = await prisma.organization.findUnique({
where: { id: orgId },
include: {
branding: true,
members: {
include: { user: { select: { id: true, name: true, email: true, banned: true } } },
orderBy: { createdAt: "asc" },
},
invitations: {
where: { status: "pending" },
select: { id: true, email: true, role: true, expiresAt: true },
},
},
});
if (!org) return null;
const [sub, episodeCount, recentEpisodes] = await Promise.all([
prisma.subscription.findFirst({
where: { referenceId: org.id, status: { in: ["active", "trialing", "past_due"] } },
select: { plan: true, status: true, seats: true },
}),
prisma.episode.count({ where: { organizationId: org.id } }),
prisma.episode.findMany({
where: { organizationId: org.id },
orderBy: { createdAt: "desc" },
take: 10,
select: { id: true, title: true, status: true, createdAt: true },
}),
]);
const planKey = (sub?.plan ?? "free") as PlanKey;
return {
id: org.id,
name: org.name,
slug: org.slug,
createdAt: org.createdAt,
plan: planKey,
status: sub?.status ?? null,
seats: sub?.seats ?? PLANS[planKey]?.limits.seats ?? 1,
members: org.members.map((m) => ({
memberId: m.id,
userId: m.user.id,
name: m.user.name,
email: m.user.email,
role: m.role,
banned: !!m.user.banned,
joinedAt: m.createdAt,
})),
invitations: org.invitations.map((i) => ({
id: i.id,
email: i.email,
role: i.role,
expiresAt: i.expiresAt,
})),
branding: org.branding
? {
brandName: org.branding.brandName,
primaryColor: org.branding.primaryColor,
logoUrl: org.branding.logoUrl,
removePoweredBy: org.branding.removePoweredBy,
customDomain: org.branding.customDomain,
}
: null,
episodeCount,
recentEpisodes,
};
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Umami (self-hosted) analytics configuration and payload redaction.
*
* Two deliberate choices are encoded here:
*
* 1. The tracker is served same-origin through a rewrite (see `ANALYTICS_PROXY_PATH`
* and next.config.mjs) rather than from the Umami host directly. That keeps the
* strict CSP in middleware.ts untouched — `script-src`/`connect-src` stay
* `'self'` — and stops content blockers from dropping the request.
*
* 2. Nothing that identifies a user's content ever reaches the analytics database.
* See `redactUrl` / `redactTitle`.
*/
/** Same-origin prefix the tracker and its beacon are proxied through. */
export const ANALYTICS_PROXY_PATH = "/_a";
export interface UmamiConfig {
/** Origin of the Umami instance, e.g. "https://analytics.example.com". */
hostUrl: string;
websiteId: string;
}
/**
* Resolve the Umami config from the environment. Returns null when either value
* is absent, which disables analytics entirely (the default in development).
*
* Both are read at build time for statically rendered pages, so they must be
* present as build args in Docker — see the Dockerfile.
*/
export function umamiConfig(): UmamiConfig | null {
const hostUrl = process.env.UMAMI_HOST_URL?.trim().replace(/\/+$/, "");
const websiteId = process.env.UMAMI_WEBSITE_ID?.trim();
if (!hostUrl || !websiteId) return null;
return { hostUrl, websiteId };
}
/**
* Path segments that are opaque identifiers rather than page names.
*
* Sending them would put episode IDs, series IDs and — worst — unlisted share
* IDs into the analytics database. Share links are secret by design, so leaking
* one there effectively publishes it. Collapsing them also makes the reports
* useful: one "/episodes/[id]" row instead of thousands of singletons.
*/
export function redactPath(pathname: string): string {
const seg = pathname.split("/").filter(Boolean);
// "/episodes/new" is a real page; "/episodes/<cuid>" is not.
if (seg[0] === "episodes" && seg[1] && seg[1] !== "new") seg[1] = "[id]";
else if (seg[0] === "series" && seg[1]) seg[1] = "[id]";
else if (seg[0] === "p" && seg[1]) seg[1] = "[shareId]";
else if (seg[0] === "admin" && seg[1] === "users" && seg[2]) seg[2] = "[id]";
return `/${seg.join("/")}`;
}
/**
* Redact a full URL: collapse identifier segments and drop the query string and
* hash outright.
*
* The query string is the sharper hazard — /reset-password carries a live
* password-reset token in `?token=`, and /sign-in carries the visitor's intended
* destination in `?redirect=`. `data-exclude-search` already strips it; this is
* the second line of defence, and it also covers the referrer field, which the
* tracker fills from the previous URL.
*/
export function redactUrl(raw: string): string {
if (!raw) return raw;
try {
// Bare paths are the common case; the base only matters for absolute URLs.
const url = new URL(raw, "http://localhost");
const path = redactPath(url.pathname);
return url.origin === "http://localhost" && !raw.startsWith("http")
? path
: `${url.origin}${path}`;
} catch {
// Unparseable — return just the redacted path portion, never the raw value.
return redactPath(raw.split(/[?#]/)[0]);
}
}
/** Whether a path carries user content that its <title> would expose. */
function isRedacted(pathname: string): boolean {
return redactPath(pathname) !== pathname;
}
/**
* Page titles on detail routes are the user's own content — `generateMetadata`
* returns the episode or series title verbatim. Swap those for the route name so
* the reports stay readable without storing anyone's content.
*/
export function redactTitle(title: string, url: string): string {
let pathname: string;
try {
pathname = new URL(url, "http://localhost").pathname;
} catch {
pathname = url.split(/[?#]/)[0];
}
return isRedacted(pathname) ? `${redactPath(pathname)} · Podcast Distribution AI` : title;
}
/** The Umami event payload we are allowed to inspect and rewrite. */
export interface UmamiPayload {
url?: string;
referrer?: string;
title?: string;
[key: string]: unknown;
}
/** Apply every redaction rule to one outgoing event payload. */
export function redactPayload(payload: UmamiPayload): UmamiPayload {
const url = payload.url ? redactUrl(payload.url) : payload.url;
return {
...payload,
...(url !== undefined ? { url } : {}),
...(payload.referrer ? { referrer: redactUrl(payload.referrer) } : {}),
...(payload.title && payload.url
? { title: redactTitle(payload.title, payload.url) }
: {}),
};
}
+134 -1
View File
@@ -1,10 +1,30 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { admin, organization } from "better-auth/plugins";
import { admin, captcha, organization } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import { createAuthMiddleware, APIError } from "better-auth/api";
import { prisma } from "@/lib/db";
import { sendEmail, emailLayout } from "@/lib/email";
/**
* Enforce the `signups_enabled` kill-switch on the REAL registration path.
*
* The sign-up page also checks this flag, but that only hides the form — a
* client can still POST /api/auth/sign-up/email directly. This is the actual
* boundary. `@/lib/flags` is imported dynamically because it pulls in
* `server-only`, which throws when lib/* is loaded outside Next's RSC bundler
* (the worker runs under plain tsx); a lazy import keeps that cost off the
* module graph until an actual sign-up is attempted.
*/
async function assertSignupsEnabled(): Promise<void> {
const { isFlagEnabled } = await import("@/lib/flags");
if (!(await isFlagEnabled("signups_enabled"))) {
throw new APIError("FORBIDDEN", {
message: "Sign-ups are currently paused. Please check back soon.",
});
}
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
@@ -24,6 +44,81 @@ if (secretIsWeak && process.env.NODE_ENV === "production") {
);
}
/**
* Guard impersonation — the single most powerful action in the platform.
*
* Two problems with leaving this to the better-auth admin plugin alone:
* 1. It is the ONLY admin mutation with no entry in our audit log, so a
* compromised admin account can read every user's data untraceably.
* 2. Nothing stops one admin impersonating another, which is lateral
* movement between privileged accounts rather than support access.
*
* Enforcing it here (rather than in the server action) means a direct POST to
* /api/auth/admin/impersonate-user is covered too.
*/
async function guardImpersonation(headers: Headers, targetUserId: unknown): Promise<void> {
if (typeof targetUserId !== "string" || !targetUserId) {
throw new APIError("BAD_REQUEST", { message: "A target user id is required." });
}
// `auth` is referenced lazily: this runs per-request, long after module init.
const session = await auth.api.getSession({ headers });
if (!session || session.user.role !== "admin") {
throw new APIError("FORBIDDEN", { message: "Not allowed." });
}
const target = await prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, role: true, email: true },
});
if (!target) throw new APIError("NOT_FOUND", { message: "User not found." });
if (target.role === "admin") {
throw new APIError("FORBIDDEN", {
message: "Admins cannot impersonate other admins.",
});
}
await prisma.auditLog.create({
data: {
actorId: session.user.id,
actorType: "admin",
action: "user.impersonate",
target: target.id,
metadata: { targetEmail: target.email },
},
});
}
/**
* Cloudflare Turnstile — bot protection on the public auth endpoints.
*
* The plugin verifies the `x-captcha-response` header server-side against
* Cloudflare's siteverify API BEFORE the handler runs, so it is the real
* boundary: hiding/!rendering the widget client-side proves nothing, and a
* bot POSTing straight to /api/auth/sign-in/email is rejected here.
*
* Enabled whenever TURNSTILE_SECRET_KEY is set. Required in production so a
* misconfigured deploy cannot silently ship with bot protection switched off
* (same fail-fast posture as BETTER_AUTH_SECRET above); left optional in
* dev/test so local work stays frictionless without Cloudflare keys.
*/
const turnstileSecretKey = process.env.TURNSTILE_SECRET_KEY?.trim();
const turnstileEnabled = !!turnstileSecretKey;
if (!turnstileEnabled && process.env.NODE_ENV === "production") {
throw new Error(
"TURNSTILE_SECRET_KEY must be set in production — Turnstile guards the sign-in, sign-up and password-reset endpoints."
);
}
// Guard the asymmetric misconfiguration: with a secret but no site key the server
// would demand a captcha token that no form can produce, locking every user out
// of sign-in. Fail the boot (and the production build) instead of shipping that.
if (
turnstileEnabled &&
!process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() &&
process.env.NODE_ENV === "production"
) {
throw new Error(
"NEXT_PUBLIC_TURNSTILE_SITE_KEY must be set alongside TURNSTILE_SECRET_KEY — without it the auth forms cannot render the Turnstile widget and every sign-in would be rejected."
);
}
export const auth = betterAuth({
appName: "Podcast Distribution AI",
secret: process.env.BETTER_AUTH_SECRET,
@@ -93,6 +188,30 @@ export const auth = betterAuth({
accountLinking: { enabled: true, trustedProviders: ["google"] },
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path === "/sign-up/email") await assertSignupsEnabled();
if (ctx.path === "/admin/impersonate-user") {
await guardImpersonation(ctx.headers ?? new Headers(), (ctx.body as { userId?: unknown } | undefined)?.userId);
}
}),
},
// Catch-all: a user row is only ever created by registration, so this also
// covers first-time Google sign-in, which does not hit /sign-up/email.
// (scripts/create-admin.ts and scripts/seed-demo.ts write via Prisma directly
// and are intentionally unaffected.)
databaseHooks: {
user: {
create: {
before: async (user) => {
await assertSignupsEnabled();
return { data: user };
},
},
},
},
plugins: [
admin({ defaultRole: "user", adminRoles: ["admin"] }),
organization({
@@ -100,6 +219,20 @@ export const auth = betterAuth({
// Agency seat cap is enforced in app logic against the subscription's seat count.
membershipLimit: 5,
}),
// Bot protection. Endpoints listed explicitly rather than relying on the
// plugin default so adding one is a deliberate, reviewable change.
// `/reset-password` is intentionally absent: it is reached only with a
// single-use token emailed to a verified address, and challenging it would
// break the emailed-link flow.
...(turnstileEnabled
? [
captcha({
provider: "cloudflare-turnstile",
secretKey: turnstileSecretKey!,
endpoints: ["/sign-in/email", "/sign-up/email", "/request-password-reset"],
}),
]
: []),
// Must remain last: lets Server Actions / route handlers set auth cookies.
nextCookies(),
],
+16
View File
@@ -0,0 +1,16 @@
/**
* Cloudflare Turnstile site key, resolved server-side.
*
* Read at request time and passed down to the auth forms as a prop (the same
* pattern `googleEnabled` uses) rather than referenced inside a client
* component. `NEXT_PUBLIC_*` values referenced in client code are inlined at
* BUILD time, which would bake the key into the Docker image and require a
* rebuild to rotate it; reading it here keeps it a runtime concern.
*
* Null when unset — the forms then render without a challenge. The server-side
* enforcement in `lib/auth/auth.ts` is keyed off TURNSTILE_SECRET_KEY, which is
* mandatory in production.
*/
export function getTurnstileSiteKey(): string | null {
return process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY?.trim() || null;
}
+21 -1
View File
@@ -24,7 +24,24 @@ export function isPaypalConfigured(): boolean {
return !!(process.env.PAYPAL_CLIENT_ID && process.env.PAYPAL_CLIENT_SECRET);
}
// PayPal client-credentials tokens are valid for hours. Minting a fresh one per
// call turned every inbound webhook into two outbound PayPal requests, which an
// anonymous caller could amplify until our PayPal rate limits were exhausted.
// Cache it in-process and refresh a minute before expiry.
let tokenCache: { token: string; expiresAt: number } | null = null;
let tokenInFlight: Promise<string> | null = null;
async function accessToken(): Promise<string> {
if (tokenCache && Date.now() < tokenCache.expiresAt) return tokenCache.token;
// Collapse concurrent misses onto a single token request.
if (tokenInFlight) return tokenInFlight;
tokenInFlight = fetchAccessToken().finally(() => {
tokenInFlight = null;
});
return tokenInFlight;
}
async function fetchAccessToken(): Promise<string> {
const { id, secret } = creds();
const res = await fetch(`${base()}/v1/oauth2/token`, {
method: "POST",
@@ -35,7 +52,10 @@ async function accessToken(): Promise<string> {
body: "grant_type=client_credentials",
});
if (!res.ok) throw new Error(`PayPal token error ${res.status}`);
const data = (await res.json()) as { access_token: string };
const data = (await res.json()) as { access_token: string; expires_in?: number };
// Default to 5 minutes if PayPal omits expires_in; refresh 60s early.
const ttlSec = Math.max((data.expires_in ?? 300) - 60, 60);
tokenCache = { token: data.access_token, expiresAt: Date.now() + ttlSec * 1000 };
return data.access_token;
}
+7 -3
View File
@@ -28,9 +28,13 @@ async function syncStripeSubscription(
const item = sub.items.data[0];
const priceId = item?.price?.id;
const mapped = priceId ? planFromStripePrice(priceId) : null;
// metadata.plan is attacker-influenceable; only honour it if it's a known plan.
// The price-mapping fallback (derived from the real Stripe price) is preferred.
const plan: PlanKey = planFromMetadata(metadata?.plan) ?? mapped?.plan ?? "free";
// The PRICE is authoritative: it is what the customer is actually charged, and
// Stripe updates it on every plan change. metadata.plan is only written once at
// checkout (lib/billing/stripe.ts) and is NOT rewritten when a customer switches
// plans in the Billing Portal — trusting it first would let a downgraded customer
// keep the higher tier's entitlements. Metadata is a fallback for the case where
// a price is missing or unmapped, and is still narrowed to a known PlanKey.
const plan: PlanKey = mapped?.plan ?? planFromMetadata(metadata?.plan) ?? "free";
const referenceId = metadata?.subjectId || sub.metadata?.subjectId;
if (!referenceId || referenceId.trim() === "") {
console.warn("[stripe] subscription without subjectId metadata, skipping", sub.id);
+30
View File
@@ -41,6 +41,36 @@ export async function finishJob(
});
}
/**
* Generic, user-safe copy for a failed generation.
*
* Episode.errorMessage is rendered straight to the end user (see
* app/(app)/episodes/[id]/page.tsx) and streamed over SSE, so it must never
* carry raw pipeline text: ffmpeg stderr leaks internal binary paths, and an
* ElevenLabs/OpenAI response body leaks our upstream quota and billing state.
*/
export const GENERIC_FAILURE =
"Generation failed. Our team has been notified — please try again shortly.";
/**
* Record a terminal pipeline failure.
*
* The RAW error is preserved on GenerationJob.error, which is only ever surfaced
* in the admin UI (lib/admin/ops.ts), so debuggability is unchanged. The episode
* itself gets the generic message above.
*/
export async function failEpisode(
episodeId: string,
rawError: string,
userMessage: string = GENERIC_FAILURE
): Promise<void> {
await prisma.generationJob.updateMany({
where: { episodeId, status: { in: ["queued", "running"] } },
data: { status: "failed", error: rawError, finishedAt: new Date() },
});
await setEpisodeStatus(episodeId, "FAILED", { errorMessage: userMessage });
}
/** Terminal episode states — used by the UI/SSE to stop polling. */
export function isTerminal(status: EpisodeStatus): boolean {
return status === "READY" || status === "FAILED";
+3
View File
@@ -87,4 +87,7 @@ export const LIMITS = {
read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key
stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user
publicMedia: { points: 120, durationSec: 60 }, // anon audio/cover (Range) reqs / min / IP
seriesPlan: { points: 5, durationSec: 3600 }, // season plans / hr / user (uncapped GPT-4o call)
export: { points: 10, durationSec: 60 }, // zip exports / min / user (buffers MP3 in memory)
webhook: { points: 60, durationSec: 60 }, // anon webhook posts / min / IP
} as const;
+143
View File
@@ -0,0 +1,143 @@
import { PLANS, PLAN_ORDER } from "@/lib/billing/plans";
import { SITE_DESCRIPTION, SITE_NAME, SITE_URL, absoluteUrl } from "@/lib/seo";
/**
* schema.org JSON-LD builders.
*
* Everything is emitted into a single `@graph` per page with stable `@id`s, so
* nodes can reference each other (e.g. the software product is `publisher`-ed by
* the organization) instead of being repeated on every route.
*/
const ORG_ID = `${SITE_URL}/#organization`;
const SITE_ID = `${SITE_URL}/#website`;
const APP_ID = `${SITE_URL}/#software`;
export function organizationSchema() {
return {
"@type": "Organization",
"@id": ORG_ID,
name: SITE_NAME,
url: absoluteUrl("/"),
description: SITE_DESCRIPTION,
logo: {
"@type": "ImageObject",
url: absoluteUrl("/logo-dark.png"),
contentUrl: absoluteUrl("/logo-dark.png"),
},
contactPoint: [
{
"@type": "ContactPoint",
contactType: "customer support",
email: "support@podcastdistributionai.com",
availableLanguage: ["English"],
},
],
};
}
export function websiteSchema() {
return {
"@type": "WebSite",
"@id": SITE_ID,
url: absoluteUrl("/"),
name: SITE_NAME,
description: SITE_DESCRIPTION,
publisher: { "@id": ORG_ID },
inLanguage: "en",
};
}
/**
* The product itself, with one Offer per plan. Prices come from the plan catalog
* so the markup can never drift from what the pricing page actually charges.
*/
export function softwareApplicationSchema() {
return {
"@type": "SoftwareApplication",
"@id": APP_ID,
name: SITE_NAME,
url: absoluteUrl("/"),
description: SITE_DESCRIPTION,
applicationCategory: "MultimediaApplication",
applicationSubCategory: "Podcast production",
operatingSystem: "Web browser",
publisher: { "@id": ORG_ID },
featureList: [
"AI podcast script generation",
"Realistic multi-voice text-to-speech",
"AI-generated episode cover art",
"Content repurposing to blog and social posts",
"Series and season generator",
"13+ languages",
"Team workspace and white-label branding",
"REST API access",
],
offers: PLAN_ORDER.map((key) => {
const plan = PLANS[key];
return {
"@type": "Offer",
name: `${plan.name} plan`,
description: plan.tagline,
price: (plan.priceMonthly / 100).toFixed(2),
priceCurrency: "USD",
category: plan.priceMonthly === 0 ? "Free" : "Subscription",
url: absoluteUrl("/pricing"),
availability: "https://schema.org/InStock",
};
}),
};
}
/** A single page node, linked to the site — gives each URL an explicit identity. */
export function webPageSchema({
path,
name,
description,
}: {
path: string;
name: string;
description: string;
}) {
return {
"@type": "WebPage",
"@id": `${absoluteUrl(path)}#webpage`,
url: absoluteUrl(path),
name,
description,
isPartOf: { "@id": SITE_ID },
about: { "@id": ORG_ID },
inLanguage: "en",
};
}
/** Breadcrumbs from Home to the current page. Pass the trail without Home. */
export function breadcrumbSchema(trail: { name: string; path: string }[]) {
return {
"@type": "BreadcrumbList",
"@id": `${absoluteUrl(trail[trail.length - 1]?.path ?? "/")}#breadcrumb`,
itemListElement: [{ name: "Home", path: "/" }, ...trail].map((item, i) => ({
"@type": "ListItem",
position: i + 1,
name: item.name,
item: absoluteUrl(item.path),
})),
};
}
export function faqPageSchema(items: { q: string; a: string }[]) {
return {
"@type": "FAQPage",
"@id": `${absoluteUrl("/faq")}#faq`,
mainEntity: items.map(({ q, a }) => ({
"@type": "Question",
name: q,
acceptedAnswer: { "@type": "Answer", text: a },
})),
};
}
/** Wrap nodes into the `@graph` envelope every page emits. */
export function graph(...nodes: Record<string, unknown>[]) {
return { "@context": "https://schema.org", "@graph": nodes };
}
+160
View File
@@ -0,0 +1,160 @@
import type { Metadata } from "next";
/**
* Central SEO configuration — the single source of truth for the canonical
* origin, brand naming, and shared metadata construction. Every page that needs
* a canonical URL, an Open Graph card, or JSON-LD should build it from here so
* the values can never drift between routes.
*/
export const SITE_NAME = "Podcast Distribution AI";
export const SITE_TAGLINE = "From topic idea to published podcast in minutes";
export const SITE_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.";
/** Brand accent (--brand, light theme) as a hex literal for OG image rendering. */
export const BRAND_HEX = "#e65000";
/**
* The canonical, absolute origin of the deployment (no trailing slash).
*
* Canonical tags, sitemap entries and OG URLs are only correct if this is the
* real public origin, so a production build refuses to silently fall back to
* localhost: an unset NEXT_PUBLIC_APP_URL would otherwise ship canonicals
* pointing at http://localhost:3000 and de-index the whole site.
*/
function resolveSiteUrl(): string {
const raw = process.env.NEXT_PUBLIC_APP_URL?.trim();
if (raw) {
try {
const url = new URL(raw);
// Strip any trailing slash so `${SITE_URL}${path}` never doubles up.
return url.origin;
} catch {
throw new Error(
`[seo] NEXT_PUBLIC_APP_URL is not a valid absolute URL: ${JSON.stringify(raw)}`
);
}
}
if (process.env.NODE_ENV === "production") {
throw new Error(
"[seo] NEXT_PUBLIC_APP_URL must be set in production — it is the canonical origin used for canonical tags, the sitemap, robots.txt and Open Graph URLs."
);
}
return "http://localhost:3000";
}
export const SITE_URL = resolveSiteUrl();
/** Resolve a root-relative path to its absolute canonical URL. */
export function absoluteUrl(path = "/"): string {
return path === "/" ? `${SITE_URL}/` : `${SITE_URL}${path.startsWith("/") ? path : `/${path}`}`;
}
/**
* The site-wide social card (app/opengraph-image.tsx).
*
* It has to be restated on every page: Next merges `metadata` shallowly, so a
* page that defines its own `openGraph` object replaces the root layout's
* entirely — including the images the file convention contributed. Leaving this
* out silently drops og:image from every page but the homepage.
*/
const DEFAULT_OG_IMAGE = {
url: "/opengraph-image",
width: 1200,
height: 630,
alt: `${SITE_NAME} — from a topic idea to a finished podcast in minutes`,
};
interface PageMetaOptions {
title: string;
description: string;
/** Root-relative path, e.g. "/pricing". Used for the canonical + OG URL. */
path: string;
/** Keep the page out of the index (authed surfaces, share links, utilities). */
noIndex?: boolean;
/** Override the social card image path (defaults to the site-wide OG card). */
image?: string;
type?: "website" | "article";
}
/**
* Build a complete, canonical-tagged Metadata object for a page. Next.js merges
* this over the root layout's metadata, so only the differing fields are set.
*/
export function pageMetadata({
title,
description,
path,
noIndex = false,
image,
type = "website",
}: PageMetaOptions): Metadata {
const url = absoluteUrl(path);
const images = image ? [{ url: image }] : [DEFAULT_OG_IMAGE];
return {
title,
description,
alternates: { canonical: url },
openGraph: {
title: `${title} · ${SITE_NAME}`,
description,
url,
siteName: SITE_NAME,
type,
locale: "en_US",
images,
},
twitter: {
card: "summary_large_image",
title: `${title} · ${SITE_NAME}`,
description,
images,
},
...(noIndex
? { robots: { index: false, follow: false, nocache: true, googleBot: { index: false, follow: false } } }
: {}),
};
}
/** Metadata for surfaces that must never be indexed (app, admin, auth). */
export const NO_INDEX: Metadata = {
robots: {
index: false,
follow: false,
nocache: true,
googleBot: { index: false, follow: false, noimageindex: true },
},
};
const MONTHS = [
"january", "february", "march", "april", "may", "june",
"july", "august", "september", "october", "november", "december",
];
/**
* Convert a human "Month D, YYYY" stamp (as rendered on the legal pages) into a
* calendar-only ISO-8601 date for schema.org `dateModified`.
*
* Parsed explicitly rather than via `new Date(...)` so the result can never be
* shifted a day by the server's timezone. Returns undefined for anything it does
* not recognise, so callers omit the property instead of emitting a bad date.
*/
export function toIsoDate(display: string): string | undefined {
const match = /^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/.exec(display.trim());
if (!match) return undefined;
const month = MONTHS.indexOf(match[1].toLowerCase());
if (month < 0) return undefined;
const day = Number(match[2]);
if (day < 1 || day > 31) return undefined;
return `${match[3]}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}