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",
+40 -9
View File
@@ -15,7 +15,10 @@
"apiKeys": "Clés API",
"webhooks": "Webhooks",
"language": "Langue",
"notifications": "Notifications"
"notifications": "Notifications",
"viewProfile": "Voir le profil",
"lightMode": "Mode clair",
"darkMode": "Mode sombre"
},
"notifications": {
"title": "Notifications",
@@ -151,7 +154,8 @@
"bulkVisibility": "{count, plural, one {1 recette définie sur {visibility}} other {{count} recettes définies sur {visibility}}}",
"bulkDeleteFailed": "Échec de la suppression",
"bulkUpdateFailed": "Échec de la mise à jour",
"bulkDeleteConfirm": "{count, plural, one {Supprimer 1 recette ?} other {Supprimer {count} recettes ?}} Cette action est irréversible.",
"bulkDeleteConfirmTitle": "{count, plural, one {Supprimer 1 recette ?} other {Supprimer {count} recettes ?}}",
"bulkDeleteConfirmDescription": "Cette action est irréversible.",
"deselectAll": "Tout désélectionner",
"selectAll": "Tout sélectionner",
"clickToSelect": "Cliquez sur les cartes pour sélectionner",
@@ -323,7 +327,8 @@
"ariaLabel": "Demander à l'IA à propos de cette recette",
"title": "Poser une question sur cette recette",
"intro": "Demandez n'importe quoi sur cette recette — ingrédients, techniques, substitutions, minutage…",
"inputPlaceholder": "Posez une question…"
"inputPlaceholder": "Posez une question…",
"send": "Envoyer le message"
},
"servingScaler": {
"servings": "Portions",
@@ -351,7 +356,10 @@
"selectConversation": "Sélectionnez une conversation",
"messageButton": "Message",
"startConversationFailed": "Échec du démarrage de la conversation",
"title": "Messages"
"title": "Messages",
"loadOlder": "Charger les messages précédents",
"loadingOlder": "Chargement…",
"send": "Envoyer le message"
},
"nutritionPanel": {
"estimateButton": "Estimer les valeurs nutritionnelles",
@@ -414,7 +422,9 @@
"anyDifficulty": "Toutes",
"editManually": "Modifier manuellement",
"surpriseMe": "Surprends-moi",
"generatingContent": "Génération du contenu de la recette…"
"generatingContent": "Génération du contenu de la recette…",
"decrease": "Diminuer",
"increase": "Augmenter"
},
"auth": {
"signIn": "Se connecter",
@@ -520,7 +530,13 @@
"createRecipe": "Créer la recette",
"updateSuccess": "Recette mise à jour",
"createSuccess": "Recette créée",
"saveError": "Échec de l'enregistrement de la recette"
"saveError": "Échec de l'enregistrement de la recette",
"ingredientsRequired": "Ajoutez au moins un ingrédient",
"stepsRequired": "Ajoutez au moins une étape",
"discardChangesTitle": "Abandonner les modifications ?",
"discardChangesDescription": "Vous avez des modifications non enregistrées. Si vous quittez maintenant, elles seront perdues.",
"keepEditing": "Continuer l'édition",
"discardChanges": "Abandonner"
},
"canCook": {
"title": "Que puis-je cuisiner ?",
@@ -604,7 +620,8 @@
"listCreating": "Création…",
"listCreate": "Créer",
"pickRecipe": "Choisir une recette",
"addEntry": "+ Ajouter"
"addEntry": "+ Ajouter",
"removeEntry": "Retirer le repas"
},
"pantry": {
"title": "Garde-manger",
@@ -618,6 +635,8 @@
"add": "Ajouter",
"addFailed": "Échec de l'ajout",
"removeFailed": "Échec de la suppression",
"removeConfirmTitle": "Retirer cet article ?",
"removeConfirmDescription": "Retirer « {name} » du garde-manger ?",
"empty": "Aucun article dans le garde-manger.",
"expired": "Expiré",
"expiresInDays": "Expire dans {days}j",
@@ -639,7 +658,10 @@
"trendingEmpty": "Aucune recette tendance cette semaine.",
"forYou": "Pour vous",
"forYouEmpty": "Ajoutez des favoris ou notez des recettes pour des suggestions personnalisées.",
"loading": "Chargement…"
"loading": "Chargement…",
"loadMore": "Charger plus",
"loadFailed": "Échec du chargement des recettes. Vérifiez votre connexion et réessayez.",
"retry": "Réessayer"
},
"shoppingLists": {
"title": "Listes de courses",
@@ -736,6 +758,13 @@
"commentPlaceholder": "Partagez vos impressions…",
"commentsTitle": "Commentaires",
"noCommentsYet": "Aucun commentaire pour l'instant. Soyez le premier !",
"deleteCommentTitle": "Supprimer le commentaire ?",
"deleteCommentDescription": "Cette action est irréversible.",
"blockConfirmTitle": "Bloquer @{username} ?",
"blockConfirmDescription": "Cette personne ne pourra plus vous suivre ni commenter vos recettes.",
"blockConfirmAction": "Bloquer",
"loadMoreComments": "Charger plus de commentaires",
"loadingMoreComments": "Chargement…",
"postButton": "Publier",
"postingButton": "Publication…",
"replyButton": "Répondre",
@@ -756,7 +785,9 @@
"signInToReact": "Connectez-vous pour réagir aux commentaires",
"reactionFailed": "Échec de la mise à jour de la réaction",
"addReaction": "Ajouter la réaction {type}",
"removeReaction": "Retirer la réaction {type}"
"removeReaction": "Retirer la réaction {type}",
"favoriteAdd": "Enregistrer la recette",
"favoriteRemove": "Retirer la recette des enregistrements"
},
"cookingMode": {
"cooking": "En cuisine",