Security audit fixes (see SECURITY_AUDIT.md):
- lib/tiers.ts: checkAndIncrementTierLimit's recipe/storage branches did a
live count/sum check with no lock tying it to the later write, so two
concurrent requests near the cap could both pass and both write past the
limit. Added checkTierLimitInTransaction — holds a per-user Postgres
advisory lock for the duration of the caller's transaction, so the check
and the write are atomic together. Applied to every recipe/storage write
path (recipes create/update, fork, rate, avatar, and 7 AI recipe-creation
routes).
- Adversarial re-verification of that fix caught a bigger gap in the same
area: four AI routes (photo import, idea generation, batch-cook,
translate-to-new-draft) had no recipe-limit check at all. Fixed.
- Added missing per-user rate limits to 5 AI endpoints (adapt, drinks,
pairings, translate, variations) that had none, unlike their siblings.
- search page now checks for a session server-side, matching every other
page under (app)/ — defense in depth; the underlying API was already
public by design and proxy.ts already blocked unauthenticated requests.
- admin/settings route now uses the shared requireAdmin instead of a local
duplicate.
- Documented two admin support-ticket endpoints missing from the OpenAPI
spec; verified full route/OpenAPI parity otherwise.
- Bumped drizzle-orm to fix a SQL-identifier-escaping CVE, and overrode two
transitive deps (esbuild, postcss) with known CVEs. pnpm audit is clean.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Recipe count and storage usage shared the monthly user_usage bucket with
AI calls, so both incorrectly reset every month even though nothing was
deleted. Only AI calls should be monthly.
Recipe count and storage are now derived live from real data (recipes,
recipe/review photos, avatar) instead of a counter — deleting a photo or
recipe is itself the "decrement", no extra wiring needed. Storage size is
tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb)
and threaded through presign -> upload -> save.
Also fixes avatar removal silently no-oping (client sent a field the PATCH
schema didn't recognize).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Full list of the audit's confirmed findings and their fixes:
- Stored XSS via unescaped JSON-LD on the public recipe page
(app/r/[id]/page.tsx) — escape < before injecting.
- CSP allowed unsafe-eval in production — now dev-only (Next prod
never eval()s; only its HMR does).
- avatarUrl accepted any URL with no ownership check — now takes an
avatarKey issued by avatar-presign, validated server-side, same
pattern as recipe/review photos.
- No session revocation on password change/reset — both now revoke
other sessions (revokeOtherSessions: true, revokeSessionsOnPasswordReset).
- Rate-limit bypass via spoofable X-Forwarded-For — take the last
(proxy-appended) hop instead of the first (client-supplied) one,
matching the single-Traefik-hop topology.
- Webhook signing secrets stored plaintext — now AES-256-GCM
encrypted like every other secret in this app, with a legacy-
plaintext fallback for pre-existing rows (bare hex has no ":", our
ciphertext format always does).
- Better Auth's own rate limiter defaulted to in-memory storage,
ineffective across replicas — now backed by the same Redis as
lib/rate-limit.ts (secondaryStorage), with storeSessionInDatabase
explicit so session storage itself doesn't move as a side effect.
- Presigned upload URLs didn't bind the declared file size to the
actual upload, letting a client under-declare size (and quota
charge) then PUT an arbitrarily large object — switched to S3
presigned POST with a signed content-length-range condition,
enforced by the storage server itself.
- generateMetadata() on the recipe page skipped the visibility
filter the page body uses, leaking a private recipe's title via
<title> to any signed-in user with the id.
- Block/unblock had no rate limit, unlike follow/unfollow.
- AI quota was charged even when a user's own BYOK key was used
(their own credentials/billing) — added an isByok flag through
the config-resolution chain and skip the charge when set. Also
wired BYOK into generate/generate-from-idea/translate/import-url,
which never looked it up at all before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously every account without a custom avatar automatically got
its email MD5-hashed and sent to gravatar.com at signup, with no way
to turn it off. Adds users.useGravatar (default false): removed the
automatic signup-time lookup entirely, and "remove photo" now falls
back to the initials placeholder instead of silently re-deriving a
Gravatar URL. New toggle in Settings -> Profile, off by default,
description explains the MD5-hash-to-third-party tradeoff. Existing
accounts' current avatarUrl is left untouched either way — no
retroactive avatar changes for anyone already using one.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Usernames are auto-assigned at signup (shipped earlier this session)
but there was still no way to change one. Adds a Username field to
Settings, validated client- and server-side against the same
3-20-char lowercase/digits/underscore pattern used for generation,
with a 409 on collision (excluding the user's own current username).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New users get a Gravatar-backed avatar automatically (computed from email
at signup); users can upload a custom photo instead via Settings, or
revert to the Gravatar/initials fallback. avatarUrl stays the single
resolved value (custom photo, OAuth photo, or precomputed Gravatar URL)
so every existing avatar-rendering spot across the app needs zero changes.
- users.hasCustomAvatar tracks whether avatarUrl is a real upload vs a
computed Gravatar fallback, so "remove photo" knows what to revert to.
- New /api/v1/upload/avatar-presign route (session-scoped, generic —
the existing recipe-photo presign route required a recipeId).
- CSP img-src needed www.gravatar.com added, or every browser blocks
the fallback avatar outright.
- Settings page now reads avatarUrl from a fresh DB query instead of
Better Auth's session object — the session cookie cache (5 min TTL)
was serving a stale image right after upload, showing the initials
fallback until the cache happened to expire.
Verified locally: signup auto-sets a Gravatar URL, upload persists
across reload, remove correctly reverts to Gravatar/initials.
- Private accounts: users.isPrivate hides a user from search and their
recipes from search/trending/for-you discovery surfaces (follow-aware
where the route already has session context, blanket exclusion where it
doesn't); existing followers and direct links are unaffected, no
follow-request approval flow was built (explicit scope limit)
- Merged /people into the Explore tab (tab=people query param); the old
standalone route now redirects there
- "Get Ideas" vs "Surprise Me" were doing the same empty-prompt call;
Surprise Me now injects a real random constraint (5-ingredient, one-pot,
etc.), matching the existing pattern in the AI recipe-generate dialog
- Meal-plan day cells now link to their recipe (was dead text) and gained
a one-click "mark as cooked" action
- Theme toggle is now a real three-way light/dark/system control instead
of a binary flip
- Nutrition goals form had zero i18n wiring; fully localized now
New migration 0027 (users.is_private) generated, left unapplied like the
others. Verified with typecheck, lint, and a clean --no-cache docker build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Follow/unfollow users. Recipe favorites. Threaded comments with emoji reactions.
Collections (public/private) with shared member invite. Activity feed.
Public profile pages at /u/[username].