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:
Arnaud
2026-07-20 23:07:28 +02:00
parent cced962bff
commit c5e1643d39
40 changed files with 18383 additions and 79 deletions
+38
View File
@@ -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(),
});
})
);
}
+3
View File
@@ -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 });
},
},
},
+10 -1
View File
@@ -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",
+19
View File
@@ -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,
};
}
+25 -1
View File
@@ -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;
}
+2 -2
View File
@@ -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
View File
@@ -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 } } } } });
+47
View File
@@ -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 -34
View File
@@ -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,