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>
This commit is contained in:
Arnaud
2026-07-09 21:50:35 +02:00
parent b4b964aafb
commit 362f65656b
128 changed files with 11271 additions and 970 deletions
+40 -9
View File
@@ -15,7 +15,10 @@
"apiKeys": "API keys",
"webhooks": "Webhooks",
"language": "Language",
"notifications": "Notifications"
"notifications": "Notifications",
"viewProfile": "View profile",
"lightMode": "Light mode",
"darkMode": "Dark mode"
},
"notifications": {
"title": "Notifications",
@@ -151,7 +154,8 @@
"bulkVisibility": "{count, plural, one {1 recipe set to {visibility}} other {{count} recipes set to {visibility}}}",
"bulkDeleteFailed": "Delete failed",
"bulkUpdateFailed": "Update failed",
"bulkDeleteConfirm": "{count, plural, one {Delete 1 recipe?} other {Delete {count} recipes?}} This cannot be undone.",
"bulkDeleteConfirmTitle": "{count, plural, one {Delete 1 recipe?} other {Delete {count} recipes?}}",
"bulkDeleteConfirmDescription": "This action cannot be undone.",
"deselectAll": "Deselect all",
"selectAll": "Select all",
"clickToSelect": "Click cards to select",
@@ -323,7 +327,8 @@
"ariaLabel": "Ask AI about this recipe",
"title": "Ask about this recipe",
"intro": "Ask anything about this recipe — ingredients, techniques, substitutions, timing…",
"inputPlaceholder": "Ask a question…"
"inputPlaceholder": "Ask a question…",
"send": "Send message"
},
"servingScaler": {
"servings": "Servings",
@@ -351,7 +356,10 @@
"selectConversation": "Select a conversation",
"messageButton": "Message",
"startConversationFailed": "Failed to start conversation",
"title": "Messages"
"title": "Messages",
"loadOlder": "Load older messages",
"loadingOlder": "Loading…",
"send": "Send message"
},
"nutritionPanel": {
"estimateButton": "Estimate nutrition",
@@ -414,7 +422,9 @@
"anyDifficulty": "Any",
"editManually": "Edit manually",
"surpriseMe": "Surprise me",
"generatingContent": "Generating recipe content…"
"generatingContent": "Generating recipe content…",
"decrease": "Decrease",
"increase": "Increase"
},
"auth": {
"signIn": "Sign in",
@@ -532,7 +542,13 @@
"createRecipe": "Create recipe",
"updateSuccess": "Recipe updated",
"createSuccess": "Recipe created",
"saveError": "Failed to save recipe"
"saveError": "Failed to save recipe",
"ingredientsRequired": "Add at least one ingredient",
"stepsRequired": "Add at least one step",
"discardChangesTitle": "Discard changes?",
"discardChangesDescription": "You have unsaved changes. If you leave now, they will be lost.",
"keepEditing": "Keep editing",
"discardChanges": "Discard changes"
},
"canCook": {
"title": "What can I cook?",
@@ -616,7 +632,8 @@
"listCreating": "Creating…",
"listCreate": "Create",
"pickRecipe": "Pick recipe",
"addEntry": "+ Add"
"addEntry": "+ Add",
"removeEntry": "Remove meal"
},
"pantry": {
"title": "Pantry",
@@ -630,6 +647,8 @@
"add": "Add",
"addFailed": "Failed to add",
"removeFailed": "Failed to remove",
"removeConfirmTitle": "Remove item?",
"removeConfirmDescription": "Remove \"{name}\" from your pantry?",
"empty": "No pantry items yet.",
"expired": "Expired",
"expiresInDays": "Expires in {days}d",
@@ -651,7 +670,10 @@
"trendingEmpty": "No trending recipes this week.",
"forYou": "For You",
"forYouEmpty": "Favorite or rate some recipes to get personalized picks here.",
"loading": "Loading…"
"loading": "Loading…",
"loadMore": "Load more",
"loadFailed": "Failed to load recipes. Check your connection and try again.",
"retry": "Retry"
},
"shoppingLists": {
"title": "Shopping Lists",
@@ -748,6 +770,13 @@
"commentPlaceholder": "Share your thoughts…",
"commentsTitle": "Comments",
"noCommentsYet": "No comments yet. Be the first!",
"deleteCommentTitle": "Delete comment?",
"deleteCommentDescription": "This action cannot be undone.",
"blockConfirmTitle": "Block @{username}?",
"blockConfirmDescription": "They won't be able to follow you or comment on your recipes.",
"blockConfirmAction": "Block",
"loadMoreComments": "Load more comments",
"loadingMoreComments": "Loading…",
"postButton": "Post",
"postingButton": "Posting…",
"replyButton": "Reply",
@@ -768,7 +797,9 @@
"signInToReact": "Sign in to react to comments",
"reactionFailed": "Failed to update reaction",
"addReaction": "Add {type} reaction",
"removeReaction": "Remove {type} reaction"
"removeReaction": "Remove {type} reaction",
"favoriteAdd": "Save recipe",
"favoriteRemove": "Unsave recipe"
},
"cookingMode": {
"cooking": "Cooking",