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:
@@ -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 };
|
||||
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 {
|
||||
await sendEmail({
|
||||
to,
|
||||
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 });
|
||||
} 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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { db, userAiKeys, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { encrypt } from "@/lib/encrypt";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const;
|
||||
|
||||
@@ -27,6 +28,9 @@ export async function POST(req: Request) {
|
||||
const { session, response } = await requireSession();
|
||||
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());
|
||||
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
|
||||
|
||||
|
||||
@@ -78,10 +78,11 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
for (const entry of plan.entries) {
|
||||
// Create draft recipe
|
||||
const recipeId = crypto.randomUUID();
|
||||
await db.insert(recipes).values({
|
||||
await tx.insert(recipes).values({
|
||||
id: recipeId,
|
||||
authorId: userId,
|
||||
title: entry.recipe.title,
|
||||
@@ -95,7 +96,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (entry.recipe.ingredients.length > 0) {
|
||||
await db.insert(recipeIngredients).values(
|
||||
await tx.insert(recipeIngredients).values(
|
||||
entry.recipe.ingredients.map((ing, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
@@ -108,7 +109,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (entry.recipe.steps.length > 0) {
|
||||
await db.insert(recipeSteps).values(
|
||||
await tx.insert(recipeSteps).values(
|
||||
entry.recipe.steps.map((step, i) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId,
|
||||
@@ -119,7 +120,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// Remove any existing entry for this day+mealType, then insert new
|
||||
const existingEntry = await db.query.mealPlanEntries.findFirst({
|
||||
const existingEntry = await tx.query.mealPlanEntries.findFirst({
|
||||
where: and(
|
||||
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
|
||||
eq(mealPlanEntries.day, entry.day),
|
||||
@@ -128,11 +129,11 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
|
||||
if (existingEntry) {
|
||||
await db.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
|
||||
await tx.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
|
||||
}
|
||||
|
||||
const entryId = crypto.randomUUID();
|
||||
await db.insert(mealPlanEntries).values({
|
||||
await tx.insert(mealPlanEntries).values({
|
||||
id: entryId,
|
||||
mealPlanId: mealPlan!.id,
|
||||
day: entry.day,
|
||||
@@ -143,6 +144,7 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
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";
|
||||
|
||||
const Schema = z.object({
|
||||
@@ -9,17 +9,42 @@ const Schema = z.object({
|
||||
isPublic: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export async function GET(_req: NextRequest) {
|
||||
export async function GET(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const rows = await db.query.collections.findMany({
|
||||
where: eq(collections.userId, session!.user.id),
|
||||
const { searchParams } = req.nextUrl;
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
return NextResponse.json(rows);
|
||||
const total = countResult[0]?.total ?? 0;
|
||||
|
||||
return NextResponse.json({ data: rows, total, limit, offset });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
|
||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/api-auth", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tiers", () => ({
|
||||
checkTierLimit: vi.fn(),
|
||||
checkAndIncrementTierLimit: vi.fn(),
|
||||
incrementUsage: vi.fn(),
|
||||
TierLimitError: class TierLimitError extends Error {},
|
||||
}));
|
||||
@@ -134,9 +134,9 @@ describe("POST /api/v1/recipes", () => {
|
||||
});
|
||||
|
||||
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");
|
||||
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
|
||||
vi.mocked(checkAndIncrementTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
|
||||
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
@@ -85,7 +85,8 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "node:crypto";
|
||||
import { db, users, eq } from "@epicure/db";
|
||||
|
||||
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
|
||||
// Handles:
|
||||
@@ -74,16 +75,23 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// TODO: wire up DB calls when Stripe billing is fully configured
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
// upgrade user to pro
|
||||
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
|
||||
case "checkout.session.completed": {
|
||||
// client_reference_id is set to our internal userId when the Checkout Session is created.
|
||||
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;
|
||||
case "customer.subscription.deleted":
|
||||
// downgrade user to free
|
||||
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
|
||||
}
|
||||
case "customer.subscription.deleted": {
|
||||
const customerId = event.data.object["customer"];
|
||||
if (typeof customerId === "string") {
|
||||
await db.update(users).set({ tier: "free" }).where(eq(users.stripeCustomerId, customerId));
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// ignore unhandled event types
|
||||
break;
|
||||
|
||||
@@ -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,9 +1,7 @@
|
||||
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];
|
||||
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;
|
||||
@@ -11,14 +9,83 @@ function isPrivateAddress(ip: string): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
@@ -26,6 +27,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"diff": "^9.0.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"ioredis": "^5.11.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.25.67"
|
||||
},
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
"generate": "drizzle-kit generate",
|
||||
"migrate": "drizzle-kit migrate",
|
||||
"studio": "drizzle-kit studio",
|
||||
"seed": "tsx src/seed.ts"
|
||||
"seed": "tsx src/seed.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"postgres": "^3.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.43",
|
||||
"drizzle-kit": "^0.31.1",
|
||||
"tsx": "^4.20.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
@@ -49,6 +49,7 @@ export const recipes = pgTable("recipes", {
|
||||
}, (t) => [
|
||||
index("recipes_author_idx").on(t.authorId),
|
||||
index("recipes_visibility_idx").on(t.visibility),
|
||||
index("recipes_dietary_tags_gin").using("gin", t.dietaryTags),
|
||||
]);
|
||||
|
||||
export const ingredients = pgTable("ingredients", {
|
||||
|
||||
@@ -25,13 +25,17 @@ export const ratings = pgTable("ratings", {
|
||||
reviewText: text("review_text"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
}, (t) => [
|
||||
index("ratings_user_idx").on(t.userId),
|
||||
]);
|
||||
|
||||
export const favorites = pgTable("favorites", {
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
}, (t) => [
|
||||
index("favorites_user_idx").on(t.userId),
|
||||
]);
|
||||
|
||||
export const comments = pgTable("comments", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -53,7 +57,9 @@ export const collections = pgTable("collections", {
|
||||
isPublic: boolean("is_public").notNull().default(false),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
}, (t) => [
|
||||
index("collections_user_idx").on(t.userId),
|
||||
]);
|
||||
|
||||
export const collectionRecipes = pgTable("collection_recipes", {
|
||||
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(),
|
||||
servings: integer("servings"),
|
||||
notes: text("notes"),
|
||||
});
|
||||
}, (t) => [
|
||||
index("cooking_history_user_idx").on(t.userId),
|
||||
]);
|
||||
|
||||
export const feedItems = pgTable("feed_items", {
|
||||
id: text("id").primaryKey(),
|
||||
@@ -104,7 +112,9 @@ export const collectionMembers = pgTable("collection_members", {
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
role: collectionMemberRoleEnum("role").notNull().default("viewer"),
|
||||
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"]);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export const users = pgTable("users", {
|
||||
username: text("username").unique(),
|
||||
role: userRoleEnum("role").notNull().default("user"),
|
||||
tier: tierEnum("tier").notNull().default("free"),
|
||||
stripeCustomerId: text("stripe_customer_id").unique(),
|
||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||
locale: text("locale").notNull().default("en"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user