Security & robustness hardening pass

Cross-cutting input-validation, isolation, and DoS-resistance fixes across
the app, API, billing, queue, and infra layers.

- Runtime validation (zod) for client-supplied admin actions (role/plan/
  limits), series generation index, and all pg-boss queue payloads
- Auth: require email verification before sign-in; reject weak/placeholder/
  short BETTER_AUTH_SECRET in production
- Billing: sanitize Stripe/PayPal errors (log server-side, generic to client);
  race-safe subscription upsert; only count "processed" webhook events as
  handled; verify org membership in getEffectivePlan to block plan escalation
- Series generation: reserve usage up front and refund on failure; bill the
  owning org, not the caller's active org
- Injection defenses: HTML-escape user fields in emails, strip CR/LF from
  subject/recipient, validate ElevenLabs voiceId before URL interpolation
- Media routes: stream off disk instead of buffering whole files; rate-limit
  anonymous public audio/cover endpoints by client IP
This commit is contained in:
Leon Serfaty
2026-06-20 20:59:03 -04:00
parent cd1d6a1a28
commit 51c541ad22
21 changed files with 489 additions and 152 deletions
+65 -10
View File
@@ -1,6 +1,7 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards"; import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -43,8 +44,13 @@ export async function banUserAction(userId: string, ban: boolean): Promise<{ ok:
export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> { export async function setRoleAction(userId: string, role: "admin" | "user"): Promise<{ ok: boolean; error?: string }> {
const s = await adminSession(); const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." }; if (!s) return { ok: false, error: "Not allowed." };
await prisma.user.update({ where: { id: userId }, data: { role } }); // Don't trust the TS union at runtime — reject anything outside the allowed set.
await audit(s.user.id, "user.role", userId, { role }); const parsedRole = z.enum(["admin", "user"]).safeParse(role);
if (!parsedRole.success) return { ok: false, error: "Invalid role." };
// Self-guard: an admin must not demote/remove their own admin role and lock out.
if (userId === s.user.id) return { ok: false, error: "You can't change your own role." };
await prisma.user.update({ where: { id: userId }, data: { role: parsedRole.data } });
await audit(s.user.id, "user.role", userId, { role: parsedRole.data });
revalidatePath("/admin/users"); revalidatePath("/admin/users");
return { ok: true }; return { ok: true };
} }
@@ -87,6 +93,16 @@ export async function compPlanAction(
const s = await adminSession(); const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." }; if (!s) return { ok: false, error: "Not allowed." };
// Validate the client-supplied plan/interval at runtime, not just via the TS union.
const parsed = z
.object({
plan: z.enum(["creator", "pro", "agency"]),
interval: z.enum(["month", "year"]),
})
.safeParse({ plan, interval });
if (!parsed.success) return { ok: false, error: "Invalid plan or interval." };
({ plan, interval } = parsed.data);
const now = new Date(); const now = new Date();
const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS); const periodEnd = new Date(now.getTime() + (interval === "year" ? 365 : 30) * DAY_MS);
@@ -309,26 +325,65 @@ export interface PlanUpdateInput {
}; };
} }
// A monthly metric cap: a finite integer that is either a real non-negative cap
// or UNLIMITED (-1). Mirrors the canonical PlanLimits shape in lib/billing/plans.ts.
const quotaCap = z.number().int().gte(-1);
// Counts that can't be "unlimited": must be finite non-negative integers.
const nonNegInt = z.number().int().nonnegative();
// Validate the limits object against the known keys only — `.strict()` rejects
// extra/unknown keys so a client can't mass-assign arbitrary JSON into the column.
const planLimitsSchema = z
.object({
script: quotaCap,
audio: quotaCap,
art: quotaCap,
repurpose: quotaCap,
seats: nonNegInt,
maxEpisodeMinutes: nonNegInt,
})
.strict();
const planUpdateSchema = z.object({
key: z.string().min(1),
priceMonthly: z.number().int().nonnegative(),
priceYearly: z.number().int().nonnegative(),
limits: planLimitsSchema,
});
/** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */ /** Override a plan's price/limits in the DB (an override on lib/billing/plans.ts). */
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> { export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
const s = await adminSession(); const s = await adminSession();
if (!s) return { ok: false, error: "Not allowed." }; if (!s) return { ok: false, error: "Not allowed." };
const existing = await prisma.plan.findUnique({ where: { key } }); // Validate ALL client args (key + prices + limits) before touching the DB so
// nothing unvalidated reaches the JSON column. Prices arrive as cents; round
// to ints first to keep the existing tolerance for fractional input.
const parsed = planUpdateSchema.safeParse({
key,
priceMonthly: Math.round(input.priceMonthly),
priceYearly: Math.round(input.priceYearly),
limits: input.limits,
});
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? "Invalid plan input." };
}
const existing = await prisma.plan.findUnique({ where: { key: parsed.data.key } });
if (!existing) return { ok: false, error: "Plan not found." }; if (!existing) return { ok: false, error: "Plan not found." };
await prisma.plan.update({ await prisma.plan.update({
where: { key }, where: { key: parsed.data.key },
data: { data: {
priceMonthly: Math.max(0, Math.round(input.priceMonthly)), priceMonthly: parsed.data.priceMonthly,
priceYearly: Math.max(0, Math.round(input.priceYearly)), priceYearly: parsed.data.priceYearly,
limits: input.limits as unknown as Prisma.InputJsonValue, limits: parsed.data.limits as unknown as Prisma.InputJsonValue,
}, },
}); });
await audit(s.user.id, "plan.update", key, { await audit(s.user.id, "plan.update", parsed.data.key, {
priceMonthly: input.priceMonthly, priceMonthly: parsed.data.priceMonthly,
priceYearly: input.priceYearly, priceYearly: parsed.data.priceYearly,
}); });
revalidatePath("/admin/settings"); revalidatePath("/admin/settings");
return { ok: true }; return { ok: true };
+5 -1
View File
@@ -20,8 +20,12 @@ import type { PlanKey } from "@/lib/billing/plans";
type ActionResult = { ok: true; url?: string } | { ok: false; error: string }; type ActionResult = { ok: true; url?: string } | { ok: false; error: string };
// Billing calls hit upstream providers (Stripe/PayPal) whose error messages can
// contain sensitive/verbose detail. Log the real error server-side, but return a
// single generic message to the client so nothing upstream leaks to end users.
function errMsg(e: unknown): string { function errMsg(e: unknown): string {
return e instanceof Error ? e.message : "Something went wrong"; console.error("[billing] action failed", e);
return "Something went wrong with billing. Please try again.";
} }
export async function startStripeCheckoutAction( export async function startStripeCheckoutAction(
+37 -6
View File
@@ -5,9 +5,11 @@ import { z } from "zod";
import type { Prisma } from "@prisma/client"; import type { Prisma } from "@prisma/client";
import { getServerSession } from "@/lib/auth/guards"; import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { subjectHasFeature } from "@/lib/billing/subscription"; import { getEffectivePlan, subjectHasFeature } from "@/lib/billing/subscription";
import { enforceLimit, LimitExceededError } from "@/lib/usage/limits"; import { reserveLimit, LimitExceededError } from "@/lib/usage/limits";
import { refundUsage } from "@/lib/usage/meter";
import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss"; import { enqueueEpisodeGeneration } from "@/lib/queue/pgboss";
import type { UsageMetric } from "@/lib/billing/plans";
import { FORMAT_SPEAKERS } from "@/lib/episodes/options"; import { FORMAT_SPEAKERS } from "@/lib/episodes/options";
import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices"; import { DEFAULT_VOICE_IDS, VOICE_CATALOG } from "@/lib/ai/voices";
import { isFlagEnabled } from "@/lib/flags"; import { isFlagEnabled } from "@/lib/flags";
@@ -62,17 +64,41 @@ export async function generateFromSeriesAction(
return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." }; return { ok: false, error: "Episode generation is temporarily paused. Please try again shortly." };
} }
// `index` is client-supplied and only TS-typed — validate it at runtime.
if (!Number.isInteger(index) || index < 0) {
return { ok: false, error: "Invalid episode index." };
}
const series = await prisma.series.findUnique({ where: { id: seriesId } }); const series = await prisma.series.findUnique({ where: { id: seriesId } });
if (!series || series.userId !== session.user.id) return { ok: false, error: "Not allowed." }; if (!series || (series.userId !== session.user.id && session.user.role !== "admin")) {
return { ok: false, error: "Not allowed." };
}
const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? []; const episodes = (series.plan as unknown as { title: string; topic: string; summary: string }[]) ?? [];
const item = episodes[index]; const item = episodes[index];
if (!item) return { ok: false, error: "Episode not found in plan." }; if (!item) return { ok: false, error: "Episode not found in plan." };
// Bill the generation against the org that OWNS the series (the resource being
// acted on), and stamp the new episode with that same org, so the billing
// subject and the episode's organizationId are always consistent — regardless
// of the caller's currently-active org. getEffectivePlan verifies membership of
// series.organizationId internally and falls back to the user subject otherwise.
const orgId = series.organizationId ?? undefined;
const { subjectId, subjectType } = await getEffectivePlan(session.user.id, orgId);
// Reserve quota atomically up front (a series generation consumes script +
// audio). The worker won't re-meter; refund below if create/enqueue fails.
const reserved: UsageMetric[] = [];
const refundReserved = async () => {
for (const m of reserved) await refundUsage(subjectId, subjectType, m);
};
try { try {
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId); await reserveLimit(session.user.id, "script", orgId);
await enforceLimit(session.user.id, "audio", session.session.activeOrganizationId); reserved.push("script");
await reserveLimit(session.user.id, "audio", orgId);
reserved.push("audio");
} catch (err) { } catch (err) {
await refundReserved();
if (err instanceof LimitExceededError) { if (err instanceof LimitExceededError) {
return { ok: false, error: `Monthly ${err.check.metric} limit reached.` }; return { ok: false, error: `Monthly ${err.check.metric} limit reached.` };
} }
@@ -85,10 +111,11 @@ export async function generateFromSeriesAction(
elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id, elevenVoiceId: DEFAULT_VOICE_IDS[s.speakerKey] ?? VOICE_CATALOG[i % VOICE_CATALOG.length].id,
})); }));
try {
const episode = await prisma.episode.create({ const episode = await prisma.episode.create({
data: { data: {
userId: session.user.id, userId: session.user.id,
organizationId: series.organizationId ?? undefined, organizationId: orgId,
seriesId: series.id, seriesId: series.id,
title: item.title, title: item.title,
topic: item.topic, topic: item.topic,
@@ -106,4 +133,8 @@ export async function generateFromSeriesAction(
revalidatePath(`/series/${seriesId}`); revalidatePath(`/series/${seriesId}`);
return { ok: true, episodeId: episode.id }; return { ok: true, episodeId: episode.id };
} catch (err) {
await refundReserved();
throw err;
}
} }
+8 -5
View File
@@ -1,3 +1,4 @@
import { Readable } from "node:stream";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { getServerSession } from "@/lib/auth/guards"; import { getServerSession } from "@/lib/auth/guards";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
@@ -42,20 +43,22 @@ export async function GET(
const isAdmin = session.user.role === "admin"; const isAdmin = session.user.role === "admin";
if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 }); if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 });
const exists = await storage().exists(key); // Stream off disk instead of buffering the whole file into memory.
if (!exists) return new Response("Not found", { status: 404 }); const total = await storage().size(key);
if (total === null) return new Response("Not found", { status: 404 });
const data = await storage().get(key);
const ext = key.split(".").pop()?.toLowerCase() ?? ""; const ext = key.split(".").pop()?.toLowerCase() ?? "";
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream"; const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
const download = req.nextUrl.searchParams.get("download"); const download = req.nextUrl.searchParams.get("download");
const filename = key.split("/").pop() ?? "asset"; const filename = key.split("/").pop() ?? "asset";
return new Response(data as BodyInit, { const node = storage().createReadStream!(key);
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
return new Response(body, {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(data.byteLength), "Content-Length": String(total),
"Cache-Control": "private, max-age=3600", "Cache-Control": "private, max-age=3600",
...(download ...(download
? { "Content-Disposition": `attachment; filename="${filename}"` } ? { "Content-Disposition": `attachment; filename="${filename}"` }
@@ -1,20 +1,39 @@
import { Readable } from "node:stream";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rateLimit, LIMITS } from "@/lib/ratelimit";
import { storage } from "@/lib/storage"; import { storage } from "@/lib/storage";
export const dynamic = "force-dynamic"; 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";
}
/** /**
* Stream an episode's MP3 to anonymous visitors, authorized purely by a valid, * Stream an episode's MP3 to anonymous visitors, authorized purely by a valid,
* still-enabled public `shareId` (NOT a session). Returns 404 when the share is * still-enabled public `shareId` (NOT a session). Returns 404 when the share is
* disabled or the audio is missing so we never disclose private episode state. * disabled or the audio is missing so we never disclose private episode state.
* *
* Supports HTTP Range requests so the audio element can seek/scrub. * Supports HTTP Range requests so the audio element can seek/scrub. The file is
* streamed off disk (never buffered whole) to avoid memory-amplification DoS.
*/ */
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ shareId: string }> } { params }: { params: Promise<{ shareId: string }> }
) { ) {
// Rate-limit by client IP (never by shareId alone).
const rl = await rateLimit("public-audio", clientKey(req), LIMITS.publicMedia);
if (!rl.ok) {
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(rl.retryAfterSec ?? 1) },
});
}
const { shareId } = await params; const { shareId } = await params;
const episode = await prisma.episode.findUnique({ const episode = await prisma.episode.findUnique({
@@ -24,10 +43,9 @@ export async function GET(
const key = episode?.audioAsset?.storageKey; const key = episode?.audioAsset?.storageKey;
if (!key) return new Response("Not found", { status: 404 }); if (!key) return new Response("Not found", { status: 404 });
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 }); const total = await storage().size(key);
if (total === null) return new Response("Not found", { status: 404 });
const data = await storage().get(key);
const total = data.byteLength;
const contentType = "audio/mpeg"; const contentType = "audio/mpeg";
const range = req.headers.get("range"); const range = req.headers.get("range");
@@ -37,12 +55,13 @@ export async function GET(
const start = Number(match[1]); const start = Number(match[1]);
const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1; const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
if (start <= end && start < total) { if (start <= end && start < total) {
const chunk = data.subarray(start, end + 1); const node = storage().createReadStream!(key, { start, end });
return new Response(chunk as BodyInit, { const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
return new Response(body, {
status: 206, status: 206,
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(chunk.byteLength), "Content-Length": String(end - start + 1),
"Content-Range": `bytes ${start}-${end}/${total}`, "Content-Range": `bytes ${start}-${end}/${total}`,
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600", "Cache-Control": "public, max-age=3600",
@@ -52,7 +71,9 @@ export async function GET(
} }
} }
return new Response(data as BodyInit, { const node = storage().createReadStream!(key);
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
return new Response(body, {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Content-Length": String(total), "Content-Length": String(total),
@@ -1,5 +1,7 @@
import { Readable } from "node:stream";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rateLimit, LIMITS } from "@/lib/ratelimit";
import { storage } from "@/lib/storage"; import { storage } from "@/lib/storage";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -11,15 +13,32 @@ const CONTENT_TYPES: Record<string, string> = {
webp: "image/webp", webp: "image/webp",
}; };
/** 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";
}
/** /**
* Serve an episode's cover art to anonymous visitors, authorized by a valid, * Serve an episode's cover art to anonymous visitors, authorized by a valid,
* still-enabled public `shareId`. Used as a fallback when the storage provider * still-enabled public `shareId`. Used as a fallback when the storage provider
* doesn't expose a directly-fetchable public URL for cover art. * doesn't expose a directly-fetchable public URL for cover art. The file is
* streamed off disk rather than buffered whole to avoid memory-amplification DoS.
*/ */
export async function GET( export async function GET(
_req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ shareId: string }> } { params }: { params: Promise<{ shareId: string }> }
) { ) {
// Rate-limit by client IP (never by shareId alone).
const rl = await rateLimit("public-cover", clientKey(req), LIMITS.publicMedia);
if (!rl.ok) {
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(rl.retryAfterSec ?? 1) },
});
}
const { shareId } = await params; const { shareId } = await params;
const episode = await prisma.episode.findUnique({ const episode = await prisma.episode.findUnique({
@@ -28,14 +47,17 @@ export async function GET(
}); });
const key = episode?.coverArt?.storageKey; const key = episode?.coverArt?.storageKey;
if (!key) return new Response("Not found", { status: 404 }); if (!key) return new Response("Not found", { status: 404 });
if (!(await storage().exists(key))) return new Response("Not found", { status: 404 });
const data = await storage().get(key); const total = await storage().size(key);
if (total === null) return new Response("Not found", { status: 404 });
const ext = key.split(".").pop()?.toLowerCase() ?? "png"; const ext = key.split(".").pop()?.toLowerCase() ?? "png";
return new Response(data as BodyInit, { const node = storage().createReadStream!(key);
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
return new Response(body, {
headers: { headers: {
"Content-Type": CONTENT_TYPES[ext] ?? "image/png", "Content-Type": CONTENT_TYPES[ext] ?? "image/png",
"Content-Length": String(data.byteLength), "Content-Length": String(total),
"Cache-Control": "public, max-age=3600", "Cache-Control": "public, max-age=3600",
}, },
}); });
+5 -2
View File
@@ -10,7 +10,7 @@ import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/co
import { refundUsage } from "@/lib/usage/meter"; import { refundUsage } from "@/lib/usage/meter";
import { isFlagEnabled } from "@/lib/flags"; import { isFlagEnabled } from "@/lib/flags";
import { moderateText, moderationReason } from "@/lib/ai/moderation"; import { moderateText, moderationReason } from "@/lib/ai/moderation";
import { sendEmail, emailLayout } from "@/lib/email"; import { sendEmail, emailLayout, escapeHtml } from "@/lib/email";
import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices"; import { DEFAULT_VOICE_IDS } from "@/lib/ai/voices";
import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types"; import type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
import type { GenerationType } from "@/lib/queue/jobs"; import type { GenerationType } from "@/lib/queue/jobs";
@@ -262,13 +262,16 @@ async function generateArt(episode: EpisodeWithRelations) {
async function notifyReady(episode: EpisodeWithRelations) { async function notifyReady(episode: EpisodeWithRelations) {
const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"; const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
// `emailLayout` interpolates the body as RAW HTML, so user-controlled fields
// (the episode title) must be escaped before being placed into it.
const safeTitle = escapeHtml(episode.title ?? "");
try { try {
await sendEmail({ await sendEmail({
to: episode.user.email, to: episode.user.email,
subject: `🎙️ "${episode.title}" is ready`, subject: `🎙️ "${episode.title}" is ready`,
html: emailLayout( html: emailLayout(
"Your episode is ready", "Your episode is ready",
`${episode.title}” has finished generating — script, audio, and cover art are all set.`, `${safeTitle}” has finished generating — script, audio, and cover art are all set.`,
{ label: "Open episode", url: `${appUrl}/episodes/${episode.id}` } { label: "Open episode", url: `${appUrl}/episodes/${episode.id}` }
), ),
text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`, text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`,
+12 -1
View File
@@ -5,12 +5,23 @@ const TTS_MODEL = process.env.ELEVENLABS_TTS_MODEL ?? "eleven_multilingual_v2";
const DIALOGUE_MODEL = process.env.ELEVENLABS_DIALOGUE_MODEL ?? "eleven_v3"; const DIALOGUE_MODEL = process.env.ELEVENLABS_DIALOGUE_MODEL ?? "eleven_v3";
const OUTPUT_FORMAT = "mp3_44100_128"; const OUTPUT_FORMAT = "mp3_44100_128";
/** ElevenLabs voice IDs are opaque alphanumeric tokens; reject anything else. */
const VOICE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
function apiKey(): string { function apiKey(): string {
const k = process.env.ELEVENLABS_API_KEY; const k = process.env.ELEVENLABS_API_KEY;
if (!k) throw new Error("ELEVENLABS_API_KEY is not set"); if (!k) throw new Error("ELEVENLABS_API_KEY is not set");
return k; return k;
} }
/** Validate a voice ID before it is interpolated into a request URL path. */
function safeVoiceId(voiceId: string): string {
if (!VOICE_ID_PATTERN.test(voiceId)) {
throw new Error(`Invalid ElevenLabs voiceId: ${voiceId}`);
}
return encodeURIComponent(voiceId);
}
interface ElevenVoice { interface ElevenVoice {
voice_id: string; voice_id: string;
name: string; name: string;
@@ -28,7 +39,7 @@ export class ElevenLabsAudioProvider implements AudioProvider {
_opts?: { language?: string } _opts?: { language?: string }
): Promise<{ audio: Buffer; characters: number }> { ): Promise<{ audio: Buffer; characters: number }> {
const res = await fetch( const res = await fetch(
`${API}/text-to-speech/${voiceId}?output_format=${OUTPUT_FORMAT}`, `${API}/text-to-speech/${safeVoiceId(voiceId)}?output_format=${OUTPUT_FORMAT}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
+17 -9
View File
@@ -9,10 +9,19 @@ const appUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET); const googleConfigured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET);
// Fail fast in production if the signing secret is missing — sessions/cookies are // Fail fast in production if the signing secret is missing, too short, or a known
// only secure when BETTER_AUTH_SECRET is set. Stay frictionless in dev/test. // placeholder — sessions/cookies are only secure when BETTER_AUTH_SECRET is a strong,
if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV === "production") { // non-default value. Stay frictionless in dev/test.
throw new Error("BETTER_AUTH_SECRET must be set in production."); const KNOWN_WEAK_SECRETS = new Set([
"dev-secret-please-change-0123456789abcdef",
]);
const authSecret = process.env.BETTER_AUTH_SECRET;
const secretIsWeak =
!authSecret || authSecret.length < 32 || KNOWN_WEAK_SECRETS.has(authSecret);
if (secretIsWeak && process.env.NODE_ENV === "production") {
throw new Error(
"BETTER_AUTH_SECRET must be set in production to a strong value (>= 32 chars, not a known placeholder)."
);
} }
export const auth = betterAuth({ export const auth = betterAuth({
@@ -27,11 +36,10 @@ export const auth = betterAuth({
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
// SECURITY GATE (currently OPEN): unverified emails can sign in. Flip this to // SECURITY GATE: unverified emails CANNOT sign in. Verification emails are sent
// `true` once email delivery is confirmed working in prod. Left `false` for now // on signup (see emailVerification.sendOnSignUp below), so users can verify before
// so existing dev accounts aren't locked out. Verification emails ARE sent on // their first login.
// signup (see emailVerification.sendOnSignUp below), so users can verify already. requireEmailVerification: true,
requireEmailVerification: false,
minPasswordLength: 8, minPasswordLength: 8,
async sendResetPassword({ user, url }) { async sendResetPassword({ user, url }) {
await sendEmail({ await sendEmail({
+8 -2
View File
@@ -70,7 +70,11 @@ export async function createPaypalSubscription(args: {
}, },
}), }),
}); });
if (!res.ok) throw new Error(`PayPal create subscription ${res.status}: ${await res.text()}`); if (!res.ok) {
// Log the full upstream detail server-side, but never surface it to clients.
console.error(`[paypal] create subscription ${res.status}: ${await res.text()}`);
throw new Error("PayPal request failed");
}
const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] }; const data = (await res.json()) as { id: string; links: { rel: string; href: string }[] };
const approveUrl = data.links.find((l) => l.rel === "approve")?.href; const approveUrl = data.links.find((l) => l.rel === "approve")?.href;
if (!approveUrl) throw new Error("PayPal did not return an approval URL"); if (!approveUrl) throw new Error("PayPal did not return an approval URL");
@@ -94,7 +98,9 @@ export async function cancelPaypalSubscription(id: string, reason = "Customer re
body: JSON.stringify({ reason }), body: JSON.stringify({ reason }),
}); });
if (!res.ok && res.status !== 204) { if (!res.ok && res.status !== 204) {
throw new Error(`PayPal cancel subscription ${res.status}: ${await res.text()}`); // Log the full upstream detail server-side, but never surface it to clients.
console.error(`[paypal] cancel subscription ${res.status}: ${await res.text()}`);
throw new Error("PayPal request failed");
} }
} }
+26 -12
View File
@@ -22,16 +22,6 @@ export interface UpsertSubscriptionInput {
* provider subscription id, so duplicate/replayed webhooks converge on one row. * provider subscription id, so duplicate/replayed webhooks converge on one row.
*/ */
export async function upsertSubscription(input: UpsertSubscriptionInput) { export async function upsertSubscription(input: UpsertSubscriptionInput) {
const existing = input.stripeSubscriptionId
? await prisma.subscription.findFirst({
where: { stripeSubscriptionId: input.stripeSubscriptionId },
})
: input.paypalSubscriptionId
? await prisma.subscription.findFirst({
where: { paypalSubscriptionId: input.paypalSubscriptionId },
})
: null;
const data = { const data = {
provider: input.provider, provider: input.provider,
plan: input.plan, plan: input.plan,
@@ -47,9 +37,25 @@ export async function upsertSubscription(input: UpsertSubscriptionInput) {
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined, cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined,
}; };
if (existing) { // Atomic upsert keyed on whichever provider subscription id is present on the
return prisma.subscription.update({ where: { id: existing.id }, data }); // incoming record. The DB-level @@unique on these columns lets concurrent
// webhook retries converge on a single row instead of racing into duplicates.
if (input.stripeSubscriptionId) {
return prisma.subscription.upsert({
where: { stripeSubscriptionId: input.stripeSubscriptionId },
create: { referenceId: input.referenceId, ...data },
update: data,
});
} }
if (input.paypalSubscriptionId) {
return prisma.subscription.upsert({
where: { paypalSubscriptionId: input.paypalSubscriptionId },
create: { referenceId: input.referenceId, ...data },
update: data,
});
}
// Safe fallback: neither provider id is present (no unique key to upsert on),
// so create a fresh row.
return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } }); return prisma.subscription.create({ data: { referenceId: input.referenceId, ...data } });
} }
@@ -85,11 +91,19 @@ export async function getEffectivePlan(
activeOrgId?: string | null activeOrgId?: string | null
): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> { ): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> {
if (activeOrgId) { if (activeOrgId) {
// Only grant the org's plan if the user is an actual member of that org.
// A stale/forged activeOrganizationId must not elevate a non-member.
const membership = await prisma.member.findUnique({
where: { organizationId_userId: { organizationId: activeOrgId, userId } },
select: { id: true },
});
if (membership) {
const key = await getSubjectPlanKey(activeOrgId); const key = await getSubjectPlanKey(activeOrgId);
if (key !== "free") { if (key !== "free") {
return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" }; return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" };
} }
} }
}
const key = await getSubjectPlanKey(userId); const key = await getSubjectPlanKey(userId);
return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" }; return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" };
} }
+3 -1
View File
@@ -3,7 +3,9 @@ import { prisma } from "@/lib/db";
/** True if we've already handled this provider event (idempotency). */ /** True if we've already handled this provider event (idempotency). */
export async function alreadyProcessed(eventId: string): Promise<boolean> { export async function alreadyProcessed(eventId: string): Promise<boolean> {
const existing = await prisma.webhookEvent.findUnique({ where: { eventId } }); const existing = await prisma.webhookEvent.findUnique({ where: { eventId } });
return !!existing; // Only a successfully "processed" event is considered handled. Rows logged as
// "failed" (or "skipped") must be reprocessable when the provider retries.
return existing?.status === "processed";
} }
/** Record a webhook delivery for the admin log (best-effort; unique on eventId). */ /** Record a webhook delivery for the admin log (best-effort; unique on eventId). */
+14 -3
View File
@@ -12,19 +12,30 @@ const FROM = process.env.EMAIL_FROM ?? "Podcast Distribution AI <noreply@podcast
* console (useful in local dev before RESEND_API_KEY is set). * console (useful in local dev before RESEND_API_KEY is set).
*/ */
export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise<void> { export async function sendEmail({ to, subject, html, text }: SendEmailInput): Promise<void> {
// Defense-in-depth header hygiene: strip CR/LF and other control chars so a
// user-controlled subject/recipient can't inject extra email headers.
const safeSubject = stripControlChars(subject).replace(/[\r\n]+/g, " ").trim();
const safeTo = stripControlChars(to).replace(/[\r\n]+/g, " ").trim();
const apiKey = process.env.RESEND_API_KEY; const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) { if (!apiKey) {
console.info(`[email:dev] To: ${to} | Subject: ${subject}\n${text ?? html}`); console.info(`[email:dev] To: ${safeTo} | Subject: ${safeSubject}\n${text ?? html}`);
return; return;
} }
const { Resend } = await import("resend"); const { Resend } = await import("resend");
const resend = new Resend(apiKey); const resend = new Resend(apiKey);
const { error } = await resend.emails.send({ from: FROM, to, subject, html, text }); const { error } = await resend.emails.send({ from: FROM, to: safeTo, subject: safeSubject, html, text });
if (error) throw new Error(`Resend error: ${error.message}`); if (error) throw new Error(`Resend error: ${error.message}`);
} }
/** Remove CR/LF and other ASCII control characters (header-injection defense). */
function stripControlChars(value: string): string {
// eslint-disable-next-line no-control-regex
return value.replace(/[\x00-\x1f\x7f]+/g, " ");
}
/** Escape text for safe interpolation into HTML/attribute contexts. */ /** Escape text for safe interpolation into HTML/attribute contexts. */
function escapeHtml(value: string): string { export function escapeHtml(value: string): string {
return value return value
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
+31 -17
View File
@@ -1,5 +1,7 @@
/** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */ /** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */
import { z } from "zod";
export const QUEUES = { export const QUEUES = {
generateEpisode: "episode.generate", generateEpisode: "episode.generate",
generateSeries: "series.generate", generateSeries: "series.generate",
@@ -10,29 +12,41 @@ export const QUEUES = {
export type QueueName = (typeof QUEUES)[keyof typeof QUEUES]; export type QueueName = (typeof QUEUES)[keyof typeof QUEUES];
export type GenerationType = "full" | "script" | "audio" | "art" | "section" | "repurpose"; export const generationTypeSchema = z.enum([
"full",
"script",
"audio",
"art",
"section",
"repurpose",
]);
export type GenerationType = z.infer<typeof generationTypeSchema>;
export interface GenerateEpisodePayload { export const generateEpisodePayloadSchema = z.object({
episodeId: string; episodeId: z.string().min(1),
/** "full" runs the whole pipeline; the others re-run a single stage. */ /** "full" runs the whole pipeline; the others re-run a single stage. */
type?: GenerationType; type: generationTypeSchema.optional(),
/** For type="section", the script section to regenerate. */ /** For type="section", the script section to regenerate. */
sectionId?: string; sectionId: z.string().optional(),
} });
export type GenerateEpisodePayload = z.infer<typeof generateEpisodePayloadSchema>;
export interface RepurposePayload { export const repurposePayloadSchema = z.object({
episodeId: string; episodeId: z.string().min(1),
format: "blog" | "social_thread" | "newsletter"; format: z.enum(["blog", "social_thread", "newsletter"]),
} });
export type RepurposePayload = z.infer<typeof repurposePayloadSchema>;
export interface GenerateSeriesPayload { export const generateSeriesPayloadSchema = z.object({
seriesId: string; seriesId: z.string().min(1),
} });
export type GenerateSeriesPayload = z.infer<typeof generateSeriesPayloadSchema>;
export interface EchoPayload { export const echoPayloadSchema = z.object({
message: string; message: z.string(),
episodeId?: string; episodeId: z.string().optional(),
} });
export type EchoPayload = z.infer<typeof echoPayloadSchema>;
/** All queues that must exist before send/work. */ /** All queues that must exist before send/work. */
export const ALL_QUEUES: QueueName[] = Object.values(QUEUES); export const ALL_QUEUES: QueueName[] = Object.values(QUEUES);
+51 -5
View File
@@ -1,13 +1,58 @@
import { RateLimiterMemory } from "rate-limiter-flexible"; import { Pool } from "pg";
import {
RateLimiterMemory,
RateLimiterPostgres,
type RateLimiterAbstract,
} from "rate-limiter-flexible";
// In-memory limiters (no Redis). Fine for a single-instance Plesk deployment; /**
// swap for RateLimiterPostgres if the app is ever scaled to multiple nodes. * Backend selection (default = in-memory):
const limiters = new Map<string, RateLimiterMemory>(); *
* - DEFAULT: RateLimiterMemory — per-process counters. Fine for a single
* instance (the Plesk VPS). No DB connection is ever opened in this mode.
* - OPT-IN: set RATE_LIMIT_BACKEND="postgres" *and* provide DATABASE_URL to
* share limits across multiple instances via RateLimiterPostgres. The
* limiter table ("rate_limits") is created automatically by
* rate-limiter-flexible on first use, so no migration is required.
*
* The pg Pool is created lazily on first consume — importing this module never
* connects to the database, so memory mode stays connection-free.
*/
const usePostgres =
process.env.RATE_LIMIT_BACKEND === "postgres" && !!process.env.DATABASE_URL;
function getLimiter(name: string, points: number, durationSec: number): RateLimiterMemory { const limiters = new Map<string, RateLimiterAbstract>();
// Lazily-created shared pg Pool. Constructing it does NOT open a connection
// (pg connects on first query), and it is only built when a Postgres-backed
// limiter is first needed — so memory mode never creates a pool.
let pgPool: Pool | null = null;
function getPool(): Pool {
if (!pgPool) {
pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
}
return pgPool;
}
function getLimiter(name: string, points: number, durationSec: number): RateLimiterAbstract {
let limiter = limiters.get(name); let limiter = limiters.get(name);
if (!limiter) { if (!limiter) {
if (usePostgres) {
// The constructor opens the (lazy) pool and ensures the backing table
// exists; ready before the first consume() thanks to the insurance limiter.
limiter = new RateLimiterPostgres({
storeClient: getPool(),
tableName: "rate_limits",
keyPrefix: name,
points,
duration: durationSec,
// If Postgres is briefly unreachable, degrade to per-process counting
// instead of failing the request outright.
insuranceLimiter: new RateLimiterMemory({ points, duration: durationSec }),
});
} else {
limiter = new RateLimiterMemory({ points, duration: durationSec }); limiter = new RateLimiterMemory({ points, duration: durationSec });
}
limiters.set(name, limiter); limiters.set(name, limiter);
} }
return limiter; return limiter;
@@ -41,4 +86,5 @@ export const LIMITS = {
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes) api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes)
read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key read: { points: 120, durationSec: 60 }, // 120 read/list calls / min / key
stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user stream: { points: 30, durationSec: 60 }, // SSE (re)connects / min / user
publicMedia: { points: 120, durationSec: 60 }, // anon audio/cover (Range) reqs / min / IP
} as const; } as const;
+12 -1
View File
@@ -1,4 +1,4 @@
import { promises as fs } from "node:fs"; import { promises as fs, createReadStream as fsCreateReadStream } from "node:fs";
import path from "node:path"; import path from "node:path";
import type { StorageProvider } from "./types"; import type { StorageProvider } from "./types";
@@ -29,6 +29,17 @@ export class LocalStorageProvider implements StorageProvider {
return fs.readFile(resolveSafe(key)); return fs.readFile(resolveSafe(key));
} }
/**
* Stream a file (optionally an inclusive byte range) without buffering it all
* into memory. Goes through resolveSafe so path-traversal protection holds.
*/
createReadStream(key: string, range?: { start: number; end: number }): NodeJS.ReadableStream {
const full = resolveSafe(key);
return range
? fsCreateReadStream(full, { start: range.start, end: range.end })
: fsCreateReadStream(full);
}
async exists(key: string): Promise<boolean> { async exists(key: string): Promise<boolean> {
try { try {
await fs.access(resolveSafe(key)); await fs.access(resolveSafe(key));
+10
View File
@@ -9,6 +9,16 @@ export interface StorageProvider {
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>; put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
/** Read the full object as a Buffer. */ /** Read the full object as a Buffer. */
get(key: string): Promise<Buffer>; get(key: string): Promise<Buffer>;
/**
* Stream the object (optionally a byte range, inclusive) instead of buffering
* it all into memory — used by the audio/asset routes to avoid memory
* amplification under concurrent load. Optional: providers may omit it, in
* which case callers fall back to get().
*/
createReadStream?(
key: string,
range?: { start: number; end: number }
): NodeJS.ReadableStream;
/** Whether an object exists at `key`. */ /** Whether an object exists at `key`. */
exists(key: string): Promise<boolean>; exists(key: string): Promise<boolean>;
/** Remove the object (no-op if missing). */ /** Remove the object (no-op if missing). */
+65 -15
View File
@@ -4,35 +4,85 @@ import { NextRequest, NextResponse } from "next/server";
// "__Secure-" variant is used when cookies are served over HTTPS in production. // "__Secure-" variant is used when cookies are served over HTTPS in production.
const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"]; const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"];
// Authed surfaces that require an optimistic session-cookie check. Anonymous users
// hitting these are redirected to /sign-in. Public/marketing/auth routes are NOT
// listed here, so they are never redirected (CSP still applies to them, below).
const AUTHED_PREFIXES = [
"/dashboard",
"/episodes",
"/series",
"/usage",
"/billing",
"/team",
"/api-keys",
"/settings",
"/admin",
];
/** /**
* Optimistic edge gate: redirect anonymous users away from authed surfaces. * Runs on every request (see matcher). Two responsibilities:
* Only checks for the *presence* of a session cookie — real session validation *
* (and admin/role checks) happen in the route-group layouts. Reading the cookie * 1. CSP/nonce (all routes): generate a per-request base64 nonce with the Web Crypto
* directly keeps the middleware bundle free of the auth/jose internals. * API (Edge-safe — no node:crypto), expose it on the inbound `x-nonce` request
* header, and set a nonce-based Content-Security-Policy response header. Next.js
* auto-applies this nonce to its own framework <script> tags when the `x-nonce`
* request header is present; the root layout may also read it via `headers()` to
* nonce any manual inline scripts.
*
* 2. Optimistic edge gate (authed prefixes only): redirect anonymous users away from
* authed surfaces. Only checks for the *presence* of a session cookie — real
* session validation (and admin/role checks) happen in the route-group layouts.
*/ */
export function middleware(req: NextRequest) { export function middleware(req: NextRequest) {
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
const { pathname, search } = req.nextUrl; const { pathname, search } = req.nextUrl;
// Per-request nonce (base64). randomUUID is Edge-runtime safe and unguessable.
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://oaidalleapiprodscus.blob.core.windows.net https://images.unsplash.com",
"media-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
// Optimistic auth gate for the previously-matched authed prefixes only.
const isAuthedPath = AUTHED_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(p + "/")
);
if (isAuthedPath) {
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
if (!hasSession) { if (!hasSession) {
const signIn = new URL("/sign-in", req.url); const signIn = new URL("/sign-in", req.url);
signIn.searchParams.set("redirect", pathname + search); signIn.searchParams.set("redirect", pathname + search);
return NextResponse.redirect(signIn); return NextResponse.redirect(signIn);
} }
}
return NextResponse.next(); // Forward the nonce to the app via a request header, and set the CSP on the response.
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set("Content-Security-Policy", csp);
return res;
} }
export const config = { export const config = {
matcher: [ matcher: [
"/dashboard/:path*", // Run on every request EXCEPT static assets so CSP applies app-wide while
"/episodes/:path*", // avoiding unnecessary work on prefetched/static files.
"/series/:path*", {
"/usage/:path*", source: "/((?!_next/static|_next/image|favicon.ico).*)",
"/billing/:path*", missing: [
"/team/:path*", { type: "header", key: "next-router-prefetch" },
"/api-keys/:path*", { type: "header", key: "purpose", value: "prefetch" },
"/settings/:path*", ],
"/admin/:path*", },
], ],
}; };
+3 -16
View File
@@ -19,22 +19,10 @@ const nextConfig = {
// Server-only packages that should be required at runtime, not bundled by webpack. // Server-only packages that should be required at runtime, not bundled by webpack.
// better-auth ships internal adapters (kysely) that break webpack's ESM analysis. // better-auth ships internal adapters (kysely) that break webpack's ESM analysis.
serverExternalPackages: ["pg-boss", "@prisma/client", "better-auth"], serverExternalPackages: ["pg-boss", "@prisma/client", "better-auth"],
// Security headers applied to every route. // Static security headers applied to every route. The Content-Security-Policy is
// intentionally NOT set here — it is built per-request with a nonce in middleware.ts
// so script-src can drop 'unsafe-inline'/'unsafe-eval' (nonce + 'strict-dynamic').
async headers() { async headers() {
// Pragmatic CSP: Next.js's inline/runtime bootstrap currently requires
// 'unsafe-inline'/'unsafe-eval' in script-src. Future improvement: tighten
// to per-request nonces (and drop 'unsafe-*') once the app is migrated.
const csp = [
"default-src 'self'",
"img-src 'self' data: https://*.blob.core.windows.net https://images.unsplash.com",
"media-src 'self'",
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
return [ return [
{ {
source: "/:path*", source: "/:path*",
@@ -48,7 +36,6 @@ const nextConfig = {
key: "Strict-Transport-Security", key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload", value: "max-age=63072000; includeSubDomains; preload",
}, },
{ key: "Content-Security-Policy", value: csp },
], ],
}, },
]; ];
+10 -2
View File
@@ -211,8 +211,16 @@ model Subscription {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([referenceId]) @@index([referenceId])
@@index([stripeSubscriptionId]) // Provider subscription ids are unique so concurrent/replayed webhooks can't
@@index([paypalSubscriptionId]) // create duplicate rows (atomic upsert keys on these). Nullable: Postgres
// treats multiple NULLs as distinct, so existing free/null rows are unaffected.
// @@unique already creates a backing index, so no separate @@index is needed.
// MIGRATION REQUIRED: these new @@unique constraints must be generated and
// applied separately by the operator (`prisma migrate dev` / `migrate deploy`).
// The migration will FAIL if duplicate non-null values already exist in the
// table — de-dupe those rows first before applying.
@@unique([stripeSubscriptionId])
@@unique([paypalSubscriptionId])
@@index([status]) @@index([status])
@@index([createdAt]) @@index([createdAt])
@@map("subscription") @@map("subscription")
+23 -3
View File
@@ -1,6 +1,12 @@
import "dotenv/config"; import "dotenv/config";
import { getBoss } from "@/lib/queue/pgboss"; import { getBoss } from "@/lib/queue/pgboss";
import { QUEUES, type GenerateEpisodePayload, type EchoPayload } from "@/lib/queue/jobs"; import {
QUEUES,
generateEpisodePayloadSchema,
echoPayloadSchema,
type GenerateEpisodePayload,
type EchoPayload,
} from "@/lib/queue/jobs";
import { runEpisodeGeneration, refundEpisodeUsage } from "@/lib/ai/pipeline/generate-episode"; import { runEpisodeGeneration, refundEpisodeUsage } from "@/lib/ai/pipeline/generate-episode";
import { setEpisodeStatus } from "@/lib/episodes/status"; import { setEpisodeStatus } from "@/lib/episodes/status";
import { recordHeartbeat } from "@/lib/queue/health"; import { recordHeartbeat } from "@/lib/queue/health";
@@ -21,7 +27,14 @@ async function main() {
// Proof-of-loop queue used by health checks / verification. // Proof-of-loop queue used by health checks / verification.
await boss.work<EchoPayload>(QUEUES.echo, { batchSize: 1 }, async (jobs) => { await boss.work<EchoPayload>(QUEUES.echo, { batchSize: 1 }, async (jobs) => {
for (const job of jobs) console.log("[echo]", job.data); for (const job of jobs) {
const parsed = echoPayloadSchema.safeParse(job.data);
if (!parsed.success) {
console.error("[echo] invalid payload — skipping job", parsed.error.issues);
continue;
}
console.log("[echo]", parsed.data);
}
}); });
// Episode generation. batchSize 1 = independent retries per job. // Episode generation. batchSize 1 = independent retries per job.
@@ -49,7 +62,14 @@ async function handleGenerate(job: {
retryCount?: number; retryCount?: number;
retryLimit?: number; retryLimit?: number;
}) { }) {
const { episodeId, type } = job.data; // Runtime-validate the payload at the consume boundary. An invalid payload is
// not retryable, so skip it rather than throwing into the work loop.
const parsed = generateEpisodePayloadSchema.safeParse(job.data);
if (!parsed.success) {
console.error("[generate] invalid payload — skipping job", parsed.error.issues);
return;
}
const { episodeId, type } = parsed.data;
try { try {
await runEpisodeGeneration(episodeId, type ?? "full"); await runEpisodeGeneration(episodeId, type ?? "full");
} catch (err) { } catch (err) {