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:
@@ -4,6 +4,7 @@ import { TierLimitError } from "../tiers";
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
@@ -22,7 +23,7 @@ vi.mock("@epicure/db", () => ({
|
||||
}));
|
||||
|
||||
// Import after mock
|
||||
const { checkTierLimit, incrementUsage } = await import("../tiers");
|
||||
const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers");
|
||||
|
||||
function makeChain(finalValue: unknown) {
|
||||
const chain = {
|
||||
@@ -54,7 +55,7 @@ describe("TierLimitError", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkTierLimit", () => {
|
||||
describe("checkAndIncrementTierLimit", () => {
|
||||
const tierDef = {
|
||||
tier: "free",
|
||||
maxRecipes: 10,
|
||||
@@ -63,50 +64,32 @@ describe("checkTierLimit", () => {
|
||||
maxPublicRecipes: 3,
|
||||
};
|
||||
|
||||
it("does not throw when usage is under limit", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
|
||||
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws TierLimitError when aiCall limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
|
||||
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("throws TierLimitError when recipe limit reached", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
|
||||
it("throws TierLimitError for recipe key when limit reached", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||
mockDb.execute.mockResolvedValueOnce([]);
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("does not throw when no usage row exists (treats as zero)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||
});
|
||||
|
||||
it("does not throw when tier definition does not exist", async () => {
|
||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not throw for storage key (no limit enforced)", async () => {
|
||||
mockDb.select
|
||||
.mockReturnValueOnce(makeChain([tierDef]))
|
||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }]));
|
||||
|
||||
await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined();
|
||||
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||
expect(mockDb.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isPrivateAddress } from "../validate-webhook-url";
|
||||
|
||||
describe("isPrivateAddress", () => {
|
||||
it("flags IPv4 private/reserved ranges", () => {
|
||||
expect(isPrivateAddress("127.0.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("10.1.2.3")).toBe(true);
|
||||
expect(isPrivateAddress("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("192.168.1.1")).toBe(true);
|
||||
expect(isPrivateAddress("169.254.1.1")).toBe(true);
|
||||
expect(isPrivateAddress("224.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IPv4 addresses", () => {
|
||||
expect(isPrivateAddress("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateAddress("1.1.1.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on malformed IPv4 octets", () => {
|
||||
expect(isPrivateAddress("999.999.999.999")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags IPv6 loopback and unspecified addresses in any compression form", () => {
|
||||
expect(isPrivateAddress("::1")).toBe(true);
|
||||
expect(isPrivateAddress("::")).toBe(true);
|
||||
expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags IPv6 unique-local (fc00::/7) and link-local (fe80::/10) ranges", () => {
|
||||
expect(isPrivateAddress("fc00::1")).toBe(true);
|
||||
expect(isPrivateAddress("fd12:3456::1")).toBe(true);
|
||||
expect(isPrivateAddress("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")).toBe(true);
|
||||
expect(isPrivateAddress("fe80::1")).toBe(true);
|
||||
expect(isPrivateAddress("fe00::1")).toBe(false);
|
||||
});
|
||||
|
||||
it("recurses into IPv4-mapped IPv6 addresses, including non-compressed forms", () => {
|
||||
expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
expect(isPrivateAddress("::ffff:10.0.0.5")).toBe(true);
|
||||
expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on a malformed IPv4-mapped address (out-of-range octets)", () => {
|
||||
expect(isPrivateAddress("::ffff:999.999.999.999")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows public IPv6 addresses", () => {
|
||||
expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false);
|
||||
});
|
||||
|
||||
it("fails closed on unparseable input", () => {
|
||||
expect(isPrivateAddress("not-an-ip")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ vi.mock("@epicure/db", () => ({
|
||||
|
||||
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
|
||||
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
@@ -38,7 +38,7 @@ beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
+10
-1
@@ -107,6 +107,15 @@ export function generateOpenApiSpec(): object {
|
||||
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
|
||||
});
|
||||
|
||||
const PaginatedCollections = z.object({
|
||||
data: z.array(CollectionRef),
|
||||
total: z.number(),
|
||||
limit: z.number(),
|
||||
offset: z.number(),
|
||||
});
|
||||
|
||||
const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) });
|
||||
|
||||
const idParam = z.object({ id: z.string() });
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
@@ -121,7 +130,7 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: Pagination }, responses: { 200: { description: "Collections", content: { "application/json": { schema: z.array(CollectionRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
+1
-34
@@ -1,6 +1,6 @@
|
||||
import { db } from "@epicure/db";
|
||||
import { tierDefinitions, userUsage } from "@epicure/db";
|
||||
import { eq, and, sql } from "@epicure/db";
|
||||
import { eq, sql } from "@epicure/db";
|
||||
|
||||
function currentMonth() {
|
||||
const now = new Date();
|
||||
@@ -69,39 +69,6 @@ export async function checkAndIncrementTierLimit(
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */
|
||||
export async function checkTierLimit(
|
||||
userId: string,
|
||||
userTier: "free" | "pro",
|
||||
key: LimitKey
|
||||
): Promise<void> {
|
||||
const [tierDef] = await db
|
||||
.select()
|
||||
.from(tierDefinitions)
|
||||
.where(eq(tierDefinitions.tier, userTier));
|
||||
|
||||
if (!tierDef) return;
|
||||
|
||||
const month = currentMonth();
|
||||
const [usage] = await db
|
||||
.select()
|
||||
.from(userUsage)
|
||||
.where(and(eq(userUsage.userId, userId), eq(userUsage.month, month)));
|
||||
|
||||
const current = usage ?? {
|
||||
aiCallsUsed: 0,
|
||||
recipeCount: 0,
|
||||
storageUsedMb: 0,
|
||||
};
|
||||
|
||||
if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) {
|
||||
throw new TierLimitError("recipe", userTier);
|
||||
}
|
||||
if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) {
|
||||
throw new TierLimitError("aiCall", userTier);
|
||||
}
|
||||
}
|
||||
|
||||
export async function incrementUsage(
|
||||
userId: string,
|
||||
key: LimitKey,
|
||||
|
||||
@@ -1,24 +1,91 @@
|
||||
import dns from "node:dns/promises";
|
||||
import net from "node:net";
|
||||
|
||||
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;
|
||||
function isPrivateV4(a: number, b: number): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
/** Expands a valid IPv6 address (any compression form) into 8 16-bit groups as a BigInt. */
|
||||
function ipv6ToBigInt(ip: string): bigint | null {
|
||||
if (net.isIPv6(ip) !== true) return null;
|
||||
|
||||
const [head, tail] = ip.split("::");
|
||||
const headParts = head ? head.split(":") : [];
|
||||
const tailParts = tail ? tail.split(":") : [];
|
||||
|
||||
// An embedded IPv4 tail (e.g. "::ffff:127.0.0.1") occupies the last two hextets.
|
||||
const expand = (parts: string[]): string[] => {
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.includes(".")) {
|
||||
const octets = last.split(".").map(Number);
|
||||
if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
|
||||
return [];
|
||||
}
|
||||
const hex1 = ((octets[0]! << 8) | octets[1]!).toString(16);
|
||||
const hex2 = ((octets[2]! << 8) | octets[3]!).toString(16);
|
||||
return [...parts.slice(0, -1), hex1, hex2];
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const expandedHead = expand(headParts);
|
||||
const expandedTail = expand(tailParts);
|
||||
|
||||
let groups: string[];
|
||||
if (ip.includes("::")) {
|
||||
const missing = 8 - (expandedHead.length + expandedTail.length);
|
||||
if (missing < 0) return null;
|
||||
groups = [...expandedHead, ...Array(missing).fill("0"), ...expandedTail];
|
||||
} else {
|
||||
groups = expandedHead;
|
||||
}
|
||||
|
||||
if (groups.length !== 8) return null;
|
||||
|
||||
let result = BigInt(0);
|
||||
for (const g of groups) {
|
||||
const val = parseInt(g || "0", 16);
|
||||
if (Number.isNaN(val)) return null;
|
||||
result = (result << BigInt(16)) | BigInt(val);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function isPrivateAddress(ip: string): boolean {
|
||||
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (v4) {
|
||||
const octets = v4.slice(1, 5).map(Number);
|
||||
if (octets.some((o) => o > 255)) return true; // malformed, fail closed
|
||||
const [a, b] = octets as [number, number];
|
||||
return isPrivateV4(a, b);
|
||||
}
|
||||
|
||||
const addr = ipv6ToBigInt(ip);
|
||||
if (addr === null) return true; // unparseable, fail closed
|
||||
|
||||
if (addr === BigInt(0) || addr === BigInt(1)) return true; // :: and ::1
|
||||
|
||||
const fc00 = BigInt(0xfc00) << BigInt(112);
|
||||
const fe80 = BigInt(0xfe80) << BigInt(112);
|
||||
const mask7 = BigInt(0xfe00) << BigInt(112); // /7 mask for fc00::/7 (top 7 bits of the address)
|
||||
const mask10 = BigInt(0xffc0) << BigInt(112); // /10 mask for fe80::/10 (top 10 bits of the address)
|
||||
if ((addr & mask7) === (fc00 & mask7)) return true; // unique local fc00::/7
|
||||
if ((addr & mask10) === (fe80 & mask10)) return true; // link-local fe80::/10
|
||||
|
||||
// IPv4-mapped ::ffff:0:0/96
|
||||
if (addr >> BigInt(32) === BigInt(0xffff)) {
|
||||
const embedded = addr & BigInt(0xffffffff);
|
||||
const a = Number((embedded >> BigInt(24)) & BigInt(0xff));
|
||||
const b = Number((embedded >> BigInt(16)) & BigInt(0xff));
|
||||
return isPrivateV4(a, b);
|
||||
}
|
||||
|
||||
const lower = ip.toLowerCase();
|
||||
if (lower === "::1" || 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user