The nonce-based script-src is incompatible with Next's static prerendering.
The nonce is minted per request in middleware, but statically generated HTML
was produced at build time with no nonce on its inline scripts, so on "/" and
the marketing routes every inline hydration script was blocked: the page
painted from SSR HTML, React failed to hydrate, and the tree unmounted to a
blank screen a moment later. Dynamic routes (/sign-in, /dashboard) render per
request, receive the nonce, and worked — which is why this was missed.
Browsers that honour a nonce ignore 'unsafe-inline', so the two cannot be
combined. Drop the nonce and allow inline scripts; the policy still restricts
which origins may serve scripts, and frame-src/connect-src stay strict.
Restoring nonces would require every route to render dynamically.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Transactional email only sends when RESEND_API_KEY is set; without it
sendEmail() silently no-ops. Combined with requireEmailVerification: true,
that meant every newly registered account was permanently locked out — the
verification link was never delivered and unverified users could not log in.
Verification mail is still sent on signup when email is configured; it is no
longer a barrier to signing in. Re-enable once transactional email is live.
Note: password reset still depends on email delivery, so users who forget a
password remain stuck until RESEND_API_KEY is configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compose services declared only STORAGE_DIR and PORT under `environment`,
so DATABASE_URL, BETTER_AUTH_SECRET and the Turnstile keys never reached the
containers: `docker compose` reads the generated .env for ${VAR} interpolation
(build args) but does not pass it into services on its own. `prisma migrate
deploy` therefore started with no DATABASE_URL and both containers exited 1 in
a restart loop.
Add `env_file: [.env]` to web and worker so the Environment tab actually
becomes runtime configuration, as the file's own comment already claimed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dokploy's managed Postgres runs as its own service on the shared
`dokploy-network`. The compose stack only had its own default network, so
web and worker could not resolve the database host — `prisma migrate deploy`
failed on boot and both containers crash-looped.
Join both services to `dokploy-network` while keeping `default` for
web<->worker traffic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Turnstile bot protection (sign-in, sign-up, password-reset):
- Register Better Auth's captcha plugin with the cloudflare-turnstile
provider; endpoints listed explicitly rather than relying on defaults.
/reset-password is intentionally excluded — it is reached only via a
single-use emailed token.
- Add an explicit-render Turnstile widget component. Tokens are single-use,
so each form resets the challenge after a failed submit; submit stays
disabled until a token is held.
- Read the site key server-side and pass it down as a prop, so rotating it
does not require a rebuild.
- Fail fast in production when TURNSTILE_SECRET_KEY is missing, and when a
secret is set without a site key (that combination would demand a token
no form can produce, locking every user out).
- Pass a throwaway secret during `next build` in the Dockerfile, mirroring
the existing BETTER_AUTH_SECRET treatment, so image builds don't need it.
CSP fixes in middleware (these blocked Turnstile entirely):
- Add frame-src for challenges.cloudflare.com. Without it the widget's
iframe fell back to default-src 'self' and was blocked outright.
- Allow 'unsafe-eval' and websockets in DEVELOPMENT only. `next dev`
compiles with eval(), so the strict policy threw EvalError and killed
hydration — no client JS ran at all, which also meant form submit
handlers never fired. Production policy is unchanged and still strict.
Also included (concurrent work in the tree):
- Admin organizations pages and lib/admin/orgs.
- Episode moderation migration, SEO metadata (sitemap, robots, JSON-LD,
OG/Twitter images, manifest), Umami analytics, not-found page.
Local dev database: docker-compose.dev.yml provisions Postgres 18 on port
5443 (5432-5442 are in use by other local projects).
Note: `npx tsc --noEmit` currently fails in app/(app)/team/page.tsx — an
`invitations` prop the component does not accept. This predates the commit
and will fail `next build` until fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The worker runs worker/index.ts under tsx (plain Node) and transitively
imports lib/flags (and other server-only-guarded modules) via the generation
pipeline. `server-only` resolves to its throwing index.js outside Next's RSC
bundler, crash-looping the worker container. Copy the package's empty.js over
index.js in the final image so the runtime import is a no-op; the build-time
client/server guard already ran during `next build`. Web is unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The security-hardening pass tightened lib/auth/auth.ts to reject secrets
shorter than 32 chars (or known placeholders) in production. next build
evaluates that guard while collecting page data for /api/auth/[...all], so
the old 21-char "build-time-placeholder" now fails the build. Lengthen the
throwaway build-only value to satisfy the guard; the real secret is still
injected at run time and never baked into the image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
publicUrl() defaulted MEDIA_BASE to "/media" even with no env set, so cover
art on public share pages pointed at a /media path that only the old nginx
(Plesk) box served. In the containerized Coolify/Docker deploy nothing serves
/media, breaking those images. Return null when MEDIA_PUBLIC_BASE_URL is unset
so callers fall back to the app's own /api/public/.../cover route (the deploy
README already documents assets as app-served). Backward compatible: behavior
is unchanged when the env var is set.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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
Ship the Podcast Distribution AI wordmark as real assets and replace the
Mic-tile + text placeholder everywhere it appeared.
- public/logo-dark.png (dark wordmark, for light backgrounds) and
public/logo-light.png (light wordmark, for dark backgrounds)
- New <Logo> component swaps the two via Tailwind dark: variants, so the
dark logo shows on all non-themed surfaces (marketing, auth, admin,
public share) and the light logo only in dark-mode app surfaces
- Wire <Logo> into the marketing header/footer, auth layout, app header
(default branch only - white-label org logos untouched), admin header
(+ Admin badge), and the public share page
- Favicon via App Router file convention: app/icon.png + app/apple-icon.png
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>