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 <noreply@anthropic.com>
This commit is contained in:
+15
-14
@@ -584,18 +584,18 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/shopping-lists/{id}/members", summary: "Remove a collaborator (owner or the member themselves)", security, request: { params: idParam, query: z.object({ memberId: z.string() }) }, responses: { 204: { description: "Removed" }, 400: { description: "memberId required", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists/{id}/export", summary: "Export items in a normalized grocery-provider format", security, request: { params: idParam }, responses: { 200: { description: "Export payload", content: { "application/json": { schema: GroceryExportRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/shopping-lists/{id}/export/instacart", summary: "Create an Instacart shopping-list link", description: "501 if Instacart isn't configured server-side.", security, request: { params: idParam }, responses: { 200: { description: "Link", content: { "application/json": { schema: z.record(z.string(), z.unknown()) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } }, 501: { description: "Not configured", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/api-keys", summary: "List API keys", description: "Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Keys (hash never returned)", content: { "application/json": { schema: z.array(ApiKeyRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/api-keys", summary: "Create API key", description: "Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ name: z.string().min(1).max(100), scope: z.enum(["full", "read"]).default("full").describe("read-only keys can only make GET requests") }) } }, required: true } }, responses: { 201: { description: "Key created — shown once", content: { "application/json": { schema: CreateApiKeyResponseRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required, or API key limit reached (max 10)", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/api-keys/{id}", summary: "Revoke API key", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 204: { description: "Revoked" }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
// --- AI keys: users' own third-party provider keys (BYOK), used instead of app-wide AI credentials ---
|
||||
const AiKeyRef = registry.register("AiKey", z.object({
|
||||
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), createdAt: z.string().datetime(),
|
||||
}).describe("Never includes the encrypted key value itself — only which providers are configured."));
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API.", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response.", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API. Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response. Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
// --- Webhooks: outbound event notifications to a user-provided URL, plus delivery log/redelivery ---
|
||||
const WebhookEventEnum = z.enum(["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted", "meal_plan.updated", "shopping_list.completed", "comment.added"]);
|
||||
@@ -618,12 +618,12 @@ export function generateOpenApiSpec(): object {
|
||||
statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/webhooks", summary: "List your webhooks", description: "The signing secret is never included — it is only ever returned once, at creation time.", security, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(WebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/webhooks", summary: "Create a webhook", description: "Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security, request: { body: { content: { "application/json": { schema: CreateWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/webhooks/{id}", summary: "Update a webhook's URL, events, or active state", description: "Validates the URL against SSRF targets if changed.", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: WebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/webhooks/{id}", summary: "Delete a webhook", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}/deliveries", summary: "List recent delivery attempts (most recent 20)", security, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(WebhookDeliveryRef) } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/webhooks", summary: "List your webhooks", description: "The signing secret is never included — it is only ever returned once, at creation time. Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(WebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/webhooks", summary: "Create a webhook", description: "Validates the URL against SSRF targets. Generates a signing secret returned once in this response only. Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: CreateWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/webhooks/{id}", summary: "Update a webhook's URL, events, or active state", description: "Validates the URL against SSRF targets if changed. Requires developer access (an admin must enable it for your account).", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: WebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "delete", path: "/api/v1/webhooks/{id}", summary: "Delete a webhook", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/webhooks/{id}/deliveries", summary: "List recent delivery attempts (most recent 20)", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(WebhookDeliveryRef) } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: idParam, body: { content: { "application/json": { schema: z.object({ deliveryId: z.string().uuid() }) } }, required: true } }, responses: { 200: { description: "Redelivery dispatched", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
const AdminWebhookEventEnum = z.enum(["user.signed_up", "support_ticket.created", "report.filed"]);
|
||||
const AdminWebhookRef = registry.register("AdminWebhook", z.object({
|
||||
@@ -879,9 +879,10 @@ export function generateOpenApiSpec(): object {
|
||||
|
||||
const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
|
||||
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro", "family"]).optional(),
|
||||
isDeveloper: z.boolean().optional().describe("Gates webhooks, self-serve API keys, and BYOK AI provider keys."),
|
||||
}));
|
||||
const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
|
||||
user: z.object({ id: z.string(), role: z.string(), tier: z.string() }),
|
||||
user: z.object({ id: z.string(), role: z.string(), tier: z.string(), isDeveloper: z.boolean() }),
|
||||
}));
|
||||
|
||||
const AdminUserUsageRef = registry.register("AdminUserUsage", z.object({
|
||||
@@ -933,7 +934,7 @@ export function generateOpenApiSpec(): object {
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/feature-flags", summary: "Enable or disable a feature for a tier", description: "Admin only. Disabling a feature doesn't hide its button client-side — the corresponding AI route (variations/drinks/pairings) returns 403 with code FEATURE_DISABLED for users on that tier, and the UI shows an upgrade prompt instead.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateFeatureFlagRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/users", summary: "Create a user directly (bypasses open/closed signup state via an internal one-time invite)", description: "Admin only. The new user is created email-verified with a random unusable password, then sent a password-reset email so they can set their own.", security: adminSecurity, request: { body: { content: { "application/json": { schema: AdminCreateUserBodyRef } }, required: true } }, responses: { 200: { description: "Created", content: { "application/json": { schema: AdminCreatedUserRef } } }, 400: { description: "Missing fields, invalid role/tier, or Better Auth signup error", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 409: { description: "A user with this email already exists", content: { "application/json": { schema: ApiErrorRef } } }, 500: { description: "User creation failed", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role and/or tier", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}", summary: "Update a user's role, tier, and/or developer access", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: AdminUpdateUserBodyRef } } } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminUpdatedUserRef } } }, 400: { description: "Invalid role or tier", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "User not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/users/{id}/usage", summary: "Reset a user's AI call count for the current month", description: "Admin only. Recipe count and storage are lifetime totals derived from real data, not resettable counters.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Reset usage (zeros, whether or not a usage row already existed)", content: { "application/json": { schema: z.object({ usage: AdminUserUsageRef }) } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
const generator = new OpenApiGeneratorV31(registry.definitions);
|
||||
|
||||
Reference in New Issue
Block a user