diff --git a/CHANGELOG.md b/CHANGELOG.md
index 229e351..33a060c 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.71.0 — 2026-07-22 10:00
+
+### Added
+- New "developer access" permission, admin-toggled per user (Admin → Users), gates webhooks, self-serve API keys, and BYOK AI provider keys — previously available to every logged-in user with no gating at all. Anyone who already had a webhook, API key, or BYOK key configured keeps access automatically.
+
## 0.70.0 — 2026-07-22 09:30
### Added
diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md
index 979da66..bb54cd4 100644
--- a/FEATURE_AUDIT.md
+++ b/FEATURE_AUDIT.md
@@ -105,10 +105,11 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| **Self-serve billing portal** | **Missing** | No Stripe customer portal; tier changes today only happen via direct admin edit at `/admin/users/[id]` | — |
| Admin dashboard | Exists | 13 sections: overview, insights/analytics, users, invites, recipe moderation, reports, support, tiers, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/**` |
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
-| User webhooks (personal automation) | Exists | 7 events, Zapier-style | `apps/web/lib/webhooks.ts` |
-| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) | `apps/web/lib/admin-webhooks.ts` |
+| **Developer permission** (new, 2026-07-22) | Exists | A fourth, orthogonal access dimension — `users.isDeveloper`, admin-toggled in `admin/users/[id]`, gates user webhooks + self-serve API keys + BYOK. Previously all three had zero gating (any logged-in user, any tier). Existing users with a webhook/API key/BYOK key were grandfathered in by the migration. Deliberately a single `hasDeveloperAccess()` function so a future subscription-tier auto-grant is a one-line change, not a redesign. | `apps/web/lib/permissions.ts`, `apps/web/lib/api-auth.ts` (`requireDeveloper`) |
+| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
+| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
| Support tickets ↔ Gitea two-way sync | Exists, verified real | Outbound (create/comment/close mirror to Gitea issue) + inbound (signed webhook receiver, dedup, loop-safe) | `apps/web/lib/gitea.ts`, `apps/web/app/api/webhooks/gitea/route.ts` |
-| Self-serve API keys | Exists | Up to 10 per user, scoped full/read, SHA-256 hashed at rest | `apps/web/app/api/v1/api-keys` |
+| Self-serve API keys | Exists | Up to 10 per user, scoped full/read, SHA-256 hashed at rest, requires developer access | `apps/web/app/api/v1/api-keys` |
| Public OpenAPI spec + docs page | Exists | Scalar-rendered at `/docs`, spec at `/api/v1/openapi.json` | `apps/web/lib/openapi.ts` |
| i18n | Partial | Only `en` and `fr` — no other locales | `apps/web/messages/*.json` |
| 2FA | Exists | TOTP + backup codes + OTP, passwordless-account-compatible | Better Auth `twoFactor` plugin |
diff --git a/apps/web/app/(app)/settings/ai/page.tsx b/apps/web/app/(app)/settings/ai/page.tsx
index bc0ad51..440fe08 100644
--- a/apps/web/app/(app)/settings/ai/page.tsx
+++ b/apps/web/app/(app)/settings/ai/page.tsx
@@ -6,6 +6,7 @@ import { UNLIMITED, getRecipeCount, getStorageUsedMb } from "@/lib/tiers";
import { ByokManager } from "@/components/settings/byok-manager";
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
import { UsageQuotaSection } from "@/components/settings/usage-quota-section";
+import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { getMessages, formatMessage } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -27,7 +28,7 @@ export default async function AiSettingsPage() {
}),
// Tier comes from the DB, not the (up to 5-minute-stale) session cookie
// cache, so a just-changed tier's limits show up immediately here.
- db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true } }),
+ db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true, isDeveloper: true } }),
]);
const [tierDef, usage, recipeCount, storageUsedMb] = await Promise.all([
@@ -80,7 +81,11 @@ export default async function AiSettingsPage() {
{m.settings.byok.description}
- k.provider)} />
+ {dbUser?.isDeveloper ? (
+ k.provider)} />
+ ) : (
+
+ )}
diff --git a/apps/web/app/(app)/settings/api-keys/page.tsx b/apps/web/app/(app)/settings/api-keys/page.tsx
index e6c3fbe..9028c26 100644
--- a/apps/web/app/(app)/settings/api-keys/page.tsx
+++ b/apps/web/app/(app)/settings/api-keys/page.tsx
@@ -3,8 +3,9 @@ import { headers } from "next/headers";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
import { auth } from "@/lib/auth/server";
-import { db, apiKeys, eq } from "@epicure/db";
+import { db, apiKeys, users, eq } from "@epicure/db";
import { ApiKeysManager } from "@/components/settings/api-keys-manager";
+import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -14,16 +15,20 @@ export default async function ApiKeysPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
- const keys = await db
- .select({
- id: apiKeys.id,
- name: apiKeys.name,
- scope: apiKeys.scope,
- lastUsedAt: apiKeys.lastUsedAt,
- createdAt: apiKeys.createdAt,
- })
- .from(apiKeys)
- .where(eq(apiKeys.userId, session.user.id));
+ const dbUser = (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
+
+ const keys = dbUser?.isDeveloper
+ ? await db
+ .select({
+ id: apiKeys.id,
+ name: apiKeys.name,
+ scope: apiKeys.scope,
+ lastUsedAt: apiKeys.lastUsedAt,
+ createdAt: apiKeys.createdAt,
+ })
+ .from(apiKeys)
+ .where(eq(apiKeys.userId, session.user.id))
+ : [];
return (
@@ -42,15 +47,18 @@ export default async function ApiKeysPage() {
{m.settings.apiKeysPage.docsLink}
- ({
- id: k.id,
- name: k.name,
- scope: k.scope,
- lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
- createdAt: k.createdAt.toISOString(),
- }))}
- />
+ {!dbUser?.isDeveloper && }
+ {dbUser?.isDeveloper && (
+ ({
+ id: k.id,
+ name: k.name,
+ scope: k.scope,
+ lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
+ createdAt: k.createdAt.toISOString(),
+ }))}
+ />
+ )}
);
diff --git a/apps/web/app/(app)/settings/layout.tsx b/apps/web/app/(app)/settings/layout.tsx
index 270fe00..9908829 100644
--- a/apps/web/app/(app)/settings/layout.tsx
+++ b/apps/web/app/(app)/settings/layout.tsx
@@ -1,5 +1,6 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
+import { db, users, eq } from "@epicure/db";
import { SettingsSidebar } from "@/components/settings/settings-sidebar";
import { getMessages } from "@/lib/i18n/server";
@@ -7,6 +8,10 @@ export default async function SettingsLayout({ children }: { children: React.Rea
const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string })?.locale);
+ const dbUser = session
+ ? (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0]
+ : undefined;
+
return (
@@ -14,7 +19,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
{m.settings.subtitle}
-
+
{children}
diff --git a/apps/web/app/(app)/settings/webhooks/page.tsx b/apps/web/app/(app)/settings/webhooks/page.tsx
index 2d9f759..f43f57a 100644
--- a/apps/web/app/(app)/settings/webhooks/page.tsx
+++ b/apps/web/app/(app)/settings/webhooks/page.tsx
@@ -1,8 +1,9 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
-import { db, webhooks, eq } from "@epicure/db";
+import { db, webhooks, users, eq } from "@epicure/db";
import { WebhooksManager } from "@/components/settings/webhooks-manager";
+import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -12,16 +13,20 @@ export default async function WebhooksPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
- const rows = await db
- .select({
- id: webhooks.id,
- url: webhooks.url,
- events: webhooks.events,
- active: webhooks.active,
- createdAt: webhooks.createdAt,
- })
- .from(webhooks)
- .where(eq(webhooks.userId, session.user.id));
+ const dbUser = (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
+
+ const rows = dbUser?.isDeveloper
+ ? await db
+ .select({
+ id: webhooks.id,
+ url: webhooks.url,
+ events: webhooks.events,
+ active: webhooks.active,
+ createdAt: webhooks.createdAt,
+ })
+ .from(webhooks)
+ .where(eq(webhooks.userId, session.user.id))
+ : [];
return (
@@ -32,15 +37,18 @@ export default async function WebhooksPage() {
{m.settings.webhooksPage.description}
- ({
- id: w.id,
- url: w.url,
- events: w.events,
- active: w.active,
- createdAt: w.createdAt.toISOString(),
- }))}
- />
+ {!dbUser?.isDeveloper && }
+ {dbUser?.isDeveloper && (
+ ({
+ id: w.id,
+ url: w.url,
+ events: w.events,
+ active: w.active,
+ createdAt: w.createdAt.toISOString(),
+ }))}
+ />
+ )}
);
diff --git a/apps/web/app/admin/users/[id]/page.tsx b/apps/web/app/admin/users/[id]/page.tsx
index 6d16911..8818e08 100644
--- a/apps/web/app/admin/users/[id]/page.tsx
+++ b/apps/web/app/admin/users/[id]/page.tsx
@@ -102,7 +102,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
Edit Role & Tier
-
+
diff --git a/apps/web/app/api/v1/admin/users/[id]/route.ts b/apps/web/app/api/v1/admin/users/[id]/route.ts
index 759bf9b..6f6176b 100644
--- a/apps/web/app/api/v1/admin/users/[id]/route.ts
+++ b/apps/web/app/api/v1/admin/users/[id]/route.ts
@@ -12,8 +12,8 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (response) return response;
const { id } = await params;
- const body = await req.json() as { role?: string; tier?: string };
- const { role, tier } = body;
+ const body = await req.json() as { role?: string; tier?: string; isDeveloper?: boolean };
+ const { role, tier, isDeveloper } = body;
const validRoles = ["user", "moderator", "admin"] as const;
const validTiers = ["free", "pro", "family"] as const;
@@ -24,18 +24,22 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (tier !== undefined && !validTiers.includes(tier as typeof validTiers[number])) {
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
}
+ if (isDeveloper !== undefined && typeof isDeveloper !== "boolean") {
+ return NextResponse.json({ error: "Invalid isDeveloper" }, { status: 400 });
+ }
- const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; updatedAt: Date }> = {
+ const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; isDeveloper: boolean; updatedAt: Date }> = {
updatedAt: new Date(),
};
if (role) updateData.role = role as "user" | "moderator" | "admin";
if (tier) updateData.tier = tier as "free" | "pro" | "family";
+ if (isDeveloper !== undefined) updateData.isDeveloper = isDeveloper;
const [updated] = await db
.update(users)
.set(updateData)
.where(eq(users.id, id))
- .returning({ id: users.id, role: users.role, tier: users.tier });
+ .returning({ id: users.id, role: users.role, tier: users.tier, isDeveloper: users.isDeveloper });
if (!updated) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
@@ -48,7 +52,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
action: "admin.user.update",
targetType: "user",
targetId: id,
- metadata: JSON.stringify({ role, tier }),
+ metadata: JSON.stringify({ role, tier, isDeveloper }),
createdAt: new Date(),
});
diff --git a/apps/web/app/api/v1/ai-keys/[provider]/route.ts b/apps/web/app/api/v1/ai-keys/[provider]/route.ts
index 1b14fb6..30103ca 100644
--- a/apps/web/app/api/v1/ai-keys/[provider]/route.ts
+++ b/apps/web/app/api/v1/ai-keys/[provider]/route.ts
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { db, userAiKeys, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
type Params = { params: Promise<{ provider: string }> };
export async function DELETE(_req: Request, { params }: Params) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const { provider } = await params;
diff --git a/apps/web/app/api/v1/ai-keys/route.ts b/apps/web/app/api/v1/ai-keys/route.ts
index 1a8e5be..85f2a01 100644
--- a/apps/web/app/api/v1/ai-keys/route.ts
+++ b/apps/web/app/api/v1/ai-keys/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db, userAiKeys, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
import { encrypt } from "@/lib/encrypt";
import { applyRateLimit } from "@/lib/rate-limit";
@@ -13,7 +13,7 @@ const PostSchema = z.object({
});
export async function GET() {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const keys = await db.query.userAiKeys.findMany({
@@ -25,7 +25,7 @@ export async function GET() {
}
export async function POST(req: Request) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
diff --git a/apps/web/app/api/v1/api-keys/[id]/route.ts b/apps/web/app/api/v1/api-keys/[id]/route.ts
index ca7fe20..9c75592 100644
--- a/apps/web/app/api/v1/api-keys/[id]/route.ts
+++ b/apps/web/app/api/v1/api-keys/[id]/route.ts
@@ -1,12 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { db, apiKeys, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const { id } = await params;
diff --git a/apps/web/app/api/v1/api-keys/route.ts b/apps/web/app/api/v1/api-keys/route.ts
index e562411..f48c5ab 100644
--- a/apps/web/app/api/v1/api-keys/route.ts
+++ b/apps/web/app/api/v1/api-keys/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, apiKeys, eq, sql } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
const CreateApiKeyBody = z.object({
name: z.string().min(1).max(100),
@@ -10,7 +10,7 @@ const CreateApiKeyBody = z.object({
});
export async function GET() {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const rows = await db
@@ -28,7 +28,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const body = await req.json() as unknown;
diff --git a/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts
index ecf4c8b..0055d9b 100644
--- a/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts
+++ b/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts
@@ -4,7 +4,7 @@ import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
- requireSession: vi.fn(),
+ requireDeveloper: vi.fn(),
}));
vi.mock("@/lib/validate-webhook-url", () => ({
@@ -33,7 +33,7 @@ vi.mock("@epicure/db", () => ({
and: vi.fn((...args) => ({ args, op: "and" })),
}));
-const { requireSession } = await import("@/lib/api-auth");
+const { requireDeveloper } = await import("@/lib/api-auth");
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
import { DELETE, PATCH } from "../route";
@@ -49,7 +49,7 @@ const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
+ vi.mocked(requireDeveloper).mockResolvedValue({ session: mockSession as never, response: null });
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
mockSelectChain.limit.mockResolvedValue([{ id: "wh-1" }]);
});
diff --git a/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts
index f9b7448..189233b 100644
--- a/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts
+++ b/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts
@@ -4,7 +4,7 @@ import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
- requireSession: vi.fn(),
+ requireDeveloper: vi.fn(),
}));
const { mockFindFirst, mockSelectChain } = vi.hoisted(() => {
@@ -29,21 +29,21 @@ vi.mock("@epicure/db", () => ({
desc: vi.fn((a) => ({ a, op: "desc" })),
}));
-const { requireSession } = await import("@/lib/api-auth");
+const { requireDeveloper } = await import("@/lib/api-auth");
import { GET } from "../route";
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
+ vi.mocked(requireDeveloper).mockResolvedValue({ session: mockSession as never, response: null });
mockFindFirst.mockResolvedValue({ id: "wh-1" });
mockSelectChain.limit.mockResolvedValue([]);
});
describe("GET /api/v1/webhooks/[id]/deliveries", () => {
it("returns 401 when not authenticated", async () => {
- vi.mocked(requireSession).mockResolvedValue({
+ vi.mocked(requireDeveloper).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
diff --git a/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts b/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts
index ad0f00b..596e99f 100644
--- a/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts
+++ b/apps/web/app/api/v1/webhooks/[id]/deliveries/route.ts
@@ -1,11 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { db, webhooks, webhookDeliveries, eq, and, desc } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const { id } = await params;
diff --git a/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts
index ec8ed91..b29f716 100644
--- a/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts
+++ b/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts
@@ -4,7 +4,7 @@ import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
- requireSession: vi.fn(),
+ requireDeveloper: vi.fn(),
}));
vi.mock("@/lib/webhooks", () => ({
@@ -29,7 +29,7 @@ vi.mock("@epicure/db", () => ({
and: vi.fn((...args) => ({ args, op: "and" })),
}));
-const { requireSession } = await import("@/lib/api-auth");
+const { requireDeveloper } = await import("@/lib/api-auth");
const { dispatchWebhook } = await import("@/lib/webhooks");
import { POST } from "../route";
@@ -47,7 +47,7 @@ const ctx = { params: Promise.resolve({ id: "wh-1" }) };
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
+ vi.mocked(requireDeveloper).mockResolvedValue({ session: mockSession as never, response: null });
mockWebhookFindFirst.mockResolvedValue({ id: "wh-1" });
mockDeliveryFindFirst.mockResolvedValue({
id: VALID_DELIVERY_ID,
@@ -58,7 +58,7 @@ beforeEach(() => {
describe("POST /api/v1/webhooks/[id]/redeliver", () => {
it("returns 401 when not authenticated", async () => {
- vi.mocked(requireSession).mockResolvedValue({
+ vi.mocked(requireDeveloper).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
diff --git a/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts b/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts
index d7c2683..8c175cd 100644
--- a/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts
+++ b/apps/web/app/api/v1/webhooks/[id]/redeliver/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, webhooks, webhookDeliveries, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
import { dispatchWebhook, type WebhookEvent } from "@/lib/webhooks";
const Schema = z.object({ deliveryId: z.string().uuid() });
@@ -9,7 +9,7 @@ const Schema = z.object({ deliveryId: z.string().uuid() });
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const { id } = await params;
diff --git a/apps/web/app/api/v1/webhooks/[id]/route.ts b/apps/web/app/api/v1/webhooks/[id]/route.ts
index 4d563f1..c675335 100644
--- a/apps/web/app/api/v1/webhooks/[id]/route.ts
+++ b/apps/web/app/api/v1/webhooks/[id]/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, webhooks, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { WEBHOOK_EVENTS } from "@/lib/webhooks";
@@ -15,7 +15,7 @@ export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const { id } = await params;
@@ -41,7 +41,7 @@ export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const { id } = await params;
diff --git a/apps/web/app/api/v1/webhooks/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/__tests__/route.test.ts
index c57937b..8c2f7d8 100644
--- a/apps/web/app/api/v1/webhooks/__tests__/route.test.ts
+++ b/apps/web/app/api/v1/webhooks/__tests__/route.test.ts
@@ -4,7 +4,7 @@ import { NextRequest } from "next/server";
const mockSession = { user: { id: "user-1" } };
vi.mock("@/lib/api-auth", () => ({
- requireSession: vi.fn(),
+ requireDeveloper: vi.fn(),
}));
vi.mock("@/lib/validate-webhook-url", () => ({
@@ -28,7 +28,7 @@ vi.mock("@epicure/db", () => ({
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
}));
-const { requireSession } = await import("@/lib/api-auth");
+const { requireDeveloper } = await import("@/lib/api-auth");
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
import { GET, POST } from "../route";
@@ -42,14 +42,14 @@ function makeRequest(method: string, body?: unknown) {
beforeEach(() => {
vi.clearAllMocks();
- vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
+ vi.mocked(requireDeveloper).mockResolvedValue({ session: mockSession as never, response: null });
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
mockSelectChain.where.mockResolvedValue([]);
});
describe("GET /api/v1/webhooks", () => {
it("returns 401 when not authenticated", async () => {
- vi.mocked(requireSession).mockResolvedValue({
+ vi.mocked(requireDeveloper).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
@@ -82,7 +82,7 @@ describe("POST /api/v1/webhooks", () => {
});
it("returns 401 when not authenticated", async () => {
- vi.mocked(requireSession).mockResolvedValue({
+ vi.mocked(requireDeveloper).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts
index f653255..89d32ec 100644
--- a/apps/web/app/api/v1/webhooks/route.ts
+++ b/apps/web/app/api/v1/webhooks/route.ts
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, webhooks, eq } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { requireDeveloper } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
import { WEBHOOK_EVENTS } from "@/lib/webhooks";
import { encrypt } from "@/lib/encrypt";
@@ -19,7 +19,7 @@ const UpdateWebhookBody = z.object({
});
export async function GET() {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const rows = await db
@@ -38,7 +38,7 @@ export async function GET() {
}
export async function POST(req: NextRequest) {
- const { session, response } = await requireSession();
+ const { session, response } = await requireDeveloper();
if (response) return response;
const body = await req.json() as unknown;
diff --git a/apps/web/components/admin/user-editor.tsx b/apps/web/components/admin/user-editor.tsx
index 8a67388..bb19ae9 100644
--- a/apps/web/components/admin/user-editor.tsx
+++ b/apps/web/components/admin/user-editor.tsx
@@ -11,16 +11,19 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
interface UserEditorProps {
userId: string;
currentRole: "user" | "moderator" | "admin";
currentTier: "free" | "pro" | "family";
+ currentIsDeveloper: boolean;
}
-export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps) {
+export function UserEditor({ userId, currentRole, currentTier, currentIsDeveloper }: UserEditorProps) {
const [role, setRole] = useState<"user" | "moderator" | "admin">(currentRole);
const [tier, setTier] = useState<"free" | "pro" | "family">(currentTier);
+ const [isDeveloper, setIsDeveloper] = useState(currentIsDeveloper);
const [saving, setSaving] = useState(false);
async function handleSave() {
@@ -29,7 +32,7 @@ export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps
const res = await fetch(`/api/v1/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ role, tier }),
+ body: JSON.stringify({ role, tier, isDeveloper }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -73,6 +76,13 @@ export function UserEditor({ userId, currentRole, currentTier }: UserEditorProps
+
+
+
+
Enables webhooks, self-serve API keys, and BYOK AI provider keys.
+
+
+