From 3042d289a0945a1ba59ca6bc6ce80ed085ce4630 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Tue, 14 Jul 2026 15:05:05 +0200 Subject: [PATCH] security: fix full audit findings (v0.32.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full list of the audit's confirmed findings and their fixes: - Stored XSS via unescaped JSON-LD on the public recipe page (app/r/[id]/page.tsx) — escape < before injecting. - CSP allowed unsafe-eval in production — now dev-only (Next prod never eval()s; only its HMR does). - avatarUrl accepted any URL with no ownership check — now takes an avatarKey issued by avatar-presign, validated server-side, same pattern as recipe/review photos. - No session revocation on password change/reset — both now revoke other sessions (revokeOtherSessions: true, revokeSessionsOnPasswordReset). - Rate-limit bypass via spoofable X-Forwarded-For — take the last (proxy-appended) hop instead of the first (client-supplied) one, matching the single-Traefik-hop topology. - Webhook signing secrets stored plaintext — now AES-256-GCM encrypted like every other secret in this app, with a legacy- plaintext fallback for pre-existing rows (bare hex has no ":", our ciphertext format always does). - Better Auth's own rate limiter defaulted to in-memory storage, ineffective across replicas — now backed by the same Redis as lib/rate-limit.ts (secondaryStorage), with storeSessionInDatabase explicit so session storage itself doesn't move as a side effect. - Presigned upload URLs didn't bind the declared file size to the actual upload, letting a client under-declare size (and quota charge) then PUT an arbitrarily large object — switched to S3 presigned POST with a signed content-length-range condition, enforced by the storage server itself. - generateMetadata() on the recipe page skipped the visibility filter the page body uses, leaking a private recipe's title via to any signed-in user with the id. - Block/unblock had no rate limit, unlike follow/unfollow. - AI quota was charged even when a user's own BYOK key was used (their own credentials/billing) — added an isByok flag through the config-resolution chain and skip the charge when set. Also wired BYOK into generate/generate-from-idea/translate/import-url, which never looked it up at all before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --- CHANGELOG.md | 14 + apps/web/app/(app)/recipes/[id]/page.tsx | 9 +- apps/web/app/api/v1/ai/adapt/[id]/route.ts | 2 +- .../api/v1/ai/batch-cook/generate/route.ts | 2 +- apps/web/app/api/v1/ai/cooking-chat/route.ts | 5 +- apps/web/app/api/v1/ai/drinks/[id]/route.ts | 2 +- .../app/api/v1/ai/generate-from-idea/route.ts | 14 +- apps/web/app/api/v1/ai/generate/route.ts | 14 +- apps/web/app/api/v1/ai/import-photo/route.ts | 3 +- apps/web/app/api/v1/ai/import-url/route.ts | 14 +- .../app/api/v1/ai/meal-plan/generate/route.ts | 2 +- apps/web/app/api/v1/ai/pairings/[id]/route.ts | 2 +- apps/web/app/api/v1/ai/recipe-chat/route.ts | 5 +- apps/web/app/api/v1/ai/recipe-ideas/route.ts | 5 +- apps/web/app/api/v1/ai/scale/route.ts | 2 +- apps/web/app/api/v1/ai/substitute/route.ts | 6 +- .../web/app/api/v1/ai/translate/[id]/route.ts | 13 +- .../app/api/v1/ai/variations/[id]/route.ts | 2 +- .../app/api/v1/upload/avatar-presign/route.ts | 6 +- apps/web/app/api/v1/upload/presign/route.ts | 6 +- .../api/v1/users/[username]/block/route.ts | 9 + apps/web/app/api/v1/users/me/route.ts | 21 +- apps/web/app/api/v1/webhooks/route.ts | 3 +- apps/web/app/r/[id]/page.tsx | 2 +- apps/web/components/recipe/photo-uploader.tsx | 6 +- .../components/settings/avatar-uploader.tsx | 18 +- .../web/components/settings/security-form.tsx | 2 +- .../components/social/cooked-it-review.tsx | 9 +- apps/web/lib/ai/ai-error.ts | 23 +- apps/web/lib/ai/factory.ts | 3 + apps/web/lib/ai/resolve-user-key.ts | 4 +- apps/web/lib/auth/server.ts | 31 + apps/web/lib/changelog.ts | 18 +- apps/web/lib/openapi.ts | 14 +- apps/web/lib/storage.ts | 37 +- apps/web/lib/upload-client.ts | 15 + apps/web/lib/webhooks.ts | 12 +- apps/web/next.config.ts | 9 +- apps/web/package.json | 6 +- apps/web/proxy.ts | 14 +- package.json | 2 +- pnpm-lock.yaml | 539 +++++++----------- 42 files changed, 508 insertions(+), 417 deletions(-) create mode 100644 apps/web/lib/upload-client.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d5a78..54efd8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 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.32.0 — 2026-07-14 17:10 + +### Security +- Fixed a stored-XSS hole in public recipe pages' embedded structured data, and tightened the production Content-Security-Policy so script injection like it can't execute even if a similar bug slips in again. +- Avatar photos now require proof you actually own the upload — previously any URL was accepted, including another user's uploaded photo key. +- Changing your password now signs out every other active session, and so does resetting a forgotten password. +- Fixed a rate-limit bypass on public share-link throttling caused by trusting a spoofable header. +- Webhook signing secrets are now encrypted at rest, matching how API keys and other secrets are already stored. +- Login/2FA/password-reset rate limiting now shares Redis instead of quietly resetting per server instance. +- Photo/avatar uploads are now size-capped by the storage server itself, not just a number the client could lie about. +- A private recipe's title could leak via its page's browser-tab title to any signed-in user who had the link — fixed to respect the same visibility rules as the page itself. +- Blocking/unblocking a user is now rate-limited, matching follow/unfollow. +- AI calls made with your own API key (Settings → AI Keys) no longer count against your monthly AI-call limit. + ## 0.31.0 — 2026-07-14 16:35 ### Added diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index efc8af0..35585be 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -45,7 +45,14 @@ type Params = { params: Promise<{ id: string }> }; export async function generateMetadata({ params }: Params): Promise<Metadata> { const { id } = await params; - const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) }); + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return { title: "Recipe" }; + const recipe = await db.query.recipes.findFirst({ + where: and( + eq(recipes.id, id), + or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])) + ), + }); return { title: recipe?.title ?? "Recipe" }; } diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 26c68c0..382c784 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -65,7 +65,7 @@ export async function POST(req: NextRequest, { params }: Params) { }, { ...aiConfig, userContext: privateBio ?? undefined }, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const adapted = result.data; diff --git a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts index aa9064d..1ba0b4f 100644 --- a/apps/web/app/api/v1/ai/batch-cook/generate/route.ts +++ b/apps/web/app/api/v1/ai/batch-cook/generate/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { }, { ...config, userContext: privateBio ?? undefined }, locale - ) + ), { skipQuota: config.isByok } ); if (!result.ok) return result.response; const plan = result.data; diff --git a/apps/web/app/api/v1/ai/cooking-chat/route.ts b/apps/web/app/api/v1/ai/cooking-chat/route.ts index b0fe7f8..950db7c 100644 --- a/apps/web/app/api/v1/ai/cooking-chat/route.ts +++ b/apps/web/app/api/v1/ai/cooking-chat/route.ts @@ -33,7 +33,8 @@ export async function POST(req: NextRequest) { getUserPrivateBio(session!.user.id), ]); if (!configResult.ok) return configResult.response; - const model = resolveModel(configResult.data); + const aiConfig = configResult.data; + const model = resolveModel(aiConfig); const bioContext = buildUserBioContext(privateBio); const locale = (session!.user as { locale?: string }).locale ?? "en"; const lang = LANG[locale] ?? "English"; @@ -43,7 +44,7 @@ export async function POST(req: NextRequest) { model, system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.${bioContext}`, prompt: parsed.data.question, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/drinks/[id]/route.ts b/apps/web/app/api/v1/ai/drinks/[id]/route.ts index 894a6ac..cda4b78 100644 --- a/apps/web/app/api/v1/ai/drinks/[id]/route.ts +++ b/apps/web/app/api/v1/ai/drinks/[id]/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest, { params }: Params) { parsed.data.count, { ...aiConfig, userContext: privateBio ?? undefined }, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts index d208e8d..e74491b 100644 --- a/apps/web/app/api/v1/ai/generate-from-idea/route.ts +++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { generateRecipe } from "@/lib/ai/features/generate-recipe"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db"; import { parseQuantity } from "@/lib/parse-quantity"; @@ -32,13 +33,18 @@ export async function POST(req: NextRequest) { const privateBio = await getUserPrivateBio(session!.user.id); const locale = (session!.user as { locale?: string }).locale ?? "en"; + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => generateRecipe(parsed.data.title, { - provider: parsed.data.provider, - model: parsed.data.model, + ...aiConfig, userContext: privateBio ?? undefined, language: LANG[locale] ?? "English", - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const recipe = result.data; diff --git a/apps/web/app/api/v1/ai/generate/route.ts b/apps/web/app/api/v1/ai/generate/route.ts index 5dd2661..1ff9f30 100644 --- a/apps/web/app/api/v1/ai/generate/route.ts +++ b/apps/web/app/api/v1/ai/generate/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { generateRecipe } from "@/lib/ai/features/generate-recipe"; import { getUserPrivateBio } from "@/lib/ai/user-bio"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; const Schema = z.object({ prompt: z.string().min(3).max(500), @@ -29,14 +30,19 @@ export async function POST(req: NextRequest) { const privateBio = await getUserPrivateBio(session!.user.id); + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => generateRecipe(parsed.data.prompt, { - provider: parsed.data.provider, - model: parsed.data.model, + ...aiConfig, language: parsed.data.language, difficulty: parsed.data.difficulty, userContext: privateBio ?? undefined, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index b37e39d..3cac04a 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -39,7 +39,8 @@ export async function POST(req: NextRequest) { } const result = await withAiQuota(userId, session!.user.tier as "free" | "pro", () => - importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale) + importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale), + { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const recipe = result.data; diff --git a/apps/web/app/api/v1/ai/import-url/route.ts b/apps/web/app/api/v1/ai/import-url/route.ts index 029b5f7..f2f14bb 100644 --- a/apps/web/app/api/v1/ai/import-url/route.ts +++ b/apps/web/app/api/v1/ai/import-url/route.ts @@ -2,9 +2,10 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { applyRateLimit } from "@/lib/rate-limit"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { importFromUrl } from "@/lib/ai/features/import-url"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; const Schema = z.object({ url: z.string().url(), @@ -30,11 +31,14 @@ export async function POST(req: NextRequest) { const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60); if (limited) return limited; + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => - importFromUrl(parsed.data.url, { - provider: parsed.data.provider, - model: parsed.data.model, - }) + importFromUrl(parsed.data.url, aiConfig), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index b0535ea..4577b61 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -88,7 +88,7 @@ export async function POST(req: NextRequest) { }, { ...config, userContext: privateBio ?? undefined }, locale - ) + ), { skipQuota: config.isByok } ); if (!result.ok) return result.response; const plan = result.data; diff --git a/apps/web/app/api/v1/ai/pairings/[id]/route.ts b/apps/web/app/api/v1/ai/pairings/[id]/route.ts index 3f2d061..1d9e368 100644 --- a/apps/web/app/api/v1/ai/pairings/[id]/route.ts +++ b/apps/web/app/api/v1/ai/pairings/[id]/route.ts @@ -56,7 +56,7 @@ export async function POST(req: NextRequest, { params }: Params) { parsed.data.count, { ...aiConfig, userContext: privateBio ?? undefined }, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/recipe-chat/route.ts b/apps/web/app/api/v1/ai/recipe-chat/route.ts index 140aec3..810b1c4 100644 --- a/apps/web/app/api/v1/ai/recipe-chat/route.ts +++ b/apps/web/app/api/v1/ai/recipe-chat/route.ts @@ -68,7 +68,8 @@ ${stepList || "None listed"} getUserPrivateBio(session!.user.id), ]); if (!configResult.ok) return configResult.response; - const model = resolveModel(configResult.data); + const aiConfig = configResult.data; + const model = resolveModel(aiConfig); const bioContext = buildUserBioContext(privateBio); const locale = (session!.user as { locale?: string }).locale ?? "en"; const lang = LANG[locale] ?? "English"; @@ -80,7 +81,7 @@ ${stepList || "None listed"} ${recipeContext}${bioContext}`, prompt: parsed.data.question, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/recipe-ideas/route.ts b/apps/web/app/api/v1/ai/recipe-ideas/route.ts index 8819957..c770e73 100644 --- a/apps/web/app/api/v1/ai/recipe-ideas/route.ts +++ b/apps/web/app/api/v1/ai/recipe-ideas/route.ts @@ -42,7 +42,8 @@ export async function POST(req: NextRequest) { getUserPrivateBio(session!.user.id), ]); if (!configResult.ok) return configResult.response; - const model = resolveModel(configResult.data); + const aiConfig = configResult.data; + const model = resolveModel(aiConfig); const bioContext = buildUserBioContext(privateBio); const userContext = bioContext @@ -62,7 +63,7 @@ export async function POST(req: NextRequest) { schema: IdeasSchema, system: `Respond in ${lang}.`, prompt, - }) + }), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/scale/route.ts b/apps/web/app/api/v1/ai/scale/route.ts index 2a59500..23101e3 100644 --- a/apps/web/app/api/v1/ai/scale/route.ts +++ b/apps/web/app/api/v1/ai/scale/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { recipe.baseServings, aiConfig, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index efaae29..d8af73a 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -5,6 +5,7 @@ import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { applyRateLimit } from "@/lib/rate-limit"; import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key"; import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient"; +import type { AiConfig } from "@/lib/ai/factory"; const Schema = z.object({ ingredient: z.string().min(1).max(200), @@ -28,7 +29,7 @@ export async function POST(req: NextRequest) { ? `recipe "${parsed.data.recipeTitle}"` : "a general recipe"; - let aiConfig; + let aiConfig: AiConfig; if (parsed.data.provider) { aiConfig = { provider: parsed.data.provider, model: parsed.data.model }; } else { @@ -40,7 +41,8 @@ export async function POST(req: NextRequest) { const locale = (session!.user as { locale?: string }).locale ?? "en"; const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => - substituteIngredient(parsed.data.ingredient, context, aiConfig, locale) + substituteIngredient(parsed.data.ingredient, context, aiConfig, locale), + { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/ai/translate/[id]/route.ts b/apps/web/app/api/v1/ai/translate/[id]/route.ts index 64ee870..a3dc367 100644 --- a/apps/web/app/api/v1/ai/translate/[id]/route.ts +++ b/apps/web/app/api/v1/ai/translate/[id]/route.ts @@ -3,8 +3,9 @@ import { z } from "zod"; import { and, eq } from "@epicure/db"; import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; -import { withAiQuota } from "@/lib/ai/ai-error"; +import { withAiQuota, resolveAiConfigOrError } from "@/lib/ai/ai-error"; import { translateRecipe } from "@/lib/ai/features/translate-recipe"; +import { withUserKey } from "@/lib/ai/resolve-user-key"; const Schema = z.object({ targetLanguage: z.string().min(2).max(50), @@ -35,6 +36,12 @@ export async function POST(req: NextRequest, { params }: Params) { return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 }); } + const configResult = await resolveAiConfigOrError(() => + withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }) + ); + if (!configResult.ok) return configResult.response; + const aiConfig = configResult.data; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro", () => translateRecipe( { @@ -44,8 +51,8 @@ export async function POST(req: NextRequest, { params }: Params) { steps: recipe.steps, }, parsed.data.targetLanguage, - { provider: parsed.data.provider, model: parsed.data.model } - ) + aiConfig + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; const translation = result.data; diff --git a/apps/web/app/api/v1/ai/variations/[id]/route.ts b/apps/web/app/api/v1/ai/variations/[id]/route.ts index 88a54f7..d7f990e 100644 --- a/apps/web/app/api/v1/ai/variations/[id]/route.ts +++ b/apps/web/app/api/v1/ai/variations/[id]/route.ts @@ -59,7 +59,7 @@ export async function POST(req: NextRequest, { params }: Params) { { ...aiConfig, userContext: privateBio ?? undefined }, parsed.data.directions, (session!.user as { locale?: string }).locale ?? "en" - ) + ), { skipQuota: aiConfig.isByok } ); if (!result.ok) return result.response; diff --git a/apps/web/app/api/v1/upload/avatar-presign/route.ts b/apps/web/app/api/v1/upload/avatar-presign/route.ts index 575f4f9..da48afd 100644 --- a/apps/web/app/api/v1/upload/avatar-presign/route.ts +++ b/apps/web/app/api/v1/upload/avatar-presign/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; -import { createPresignedUploadUrl } from "@/lib/storage"; +import { createPresignedUploadPost } from "@/lib/storage"; import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const; @@ -39,7 +39,7 @@ export async function POST(req: NextRequest) { const ext = contentType.split("/")[1] ?? "jpg"; const key = `user-avatars/${session!.user.id}/${crypto.randomUUID()}.${ext}`; - const url = await createPresignedUploadUrl(key, contentType); + const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE); - return NextResponse.json({ url, key }); + return NextResponse.json({ url, fields, key }); } diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts index c760544..e321423 100644 --- a/apps/web/app/api/v1/upload/presign/route.ts +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/api-auth"; -import { createPresignedUploadUrl } from "@/lib/storage"; +import { createPresignedUploadPost } from "@/lib/storage"; import { db, recipes, eq, and } from "@epicure/db"; import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers"; @@ -53,7 +53,7 @@ export async function POST(req: NextRequest) { const ext = contentType.split("/")[1] ?? "jpg"; const folder = purpose === "review" ? "reviews" : "photos"; const key = `recipes/${recipeId}/${folder}/${session!.user.id}-${crypto.randomUUID()}.${ext}`; - const url = await createPresignedUploadUrl(key, contentType); + const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE); - return NextResponse.json({ url, key }); + return NextResponse.json({ url, fields, key }); } diff --git a/apps/web/app/api/v1/users/[username]/block/route.ts b/apps/web/app/api/v1/users/[username]/block/route.ts index 63bd0be..63dde2b 100644 --- a/apps/web/app/api/v1/users/[username]/block/route.ts +++ b/apps/web/app/api/v1/users/[username]/block/route.ts @@ -1,12 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { db, users, userBlocks, userFollows, eq, and, or } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; +import { applyRateLimit } from "@/lib/rate-limit"; type Params = { params: Promise<{ username: string }> }; export async function POST(_req: NextRequest, { params }: Params) { const { session, response } = await requireSession(); if (response) return response; + + const limited = await applyRateLimit(`rl:block:${session!.user.id}`, 30, 60); + if (limited) return limited; + const { username } = await params; const target = await db.query.users.findFirst({ where: eq(users.username, username) }); @@ -31,6 +36,10 @@ export async function POST(_req: NextRequest, { params }: Params) { export async function DELETE(_req: NextRequest, { params }: Params) { const { session, response } = await requireSession(); if (response) return response; + + const limited = await applyRateLimit(`rl:block:${session!.user.id}`, 30, 60); + if (limited) return limited; + const { username } = await params; const target = await db.query.users.findFirst({ where: eq(users.username, username) }); diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts index 6e6a3a0..8e9c2e1 100644 --- a/apps/web/app/api/v1/users/me/route.ts +++ b/apps/web/app/api/v1/users/me/route.ts @@ -5,6 +5,7 @@ import { db, users, eq } from "@epicure/db"; import { z } from "zod"; import { gravatarUrl } from "@/lib/gravatar"; import { USERNAME_PATTERN, isUsernameTaken } from "@/lib/username"; +import { isOwnedAvatarKey, getPublicUrl } from "@/lib/storage"; const PatchSchema = z.object({ name: z.string().min(1).max(100).optional(), @@ -13,9 +14,12 @@ const PatchSchema = z.object({ privateBio: z.string().max(2000).optional().nullable(), isPrivate: z.boolean().optional(), username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(), - // A custom-uploaded avatar URL, or null to revert to the initials fallback - // (or Gravatar, if useGravatar is on — see below). - avatarUrl: z.string().url().max(2048).optional().nullable(), + // The storage key returned by /api/v1/upload/avatar-presign (not a URL) — + // the server validates it was actually issued to this user and builds the + // public URL itself, so a client can't point avatarUrl at an arbitrary or + // another user's object. `null` reverts to the initials fallback (or + // Gravatar, if useGravatar is on — see below). + avatarKey: z.string().max(500).optional().nullable(), // Off by default (Settings → Profile) — Gravatar is looked up by an MD5 // hash of the user's email, sent to a third party. useGravatar: z.boolean().optional(), @@ -32,7 +36,7 @@ export async function PATCH(req: Request) { return NextResponse.json({ error: "Username already taken" }, { status: 409 }); } - const { avatarUrl, useGravatar, ...rest } = body.data; + const { avatarKey, useGravatar, ...rest } = body.data; const updates: Partial<typeof users.$inferInsert> = { ...rest }; // useGravatar is only ever toggled from the settings form, never alongside @@ -45,13 +49,16 @@ export async function PATCH(req: Request) { } } - if (avatarUrl !== undefined) { - if (avatarUrl === null) { + if (avatarKey !== undefined) { + if (avatarKey === null) { const gravatarOptedIn = useGravatar ?? (await getUseGravatar(session.user.id)); updates.avatarUrl = gravatarOptedIn ? gravatarUrl(session.user.email) : null; updates.hasCustomAvatar = false; } else { - updates.avatarUrl = avatarUrl; + if (!isOwnedAvatarKey(avatarKey, session.user.id)) { + return NextResponse.json({ error: "Invalid avatar key" }, { status: 400 }); + } + updates.avatarUrl = getPublicUrl(avatarKey); updates.hasCustomAvatar = true; } } diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index 397fbdd..f653255 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -5,6 +5,7 @@ import { db, webhooks, eq } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { validateWebhookUrl } from "@/lib/validate-webhook-url"; import { WEBHOOK_EVENTS } from "@/lib/webhooks"; +import { encrypt } from "@/lib/encrypt"; const CreateWebhookBody = z.object({ url: z.string().min(1).max(2048), @@ -64,7 +65,7 @@ export async function POST(req: NextRequest) { userId: session!.user.id, url: parsed.data.url, events: parsed.data.events, - secret, + secret: encrypt(secret), active: true, createdAt: now, }); diff --git a/apps/web/app/r/[id]/page.tsx b/apps/web/app/r/[id]/page.tsx index 06248b4..6f8fbf4 100644 --- a/apps/web/app/r/[id]/page.tsx +++ b/apps/web/app/r/[id]/page.tsx @@ -82,7 +82,7 @@ export default async function PublicRecipePage({ params }: Params) { return ( <> - <Script id="recipe-jsonld" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> + <Script id="recipe-jsonld" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, "\\u003c") }} /> <div className="container mx-auto max-w-3xl px-4 py-8 space-y-8"> {/* Breadcrumb-style nav */} <div className="flex items-center gap-2 text-sm text-muted-foreground"> diff --git a/apps/web/components/recipe/photo-uploader.tsx b/apps/web/components/recipe/photo-uploader.tsx index bbe0fc7..311105b 100644 --- a/apps/web/components/recipe/photo-uploader.tsx +++ b/apps/web/components/recipe/photo-uploader.tsx @@ -6,6 +6,7 @@ import { useTranslations } from "next-intl"; import { Upload, X, Star } from "lucide-react"; import { Button } from "@/components/ui/button"; import { getPublicUrl } from "@/lib/storage"; +import { uploadToPresignedPost } from "@/lib/upload-client"; export type PhotoEntry = { key: string; @@ -41,8 +42,9 @@ export function PhotoUploader({ body: JSON.stringify({ recipeId, contentType: file.type, fileSize: file.size }), }); if (!res.ok) continue; - const { url, key } = await res.json() as { url: string; key: string }; - await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); + const { url, fields, key } = await res.json() as { url: string; fields: Record<string, string>; key: string }; + const uploaded = await uploadToPresignedPost(url, fields, file); + if (!uploaded) continue; const isFirst = next.length === 0; next = [...next, { key, isCover: isFirst, preview: URL.createObjectURL(file) }]; onChange(next); diff --git a/apps/web/components/settings/avatar-uploader.tsx b/apps/web/components/settings/avatar-uploader.tsx index 22706cf..9587c80 100644 --- a/apps/web/components/settings/avatar-uploader.tsx +++ b/apps/web/components/settings/avatar-uploader.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { Camera, Loader2, X } from "lucide-react"; import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; +import { uploadToPresignedPost } from "@/lib/upload-client"; export function AvatarUploader({ name, @@ -37,22 +38,27 @@ export function AvatarUploader({ toast.error(err.error ?? t("avatarUploadFailed")); return; } - const { url } = await presignRes.json() as { url: string; key: string }; - await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); + const { url, fields, key } = await presignRes.json() as { url: string; fields: Record<string, string>; key: string }; + const uploaded = await uploadToPresignedPost(url, fields, file); + if (!uploaded) { + toast.error(t("avatarUploadFailed")); + return; + } - // The presigned PUT URL is already publicUrl/bucket/key with query-string - // signing params appended — strip those to get the plain public GET URL. + // Send back the storage key we were issued, not a client-derived URL — + // the server validates the key belongs to us and builds the public URL + // itself, so a client can't set avatarUrl to an arbitrary/unowned target. const saveRes = await fetch("/api/v1/users/me", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ avatarUrl: url.split("?")[0] }), + body: JSON.stringify({ avatarKey: key }), }); if (!saveRes.ok) { toast.error(t("avatarUploadFailed")); return; } const saved = await saveRes.json() as { avatarUrl?: string }; - onChange(saved.avatarUrl ?? url.split("?")[0]!, true); + onChange(saved.avatarUrl ?? null, true); toast.success(t("avatarUploadSuccess")); } finally { setUploading(false); diff --git a/apps/web/components/settings/security-form.tsx b/apps/web/components/settings/security-form.tsx index 9e21c59..5981684 100644 --- a/apps/web/components/settings/security-form.tsx +++ b/apps/web/components/settings/security-form.tsx @@ -59,7 +59,7 @@ export function SecurityForm({ const { error } = await authClient.changePassword({ currentPassword, newPassword, - revokeOtherSessions: false, + revokeOtherSessions: true, }); if (error) { toast.error(error.message ?? t("passwordChangeFailed")); diff --git a/apps/web/components/social/cooked-it-review.tsx b/apps/web/components/social/cooked-it-review.tsx index 4ade701..f32966e 100644 --- a/apps/web/components/social/cooked-it-review.tsx +++ b/apps/web/components/social/cooked-it-review.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getPublicUrl } from "@/lib/storage"; +import { uploadToPresignedPost } from "@/lib/upload-client"; import { cn } from "@/lib/utils"; type Review = { @@ -93,8 +94,12 @@ export function CookedItReview({ toast.error(t("reviewPhotoFailed")); return; } - const { url, key } = (await res.json()) as { url: string; key: string }; - await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); + const { url, fields, key } = (await res.json()) as { url: string; fields: Record<string, string>; key: string }; + const uploaded = await uploadToPresignedPost(url, fields, file); + if (!uploaded) { + toast.error(t("reviewPhotoFailed")); + return; + } setPhotoKey(key); setPreview(URL.createObjectURL(file)); } finally { diff --git a/apps/web/lib/ai/ai-error.ts b/apps/web/lib/ai/ai-error.ts index 743356c..1456ec3 100644 --- a/apps/web/lib/ai/ai-error.ts +++ b/apps/web/lib/ai/ai-error.ts @@ -47,26 +47,33 @@ type QuotaResult<T> = { ok: true; data: T } | { ok: false; response: NextRespons * throws — so a provider outage never silently burns a user's quota. * Centralizes the TierLimitError → 403 and AI-failure → clean-JSON mapping * that every AI route needs instead of repeating try/catch per route. + * + * Pass `skipQuota: true` when the resolved AiConfig used the caller's own + * BYOK key (config.isByok) — it's their own credentials/billing, so it + * shouldn't count against the platform's monthly AI-call limit. */ export async function withAiQuota<T>( userId: string, tier: "free" | "pro", - fn: () => Promise<T> + fn: () => Promise<T>, + opts?: { skipQuota?: boolean } ): Promise<QuotaResult<T>> { - try { - await checkAndIncrementTierLimit(userId, tier, "aiCall"); - } catch (err) { - if (err instanceof TierLimitError) { - return { ok: false, response: NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 }) }; + if (!opts?.skipQuota) { + try { + await checkAndIncrementTierLimit(userId, tier, "aiCall"); + } catch (err) { + if (err instanceof TierLimitError) { + return { ok: false, response: NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 }) }; + } + throw err; } - throw err; } try { const data = await fn(); return { ok: true, data }; } catch (err) { - await refundAiCall(userId); + if (!opts?.skipQuota) await refundAiCall(userId); return { ok: false, response: aiErrorResponse(err) }; } } diff --git a/apps/web/lib/ai/factory.ts b/apps/web/lib/ai/factory.ts index dfc541f..662e903 100644 --- a/apps/web/lib/ai/factory.ts +++ b/apps/web/lib/ai/factory.ts @@ -8,6 +8,9 @@ export type AiConfig = { provider?: AiProvider; model?: string; apiKey?: string; + /** True when apiKey is the caller's own BYOK key — their own credentials + * and billing, so platform AI-call quota shouldn't be charged for it. */ + isByok?: boolean; }; export function resolveModel(config: AiConfig = {}): LanguageModel { diff --git a/apps/web/lib/ai/resolve-user-key.ts b/apps/web/lib/ai/resolve-user-key.ts index 30bc48d..63db0e1 100644 --- a/apps/web/lib/ai/resolve-user-key.ts +++ b/apps/web/lib/ai/resolve-user-key.ts @@ -44,7 +44,7 @@ export async function withUserKey(userId: string, config: AiConfig): Promise<AiC try { const apiKey = decrypt(row.encryptedKey); - return { ...config, apiKey }; + return { ...config, apiKey, isByok: true }; } catch { throw new ByokDecryptError(provider); } @@ -81,7 +81,7 @@ export async function getDefaultProviderWithKey(userId: string, useCase?: ModelU const row = keys.find((k) => k.provider === p); if (row) { try { - return { provider: p, apiKey: decrypt(row.encryptedKey) }; + return { provider: p, apiKey: decrypt(row.encryptedKey), isByok: true }; } catch { throw new ByokDecryptError(p); } diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts index 68bfe5b..9e9192c 100644 --- a/apps/web/lib/auth/server.ts +++ b/apps/web/lib/auth/server.ts @@ -6,10 +6,32 @@ import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/li import { isSignupsDisabled } from "@/lib/site-settings"; import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites"; import { generateUniqueUsername } from "@/lib/username"; +import { getRedis } from "@/lib/redis"; + +// Without this, better-auth's own rate limiter (sign-in/sign-up/2FA/password +// reset throttles below) defaults to an in-process Map — fine for a single +// instance, but each replica gets its own independent counter the moment +// this app is ever scaled horizontally, multiplying the effective limit by +// the instance count. Backing it with the same Redis used elsewhere +// (lib/rate-limit.ts) makes the limit actually shared and enforceable. +const redisSecondaryStorage = { + async get(key: string) { + return getRedis().get(key); + }, + async set(key: string, value: string, ttl?: number) { + if (ttl) await getRedis().set(key, value, "EX", ttl); + else await getRedis().set(key, value); + }, + async delete(key: string) { + await getRedis().del(key); + }, +}; export const auth = betterAuth({ trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"], + secondaryStorage: redisSecondaryStorage, + // Explicit rather than relying on the isProduction default so dev/staging // are protected too. Sign-in/sign-up/change-password/change-email get a // strict built-in 3-req/10s-per-IP rule (better-auth's default special @@ -34,6 +56,9 @@ export const auth = betterAuth({ emailAndPassword: { enabled: true, requireEmailVerification: true, + // A stolen session should not survive the account owner noticing and + // resetting their password. + revokeSessionsOnPasswordReset: true, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, @@ -100,6 +125,12 @@ export const auth = betterAuth({ enabled: true, maxAge: 60 * 5, }, + // secondaryStorage above is only meant to back the rate limiter — without + // this, better-auth would also move session storage itself into Redis + // (see internal-adapter.mjs's `storeInDb` checks), which is an unrelated, + // much bigger behavior change (session data no longer queryable from the + // `sessions` table at all) that nothing else in this app expects. + storeSessionInDatabase: true, }, databaseHooks: { diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index ffd2eb8..f505f47 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.31.0"; +export const APP_VERSION = "0.32.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,22 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.32.0", + date: "2026-07-14 17:10", + security: [ + "Fixed a stored-XSS hole in public recipe pages' embedded structured data, and tightened the production Content-Security-Policy so script injection like it can't execute even if a similar bug slips in again.", + "Avatar photos now require proof you actually own the upload — previously any URL was accepted, including another user's uploaded photo key.", + "Changing your password now signs out every other active session, and so does resetting a forgotten password.", + "Fixed a rate-limit bypass on public share-link throttling caused by trusting a spoofable header.", + "Webhook signing secrets are now encrypted at rest, matching how API keys and other secrets are already stored.", + "Login/2FA/password-reset rate limiting now shares Redis instead of quietly resetting per server instance.", + "Photo/avatar uploads are now size-capped by the storage server itself, not just a number the client could lie about.", + "A private recipe's title could leak via its page's browser-tab title to any signed-in user who had the link — fixed to respect the same visibility rules as the page itself.", + "Blocking/unblocking a user is now rate-limited, matching follow/unfollow.", + "AI calls made with your own API key (Settings → AI Keys) no longer count against your monthly AI-call limit.", + ], + }, { version: "0.31.0", date: "2026-07-14 16:35", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index 2b202dc..f838707 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -395,7 +395,7 @@ export function generateOpenApiSpec(): object { name: z.string().min(1).max(100).optional(), locale: z.string().max(10).optional(), bio: z.string().max(500).nullable().optional(), privateBio: z.string().max(2000).nullable().optional(), isPrivate: z.boolean().optional(), username: z.string().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(), - avatarUrl: z.string().url().max(2048).nullable().optional(), useGravatar: z.boolean().optional(), + avatarKey: z.string().max(500).nullable().optional().describe("Storage key returned by POST /upload/avatar-presign, not a URL — validated server-side against the caller's own uploads."), useGravatar: z.boolean().optional(), })); const ModelPrefsRef = registry.register("ModelPrefs", z.object({ id: z.string(), userId: z.string(), @@ -450,7 +450,7 @@ export function generateOpenApiSpec(): object { recipeCount: z.number().int(), isFollowing: z.boolean(), })); - registry.registerPath({ method: "patch", path: "/api/v1/users/me", summary: "Update your profile", description: "Toggling useGravatar or clearing avatarUrl re-derives the effective avatar (Gravatar or initials fallback).", security, request: { body: { content: { "application/json": { schema: UpdateMeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), avatarUrl: z.string().nullable() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Username already taken", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "patch", path: "/api/v1/users/me", summary: "Update your profile", description: "Toggling useGravatar or clearing avatarKey re-derives the effective avatar (Gravatar or initials fallback).", security, request: { body: { content: { "application/json": { schema: UpdateMeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), avatarUrl: z.string().nullable() }) } } }, 400: { description: "Validation error, or avatarKey wasn't issued to you by avatar-presign", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "Username already taken", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/me/export", summary: "Export all of your account data as a JSON file", description: "Rate-limited: 3 req/hour. Includes profile, recipes, social graph, collections, meal plans, pantry, shopping lists, webhooks, API keys, messages, and more.", security, responses: { 200: { description: "Downloadable JSON export", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/me/model-prefs", summary: "Get your AI model provider/model preferences", security, responses: { 200: { description: "Preferences or null", content: { "application/json": { schema: ModelPrefsRef.nullable() } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/users/me/model-prefs", summary: "Set your AI model provider/model preferences", security, request: { body: { content: { "application/json": { schema: UpdateModelPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); @@ -620,10 +620,14 @@ export function generateOpenApiSpec(): object { contentType: z.enum(["image/jpeg", "image/png", "image/webp", "image/avif"]), fileSize: z.number().int().positive().max(5 * 1024 * 1024, "File exceeds 5MB limit"), })); - const PresignResponseRef = registry.register("PresignResponse", z.object({ url: z.string(), key: z.string() })); + const PresignResponseRef = registry.register("PresignResponse", z.object({ + url: z.string().describe("POST this URL as multipart/form-data — include every field below, then the file itself last."), + fields: z.record(z.string(), z.string()).describe("Form fields to include in the upload POST, including a signed content-length-range condition that the storage server enforces (the declared fileSize can't be lied about)."), + key: z.string(), + })); - registry.registerPath({ method: "post", path: "/api/v1/upload/presign", summary: "Get a presigned S3 URL to upload a recipe or review photo", description: "Recipe photos: recipeId must be your own recipe. Review photos: recipe must be visible to you. Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignUploadRef } }, required: true } }, responses: { 200: { description: "Presigned URL and object key", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); - registry.registerPath({ method: "post", path: "/api/v1/upload/avatar-presign", summary: "Get a presigned S3 URL to upload your avatar photo", description: "Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignAvatarUploadRef } }, required: true } }, responses: { 200: { description: "Presigned URL and object key", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "post", path: "/api/v1/upload/presign", summary: "Get a presigned S3 upload POST (URL + form fields) for a recipe or review photo", description: "Recipe photos: recipeId must be your own recipe. Review photos: recipe must be visible to you. Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "post", path: "/api/v1/upload/avatar-presign", summary: "Get a presigned S3 upload POST (URL + form fields) for your avatar photo", description: "Charges your tier's storage quota.", security, request: { body: { content: { "application/json": { schema: PresignAvatarUploadRef } }, required: true } }, responses: { 200: { description: "Presigned POST", content: { "application/json": { schema: PresignResponseRef } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Storage limit reached for your tier", content: { "application/json": { schema: ApiErrorRef } } } } }); // --- Push notifications --- const PushSubscribeRef = registry.register("PushSubscribe", z.object({ diff --git a/apps/web/lib/storage.ts b/apps/web/lib/storage.ts index 7c60442..ebd923c 100644 --- a/apps/web/lib/storage.ts +++ b/apps/web/lib/storage.ts @@ -1,5 +1,5 @@ -import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3"; -import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +import { S3Client, DeleteObjectCommand } from "@aws-sdk/client-s3"; +import { createPresignedPost } from "@aws-sdk/s3-presigned-post"; const bucket = process.env["STORAGE_BUCKET"] || "epicure-uploads"; const endpoint = process.env["STORAGE_ENDPOINT"] || "http://localhost:9000"; @@ -32,9 +32,31 @@ const presignS3 = new S3Client({ credentials, }); -export async function createPresignedUploadUrl(key: string, contentType: string): Promise<string> { - const command = new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: contentType }); - return getSignedUrl(presignS3, command, { expiresIn: 300 }); +/** + * A presigned PUT URL only signs Bucket/Key/ContentType — nothing binds the + * actual request body size to what the caller declared when it asked for the + * URL (and was charged storage-tier quota for), so a client could declare + * 1 byte and then PUT an arbitrarily large object. S3/MinIO's POST-policy + * upload flow is the fix: `content-length-range` is a *signed* condition, + * enforced by the storage server itself when it receives the upload — a + * mismatched Content-Length is rejected before the bytes are ever accepted, + * not just checked against something the client also controls. + */ +export async function createPresignedUploadPost( + key: string, + contentType: string, + maxBytes: number +): Promise<{ url: string; fields: Record<string, string> }> { + return createPresignedPost(presignS3, { + Bucket: bucket, + Key: key, + Conditions: [ + ["content-length-range", 0, maxBytes], + { "Content-Type": contentType }, + ], + Fields: { "Content-Type": contentType }, + Expires: 300, + }); } export async function deleteObject(key: string): Promise<void> { @@ -60,3 +82,8 @@ export function isOwnedRecipePhotoKey(key: string, recipeId: string, userId: str export function isOwnedReviewPhotoKey(key: string, recipeId: string, userId: string): boolean { return key.startsWith(`recipes/${recipeId}/reviews/${userId}-`); } + +/** Same rationale as isOwnedRecipePhotoKey, for the avatar-presign key prefix. */ +export function isOwnedAvatarKey(key: string, userId: string): boolean { + return key.startsWith(`user-avatars/${userId}/`); +} diff --git a/apps/web/lib/upload-client.ts b/apps/web/lib/upload-client.ts new file mode 100644 index 0000000..136cc13 --- /dev/null +++ b/apps/web/lib/upload-client.ts @@ -0,0 +1,15 @@ +/** Uploads a file to an S3/MinIO presigned POST URL (see lib/storage.ts's + * createPresignedUploadPost) — unlike a presigned PUT, the storage server + * itself rejects a mismatched size via the signed content-length-range + * condition, so this can't be used to upload past the declared limit. */ +export async function uploadToPresignedPost( + url: string, + fields: Record<string, string>, + file: File +): Promise<boolean> { + const formData = new FormData(); + for (const [key, value] of Object.entries(fields)) formData.append(key, value); + formData.append("file", file); + const res = await fetch(url, { method: "POST", body: formData }); + return res.ok; +} diff --git a/apps/web/lib/webhooks.ts b/apps/web/lib/webhooks.ts index 4b8097d..a0b3fad 100644 --- a/apps/web/lib/webhooks.ts +++ b/apps/web/lib/webhooks.ts @@ -3,6 +3,16 @@ import { db } from "@epicure/db"; import { webhooks, webhookDeliveries } from "@epicure/db"; import { eq, and } from "@epicure/db"; import { safeFetch } from "@/lib/validate-webhook-url"; +import { decrypt } from "@/lib/encrypt"; + +// Secrets created before encryption-at-rest was added are a bare 64-char hex +// string (crypto.randomBytes(32).toString("hex")) with no ":" separators — +// encrypt()'s output is always "iv:authTag:ciphertext". Fall back to treating +// the value as already-plaintext rather than a hard migration, since this is +// only reached with a value this app itself generated (never user input). +function decryptWebhookSecret(stored: string): string { + return stored.split(":").length === 3 ? decrypt(stored) : stored; +} export const WEBHOOK_EVENTS = [ "recipe.created", @@ -27,7 +37,7 @@ export async function dispatchWebhook(userId: string, event: WebhookEvent, paylo await Promise.allSettled( filtered.map(async (hook) => { const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() }); - const sig = crypto.createHmac("sha256", hook.secret).update(body).digest("hex"); + const sig = crypto.createHmac("sha256", decryptWebhookSecret(hook.secret)).update(body).digest("hex"); let statusCode = 0; let success = false; try { diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index c484440..5cd212a 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -4,6 +4,13 @@ const storagePublicUrl = process.env["STORAGE_PUBLIC_URL"] || "http://localhost: const storageUrl = new URL(storagePublicUrl); const storageOrigin = storageUrl.origin; +// unsafe-eval is only needed by Next.js dev's HMR/Fast Refresh — production +// builds never eval(), so drop it there instead of leaving XSS payloads a +// wide-open eval sink in the deployed app. +const scriptSrc = process.env.NODE_ENV === "production" + ? "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net" + : "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net"; + const securityHeaders = [ { key: "X-Content-Type-Options", @@ -33,7 +40,7 @@ const securityHeaders = [ key: "Content-Security-Policy", value: [ "default-src 'self'", - "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. + scriptSrc, // 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' https://cdn.jsdelivr.net data:", diff --git a/apps/web/package.json b/apps/web/package.json index abe7a77..4f5a028 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.31.0", + "version": "0.32.0", "private": true, "scripts": { "dev": "next dev", @@ -16,8 +16,8 @@ "@ai-sdk/anthropic": "^3.0.86", "@ai-sdk/openai": "^3.0.74", "@asteasolutions/zod-to-openapi": "^7.3.4", - "@aws-sdk/client-s3": "^3.1075.0", - "@aws-sdk/s3-request-presigner": "^3.1075.0", + "@aws-sdk/client-s3": "^3.1085.0", + "@aws-sdk/s3-presigned-post": "^3.1085.0", "@base-ui/react": "^1.6.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index c77e02d..7c0d1a9 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -18,9 +18,21 @@ const IP_RATE_LIMITED_PATHS = ["/r/", "/s/"]; const IP_RATE_LIMIT = 60; const IP_RATE_LIMIT_WINDOW_SECONDS = 60; +// Epicure sits behind exactly one reverse proxy (Traefik — see traefik/epicure.yml). +// Each hop *appends* the address it saw to X-Forwarded-For, so with a single +// trusted hop in front of us, the rightmost entry is the address Traefik itself +// observed — i.e. the real client — while anything to its left (including the +// entire header, on a direct request with no proxy at all) is attacker-supplied +// and must not be trusted. Taking the leftmost entry, as before, let a client +// set an arbitrary X-Forwarded-For and get a fresh rate-limit bucket on every +// request. If another reverse proxy is ever added in front of Traefik, this +// needs to pop one more entry per added hop. function getClientIp(request: NextRequest): string { const forwardedFor = request.headers.get("x-forwarded-for"); - if (forwardedFor) return forwardedFor.split(",")[0]!.trim(); + if (forwardedFor) { + const parts = forwardedFor.split(","); + return parts[parts.length - 1]!.trim(); + } return request.headers.get("x-real-ip") ?? "unknown"; } diff --git a/package.json b/package.json index 30342cf..ee3485c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.31.0", + "version": "0.32.0", "private": true, "scripts": { "dev": "pnpm --filter web dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4025c8..ac7c93d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,11 +24,11 @@ importers: specifier: ^7.3.4 version: 7.3.4(zod@3.25.76) '@aws-sdk/client-s3': - specifier: ^3.1075.0 - version: 3.1075.0 - '@aws-sdk/s3-request-presigner': - specifier: ^3.1075.0 - version: 3.1075.0 + specifier: ^3.1085.0 + version: 3.1085.0 + '@aws-sdk/s3-presigned-post': + specifier: ^3.1085.0 + version: 3.1085.0 '@base-ui/react': specifier: ^1.6.0 version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -257,111 +257,80 @@ packages: peerDependencies: zod: ^3.20.2 - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/checksums@3.1000.8': - resolution: {integrity: sha512-v0U9S7gBIme3OTgt1LdbAF4RpvavCc+4GK1+1xqAcqtbrHsEhjQo6R45LKcjhs/+WrRJij1Y0Gztw7QPAIeUfA==} + '@aws-sdk/checksums@3.1000.16': + resolution: {integrity: sha512-EKnvkXSmz3IpA99tCNuI+dLFXyZyClSm8zns9sB/elvkU+MTuomAs6toJMPMBf98/fICG/urXDkzGz0/c3yyAQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1075.0': - resolution: {integrity: sha512-h1A6nIl1YX6Y45enGsTK7ef3ZrOnBiQJ1qF5R2K/nMWfsu6A9mc2Y5T66nxerABzyjjyyvign3MrzafnFoQKmA==} + '@aws-sdk/client-s3@3.1085.0': + resolution: {integrity: sha512-O0xe8sR50AYkwxlvRRsV0qytEO2dtXQTQ1CF3YBBdE5xtVkbu27H0vGa1mjQi1/+fbYM80AWEIPai5jZmXyubw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.23': - resolution: {integrity: sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==} + '@aws-sdk/core@3.975.1': + resolution: {integrity: sha512-8qh/6EYb7hl/ZwVfQufhbMEZs1gQIc7GbdrIf4eprQJ7cv042+74nE6l3YDfyWNzb9iPXb8fRyYSHkNIk5eE6Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.49': - resolution: {integrity: sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==} + '@aws-sdk/credential-provider-env@3.972.57': + resolution: {integrity: sha512-1RfJaF7SW1TOnvNGU7kaYjwUf5H3sfm+synGH1bHhRlqcnxCt3szebH3dmKEyY4tuGcbQ6ffzUT89cRitBV8OQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.51': - resolution: {integrity: sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==} + '@aws-sdk/credential-provider-http@3.972.59': + resolution: {integrity: sha512-sRCkpTiFnCdQvuaRVjQ6SVoHu6i7RUpurVo1c4F81HWhPvUJ7Wdp5MNtSdX1O29CNXc8em3O5m52hCjVtAD9SA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.56': - resolution: {integrity: sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==} + '@aws-sdk/credential-provider-ini@3.973.1': + resolution: {integrity: sha512-6d8H6ZAh3ZPKZ6fe1nG2OWeZEZPtt9ravoD1dezPdPtsSkJRoxGAnFSHwKT3E/Te6fHE30zRzjV6TD12rvF6yQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.55': - resolution: {integrity: sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==} + '@aws-sdk/credential-provider-login@3.972.63': + resolution: {integrity: sha512-GREWRrMj0XnNKMaVa/Mauoaui26qBEHu71WWqXbwZOu/jFQOnPZjTf7u0KtGKC8VGa6VUs9kDWGgocrKNLS9vw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.58': - resolution: {integrity: sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==} + '@aws-sdk/credential-provider-node@3.972.66': + resolution: {integrity: sha512-f+qjRXZpz7sgzbc4QB+6nLKfyKFgRRXzWdXbsKPv/VhVRyHsDyq4yBWC/B75BAJpFIcUeI2XR/3gdWJ677zB4A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.49': - resolution: {integrity: sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==} + '@aws-sdk/credential-provider-process@3.972.57': + resolution: {integrity: sha512-TiVQhuU0pbhIZAUZacbPHMyzrIdiH+lnx+PMY/Pu/b93dJrq3wdZwzUJ0TPpvNxaqbHsxJvQZW3/h/beLiKq7Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.55': - resolution: {integrity: sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==} + '@aws-sdk/credential-provider-sso@3.973.1': + resolution: {integrity: sha512-3foTZUJ4821Ij60X7K3NJroygiZLnbBmarN+T//O2cjkISan90zElN3NBmgSlDrTQ7Gs6z/yO8V7h60QNcDZHQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.55': - resolution: {integrity: sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==} + '@aws-sdk/credential-provider-web-identity@3.972.63': + resolution: {integrity: sha512-8qZLFhM69eKcS37m459ctPR05Qimycm/74OPVioe6wNZabMT54GYhwBju0+J656RkMasNSawWQu+c8CmBe3TUQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.33': - resolution: {integrity: sha512-qMgQSPemQq2/eW/e/0+SpY4kYR5L7dUgBiVdEc5bd+ztHNv07ZMYiI+sTiir3TgKndFfglSw/VFi7oZJ6bZ63g==} + '@aws-sdk/middleware-sdk-s3@3.972.62': + resolution: {integrity: sha512-k8JJwYXVYlOOjWnPZDThQS1xDFJgi5Dokt73qFlDtrZAbdcint5aIdjB9XgJAAQVP5OoqcefQmh1FYXiPpvsvw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.54': - resolution: {integrity: sha512-GDfDQ0gwLFRKN9gWIKcmVrHJ3e7XagnY7N1LLzMVNgnOnuY7f/ALgmy3CuBjosWD95T/Z6e+gs1IeWmLPkyLKQ==} + '@aws-sdk/nested-clients@3.997.31': + resolution: {integrity: sha512-BDHTpwcsZHEBNEJzOg/B1BkFYJxAXY50dau/NyVWs3d51F0WgIUGSWZot/Os+N3KpDhXeaXnz37mWffAvduREw==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.23': - resolution: {integrity: sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==} + '@aws-sdk/s3-presigned-post@3.1085.0': + resolution: {integrity: sha512-3xBJlcanWVv1LqMKebIYGgN/ufO/tB6Uo8SKqMFIxBRcxt14ONaSldv5Oh4CrqTuj2JkNg7ZVCofPSGSoP8iqw==} engines: {node: '>=20.0.0'} - '@aws-sdk/s3-request-presigner@3.1075.0': - resolution: {integrity: sha512-++ftTvAGZSTuzFVHEPk8lLi7mybBD8PzJ9USWBvwnE4kSrXOyqYVJ5Ixd06xUEWS/xsrhpkI07mzCLGIxrRymA==} + '@aws-sdk/signature-v4-multi-region@3.996.39': + resolution: {integrity: sha512-8+srXqYIF8KYMLC4FxMLEM5Ek7kUNibJu1R4m8/fUhhNYIZZz26oGtKkCr8I/HiG2fFQxBvaGgQZT4/mqRCSnA==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.35': - resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} + '@aws-sdk/token-providers@3.1083.0': + resolution: {integrity: sha512-s0woKnxuHrExLc5L2ArIH5BMkbonHPtt+5hSBM8oknp9M6QTuUmmAmJ2E0EdzCGONrO+8+ADPqvv6UX0nNcc7A==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1074.0': - resolution: {integrity: sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==} + '@aws-sdk/types@3.974.0': + resolution: {integrity: sha512-QIBrw90CDm4O0UaIIzkU6DrFdeJzEb2Va5EPEVpyldj6sHJxB6cshhStJuhZxk3wR3PmjJlYsjPmY1kNb+KGBg==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.13': - resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} + '@aws-sdk/xml-builder@3.972.34': + resolution: {integrity: sha512-wHhWL1y7sN3enBA8POrPpQM5jCcmu2ozyhbRei4c8OjVcEaEs6yLucLa/pla457ggS/ysuy7bosagz3HaJkZXA==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.8': - resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/xml-builder@3.972.31': - resolution: {integrity: sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.29.7': @@ -1911,42 +1880,30 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/core@3.26.0': - resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==} + '@smithy/core@3.29.3': + resolution: {integrity: sha512-L+Ys6ecjk5vwPMAKHBpPKlJ3DkqwNcnfEISXBZIsVvWG/XKXfsAP8mwIYlTeLcd2ElHdesPI8OuOmJSFAPhm6A==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.4.2': - resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} + '@smithy/credential-provider-imds@4.4.8': + resolution: {integrity: sha512-q9J7JTiXrAhB8sDp4px97uEPT7CwKH61Co78grdNQvU8QZAdiuaSRhP0tUVf2ogy36RZTrlMU1rBmDEH+cnkiA==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.5.2': - resolution: {integrity: sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==} + '@smithy/fetch-http-handler@5.6.5': + resolution: {integrity: sha512-SuqeisTyPoiIPtIYru/sGxGyXzmZ+8nnFOhC+qRPglt06Ebd1yH//CDltZB2J/3WBNVhwfUaZ0EtHB3cm2X32g==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/node-http-handler@4.8.2': - resolution: {integrity: sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==} + '@smithy/node-http-handler@4.9.5': + resolution: {integrity: sha512-bNqdxTQTxmLbomSmlkZFz8L6B/feQ2HHzw4L2zY7Ecp2XffYAZq2uzdWDdxJHJFbEvqd+SRuluJso0P8+xPdbw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.5.2': - resolution: {integrity: sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==} + '@smithy/signature-v4@5.6.4': + resolution: {integrity: sha512-B89bpf2t/y/wia6LZ+4JfHXYQT9PnVftsH05rgJKKIStS7r/4XSs9HOjtPoLtgcA6HCW9jVqX5DBbq7E0PAkiQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.15.0': - resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -5597,243 +5554,180 @@ snapshots: openapi3-ts: 4.6.0 zod: 3.25.76 - '@aws-crypto/crc32@5.2.0': + '@aws-sdk/checksums@3.1000.16': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/crc32c@5.2.0': + '@aws-sdk/client-s3@3.1085.0': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 + '@aws-sdk/checksums': 3.1000.16 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-node': 3.972.66 + '@aws-sdk/middleware-sdk-s3': 3.972.62 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/sha1-browser@5.2.0': + '@aws-sdk/core@3.975.1': dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 - '@aws-sdk/util-locate-window': 3.965.8 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 - '@aws-sdk/util-locate-window': 3.965.8 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.13 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.973.13 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/checksums@3.1000.8': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 - tslib: 2.8.1 - - '@aws-sdk/client-s3@3.1075.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.23 - '@aws-sdk/credential-provider-node': 3.972.58 - '@aws-sdk/middleware-flexible-checksums': 3.974.33 - '@aws-sdk/middleware-sdk-s3': 3.972.54 - '@aws-sdk/signature-v4-multi-region': 3.996.35 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 - '@smithy/node-http-handler': 4.8.2 - '@smithy/types': 4.15.0 - tslib: 2.8.1 - - '@aws-sdk/core@3.974.23': - dependencies: - '@aws-sdk/types': 3.973.13 - '@aws-sdk/xml-builder': 3.972.31 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.26.0 - '@smithy/signature-v4': 5.5.2 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@aws-sdk/xml-builder': 3.972.34 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.3 + '@smithy/signature-v4': 5.6.4 + '@smithy/types': 4.16.1 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.49': + '@aws-sdk/credential-provider-env@3.972.57': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.51': + '@aws-sdk/credential-provider-http@3.972.59': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 - '@smithy/node-http-handler': 4.8.2 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.56': + '@aws-sdk/credential-provider-ini@3.973.1': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/credential-provider-env': 3.972.49 - '@aws-sdk/credential-provider-http': 3.972.51 - '@aws-sdk/credential-provider-login': 3.972.55 - '@aws-sdk/credential-provider-process': 3.972.49 - '@aws-sdk/credential-provider-sso': 3.972.55 - '@aws-sdk/credential-provider-web-identity': 3.972.55 - '@aws-sdk/nested-clients': 3.997.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/credential-provider-imds': 4.4.2 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-login': 3.972.63 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/credential-provider-imds': 4.4.8 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.55': + '@aws-sdk/credential-provider-login@3.972.63': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/nested-clients': 3.997.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.58': + '@aws-sdk/credential-provider-node@3.972.66': dependencies: - '@aws-sdk/credential-provider-env': 3.972.49 - '@aws-sdk/credential-provider-http': 3.972.51 - '@aws-sdk/credential-provider-ini': 3.972.56 - '@aws-sdk/credential-provider-process': 3.972.49 - '@aws-sdk/credential-provider-sso': 3.972.55 - '@aws-sdk/credential-provider-web-identity': 3.972.55 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/credential-provider-imds': 4.4.2 - '@smithy/types': 4.15.0 + '@aws-sdk/credential-provider-env': 3.972.57 + '@aws-sdk/credential-provider-http': 3.972.59 + '@aws-sdk/credential-provider-ini': 3.973.1 + '@aws-sdk/credential-provider-process': 3.972.57 + '@aws-sdk/credential-provider-sso': 3.973.1 + '@aws-sdk/credential-provider-web-identity': 3.972.63 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/credential-provider-imds': 4.4.8 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.49': + '@aws-sdk/credential-provider-process@3.972.57': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.55': + '@aws-sdk/credential-provider-sso@3.973.1': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/nested-clients': 3.997.23 - '@aws-sdk/token-providers': 3.1074.0 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/token-providers': 3.1083.0 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.55': + '@aws-sdk/credential-provider-web-identity@3.972.63': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/nested-clients': 3.997.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.33': + '@aws-sdk/middleware-sdk-s3@3.972.62': dependencies: - '@aws-sdk/checksums': 3.1000.8 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.54': + '@aws-sdk/nested-clients@3.997.31': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/signature-v4-multi-region': 3.996.35 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/signature-v4-multi-region': 3.996.39 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/fetch-http-handler': 5.6.5 + '@smithy/node-http-handler': 4.9.5 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.23': + '@aws-sdk/s3-presigned-post@3.1085.0': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.23 - '@aws-sdk/signature-v4-multi-region': 3.996.35 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/fetch-http-handler': 5.5.2 - '@smithy/node-http-handler': 4.8.2 - '@smithy/types': 4.15.0 + '@aws-sdk/client-s3': 3.1085.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/signature-v4': 5.6.4 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/s3-request-presigner@3.1075.0': + '@aws-sdk/signature-v4-multi-region@3.996.39': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/signature-v4-multi-region': 3.996.35 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@aws-sdk/types': 3.974.0 + '@smithy/signature-v4': 5.6.4 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.35': + '@aws-sdk/token-providers@3.1083.0': dependencies: - '@aws-sdk/types': 3.973.13 - '@smithy/signature-v4': 5.5.2 - '@smithy/types': 4.15.0 + '@aws-sdk/core': 3.975.1 + '@aws-sdk/nested-clients': 3.997.31 + '@aws-sdk/types': 3.974.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1074.0': + '@aws-sdk/types@3.974.0': dependencies: - '@aws-sdk/core': 3.974.23 - '@aws-sdk/nested-clients': 3.997.23 - '@aws-sdk/types': 3.973.13 - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/types@3.973.13': + '@aws-sdk/xml-builder@3.972.34': dependencies: - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.8': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.31': - dependencies: - '@smithy/types': 4.15.0 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.29.7': dependencies: @@ -6048,7 +5942,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 @@ -6062,38 +5956,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 @@ -7016,52 +6910,37 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.26.0': + '@smithy/core@3.29.3': dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.15.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.4.2': + '@smithy/credential-provider-imds@4.4.8': dependencies: - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.5.2': + '@smithy/fetch-http-handler@5.6.5': dependencies: - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': + '@smithy/node-http-handler@4.9.5': dependencies: + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.8.2': + '@smithy/signature-v4@5.6.4': dependencies: - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 + '@smithy/core': 3.29.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/signature-v4@5.5.2': + '@smithy/types@4.16.1': dependencies: - '@smithy/core': 3.26.0 - '@smithy/types': 4.15.0 - tslib: 2.8.1 - - '@smithy/types@4.15.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 '@standard-schema/spec@1.1.0': {} @@ -7722,13 +7601,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