feat: per-category email prefs, admin ops webhooks, per-user feature toggles (v0.61.0)
- Notification email preferences: every push category (follow, comment, reply, reaction, rating, mention, leftoverExpiring, shoppingList) now has an independent email toggle, plus a Weekly Digest toggle. Previously email sent unconditionally whenever the recipient had one; now gated the same way push already was. The weekly-digest cron route excludes opted-out users. - Admin-only site-wide webhooks (Admin → Webhooks): new signups, support tickets, and reports filed can now fire an HMAC-signed HTTP webhook (Slack/Discord/ops alerting), independent of the existing per-user webhooks (which stay scoped to a user's own recipe/meal-plan/shopping-list events). Signing/delivery logic factored into lib/webhook-delivery.ts and shared by both dispatchers instead of duplicated. - Settings → Features: users can hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from their own nav. Purely cosmetic — hidden pages stay reachable by direct link, nothing is access-restricted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import crypto from "crypto";
|
||||
import { db } from "@epicure/db";
|
||||
import { adminWebhooks, adminWebhookDeliveries } from "@epicure/db";
|
||||
import { eq } from "@epicure/db";
|
||||
import { deliverWebhook } from "@/lib/webhook-delivery";
|
||||
|
||||
// Site-wide ops events — distinct from WEBHOOK_EVENTS in lib/webhooks.ts,
|
||||
// which are per-user events on a user's own data. These fire regardless of
|
||||
// who's involved, for ops alerting (Slack/Discord/etc via a generic
|
||||
// incoming webhook), so only admins can subscribe to them.
|
||||
export const ADMIN_WEBHOOK_EVENTS = [
|
||||
"user.signed_up",
|
||||
"support_ticket.created",
|
||||
"report.filed",
|
||||
] as const;
|
||||
|
||||
export type AdminWebhookEvent = (typeof ADMIN_WEBHOOK_EVENTS)[number];
|
||||
|
||||
export async function dispatchAdminWebhook(event: AdminWebhookEvent, payload: object) {
|
||||
const hooks = await db.select().from(adminWebhooks).where(eq(adminWebhooks.active, true));
|
||||
const filtered = hooks.filter((h) => h.events.length === 0 || h.events.includes(event));
|
||||
|
||||
await Promise.allSettled(
|
||||
filtered.map(async (hook) => {
|
||||
const { statusCode, success } = await deliverWebhook(hook.url, hook.secret, event, payload);
|
||||
await db.insert(adminWebhookDeliveries).values({
|
||||
id: crypto.randomUUID(),
|
||||
webhookId: hook.id,
|
||||
event,
|
||||
payload: payload as Record<string, unknown>,
|
||||
statusCode,
|
||||
success,
|
||||
attempts: 1,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { isSignupsDisabled } from "@/lib/site-settings";
|
||||
import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites";
|
||||
import { generateUniqueUsername } from "@/lib/username";
|
||||
import { getRedis } from "@/lib/redis";
|
||||
import { dispatchAdminWebhook } from "@/lib/admin-webhooks";
|
||||
|
||||
// Without this, better-auth's own rate limiter (sign-in/sign-up/2FA/password
|
||||
// reset throttles below) defaults to an in-process Map — fine for a single
|
||||
@@ -178,6 +179,8 @@ export const auth = betterAuth({
|
||||
subject: "Welcome to Epicure",
|
||||
html: welcomeHtml(user.name),
|
||||
}).catch(() => {});
|
||||
|
||||
void dispatchAdminWebhook("user.signed_up", { id: user.id, email: user.email, name: user.name });
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.60.0";
|
||||
export const APP_VERSION = "0.61.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,15 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.61.0",
|
||||
date: "2026-07-20 23:15",
|
||||
added: [
|
||||
"Settings → Notifications now has an email toggle for every category alongside the existing push toggle, plus a Weekly Digest toggle to opt out of the weekly email summary entirely.",
|
||||
"Admin → Webhooks: site-wide ops webhooks (new signups, support tickets, reports filed) — separate from the existing per-user webhooks, for Slack/Discord-style ops alerting.",
|
||||
"Settings → Features: hide Nutrition, Pantry, Meal Plan, Shopping Lists, Collections, or Messages from your own navigation. Cosmetic only — hidden pages stay reachable by direct link.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.60.0",
|
||||
date: "2026-07-20 22:15",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { db, userFeaturePrefs, eq } from "@epicure/db";
|
||||
|
||||
export type FeatureKey = "nutrition" | "pantry" | "mealPlan" | "shoppingLists" | "collections" | "messages";
|
||||
|
||||
export type FeaturePrefs = Record<FeatureKey, boolean>;
|
||||
|
||||
const DEFAULT_PREFS: FeaturePrefs = {
|
||||
nutrition: true, pantry: true, mealPlan: true, shoppingLists: true, collections: true, messages: true,
|
||||
};
|
||||
|
||||
/** No row yet means every feature defaults to on — same default the DB columns encode. */
|
||||
export async function getFeaturePrefs(userId: string): Promise<FeaturePrefs> {
|
||||
const row = await db.query.userFeaturePrefs.findFirst({ where: eq(userFeaturePrefs.userId, userId) });
|
||||
if (!row) return { ...DEFAULT_PREFS };
|
||||
return {
|
||||
nutrition: row.nutrition, pantry: row.pantry, mealPlan: row.mealPlan,
|
||||
shoppingLists: row.shoppingLists, collections: row.collections, messages: row.messages,
|
||||
};
|
||||
}
|
||||
@@ -4,11 +4,20 @@ export type NotificationCategory =
|
||||
| "follow" | "comment" | "reply" | "reaction" | "rating" | "mention"
|
||||
| "leftoverExpiring" | "shoppingList";
|
||||
|
||||
export type NotificationPrefs = Record<NotificationCategory, boolean>;
|
||||
export type NotificationPrefs = {
|
||||
follow: boolean; comment: boolean; reply: boolean; reaction: boolean; rating: boolean; mention: boolean;
|
||||
leftoverExpiring: boolean; shoppingList: boolean;
|
||||
followEmail: boolean; commentEmail: boolean; replyEmail: boolean; reactionEmail: boolean;
|
||||
ratingEmail: boolean; mentionEmail: boolean; leftoverExpiringEmail: boolean; shoppingListEmail: boolean;
|
||||
weeklyDigestEmail: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_PREFS: NotificationPrefs = {
|
||||
follow: true, comment: true, reply: true, reaction: true, rating: true, mention: true,
|
||||
leftoverExpiring: true, shoppingList: true,
|
||||
followEmail: true, commentEmail: true, replyEmail: true, reactionEmail: true,
|
||||
ratingEmail: true, mentionEmail: true, leftoverExpiringEmail: true, shoppingListEmail: true,
|
||||
weeklyDigestEmail: true,
|
||||
};
|
||||
|
||||
export async function getNotificationPrefs(userId: string): Promise<NotificationPrefs> {
|
||||
@@ -17,6 +26,10 @@ export async function getNotificationPrefs(userId: string): Promise<Notification
|
||||
return {
|
||||
follow: row.follow, comment: row.comment, reply: row.reply, reaction: row.reaction,
|
||||
rating: row.rating, mention: row.mention, leftoverExpiring: row.leftoverExpiring, shoppingList: row.shoppingList,
|
||||
followEmail: row.followEmail, commentEmail: row.commentEmail, replyEmail: row.replyEmail,
|
||||
reactionEmail: row.reactionEmail, ratingEmail: row.ratingEmail, mentionEmail: row.mentionEmail,
|
||||
leftoverExpiringEmail: row.leftoverExpiringEmail, shoppingListEmail: row.shoppingListEmail,
|
||||
weeklyDigestEmail: row.weeklyDigestEmail,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,3 +38,14 @@ export async function isNotificationCategoryEnabled(userId: string, category: No
|
||||
const prefs = await getNotificationPrefs(userId);
|
||||
return prefs[category];
|
||||
}
|
||||
|
||||
/** Same as isNotificationCategoryEnabled, but for the email variant of the category. */
|
||||
export async function isEmailCategoryEnabled(userId: string, category: NotificationCategory): Promise<boolean> {
|
||||
const prefs = await getNotificationPrefs(userId);
|
||||
return prefs[`${category}Email`];
|
||||
}
|
||||
|
||||
export async function isWeeklyDigestEnabled(userId: string): Promise<boolean> {
|
||||
const prefs = await getNotificationPrefs(userId);
|
||||
return prefs.weeklyDigestEmail;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
|
||||
import { sendPushNotification } from "./push";
|
||||
import { sendEmail, notificationEmailHtml } from "./email";
|
||||
import { getMessages, formatMessage } from "./i18n/server";
|
||||
import { isNotificationCategoryEnabled } from "./notification-prefs";
|
||||
import { isNotificationCategoryEnabled, isEmailCategoryEnabled } from "./notification-prefs";
|
||||
|
||||
type NotificationType = "follow" | "comment" | "reply" | "reaction" | "rating" | "mention";
|
||||
|
||||
@@ -82,7 +82,7 @@ async function dispatchAlerts(opts: CreateNotificationOpts): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.email) {
|
||||
if (recipient.email && (await isEmailCategoryEnabled(opts.userId, opts.type))) {
|
||||
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
|
||||
void sendEmail({
|
||||
to: recipient.email,
|
||||
|
||||
+45
-1
@@ -465,11 +465,26 @@ export function generateOpenApiSpec(): object {
|
||||
const NotificationPrefsRef = registry.register("NotificationPrefs", z.object({
|
||||
follow: z.boolean(), comment: z.boolean(), reply: z.boolean(), reaction: z.boolean(),
|
||||
rating: z.boolean(), mention: z.boolean(), leftoverExpiring: z.boolean(), shoppingList: z.boolean(),
|
||||
}));
|
||||
followEmail: z.boolean(), commentEmail: z.boolean(), replyEmail: z.boolean(), reactionEmail: z.boolean(),
|
||||
ratingEmail: z.boolean(), mentionEmail: z.boolean(), leftoverExpiringEmail: z.boolean(), shoppingListEmail: z.boolean(),
|
||||
weeklyDigestEmail: z.boolean(),
|
||||
}).describe("The non-Email fields gate push notifications; the *Email fields gate the matching email. weeklyDigestEmail gates the weekly digest email (no push equivalent)."));
|
||||
const UpdateNotificationPrefsRef = registry.register("UpdateNotificationPrefs", z.object({
|
||||
follow: z.boolean().optional(), comment: z.boolean().optional(), reply: z.boolean().optional(), reaction: z.boolean().optional(),
|
||||
rating: z.boolean().optional(), mention: z.boolean().optional(), leftoverExpiring: z.boolean().optional(), shoppingList: z.boolean().optional(),
|
||||
followEmail: z.boolean().optional(), commentEmail: z.boolean().optional(), replyEmail: z.boolean().optional(), reactionEmail: z.boolean().optional(),
|
||||
ratingEmail: z.boolean().optional(), mentionEmail: z.boolean().optional(), leftoverExpiringEmail: z.boolean().optional(), shoppingListEmail: z.boolean().optional(),
|
||||
weeklyDigestEmail: z.boolean().optional(),
|
||||
}));
|
||||
const FeaturePrefsRef = registry.register("FeaturePrefs", z.object({
|
||||
nutrition: z.boolean(), pantry: z.boolean(), mealPlan: z.boolean(),
|
||||
shoppingLists: z.boolean(), collections: z.boolean(), messages: z.boolean(),
|
||||
}).describe("Purely cosmetic — hides the feature from the user's own nav. Never restricts access to the underlying pages/routes."));
|
||||
const UpdateFeaturePrefsRef = registry.register("UpdateFeaturePrefs", z.object({
|
||||
nutrition: z.boolean().optional(), pantry: z.boolean().optional(), mealPlan: z.boolean().optional(),
|
||||
shoppingLists: z.boolean().optional(), collections: z.boolean().optional(), messages: z.boolean().optional(),
|
||||
}));
|
||||
|
||||
const NutritionGoalsRef2 = registry.register("NutritionGoalsMe", z.object({
|
||||
id: z.string(), userId: z.string(),
|
||||
caloriesKcal: z.number().int().nullable(), proteinG: z.number().int().nullable(),
|
||||
@@ -509,6 +524,8 @@ export function generateOpenApiSpec(): object {
|
||||
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/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 } } } } });
|
||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/feature-prefs", summary: "Set your feature visibility preferences", description: "Purely cosmetic (hides nav items) — does not restrict access to the underlying pages.", security, request: { body: { content: { "application/json": { schema: UpdateFeaturePrefsRef } }, 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/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC)") }) }, responses: { 200: { description: "Diary", content: { "application/json": { schema: NutritionDiaryRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, 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 } } } } });
|
||||
@@ -600,6 +617,26 @@ export function generateOpenApiSpec(): object {
|
||||
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 } } } } });
|
||||
|
||||
const AdminWebhookEventEnum = z.enum(["user.signed_up", "support_ticket.created", "report.filed"]);
|
||||
const AdminWebhookRef = registry.register("AdminWebhook", z.object({
|
||||
id: z.string(), url: z.string(), events: z.array(AdminWebhookEventEnum), active: z.boolean(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
const CreateAdminWebhookRef = registry.register("CreateAdminWebhook", z.object({
|
||||
url: z.string().min(1).max(2048), events: z.array(AdminWebhookEventEnum).default([]),
|
||||
}));
|
||||
const CreatedAdminWebhookRef = registry.register("CreatedAdminWebhook", z.object({
|
||||
id: z.string(), url: z.string(), events: z.array(AdminWebhookEventEnum),
|
||||
secret: z.string().describe("Signing secret — shown only in this response, never returned again."),
|
||||
active: z.boolean(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
const UpdateAdminWebhookRef = registry.register("UpdateAdminWebhook", z.object({
|
||||
url: z.string().min(1).max(2048).optional(), events: z.array(AdminWebhookEventEnum).optional(), active: z.boolean().optional(),
|
||||
}));
|
||||
const AdminWebhookDeliveryRef = registry.register("AdminWebhookDelivery", z.object({
|
||||
id: z.string(), webhookId: z.string(), event: z.string(), payload: z.record(z.string(), z.unknown()).nullable(),
|
||||
statusCode: z.number().int().nullable(), success: z.boolean(), attempts: z.number().int(), createdAt: z.string().datetime(),
|
||||
}));
|
||||
|
||||
// --- Support: bug reports, suggestions, and questions submitted in-app ---
|
||||
const SupportTicketTypeEnum = z.enum(["bug", "suggestion", "question"]);
|
||||
const SupportTicketStatusEnum = z.enum(["open", "triaged", "closed"]);
|
||||
@@ -842,6 +879,13 @@ export function generateOpenApiSpec(): object {
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/support", summary: "List all support tickets, newest first", description: "Admin only.", security: adminSecurity, responses: { 200: { description: "Tickets", content: { "application/json": { schema: z.array(AdminSupportTicketRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/support/{id}", summary: "Update a support ticket's status, or retry opening its Gitea issue", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminSupportTicketRef } }, required: true } }, responses: { 200: { description: "Updated", 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: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/admin/webhooks", summary: "List site-wide ops webhooks", description: "Admin only. Site-wide events (new signups, support tickets, reports) — distinct from the per-user webhooks under /api/v1/webhooks. The signing secret is never included — it is only ever returned once, at creation time.", security: adminSecurity, responses: { 200: { description: "Webhooks", content: { "application/json": { schema: z.array(AdminWebhookRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/admin/webhooks", summary: "Create a site-wide ops webhook", description: "Admin only. Validates the URL against SSRF targets. Generates a signing secret returned once in this response only.", security: adminSecurity, request: { body: { content: { "application/json": { schema: CreateAdminWebhookRef } }, required: true } }, responses: { 201: { description: "Created — includes the signing secret (write-once)", content: { "application/json": { schema: CreatedAdminWebhookRef } } }, 400: { description: "Validation error or blocked URL", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/webhooks/{id}", summary: "Update a site-wide ops webhook's URL, events, or active state", description: "Admin only. Validates the URL against SSRF targets if changed.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: UpdateAdminWebhookRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: AdminWebhookRef } } }, 400: { description: "Validation error, blocked URL, or no fields to update", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", 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: "delete", path: "/api/v1/admin/webhooks/{id}", summary: "Delete a site-wide ops webhook", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 204: { description: "Deleted" }, 401: { description: "Unauthorized", 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/admin/webhooks/{id}/deliveries", summary: "List recent delivery attempts for a site-wide ops webhook (most recent 20)", description: "Admin only.", security: adminSecurity, request: { params: idParam }, responses: { 200: { description: "Deliveries, newest first", content: { "application/json": { schema: z.array(AdminWebhookDeliveryRef) } } }, 401: { description: "Unauthorized", 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: "post", path: "/api/v1/admin/webhooks/{id}/redeliver", summary: "Re-send a past delivery's payload for a site-wide ops webhook", description: "Admin only.", security: adminSecurity, 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 } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Webhook or delivery not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "patch", path: "/api/v1/admin/reports/{id}", summary: "Resolve a report (mark reviewed or dismissed)", description: "Admin only.", security: adminSecurity, request: { params: idParam, body: { content: { "application/json": { schema: ResolveReportBodyRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ report: ResolvedReportRef }) } } }, 400: { description: "Invalid status", 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: "put", path: "/api/v1/admin/settings", summary: "Update site settings (AI provider keys, VAPID keys, signups toggle)", description: "Admin only. Secret-flagged keys are encrypted at rest and never echoed back by any endpoint; this route only accepts new values, it does not return current ones.", security: adminSecurity, request: { body: { content: { "application/json": { schema: UpdateSiteSettingsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Forbidden", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import crypto from "crypto";
|
||||
import { safeFetch } from "@/lib/validate-webhook-url";
|
||||
import { decrypt } from "@/lib/encrypt";
|
||||
|
||||
// Secrets created before encryption-at-rest was added are a bare 64-char hex
|
||||
// string (crypto.randomBytes(32).toString("hex")) with no ":" separators —
|
||||
// encrypt()'s output is always "iv:authTag:ciphertext". Fall back to treating
|
||||
// the value as already-plaintext rather than a hard migration, since this is
|
||||
// only reached with a value this app itself generated (never user input).
|
||||
export function decryptWebhookSecret(stored: string): string {
|
||||
return stored.split(":").length === 3 ? decrypt(stored) : stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs and POSTs a single webhook payload. Shared by the per-user
|
||||
* (`lib/webhooks.ts`) and admin (`lib/admin-webhooks.ts`) dispatchers so the
|
||||
* signing/delivery mechanics — which are security-relevant — live in exactly
|
||||
* one place instead of two copies that could drift.
|
||||
*/
|
||||
export async function deliverWebhook(
|
||||
url: string,
|
||||
secret: string,
|
||||
event: string,
|
||||
payload: object
|
||||
): Promise<{ statusCode: number; success: boolean }> {
|
||||
const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() });
|
||||
const sig = crypto.createHmac("sha256", decryptWebhookSecret(secret)).update(body).digest("hex");
|
||||
|
||||
try {
|
||||
// safeFetch resolves and pins the connection to a single validated IP
|
||||
// (and re-validates + re-pins every redirect hop), so there's no
|
||||
// separate re-resolution for a rebinding attack to exploit.
|
||||
const res = await safeFetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Epicure-Signature": `sha256=${sig}`,
|
||||
"X-Epicure-Event": event,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
return { statusCode: res.status, success: res.ok };
|
||||
} catch {
|
||||
return { statusCode: 0, success: false };
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,7 @@ import crypto from "crypto";
|
||||
import { db } from "@epicure/db";
|
||||
import { webhooks, webhookDeliveries } from "@epicure/db";
|
||||
import { eq, and } from "@epicure/db";
|
||||
import { safeFetch } from "@/lib/validate-webhook-url";
|
||||
import { decrypt } from "@/lib/encrypt";
|
||||
|
||||
// Secrets created before encryption-at-rest was added are a bare 64-char hex
|
||||
// string (crypto.randomBytes(32).toString("hex")) with no ":" separators —
|
||||
// encrypt()'s output is always "iv:authTag:ciphertext". Fall back to treating
|
||||
// the value as already-plaintext rather than a hard migration, since this is
|
||||
// only reached with a value this app itself generated (never user input).
|
||||
function decryptWebhookSecret(stored: string): string {
|
||||
return stored.split(":").length === 3 ? decrypt(stored) : stored;
|
||||
}
|
||||
import { deliverWebhook } from "@/lib/webhook-delivery";
|
||||
|
||||
export const WEBHOOK_EVENTS = [
|
||||
"recipe.created",
|
||||
@@ -36,29 +26,7 @@ export async function dispatchWebhook(userId: string, event: WebhookEvent, paylo
|
||||
|
||||
await Promise.allSettled(
|
||||
filtered.map(async (hook) => {
|
||||
const body = JSON.stringify({ event, payload, timestamp: new Date().toISOString() });
|
||||
const sig = crypto.createHmac("sha256", decryptWebhookSecret(hook.secret)).update(body).digest("hex");
|
||||
let statusCode = 0;
|
||||
let success = false;
|
||||
try {
|
||||
// safeFetch resolves and pins the connection to a single validated IP
|
||||
// (and re-validates + re-pins every redirect hop), so there's no
|
||||
// separate re-resolution for a rebinding attack to exploit.
|
||||
const res = await safeFetch(hook.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Epicure-Signature": `sha256=${sig}`,
|
||||
"X-Epicure-Event": event,
|
||||
},
|
||||
body,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
statusCode = res.status;
|
||||
success = res.ok;
|
||||
} catch {
|
||||
// delivery failed; statusCode stays 0, success stays false
|
||||
}
|
||||
const { statusCode, success } = await deliverWebhook(hook.url, hook.secret, event, payload);
|
||||
await db.insert(webhookDeliveries).values({
|
||||
id: crypto.randomUUID(),
|
||||
webhookId: hook.id,
|
||||
|
||||
Reference in New Issue
Block a user