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
+37
View File
@@ -0,0 +1,37 @@
# Known issues / backlog
Findings from a codebase health scan (security, data integrity, tests, perf, cleanup). All items below are resolved as of this pass.
## Resolved
1. ~~**IDOR** — `collections/[id]/route.ts`~~ — investigated: the `PUT` handler's top-level ownership check (`existing = findFirst(id + userId)`) already gates the entire handler, including `removeRecipeId`/`addRecipeId`. Not actually vulnerable. No change made.
2. ~~**Missing transaction** — meal-plan generation~~ — wrapped the per-entry insert sequence in `db.transaction(...)`.
3. ~~**Missing indexes**~~ — added `userId` indexes to `collections`, `collectionMembers`, `cookingHistory`, `ratings`, `favorites`.
4. ~~**Zero test coverage** on `api/v1/admin/*` and `api/v1/webhooks/*`~~ — added Vitest coverage for all 7 route files (role checks, SSRF validation path, redelivery logic).
5. ~~SSRF gap — malformed IPv6~~ — replaced string-prefix heuristics with a proper IPv6 parser (handles `::` compression, IPv4-mapped addresses, fails closed on malformed input). Also found and fixed an identical duplicated bug in `lib/ai/features/import-url.ts`; consolidated both call sites onto the one fixed implementation.
6. ~~Race condition — `checkAndIncrementTierLimit`~~ — investigated: already atomic (single `INSERT ... ON CONFLICT DO UPDATE ... RETURNING`). The old racy `checkTierLimit` was dead code (zero callers) — deleted.
7. ~~N+1 / no pagination — collections list~~ — added `limit`/`offset` pagination, matching the `search` route's pattern.
8. ~~Dietary-tag search — missing index~~ — added a GIN index on `recipes.dietaryTags`; also switched the search filter from `->>` text extraction to `@>` containment so the index is actually used.
9. ~~Duplicated Zod schemas across AI features~~ — extracted shared `dietaryTagsSchema`/`ingredientSchema`/`stepSchema` into `lib/ai/features/recipe-schema.ts`.
10. ~~Stripe webhook stubbed~~ — implemented tier upgrade/downgrade; added `users.stripeCustomerId` to map `customer.subscription.deleted` events back to a user.
11. ~~No rate limit on `ai-keys`~~ — added `applyRateLimit` to the POST handler.
12. ~~`pnpm typecheck` documented but missing~~ — added the script to all three workspace packages; also fixed the pre-existing type errors it exposed (`packages/db` missing `@types/node`, two stale test mocks).
13. ~~Hardcoded `localhost:3001` fallback~~ — now throws a 500 with a clear message if `BETTER_AUTH_URL` is unset, instead of silently generating a broken link.
# Feature ideas
Brainstormed extensions building on existing infra (pantry match, meal planning, cooking mode w/ voice, tiers, AI generation, social/collections, print, version history).
## Done
1. **Expiry-aware pantry** — done. Pantry schema/UI already had `expiresAt` fully wired (date input, sort, expiry badges); added the missing piece — the canCook page now surfaces a "Use it up" badge on recipes that use soon-expiring pantry items and sorts them to the top.
2. **Shared meal plans/shopping lists** — done. Added `shoppingListMembers`/`mealPlanMembers` tables (viewer/editor roles, mirrors `collectionMembers`), share dialogs, and membership-checked API routes. Meal plans keep their owner-side weekly routes; shared access goes through new `/api/v1/meal-plans/shared/[mealPlanId]` routes since plans are addressed by `(userId, weekStart)`.
3. **PDF cookbook export** — done. `/print/collection/[id]` renders every recipe in a collection with `page-break-after` between them, reusing the existing print-page CSS; browser print-to-PDF produces the file, matching the existing single-recipe/meal-plan print pattern (no new PDF-rendering dependency).
4. **Recipe diff/compare view** — done. Added the `diff` package + `VersionDiffView`; version-history-button now has a "Compare with current" action next to Restore.
5. **Grocery delivery handoff** — done as a stub, per confirmed scope: shopping lists get a "Send to grocery delivery" button that always offers copy-as-text, plus a documented (not live) Instacart adapter gated behind `INSTACART_API_KEY`/`NEXT_PUBLIC_GROCERY_PROVIDER` — real activation needs an actual partner agreement.
6. **Personalized "for you" feed** — done. New `/api/v1/feed/for-you` ranks public recipes by tag/dietary-tag overlap with the user's favorited/highly-rated history (falls back to recency when there's no history yet); third feed tab added.
7. **PWA/offline mode** — done. The service worker and offline fallback already existed; added `manifest.json` + icons and wired them into the root layout metadata so the app is installable. Cache-first on `/cook` already makes previously-visited cooking-mode pages available offline.
## Also fixed this pass
- **Mobile responsiveness** — Recipes/Collections/Pantry/Meal Plan/Shopping Lists page headers now stack and wrap instead of clipping buttons off-screen on narrow viewports (`flex-col sm:flex-row` + `flex-wrap`).
@@ -0,0 +1,80 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("next/headers", () => ({
headers: vi.fn().mockResolvedValue(new Headers()),
}));
vi.mock("@/lib/auth/server", () => ({
auth: { api: { getSession: vi.fn() } },
}));
vi.mock("@/lib/site-settings", () => ({
setSiteSetting: vi.fn().mockResolvedValue(undefined),
}));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ role: "admin" }]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
users: { id: "id", role: "role" },
auditLogs: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { auth } = await import("@/lib/auth/server");
const { setSiteSetting } = await import("@/lib/site-settings");
import { PUT } from "../route";
function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/v1/admin/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never);
mockSelectChain.where.mockResolvedValue([{ role: "admin" }]);
});
describe("PUT /api/v1/admin/settings", () => {
it("returns 403 when caller is not an admin", async () => {
mockSelectChain.where.mockResolvedValue([{ role: "user" }]);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
});
it("returns 403 when there is no session", async () => {
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(403);
});
it("updates allowed keys and writes an audit log", async () => {
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
expect(res.status).toBe(200);
expect(vi.mocked(setSiteSetting)).toHaveBeenCalledWith("OPENAI_API_KEY", "sk-1", "admin-1");
expect(mockInsertValues).toHaveBeenCalled();
});
it("silently ignores keys not in the allow-list", async () => {
const res = await PUT(makeRequest({ NOT_A_REAL_KEY: "x" }));
expect(res.status).toBe(200);
expect(vi.mocked(setSiteSetting)).not.toHaveBeenCalled();
expect(mockInsertValues).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("@/lib/api-auth", () => ({
requireAdmin: vi.fn(),
}));
vi.mock("@/lib/email", () => ({
sendEmail: vi.fn().mockResolvedValue(undefined),
verifyEmailHtml: vi.fn((url: string) => `<a href="${url}">verify</a>`),
}));
const { requireAdmin } = await import("@/lib/api-auth");
const { sendEmail } = await import("@/lib/email");
import { POST } from "../route";
function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/v1/admin/test-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
const ORIGINAL_ENV = process.env["BETTER_AUTH_URL"];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
process.env["BETTER_AUTH_URL"] = "https://epicure.example.com";
});
afterEach(() => {
if (ORIGINAL_ENV === undefined) delete process.env["BETTER_AUTH_URL"];
else process.env["BETTER_AUTH_URL"] = ORIGINAL_ENV;
});
describe("POST /api/v1/admin/test-email", () => {
it("returns 403 when caller is not an admin", async () => {
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
} as never);
const res = await POST(makeRequest({ to: "user@example.com" }));
expect(res.status).toBe(403);
});
it("returns 400 when 'to' is missing", async () => {
const res = await POST(makeRequest({}));
expect(res.status).toBe(400);
});
it("returns 500 when BETTER_AUTH_URL is not configured", async () => {
delete process.env["BETTER_AUTH_URL"];
const res = await POST(makeRequest({ to: "user@example.com" }));
expect(res.status).toBe(500);
expect(sendEmail).not.toHaveBeenCalled();
});
it("sends the test email using the configured base URL", async () => {
const res = await POST(makeRequest({ to: "user@example.com" }));
expect(res.status).toBe(200);
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({ to: "user@example.com" })
);
});
});
@@ -9,11 +9,16 @@ export async function POST(req: NextRequest) {
const { to } = await req.json() as { to: string }; const { to } = await req.json() as { to: string };
if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 }); if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 });
const baseUrl = process.env["BETTER_AUTH_URL"];
if (!baseUrl) {
return NextResponse.json({ error: "BETTER_AUTH_URL is not configured" }, { status: 500 });
}
try { try {
await sendEmail({ await sendEmail({
to, to,
subject: "Epicure — test email", subject: "Epicure — test email",
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`), html: verifyEmailHtml(`${baseUrl}/verify-email?token=test`),
}); });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (err) { } catch (err) {
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
vi.mock("@/lib/api-auth", () => ({
requireAdmin: vi.fn(),
}));
const { mockUpdateChain, mockInsertValues } = vi.hoisted(() => {
const mockUpdateChain = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]),
};
return { mockUpdateChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({
db: {
update: vi.fn(() => mockUpdateChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
users: { id: "id", role: "role", tier: "tier" },
auditLogs: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { requireAdmin } = await import("@/lib/api-auth");
import { PATCH } from "../route";
function makeRequest(body: unknown) {
return new NextRequest("http://localhost/api/v1/admin/users/target-1", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
const ctx = { params: Promise.resolve({ id: "target-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
mockUpdateChain.returning.mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]);
});
describe("PATCH /api/v1/admin/users/[id]", () => {
it("returns 403 when caller is not an admin", async () => {
vi.mocked(requireAdmin).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
} as never);
const res = await PATCH(makeRequest({ role: "admin" }), ctx);
expect(res.status).toBe(403);
});
it("returns 400 for an invalid role", async () => {
const res = await PATCH(makeRequest({ role: "superuser" }), ctx);
expect(res.status).toBe(400);
});
it("returns 400 for an invalid tier", async () => {
const res = await PATCH(makeRequest({ tier: "enterprise" }), ctx);
expect(res.status).toBe(400);
});
it("returns 404 when the target user does not exist", async () => {
mockUpdateChain.returning.mockResolvedValue([]);
const res = await PATCH(makeRequest({ role: "moderator" }), ctx);
expect(res.status).toBe(404);
});
it("updates the user and writes an audit log", async () => {
const res = await PATCH(makeRequest({ role: "moderator" }), ctx);
expect(res.status).toBe(200);
const body = await res.json() as { user: { id: string } };
expect(body.user.id).toBe("target-1");
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({ action: "admin.user.update", targetId: "target-1" })
);
});
});
+4
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { db, userAiKeys, eq, and } from "@epicure/db"; import { db, userAiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth"; import { requireSession } from "@/lib/api-auth";
import { encrypt } from "@/lib/encrypt"; import { encrypt } from "@/lib/encrypt";
import { applyRateLimit } from "@/lib/rate-limit";
const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const; const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const;
@@ -27,6 +28,9 @@ export async function POST(req: Request) {
const { session, response } = await requireSession(); const { session, response } = await requireSession();
if (response) return response; if (response) return response;
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
if (limited) return limited;
const body = PostSchema.safeParse(await req.json()); const body = PostSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 }); if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
@@ -78,71 +78,73 @@ export async function POST(req: NextRequest) {
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = []; const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
for (const entry of plan.entries) { await db.transaction(async (tx) => {
// Create draft recipe for (const entry of plan.entries) {
const recipeId = crypto.randomUUID(); // Create draft recipe
await db.insert(recipes).values({ const recipeId = crypto.randomUUID();
id: recipeId, await tx.insert(recipes).values({
authorId: userId, id: recipeId,
title: entry.recipe.title, authorId: userId,
description: entry.recipe.description, title: entry.recipe.title,
baseServings: entry.servings, description: entry.recipe.description,
visibility: "private", baseServings: entry.servings,
aiGenerated: true, visibility: "private",
difficulty: entry.recipe.difficulty ?? null, aiGenerated: true,
prepMins: entry.recipe.prepMins ?? null, difficulty: entry.recipe.difficulty ?? null,
cookMins: entry.recipe.cookMins ?? null, prepMins: entry.recipe.prepMins ?? null,
}); cookMins: entry.recipe.cookMins ?? null,
});
if (entry.recipe.ingredients.length > 0) { if (entry.recipe.ingredients.length > 0) {
await db.insert(recipeIngredients).values( await tx.insert(recipeIngredients).values(
entry.recipe.ingredients.map((ing, i) => ({ entry.recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(), id: crypto.randomUUID(),
recipeId, recipeId,
rawName: ing.rawName, rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null, quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null, unit: ing.unit ?? null,
order: i, order: i,
})) }))
); );
}
if (entry.recipe.steps.length > 0) {
await tx.insert(recipeSteps).values(
entry.recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
order: i,
}))
);
}
// Remove any existing entry for this day+mealType, then insert new
const existingEntry = await tx.query.mealPlanEntries.findFirst({
where: and(
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
eq(mealPlanEntries.day, entry.day),
eq(mealPlanEntries.mealType, entry.mealType)
),
});
if (existingEntry) {
await tx.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
}
const entryId = crypto.randomUUID();
await tx.insert(mealPlanEntries).values({
id: entryId,
mealPlanId: mealPlan!.id,
day: entry.day,
mealType: entry.mealType,
recipeId,
servings: entry.servings,
});
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
} }
});
if (entry.recipe.steps.length > 0) {
await db.insert(recipeSteps).values(
entry.recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
order: i,
}))
);
}
// Remove any existing entry for this day+mealType, then insert new
const existingEntry = await db.query.mealPlanEntries.findFirst({
where: and(
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
eq(mealPlanEntries.day, entry.day),
eq(mealPlanEntries.mealType, entry.mealType)
),
});
if (existingEntry) {
await db.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
}
const entryId = crypto.randomUUID();
await db.insert(mealPlanEntries).values({
id: entryId,
mealPlanId: mealPlan!.id,
day: entry.day,
mealType: entry.mealType,
recipeId,
servings: entry.servings,
});
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
}
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries }); return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
} }
+33 -8
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { db, collections, eq, desc } from "@epicure/db"; import { db, collections, eq, desc, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth"; import { requireSession } from "@/lib/api-auth";
const Schema = z.object({ const Schema = z.object({
@@ -9,17 +9,42 @@ const Schema = z.object({
isPublic: z.boolean().default(false), isPublic: z.boolean().default(false),
}); });
export async function GET(_req: NextRequest) { export async function GET(req: NextRequest) {
const { session, response } = await requireSession(); const { session, response } = await requireSession();
if (response) return response; if (response) return response;
const rows = await db.query.collections.findMany({ const { searchParams } = req.nextUrl;
where: eq(collections.userId, session!.user.id),
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
});
return NextResponse.json(rows); const limitRaw = searchParams.get("limit");
const limit = Math.min(
limitRaw !== null && !Number.isNaN(Number(limitRaw))
? Math.max(1, Number(limitRaw))
: 20,
50
);
const offsetRaw = searchParams.get("offset");
const offset =
offsetRaw !== null && !Number.isNaN(Number(offsetRaw))
? Math.max(0, Number(offsetRaw))
: 0;
const where = eq(collections.userId, session!.user.id);
const [rows, countResult] = await Promise.all([
db.query.collections.findMany({
where,
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
limit,
offset,
}),
db.select({ total: sql<number>`count(*)::int` }).from(collections).where(where),
]);
const total = countResult[0]?.total ?? 0;
return NextResponse.json({ data: rows, total, limit, offset });
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -12,7 +12,7 @@ vi.mock("@/lib/api-auth", () => ({
})); }));
vi.mock("@/lib/tiers", () => ({ vi.mock("@/lib/tiers", () => ({
checkTierLimit: vi.fn(), checkAndIncrementTierLimit: vi.fn(),
incrementUsage: vi.fn(), incrementUsage: vi.fn(),
TierLimitError: class TierLimitError extends Error {}, TierLimitError: class TierLimitError extends Error {},
})); }));
@@ -134,9 +134,9 @@ describe("POST /api/v1/recipes", () => {
}); });
it("returns 403 when tier limit reached", async () => { it("returns 403 when tier limit reached", async () => {
const { checkTierLimit } = await import("@/lib/tiers"); const { checkAndIncrementTierLimit } = await import("@/lib/tiers");
const { TierLimitError } = await import("@/lib/tiers"); const { TierLimitError } = await import("@/lib/tiers");
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free")); vi.mocked(checkAndIncrementTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
const res = await POST(makeRequest("POST", validRecipe)); const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(403); expect(res.status).toBe(403);
+2 -1
View File
@@ -85,7 +85,8 @@ export async function GET(req: NextRequest) {
} }
for (const tag of dietaryTags) { for (const tag of dietaryTags) {
conditions.push(sql`${recipes.dietaryTags}->>${tag} = 'true'`); // Containment (@>) instead of ->> text extraction so the GIN index on dietaryTags is actually used.
conditions.push(sql`${recipes.dietaryTags} @> ${JSON.stringify({ [tag]: true })}::jsonb`);
} }
const where = and(...conditions); const where = and(...conditions);
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(null),
}));
const { mockSelectChain, mockDeleteChain, mockUpdateChain } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ id: "wh-1" }]),
};
const mockDeleteChain = { where: vi.fn().mockResolvedValue(undefined) };
const mockUpdateChain = { set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(undefined) };
return { mockSelectChain, mockDeleteChain, mockUpdateChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
delete: vi.fn(() => mockDeleteChain),
update: vi.fn(() => mockUpdateChain),
},
webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
}));
const { requireSession } = await import("@/lib/api-auth");
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
import { DELETE, PATCH } from "../route";
function makeRequest(method: string, body?: unknown) {
return new NextRequest("http://localhost/api/v1/webhooks/wh-1", {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
mockSelectChain.limit.mockResolvedValue([{ id: "wh-1" }]);
});
describe("DELETE /api/v1/webhooks/[id]", () => {
it("returns 404 when the webhook does not belong to the caller", async () => {
mockSelectChain.limit.mockResolvedValue([]);
const res = await DELETE(makeRequest("DELETE"), ctx);
expect(res.status).toBe(404);
});
it("returns 204 on successful deletion", async () => {
const res = await DELETE(makeRequest("DELETE"), ctx);
expect(res.status).toBe(204);
});
});
describe("PATCH /api/v1/webhooks/[id]", () => {
it("returns 404 when the webhook does not belong to the caller", async () => {
mockSelectChain.limit.mockResolvedValue([]);
const res = await PATCH(makeRequest("PATCH", { active: false }), ctx);
expect(res.status).toBe(404);
});
it("returns 400 when the new URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address");
const res = await PATCH(makeRequest("PATCH", { url: "http://169.254.169.254/" }), ctx);
expect(res.status).toBe(400);
});
it("returns 400 when there are no fields to update", async () => {
const res = await PATCH(makeRequest("PATCH", {}), ctx);
expect(res.status).toBe(400);
});
it("returns 200 and applies the update", async () => {
const res = await PATCH(makeRequest("PATCH", { active: false }), ctx);
expect(res.status).toBe(200);
expect(mockUpdateChain.set).toHaveBeenCalledWith(expect.objectContaining({ active: false }));
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
const { mockFindFirst, mockSelectChain } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
return { mockFindFirst: vi.fn(), mockSelectChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
query: { webhooks: { findFirst: mockFindFirst } },
},
webhooks: { id: "id", userId: "user_id" },
webhookDeliveries: { webhookId: "webhook_id", createdAt: "created_at" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
desc: vi.fn((a) => ({ a, op: "desc" })),
}));
const { requireSession } = await import("@/lib/api-auth");
import { GET } from "../route";
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
mockFindFirst.mockResolvedValue({ id: "wh-1" });
mockSelectChain.limit.mockResolvedValue([]);
});
describe("GET /api/v1/webhooks/[id]/deliveries", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
expect(res.status).toBe(401);
});
it("returns 404 when the webhook does not belong to the caller", async () => {
mockFindFirst.mockResolvedValue(undefined);
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
expect(res.status).toBe(404);
});
it("returns 200 with the delivery history", async () => {
mockSelectChain.limit.mockResolvedValue([{ id: "del-1", event: "recipe.created" }]);
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
expect(res.status).toBe(200);
const body = await res.json() as unknown[];
expect(body).toHaveLength(1);
});
});
@@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/webhooks", () => ({
dispatchWebhook: vi.fn().mockResolvedValue(undefined),
}));
const { mockWebhookFindFirst, mockDeliveryFindFirst } = vi.hoisted(() => ({
mockWebhookFindFirst: vi.fn(),
mockDeliveryFindFirst: vi.fn(),
}));
vi.mock("@epicure/db", () => ({
db: {
query: {
webhooks: { findFirst: mockWebhookFindFirst },
webhookDeliveries: { findFirst: mockDeliveryFindFirst },
},
},
webhooks: { id: "id", userId: "user_id" },
webhookDeliveries: { id: "id", webhookId: "webhook_id" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
and: vi.fn((...args) => ({ args, op: "and" })),
}));
const { requireSession } = await import("@/lib/api-auth");
const { dispatchWebhook } = await import("@/lib/webhooks");
import { POST } from "../route";
const VALID_DELIVERY_ID = "11111111-1111-1111-1111-111111111111";
function makeRequest(body?: unknown) {
return new NextRequest("http://localhost/api/v1/webhooks/wh-1/redeliver", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
mockWebhookFindFirst.mockResolvedValue({ id: "wh-1" });
mockDeliveryFindFirst.mockResolvedValue({
id: VALID_DELIVERY_ID,
event: "recipe.created",
payload: { recipeId: "r-1" },
});
});
describe("POST /api/v1/webhooks/[id]/redeliver", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(401);
});
it("returns 404 when the webhook does not belong to the caller", async () => {
mockWebhookFindFirst.mockResolvedValue(undefined);
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(404);
});
it("returns 400 when deliveryId is not a valid UUID", async () => {
const res = await POST(makeRequest({ deliveryId: "not-a-uuid" }), ctx);
expect(res.status).toBe(400);
});
it("returns 404 when the delivery is not found", async () => {
mockDeliveryFindFirst.mockResolvedValue(undefined);
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(404);
});
it("replays the delivery payload via dispatchWebhook", async () => {
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
expect(res.status).toBe(200);
expect(dispatchWebhook).toHaveBeenCalledWith("user-1", "recipe.created", { recipeId: "r-1" });
});
});
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
requireSession: vi.fn(),
}));
vi.mock("@/lib/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(null),
}));
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([]),
};
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
},
webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" },
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
const { requireSession } = await import("@/lib/api-auth");
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
import { GET, POST } from "../route";
function makeRequest(method: string, body?: unknown) {
return new NextRequest("http://localhost/api/v1/webhooks", {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
mockSelectChain.where.mockResolvedValue([]);
});
describe("GET /api/v1/webhooks", () => {
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET();
expect(res.status).toBe(401);
});
it("returns 200 with the user's webhooks", async () => {
mockSelectChain.where.mockResolvedValue([{ id: "wh-1", userId: "user-1" }]);
const res = await GET();
expect(res.status).toBe(200);
const body = await res.json() as unknown[];
expect(body).toHaveLength(1);
});
});
describe("POST /api/v1/webhooks", () => {
const validBody = { url: "https://example.com/hook", events: ["recipe.created"] };
it("returns 400 on validation error", async () => {
const res = await POST(makeRequest("POST", { url: "" }));
expect(res.status).toBe(400);
});
it("returns 400 when the URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address");
const res = await POST(makeRequest("POST", validBody));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSession).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest("POST", validBody));
expect(res.status).toBe(401);
});
it("returns 201 and creates the webhook", async () => {
const res = await POST(makeRequest("POST", validBody));
expect(res.status).toBe(201);
const body = await res.json() as { url: string; secret: string };
expect(body.url).toBe(validBody.url);
expect(body.secret).toBeTruthy();
expect(mockInsertValues).toHaveBeenCalled();
});
});
+15 -7
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { db, users, eq } from "@epicure/db";
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256. // Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
// Handles: // Handles:
@@ -74,16 +75,23 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
} }
// TODO: wire up DB calls when Stripe billing is fully configured
switch (event.type) { switch (event.type) {
case "checkout.session.completed": case "checkout.session.completed": {
// upgrade user to pro // client_reference_id is set to our internal userId when the Checkout Session is created.
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]); const userId = event.data.object["client_reference_id"];
const customerId = event.data.object["customer"];
if (typeof userId === "string" && typeof customerId === "string") {
await db.update(users).set({ tier: "pro", stripeCustomerId: customerId }).where(eq(users.id, userId));
}
break; break;
case "customer.subscription.deleted": }
// downgrade user to free case "customer.subscription.deleted": {
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]); const customerId = event.data.object["customer"];
if (typeof customerId === "string") {
await db.update(users).set({ tier: "free" }).where(eq(users.stripeCustomerId, customerId));
}
break; break;
}
default: default:
// ignore unhandled event types // ignore unhandled event types
break; break;
+17 -34
View File
@@ -4,6 +4,7 @@ import { TierLimitError } from "../tiers";
const mockDb = vi.hoisted(() => ({ const mockDb = vi.hoisted(() => ({
select: vi.fn(), select: vi.fn(),
insert: vi.fn(), insert: vi.fn(),
execute: vi.fn(),
})); }));
vi.mock("@epicure/db", () => ({ vi.mock("@epicure/db", () => ({
@@ -22,7 +23,7 @@ vi.mock("@epicure/db", () => ({
})); }));
// Import after mock // Import after mock
const { checkTierLimit, incrementUsage } = await import("../tiers"); const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers");
function makeChain(finalValue: unknown) { function makeChain(finalValue: unknown) {
const chain = { const chain = {
@@ -54,7 +55,7 @@ describe("TierLimitError", () => {
}); });
}); });
describe("checkTierLimit", () => { describe("checkAndIncrementTierLimit", () => {
const tierDef = { const tierDef = {
tier: "free", tier: "free",
maxRecipes: 10, maxRecipes: 10,
@@ -63,50 +64,32 @@ describe("checkTierLimit", () => {
maxPublicRecipes: 3, maxPublicRecipes: 3,
}; };
it("does not throw when usage is under limit", async () => { it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
.mockReturnValueOnce(makeChain([tierDef])) mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
}); });
it("throws TierLimitError when aiCall limit reached", async () => { it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
.mockReturnValueOnce(makeChain([tierDef])) mockDb.execute.mockResolvedValueOnce([]);
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
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 () => { it("throws TierLimitError for recipe key when limit reached", async () => {
mockDb.select mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
.mockReturnValueOnce(makeChain([tierDef])) mockDb.execute.mockResolvedValueOnce([]);
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError); await expect(checkAndIncrementTierLimit("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();
}); });
it("does not throw when tier definition does not exist", async () => { it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([])); mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
}); expect(mockDb.execute).not.toHaveBeenCalled();
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();
}); });
}); });
@@ -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);
});
});
+2 -2
View File
@@ -22,7 +22,7 @@ vi.mock("@epicure/db", () => ({
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = []; 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) ?? ""; const body = (init?.body as string) ?? "";
fetchCalls.push({ fetchCalls.push({
url: url as string, url: url as string,
@@ -38,7 +38,7 @@ beforeEach(() => {
fetchCalls = []; fetchCalls = [];
vi.clearAllMocks(); vi.clearAllMocks();
mockInsert.mockReturnValue({ values: mockInsertValues }); 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) ?? ""; const body = (init?.body as string) ?? "";
fetchCalls.push({ fetchCalls.push({
url: url as string, url: url as string,
@@ -92,7 +92,7 @@ describe("withUserKey", () => {
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
// withUserKey uses findFirst via userAiKeys // withUserKey uses findFirst via userAiKeys
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey }); 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" }); const config = await withUserKey("user1", { provider: "openai" });
expect(config.apiKey).toBe("sk-user-key"); expect(config.apiKey).toBe("sk-user-key");
@@ -100,7 +100,7 @@ describe("withUserKey", () => {
it("returns config unchanged when no user key for provider", async () => { it("returns config unchanged when no user key for provider", async () => {
const mockFindFirst = vi.fn().mockResolvedValue(null); 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" }); const config = await withUserKey("user1", { provider: "anthropic" });
expect(config.apiKey).toBeUndefined(); expect(config.apiKey).toBeUndefined();
@@ -118,7 +118,7 @@ describe("getModelConfigForUseCase", () => {
mealPlanModel: null, mealPlanModel: null,
}); });
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key 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"); const config = await getModelConfigForUseCase("user1", "text");
expect(config.provider).toBe("anthropic"); expect(config.provider).toBe("anthropic");
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai"; import { generateObject } from "ai";
import { z } from "zod"; import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory"; import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const AdaptedRecipeSchema = z.object({ const AdaptedRecipeSchema = z.object({
title: z.string(), title: z.string(),
@@ -9,25 +10,9 @@ const AdaptedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(), prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]), difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({ dietaryTags: dietaryTagsSchema,
vegan: z.boolean().optional(), ingredients: z.array(ingredientSchema(z.number())),
vegetarian: z.boolean().optional(), steps: z.array(stepSchema),
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(),
})),
adaptationNotes: z.string(), adaptationNotes: z.string(),
}); });
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai"; import { generateObject } from "ai";
import { z } from "zod"; import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory"; import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const RecipeOutputSchema = z.object({ const RecipeOutputSchema = z.object({
title: z.string(), title: z.string(),
@@ -9,25 +10,9 @@ const RecipeOutputSchema = z.object({
prepMins: z.number().int().min(0).optional(), prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]), difficulty: z.enum(["easy", "medium", "hard"]),
dietaryTags: z.object({ dietaryTags: dietaryTagsSchema,
vegan: z.boolean().optional(), ingredients: z.array(ingredientSchema(z.number())),
vegetarian: z.boolean().optional(), steps: z.array(stepSchema),
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(),
})),
}); });
export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>; export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
+4 -19
View File
@@ -1,6 +1,7 @@
import { generateObject } from "ai"; import { generateObject } from "ai";
import { z } from "zod"; import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory"; import { resolveModel, type AiConfig } from "../factory";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({ const ImportedRecipeSchema = z.object({
title: z.string(), title: z.string(),
@@ -9,25 +10,9 @@ const ImportedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(), prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({ dietaryTags: dietaryTagsSchema.optional(),
vegan: z.boolean().optional(), ingredients: z.array(ingredientSchema(z.string())),
vegetarian: z.boolean().optional(), steps: z.array(stepSchema),
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(),
})),
}); });
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>; export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
+6 -77
View File
@@ -1,63 +1,8 @@
import { generateObject } from "ai"; import { generateObject } from "ai";
import dns from "node:dns/promises";
import { z } from "zod"; import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory"; import { resolveModel, type AiConfig } from "../factory";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
/** import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
* 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;
}
const ImportedRecipeSchema = z.object({ const ImportedRecipeSchema = z.object({
title: z.string(), title: z.string(),
@@ -66,31 +11,15 @@ const ImportedRecipeSchema = z.object({
prepMins: z.number().int().min(0).optional(), prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(),
dietaryTags: z.object({ dietaryTags: dietaryTagsSchema.optional(),
vegan: z.boolean().optional(), ingredients: z.array(ingredientSchema(z.string())),
vegetarian: z.boolean().optional(), steps: z.array(stepSchema),
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(),
})),
}); });
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>; export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> { 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); if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, { 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(),
});
}
+10 -1
View File
@@ -107,6 +107,15 @@ export function generateOpenApiSpec(): object {
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }), 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() }); 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 } } } } }); 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/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: "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/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/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/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 } } } } }); 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
View File
@@ -1,6 +1,6 @@
import { db } from "@epicure/db"; import { db } from "@epicure/db";
import { tierDefinitions, userUsage } from "@epicure/db"; import { tierDefinitions, userUsage } from "@epicure/db";
import { eq, and, sql } from "@epicure/db"; import { eq, sql } from "@epicure/db";
function currentMonth() { function currentMonth() {
const now = new Date(); 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( export async function incrementUsage(
userId: string, userId: string,
key: LimitKey, key: LimitKey,
+84 -17
View File
@@ -1,24 +1,91 @@
import dns from "node:dns/promises"; import dns from "node:dns/promises";
import net from "node:net";
function isPrivateAddress(ip: string): boolean { function isPrivateV4(a: number, b: number): boolean {
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (a === 127) return true;
if (v4) { if (a === 10) return true;
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number]; if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 127) return true; if (a === 192 && b === 168) return true;
if (a === 10) return true; if (a === 169 && b === 254) return true;
if (a === 172 && b >= 16 && b <= 31) return true; if (a >= 224) return true;
if (a === 192 && b === 168) return true; return false;
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; return false;
} }
+2
View File
@@ -7,6 +7,7 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
@@ -26,6 +27,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"diff": "^9.0.0",
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"ioredis": "^5.11.1", "ioredis": "^5.11.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
+3
View File
@@ -7,6 +7,9 @@
"exports": { "exports": {
".": "./src/index.ts" ".": "./src/index.ts"
}, },
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": { "dependencies": {
"zod": "^3.25.67" "zod": "^3.25.67"
}, },
+3 -1
View File
@@ -12,13 +12,15 @@
"generate": "drizzle-kit generate", "generate": "drizzle-kit generate",
"migrate": "drizzle-kit migrate", "migrate": "drizzle-kit migrate",
"studio": "drizzle-kit studio", "studio": "drizzle-kit studio",
"seed": "tsx src/seed.ts" "seed": "tsx src/seed.ts",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"drizzle-orm": "^0.44.7", "drizzle-orm": "^0.44.7",
"postgres": "^3.4.7" "postgres": "^3.4.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.19.43",
"drizzle-kit": "^0.31.1", "drizzle-kit": "^0.31.1",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.8.3" "typescript": "^5.8.3"
@@ -0,0 +1,6 @@
CREATE INDEX "recipes_dietary_tags_gin" ON "recipes" USING gin ("dietary_tags");--> statement-breakpoint
CREATE INDEX "collection_members_user_idx" ON "collection_members" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "collections_user_idx" ON "collections" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "cooking_history_user_idx" ON "cooking_history" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "favorites_user_idx" ON "favorites" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "ratings_user_idx" ON "ratings" USING btree ("user_id");
@@ -0,0 +1,2 @@
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_stripe_customer_id_unique" UNIQUE("stripe_customer_id");
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -49,6 +49,7 @@ export const recipes = pgTable("recipes", {
}, (t) => [ }, (t) => [
index("recipes_author_idx").on(t.authorId), index("recipes_author_idx").on(t.authorId),
index("recipes_visibility_idx").on(t.visibility), index("recipes_visibility_idx").on(t.visibility),
index("recipes_dietary_tags_gin").using("gin", t.dietaryTags),
]); ]);
export const ingredients = pgTable("ingredients", { export const ingredients = pgTable("ingredients", {
+15 -5
View File
@@ -25,13 +25,17 @@ export const ratings = pgTable("ratings", {
reviewText: text("review_text"), reviewText: text("review_text"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); }, (t) => [
index("ratings_user_idx").on(t.userId),
]);
export const favorites = pgTable("favorites", { export const favorites = pgTable("favorites", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }), recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
}); }, (t) => [
index("favorites_user_idx").on(t.userId),
]);
export const comments = pgTable("comments", { export const comments = pgTable("comments", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -53,7 +57,9 @@ export const collections = pgTable("collections", {
isPublic: boolean("is_public").notNull().default(false), isPublic: boolean("is_public").notNull().default(false),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(),
}); }, (t) => [
index("collections_user_idx").on(t.userId),
]);
export const collectionRecipes = pgTable("collection_recipes", { export const collectionRecipes = pgTable("collection_recipes", {
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }), collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
@@ -68,7 +74,9 @@ export const cookingHistory = pgTable("cooking_history", {
cookedAt: timestamp("cooked_at").notNull().defaultNow(), cookedAt: timestamp("cooked_at").notNull().defaultNow(),
servings: integer("servings"), servings: integer("servings"),
notes: text("notes"), notes: text("notes"),
}); }, (t) => [
index("cooking_history_user_idx").on(t.userId),
]);
export const feedItems = pgTable("feed_items", { export const feedItems = pgTable("feed_items", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
@@ -104,7 +112,9 @@ export const collectionMembers = pgTable("collection_members", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
role: collectionMemberRoleEnum("role").notNull().default("viewer"), role: collectionMemberRoleEnum("role").notNull().default("viewer"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
}); }, (t) => [
index("collection_members_user_idx").on(t.userId),
]);
export const commentReactionTypeEnum = pgEnum("comment_reaction_type", ["like", "love", "laugh", "wow", "sad", "fire"]); export const commentReactionTypeEnum = pgEnum("comment_reaction_type", ["like", "love", "laugh", "wow", "sad", "fire"]);
+1
View File
@@ -23,6 +23,7 @@ export const users = pgTable("users", {
username: text("username").unique(), username: text("username").unique(),
role: userRoleEnum("role").notNull().default("user"), role: userRoleEnum("role").notNull().default("user"),
tier: tierEnum("tier").notNull().default("free"), tier: tierEnum("tier").notNull().default("free"),
stripeCustomerId: text("stripe_customer_id").unique(),
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"), unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
locale: text("locale").notNull().default("en"), locale: text("locale").notNull().default("en"),
createdAt: timestamp("created_at").notNull().defaultNow(), createdAt: timestamp("created_at").notNull().defaultNow(),
+2 -1
View File
@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src" "rootDir": "./src",
"types": ["node"]
}, },
"include": ["src"] "include": ["src"]
} }