Commit Graph

11 Commits

Author SHA1 Message Date
Arnaud 7626f1b496 fix: close TOCTOU race + missing checks in tier limits, add AI rate limits, patch CVEs (v0.60.0)
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>
2026-07-20 22:21:51 +02:00
Arnaud b1f745da66 fix: batch of collections/i18n/translate-button fixes (v0.53.1)
- Drag-reorder used verticalListSortingStrategy on a multi-column grid,
  which computes wrong transforms for grid reflow — swapped to
  rectSortingStrategy so cards actually animate live while dragging.
- Grip handle was rendered as a sibling of (not a descendant of) the
  `group` element its `group-hover:opacity-100` depended on, so it was
  permanently invisible. Fixed the DOM nesting and made it always
  partially visible instead of hover-only.
- common.edit was missing from both locales (not just French) —
  edit-collection-dialog.tsx was the first caller to hit it.
- Root cause of "generated in my language but Translate still shows":
  generate-meal, meal-plan/generate, and adapt never set recipes.language
  on the row they inserted, so the button's `!recipe.language || ...`
  check always fell back to "show it". Fixed at all three insert sites.
- Translate dialog was entirely hardcoded English (title, description,
  language names, buttons) despite i18n keys already existing for most of
  it — now uses them, plus new translated language-name keys.
- Recipe tags now render on the recipe detail page (previously grid-card
  only).
- Collection header actions converted to icon-only + tooltip, matching
  the recipe page's pattern instead of icon+label buttons.
- Collections list search now also matches recipe titles inside each
  collection, not just the collection's own name/description.
- Explore page links to /collections/explore next to its tabs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:16:03 +02:00
Arnaud c8f4b50ef3 rename: "Team" billing tier to "Family"
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
2026-07-18 00:25:51 +02:00
Arnaud c31ab8771a feat: add Team billing tier
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
2026-07-17 17:34:13 +02:00
Arnaud d8dc0aa465 fix: variations/adapt-recipe silent errors and no-op duplicates
AI adapt/variations flows had no catch on their fetch calls, so a
network failure surfaced as nothing happening rather than an error
toast. They also unconditionally persisted the AI's output even when
it was identical to the source recipe, and the adapt route never
recorded a recipeVariations row (only plain forks did) or enforced
the per-tier recipe-count limit other create paths already check.

v0.39.0
2026-07-17 16:48:38 +02:00
Arnaud 3042d289a0 security: fix full audit findings (v0.32.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>
2026-07-14 15:05:05 +02:00
Arnaud 3e71bd29a2 security: widen API-key auth to content/AI routes (v0.27.0)
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>
2026-07-14 11:20:26 +02:00
Arnaud 362f65656b fix: audit fixes — tier-quota bypass, webhook SSRF, auth hardening, pagination, a11y
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>
2026-07-09 21:50:35 +02:00
Arnaud 524310433c fix: return clean errors and refund quota on AI provider failures
AI routes had no consistent error handling — a provider failure (e.g.
OpenRouter returning a degraded-model error) crashed the route as an
uncaught 500 and, worse, still charged the user's monthly aiCall
quota for a request that never succeeded.

Adds withAiQuota()/aiErrorResponse() (lib/ai/ai-error.ts) and applies
them across all 12 AI generation routes: charges the quota, runs the
AI call, refunds the credit and returns a clean user-facing message
if it throws. Frontend needs no changes — existing dialogs already
toast whatever `error` string comes back.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-02 12:32:31 +02:00
Arnaud 8b57a3fd87 Update features and dependencies 2026-07-01 11:10:37 +02:00
Arnaud d9d58fd01a feat(ai): Vercel AI SDK provider factory with BYOK and per-use-case model prefs
Provider factory (OpenAI/Anthropic/OpenRouter/Ollama). generateObject for all outputs.
Features: recipe generation, photo-to-recipe vision, variations, drink/meal pairings,
ingredient substitution, recipe adaptation, nutrition analysis, URL import, meal plan gen.
User BYOK keys (AES-256-GCM). Per-use-case model preferences (text/vision/mealPlan).
Site-level key override via admin settings.
2026-07-01 08:10:19 +02:00