From a834695609a5b36807a938174961d4ab4df22903 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 24 Jul 2026 09:13:36 +0200 Subject: [PATCH] fix: gate Model Prefs picker behind BYOK access (v0.73.1) Settings -> AI's model-selection form (per-use-case provider + model ID picker) had no access gate -- every user could see and use it, regardless of whether they had a BYOK key to route calls to or any reason to care which model served a request. Now gated behind isByokEnabled, same reasoning as BYOK itself: picking a specific provider/model only makes sense once you have your own key. Hidden entirely for everyone else, not locked-and-teased -- there's nothing for a non-BYOK user to unlock here. GET/PUT /api/v1/users/me/model-prefs switched from requireSession to requireByok to match. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 +++++ FEATURE_AUDIT.md | 6 +++--- apps/web/app/(app)/settings/ai/page.tsx | 20 ++++++++++--------- .../app/api/v1/users/me/model-prefs/route.ts | 6 +++--- apps/web/lib/changelog.ts | 9 ++++++++- apps/web/lib/openapi.ts | 4 ++-- apps/web/package.json | 2 +- package.json | 2 +- 8 files changed, 34 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3908033..a1c1f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ 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.73.1 — 2026-07-24 09:30 + +### Fixed +- Settings → AI's model picker (choose a specific provider/model like `gpt-4o-mini` or `o3-mini` per AI feature) had no access gate at all — every user could see and use it, regardless of whether they had any reason to. Now hidden unless you have BYOK access, since picking a specific model only matters once you've got your own key to route it to. + ## 0.73.0 — 2026-07-23 14:00 ### Added diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md index 485c68a..b8a79af 100644 --- a/FEATURE_AUDIT.md +++ b/FEATURE_AUDIT.md @@ -10,7 +10,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real **AI stack**: Vercel AI SDK, provider-agnostic (`apps/web/lib/ai/factory.ts`) — OpenAI, Anthropic, OpenRouter, Ollama (local). Key resolution order: user's own BYOK key → admin default per use-case → admin's raw provider key → env var. Structured outputs always `generateObject` + Zod; chat features use `generateText` with optional tool-calling. -**⚠️ Found this pass — ungated technical UI:** `Settings → AI`'s **Model Prefs** section (`apps/web/components/settings/model-prefs-form.tsx`) lets *every* user, with no developer/BYOK gate at all, pick a raw provider + specific model ID (`gpt-4o-mini`, `o3-mini`, `claude-opus-4-8`, etc.) per use-case, spending the admin's own API budget. This is the one place in the app where a home cook can stumble into an LLM-playground-style control panel with zero fencing. See the "Consumer-friendliness" section at the end of this doc. +**Model Prefs gating (closed 2026-07-24):** `Settings → AI`'s Model Prefs section previously had zero gate — every user could pick a raw provider + model ID with no fencing. Now behind `isByokEnabled` (same gate as BYOK itself, since picking a specific provider/model only makes sense once you have your own key to route it to) — hidden entirely for non-BYOK users, not just locked-and-teased. `GET`/`PUT /api/v1/users/me/model-prefs` gated server-side via `requireByok()` too. | Feature | Status | Description | Key files | |---|---|---|---| @@ -160,7 +160,7 @@ Ranked roughly by likely value: 10. ~~Moderator-scoped admin routes~~ — closed 2026-07-21 (reports + recipe-unpublish). 11. **Followers-only recipes invisible on the author's own profile grid** (2026-07-24) — `u/[username]/page.tsx` only queries `visibility = "public"` for the grid, so a followers-only recipe shows up in a follower's feed but never on the profile it belongs to. Real bug, not a design choice. 12. **Auto-deduct pantry on cook is dead in the UI** (2026-07-24) — correct, tested API logic that literally nothing in the product surfaces; both UI callers hardcode it off. Either wire it up (a real feature a home cook would want) or stop pretending it's a shipped feature. -13. **Model Prefs picker is ungated** (2026-07-24) — see "Consumer-friendliness" below; the one clear case of a technical control exposed with no fencing at all. +13. ~~Model Prefs picker is ungated~~ — closed 2026-07-24 (now behind BYOK access, hidden entirely for everyone else). ## Consumer-friendliness assessment — "is Epicure too tech-savvy?" (2026-07-24) @@ -174,7 +174,7 @@ Asked directly this pass: has Epicure accumulated too many technical/developer-o - **Moderator console** — same story, staff-only. - **Every consumer-facing domain checked clean**: recipe/meal-plan/pantry/shopping/social UI copy uses plain language throughout (checked directly — no "service worker," "IndexedDB," "webhook," "API," or similar jargon leaks into any user-facing string, including the newer offline/PWA surfaces). Cooking mode, batch cooking, collections, search, notifications — all read as ordinary consumer app features, not power-user tooling. -**Net take:** the *codebase* has a lot of technical depth relative to "recipe app," but almost all of it is correctly fenced off from the people it's not for. The fencing itself (developer-access self-serve, BYOK admin-gate, admin-only sections) is well-designed. The Model Prefs picker is the one place that fencing was skipped — fix that one thing and the "too tech-savvy" concern is essentially resolved. +**Net take:** the *codebase* has a lot of technical depth relative to "recipe app," but almost all of it is correctly fenced off from the people it's not for. The fencing itself (developer-access self-serve, BYOK admin-gate, admin-only sections) is well-designed. The Model Prefs picker was the one place that fencing was skipped — closed 2026-07-24 (now gated behind BYOK access, hidden entirely rather than locked-and-teased for everyone else). The "too tech-savvy" concern is resolved as of this fix. ## Deliberate design choices (not gaps) - Direct messaging is 1:1 only — enforced at the schema level, not a missing feature. diff --git a/apps/web/app/(app)/settings/ai/page.tsx b/apps/web/app/(app)/settings/ai/page.tsx index e69e4d1..7893198 100644 --- a/apps/web/app/(app)/settings/ai/page.tsx +++ b/apps/web/app/(app)/settings/ai/page.tsx @@ -88,15 +88,17 @@ export default async function AiSettingsPage() { )} -
-
-

{m.settings.modelPrefs.title}

-

- {m.settings.modelPrefs.description} -

-
- -
+ {dbUser?.isByokEnabled && ( +
+
+

{m.settings.modelPrefs.title}

+

+ {m.settings.modelPrefs.description} +

+
+ +
+ )} ); } diff --git a/apps/web/app/api/v1/users/me/model-prefs/route.ts b/apps/web/app/api/v1/users/me/model-prefs/route.ts index 1a4e26e..c9bde7b 100644 --- a/apps/web/app/api/v1/users/me/model-prefs/route.ts +++ b/apps/web/app/api/v1/users/me/model-prefs/route.ts @@ -1,6 +1,6 @@ import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { requireSession } from "@/lib/api-auth"; +import { requireByok } from "@/lib/api-auth"; import { db, userModelPrefs, eq } from "@epicure/db"; const Schema = z.object({ @@ -13,7 +13,7 @@ const Schema = z.object({ }); export async function GET() { - const { session, response } = await requireSession(); + const { session, response } = await requireByok(); if (response) return response; const prefs = await db.query.userModelPrefs.findFirst({ @@ -24,7 +24,7 @@ export async function GET() { } export async function PUT(req: NextRequest) { - const { session, response } = await requireSession(); + const { session, response } = await requireByok(); if (response) return response; const body = Schema.safeParse(await req.json()); diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index ca479c7..0b14957 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.73.0"; +export const APP_VERSION = "0.73.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.73.1", + date: "2026-07-24 09:30", + fixed: [ + "Settings → AI's model picker (choose a specific provider/model like gpt-4o-mini or o3-mini per AI feature) had no access gate at all — every user could see and use it, regardless of whether they had any reason to. Now hidden unless you have BYOK access, since picking a specific model only matters once you've got your own key to route it to.", + ], + }, { version: "0.73.0", date: "2026-07-23 14:00", diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index e788a62..13260ce 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -528,8 +528,8 @@ export function generateOpenApiSpec(): object { 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 } } } } }); + registry.registerPath({ method: "get", path: "/api/v1/users/me/model-prefs", summary: "Get your AI model provider/model preferences", description: "Requires BYOK access (an admin must enable it for your account) — picking a specific provider/model only makes sense once you've got your own key to route it to.", security, responses: { 200: { description: "Preferences or null", content: { "application/json": { schema: ModelPrefsRef.nullable() } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "put", path: "/api/v1/users/me/model-prefs", summary: "Set your AI model provider/model preferences", description: "Requires BYOK access (an admin must enable it for your account).", 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 } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/me/notification-prefs", summary: "Get your notification category preferences", description: "Categories with no saved row default to enabled.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: NotificationPrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, 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 } } } } }); registry.registerPath({ method: "get", path: "/api/v1/users/me/feature-prefs", summary: "Get your feature visibility preferences", description: "Features with no saved row default to visible.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: FeaturePrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); diff --git a/apps/web/package.json b/apps/web/package.json index 0518b3f..7f0e4e7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.73.0", + "version": "0.73.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index dbd3427..1274c0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.73.0", + "version": "0.73.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",