From eb99faf6559577f991f3cf2b6467d6c68dc55541 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 22 Jul 2026 09:49:07 +0200 Subject: [PATCH] feat: developer access permission gates webhooks/API keys/BYOK (v0.71.0) Webhooks, self-serve API keys, and BYOK AI provider keys had zero access gating -- any logged-in user, any tier. Adds users.isDeveloper (boolean, admin-toggled in admin/users/[id] alongside role/tier), checked via a single hasDeveloperAccess() (lib/permissions.ts) so a future subscription-tier auto-grant is a one-line change there, not a redesign across call sites. requireDeveloper() (lib/api-auth.ts) wraps requireSession() with a fresh isDeveloper check (same reasoning as requireAdmin re-querying role: session.user's cookieCache can be up to 5 minutes stale) and replaces requireSession in all 8 gated routes: webhooks CRUD + deliveries + redeliver, api-keys CRUD, ai-keys CRUD. Settings UI: the sidebar hides API Keys/Webhooks nav entries for non-developers; those pages and the BYOK section of Settings -> AI show a locked notice instead of the manager component when accessed directly. Migration grandfathers in anyone who already has a webhook, API key, or BYOK key row -- ships as a new gate on existing features, not a silent lockout of active integrations. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 + FEATURE_AUDIT.md | 7 +- apps/web/app/(app)/settings/ai/page.tsx | 9 +- apps/web/app/(app)/settings/api-keys/page.tsx | 48 +- apps/web/app/(app)/settings/layout.tsx | 7 +- apps/web/app/(app)/settings/webhooks/page.tsx | 48 +- apps/web/app/admin/users/[id]/page.tsx | 2 +- apps/web/app/api/v1/admin/users/[id]/route.ts | 14 +- .../app/api/v1/ai-keys/[provider]/route.ts | 4 +- apps/web/app/api/v1/ai-keys/route.ts | 6 +- apps/web/app/api/v1/api-keys/[id]/route.ts | 4 +- apps/web/app/api/v1/api-keys/route.ts | 6 +- .../v1/webhooks/[id]/__tests__/route.test.ts | 6 +- .../[id]/deliveries/__tests__/route.test.ts | 8 +- .../api/v1/webhooks/[id]/deliveries/route.ts | 4 +- .../[id]/redeliver/__tests__/route.test.ts | 8 +- .../api/v1/webhooks/[id]/redeliver/route.ts | 4 +- apps/web/app/api/v1/webhooks/[id]/route.ts | 6 +- .../api/v1/webhooks/__tests__/route.test.ts | 10 +- apps/web/app/api/v1/webhooks/route.ts | 6 +- apps/web/components/admin/user-editor.tsx | 14 +- .../settings/developer-locked-notice.tsx | 10 + .../components/settings/settings-sidebar.tsx | 8 +- apps/web/lib/api-auth.ts | 24 + apps/web/lib/changelog.ts | 9 +- apps/web/lib/openapi.ts | 29 +- apps/web/lib/permissions.ts | 8 + apps/web/messages/en.json | 1 + apps/web/messages/fr.json | 1 + apps/web/package.json | 2 +- package.json | 2 +- .../migrations/0060_wandering_mongoose.sql | 13 + .../db/src/migrations/meta/0060_snapshot.json | 6032 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/users.ts | 5 + 35 files changed, 6268 insertions(+), 109 deletions(-) create mode 100644 apps/web/components/settings/developer-locked-notice.tsx create mode 100644 apps/web/lib/permissions.ts create mode 100644 packages/db/src/migrations/0060_wandering_mongoose.sql create mode 100644 packages/db/src/migrations/meta/0060_snapshot.json 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.

+
+ +