fix: resolve TODO.md security/perf/test-coverage backlog

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>
This commit is contained in:
Arnaud
2026-07-02 12:12:42 +02:00
parent 2154512e54
commit d2faf98ac1
38 changed files with 7598 additions and 315 deletions
@@ -92,7 +92,7 @@ describe("withUserKey", () => {
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
// withUserKey uses findFirst via userAiKeys
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey });
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await withUserKey("user1", { provider: "openai" });
expect(config.apiKey).toBe("sk-user-key");
@@ -100,7 +100,7 @@ describe("withUserKey", () => {
it("returns config unchanged when no user key for provider", async () => {
const mockFindFirst = vi.fn().mockResolvedValue(null);
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await withUserKey("user1", { provider: "anthropic" });
expect(config.apiKey).toBeUndefined();
@@ -118,7 +118,7 @@ describe("getModelConfigForUseCase", () => {
mealPlanModel: null,
});
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
const config = await getModelConfigForUseCase("user1", "text");
expect(config.provider).toBe("anthropic");
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const AdaptedRecipeSchema = z.object({
title: z.string(),
@@ -9,25 +10,9 @@ const AdaptedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema,
ingredients: z.array(ingredientSchema(z.number())),
steps: z.array(stepSchema),
adaptationNotes: z.string(),
});
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const RecipeOutputSchema = z.object({
title: z.string(),
@@ -9,25 +10,9 @@ const RecipeOutputSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.number().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema,
ingredients: z.array(ingredientSchema(z.number())),
steps: z.array(stepSchema),
});
export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
title: z.string(),
@@ -9,25 +10,9 @@ const ImportedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema.optional(),
ingredients: z.array(ingredientSchema(z.string())),
steps: z.array(stepSchema),
});
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
+6 -77
View File
@@ -1,63 +1,8 @@
import { generateObject } from "ai";
import dns from "node:dns/promises";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
/**
* Resolves the hostname in `rawUrl` and returns an error string if the
* URL targets a private/reserved address range (SSRF guard), or null if safe.
*/
async function validateImportUrl(rawUrl: string): Promise<string | null> {
let url: URL;
try {
url = new URL(rawUrl);
} catch {
return "Invalid URL";
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
return "URL must use http or https";
}
let addresses: string[];
try {
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
addresses = results.map((r) => r.address);
} catch {
return "Unable to resolve hostname";
}
for (const addr of addresses) {
if (isPrivateAddress(addr)) {
return "URL must not point to a private or reserved address";
}
}
return null;
}
function isPrivateAddress(ip: string): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (v4) {
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
if (a === 127) return true;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && b === 168) return true;
if (a === 169 && b === 254) return true;
if (a >= 224) return true;
return false;
}
const lower = ip.toLowerCase();
if (lower === "::1") return true;
if (lower === "::") return true;
if (lower.startsWith("fe80:")) return true;
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
return false;
}
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
title: z.string(),
@@ -66,31 +11,15 @@ const ImportedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string(),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
})),
steps: z.array(z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
})),
dietaryTags: dietaryTagsSchema.optional(),
ingredients: z.array(ingredientSchema(z.string())),
steps: z.array(stepSchema),
});
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const ssrfError = await validateImportUrl(url);
const ssrfError = await validateWebhookUrl(url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, {
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
export const dietaryTagsSchema = z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
glutenFree: z.boolean().optional(),
dairyFree: z.boolean().optional(),
nutFree: z.boolean().optional(),
halal: z.boolean().optional(),
kosher: z.boolean().optional(),
});
export const stepSchema = z.object({
instruction: z.string(),
timerSeconds: z.number().int().optional(),
});
export function ingredientSchema<Q extends z.ZodTypeAny>(quantity: Q) {
return z.object({
rawName: z.string(),
quantity: quantity.optional(),
unit: z.string().optional(),
note: z.string().optional(),
});
}