Compare commits

..

4 Commits

Author SHA1 Message Date
Arnaud e678e21ee8 fix: API Reference page (/docs) blank — CSP blocked the Scalar bundle
script-src had no allowance for cdn.jsdelivr.net, which is where the
Scalar API-reference viewer's JS is loaded from — the script itself
never ran. Added it to script-src/style-src/font-src/connect-src.

(User-confirmed before applying — widens what's allowed to load, so
wanted explicit sign-off rather than just doing it in response to a
console error.)
2026-07-12 19:07:43 +02:00
Arnaud 4d5269aced feat: granular per-category push notification settings
New Settings → Notifications section with a toggle per category (follow,
comment, reply, reaction, rating, mention, leftover-expiring, shared
shopping list) — previously it was all-or-nothing (browser permission only).

userNotificationPrefs (one row per user, defaults all-on so existing users
see no behavior change until they opt out of something). Gated the push
send in the three places that dispatch one: lib/notifications.ts (the 6
in-app notification types), the leftover-expiry cron, and shopping-list-notify
— in-app notification-center entries and email are unaffected, this only
gates the push itself, matching the literal ask.

Verified locally: GET/PUT round-trips correctly, disabling a category
and confirming the settings page renders all 8 toggles.
2026-07-12 19:07:32 +02:00
Arnaud e78c959c49 fix: push-notification toggle in settings always showed as off
Never checked the browser's actual push subscription on mount — state
initialized to false unconditionally, so the button looked reset to
"Enable notifications" on every page load even when notifications were
genuinely still active. Now checks navigator.serviceWorker + getSubscription()
on mount and initializes from that.
2026-07-12 19:06:54 +02:00
Arnaud 5a9e306357 fix: close SSRF/rebinding, IDOR, and stale-session authz gaps found in audit
Bump to 0.5.1. Fixes: unfollowed-redirect SSRF + DNS-rebinding in AI
url-import and webhook dispatch (new safeFetch with IP-pinned undici
dispatcher); cross-user photo deletion via unvalidated recipe/review
storage keys; comment-moderation and tier-quota checks trusting a
stale cached session role/tier instead of the DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:05:20 +02:00
32 changed files with 5371 additions and 66 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
## 0.5.1 — 2026-07-12
### Security
- Recipe/review photo uploads: a stolen storage key from another user's public recipe or review could be submitted as your own, causing that user's photo to be deleted from storage when the recipe was later edited or deleted. Both routes now check the key was actually issued to you for that recipe.
- AI URL-import could be redirected to internal/private network addresses via a crafted 3xx response, and was separately vulnerable to DNS-rebinding between validation and fetch. Both the import and outgoing webhook delivery now resolve the host once and pin the connection to that address, re-validating on every redirect hop.
- Comment moderation and monthly AI/recipe/storage quota checks trusted a session field that can lag up to 5 minutes behind a role or tier change — both now re-check the current value in the database.
## 0.5.0 — 2026-07-12
### Added
+97
View File
@@ -0,0 +1,97 @@
# Security Audit — Epicure
Date: 2026-07-12
Scope: full app (`apps/web`, `packages/db`), 3 independent rounds, each own lens.
Bar: confidence ≥8/10 only.
---
## Round 1 — Auth & Authorization
### Finding 1: Stale cached role trusted for comment moderation — MEDIUM
- File: `apps/web/app/api/v1/comments/[id]/route.ts:33`
- Confidence: 8
- Description: Route checks `session.user.role === "user"` directly. Role comes from Better Auth `cookieCache`, stale up to 5 min (`apps/web/lib/auth/server.ts:79-82`). `requireAdmin()` (`apps/web/lib/api-auth.ts:17-30`) already works around this by re-querying DB — this route doesn't.
- Exploit: Admin demotes abusive moderator/admin to `user`. Demoted user's existing session still passes stale-role check for up to 5 min → can keep deleting other users' comments via `DELETE /api/v1/comments/[id]`.
- Fix: Reuse fresh-DB-role pattern from `requireAdmin()` (or shared `requireRole()` helper) instead of trusting `session.user.role` directly.
### Finding 2: Tier entitlement not re-verified against DB — LOW/MEDIUM
- Files: `apps/web/lib/auth/server.ts:79-82` (cookieCache) + all quota call sites (`api/v1/ai/generate/route.ts`, `ai/adapt/[id]/route.ts`, `ai/meal-plan/generate/route.ts`, `ai/batch-cook/generate/route.ts`, `apps/web/lib/tiers.ts:31`)
- Confidence: 8
- Description: Same cookieCache staleness as Finding 1 applies to `session.user.tier`, but `checkAndIncrementTierLimit` never re-checks DB tier.
- Exploit: User downgraded pro→free (cancellation/chargeback). Stale session keeps pro-tier quota (AI calls, recipe count, storage) applying for up to 5 min. Bounded quota-bypass, not data breach.
- Fix: Fetch `users.tier` fresh in `checkAndIncrementTierLimit`, mirroring `requireAdmin`.
**No other findings ≥8** — recipes/comments/ratings/meal-plans/shopping-lists/collections/admin routes/api-keys/webhooks/Better Auth config/proxy.ts/public share pages all reviewed clean.
---
## Round 2 — Injection & Input Validation
### Finding 1: SSRF via unfollowed redirect in AI URL-import — HIGH
- File: `apps/web/lib/ai/features/import-url.ts:22-28`
- Confidence: 9
- Description: `POST /api/v1/ai/import-url` validates host via `validateWebhookUrl()` (blocks private/loopback/metadata ranges) but then `fetch(url, {...})` uses default `redirect: "follow"` — no restriction on where a 3xx response redirects to. `apps/web/lib/webhooks.ts:39-49` already hardens this with `redirect: "manual"` + comment explaining why; `import-url.ts` never got the same fix.
- Exploit: Attacker hosts public page returning `302 Location: http://169.254.169.254/latest/meta-data/...` (or internal service). Initial host passes validation; fetch follows redirect straight to internal/metadata address, response fed into AI prompt.
- Fix: Set `redirect: "manual"` in `import-url.ts`, treat 3xx as failure (mirror `webhooks.ts`).
### Finding 2: DNS-rebinding TOCTOU in `validateWebhookUrl` — MEDIUM
- Files: `apps/web/lib/validate-webhook-url.ts:96-123`, consumed by `import-url.ts:22-25`
- Confidence: 8
- Description: Validation does its own `dns.lookup`; the real `fetch()` re-resolves independently moments later. Attacker controlling authoritative DNS (low TTL) can return public IP for validation, private IP for fetch. `webhooks.ts` has a comment accepting this as residual risk; `import-url.ts` has no such mitigation and also lacks redirect protection (compounds with Finding 1).
- Fix: Resolve host once, validate resolved IP, connect directly to that IP (pin via custom `dns.lookup`/agent) instead of re-resolving in `fetch`. Apply to both `webhooks.ts` and `import-url.ts`.
### Finding 3: IDOR into S3 `deleteObject` via unvalidated photo storage keys — HIGH
- Files: `apps/web/app/api/v1/recipes/route.ts:48-51`, `apps/web/app/api/v1/recipes/[id]/route.ts:45-48,211-231`, sink `apps/web/lib/storage.ts:40-42`
- Confidence: 8
- Description: `photos[].key` validated only as non-empty string — no check that key was actually issued to this user/recipe by `/api/v1/upload/presign` (no prefix check against `recipes/{recipeId}/photos/` + `session.user.id`). Storage keys of public recipes are visible in rendered `<Image>` src on `apps/web/app/r/[id]/page.tsx:52-58`. On `PUT`, any photo key present in old list but absent from new list gets passed to `deleteObject()` with no ownership check.
- Exploit: Attacker views victim's public recipe, copies photo storage key from image URL. Calls `PUT /api/v1/recipes/{ownRecipeId}` with `photos: [{key: <victim's key>}]` (accepted, no ownership check), then `PUT` again without that key → diff logic treats it as "removed" → `deleteObject` permanently deletes victim's S3 object. No privilege required.
- Fix: Track storage keys server-side as belonging to a specific recipe/user (row created at presign time, or HMAC-signed key). Validate every submitted photo key was issued for this recipe/user before accepting into `recipePhotos` or before deletion eligibility. At minimum prefix-match against `recipes/{recipeId}/` + `session.user.id`, and never `deleteObject` a key not already present in that recipe's existing photo set.
**No other findings ≥8** — all `sql\`...\`` usages parameterized correctly, no eval/exec usage, no raw filesystem path construction from user filenames elsewhere, Zod schemas otherwise bound ranges/enums/lengths properly.
---
## Round 3 — Crypto, Secrets Management & Data Exposure
**No findings ≥8.** Reviewed: `lib/auth/server.ts`, `lib/encrypt.ts`, `lib/gravatar.ts`, `lib/invites.ts`, `lib/api-auth.ts`, shopping-list public-share feature (`app/s/[id]/page.tsx`, `api/v1/shopping-lists/**`), profile-picture upload, `users/me/export`, api-keys/ai-keys, webhooks, Stripe/cron signature verification, broad sweep of routes joining/returning `users` rows.
Notable good patterns confirmed:
- Share-link tokens use `crypto.randomUUID()` (122 bits), scoped queries never leak owner PII.
- User-row queries always project to narrow DTO before serializing (password/email never leak).
- All security-sensitive tokens use `crypto.randomBytes`/`randomUUID`; `Math.random()` only in non-security contexts.
- `lib/encrypt.ts`: AES-256-GCM + HKDF-SHA256, random IV per encryption, auth-tag verified.
- Webhook/cron secret checks use `crypto.timingSafeEqual` with length pre-check.
- No TLS-verification bypasses, no hardcoded secrets.
- `requireAdmin()` re-queries DB role rather than trusting session cache (see Round 1 Finding 1 — inconsistently applied elsewhere).
---
## Summary
| # | Severity | Category | Location | Confidence |
|---|----------|----------|----------|------------|
| R2-F1 | HIGH | SSRF (redirect) | `lib/ai/features/import-url.ts:22-28` | 9 |
| R2-F3 | HIGH | IDOR / arbitrary delete | `api/v1/recipes/[id]/route.ts:211-231` | 8 |
| R1-F1 | MEDIUM | Stale-role authz bypass | `api/v1/comments/[id]/route.ts:33` | 8 |
| R2-F2 | MEDIUM | SSRF (DNS rebinding) | `lib/validate-webhook-url.ts:96-123` | 8 |
| R1-F2 | LOW/MEDIUM | Stale-tier quota bypass | `lib/tiers.ts:31` + call sites | 8 |
**Priority fix order**: R2-F1 (SSRF redirect) and R2-F3 (IDOR delete) first — both HIGH, both concrete exploit paths with no special privilege needed. Then R1-F1 (moderation bypass), R2-F2 (rebinding, compounds with F1), R1-F2 (quota bypass, low impact).
**Status: all 5 fixed** (`safeFetch` w/ IP-pinning + manual-redirect in `lib/validate-webhook-url.ts`; `isOwnedRecipePhotoKey` in `lib/storage.ts` enforced on recipe create/update; fresh-DB role check in `comments/[id]/route.ts`; fresh-DB tier check in `lib/tiers.ts`).
---
## Round 4 — Verification + fresh sweep
Re-verified all 5 fixes above: all PASS, no gaps, no regressions (full `pnpm typecheck` + `pnpm test` clean, same 2 pre-existing unrelated test failures as baseline `main`).
### Finding: IDOR on rating/review `photoKey` — same class as R2-F3, different endpoint — HIGH (fixed)
- File: `apps/web/app/api/v1/recipes/[id]/rate/route.ts:10,41,54`
- Confidence: 9
- Description: `POST /api/v1/recipes/[id]/rate` accepted a client-supplied `photoKey` with no ownership check — the R2-F3 fix only covered `recipePhotos` (folder `photos/`), not review photos (folder `reviews/`), which use a different prefix format and weren't validated at all.
- Exploit: Attacker reads a victim's review-photo storage key off any public recipe/review response, submits it as `photoKey` when rating an unrelated recipe. When that recipe's author later deletes their recipe, `recipes/[id]/route.ts` DELETE collects all `ratings.photoKey` for the recipe and calls `deleteObject()` unconditionally — destroying the victim's photo in a completely different recipe.
- Fix applied: added `isOwnedReviewPhotoKey(key, recipeId, userId)` to `lib/storage.ts` (checks `recipes/{recipeId}/reviews/{userId}-` prefix), enforced in `rate/route.ts` before insert/update — rejects with 400 if the submitted key wasn't issued to this user for this recipe's reviews.
No other new HIGH/MEDIUM findings survived this round. Checked clean: admin role checks, internal cron auth, public shopping-list share endpoints, all raw `sql` usage, other `deleteObject`/presign call sites.
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
import { NotificationCategoriesForm } from "@/components/settings/notification-categories-form";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -21,6 +22,16 @@ export default async function NotificationsPage() {
</div>
<PushSubscribeButton />
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{m.settingsForm.notificationCategories.title}</h2>
<p className="text-sm text-muted-foreground mt-1">
{m.settingsForm.notificationCategories.description}
</p>
</div>
<NotificationCategoriesForm />
</section>
</div>
);
}
@@ -5,6 +5,7 @@ import { sendEmail, notificationEmailHtml } from "@/lib/email";
import { sendPushNotification } from "@/lib/push";
import { isLeftoverExpiringSoon } from "@/lib/leftover-match";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
// Internal cron endpoint — triggered daily by a cron container (see
// compose.prod.yml / cron/crontab). Not part of the public API surface;
@@ -53,11 +54,14 @@ export async function POST(req: NextRequest) {
const title = messages.notifications.pushTitle.leftoverExpiring;
const body = formatMessage(template, { dish: log.batchDish.name, title: log.recipe.title });
const url = `/recipes/${log.recipe.id}`;
const pushEnabled = await isNotificationCategoryEnabled(log.user.id, "leftoverExpiring");
await Promise.all([
sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
}),
pushEnabled
? sendPushNotification(log.user.id, { title, body, url }).catch((err) => {
console.error("[leftover-reminders] push failed", err);
})
: Promise.resolve(),
log.user.email
? sendEmail({
to: log.user.email,
+12 -3
View File
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, comments, eq, and } from "@epicure/db";
import { db, comments, users, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
@@ -30,8 +30,17 @@ export async function DELETE(_req: NextRequest, { params }: Params) {
const comment = await db.query.comments.findFirst({ where: eq(comments.id, id) });
if (!comment) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (comment.userId !== session!.user.id && session!.user.role === "user") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
if (comment.userId !== session!.user.id) {
// session.user.role comes from a 5-minute cookieCache — a just-demoted
// moderator/admin would keep access for up to 5 minutes. Re-query fresh.
const [dbUser] = await db
.select({ role: users.role })
.from(users)
.where(eq(users.id, session!.user.id))
.limit(1);
if (dbUser?.role !== "admin" && dbUser?.role !== "moderator") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
await db.delete(comments).where(eq(comments.id, id));
@@ -3,6 +3,7 @@ import { z } from "zod";
import { db, recipes, ratings, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { createNotification } from "@/lib/notifications";
import { isOwnedReviewPhotoKey } from "@/lib/storage";
const Schema = z.object({
score: z.number().int().min(1).max(5),
@@ -29,6 +30,10 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
if (parsed.data.photoKey && !isOwnedReviewPhotoKey(parsed.data.photoKey, id, session!.user.id)) {
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photoKey"], message: "Photo key not issued for this review" }] }, { status: 400 });
}
const existing = await db.query.ratings.findFirst({
where: and(eq(ratings.recipeId, id), eq(ratings.userId, session!.user.id)),
});
+5 -1
View File
@@ -3,7 +3,7 @@ import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchD
import { eq, and, max, isNotNull } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { deleteObject } from "@/lib/storage";
import { deleteObject, isOwnedRecipePhotoKey } from "@/lib/storage";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
@@ -106,6 +106,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
const data = parsed.data;
if (data.photos?.some((p) => !isOwnedRecipePhotoKey(p.key, id, session!.user.id))) {
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photos"], message: "Photo key not issued for this recipe" }] }, { status: 400 });
}
await db.transaction(async (tx) => {
// Create a snapshot of the current state before updating
const [maxVersionRow] = await tx
+9 -4
View File
@@ -3,6 +3,7 @@ import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchD
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { isOwnedRecipePhotoKey } from "@/lib/storage";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
@@ -93,6 +94,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const id = crypto.randomUUID();
const now = new Date();
const data = parsed.data;
if (data.photos.some((p) => !isOwnedRecipePhotoKey(p.key, id, session!.user.id))) {
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photos"], message: "Photo key not issued for this recipe" }] }, { status: 400 });
}
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
} catch (err) {
@@ -102,10 +111,6 @@ export async function POST(req: NextRequest) {
throw err;
}
const id = crypto.randomUUID();
const now = new Date();
const data = parsed.data;
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id,
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from "next/server";
import { db, userNotificationPrefs, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { getNotificationPrefs } from "@/lib/notification-prefs";
import { z } from "zod";
const PutSchema = z.object({
follow: z.boolean().optional(),
comment: z.boolean().optional(),
reply: z.boolean().optional(),
reaction: z.boolean().optional(),
rating: z.boolean().optional(),
mention: z.boolean().optional(),
leftoverExpiring: z.boolean().optional(),
shoppingList: z.boolean().optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const prefs = await getNotificationPrefs(session!.user.id);
return NextResponse.json({ data: prefs });
}
export async function PUT(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const parsed = PutSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const body = parsed.data;
const userId = session!.user.id;
await db
.insert(userNotificationPrefs)
.values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() })
.onConflictDoUpdate({
target: userNotificationPrefs.userId,
set: { ...body, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
@@ -1,13 +1,34 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { Bell, BellOff } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
export function PushSubscribeButton() {
const [subscribed, setSubscribed] = useState(false);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
// The button previously always started as "not subscribed" regardless of
// actual state — it never checked the browser's real push subscription on
// mount, so it looked reset every time this page loaded even when
// notifications were genuinely still enabled.
useEffect(() => {
let cancelled = false;
(async () => {
try {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const sub = await registration.pushManager.getSubscription();
if (!cancelled) setSubscribed(!!sub);
} catch {
// ignore — leave as not-subscribed if the check itself fails
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, []);
async function handleSubscribe() {
if (Notification.permission === "denied") {
@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import type { NotificationCategory, NotificationPrefs } from "@/lib/notification-prefs";
const CATEGORIES: NotificationCategory[] = [
"follow", "comment", "reply", "reaction", "rating", "mention", "leftoverExpiring", "shoppingList",
];
export function NotificationCategoriesForm() {
const t = useTranslations("settingsForm.notificationCategories");
const t_common = useTranslations("common");
const [prefs, setPrefs] = useState<NotificationPrefs | null>(null);
const [saving, setSaving] = useState<NotificationCategory | null>(null);
useEffect(() => {
fetch("/api/v1/users/me/notification-prefs")
.then((res) => (res.ok ? res.json() : null))
.then((json) => setPrefs(json?.data ?? null))
.catch(() => setPrefs(null));
}, []);
async function toggle(category: NotificationCategory, checked: boolean) {
if (!prefs) return;
const previous = prefs;
setPrefs({ ...prefs, [category]: checked });
setSaving(category);
try {
const res = await fetch("/api/v1/users/me/notification-prefs", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [category]: checked }),
});
if (!res.ok) {
setPrefs(previous);
toast.error(t_common("saveFailed"));
}
} catch {
setPrefs(previous);
toast.error(t_common("saveFailed"));
} finally {
setSaving(null);
}
}
if (!prefs) return null;
return (
<div className="space-y-3">
{CATEGORIES.map((category) => (
<div key={category} className="flex items-center justify-between gap-3">
<Label htmlFor={`notif-${category}`} className="cursor-pointer">
{t(category)}
</Label>
<Switch
id={`notif-${category}`}
checked={prefs[category]}
disabled={saving === category}
onCheckedChange={(checked) => { void toggle(category, checked); }}
/>
</div>
))}
</div>
);
}
@@ -32,6 +32,15 @@ export function ChangelogList() {
</div>
)}
{entry.security && entry.security.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Security</p>
<ul className="space-y-1 text-sm list-disc pl-5">
{entry.security.map((line, j) => <li key={j}>{line}</li>)}
</ul>
</div>
)}
{i < CHANGELOG.length - 1 && <Separator className="mt-6" />}
</div>
))}
+14
View File
@@ -10,6 +10,7 @@ const mockDb = vi.hoisted(() => ({
vi.mock("@epicure/db", () => ({
db: mockDb,
tierDefinitions: { tier: "tier" },
users: { id: "id", tier: "tier" },
userUsage: {
userId: "user_id",
month: "month",
@@ -65,6 +66,7 @@ describe("checkAndIncrementTierLimit", () => {
};
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
@@ -72,6 +74,7 @@ describe("checkAndIncrementTierLimit", () => {
});
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
@@ -79,13 +82,24 @@ describe("checkAndIncrementTierLimit", () => {
});
it("throws TierLimitError for recipe key when limit reached", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
mockDb.execute.mockResolvedValueOnce([]);
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
});
it("re-reads the current tier from the DB instead of trusting the caller's fallbackTier", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "pro" }]));
mockDb.select.mockReturnValueOnce(makeChain([{ ...tierDef, tier: "pro" }]));
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
// caller still thinks it's "free" (stale session), but DB says "pro"
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
});
it("does not throw when tier definition does not exist", async () => {
mockDb.select.mockReturnValueOnce(makeChain([{ tier: "free" }]));
mockDb.select.mockReturnValueOnce(makeChain([]));
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
+2 -5
View File
@@ -1,7 +1,7 @@
import { generateObject } from "ai";
import { z } from "zod";
import { resolveModel, type AiConfig } from "../factory";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { safeFetch } from "@/lib/validate-webhook-url";
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
const ImportedRecipeSchema = z.object({
@@ -19,10 +19,7 @@ const ImportedRecipeSchema = z.object({
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
const ssrfError = await validateWebhookUrl(url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(url, {
const res = await safeFetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" },
signal: AbortSignal.timeout(10000),
});
+11 -1
View File
@@ -1,15 +1,25 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.5.0";
export const APP_VERSION = "0.5.1";
export type ChangelogEntry = {
version: string;
date: string;
added?: string[];
fixed?: string[];
security?: string[];
notes?: string;
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.5.1",
date: "2026-07-12",
security: [
"Recipe/review photo uploads: a stolen storage key from another user's public recipe or review could be submitted as your own, causing that user's photo to be deleted from storage when the recipe was later edited or deleted. Both routes now check the key was actually issued to you for that recipe.",
"AI URL-import could be redirected to internal/private network addresses via a crafted 3xx response, and was separately vulnerable to DNS-rebinding between validation and fetch. Both the import and outgoing webhook delivery now resolve the host once and pin the connection to that address, re-validating on every redirect hop.",
"Comment moderation and monthly AI/recipe/storage quota checks trusted a session field that can lag up to 5 minutes behind a role or tier change — both now re-check the current value in the database.",
],
},
{
version: "0.5.0",
date: "2026-07-12",
+27
View File
@@ -0,0 +1,27 @@
import { db, userNotificationPrefs, eq } from "@epicure/db";
export type NotificationCategory =
| "follow" | "comment" | "reply" | "reaction" | "rating" | "mention"
| "leftoverExpiring" | "shoppingList";
export type NotificationPrefs = Record<NotificationCategory, boolean>;
const DEFAULT_PREFS: NotificationPrefs = {
follow: true, comment: true, reply: true, reaction: true, rating: true, mention: true,
leftoverExpiring: true, shoppingList: true,
};
export async function getNotificationPrefs(userId: string): Promise<NotificationPrefs> {
const row = await db.query.userNotificationPrefs.findFirst({ where: eq(userNotificationPrefs.userId, userId) });
if (!row) return { ...DEFAULT_PREFS };
return {
follow: row.follow, comment: row.comment, reply: row.reply, reaction: row.reaction,
rating: row.rating, mention: row.mention, leftoverExpiring: row.leftoverExpiring, shoppingList: row.shoppingList,
};
}
/** No row yet means every category defaults to on — same default the DB columns encode. */
export async function isNotificationCategoryEnabled(userId: string, category: NotificationCategory): Promise<boolean> {
const prefs = await getNotificationPrefs(userId);
return prefs[category];
}
+6 -3
View File
@@ -3,6 +3,7 @@ import { randomUUID } from "crypto";
import { sendPushNotification } from "./push";
import { sendEmail, notificationEmailHtml } from "./email";
import { getMessages, formatMessage } from "./i18n/server";
import { isNotificationCategoryEnabled } from "./notification-prefs";
type NotificationType = "follow" | "comment" | "reply" | "reaction" | "rating" | "mention";
@@ -75,9 +76,11 @@ async function dispatchAlerts(opts: CreateNotificationOpts): Promise<void> {
? (actor.username ? `/u/${actor.username}` : "/")
: opts.recipeId ? `/recipes/${opts.recipeId}` : "/";
void sendPushNotification(opts.userId, { title, body, url }).catch((err) => {
console.error("[notifications] push failed", err);
});
if (await isNotificationCategoryEnabled(opts.userId, opts.type)) {
void sendPushNotification(opts.userId, { title, body, url }).catch((err) => {
console.error("[notifications] push failed", err);
});
}
if (recipient.email) {
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
+3
View File
@@ -1,6 +1,7 @@
import { db, shoppingLists, shoppingListMembers, eq } from "@epicure/db";
import { sendPushNotification } from "@/lib/push";
import { getMessages, formatMessage } from "@/lib/i18n/server";
import { isNotificationCategoryEnabled } from "@/lib/notification-prefs";
// formatMessage() is a plain {key} interpolator, not ICU-plural-aware, so
// pluralization has to be resolved to a plain string before it's passed in.
@@ -53,6 +54,8 @@ export async function notifyShoppingListMembers(
await Promise.all(
recipients.map(async (recipient) => {
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
const messages = getMessages(recipient.locale);
const title = messages.notifications.pushTitle.shoppingListUpdate;
const body =
+16
View File
@@ -44,3 +44,19 @@ export async function deleteObject(key: string): Promise<void> {
export function getPublicUrl(key: string): string {
return `${clientPublicUrl}/${bucket}/${key}`;
}
/**
* A recipe/review photo's storage key is only trustworthy if it matches the
* exact prefix issued by /api/v1/upload/presign for this recipe + user +
* purpose — otherwise a client could submit another user's key (discoverable
* from a public recipe's rendered <Image> src, or a review's photoKey in API
* responses) and have it adopted, then later evicted, triggering
* deleteObject() against an object it never owned.
*/
export function isOwnedRecipePhotoKey(key: string, recipeId: string, userId: string): boolean {
return key.startsWith(`recipes/${recipeId}/photos/${userId}-`);
}
export function isOwnedReviewPhotoKey(key: string, recipeId: string, userId: string): boolean {
return key.startsWith(`recipes/${recipeId}/reviews/${userId}-`);
}
+10 -2
View File
@@ -1,5 +1,5 @@
import { db } from "@epicure/db";
import { tierDefinitions, userUsage } from "@epicure/db";
import { tierDefinitions, userUsage, users } from "@epicure/db";
import { eq, sql } from "@epicure/db";
function currentMonth() {
@@ -24,16 +24,24 @@ export class TierLimitError extends Error {
* single SQL statement, eliminating the TOCTOU race that existed when
* checkTierLimit and incrementUsage were called separately.
*
* The caller's `userTier` is never trusted directly — it comes from the
* session's 5-minute cookieCache (see lib/auth/server.ts), so a just-downgraded
* user would otherwise keep the old tier's caps for up to 5 minutes. The
* current tier is always re-read from the DB here.
*
* 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",
fallbackTier: "free" | "pro",
key: "recipe" | "aiCall" | "storage",
amount = 1
): Promise<void> {
const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, userId));
const userTier = (dbUser?.tier as "free" | "pro" | undefined) ?? fallbackTier;
const [tierDef] = await db
.select()
.from(tierDefinitions)
+72
View File
@@ -1,5 +1,6 @@
import dns from "node:dns/promises";
import net from "node:net";
import { Agent } from "undici";
function isPrivateV4(a: number, b: number): boolean {
if (a === 127) return true;
@@ -121,3 +122,74 @@ export async function validateWebhookUrl(rawUrl: string): Promise<string | null>
return null;
}
/** Resolves hostname once and returns the first non-private address, or throws. */
async function resolvePinnedAddress(hostname: string): Promise<{ address: string; family: number }> {
let results: { address: string; family: number }[];
try {
results = await dns.lookup(hostname, { all: true, family: 0 });
} catch {
throw new Error("Unable to resolve hostname");
}
const safe = results.find((r) => !isPrivateAddress(r.address));
if (!safe) throw new Error("URL must not point to a private or reserved address");
return safe;
}
/**
* SSRF-safe fetch: resolves the hostname once, validates the resolved IP, then
* pins the actual connection to that exact IP (via a custom undici Agent lookup)
* so a subsequent DNS re-resolution by the HTTP client can't be rebound to an
* internal address. Redirects are followed manually, with each hop re-validated
* and re-pinned the same way — so a 3xx response can't be used to reach an
* internal service either.
*/
export async function safeFetch(
rawUrl: string,
init: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal } = {},
maxRedirects = 5
): Promise<Response> {
let currentUrl = rawUrl;
for (let hop = 0; ; hop++) {
let url: URL;
try {
url = new URL(currentUrl);
} catch {
throw new Error("Invalid URL");
}
if (url.protocol !== "https:" && url.protocol !== "http:") {
throw new Error("URL must use http or https");
}
const { address, family } = await resolvePinnedAddress(url.hostname);
const agent = new Agent({
connect: {
lookup: (_hostname, _opts, callback) => {
callback(null, [{ address, family: family as 4 | 6 }]);
},
},
});
// Node's global fetch is undici under the hood and accepts the same
// `dispatcher` extension, so this pins the connection without needing a
// separate undici-specific fetch call.
const res = await fetch(currentUrl, {
...init,
redirect: "manual",
// @ts-expect-error -- `dispatcher` is a Node/undici fetch extension, not in the lib.dom.d.ts RequestInit type
dispatcher: agent,
});
if (res.status >= 300 && res.status < 400) {
if (hop >= maxRedirects) throw new Error("Too many redirects");
const location = res.headers.get("location");
if (!location) throw new Error("Redirect with no Location header");
currentUrl = new URL(location, currentUrl).toString();
continue;
}
return res;
}
}
+5 -9
View File
@@ -2,7 +2,7 @@ import crypto from "crypto";
import { db } from "@epicure/db";
import { webhooks, webhookDeliveries } from "@epicure/db";
import { eq, and } from "@epicure/db";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { safeFetch } from "@/lib/validate-webhook-url";
export const WEBHOOK_EVENTS = [
"recipe.created",
@@ -31,12 +31,10 @@ export async function dispatchWebhook(userId: string, event: WebhookEvent, paylo
let statusCode = 0;
let success = false;
try {
// Re-validate at dispatch time to defeat DNS rebinding between create and
// dispatch. A small window remains between this lookup and fetch's own
// resolution of the hostname — accepted for now.
const ssrfError = await validateWebhookUrl(hook.url);
if (ssrfError) throw new Error(ssrfError);
const res = await fetch(hook.url, {
// safeFetch resolves and pins the connection to a single validated IP
// (and re-validates + re-pins every redirect hop), so there's no
// separate re-resolution for a rebinding attack to exploit.
const res = await safeFetch(hook.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -44,8 +42,6 @@ export async function dispatchWebhook(userId: string, event: WebhookEvent, paylo
"X-Epicure-Event": event,
},
body,
// Redirects could point at internal services, so treat 3xx as failure.
redirect: "manual",
signal: AbortSignal.timeout(10000),
});
statusCode = res.status;
+12
View File
@@ -1068,6 +1068,18 @@
}
},
"settingsForm": {
"notificationCategories": {
"title": "Notification categories",
"description": "Choose which push notifications you want to receive.",
"follow": "New followers",
"comment": "Comments on your recipes",
"reply": "Replies to your comments",
"reaction": "Reactions to your comments",
"rating": "Ratings on your recipes",
"mention": "Mentions in comments",
"leftoverExpiring": "Leftovers expiring soon",
"shoppingList": "Shared shopping list updates"
},
"profile": "Profile",
"changePhoto": "Change photo",
"removePhoto": "Remove photo",
+12
View File
@@ -1056,6 +1056,18 @@
}
},
"settingsForm": {
"notificationCategories": {
"title": "Catégories de notifications",
"description": "Choisissez les notifications push que vous souhaitez recevoir.",
"follow": "Nouveaux abonnés",
"comment": "Commentaires sur vos recettes",
"reply": "Réponses à vos commentaires",
"reaction": "Réactions à vos commentaires",
"rating": "Notes sur vos recettes",
"mention": "Mentions dans les commentaires",
"leftoverExpiring": "Restes bientôt périmés",
"shoppingList": "Mises à jour des listes de courses partagées"
},
"profile": "Profil",
"changePhoto": "Changer la photo",
"removePhoto": "Supprimer la photo",
+4 -4
View File
@@ -33,11 +33,11 @@ const securityHeaders = [
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed by Next.js dev; tighten in prod
"style-src 'self' 'unsafe-inline'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net", // unsafe-eval needed by Next.js dev; tighten in prod. jsdelivr serves the Scalar API-reference bundle on /docs.
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
`img-src 'self' data: blob: ${storageOrigin} https://www.gravatar.com`,
"font-src 'self'",
`connect-src 'self' ${storageOrigin}`,
"font-src 'self' https://cdn.jsdelivr.net data:",
`connect-src 'self' ${storageOrigin} https://cdn.jsdelivr.net`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -15,7 +15,7 @@
"dependencies": {
"@ai-sdk/anthropic": "^3.0.86",
"@ai-sdk/openai": "^3.0.74",
"@asteasolutions/zod-to-openapi": "^8.5.0",
"@asteasolutions/zod-to-openapi": "^7.3.4",
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1075.0",
"@base-ui/react": "^1.6.0",
@@ -46,6 +46,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"undici": "^7.28.0",
"web-push": "^3.6.7",
"zod": "^3.25.76"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.5.0",
"version": "0.5.1",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
@@ -0,0 +1,16 @@
CREATE TABLE "user_notification_prefs" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"follow" boolean DEFAULT true NOT NULL,
"comment" boolean DEFAULT true NOT NULL,
"reply" boolean DEFAULT true NOT NULL,
"reaction" boolean DEFAULT true NOT NULL,
"rating" boolean DEFAULT true NOT NULL,
"mention" boolean DEFAULT true NOT NULL,
"leftover_expiring" boolean DEFAULT true NOT NULL,
"shopping_list" boolean DEFAULT true NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "user_notification_prefs_user_id_unique" UNIQUE("user_id")
);
--> statement-breakpoint
ALTER TABLE "user_notification_prefs" ADD CONSTRAINT "user_notification_prefs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
@@ -239,6 +239,13 @@
"when": 1783864805460,
"tag": "0033_graceful_jetstream",
"breakpoints": true
},
{
"idx": 34,
"version": "7",
"when": 1783875228288,
"tag": "0034_dapper_nocturne",
"breakpoints": true
}
]
}
+18
View File
@@ -145,10 +145,28 @@ export const userNutritionGoals = pgTable("user_nutrition_goals", {
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const userNotificationPrefs = pgTable("user_notification_prefs", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }).unique(),
follow: boolean("follow").notNull().default(true),
comment: boolean("comment").notNull().default(true),
reply: boolean("reply").notNull().default(true),
reaction: boolean("reaction").notNull().default(true),
rating: boolean("rating").notNull().default(true),
mention: boolean("mention").notNull().default(true),
leftoverExpiring: boolean("leftover_expiring").notNull().default(true),
shoppingList: boolean("shopping_list").notNull().default(true),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const pushSubscriptionsRelations = relations(pushSubscriptions, ({ one }) => ({
user: one(users, { fields: [pushSubscriptions.userId], references: [users.id] }),
}));
export const userNotificationPrefsRelations = relations(userNotificationPrefs, ({ one }) => ({
user: one(users, { fields: [userNotificationPrefs.userId], references: [users.id] }),
}));
export const userNutritionGoalsRelations = relations(userNutritionGoals, ({ one }) => ({
user: one(users, { fields: [userNutritionGoals.userId], references: [users.id] }),
}));
+29 -26
View File
@@ -21,8 +21,8 @@ importers:
specifier: ^3.0.74
version: 3.0.74(zod@3.25.76)
'@asteasolutions/zod-to-openapi':
specifier: ^8.5.0
version: 8.5.0(zod@3.25.76)
specifier: ^7.3.4
version: 7.3.4(zod@3.25.76)
'@aws-sdk/client-s3':
specifier: ^3.1075.0
version: 3.1075.0
@@ -113,6 +113,9 @@ importers:
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
undici:
specifier: ^7.28.0
version: 7.28.0
web-push:
specifier: ^3.6.7
version: 3.6.7
@@ -243,10 +246,10 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@asteasolutions/zod-to-openapi@8.5.0':
resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==}
'@asteasolutions/zod-to-openapi@7.3.4':
resolution: {integrity: sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA==}
peerDependencies:
zod: ^4.0.0
zod: ^3.20.2
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
@@ -5502,7 +5505,7 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@asteasolutions/zod-to-openapi@8.5.0(zod@3.25.76)':
'@asteasolutions/zod-to-openapi@7.3.4(zod@3.25.76)':
dependencies:
openapi3-ts: 4.6.0
zod: 3.25.76
@@ -5958,7 +5961,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
'@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)':
dependencies:
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -5972,38 +5975,38 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))':
'@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))':
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
optionalDependencies:
drizzle-orm: 0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9)
'@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
'@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)':
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
optionalDependencies:
kysely: 0.29.2
'@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
'@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
'@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
'@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)':
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
'@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)':
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
@@ -7628,13 +7631,13 @@ snapshots:
better-auth@1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.9):
dependencies:
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))
'@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
'@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
'@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
'@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
'@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
'@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))
'@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)
'@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
'@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
'@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)
'@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@3.25.76))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)
'@better-auth/utils': 0.4.2
'@better-fetch/fetch': 1.3.1
'@noble/ciphers': 2.2.0