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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -88,15 +88,17 @@ export default async function AiSettingsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">{m.settings.modelPrefs.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{m.settings.modelPrefs.description}
|
||||
</p>
|
||||
</div>
|
||||
<ModelPrefsForm initialPrefs={modelPrefs ?? null} />
|
||||
</section>
|
||||
{dbUser?.isByokEnabled && (
|
||||
<section className="rounded-xl border p-6 space-y-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-lg">{m.settings.modelPrefs.title}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{m.settings.modelPrefs.description}
|
||||
</p>
|
||||
</div>
|
||||
<ModelPrefsForm initialPrefs={modelPrefs ?? null} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } } } } });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.73.0",
|
||||
"version": "0.73.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.73.0",
|
||||
"version": "0.73.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user