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>
All literal "team" tier-value references renamed to "family" across
API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum
value itself is renamed in place via ALTER TYPE ... RENAME VALUE
(migration 0044) rather than drizzle-kit's auto-generated
drop-and-recreate-the-enum migration, which would have failed against
any existing row still holding 'team' — RENAME VALUE preserves
existing data with no cast/backfill needed.
Also adds STRIPE_PLAN.md — a full Stripe billing integration plan
(Checkout+Portal, tier→Price mapping, admin billing dashboard, and a
multi-user Family-group design since Family is meant to cover several
accounts under one subscription, not one payer). Planning only, no
Stripe code yet.
v0.47.0
Widens the tier enum from free/pro to free/pro/team and every
"free" | "pro" cast that assumed exactly two tiers (~30 call sites:
every AI route's withAiQuota/checkAndIncrementTierLimit call, admin
user/invite management, upload quota checks, OpenAPI schemas). Team
sits above Pro with genuinely unlimited recipes/public-recipes (the
-1 sentinel, which Pro doesn't actually use — Pro uses large finite
numbers instead) and a higher AI-call/storage cap. Seeded via
db:seed, editable afterward from Admin > Tiers.
role (user/moderator/admin — permissions) and tier (free/pro/team —
billing limits) stay separate concepts, as they already were; this
does not touch role-based permissions.
Requires migration 0043 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate` then `pnpm db:seed`.
v0.44.0
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>
Add DEFAULT_{TEXT,VISION,MEAL_PLAN}_{PROVIDER,MODEL} site settings,
editable from Settings -> Admin (new AdminDefaultModelForm, mirroring
the per-user model-prefs UI). getDefaultProviderWithKey now accepts
the use case and checks the admin default (after the user's own BYOK
key, before the old "first configured site key" heuristic) so admins
can pin a specific provider/model per feature instead of it being
whichever key happens to exist.
Wired into scale/substitute/batch-cook/meal-plan generation routes,
which previously called getDefaultProviderWithKey() without a use
case and so never had visibility into per-feature routing at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Convert requireSession -> requireSessionOrApiKey across recipes,
collections, meal-plans, shopping-lists, pantry, feed, and ai/*
(52 routes) so API keys work end-to-end, not just for the handful of
endpoints that supported them before. Scope was explicitly confirmed
per-resource-family with the user before any file was touched.
Left session-cookie-only, deliberately: users/me*, ai-keys/*,
webhooks/*, conversations/*, notifications/*, push/subscribe, admin/*
— account/credential-adjacent surface that shouldn't widen without a
separate, explicit decision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- AI recipe generation always used the app's own locale rather than a
separate language picker — removed the picker, generation and the
saved recipe's language now both follow useLocale() directly.
- Bulk-select action bar had an ungated max-w-md that capped it at 448px
even past the sm: breakpoint where sm:w-auto was supposed to let it
grow to content — added sm:max-w-none so desktop never scrolls.
- Batch-cooking generation had no free-text "describe" field like the
standard recipe generator does — added one (BatchCookFields, both the
standalone dialog and the AI dialog's batch tab), threaded through the
API route and generateBatchCook's prompt as additional guidance.
- Recipes imported from a URL never actually got their sourceUrl
persisted — CreateRecipeSchema didn't declare the field, so Zod
silently stripped it before it ever reached the insert. The detail
page's "Source: <hostname>" link already existed but was dead code
for every real import until now.
Verified all 4 live: generate dialog has no language selector, batch
tab has a working describe textarea, bulk-select bar renders at full
content width with zero horizontal overflow on desktop, and a recipe
created with sourceUrl now round-trips and renders its source link.
Shopping list add already worked generically for batch-cook recipes —
no code needed there.
New: mark a specific batch-cook dish as "cooked today", track its
fridge expiry (cookingHistory.batchDishId), surface a "Leftovers
expiring soon" widget on the pantry page, and send a daily push+email
reminder via a new /api/internal/cron/leftover-reminders endpoint
(mirrors the weekly-digest cron pattern; doesn't use the social
notifications table, which requires a non-null actor and isn't built
for self-reminders).
Also fixes, from user-reported bugs:
- Recipe cards showed no batch-cook badge/dish-count/prep-time in some
views — added dishCount + prepMins/cookMins (now generated by the AI
and persisted) to the card component and /recipes query.
- Batch-cook descriptions occasionally contained raw markdown
(**bold**) — added explicit "plain prose only" prompt instructions
and a stripMarkdown() defensive fallback at render time.
- Truncated/cut-off descriptions — the generateObject call had no
maxOutputTokens set, so long structured responses could get cut off
mid-field; now capped explicitly at 8000.
- Generate dialogs (batch-cook + the main AI dialog) could show
buttons unreachable once the progress bar appeared mid-generation —
restructured so the action row is pinned outside the scrollable
content area, not affected by content height changes.
- /api/internal/* routes were being redirected to /login by middleware
before their own CRON_SECRET check ever ran (pre-existing bug,
affected the weekly-digest cron too) — added to PUBLIC_PATHS.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New AI-generated batch-cooking flow: pick N dinners/lunches + servings,
get one unified recipe covering a full prep session — merged shopping
list, steps tagged per-dish (a step can advance several dishes at once,
e.g. a shared oven bake), and per-dish storage (fridge/freezer) + exact
day-of reheat/finishing instructions.
Schema: recipes.isBatchCook, recipeSteps.appliesTo (dish names), new
recipeBatchDishes table. Batch recipes are excluded from the normal
/recipes list and live under their own /batch-cooking section.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>