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:
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
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 }> {
|
||||
const s = await adminSession();
|
||||
if (!s) return { ok: false, error: "Not allowed." };
|
||||
await prisma.user.update({ where: { id: userId }, data: { role } });
|
||||
await audit(s.user.id, "user.role", userId, { role });
|
||||
// Don't trust the TS union at runtime — reject anything outside the allowed set.
|
||||
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");
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -87,6 +93,16 @@ export async function compPlanAction(
|
||||
const s = await adminSession();
|
||||
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 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). */
|
||||
export async function updatePlanAction(key: string, input: PlanUpdateInput): Promise<ActionResult> {
|
||||
const s = await adminSession();
|
||||
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." };
|
||||
|
||||
await prisma.plan.update({
|
||||
where: { key },
|
||||
where: { key: parsed.data.key },
|
||||
data: {
|
||||
priceMonthly: Math.max(0, Math.round(input.priceMonthly)),
|
||||
priceYearly: Math.max(0, Math.round(input.priceYearly)),
|
||||
limits: input.limits as unknown as Prisma.InputJsonValue,
|
||||
priceMonthly: parsed.data.priceMonthly,
|
||||
priceYearly: parsed.data.priceYearly,
|
||||
limits: parsed.data.limits as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
await audit(s.user.id, "plan.update", key, {
|
||||
priceMonthly: input.priceMonthly,
|
||||
priceYearly: input.priceYearly,
|
||||
await audit(s.user.id, "plan.update", parsed.data.key, {
|
||||
priceMonthly: parsed.data.priceMonthly,
|
||||
priceYearly: parsed.data.priceYearly,
|
||||
});
|
||||
revalidatePath("/admin/settings");
|
||||
return { ok: true };
|
||||
|
||||
@@ -20,8 +20,12 @@ import type { PlanKey } from "@/lib/billing/plans";
|
||||
|
||||
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 {
|
||||
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(
|
||||
|
||||
@@ -5,9 +5,11 @@ import { z } from "zod";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { subjectHasFeature } from "@/lib/billing/subscription";
|
||||
import { enforceLimit, LimitExceededError } from "@/lib/usage/limits";
|
||||
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";
|
||||
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";
|
||||
@@ -62,17 +64,41 @@ export async function generateFromSeriesAction(
|
||||
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 } });
|
||||
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 item = episodes[index];
|
||||
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 {
|
||||
await enforceLimit(session.user.id, "script", session.session.activeOrganizationId);
|
||||
await enforceLimit(session.user.id, "audio", session.session.activeOrganizationId);
|
||||
await reserveLimit(session.user.id, "script", orgId);
|
||||
reserved.push("script");
|
||||
await reserveLimit(session.user.id, "audio", orgId);
|
||||
reserved.push("audio");
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
if (err instanceof LimitExceededError) {
|
||||
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,
|
||||
}));
|
||||
|
||||
try {
|
||||
const episode = await prisma.episode.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
organizationId: series.organizationId ?? undefined,
|
||||
organizationId: orgId,
|
||||
seriesId: series.id,
|
||||
title: item.title,
|
||||
topic: item.topic,
|
||||
@@ -106,4 +133,8 @@ export async function generateFromSeriesAction(
|
||||
|
||||
revalidatePath(`/series/${seriesId}`);
|
||||
return { ok: true, episodeId: episode.id };
|
||||
} catch (err) {
|
||||
await refundReserved();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getServerSession } from "@/lib/auth/guards";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -42,20 +43,22 @@ export async function GET(
|
||||
const isAdmin = session.user.role === "admin";
|
||||
if (!isOwner && !isAdmin) return new Response("Forbidden", { status: 403 });
|
||||
|
||||
const exists = await storage().exists(key);
|
||||
if (!exists) return new Response("Not found", { status: 404 });
|
||||
// Stream off disk instead of buffering the whole file into memory.
|
||||
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 contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
||||
|
||||
const download = req.nextUrl.searchParams.get("download");
|
||||
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: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(data.byteLength),
|
||||
"Content-Length": String(total),
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
...(download
|
||||
? { "Content-Disposition": `attachment; filename="${filename}"` }
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
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,
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
req: NextRequest,
|
||||
{ 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 episode = await prisma.episode.findUnique({
|
||||
@@ -24,10 +43,9 @@ export async function GET(
|
||||
const key = episode?.audioAsset?.storageKey;
|
||||
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 range = req.headers.get("range");
|
||||
@@ -37,12 +55,13 @@ export async function GET(
|
||||
const start = Number(match[1]);
|
||||
const end = match[2] ? Math.min(Number(match[2]), total - 1) : total - 1;
|
||||
if (start <= end && start < total) {
|
||||
const chunk = data.subarray(start, end + 1);
|
||||
return new Response(chunk as BodyInit, {
|
||||
const node = storage().createReadStream!(key, { start, end });
|
||||
const body = Readable.toWeb(node as Readable) as unknown as BodyInit;
|
||||
return new Response(body, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(chunk.byteLength),
|
||||
"Content-Length": String(end - start + 1),
|
||||
"Content-Range": `bytes ${start}-${end}/${total}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"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: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(total),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { rateLimit, LIMITS } from "@/lib/ratelimit";
|
||||
import { storage } from "@/lib/storage";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,15 +13,32 @@ const CONTENT_TYPES: Record<string, string> = {
|
||||
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,
|
||||
* 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(
|
||||
_req: NextRequest,
|
||||
req: NextRequest,
|
||||
{ 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 episode = await prisma.episode.findUnique({
|
||||
@@ -28,14 +47,17 @@ export async function GET(
|
||||
});
|
||||
const key = episode?.coverArt?.storageKey;
|
||||
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";
|
||||
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: {
|
||||
"Content-Type": CONTENT_TYPES[ext] ?? "image/png",
|
||||
"Content-Length": String(data.byteLength),
|
||||
"Content-Length": String(total),
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { recordCost, scriptCostUsd, audioCostUsd, artCostUsd } from "@/lib/ai/co
|
||||
import { refundUsage } from "@/lib/usage/meter";
|
||||
import { isFlagEnabled } from "@/lib/flags";
|
||||
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 type { EpisodeConfig, StructuredScript } from "@/lib/ai/types";
|
||||
import type { GenerationType } from "@/lib/queue/jobs";
|
||||
@@ -262,13 +262,16 @@ async function generateArt(episode: EpisodeWithRelations) {
|
||||
|
||||
async function notifyReady(episode: EpisodeWithRelations) {
|
||||
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 {
|
||||
await sendEmail({
|
||||
to: episode.user.email,
|
||||
subject: `🎙️ "${episode.title}" is ready`,
|
||||
html: emailLayout(
|
||||
"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}` }
|
||||
),
|
||||
text: `Your episode "${episode.title}" is ready: ${appUrl}/episodes/${episode.id}`,
|
||||
|
||||
@@ -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 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 {
|
||||
const k = process.env.ELEVENLABS_API_KEY;
|
||||
if (!k) throw new Error("ELEVENLABS_API_KEY is not set");
|
||||
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 {
|
||||
voice_id: string;
|
||||
name: string;
|
||||
@@ -28,7 +39,7 @@ export class ElevenLabsAudioProvider implements AudioProvider {
|
||||
_opts?: { language?: string }
|
||||
): Promise<{ audio: Buffer; characters: number }> {
|
||||
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",
|
||||
headers: {
|
||||
|
||||
+17
-9
@@ -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);
|
||||
|
||||
// Fail fast in production if the signing secret is missing — sessions/cookies are
|
||||
// only secure when BETTER_AUTH_SECRET is set. Stay frictionless in dev/test.
|
||||
if (!process.env.BETTER_AUTH_SECRET && process.env.NODE_ENV === "production") {
|
||||
throw new Error("BETTER_AUTH_SECRET must be set in production.");
|
||||
// Fail fast in production if the signing secret is missing, too short, or a known
|
||||
// placeholder — sessions/cookies are only secure when BETTER_AUTH_SECRET is a strong,
|
||||
// non-default value. Stay frictionless in dev/test.
|
||||
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({
|
||||
@@ -27,11 +36,10 @@ export const auth = betterAuth({
|
||||
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
// SECURITY GATE (currently OPEN): unverified emails can sign in. Flip this to
|
||||
// `true` once email delivery is confirmed working in prod. Left `false` for now
|
||||
// so existing dev accounts aren't locked out. Verification emails ARE sent on
|
||||
// signup (see emailVerification.sendOnSignUp below), so users can verify already.
|
||||
requireEmailVerification: false,
|
||||
// SECURITY GATE: unverified emails CANNOT sign in. Verification emails are sent
|
||||
// on signup (see emailVerification.sendOnSignUp below), so users can verify before
|
||||
// their first login.
|
||||
requireEmailVerification: true,
|
||||
minPasswordLength: 8,
|
||||
async sendResetPassword({ user, url }) {
|
||||
await sendEmail({
|
||||
|
||||
@@ -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 approveUrl = data.links.find((l) => l.rel === "approve")?.href;
|
||||
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 }),
|
||||
});
|
||||
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
@@ -22,16 +22,6 @@ export interface UpsertSubscriptionInput {
|
||||
* provider subscription id, so duplicate/replayed webhooks converge on one row.
|
||||
*/
|
||||
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 = {
|
||||
provider: input.provider,
|
||||
plan: input.plan,
|
||||
@@ -47,9 +37,25 @@ export async function upsertSubscription(input: UpsertSubscriptionInput) {
|
||||
cancelAtPeriodEnd: input.cancelAtPeriodEnd ?? undefined,
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
return prisma.subscription.update({ where: { id: existing.id }, data });
|
||||
// Atomic upsert keyed on whichever provider subscription id is present on the
|
||||
// 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 } });
|
||||
}
|
||||
|
||||
@@ -85,11 +91,19 @@ export async function getEffectivePlan(
|
||||
activeOrgId?: string | null
|
||||
): Promise<{ plan: Plan; key: PlanKey; subjectId: string; subjectType: "user" | "organization" }> {
|
||||
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);
|
||||
if (key !== "free") {
|
||||
return { plan: getPlan(key), key, subjectId: activeOrgId, subjectType: "organization" };
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = await getSubjectPlanKey(userId);
|
||||
return { plan: getPlan(key), key, subjectId: userId, subjectType: "user" };
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import { prisma } from "@/lib/db";
|
||||
/** True if we've already handled this provider event (idempotency). */
|
||||
export async function alreadyProcessed(eventId: string): Promise<boolean> {
|
||||
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). */
|
||||
|
||||
+14
-3
@@ -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).
|
||||
*/
|
||||
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;
|
||||
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;
|
||||
}
|
||||
const { Resend } = await import("resend");
|
||||
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}`);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function escapeHtml(value: string): string {
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
|
||||
+31
-17
@@ -1,5 +1,7 @@
|
||||
/** Queue/job names and their typed payloads. Shared by the web (producer) and worker (consumer). */
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const QUEUES = {
|
||||
generateEpisode: "episode.generate",
|
||||
generateSeries: "series.generate",
|
||||
@@ -10,29 +12,41 @@ export const 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 {
|
||||
episodeId: string;
|
||||
export const generateEpisodePayloadSchema = z.object({
|
||||
episodeId: z.string().min(1),
|
||||
/** "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. */
|
||||
sectionId?: string;
|
||||
}
|
||||
sectionId: z.string().optional(),
|
||||
});
|
||||
export type GenerateEpisodePayload = z.infer<typeof generateEpisodePayloadSchema>;
|
||||
|
||||
export interface RepurposePayload {
|
||||
episodeId: string;
|
||||
format: "blog" | "social_thread" | "newsletter";
|
||||
}
|
||||
export const repurposePayloadSchema = z.object({
|
||||
episodeId: z.string().min(1),
|
||||
format: z.enum(["blog", "social_thread", "newsletter"]),
|
||||
});
|
||||
export type RepurposePayload = z.infer<typeof repurposePayloadSchema>;
|
||||
|
||||
export interface GenerateSeriesPayload {
|
||||
seriesId: string;
|
||||
}
|
||||
export const generateSeriesPayloadSchema = z.object({
|
||||
seriesId: z.string().min(1),
|
||||
});
|
||||
export type GenerateSeriesPayload = z.infer<typeof generateSeriesPayloadSchema>;
|
||||
|
||||
export interface EchoPayload {
|
||||
message: string;
|
||||
episodeId?: string;
|
||||
}
|
||||
export const echoPayloadSchema = z.object({
|
||||
message: z.string(),
|
||||
episodeId: z.string().optional(),
|
||||
});
|
||||
export type EchoPayload = z.infer<typeof echoPayloadSchema>;
|
||||
|
||||
/** All queues that must exist before send/work. */
|
||||
export const ALL_QUEUES: QueueName[] = Object.values(QUEUES);
|
||||
|
||||
+51
-5
@@ -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.
|
||||
const limiters = new Map<string, RateLimiterMemory>();
|
||||
/**
|
||||
* Backend selection (default = in-memory):
|
||||
*
|
||||
* - 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);
|
||||
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 });
|
||||
}
|
||||
limiters.set(name, limiter);
|
||||
}
|
||||
return limiter;
|
||||
@@ -41,4 +86,5 @@ export const LIMITS = {
|
||||
api: { points: 60, durationSec: 60 }, // 60 API calls / min / key (writes)
|
||||
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
|
||||
} as const;
|
||||
|
||||
+12
-1
@@ -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 type { StorageProvider } from "./types";
|
||||
|
||||
@@ -29,6 +29,17 @@ export class LocalStorageProvider implements StorageProvider {
|
||||
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> {
|
||||
try {
|
||||
await fs.access(resolveSafe(key));
|
||||
|
||||
@@ -9,6 +9,16 @@ export interface StorageProvider {
|
||||
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
||||
/** Read the full object as a 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`. */
|
||||
exists(key: string): Promise<boolean>;
|
||||
/** Remove the object (no-op if missing). */
|
||||
|
||||
+65
-15
@@ -4,35 +4,85 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
// "__Secure-" variant is used when cookies are served over HTTPS in production.
|
||||
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.
|
||||
* 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
|
||||
* directly keeps the middleware bundle free of the auth/jose internals.
|
||||
* Runs on every request (see matcher). Two responsibilities:
|
||||
*
|
||||
* 1. CSP/nonce (all routes): generate a per-request base64 nonce with the Web Crypto
|
||||
* 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) {
|
||||
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
|
||||
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) {
|
||||
const signIn = new URL("/sign-in", req.url);
|
||||
signIn.searchParams.set("redirect", pathname + search);
|
||||
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 = {
|
||||
matcher: [
|
||||
"/dashboard/:path*",
|
||||
"/episodes/:path*",
|
||||
"/series/:path*",
|
||||
"/usage/:path*",
|
||||
"/billing/:path*",
|
||||
"/team/:path*",
|
||||
"/api-keys/:path*",
|
||||
"/settings/:path*",
|
||||
"/admin/:path*",
|
||||
// Run on every request EXCEPT static assets so CSP applies app-wide while
|
||||
// avoiding unnecessary work on prefetched/static files.
|
||||
{
|
||||
source: "/((?!_next/static|_next/image|favicon.ico).*)",
|
||||
missing: [
|
||||
{ type: "header", key: "next-router-prefetch" },
|
||||
{ type: "header", key: "purpose", value: "prefetch" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
+3
-16
@@ -19,22 +19,10 @@ const nextConfig = {
|
||||
// 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.
|
||||
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() {
|
||||
// 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 [
|
||||
{
|
||||
source: "/:path*",
|
||||
@@ -48,7 +36,6 @@ const nextConfig = {
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{ key: "Content-Security-Policy", value: csp },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
+10
-2
@@ -211,8 +211,16 @@ model Subscription {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([referenceId])
|
||||
@@index([stripeSubscriptionId])
|
||||
@@index([paypalSubscriptionId])
|
||||
// Provider subscription ids are unique so concurrent/replayed webhooks can't
|
||||
// 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([createdAt])
|
||||
@@map("subscription")
|
||||
|
||||
+23
-3
@@ -1,6 +1,12 @@
|
||||
import "dotenv/config";
|
||||
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 { setEpisodeStatus } from "@/lib/episodes/status";
|
||||
import { recordHeartbeat } from "@/lib/queue/health";
|
||||
@@ -21,7 +27,14 @@ async function main() {
|
||||
|
||||
// Proof-of-loop queue used by health checks / verification.
|
||||
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.
|
||||
@@ -49,7 +62,14 @@ async function handleGenerate(job: {
|
||||
retryCount?: 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 {
|
||||
await runEpisodeGeneration(episodeId, type ?? "full");
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user