Update features and dependencies
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { encrypt, decrypt } from "../encrypt";
|
||||
|
||||
describe("encrypt / decrypt", () => {
|
||||
it("round-trips a short string", () => {
|
||||
const plain = "hello world";
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("round-trips an empty string", () => {
|
||||
expect(decrypt(encrypt(""))).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips a unicode string", () => {
|
||||
const plain = "café ☕ résumé";
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("round-trips a long string (API key-sized)", () => {
|
||||
const plain = "sk-proj-" + "a".repeat(128);
|
||||
expect(decrypt(encrypt(plain))).toBe(plain);
|
||||
});
|
||||
|
||||
it("produces different ciphertexts for same input (random IV)", () => {
|
||||
const plain = "same input";
|
||||
const c1 = encrypt(plain);
|
||||
const c2 = encrypt(plain);
|
||||
expect(c1).not.toBe(c2);
|
||||
// but both decrypt to same value
|
||||
expect(decrypt(c1)).toBe(plain);
|
||||
expect(decrypt(c2)).toBe(plain);
|
||||
});
|
||||
|
||||
it("ciphertext format is iv:authTag:encrypted (3 hex segments)", () => {
|
||||
const ct = encrypt("test");
|
||||
const parts = ct.split(":");
|
||||
expect(parts).toHaveLength(3);
|
||||
parts.forEach((p) => expect(p).toMatch(/^[0-9a-f]+$/));
|
||||
});
|
||||
|
||||
it("throws on malformed ciphertext (wrong segments)", () => {
|
||||
expect(() => decrypt("notvalid")).toThrow("Invalid ciphertext format");
|
||||
expect(() => decrypt("a:b")).toThrow("Invalid ciphertext format");
|
||||
});
|
||||
|
||||
it("throws on tampered ciphertext (auth tag mismatch)", () => {
|
||||
const ct = encrypt("sensitive");
|
||||
const parts = ct.split(":");
|
||||
// flip a byte in the encrypted payload
|
||||
const tampered = parts[0] + ":" + parts[1] + ":" + "00" + parts[2]!.slice(2);
|
||||
expect(() => decrypt(tampered)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatQuantity, scaleQuantity } from "../fractions";
|
||||
|
||||
describe("formatQuantity", () => {
|
||||
it("returns '0' for 0", () => {
|
||||
expect(formatQuantity(0)).toBe("0");
|
||||
});
|
||||
|
||||
it("returns whole numbers as strings", () => {
|
||||
expect(formatQuantity(1)).toBe("1");
|
||||
expect(formatQuantity(4)).toBe("4");
|
||||
});
|
||||
|
||||
it("rounds near-integer values up", () => {
|
||||
expect(formatQuantity(0.97)).toBe("1");
|
||||
expect(formatQuantity(2.96)).toBe("3");
|
||||
});
|
||||
|
||||
it("formats known fractions with unicode symbols", () => {
|
||||
expect(formatQuantity(0.5)).toBe("½");
|
||||
expect(formatQuantity(0.25)).toBe("¼");
|
||||
expect(formatQuantity(0.75)).toBe("¾");
|
||||
expect(formatQuantity(0.333)).toBe("⅓");
|
||||
expect(formatQuantity(0.667)).toBe("⅔");
|
||||
expect(formatQuantity(0.125)).toBe("⅛");
|
||||
});
|
||||
|
||||
it("formats mixed numbers", () => {
|
||||
expect(formatQuantity(1.5)).toBe("1 ½");
|
||||
expect(formatQuantity(2.25)).toBe("2 ¼");
|
||||
expect(formatQuantity(3.75)).toBe("3 ¾");
|
||||
});
|
||||
|
||||
it("falls back to one decimal for values that don't match a fraction", () => {
|
||||
// 0.4375 is midpoint between ⅜ (0.375) and ½ (0.5), diff=0.0625 from both — exceeds 0.06 threshold
|
||||
expect(formatQuantity(0.4375)).toMatch(/^0\.\d$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleQuantity", () => {
|
||||
it("returns empty string for null/empty base quantity", () => {
|
||||
expect(scaleQuantity(null, 4, 2)).toBe("");
|
||||
expect(scaleQuantity("", 4, 2)).toBe("");
|
||||
});
|
||||
|
||||
it("returns base quantity unchanged for non-numeric strings", () => {
|
||||
expect(scaleQuantity("to taste", 4, 2)).toBe("to taste");
|
||||
});
|
||||
|
||||
it("scales down by half", () => {
|
||||
expect(scaleQuantity("4", 4, 2)).toBe("2");
|
||||
});
|
||||
|
||||
it("scales up by double", () => {
|
||||
expect(scaleQuantity("2", 2, 4)).toBe("4");
|
||||
});
|
||||
|
||||
it("handles base=0 gracefully (no division by zero)", () => {
|
||||
expect(scaleQuantity("3", 0, 4)).toBe("3");
|
||||
});
|
||||
|
||||
it("produces fraction symbols when result is fractional", () => {
|
||||
expect(scaleQuantity("1", 2, 1)).toBe("½");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const pipelineChain = vi.hoisted(() => ({
|
||||
zadd: vi.fn().mockReturnThis(),
|
||||
zremrangebyscore: vi.fn().mockReturnThis(),
|
||||
zcard: vi.fn().mockReturnThis(),
|
||||
expire: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../redis", () => ({
|
||||
getRedis: vi.fn(() => ({ pipeline: vi.fn(() => pipelineChain) })),
|
||||
}));
|
||||
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn((body: unknown, init?: { status?: number }) => ({ body, init, isNextResponse: true })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
const { applyRateLimit } = await import("../rate-limit");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("applyRateLimit", () => {
|
||||
it("returns null when under the limit", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 3], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns 429 response when over the limit", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 11], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).not.toBeNull();
|
||||
expect(NextResponse.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.any(String) }),
|
||||
expect.objectContaining({ status: 429 })
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when exactly at limit (count === limit is allowed)", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 10], null]);
|
||||
const result = await applyRateLimit("test-key", 10, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("calls pipeline with zadd, zremrangebyscore, zcard, expire", async () => {
|
||||
pipelineChain.exec.mockResolvedValue([null, null, [null, 1], null]);
|
||||
await applyRateLimit("my-key", 5, 30);
|
||||
expect(pipelineChain.zadd).toHaveBeenCalled();
|
||||
expect(pipelineChain.zremrangebyscore).toHaveBeenCalled();
|
||||
expect(pipelineChain.zcard).toHaveBeenCalled();
|
||||
expect(pipelineChain.expire).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
const mockFindFirst = vi.fn();
|
||||
const mockFindMany = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockDelete = vi.fn();
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
siteSettings: {
|
||||
findFirst: mockFindFirst,
|
||||
findMany: mockFindMany,
|
||||
},
|
||||
},
|
||||
insert: mockInsert,
|
||||
delete: mockDelete,
|
||||
},
|
||||
siteSettings: { key: "key", value: "value", isSecret: "is_secret", updatedAt: "updated_at", updatedById: "updated_by_id" },
|
||||
eq: vi.fn((a, b) => ({ a, b })),
|
||||
}));
|
||||
|
||||
const insertChain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const deleteChain = {
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const { getSiteSetting, setSiteSetting, isSecretKey } = await import("../site-settings");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue(insertChain);
|
||||
mockDelete.mockReturnValue(deleteChain);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
});
|
||||
|
||||
describe("isSecretKey", () => {
|
||||
it("marks API keys as secret", () => {
|
||||
expect(isSecretKey("OPENAI_API_KEY")).toBe(true);
|
||||
expect(isSecretKey("ANTHROPIC_API_KEY")).toBe(true);
|
||||
expect(isSecretKey("VAPID_PRIVATE_KEY")).toBe(true);
|
||||
});
|
||||
|
||||
it("marks non-secret settings as public", () => {
|
||||
expect(isSecretKey("OPENROUTER_DEFAULT_MODEL")).toBe(false);
|
||||
expect(isSecretKey("OLLAMA_BASE_URL")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSiteSetting", () => {
|
||||
it("returns DB value when present (plain)", async () => {
|
||||
mockFindFirst.mockResolvedValue({ value: "gpt-4o", isSecret: false });
|
||||
const val = await getSiteSetting("OPENROUTER_DEFAULT_MODEL");
|
||||
expect(val).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("decrypts DB value when secret", async () => {
|
||||
// Pre-encrypt something using the real encrypt function
|
||||
const { encrypt } = await import("../encrypt");
|
||||
const ciphertext = encrypt("sk-real-key");
|
||||
mockFindFirst.mockResolvedValue({ value: ciphertext, isSecret: true });
|
||||
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBe("sk-real-key");
|
||||
});
|
||||
|
||||
it("falls back to env var when no DB row", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
process.env["OPENAI_API_KEY"] = "sk-from-env";
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBe("sk-from-env");
|
||||
});
|
||||
|
||||
it("returns null when neither DB nor env is set", async () => {
|
||||
mockFindFirst.mockResolvedValue(null);
|
||||
const val = await getSiteSetting("OPENAI_API_KEY");
|
||||
expect(val).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to env when DB throws", async () => {
|
||||
mockFindFirst.mockRejectedValue(new Error("DB error"));
|
||||
process.env["ANTHROPIC_API_KEY"] = "sk-anthropic-env";
|
||||
const val = await getSiteSetting("ANTHROPIC_API_KEY");
|
||||
expect(val).toBe("sk-anthropic-env");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSiteSetting", () => {
|
||||
it("deletes the row when value is null", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", null, "admin-user");
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
expect(mockInsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the row when value is empty string", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", "", "admin-user");
|
||||
expect(mockDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("inserts encrypted value for secret key", async () => {
|
||||
await setSiteSetting("OPENAI_API_KEY", "sk-test", "admin-user");
|
||||
expect(mockInsert).toHaveBeenCalled();
|
||||
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(inserted.isSecret).toBe(true);
|
||||
// stored value should not be plain text
|
||||
expect(inserted.value).not.toBe("sk-test");
|
||||
expect(typeof inserted.value).toBe("string");
|
||||
});
|
||||
|
||||
it("inserts plain value for non-secret key", async () => {
|
||||
await setSiteSetting("OPENROUTER_DEFAULT_MODEL", "gpt-4o", "admin-user");
|
||||
const inserted = insertChain.values.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(inserted.isSecret).toBe(false);
|
||||
expect(inserted.value).toBe("gpt-4o");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TierLimitError } from "../tiers";
|
||||
|
||||
const mockDb = vi.hoisted(() => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: mockDb,
|
||||
tierDefinitions: { tier: "tier" },
|
||||
userUsage: {
|
||||
userId: "user_id",
|
||||
month: "month",
|
||||
aiCallsUsed: "ai_calls_used",
|
||||
recipeCount: "recipe_count",
|
||||
storageUsedMb: "storage_used_mb",
|
||||
},
|
||||
eq: vi.fn((col, val) => ({ col, val, op: "eq" })),
|
||||
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||
sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })),
|
||||
}));
|
||||
|
||||
// Import after mock
|
||||
const { checkTierLimit, incrementUsage } = await import("../tiers");
|
||||
|
||||
function makeChain(finalValue: unknown) {
|
||||
const chain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockResolvedValue(finalValue),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain() {
|
||||
const chain = {
|
||||
values: vi.fn().mockReturnThis(),
|
||||
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("TierLimitError", () => {
|
||||
it("has correct name and message", () => {
|
||||
const err = new TierLimitError("aiCall", "free");
|
||||
expect(err.name).toBe("TierLimitError");
|
||||
expect(err.message).toContain("aiCall");
|
||||
expect(err.message).toContain("free");
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkTierLimit", () => {
|
||||
const tierDef = {
|
||||
tier: "free",
|
||||
maxRecipes: 10,
|
||||
aiCallsPerMonth: 5,
|
||||
storageMb: 100,
|
||||
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 }]));
|
||||
|
||||
await expect(checkTierLimit("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 }]));
|
||||
|
||||
await expect(checkTierLimit("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 }]));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe("incrementUsage", () => {
|
||||
it("calls insert with correct aiCall initial values", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "aiCall");
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user1",
|
||||
aiCallsUsed: 1,
|
||||
recipeCount: 0,
|
||||
storageUsedMb: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("calls insert with correct recipe initial values", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "recipe");
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recipeCount: 1,
|
||||
aiCallsUsed: 0,
|
||||
storageUsedMb: 0,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("respects custom amount", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "storage", 50);
|
||||
|
||||
expect(chain.values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ storageUsedMb: 50 })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses onConflictDoUpdate to increment (not overwrite)", async () => {
|
||||
const chain = makeInsertChain();
|
||||
mockDb.insert.mockReturnValue(chain);
|
||||
|
||||
await incrementUsage("user1", "aiCall");
|
||||
|
||||
expect(chain.onConflictDoUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
set: expect.objectContaining({ aiCallsUsed: expect.anything() }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import crypto from "crypto";
|
||||
|
||||
const mockWebhookSelect = vi.fn();
|
||||
const mockInsert = vi.fn();
|
||||
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: mockWebhookSelect,
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||
},
|
||||
webhooks: {},
|
||||
webhookDeliveries: {},
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
}));
|
||||
|
||||
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
|
||||
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
body,
|
||||
headers: (init?.headers as Record<string, string>) ?? {},
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
|
||||
const { dispatchWebhook } = await import("../webhooks");
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCalls = [];
|
||||
vi.clearAllMocks();
|
||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
||||
const body = (init?.body as string) ?? "";
|
||||
fetchCalls.push({
|
||||
url: url as string,
|
||||
body,
|
||||
headers: (init?.headers as Record<string, string>) ?? {},
|
||||
});
|
||||
return new Response(null, { status: 200 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dispatchWebhook", () => {
|
||||
it("does not fetch when no matching webhooks", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([]);
|
||||
await dispatchWebhook("user1", "recipe.created", { id: "r1" });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends POST to webhook URL with event and payload", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "mysecret", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", { id: "r1", title: "Test" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledOnce();
|
||||
const call = fetchCalls[0]!;
|
||||
expect(call.url).toBe("https://example.com/hook");
|
||||
const parsed = JSON.parse(call.body) as { event: string; payload: unknown };
|
||||
expect(parsed.event).toBe("recipe.created");
|
||||
expect(parsed.payload).toMatchObject({ id: "r1", title: "Test" });
|
||||
});
|
||||
|
||||
it("includes valid HMAC-SHA256 signature header", async () => {
|
||||
const secret = "test-webhook-secret";
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret, events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.updated", { id: "r2" });
|
||||
|
||||
const call = fetchCalls[0]!;
|
||||
const sigHeader = call.headers["X-Epicure-Signature"];
|
||||
expect(sigHeader).toBeTruthy();
|
||||
expect(sigHeader).toMatch(/^sha256=/);
|
||||
|
||||
const expectedSig = "sha256=" + crypto.createHmac("sha256", secret).update(call.body).digest("hex");
|
||||
expect(sigHeader).toBe(expectedSig);
|
||||
});
|
||||
|
||||
it("sends X-Epicure-Event header", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "comment.added", {});
|
||||
|
||||
expect(fetchCalls[0]!.headers["X-Epicure-Event"]).toBe("comment.added");
|
||||
});
|
||||
|
||||
it("filters webhooks by event subscription", async () => {
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://a.com/hook", secret: "s", events: ["recipe.deleted"], active: true },
|
||||
{ id: "wh2", url: "https://b.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", {});
|
||||
|
||||
// wh1 only subscribes to recipe.deleted, wh2 subscribes to all (empty = all)
|
||||
expect(global.fetch).toHaveBeenCalledOnce();
|
||||
expect(fetchCalls[0]!.url).toBe("https://b.com/hook");
|
||||
});
|
||||
|
||||
it("records delivery success in DB", async () => {
|
||||
const { db } = await import("@epicure/db");
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", {});
|
||||
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
const insertedRow = mockInsertValues.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(insertedRow.success).toBe(true);
|
||||
expect(insertedRow.event).toBe("recipe.created");
|
||||
});
|
||||
|
||||
it("records delivery failure when fetch throws", async () => {
|
||||
const { db } = await import("@epicure/db");
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await dispatchWebhook("user1", "recipe.created", {});
|
||||
|
||||
expect(db.insert).toHaveBeenCalled();
|
||||
const insertedRow = mockInsertValues.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(insertedRow.success).toBe(false);
|
||||
expect(insertedRow.statusCode).toBe(0);
|
||||
});
|
||||
|
||||
it("still dispatches remaining hooks if one fails (allSettled)", async () => {
|
||||
global.fetch = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Timeout"))
|
||||
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
mockWebhookSelect.mockResolvedValue([
|
||||
{ id: "wh1", url: "https://fail.com/hook", secret: "s", events: [], active: true },
|
||||
{ id: "wh2", url: "https://ok.com/hook", secret: "s", events: [], active: true },
|
||||
]);
|
||||
|
||||
await expect(dispatchWebhook("user1", "recipe.created", {})).resolves.toBeUndefined();
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock AI SDK providers
|
||||
const mockOpenAiModel = vi.fn(() => "openai-model-instance");
|
||||
const mockAnthropicModel = vi.fn(() => "anthropic-model-instance");
|
||||
const mockOpenAiProvider = vi.fn(() => mockOpenAiModel);
|
||||
const mockAnthropicProvider = vi.fn(() => mockAnthropicModel);
|
||||
|
||||
vi.mock("@ai-sdk/openai", () => ({
|
||||
createOpenAI: vi.fn(() => mockOpenAiProvider),
|
||||
}));
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", () => ({
|
||||
createAnthropic: vi.fn(() => mockAnthropicProvider),
|
||||
}));
|
||||
|
||||
const { createOpenAI } = await import("@ai-sdk/openai");
|
||||
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
||||
const { resolveModel } = await import("../factory");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
delete process.env["OPENROUTER_API_KEY"];
|
||||
delete process.env["OLLAMA_BASE_URL"];
|
||||
});
|
||||
|
||||
describe("resolveModel", () => {
|
||||
it("uses openai provider when specified", () => {
|
||||
resolveModel({ provider: "openai", apiKey: "sk-test" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" }));
|
||||
});
|
||||
|
||||
it("uses anthropic provider when specified", () => {
|
||||
resolveModel({ provider: "anthropic", apiKey: "sk-ant-test" });
|
||||
expect(createAnthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-ant-test" }));
|
||||
});
|
||||
|
||||
it("uses openrouter base URL when provider is openrouter", () => {
|
||||
resolveModel({ provider: "openrouter", apiKey: "or-key" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ollama base URL from env or default", () => {
|
||||
process.env["OLLAMA_BASE_URL"] = "http://custom:11434/v1";
|
||||
resolveModel({ provider: "ollama" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "http://custom:11434/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to ollama default URL when env not set", () => {
|
||||
resolveModel({ provider: "ollama" });
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "http://localhost:11434/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("uses custom model when specified", () => {
|
||||
resolveModel({ provider: "openai", model: "gpt-4-turbo", apiKey: "sk-test" });
|
||||
expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4-turbo");
|
||||
});
|
||||
|
||||
it("uses default openai model when none specified", () => {
|
||||
resolveModel({ provider: "openai", apiKey: "sk-test" });
|
||||
expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4o-mini");
|
||||
});
|
||||
|
||||
it("uses default anthropic model when none specified", () => {
|
||||
resolveModel({ provider: "anthropic", apiKey: "sk-ant" });
|
||||
expect(mockAnthropicProvider).toHaveBeenCalledWith("claude-haiku-4-5-20251001");
|
||||
});
|
||||
|
||||
it("defaults to openrouter when OPENROUTER_API_KEY is set (no explicit provider)", () => {
|
||||
process.env["OPENROUTER_API_KEY"] = "or-key";
|
||||
resolveModel({});
|
||||
expect(createOpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" })
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults to openai when OPENAI_API_KEY set and no openrouter", () => {
|
||||
process.env["OPENAI_API_KEY"] = "sk-test";
|
||||
resolveModel({});
|
||||
expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" }));
|
||||
});
|
||||
|
||||
it("throws on unknown provider", () => {
|
||||
// @ts-expect-error - testing invalid input
|
||||
expect(() => resolveModel({ provider: "nonexistent" })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { encrypt } from "../../encrypt";
|
||||
|
||||
const mockUserAiKeysFindMany = vi.fn();
|
||||
const mockUserModelPrefsFindFirst = vi.fn();
|
||||
const mockSiteSettingFindFirst = vi.fn();
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
userAiKeys: { findMany: mockUserAiKeysFindMany },
|
||||
userModelPrefs: { findFirst: mockUserModelPrefsFindFirst },
|
||||
siteSettings: { findFirst: mockSiteSettingFindFirst },
|
||||
},
|
||||
},
|
||||
userAiKeys: {},
|
||||
userModelPrefs: {},
|
||||
siteSettings: {},
|
||||
eq: vi.fn((a, b) => ({ a, b })),
|
||||
and: vi.fn((...args) => args),
|
||||
}));
|
||||
|
||||
const { getDefaultProviderWithKey, getModelConfigForUseCase, withUserKey } = await import("../resolve-user-key");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
delete process.env["ANTHROPIC_API_KEY"];
|
||||
delete process.env["OPENROUTER_API_KEY"];
|
||||
// Make getSiteSetting return null by default
|
||||
mockSiteSettingFindFirst.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("getDefaultProviderWithKey", () => {
|
||||
it("returns empty config when no keys at all", async () => {
|
||||
mockUserAiKeysFindMany.mockResolvedValue([]);
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config).toEqual({});
|
||||
});
|
||||
|
||||
it("returns openrouter key first (priority order)", async () => {
|
||||
const encOpenrouter = encrypt("or-key");
|
||||
const encOpenai = encrypt("sk-test");
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openai", encryptedKey: encOpenai },
|
||||
{ provider: "openrouter", encryptedKey: encOpenrouter },
|
||||
]);
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openrouter");
|
||||
expect(config.apiKey).toBe("or-key");
|
||||
});
|
||||
|
||||
it("falls back to openai when no openrouter", async () => {
|
||||
const encOpenai = encrypt("sk-openai");
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openai", encryptedKey: encOpenai },
|
||||
]);
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-openai");
|
||||
});
|
||||
|
||||
it("uses site settings when no BYOK key exists", async () => {
|
||||
mockUserAiKeysFindMany.mockResolvedValue([]);
|
||||
// Mock getSiteSetting via siteSettings.findFirst to return encrypted key for OPENAI
|
||||
const encKey = encrypt("sk-from-site-settings");
|
||||
mockSiteSettingFindFirst.mockResolvedValueOnce(null) // openrouter
|
||||
.mockResolvedValueOnce({ value: encKey, isSecret: true }); // openai
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-from-site-settings");
|
||||
});
|
||||
|
||||
it("skips corrupted BYOK key and tries next provider", async () => {
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openrouter", encryptedKey: "CORRUPT:NOT:VALID" },
|
||||
{ provider: "openai", encryptedKey: encrypt("sk-valid") },
|
||||
]);
|
||||
|
||||
const config = await getDefaultProviderWithKey("user1");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-valid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withUserKey", () => {
|
||||
it("injects BYOK key when user has one for this provider", async () => {
|
||||
const encKey = encrypt("sk-user-key");
|
||||
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;
|
||||
|
||||
const config = await withUserKey("user1", { provider: "openai" });
|
||||
expect(config.apiKey).toBe("sk-user-key");
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const config = await withUserKey("user1", { provider: "anthropic" });
|
||||
expect(config.apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getModelConfigForUseCase", () => {
|
||||
it("uses user model prefs when set", async () => {
|
||||
mockUserModelPrefsFindFirst.mockResolvedValue({
|
||||
textProvider: "anthropic",
|
||||
textModel: "claude-sonnet-4-6",
|
||||
visionProvider: null,
|
||||
visionModel: null,
|
||||
mealPlanProvider: null,
|
||||
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;
|
||||
|
||||
const config = await getModelConfigForUseCase("user1", "text");
|
||||
expect(config.provider).toBe("anthropic");
|
||||
expect(config.model).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("falls back to getDefaultProviderWithKey when no prefs", async () => {
|
||||
mockUserModelPrefsFindFirst.mockResolvedValue(null);
|
||||
mockUserAiKeysFindMany.mockResolvedValue([
|
||||
{ provider: "openai", encryptedKey: encrypt("sk-default") },
|
||||
]);
|
||||
|
||||
const config = await getModelConfigForUseCase("user1", "vision");
|
||||
expect(config.provider).toBe("openai");
|
||||
expect(config.apiKey).toBe("sk-default");
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export async function adaptRecipe(
|
||||
excludeIngredients: string[];
|
||||
extraConstraints?: string;
|
||||
},
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<AdaptedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
@@ -74,7 +74,7 @@ export async function adaptRecipe(
|
||||
model,
|
||||
schema: AdaptedRecipeSchema,
|
||||
system:
|
||||
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.`,
|
||||
`You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const MealPlanSchema = z.object({
|
||||
description: z.string().max(300),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string(),
|
||||
quantity: z.string().optional(),
|
||||
quantity: z.number().optional(),
|
||||
unit: z.string().optional(),
|
||||
})).max(20),
|
||||
steps: z.array(z.object({
|
||||
@@ -36,8 +36,9 @@ export async function generateMealPlan(
|
||||
pantryItems?: string[];
|
||||
days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
|
||||
pantryMode?: boolean;
|
||||
difficulty?: "easy" | "medium" | "hard";
|
||||
},
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<GeneratedMealPlan> {
|
||||
const model = resolveModel(config);
|
||||
@@ -45,28 +46,35 @@ export async function generateMealPlan(
|
||||
const servings = options.servings ?? 2;
|
||||
const days = options.days ?? ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||
const pantryMode = options.pantryMode ?? false;
|
||||
const difficulty = options.difficulty;
|
||||
|
||||
const dietaryClause = options.dietaryPrefs?.trim()
|
||||
? `Dietary requirements: ${options.dietaryPrefs.trim()}.`
|
||||
: "";
|
||||
const difficultyClause = difficulty
|
||||
? `All recipes must be ${difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[difficulty]}.`
|
||||
: "";
|
||||
const pantryClause =
|
||||
options.pantryItems && options.pantryItems.length > 0
|
||||
? `Available pantry ingredients to use: ${options.pantryItems.slice(0, 20).join(", ")}.`
|
||||
: "";
|
||||
|
||||
const systemPrompt = pantryMode
|
||||
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. Respond in ${lang}.`
|
||||
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. Respond in ${lang}.`;
|
||||
? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`
|
||||
: `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`;
|
||||
|
||||
const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "");
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: MealPlanSchema,
|
||||
system: systemPrompt,
|
||||
system: systemPromptWithContext,
|
||||
prompt: [
|
||||
`Generate a meal plan for ${days.length} day(s): ${days.join(", ")}.`,
|
||||
`Include breakfast, lunch, and dinner for each day.`,
|
||||
`Plan for ${servings} servings per meal.`,
|
||||
dietaryClause,
|
||||
difficultyClause,
|
||||
pantryClause,
|
||||
pantryMode
|
||||
? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased."
|
||||
|
||||
@@ -34,17 +34,19 @@ export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
|
||||
|
||||
export async function generateRecipe(
|
||||
prompt: string,
|
||||
config?: AiConfig & { language?: string }
|
||||
config?: AiConfig & { language?: string; difficulty?: "easy" | "medium" | "hard"; userContext?: string }
|
||||
): Promise<GeneratedRecipe> {
|
||||
const model = resolveModel(config);
|
||||
const lang = config?.language ?? "en";
|
||||
const langInstruction = lang !== "en" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}.` : "";
|
||||
const difficultyInstruction = config?.difficulty ? ` The recipe must be ${config.difficulty} difficulty: ${{ easy: "simple techniques, minimal steps, common ingredients", medium: "moderate skill required, standard techniques", hard: "advanced techniques, multiple components, skilled cook required" }[config.difficulty]}.` : "";
|
||||
const userInstruction = config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : "";
|
||||
|
||||
const { object } = await generateObject({
|
||||
model,
|
||||
schema: RecipeOutputSchema,
|
||||
system:
|
||||
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}`,
|
||||
`You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}${difficultyInstruction}${userInstruction}`,
|
||||
prompt: `Create a recipe for: ${prompt}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,64 @@
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
const ImportedRecipeSchema = z.object({
|
||||
title: z.string(),
|
||||
description: z.string().optional(),
|
||||
@@ -33,6 +90,9 @@ const ImportedRecipeSchema = z.object({
|
||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||
|
||||
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
|
||||
const ssrfError = await validateImportUrl(url);
|
||||
if (ssrfError) throw new Error(ssrfError);
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
|
||||
signal: AbortSignal.timeout(10000),
|
||||
|
||||
@@ -27,7 +27,7 @@ export async function suggestDrinks(
|
||||
ingredients: Array<{ rawName: string }>;
|
||||
},
|
||||
count = 4,
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<DrinkSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
@@ -41,7 +41,7 @@ export async function suggestDrinks(
|
||||
model,
|
||||
schema: DrinksOutputSchema,
|
||||
system:
|
||||
`You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.`,
|
||||
`You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Suggest ${count} drinks to serve with this recipe.\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function suggestPairings(
|
||||
ingredients: Array<{ rawName: string }>;
|
||||
},
|
||||
count = 4,
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
locale?: string
|
||||
): Promise<PairingSuggestion[]> {
|
||||
const model = resolveModel(config);
|
||||
@@ -40,7 +40,7 @@ export async function suggestPairings(
|
||||
model,
|
||||
schema: PairingsOutputSchema,
|
||||
system:
|
||||
`You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.`,
|
||||
`You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Suggest ${count} dishes that pair well with this recipe to form a complete meal:\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function suggestVariations(
|
||||
steps: Array<{ instruction: string }>;
|
||||
},
|
||||
count = 3,
|
||||
config?: AiConfig,
|
||||
config?: AiConfig & { userContext?: string },
|
||||
directions?: string,
|
||||
locale?: string
|
||||
): Promise<VariationSuggestion[]> {
|
||||
@@ -61,7 +61,7 @@ export async function suggestVariations(
|
||||
model,
|
||||
schema: VariationsOutputSchema,
|
||||
system:
|
||||
`You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.`,
|
||||
`You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`,
|
||||
prompt: `Suggest ${count} variations of this recipe:\n\n${recipeText}${directionsClause}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { db, users, eq } from "@epicure/db";
|
||||
|
||||
export async function getUserPrivateBio(userId: string): Promise<string | null> {
|
||||
const row = await db.query.users.findFirst({
|
||||
where: eq(users.id, userId),
|
||||
columns: { privateBio: true },
|
||||
});
|
||||
return row?.privateBio ?? null;
|
||||
}
|
||||
|
||||
export function buildUserBioContext(privateBio: string | null): string {
|
||||
if (!privateBio?.trim()) return "";
|
||||
return `\n\nUser preferences and context:\n${privateBio.trim()}`;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { genericOAuthClient } from "better-auth/client/plugins";
|
||||
|
||||
export const authClient = createAuthClient();
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
});
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db";
|
||||
import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email";
|
||||
@@ -38,8 +39,37 @@ export const auth = betterAuth({
|
||||
clientId: process.env["GOOGLE_CLIENT_ID"] ?? "",
|
||||
clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
...(process.env["GITHUB_CLIENT_ID"] && {
|
||||
github: {
|
||||
clientId: process.env["GITHUB_CLIENT_ID"],
|
||||
clientSecret: process.env["GITHUB_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
}),
|
||||
...(process.env["DISCORD_CLIENT_ID"] && {
|
||||
discord: {
|
||||
clientId: process.env["DISCORD_CLIENT_ID"],
|
||||
clientSecret: process.env["DISCORD_CLIENT_SECRET"] ?? "",
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
plugins: [
|
||||
...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "authentik",
|
||||
clientId: process.env["AUTHENTIK_CLIENT_ID"],
|
||||
clientSecret: process.env["AUTHENTIK_CLIENT_SECRET"] ?? "",
|
||||
// Authentik OIDC discovery URL: https://<your-authentik-domain>/application/o/<slug>/
|
||||
discoveryUrl: `${process.env["AUTHENTIK_BASE_URL"]}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
] : []),
|
||||
],
|
||||
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
|
||||
+24
-3
@@ -1,10 +1,31 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto";
|
||||
import {
|
||||
createCipheriv,
|
||||
createDecipheriv,
|
||||
randomBytes,
|
||||
hkdfSync,
|
||||
} from "crypto";
|
||||
|
||||
const ALGORITHM = "aes-256-gcm";
|
||||
|
||||
// Static salt — committed to source so it is stable across deployments.
|
||||
// It does NOT need to be secret; its purpose is domain-separation and to
|
||||
// prevent the raw secret from being used directly as a key.
|
||||
const HKDF_SALT = Buffer.from("epicure-encryption-v1-salt", "utf8");
|
||||
const HKDF_INFO = Buffer.from("epicure-aes-256-gcm-key", "utf8");
|
||||
|
||||
function getKey(): Buffer {
|
||||
const secret = process.env["BETTER_AUTH_SECRET"] ?? "dev-secret-needs-32-bytes-padding!";
|
||||
return createHash("sha256").update(secret).digest();
|
||||
// Prefer a dedicated encryption secret; fall back to BETTER_AUTH_SECRET so
|
||||
// existing deployments keep working without an immediate migration.
|
||||
const secret =
|
||||
process.env["ENCRYPTION_SECRET"] ?? process.env["BETTER_AUTH_SECRET"];
|
||||
if (!secret) throw new Error("ENCRYPTION_SECRET (or BETTER_AUTH_SECRET) is required");
|
||||
|
||||
// HKDF-SHA256 provides proper key derivation with domain separation.
|
||||
// This replaces the previous raw SHA-256 hash which offered no stretching
|
||||
// or salt, making brute-force against leaked ciphertexts trivial.
|
||||
return Buffer.from(
|
||||
hkdfSync("sha256", secret, HKDF_SALT, HKDF_INFO, 32)
|
||||
);
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
const UNICODE_FRACTIONS: Record<string, number> = {
|
||||
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
|
||||
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
|
||||
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
|
||||
};
|
||||
|
||||
export function parseQuantity(v: string | number | undefined): string | undefined {
|
||||
if (v === undefined || v === "") return undefined;
|
||||
const s = String(v).trim();
|
||||
if (!s) return undefined;
|
||||
|
||||
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
|
||||
|
||||
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
if (s.endsWith(frac)) {
|
||||
const whole = s.slice(0, -frac.length).trim();
|
||||
if (!whole) return String(val);
|
||||
const w = parseFloat(whole);
|
||||
if (!isNaN(w)) return String(w + val);
|
||||
}
|
||||
}
|
||||
|
||||
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
||||
if (mixed) {
|
||||
const den = parseInt(mixed[3]!);
|
||||
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
|
||||
}
|
||||
|
||||
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (slash) {
|
||||
const den = parseInt(slash[2]!);
|
||||
if (den !== 0) return String(parseInt(slash[1]!) / den);
|
||||
}
|
||||
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? undefined : String(n);
|
||||
}
|
||||
@@ -16,6 +16,60 @@ export class TierLimitError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically checks the tier limit and increments the usage counter in a
|
||||
* single SQL statement, eliminating the TOCTOU race that existed when
|
||||
* checkTierLimit and incrementUsage were called separately.
|
||||
*
|
||||
* Throws TierLimitError if the limit has already been reached.
|
||||
* Use this instead of the separate checkTierLimit + incrementUsage pair
|
||||
* for "recipe" and "aiCall" keys.
|
||||
*/
|
||||
export async function checkAndIncrementTierLimit(
|
||||
userId: string,
|
||||
userTier: "free" | "pro",
|
||||
key: "recipe" | "aiCall"
|
||||
): Promise<void> {
|
||||
const [tierDef] = await db
|
||||
.select()
|
||||
.from(tierDefinitions)
|
||||
.where(eq(tierDefinitions.tier, userTier));
|
||||
|
||||
if (!tierDef) return;
|
||||
|
||||
const month = currentMonth();
|
||||
const id = `${userId}-${month}`;
|
||||
|
||||
if (key === "aiCall") {
|
||||
const limit = tierDef.aiCallsPerMonth;
|
||||
const result = await db.execute(sql`
|
||||
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
||||
VALUES (${id}, ${userId}, ${month}, 1, 0, 0)
|
||||
ON CONFLICT (user_id, month) DO UPDATE
|
||||
SET ai_calls_used = user_usage.ai_calls_used + 1
|
||||
WHERE user_usage.ai_calls_used < ${limit}
|
||||
RETURNING ai_calls_used
|
||||
`);
|
||||
if (result.length === 0) {
|
||||
throw new TierLimitError("aiCall", userTier);
|
||||
}
|
||||
} else {
|
||||
const limit = tierDef.maxRecipes;
|
||||
const result = await db.execute(sql`
|
||||
INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb)
|
||||
VALUES (${id}, ${userId}, ${month}, 0, 1, 0)
|
||||
ON CONFLICT (user_id, month) DO UPDATE
|
||||
SET recipe_count = user_usage.recipe_count + 1
|
||||
WHERE user_usage.recipe_count < ${limit}
|
||||
RETURNING recipe_count
|
||||
`);
|
||||
if (result.length === 0) {
|
||||
throw new TierLimitError("recipe", userTier);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */
|
||||
export async function checkTierLimit(
|
||||
userId: string,
|
||||
userTier: "free" | "pro",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import dns from "node:dns/promises";
|
||||
|
||||
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" || 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an error string if the URL is disallowed (SSRF), or null if safe.
|
||||
* Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata.
|
||||
*/
|
||||
export async function validateWebhookUrl(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 "Webhook 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 webhook hostname";
|
||||
}
|
||||
|
||||
for (const addr of addresses) {
|
||||
if (isPrivateAddress(addr)) {
|
||||
return "Webhook URL must not point to a private or reserved address";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user