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>
Chatbot (general assistant + per-recipe Q&A) now resolves its default model
from its own site setting (DEFAULT_CHAT_PROVIDER/MODEL) instead of sharing
the generic "text" use case with recipe generation, so admins can point it
at a different model.
Added AI_TOOL_CALLING_ENABLED: turns off the chatbot's createRecipe/
addToShoppingList tools (and the forced-retry pass) for models that don't
reliably support tool calling — it falls back to plain text answers.
Simplified the admin AI Configuration page: it showed provider keys and
routing settings twice (a read-only card, then an identical edit form).
Merged into one editable section; the resolved fallback provider is now a
one-line note instead of its own card.
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>
Replaces collections.isPublic (boolean) with collections.visibility
(private/unlisted/public/followers — same enum recipes use). Two-step
migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's
add+drop-in-one-diff rename heuristic needs an interactive prompt we
can't satisfy here.
New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the
existing recipe helper (author always sees own; public/unlisted visible
to anyone; followers-only via the same user_follows EXISTS pattern) —
used by the collection detail page, its print view, fork, and favorite,
replacing their old `or(isPublic, own)` checks.
Create/edit collection dialogs get the same 4-option visibility select
as the recipe form instead of a public/private checkbox.
Collection PDF export now generates a QR code (qrcode, same as the
recipe PDF) linking to /collections/{id}, shown only when visibility is
public/unlisted — same "would an anonymous scanner actually resolve
this" rule as the recipe QR.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Seven related improvements to collections:
- Drag-and-drop reorder (dnd-kit, same pattern as the shopping list) — new
collection_recipes.position column (migration 0049, backfilled from
existing added_at order so nothing jumps around on upgrade).
- Search collections by name/description (server-side, list page) and
search recipes within a collection (client-side filter, already loaded).
- Edit collection: name/description/tags/private notes via a new dialog;
new collections.notes + collections.tags columns.
- Delete collection with a choice to also delete its recipes — only ones
the deleting user actually owns, never recipes shared in by others.
- Collection detail (both owner and public view) now renders the same
RecipeGridCard used on /recipes, instead of the older, plainer RecipeCard.
- Collection list cards redesigned — photo-collage preview (first 4 recipe
covers/placeholders), tag badges, cleaner layout.
- Fixed the recipe count shown on a collection card: the query capped the
`recipes` relation at 1 for thumbnail purposes and then read `.length`
off that same capped array, so it never showed more than 1. Now a
proper grouped count query, separate from the thumbnail fetch.
New/changed endpoints documented in OpenAPI: PATCH /collections/{id}/reorder,
DELETE /collections/{id}?deleteRecipes, PUT /collections/{id}'s new
notes/tags fields.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New "Generate meal" button on any owned collection — pick a theme (free
text) and 2-6 courses (starter/main/side/dessert/drink), one generateObject
call produces a coherent recipe per course (shared cuisine/style, matching
flavors across courses) and all of them get saved as real recipes and
added to that collection in one action.
Follows the meal-plan generation route's established pattern: single
withAiQuota charge for the whole generation, then a pre-flight loop
charging the tier's recipe limit once per generated recipe (rolled back
on a partial breach) before the insert transaction runs. Recipes are
tagged with their course name for later filtering; visibility defaults to
private like every other AI-generated recipe.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two changes to the no-photo cover placeholder shipped last version:
1. Muted the gradient palette (100/40-opacity tints instead of solid -200/
-950 stops) — the original was too saturated next to real cover photos
in the same grid, per feedback.
2. New coverIcon/coverColor columns on recipes (nullable text, migration
0048, additive-only) let the author pin a specific color+icon from the
recipe editor instead of the automatic per-id pick. getRecipePlaceholder
now checks these first, falling back to the deterministic hash pick when
unset — existing recipes are unaffected until edited.
Wired coverIcon/coverColor through every explicit-column recipe select
that feeds a grid card (explore trending/recent, search, feed, for-you) —
the relational-query call sites (recipes page, collections, profile pages)
already return all columns and needed no changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admins can now disable specific AI features per tier from Admin > Tier
Limits — new feature_flags table (feature x tier -> enabled, defaulting
to true so adding a new gated feature never needs a backfill).
Covers recipe variations, drink pairing, and meal pairing to start.
When disabled for a user's tier, the button stays visible (with a small
lock badge) but opens an upgrade dialog instead of running; the API
route rejects the call server-side either way (requireFeatureEnabled,
re-reads tier from the DB rather than trusting the session's cache,
same rationale as checkAndIncrementTierLimit).
The upgrade dialog is informational only — no Stripe checkout exists
yet (STRIPE_PLAN.md is still just a plan) — its CTA links to /support
prefilled as an upgrade-interest suggestion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Support form now accepts up to 5 attachments (images, PDF, text, JSON,
zip; 10MB each) via the existing S3/MinIO presigned-upload flow. New
support_ticket_attachments table; attachment links get folded into the
Gitea issue body and shown as thumbnails in both the user's ticket
history and the admin support view.
New presign endpoint (/api/v1/support/attachments/presign, rate-limited
20/hr) scoped to the uploading user rather than a ticket id, since the
ticket doesn't exist yet at upload time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Users can now report bugs, suggestions, or questions from a new /support
page. Each submission sends a confirmation email and, when GITEA_URL/
GITEA_TOKEN/GITEA_REPO are configured in admin Settings, opens a labeled
issue on that repo automatically (best-effort — failure doesn't block the
ticket). Admins get a Support section to triage status and retry failed
Gitea issue creation.
New support_tickets table, gitea.ts client, site-settings entries for the
three new secrets, and OpenAPI docs for the two user-facing endpoints.
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
Second tool alongside createRecipe, same safety shape: addToShoppingList's
execute only validates/echoes input, no DB write. The proposal card lets
the user pick an existing list (fetched lazily) or name a new one, then
confirms through the exact two-call flow AddToShoppingListButton already
uses — POST /api/v1/shopping-lists (if new) then POST .../items — no new
persistence code path.
generateMealPlan intentionally not built as a tool: the existing
/api/v1/ai/meal-plan/generate endpoint generates and writes in one step
with a week/preferences input, not a specific plan payload, so it has no
"save this exact draft" entry point to confirm against without a larger
refactor. Documented as a known gap rather than forcing a weaker pattern.
v0.46.0
Gives the general chat a createRecipe tool (Vercel AI SDK, stepCountIs(3))
scoped so it can only ever produce a draft — the tool's execute is a pure
echo, no DB write. The route surfaces the tool call as proposedRecipe
alongside the normal text answer; the chat UI renders it as a card with
explicit Create/Discard buttons. Create POSTs to the existing
/api/v1/recipes endpoint — the same code path and tier/recipe-count limit
the manual editor already goes through — so there's exactly one place
that actually creates a recipe row, and nothing happens without the user
clicking Create.
Scoped to the general assistant only (not per-recipe chat), and to one
tool for now — addToShoppingList/generateMealPlan are follow-ups.
v0.45.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
The general assistant had exactly one conversation per user forever
(recipeId null on chat_messages) — no way to start fresh or organize
by topic. Adds an ai_conversations table (title, timestamps) and a
nullable conversationId FK on chat_messages; the assistant panel gets
a conversations menu to create/switch/rename/delete, auto-titling a
new conversation from its first question.
Per-recipe chat is untouched — each recipe already has one natural
thread, so multi-conversation support only applies to the homepage
assistant. Pre-existing general messages (no conversationId) aren't
migrated into the new model and won't appear in the conversation list.
Requires migration 0042 to run against a live DB — not applied in
this sandbox (no Docker here); run `pnpm db:migrate`.
v0.43.0
Every existing AI entry point (generate, generate-from-idea, adapt,
variations) either creates a new recipe or a saved variation — none
let you revise the draft you're currently editing in place. Adds a
stateless /api/v1/ai/regenerate endpoint that takes the editor's
current in-progress fields (not a recipeId, so unsaved edits are
included) plus a free-text instruction, and returns a full revised
draft the editor merges into its own state. No DB write happens;
the user still saves normally.
v0.42.0
Adds a fourth visibility tier alongside private/unlisted/public:
"followers" — visible to the author and anyone who follows them,
excluded from public search/explore/profile listings and from the
anonymous /r/[id] share route, included in the Following feed. The
in-app recipe detail gate and the public share route both check
follow status directly against the DB rather than the union type
alone, since neither previously had any per-viewer access logic for
a non-public/non-owner case.
Requires the generated migration (0041) to run against a live DB —
not applied in this sandbox (no Docker here); run `pnpm db:migrate`.
v0.41.0
The photo-import flow used one vision-capable model to both read the
photo and structure the full recipe (quantities, steps, timing) in a
single call. Split it into two: a vision model recognizes what's in
the picture, then a text model reconstructs the recipe from that
description — same pattern the pantry photo-scan already uses for
recognition, and lets structuring use whichever model is actually
configured for text generation. Still counts as one AI-quota unit.
v0.37.0
Explore and Feed covered overlapping ground (recommendations, following,
trending) in two separate places with three different card designs.
Explore now hosts Discover/Following/For You tabs, and every recipe
card everywhere on the page matches the Recipes page's cover-photo
card instead of the old text-only search result.
v0.36.0
Lets authors enter nutrition values by hand instead of relying only on
the AI estimate; the detail-page panel now shows whether data was
manually entered or AI-estimated and offers to switch between them.
v0.35.0
generate, generate-from-idea, URL import, and photo import all now
have the AI set recipeType directly (defaulting to "dish" whenever
ambiguous, per instruction), with cookMins force-cleared for drinks
as a backstop in case the model doesn't follow the prompt guidance.
1-serving-by-default for unspecified drink quantities is left to the
model's own judgment of the request text, since there's no reliable
way to detect "was a serving count stated" without re-parsing intent.
Drink recipes get a distinct default icon (no cover photo case), and
Explore gains the same dish/drink Type filter My Recipes already had.
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>
Add recipes.recipeType enum (dish|drink, default dish). Recipe form
now shows a Type selector and hides food-only fields for drinks
(prep/cook time, difficulty, batch-cook toggle) instead of forcing
every cocktail through meal-oriented UI. Detail page skips the
meal/drink pairing suggestion buttons on drink recipes (pairing a
drink with a drink doesn't make sense). Recipes list gains a Type
filter facet alongside difficulty/batch-cook.
Scoped deliberately narrow per investigation: no ABV/glass-type/
garnish structured fields, no meal-plan schema changes, and the
existing AI drink-pairing-suggestion feature (ephemeral, never saved
as a recipe) is left as-is rather than wired into recipe creation —
those suggestions are wine/beer/spirit recommendations without
ingredients/steps, not really recipes to save.
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>
importFromPhoto() accepted a locale param but silently discarded it
(named _locale, never read) — now builds a language instruction the
same way generate-recipe.ts does for text generation.
The AI output schema had no way to signal "couldn't find a recipe in
this image" — generateObject would always fabricate a title/fields.
Added a `found` boolean the model sets to false in that case; the
route now returns 422 instead of creating a garbage recipe and
sending the user into the editor with it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Audited every documented request/response schema against the real
Zod validators and NextResponse.json shapes across all route
families, then fixed drift: missing batch-cook/tags/photos fields on
recipes, wrong comment/rating shapes, meal-plan entry shape (day vs
dayOfWeek, nullable recipeId), shopping-list/pantry response fields,
a phantom GET /webhooks/{id} with no backing route, and numerous
missing 400/401/429 response codes.
Also fixes a real bug found during the audit: the webhook-redelivery
route returned {error: object} instead of the app-wide {error:
string} convention on validation failure.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Went from 14 to ~100 documented endpoints in lib/openapi.ts — pantry,
meal-plans, shopping-lists, collections, users/social, ai/*, webhooks,
conversations, notifications, and admin routes now all appear in
/docs with accurate request/response schemas. No auth/route changes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New keys can be created as "Full access" (default, unchanged) or
"Read-only" — read-only keys can only make GET/HEAD/OPTIONS requests,
enforced once in requireSessionOrApiKey (lib/api-auth.ts) rather than in
every route, since a route has no way to know a request came from a
scoped key without that check. Existing keys default to full access —
no behavior change for anyone who doesn't opt in.
Also included in this migration: the chat_messages table for the
next commit (chat history persistence) — generated together since both
touched packages/db/src/schema/users.ts in the same pass.
Verified locally: created both a read-only and a full-access key,
confirmed GET succeeds and POST 403s on the read-only key, confirmed
POST still works on the full-access key, and checked the scope badges
render correctly in the real Settings → API Keys UI.
Full audit (bugs/UI-UX/backend/feature-gap) turned up a money-leak AI quota
bypass, webhook SSRF, and a long tail of missing pagination/auth/a11y work.
Fixes land together since HANDOFF.md tracked them as one backlog.
- AI routes charge tier quota before generating; nutrition POST is author-only
- Webhook dispatch re-validates URL per delivery (SSRF/DNS-rebinding), treats
redirects as failures; recipe.published now actually dispatches
- New indexes/unique constraints on recipes, meal-planning, comments FK cascade
- Recipe PUT/restore snapshot only inside the transaction, after validation
- Recipe DELETE cleans up S3 objects (recipe + review photos)
- Optimistic UI (favorite/star/follow/shopping-list) rolls back on failure
- Upload presign enforces file size cap + per-tier storage quota
- Route-level loading/error/not-found states across (app), admin, and root
- middleware.ts guards (app)/admin; requireAdmin checks DB role, not cached
session; rate limiting applied to both session and API-key branches,
bucketed per key; Stripe webhook dedupes by event id
- Pagination added to recipes, feed, profile, comments, pantry, admin tables
- Nav shows real avatar + profile link + dark-mode toggle; destructive actions
standardized on AlertDialog
- Unsaved-changes guard + real ingredient/step validation on recipe form;
canonical /recipes/[id] used in-app; next/image migration; aria-labels and
alt text across icon buttons, avatars, recipe photos
- packages/api-types removed (zero callers, too drifted to safely rewire);
openapi.ts and ai-keys error shape drift fixed; BYOK decrypt failures now
surface instead of silently falling back to the platform key
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fixes the 13-item codebase health scan backlog: wraps meal-plan
generation in a transaction, adds missing userId/GIN indexes, fixes
an IPv6-parsing gap in the webhook SSRF guard (and an identical
duplicated bug in the AI URL-import path, now consolidated onto one
implementation), paginates the collections list, dedupes the AI
recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key
rotation, gets `pnpm typecheck` actually working, and adds test
coverage for the previously-untested admin/webhooks routes.
Two flagged issues (collection removeRecipeId IDOR, tier-limit race)
turned out to already be fixed/non-issues on inspection — noted in
TODO.md rather than silently dropped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>