diff --git a/nextjs_space/lib/db.ts b/nextjs_space/lib/db.ts
index cbb0512a..b8472910 100644
--- a/nextjs_space/lib/db.ts
+++ b/nextjs_space/lib/db.ts
@@ -81,6 +81,8 @@ const tenantScopedModelsWithNullAccess = new Set([
'email_event_mappings',
]);
+// Reads only — deleteMany used to sit here too, but every write action now
+// routes through the strict write branch below, so the sets say what they mean.
const tenantScopedReadActions = new Set([
'findMany',
'findFirst',
@@ -88,7 +90,6 @@ const tenantScopedReadActions = new Set([
'count',
'aggregate',
'groupBy',
- 'deleteMany',
]);
const tenantScopedWriteManyActions = new Set([
@@ -102,7 +103,10 @@ const tenantScopedCreateActions = new Set([
'upsert',
]);
-const applyTenantScope = (where: Record, tenantId: string, allowNull: boolean) => {
+// READ scope. `allowNull` widens the predicate so a tenant can also SEE shared
+// system rows (tenantId null) — which is why it wraps the caller's where in an
+// AND/OR instead of stamping a flat tenantId. Exported for tests.
+export const applyTenantScope = (where: Record, tenantId: string, allowNull: boolean) => {
if (allowNull) {
return {
AND: [
@@ -120,6 +124,19 @@ const applyTenantScope = (where: Record, tenantId: string, allowNul
};
};
+// WRITE scope — always strict, null-access models included. The OR-null read
+// widening exists so a tenant can SEE shared system rows, never write them.
+// It also cannot be expressed on update/delete: their `where` is a
+// WhereUniqueInput, which requires the unique field at the TOP level of the
+// object (the same constraint that rewrites findUnique to findFirst below), so
+// the AND/OR wrap is a PrismaClientValidationError there. Wrapping writes was
+// exactly the bug that 500'd every tenant email-template save, delete and
+// enable/disable toggle. Exported for tests.
+export const applyTenantWriteScope = (where: Record, tenantId: string) => ({
+ ...where,
+ tenantId,
+});
+
// Immutably inject the resolved tenantId into create / createMany / upsert
// payloads so every bound write is stamped with its tenant. Mirrors the create
// branch of the former tenant-scope $use, but returns NEW objects (never mutates
@@ -215,11 +232,15 @@ const createPrismaClient = (): any => {
if (tenantScopedCreateActions.has(action)) {
nextArgs = injectTenantIdIntoCreate(nextArgs, action, tenantId);
} else if (
- tenantScopedReadActions.has(action) ||
tenantScopedWriteManyActions.has(action) ||
action === 'update' ||
action === 'delete'
) {
+ nextArgs = {
+ ...nextArgs,
+ where: applyTenantWriteScope(nextArgs?.where ?? {}, tenantId),
+ };
+ } else if (tenantScopedReadActions.has(action)) {
nextArgs = {
...nextArgs,
where: applyTenantScope(
diff --git a/nextjs_space/lib/email/email-preview.ts b/nextjs_space/lib/email/email-preview.ts
index da75b356..d8462638 100644
--- a/nextjs_space/lib/email/email-preview.ts
+++ b/nextjs_space/lib/email/email-preview.ts
@@ -86,6 +86,15 @@ export interface RenderEmailPreviewInput {
readonly tenantId: string | null;
/** Live tenant name, so the preview reads like the real thing. */
readonly businessName?: string | null;
+ /**
+ * The origin of the admin request this preview is FOR. Images and the shell
+ * logo absolutise against it instead of the tenant's domain: the pane's
+ * srcdoc iframe inherits the admin page's CSP, whose img-src carries no
+ * tenant hosts, so a tenant-domain URL renders there as a broken image while
+ * being perfectly fetchable from a real inbox. The stored/mailed render
+ * never takes this path.
+ */
+ readonly baseUrlOverride?: string;
}
/**
@@ -99,12 +108,14 @@ export async function renderEmailPreview({
eventType,
tenantId,
businessName,
+ baseUrlOverride,
}: RenderEmailPreviewInput): Promise {
const stored = await resolveTemplateContent({
contentHtml,
contentJson,
tenantId,
category,
+ baseUrlOverride,
});
// `resolveTemplateContent` returns no HTML when the request carried neither
diff --git a/nextjs_space/lib/email/email-render-pipeline.ts b/nextjs_space/lib/email/email-render-pipeline.ts
index fb554fd2..ae8e434f 100644
--- a/nextjs_space/lib/email/email-render-pipeline.ts
+++ b/nextjs_space/lib/email/email-render-pipeline.ts
@@ -110,6 +110,13 @@ export interface RenderEmailTemplateOptions {
* before this story.
*/
readonly tracking?: { readonly tenantId: string } | null;
+ /**
+ * PREVIEW ONLY — absolutise images (and the shell logo) against this origin
+ * instead of the tenant's base URL, so the srcdoc preview iframe — which
+ * inherits the admin page's CSP — can actually load them. Never set when the
+ * result is stored or mailed: those must carry the tenant's own host.
+ */
+ readonly baseUrlOverride?: string;
}
/**
@@ -170,9 +177,10 @@ export async function renderEmailTemplateHtml({
category,
unsubscribeUrl,
tracking,
+ baseUrlOverride,
}: RenderEmailTemplateOptions): Promise {
const doc = parseEmailContentJson(contentJson);
- const baseUrl = tenant ? getTenantBaseUrl(tenant) : null;
+ const baseUrl = baseUrlOverride ?? (tenant ? getTenantBaseUrl(tenant) : null);
const trackingContext = resolveTrackingContext(tracking, tenant, baseUrl);
const normalised = normaliseEmailContentJson(doc, baseUrl);
@@ -188,7 +196,7 @@ export async function renderEmailTemplateHtml({
trackingContext ? trackingPixelHtml(trackingContext) : ""
}`,
tenant ?? SYSTEM_SHELL_TENANT,
- { category, unsubscribeUrl },
+ { category, unsubscribeUrl, baseUrlOverride },
);
// `applyHeightAttributes: false` — juice mirrors an inlined `height` onto the
diff --git a/nextjs_space/lib/email/email-shell.ts b/nextjs_space/lib/email/email-shell.ts
index 3d606640..652c004c 100644
--- a/nextjs_space/lib/email/email-shell.ts
+++ b/nextjs_space/lib/email/email-shell.ts
@@ -70,6 +70,15 @@ export interface RenderEmailBodyOptions {
* carries {@link UNSUBSCRIBE_URL_SLOT} instead.
*/
readonly unsubscribeUrl?: string | null;
+ /**
+ * PREVIEW ONLY — resolve the logo against this origin instead of the
+ * tenant's own base URL. The preview pane's iframe is `srcDoc`, so it
+ * inherits the ADMIN page's CSP, whose img-src carries no tenant domains —
+ * assets must resolve against the origin the author is actually on to load
+ * there. Never set on a send or save path: a mailed shell must carry the
+ * tenant's own host.
+ */
+ readonly baseUrlOverride?: string;
}
/** Postal columns, in the order they are joined into one footer line. */
@@ -125,7 +134,7 @@ export async function renderEmailBody(
tenant: EmailShellTenant,
options: RenderEmailBodyOptions = {},
): Promise {
- const baseUrl = getTenantBaseUrl(tenant);
+ const baseUrl = options.baseUrlOverride ?? getTenantBaseUrl(tenant);
const category = options.category ?? DEFAULT_EMAIL_CATEGORY;
return render(
diff --git a/nextjs_space/lib/email/email-template-content.ts b/nextjs_space/lib/email/email-template-content.ts
index e056db61..7f2c32f4 100644
--- a/nextjs_space/lib/email/email-template-content.ts
+++ b/nextjs_space/lib/email/email-template-content.ts
@@ -45,6 +45,8 @@ export interface TemplateContentInput {
* once for many tenants' events.
*/
readonly trackable?: boolean;
+ /** PREVIEW ONLY — see {@link RenderEmailTemplateOptions.baseUrlOverride}. */
+ readonly baseUrlOverride?: string;
}
/** Prisma `data` fragment — spread into a create/update. */
@@ -73,6 +75,7 @@ export async function resolveTemplateContent({
tenantId,
category,
trackable,
+ baseUrlOverride,
}: TemplateContentInput): Promise {
if (contentJson) {
return {
@@ -81,6 +84,7 @@ export async function resolveTemplateContent({
tenant: tenantId ? await requireEmailShellTenant(tenantId) : null,
category: emailCategoryOfTemplate(category),
tracking: trackable && tenantId ? { tenantId } : null,
+ baseUrlOverride,
}),
contentJson: toJsonColumnValue(contentJson),
};
diff --git a/nextjs_space/tests/unit/email-preview.test.ts b/nextjs_space/tests/unit/email-preview.test.ts
index 933c09a7..588f80a0 100644
--- a/nextjs_space/tests/unit/email-preview.test.ts
+++ b/nextjs_space/tests/unit/email-preview.test.ts
@@ -511,3 +511,47 @@ describe("POST super-admin email-templates/preview", () => {
expect(res.status).toBe(401);
});
});
+
+// The lekkerweed report (2026-08-18): uploaded images rendered fine in the
+// composer but broken in the preview pane. The pipeline absolutises image srcs
+// against the TENANT's domain, and the pane's srcdoc iframe inherits the admin
+// page's CSP — whose img-src carries no tenant hosts — so the browser blocked
+// exactly the URLs a real inbox loads happily. The routes now pass the
+// request's own origin as `baseUrlOverride`, making preview assets same-origin
+// with the admin page ('self') while stored/mailed HTML keeps the tenant host.
+describe("renderEmailPreview — baseUrlOverride", () => {
+ const UPLOADED_SRC =
+ "/api/public/images/development/tenants/tenant-a/uploads/1-banner.png";
+
+ const docWithImage = (): EmailContentJson => ({
+ type: "doc",
+ content: [
+ { type: "image", attrs: { src: UPLOADED_SRC } },
+ { type: "paragraph", content: [{ type: "text", text: "Hello" }] },
+ ],
+ });
+
+ it("absolutises an uploaded image against the admin origin, not the tenant domain", async () => {
+ prismaMock.tenants.findFirst.mockResolvedValue(TENANT_ROW);
+
+ const html = await renderEmailPreview({
+ contentJson: docWithImage(),
+ tenantId: TENANT_A,
+ baseUrlOverride: "https://app.budstacks.io",
+ });
+
+ expect(html).toContain(`https://app.budstacks.io${UPLOADED_SRC}`);
+ expect(html).not.toContain(`https://shop.example${UPLOADED_SRC}`);
+ });
+
+ it("keeps the tenant domain when no override is given (the save-path shape)", async () => {
+ prismaMock.tenants.findFirst.mockResolvedValue(TENANT_ROW);
+
+ const html = await renderEmailPreview({
+ contentJson: docWithImage(),
+ tenantId: TENANT_A,
+ });
+
+ expect(html).toContain(`https://shop.example${UPLOADED_SRC}`);
+ });
+});
diff --git a/nextjs_space/tests/unit/tenant-scope-write-scoping.test.ts b/nextjs_space/tests/unit/tenant-scope-write-scoping.test.ts
new file mode 100644
index 00000000..201d608a
--- /dev/null
+++ b/nextjs_space/tests/unit/tenant-scope-write-scoping.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect } from "vitest";
+
+import { applyTenantScope, applyTenantWriteScope } from "@/lib/db";
+
+// Regression for the lekkerweed report (2026-08-18): every tenant-admin email
+// template save, delete and enable/disable toggle 500'd, because the
+// tenant-scope extension wrapped update/delete `where`s of the null-access
+// models (email_templates, email_event_mappings) in
+// `{ AND: [where, { OR: [{tenantId}, {tenantId: null}] }] }`. Those operations
+// take a WhereUniqueInput, which Prisma generates as `AtLeast<{...}, "id">` —
+// the unique field must sit at the TOP LEVEL of the object — so the wrap was a
+// PrismaClientValidationError on every single call. The route tests never saw
+// it: they mock `@/lib/db` wholesale, so the extension never runs there.
+//
+// The invariants held here:
+// 1. WRITE scoping never wraps — the caller's unique field stays top-level
+// and the tenant stamp is a sibling, a shape WhereUniqueInput accepts.
+// 2. WRITE scoping never widens to tenantId-null rows — the OR-null read
+// widening exists so a tenant can SEE shared system rows, never write
+// them — and cannot be aimed at another tenant by the caller.
+// 3. READ scoping still widens for null-access models, unchanged.
+describe("applyTenantWriteScope", () => {
+ it("keeps the unique field at the top level (WhereUniqueInput shape)", () => {
+ const where = applyTenantWriteScope({ id: "template-1" }, "tenant-a");
+
+ expect(where).toEqual({ id: "template-1", tenantId: "tenant-a" });
+ expect(Object.keys(where)).not.toContain("AND");
+ expect(Object.keys(where)).not.toContain("OR");
+ });
+
+ it("never widens a write to tenantId-null system rows", () => {
+ const where = applyTenantWriteScope({ id: "template-1" }, "tenant-a");
+
+ expect(JSON.stringify(where)).not.toContain("null");
+ });
+
+ it("stamps the bound tenant over a caller-supplied tenantId", () => {
+ const where = applyTenantWriteScope(
+ { id: "template-1", tenantId: "tenant-b" },
+ "tenant-a",
+ );
+
+ expect(where.tenantId).toBe("tenant-a");
+ });
+
+ it("scopes a non-unique where (updateMany/deleteMany) the same strict way", () => {
+ const where = applyTenantWriteScope({ templateId: "template-1" }, "tenant-a");
+
+ expect(where).toEqual({ templateId: "template-1", tenantId: "tenant-a" });
+ });
+});
+
+describe("applyTenantScope (reads)", () => {
+ it("widens null-access reads so shared system rows stay visible", () => {
+ expect(applyTenantScope({ id: "template-1" }, "tenant-a", true)).toEqual({
+ AND: [
+ { id: "template-1" },
+ { OR: [{ tenantId: "tenant-a" }, { tenantId: null }] },
+ ],
+ });
+ });
+
+ it("stamps strict reads flat", () => {
+ expect(applyTenantScope({ id: "order-1" }, "tenant-a", false)).toEqual({
+ id: "order-1",
+ tenantId: "tenant-a",
+ });
+ });
+});