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",