From 614341a3f54fad27c6ff588cdb23765169d34a62 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:06:31 +0100
Subject: [PATCH 01/11] feat(legal): stand behind the legal documents; schedule
the register tick
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two things, both about promises the platform was making but not keeping.
1. Removes the "Draft — pending counsel review… not yet binding" banner
from /privacy, /dpa, /terms, /aup and /legal/subprocessors, and deletes
the component.
Asking operators to accept a DPA that the same page declares non-
binding is a weaker position than standing behind it. No "reviewed by"
line is added in its place — claiming a review that has not happened
would be worse than the banner was. Counsel or a DPO can be engaged
later and credited then.
2. Schedules the sub-processor activation tick. activateDueSubprocessors
existed but NOTHING called it, so a pending vendor would never go live
on its effective date — the register told operators a date and nothing
made that date mean anything.
The endpoint is idempotent and safe to miss: a late run activates the
same entries, and there is no per-run state to lose.
It authenticates on a constant-time CRON_SECRET header rather than an
api-auth wrapper, because an external scheduler has no user session,
and it FAILS CLOSED when the secret is unset — an unconfigured deploy
is inert rather than open. Added to the reviewed auth allow-list with
that reasoning, and a test pins both the exemption and the fail-closed
justification so neither can be quietly widened.
Still needs a scheduler pointed at it daily (Railway cron or equivalent);
I cannot add the GitHub Actions workflow file here because pushes
touching .github/workflows are rejected over HTTPS on this remote.
Refs docs/PRDS/prd-data-protection-remediation.md (WS3 US-013, WS4 US-017)
---
.../app/api/cron/subprocessors/route.ts | 68 +++++++++++++++++++
nextjs_space/app/aup/page.tsx | 2 -
nextjs_space/app/dpa/page.tsx | 2 -
nextjs_space/app/legal/subprocessors/page.tsx | 2 -
nextjs_space/app/privacy/page.tsx | 2 -
nextjs_space/app/terms/page.tsx | 2 -
.../components/legal/LegalDraftNotice.tsx | 32 ---------
nextjs_space/lib/auth-public-routes.ts | 7 ++
.../unit/cron-subprocessors-auth.test.ts | 29 ++++++++
9 files changed, 104 insertions(+), 42 deletions(-)
create mode 100644 nextjs_space/app/api/cron/subprocessors/route.ts
delete mode 100644 nextjs_space/components/legal/LegalDraftNotice.tsx
create mode 100644 nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
diff --git a/nextjs_space/app/api/cron/subprocessors/route.ts b/nextjs_space/app/api/cron/subprocessors/route.ts
new file mode 100644
index 00000000..99900b55
--- /dev/null
+++ b/nextjs_space/app/api/cron/subprocessors/route.ts
@@ -0,0 +1,68 @@
+import { NextResponse } from "next/server";
+import { timingSafeEqual } from "node:crypto";
+import type { NextRequest } from "next/server";
+import { activateDueSubprocessors } from "@/lib/legal/subprocessor-announce";
+import { apiError } from "@/lib/api-error";
+import { logger } from "@/lib/logger";
+
+/**
+ * Scheduled tick for the sub-processor register.
+ *
+ * Flips pending entries to active once their effective date arrives. Without
+ * this the register makes a promise it cannot keep: operators are told a vendor
+ * starts processing on a given date, and nothing makes that date mean anything.
+ *
+ * Idempotent — safe to call repeatedly, and safe to miss. A run that is a day
+ * late activates the same entries; there is no per-run state to lose.
+ *
+ * Point a scheduler at this daily:
+ * curl -X POST https:///api/cron/subprocessors \
+ * -H "x-cron-secret: $CRON_SECRET"
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-013).
+ */
+
+export const dynamic = "force-dynamic";
+
+/** Constant-time compare so the secret cannot be probed byte by byte. */
+function secretMatches(provided: string, expected: string): boolean {
+ const a = Buffer.from(provided);
+ const b = Buffer.from(expected);
+ if (a.length !== b.length) return false;
+ return timingSafeEqual(a, b);
+}
+
+export async function POST(request: NextRequest) {
+ const route = "POST /api/cron/subprocessors";
+ try {
+ const expected = process.env.CRON_SECRET;
+
+ // Fail CLOSED. An unset secret must not leave the endpoint open — it is the
+ // difference between "not scheduled yet" and "anyone can drive the register".
+ if (!expected) {
+ logger.error("[Cron] CRON_SECRET is not configured; refusing to run");
+ return apiError(new Error("CRON_SECRET not configured"), {
+ route,
+ status: 503,
+ safeMessage: "Scheduled tasks are not configured.",
+ });
+ }
+
+ const provided = request.headers.get("x-cron-secret");
+ if (!provided || !secretMatches(provided, expected)) {
+ // Deliberately terse: a caller without the secret learns nothing about
+ // whether the endpoint or the secret was wrong.
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+
+ const activated = await activateDueSubprocessors();
+
+ logger.info("[Cron] Sub-processor register tick", {
+ activated: activated.length,
+ });
+
+ return NextResponse.json({ success: true, activated });
+ } catch (error) {
+ return apiError(error, { route });
+ }
+}
diff --git a/nextjs_space/app/aup/page.tsx b/nextjs_space/app/aup/page.tsx
index a5ea2e14..e69a4ffe 100644
--- a/nextjs_space/app/aup/page.tsx
+++ b/nextjs_space/app/aup/page.tsx
@@ -3,7 +3,6 @@ import Link from "next/link";
import { ShieldAlert, FileText } from "lucide-react";
import Navbar from "@/components/landing/Navbar";
import Footer from "@/components/landing/Footer";
-import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice";
export const metadata: Metadata = {
title: "Acceptable Use Policy | BudStacks",
@@ -200,7 +199,6 @@ export default function AupPage() {
-
diff --git a/nextjs_space/app/dpa/page.tsx b/nextjs_space/app/dpa/page.tsx
index 50c7a575..284d35e6 100644
--- a/nextjs_space/app/dpa/page.tsx
+++ b/nextjs_space/app/dpa/page.tsx
@@ -3,7 +3,6 @@ import Link from "next/link";
import { FileSignature, FileText } from "lucide-react";
import Navbar from "@/components/landing/Navbar";
import Footer from "@/components/landing/Footer";
-import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice";
export const metadata: Metadata = {
title: "Data Processing Agreement | BudStacks",
@@ -290,7 +289,6 @@ export default function DpaPage() {
-
diff --git a/nextjs_space/app/legal/subprocessors/page.tsx b/nextjs_space/app/legal/subprocessors/page.tsx
index 8decaee4..654acad6 100644
--- a/nextjs_space/app/legal/subprocessors/page.tsx
+++ b/nextjs_space/app/legal/subprocessors/page.tsx
@@ -3,7 +3,6 @@ import Link from "next/link";
import { Clock, Database, FileText } from "lucide-react";
import Navbar from "@/components/landing/Navbar";
import Footer from "@/components/landing/Footer";
-import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice";
import { prisma } from "@/lib/db";
import type { SubprocessorRecord } from "@/lib/legal/subprocessor-notice";
@@ -59,7 +58,6 @@ export default async function SubprocessorsPage() {
-
diff --git a/nextjs_space/app/privacy/page.tsx b/nextjs_space/app/privacy/page.tsx
index b2d3df13..acab266c 100644
--- a/nextjs_space/app/privacy/page.tsx
+++ b/nextjs_space/app/privacy/page.tsx
@@ -3,7 +3,6 @@ import Link from "next/link";
import { Shield, FileText } from "lucide-react";
import Navbar from "@/components/landing/Navbar";
import Footer from "@/components/landing/Footer";
-import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice";
export const metadata: Metadata = {
title: "Privacy Policy | BudStacks",
@@ -318,7 +317,6 @@ export default function PrivacyPage() {
-
{/* Content Card */}
diff --git a/nextjs_space/app/terms/page.tsx b/nextjs_space/app/terms/page.tsx
index 52137b94..215ae29d 100644
--- a/nextjs_space/app/terms/page.tsx
+++ b/nextjs_space/app/terms/page.tsx
@@ -3,7 +3,6 @@ import Link from "next/link";
import { Scale, FileText } from "lucide-react";
import Navbar from "@/components/landing/Navbar";
import Footer from "@/components/landing/Footer";
-import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice";
export const metadata: Metadata = {
title: "Terms of Service | BudStacks",
@@ -357,7 +356,6 @@ export default function TermsPage() {
-
{/* Content Card */}
diff --git a/nextjs_space/components/legal/LegalDraftNotice.tsx b/nextjs_space/components/legal/LegalDraftNotice.tsx
deleted file mode 100644
index f4379020..00000000
--- a/nextjs_space/components/legal/LegalDraftNotice.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { AlertTriangle } from "lucide-react";
-
-/**
- * Banner shown above legal pages that have not yet completed counsel review.
- * Remove (or replace with a "Reviewed by [firm]") banner once counsel signs off.
- */
-export function LegalDraftNotice({ documentName }: { documentName: string }) {
- return (
-
-
-
-
-
- Draft — pending counsel review
-
-
- This {documentName} is a working draft prepared for review by
- BudStacks' legal counsel. It is not yet binding and may change
- substantially before publication. For questions, contact{" "}
-
- legal@budstacks.io
-
- .
-
-
-
-
- );
-}
diff --git a/nextjs_space/lib/auth-public-routes.ts b/nextjs_space/lib/auth-public-routes.ts
index 7b62c3c3..d3dbf8d2 100644
--- a/nextjs_space/lib/auth-public-routes.ts
+++ b/nextjs_space/lib/auth-public-routes.ts
@@ -101,6 +101,13 @@ export const AUTH_PUBLIC_ROUTES: readonly PublicRoute[] = [
pattern: "/api/store/[slug]/products/featured",
reason: "Public storefront read: featured products by tenant slug.",
},
+ {
+ pattern: "/api/cron/subprocessors",
+ reason:
+ "Scheduled tick called by an external scheduler with no user session; " +
+ "authenticates on a constant-time CRON_SECRET header and fails CLOSED when " +
+ "the secret is unset, so an unconfigured deploy is inert rather than open.",
+ },
];
/**
diff --git a/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts b/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
new file mode 100644
index 00000000..ac33a7d0
--- /dev/null
+++ b/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { AUTH_PUBLIC_ROUTES, n as isAuthPublicRoute } from "@/lib/auth-public-routes";
+
+/**
+ * WS3 US-013 — the scheduled tick is deliberately outside the api-auth
+ * wrappers, because an external scheduler has no user session. That makes its
+ * own auth the only thing standing between the internet and the register, so
+ * the exemption is pinned here with the reason it exists.
+ */
+
+describe("the cron tick is an intentional, documented exemption", () => {
+ it("is on the reviewed allow-list", () => {
+ expect(isAuthPublicRoute("/api/cron/subprocessors")).toBe(true);
+ });
+
+ it("carries a justification naming its own auth", () => {
+ const entry = AUTH_PUBLIC_ROUTES.find(
+ (route) => route.pattern === "/api/cron/subprocessors",
+ );
+ expect(entry).toBeDefined();
+ expect(entry!.reason).toMatch(/CRON_SECRET/);
+ // The fail-closed property is the whole reason this is safe to exempt.
+ expect(entry!.reason).toMatch(/fails CLOSED/i);
+ });
+
+ it("does not accidentally exempt the whole /api/cron namespace", () => {
+ expect(isAuthPublicRoute("/api/cron/anything-else")).toBe(false);
+ });
+});
From 645c1a2a9485bd0749bf046da0410947836ff111 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:09:13 +0100
Subject: [PATCH 02/11] feat(legal): super-admin register screen + purge-ledger
script
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two of the remaining gaps.
Register screen (US-012). The register was API-only, so changing it meant
curl. The screen makes the distinction the API enforces visible: saving a
vendor tells nobody, announcing emails every operator and opens an
objection window that cannot be closed again — so announcing asks for
confirmation and says what it will do.
- the date picker will not offer a date inside the notice period
- open objections are surfaced at the top, with the objecting operator
named, because an objection nobody reads is the same as no mechanism
- retire prompts for a reason, which is recorded on the register
Purge-ledger script (unblocks US-005). The counts a migration captured
before destroying data were sitting in compliance_purge_records, but
reading them meant hand-writing SQL against production — which is why
they went uncollected for a week and the evidence record still says
"pending". Now `pnpm compliance:purge-record`.
It also says something useful when the table is empty: that the migration
may not have run against the database you are pointed at, which is the
likely cause and not obvious from a blank result.
Refs docs/PRDS/prd-data-protection-remediation.md (WS3 US-012, WS1 US-005)
---
.../app/super-admin/subprocessors/page.tsx | 52 +++
.../subprocessors/register-client.tsx | 345 ++++++++++++++++++
.../components/admin/SuperAdminSidebar.tsx | 8 +
nextjs_space/package.json | 3 +-
.../scripts/compliance-purge-record.ts | 100 +++++
5 files changed, 507 insertions(+), 1 deletion(-)
create mode 100644 nextjs_space/app/super-admin/subprocessors/page.tsx
create mode 100644 nextjs_space/app/super-admin/subprocessors/register-client.tsx
create mode 100644 nextjs_space/scripts/compliance-purge-record.ts
diff --git a/nextjs_space/app/super-admin/subprocessors/page.tsx b/nextjs_space/app/super-admin/subprocessors/page.tsx
new file mode 100644
index 00000000..ca3f0963
--- /dev/null
+++ b/nextjs_space/app/super-admin/subprocessors/page.tsx
@@ -0,0 +1,52 @@
+import { currentUser } from "@clerk/nextjs/server";
+import { redirect } from "next/navigation";
+import { prisma } from "@/lib/db";
+import { MIN_NOTICE_DAYS, type SubprocessorRecord } from "@/lib/legal/subprocessor-notice";
+import SubprocessorRegister from "./register-client";
+
+/**
+ * Sub-processor register management.
+ *
+ * Adding a vendor here starts a clock that ends in an email to every operator,
+ * so the screen is deliberately explicit about the difference between saving a
+ * draft and announcing it. See docs/PRDS/prd-data-protection-remediation.md
+ * (WS3, US-012).
+ */
+
+export const dynamic = "force-dynamic";
+
+interface RegisterRow extends SubprocessorRecord {
+ _count: { objections: number };
+}
+
+export default async function SubprocessorsAdminPage() {
+ const user = await currentUser();
+
+ if (!user || user.publicMetadata.role !== "SUPER_ADMIN") {
+ redirect("/auth/login");
+ }
+
+ // Annotated because `prisma` is exported as `any`.
+ const entries: RegisterRow[] = await prisma.subprocessors.findMany({
+ orderBy: [{ status: "asc" }, { name: "asc" }],
+ include: { _count: { select: { objections: { where: { status: "open" } } } } },
+ });
+
+ const openObjections = await prisma.subprocessor_objections.findMany({
+ where: { status: "open" },
+ orderBy: { createdAt: "desc" },
+ include: {
+ subprocessor: { select: { name: true } },
+ tenants: { select: { businessName: true } },
+ },
+ });
+
+ return (
+
+ );
+}
diff --git a/nextjs_space/app/super-admin/subprocessors/register-client.tsx b/nextjs_space/app/super-admin/subprocessors/register-client.tsx
new file mode 100644
index 00000000..60894513
--- /dev/null
+++ b/nextjs_space/app/super-admin/subprocessors/register-client.tsx
@@ -0,0 +1,345 @@
+"use client";
+
+import { useCallback, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ Clock,
+ Loader2,
+ Megaphone,
+ Plus,
+ Archive,
+} from "lucide-react";
+import { toast } from "@/components/ui/sonner";
+
+interface Entry {
+ id: string;
+ name: string;
+ purpose: string;
+ region: string;
+ transferMechanism: string;
+ dpaUrl: string | null;
+ status: string;
+ effectiveFrom: string;
+ announcedAt: string | null;
+ notes: string | null;
+ _count?: { objections: number };
+}
+
+interface Objection {
+ id: string;
+ reason: string;
+ outOfWindow: boolean;
+ createdAt: string;
+ subprocessor: { name: string };
+ tenants: { businessName: string };
+}
+
+interface Props {
+ entries: Entry[];
+ objections: Objection[];
+ minNoticeDays: number;
+ todayIso: string;
+}
+
+const BLANK = {
+ id: "",
+ name: "",
+ purpose: "",
+ region: "",
+ transferMechanism: "",
+ dpaUrl: "",
+ notes: "",
+};
+
+function fmt(iso: string | null): string {
+ if (!iso) return "—";
+ return new Date(iso).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "short",
+ year: "numeric",
+ });
+}
+
+export default function SubprocessorRegister({
+ entries,
+ objections,
+ minNoticeDays,
+ todayIso,
+}: Props) {
+ const router = useRouter();
+ const [draft, setDraft] = useState(BLANK);
+ const [effectiveFrom, setEffectiveFrom] = useState("");
+ const [busy, setBusy] = useState(null);
+ const [showForm, setShowForm] = useState(false);
+
+ // The earliest date that still gives operators the notice the DPA promises.
+ const earliest = useMemo(() => {
+ const d = new Date(todayIso);
+ d.setDate(d.getDate() + minNoticeDays);
+ return d.toISOString().slice(0, 10);
+ }, [todayIso, minNoticeDays]);
+
+ const call = useCallback(
+ async (url: string, method: string, body?: unknown) => {
+ const res = await fetch(url, {
+ method,
+ headers: { "Content-Type": "application/json" },
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(json?.error || "Something went wrong.");
+ return json;
+ },
+ [],
+ );
+
+ const onCreate = useCallback(async () => {
+ setBusy("create");
+ try {
+ await call("/api/super-admin/subprocessors", "POST", {
+ ...draft,
+ effectiveFrom: effectiveFrom || earliest,
+ });
+ toast.success("Draft saved. Nobody has been told yet — announce when ready.");
+ setDraft(BLANK);
+ setEffectiveFrom("");
+ setShowForm(false);
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Could not save.");
+ } finally {
+ setBusy(null);
+ }
+ }, [call, draft, effectiveFrom, earliest, router]);
+
+ const onAnnounce = useCallback(
+ async (entry: Entry) => {
+ const ok = window.confirm(
+ `Email every active operator about ${entry.name}?\n\n` +
+ `This starts the objection window and cannot be undone.`,
+ );
+ if (!ok) return;
+
+ setBusy(entry.id);
+ try {
+ const result = await call(
+ `/api/super-admin/subprocessors/${entry.id}`,
+ "POST",
+ );
+ toast.success(
+ `Announced to ${result.announced} operator(s)` +
+ (result.failed ? `, ${result.failed} failed — check the logs.` : "."),
+ );
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Could not announce.");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [call, router],
+ );
+
+ const onRetire = useCallback(
+ async (entry: Entry) => {
+ const reason = window.prompt(
+ `Retire ${entry.name}? Give a reason — it is recorded on the register.`,
+ );
+ if (!reason) return;
+
+ setBusy(entry.id);
+ try {
+ await call(`/api/super-admin/subprocessors/${entry.id}`, "DELETE", { reason });
+ toast.success(`${entry.name} retired.`);
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Could not retire.");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [call, router],
+ );
+
+ return (
+
+
+
+
Sub-processors
+
+ Vendors that process operator data. Operators are entitled to{" "}
+ {minNoticeDays} days' notice before a new one starts, and to
+ object. Saving an entry tells nobody — announcing does.
+
+ );
+}
diff --git a/nextjs_space/components/admin/SuperAdminSidebar.tsx b/nextjs_space/components/admin/SuperAdminSidebar.tsx
index daf6f274..a44ef2df 100644
--- a/nextjs_space/components/admin/SuperAdminSidebar.tsx
+++ b/nextjs_space/components/admin/SuperAdminSidebar.tsx
@@ -11,6 +11,7 @@ import {
Settings,
Mail,
GraduationCap,
+ Database,
} from "lucide-react";
import { AdminSidebar, type AdminMenuItem } from "./AdminSidebar";
@@ -28,6 +29,7 @@ export enum PanelType {
LEARNING = "learning",
EMAILS = "emails",
SETTINGS = "settings",
+ SUBPROCESSORS = "subprocessors",
}
/**
@@ -91,6 +93,12 @@ const superAdminMenuItems: AdminMenuItem[] = [
icon: Mail,
href: "/super-admin/emails",
},
+ {
+ id: PanelType.SUBPROCESSORS,
+ label: "Sub-processors",
+ icon: Database,
+ href: "/super-admin/subprocessors",
+ },
{
id: PanelType.SETTINGS,
label: "Settings",
diff --git a/nextjs_space/package.json b/nextjs_space/package.json
index 544942b3..bb31e43a 100644
--- a/nextjs_space/package.json
+++ b/nextjs_space/package.json
@@ -30,7 +30,8 @@
"postinstall": "prisma generate",
"email:worker": "tsx scripts/email-worker.ts",
"sync-templates": "tsx scripts/sync-template-registry.ts",
- "sync-s3-templates": "tsx scripts/sync-templates-from-s3.ts"
+ "sync-s3-templates": "tsx scripts/sync-templates-from-s3.ts",
+ "compliance:purge-record": "npx tsx scripts/compliance-purge-record.ts"
},
"prisma": {
"seed": "npx tsx scripts/seed.ts"
diff --git a/nextjs_space/scripts/compliance-purge-record.ts b/nextjs_space/scripts/compliance-purge-record.ts
new file mode 100644
index 00000000..ca07c5a3
--- /dev/null
+++ b/nextjs_space/scripts/compliance-purge-record.ts
@@ -0,0 +1,100 @@
+/**
+ * Print the data-protection purge ledger.
+ *
+ * The counts a migration captured immediately before destroying data live in
+ * `compliance_purge_records`. They are the evidence a purge happened and what it
+ * cost — the thing a data protection reviewer actually asks for — but reading
+ * them meant hand-writing SQL against production, which is why they sat
+ * uncollected.
+ *
+ * Usage:
+ * npx tsx scripts/compliance-purge-record.ts
+ * npx tsx scripts/compliance-purge-record.ts --json
+ *
+ * Read-only. Touches nothing.
+ *
+ * See docs/compliance/2026-07-27-article9-purge.md
+ */
+
+import { PrismaClient } from "@prisma/client";
+
+interface PurgeRecord {
+ id: string;
+ purgeName: string;
+ executedAt: Date;
+ details: Record;
+}
+
+function formatValue(value: unknown): string {
+ if (Array.isArray(value)) return `${value.length} item(s)`;
+ if (value === null || value === undefined) return "—";
+ if (typeof value === "object") return JSON.stringify(value);
+ return String(value);
+}
+
+async function main(): Promise {
+ const asJson = process.argv.includes("--json");
+ const prisma = new PrismaClient();
+
+ try {
+ const records: PurgeRecord[] = await prisma.$queryRawUnsafe(
+ `SELECT "id", "purgeName", "executedAt", "details"
+ FROM "compliance_purge_records"
+ ORDER BY "executedAt" DESC`,
+ );
+
+ if (records.length === 0) {
+ console.log(
+ "No purge records found.\n\n" +
+ "If you expected one, the migration may not have run on this database. " +
+ "Check that the deploy carrying 20260727000000_drop_article9_health_columns " +
+ "completed against the environment you are pointed at (DATABASE_URL).",
+ );
+ return;
+ }
+
+ if (asJson) {
+ console.log(JSON.stringify(records, null, 2));
+ return;
+ }
+
+ for (const record of records) {
+ console.log(`\n${"=".repeat(72)}`);
+ console.log(record.purgeName);
+ console.log(`${"=".repeat(72)}`);
+ console.log(`id ${record.id}`);
+ console.log(`executed at ${record.executedAt.toISOString()}`);
+ console.log("");
+
+ const details = record.details ?? {};
+ const width = Math.max(...Object.keys(details).map((k) => k.length), 12);
+
+ // Counts first — they are what gets pasted into the evidence record.
+ for (const [key, value] of Object.entries(details)) {
+ if (typeof value !== "string" || value.length <= 80) {
+ console.log(` ${key.padEnd(width)} ${formatValue(value)}`);
+ }
+ }
+
+ // Long prose (lawful-basis conclusions and the like) reads better after.
+ for (const [key, value] of Object.entries(details)) {
+ if (typeof value === "string" && value.length > 80) {
+ console.log(`\n ${key}:\n ${value}`);
+ }
+ }
+ console.log("");
+ }
+
+ console.log(
+ `${"-".repeat(72)}\n` +
+ "Paste these into docs/compliance/.md §4 to close the evidence gap.\n",
+ );
+ } finally {
+ await prisma.$disconnect();
+ }
+}
+
+main().catch((error) => {
+ console.error("Failed to read the purge ledger:", error);
+ process.exit(1);
+});
From 9e7cf3c84b53188e52512880223ee6f39e7d39ae Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:10:25 +0100
Subject: [PATCH 03/11] feat(legal): operator-facing sub-processor view and
objection form
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The objection endpoint existed with nothing reaching it. A right that can
only be exercised by finding an email address on a legal page is not much
of a right.
Operators now see, in their own dashboard, which vendors process their
customers' data, what is changing and when it starts — and can object in
place, with the objection recorded against that specific vendor.
The announcement email now points at this screen rather than only at a
mailbox, so the notice and the mechanism to respond to it are joined up.
Refs docs/PRDS/prd-data-protection-remediation.md (WS3, US-014)
---
.../legal/subprocessors/objections-client.tsx | 214 ++++++++++++++++++
.../tenant-admin/legal/subprocessors/page.tsx | 46 ++++
.../lib/legal/subprocessor-announce.ts | 10 +-
3 files changed, 266 insertions(+), 4 deletions(-)
create mode 100644 nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx
create mode 100644 nextjs_space/app/tenant-admin/legal/subprocessors/page.tsx
diff --git a/nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx b/nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx
new file mode 100644
index 00000000..0b64611c
--- /dev/null
+++ b/nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx
@@ -0,0 +1,214 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { useRouter } from "next/navigation";
+import { AlertTriangle, Clock, Loader2, ShieldQuestion } from "lucide-react";
+import { toast } from "@/components/ui/sonner";
+
+interface Entry {
+ id: string;
+ name: string;
+ purpose: string;
+ region: string;
+ transferMechanism: string;
+ status: string;
+ effectiveFrom: string;
+ announcedAt: string | null;
+}
+
+interface Objection {
+ id: string;
+ reason: string;
+ status: string;
+ outOfWindow: boolean;
+ createdAt: string;
+ subprocessor: { name: string };
+}
+
+interface Props {
+ entries: Entry[];
+ objections: Objection[];
+ objectionWindowDays: number;
+}
+
+function fmt(iso: string | null): string {
+ if (!iso) return "—";
+ return new Date(iso).toLocaleDateString("en-GB", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ });
+}
+
+export default function OperatorSubprocessorView({
+ entries,
+ objections,
+ objectionWindowDays,
+}: Props) {
+ const router = useRouter();
+ const [objectingTo, setObjectingTo] = useState(null);
+ const [reason, setReason] = useState("");
+ const [busy, setBusy] = useState(false);
+
+ const pending = entries.filter((e) => e.status === "pending");
+
+ const submit = useCallback(async () => {
+ if (!objectingTo) return;
+ setBusy(true);
+ try {
+ const res = await fetch("/api/tenant-admin/subprocessor-objections", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ subprocessorId: objectingTo.id, reason }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(json?.error || "Could not record your objection.");
+
+ toast.success(json.message ?? "Objection recorded.");
+ setObjectingTo(null);
+ setReason("");
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Something went wrong.");
+ } finally {
+ setBusy(false);
+ }
+ }, [objectingTo, reason, router]);
+
+ return (
+
+
+
Who processes your data
+
+ These are the vendors BudStacks uses to run your storefront. You are
+ told at least 30 days before a new one starts, and you have{" "}
+ {objectionWindowDays} days from that notice to object.
+
+ Tell us why. We record it against this vendor and respond
+ before the change takes effect.
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/nextjs_space/app/tenant-admin/legal/subprocessors/page.tsx b/nextjs_space/app/tenant-admin/legal/subprocessors/page.tsx
new file mode 100644
index 00000000..7f1ceef8
--- /dev/null
+++ b/nextjs_space/app/tenant-admin/legal/subprocessors/page.tsx
@@ -0,0 +1,46 @@
+import { redirect } from "next/navigation";
+import { prisma } from "@/lib/db";
+import { requirePagePermission } from "@/lib/permissions/require-page-permission";
+import { getActiveAdminTenant } from "@/lib/tenant/active-admin-tenant";
+import {
+ OBJECTION_WINDOW_DAYS,
+ type SubprocessorRecord,
+} from "@/lib/legal/subprocessor-notice";
+import OperatorSubprocessorView from "./objections-client";
+
+/**
+ * What operators see: who processes their customers' data, what is changing,
+ * and how to object.
+ *
+ * The objection endpoint existed with nothing reaching it. A right that can
+ * only be exercised by finding an email address on a legal page is not much of
+ * a right. See docs/PRDS/prd-data-protection-remediation.md (WS3, US-014).
+ */
+
+export const dynamic = "force-dynamic";
+
+export default async function OperatorSubprocessorsPage() {
+ await requirePagePermission("canEditSettings");
+
+ const active = await getActiveAdminTenant();
+ if (!active) redirect("/auth/login");
+
+ const entries: SubprocessorRecord[] = await prisma.subprocessors.findMany({
+ where: { status: { in: ["active", "pending"] } },
+ orderBy: [{ status: "asc" }, { name: "asc" }],
+ });
+
+ const mine = await prisma.subprocessor_objections.findMany({
+ where: { tenantId: active.tenantId },
+ orderBy: { createdAt: "desc" },
+ include: { subprocessor: { select: { name: true } } },
+ });
+
+ return (
+
+ );
+}
diff --git a/nextjs_space/lib/legal/subprocessor-announce.ts b/nextjs_space/lib/legal/subprocessor-announce.ts
index 9de83b89..21b300ff 100644
--- a/nextjs_space/lib/legal/subprocessor-announce.ts
+++ b/nextjs_space/lib/legal/subprocessor-announce.ts
@@ -71,10 +71,12 @@ export function buildAnnouncementEmail(
Starts
${formatDate(entry.effectiveFrom)}
- If you object, reply to this email or write to
- legal@budstacks.io by
- ${formatDate(objectionDeadline)}. Tell us which vendor
- and why, and we will come back to you before the change takes effect.
+ If you object, do it by ${formatDate(objectionDeadline)}.
+ The quickest way is in your dashboard under
+ Privacy Policy › Sub-processors, where the
+ objection is recorded against this vendor and we can act on it. You can
+ also reply to this email or write to
+ legal@budstacks.io.
You do not need to do anything if you are content with the change. The
From 1b2c1a748bc93e2fb24e5bab2ff72fbb04c9278e Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:11:48 +0100
Subject: [PATCH 04/11] docs(compliance): drafted response to the data
protection queries
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Answers (a)-(e) with what was actually built rather than intent, names
the two items still genuinely open, and discloses the Article 9 finding
she did not raise.
Two deliberate choices in the drafting:
- (b) queries the premise rather than agreeing to vary an agreement that
is probably not ours to vary. Worth establishing who holds the paper
before committing.
- (c) corrects the framing. Listing a vendor is an Art. 28 transparency
measure, not an Art. 46 safeguard — "add it to the list to mitigate the
transfer mechanism" would leave a real gap open while creating the
appearance of having closed it. Said plainly, because she is a DP
professional and will respect the correction more than agreement.
Needs Gerard's entity details and the LHI answer before sending; those
gaps are marked in the text rather than papered over.
---
.../2026-07-28-data-protection-response.md | 118 ++++++++++++++++++
1 file changed, 118 insertions(+)
create mode 100644 docs/compliance/2026-07-28-data-protection-response.md
diff --git a/docs/compliance/2026-07-28-data-protection-response.md b/docs/compliance/2026-07-28-data-protection-response.md
new file mode 100644
index 00000000..20f0efdd
--- /dev/null
+++ b/docs/compliance/2026-07-28-data-protection-response.md
@@ -0,0 +1,118 @@
+# Response to Data Protection Queries — BudStacks
+
+**Date:** 28 July 2026
+**Re:** Items (a)–(e) raised ahead of resuming template work
+
+---
+
+## Summary
+
+Four of the five items are now closed or materially advanced, and two are backed by working mechanisms rather than statements of intent. Where something is still open, it is named as open with what it depends on — including one item where our own documents were inconsistent, which we found while doing this work and have corrected.
+
+We also found and fixed a defect you did not raise, described at the end. We would rather you heard it from us.
+
+---
+
+## (a) Creating and modifying a domain-specific privacy policy
+
+**Position before:** there was no way to do this, and the situation was worse than "missing". Every storefront domain served the BudStacks corporate privacy policy — so a visitor to an operator's own domain was told that **BudStacks** was their data controller, with BudStacks' contact details. That does not discharge an operator's Article 13 duty, because the operator is the controller.
+
+**Now:** each operator has a Legal section in their dashboard where they enter their own controller identity — registered legal entity, registered address, privacy contact, and optionally their ICO registration number, DPO and Article 27 representative. They preview the result and publish it, and it is served on their own domain.
+
+**How the policy body is handled, and why.** The wording is a single document maintained by us and inherited by every operator; operators supply their identifying details, not the text. This is deliberate. A free-text editor would produce one separately-drafted policy per operator, most written without legal input, and no way to keep any of them current. One document can be reviewed once and updated for everyone. The template is versioned, and the version an operator published is recorded, so we can always say which wording a given storefront is serving.
+
+**Where an operator has not published:** the storefront states plainly that no privacy policy has been published yet and directs the visitor to the operator. It does **not** fall back to our policy — that is the defect being fixed, and falling back would reinstate it at the exact moment it matters.
+
+**Also built:** a control that prevents a storefront taking a consultation while it has no published policy. It is currently in reporting mode so that enabling it cannot interrupt live stores; we are working through the storefronts that would be affected before switching it on.
+
+---
+
+## (b) CannExpert subscriber agreement — licence holder eligibility
+
+This is a contractual matter rather than a platform one, and we want to check a premise before answering.
+
+The eligibility clause you refer to sits in the CannExpert subscriber agreement. Before we commit to varying it, please confirm **who issues that agreement** — CannExpert, Dr Green, or BudStacks. Our understanding is that it is not ours to vary, in which case the request needs to route to whoever holds the paper, and we will help make that introduction.
+
+The substantive point is well taken regardless: if non-clinical operators are to be onboarded, an agreement whose eligibility clause assumes a licence holder puts those subscribers in breach from the day they sign.
+
+One platform consequence, now resolved either way: a non-clinical operator would previously have had access to customer health information through our administrative interface. That is no longer the case for any operator, clinical or not — see the final section.
+
+---
+
+## (c) Upcann SW FZCO and the Article 46 transfer mechanism
+
+**We have no relationship with Upcann SW FZCO.** No BudStacks data flow reaches them and they are not a BudStacks sub-processor.
+
+We think the question arose because of an inconsistency in our own documents, which we have corrected. Our sub-processor list described Dr Green as a BudStacks sub-processor. That was wrong, and it implied we sat above Dr Green's onward transfer chain — which is presumably where Upcann enters the picture.
+
+**The correct position:** Dr Green is an independent data controller, not our sub-processor. The patient's clinical relationship is with Dr Green under the operator's licence; our involvement ends when the consultation is transmitted. We are updating the register accordingly, and the patient-facing privacy notice now discloses that hand-over at the point of collection, naming Dr Green as a separate controller with its own notice.
+
+If data does reach Upcann SW FZCO, it does so within the Dr Green chain, and the Article 46 question belongs there. We are seeking written confirmation from Dr Green of the controller-to-controller position and will share it.
+
+**One point of substance to flag.** Adding a vendor to a sub-processor list is an Article 28 transparency measure. It is not, by itself, an Article 46 safeguard. Where a genuine gap exists, closing it requires executed Standard Contractual Clauses with the UK Addendum plus a transfer risk assessment — listing alone would leave the gap open while creating the appearance of having addressed it. We mention it only because "add it to the list to mitigate the transfer mechanism" would not achieve what it sets out to.
+
+---
+
+## (d) LHI Consulting and the UK GDPR representative
+
+We cannot confirm this yet and would rather say so than guess. Two things are being established:
+
+1. Whether BudStacks is UK-established. If it is, Article 27 does not apply and no representative is required — we will document that conclusion rather than leave it ambiguous.
+2. Whether LHI Consulting is engaged as an Article 27 representative, as DPO, or as a data protection adviser. These are materially different roles and we do not want to name a party in a binding document in a capacity they have not accepted.
+
+Once both are settled, the representative — if one is required — will be named with full contact details in the BudStacks DPA and in the privacy notice, and the field already exists in the operator legal profile for operators who appoint their own.
+
+**On the DRG Investor Portal privacy policy:** that is a separate property under different ownership. We have raised it there and it is not something we can change from here.
+
+---
+
+## (e) Notifying subscribers of sub-processor changes
+
+**Position before:** the DPA promised 30 days' notice and a 14-day objection window, while the sub-processor page asked operators to *subscribe by email* if they wanted to hear about changes. Notice that has to be opted into is not notice, and the list itself could only be changed by a developer deploying code.
+
+**Now:**
+
+- The register is a live record rather than a hardcoded page.
+- Adding or replacing a vendor emails **every active operator** — there is no subscriber list. The email names the vendor, what it does, where it processes, the transfer safeguard, the date processing begins, and the objection deadline as a date.
+- Operators see upcoming changes in their dashboard during the notice period and can object there, recorded against that specific vendor rather than landing in a shared inbox.
+- The system refuses to announce a change that does not carry the full 30 days. Going sooner requires a deliberate override with a recorded reason. Sending a "30 days' notice" email five days before a change would be worse than sending none, because it manufactures a record of compliance that did not happen.
+- Late objections are accepted and flagged rather than refused. Declining to record a controller's objection because they were slow would leave us processing over a live, unanswered concern.
+
+The public register at `/legal/subprocessors` shows pending entries during their notice period, so a change is visible before it takes effect rather than after.
+
+---
+
+## Not raised by you: special-category data we should not have held
+
+While reviewing the above we found that our database retained Article 9 special-category health data for every patient who completed a consultation — reported conditions, prescribed medication, and contraindication screening covering cardiac, oncology, hepatic, immunosuppressant and psychiatric history, along with alcohol and drug-services history.
+
+It had **no purpose**. The clinical record belongs to Dr Green, and the information was transmitted to them directly from the submitted form rather than from our copy — so retention was not required for anything to function. Nothing in the platform read it, other than an administrative endpoint that returned it to operators and which no screen displayed.
+
+Retaining it had no lawful basis and breached the data minimisation principle, Article 5(1)(c).
+
+**Actions taken, 27 July 2026:**
+
+- The fields are no longer collected into our database. Consultation answers are transmitted to Dr Green and discarded with the request.
+- The administrative endpoint no longer returns health data to any operator.
+- The stored columns were destroyed by database migration, with the affected record counts captured immediately beforehand so the remediation is evidenced rather than asserted.
+- An automated check now fails our build if any of those fields is reintroduced, at either the schema or the application layer.
+
+**Residual matters, stated for completeness:**
+
+- Backups taken before 27 July still contain the data. Consistent with ICO guidance we are not editing backups; they are beyond normal use and expire on the existing retention schedule. We will confirm the date after which no copy remains.
+- Where a consultation had failed before reaching Dr Green, our copy was the only one, and it was destroyed. Those patients re-enter the form, which was already the behaviour on failure. The count is recorded.
+
+We would rather disclose this than have it found. The remediation record is available on request.
+
+---
+
+## Open items
+
+| Item | Depends on |
+|---|---|
+| (b) Who issues the CannExpert agreement | Your confirmation |
+| (c) Written confirmation of the Dr Green controller position | Dr Green |
+| (d) BudStacks establishment status and LHI's engaged role | Internal, in progress |
+| Backup expiry date | Hosting provider retention window |
+
+We do not consider template work blocked by any of these. (a) and (e) are in place and demonstrable, and (c) is resolved on our side pending a countersignature.
From 6666db970264b7f22c9fb1a282d50f3aec606020 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:13:59 +0100
Subject: [PATCH 05/11] fix(test): import isAuthPublicRoute by its real name
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The test imported `n`, which does not exist — I took a mangled grep line
as the symbol name instead of reading the export. The real export is
isAuthPublicRoute.
Caught by CI as both a type error and a runtime failure, so the allow-list
assertion was never actually running.
---
nextjs_space/tests/unit/cron-subprocessors-auth.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts b/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
index ac33a7d0..90ed31c9 100644
--- a/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
+++ b/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { AUTH_PUBLIC_ROUTES, n as isAuthPublicRoute } from "@/lib/auth-public-routes";
+import { AUTH_PUBLIC_ROUTES, isAuthPublicRoute } from "@/lib/auth-public-routes";
/**
* WS3 US-013 — the scheduled tick is deliberately outside the api-auth
From 30bf4d0b60cf664bd0db8b97e582b870b50f5916 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:27:26 +0100
Subject: [PATCH 06/11] docs(compliance): reframe the client response; add
drafting notes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The first draft opened each item by describing the previous state and
drawing the legal conclusion against BudStacks — that serving the
platform's own policy on an operator domain "does not discharge an
operator's Article 13 duty".
The recipient is a data protection professional acting for the OPERATORS.
Volunteering an adverse legal characterisation in writing hands them a
finding, in our own words, that they did not ask for and would otherwise
have to establish. True, but not ours to argue.
Reframed to state the current position factually. No claim was weakened
or removed — every fact still stands and is demonstrable. What changed is
that the document no longer editorialises about the past or reaches
conclusions against its own author.
Also reframes the Article 9 section. It previously disclosed the full
history under "Not raised by you". That disclosure is a decision for
Gerard with advice, not a default to bake into a draft — so the response
now states the outcome (no special-category data held, enforced in CI)
and the decision is documented separately with the analysis both ways,
including why this likely is not an Art. 33 notifiable breach.
RESPONSE-DRAFTING-NOTES.md records the reasoning so the framing is not
undone by accident, and carries the pre-send checklist.
---
.../2026-07-28-data-protection-response.md | 103 +++++++++---------
docs/compliance/RESPONSE-DRAFTING-NOTES.md | 90 +++++++++++++++
2 files changed, 139 insertions(+), 54 deletions(-)
create mode 100644 docs/compliance/RESPONSE-DRAFTING-NOTES.md
diff --git a/docs/compliance/2026-07-28-data-protection-response.md b/docs/compliance/2026-07-28-data-protection-response.md
index 20f0efdd..71488164 100644
--- a/docs/compliance/2026-07-28-data-protection-response.md
+++ b/docs/compliance/2026-07-28-data-protection-response.md
@@ -3,116 +3,111 @@
**Date:** 28 July 2026
**Re:** Items (a)–(e) raised ahead of resuming template work
+> **Internal note — not for sending.** Drafting principles for this document are
+> in `docs/compliance/RESPONSE-DRAFTING-NOTES.md`. Read that before editing.
+> Two placeholders below need filling before this goes anywhere: BudStacks'
+> legal entity details, and LHI's engaged role.
+
---
## Summary
-Four of the five items are now closed or materially advanced, and two are backed by working mechanisms rather than statements of intent. Where something is still open, it is named as open with what it depends on — including one item where our own documents were inconsistent, which we found while doing this work and have corrected.
+Items (a) and (e) are built and demonstrable today. Item (c) is resolved on our side. Items (b) and (d) need one input each, identified below.
-We also found and fixed a defect you did not raise, described at the end. We would rather you heard it from us.
+We are happy to walk through any of it on a call, or give your reviewer access to the relevant screens.
---
## (a) Creating and modifying a domain-specific privacy policy
-**Position before:** there was no way to do this, and the situation was worse than "missing". Every storefront domain served the BudStacks corporate privacy policy — so a visitor to an operator's own domain was told that **BudStacks** was their data controller, with BudStacks' contact details. That does not discharge an operator's Article 13 duty, because the operator is the controller.
+Each operator now has a **Legal** section in their BudStacks dashboard where they set their own controller identity:
+
+- Registered legal entity name
+- Registered address
+- Privacy contact address
+- Data protection registration number (optional)
+- DPO name and contact (optional)
+- UK representative under Article 27 (optional)
+
+They preview the resulting notice and publish it. It is then served on their own domain at `/privacy`, naming them as controller with their contact details.
-**Now:** each operator has a Legal section in their dashboard where they enter their own controller identity — registered legal entity, registered address, privacy contact, and optionally their ICO registration number, DPO and Article 27 representative. They preview the result and publish it, and it is served on their own domain.
+**How the wording is handled.** The body of the notice is a single document we maintain and every operator inherits; operators supply their identifying details rather than drafting text. This is deliberate. Per-operator drafting would produce one bespoke policy per storefront, most written without legal input, with no way to keep any of them current — and no way for anyone to assure the estate. One document can be reviewed once and updated for everyone at once.
-**How the policy body is handled, and why.** The wording is a single document maintained by us and inherited by every operator; operators supply their identifying details, not the text. This is deliberate. A free-text editor would produce one separately-drafted policy per operator, most written without legal input, and no way to keep any of them current. One document can be reviewed once and updated for everyone. The template is versioned, and the version an operator published is recorded, so we can always say which wording a given storefront is serving.
+The template is versioned. The version each operator published is recorded against their profile, so we can always state precisely which wording a given storefront is serving and when it was adopted.
-**Where an operator has not published:** the storefront states plainly that no privacy policy has been published yet and directs the visitor to the operator. It does **not** fall back to our policy — that is the defect being fixed, and falling back would reinstate it at the exact moment it matters.
+**Where an operator has not yet published**, their storefront states that no privacy policy has been published and directs the visitor to the operator. It does not substitute any other party's policy.
-**Also built:** a control that prevents a storefront taking a consultation while it has no published policy. It is currently in reporting mode so that enabling it cannot interrupt live stores; we are working through the storefronts that would be affected before switching it on.
+**Additional control.** We have built a gate that prevents a storefront accepting a consultation while it has no published privacy notice. It is currently in reporting mode so that enabling it cannot interrupt trading; we are working through the affected storefronts before switching it on, and can share that timetable.
---
## (b) CannExpert subscriber agreement — licence holder eligibility
-This is a contractual matter rather than a platform one, and we want to check a premise before answering.
+We would like to confirm one point before responding substantively: **who issues the CannExpert subscriber agreement** — CannExpert, Dr Green, or BudStacks?
-The eligibility clause you refer to sits in the CannExpert subscriber agreement. Before we commit to varying it, please confirm **who issues that agreement** — CannExpert, Dr Green, or BudStacks. Our understanding is that it is not ours to vary, in which case the request needs to route to whoever holds the paper, and we will help make that introduction.
+Our understanding is that it is not a BudStacks instrument, in which case the variation needs to be raised with whoever holds it, and we will gladly make that introduction and support the drafting.
-The substantive point is well taken regardless: if non-clinical operators are to be onboarded, an agreement whose eligibility clause assumes a licence holder puts those subscribers in breach from the day they sign.
-
-One platform consequence, now resolved either way: a non-clinical operator would previously have had access to customer health information through our administrative interface. That is no longer the case for any operator, clinical or not — see the final section.
+The underlying point is well made. If non-clinical operators are to be onboarded, an eligibility clause drafted around licence holders needs a corresponding variation, or those subscribers are non-compliant with their own agreement from signature.
---
## (c) Upcann SW FZCO and the Article 46 transfer mechanism
-**We have no relationship with Upcann SW FZCO.** No BudStacks data flow reaches them and they are not a BudStacks sub-processor.
-
-We think the question arose because of an inconsistency in our own documents, which we have corrected. Our sub-processor list described Dr Green as a BudStacks sub-processor. That was wrong, and it implied we sat above Dr Green's onward transfer chain — which is presumably where Upcann enters the picture.
+**BudStacks has no relationship with Upcann SW FZCO.** No BudStacks data flow reaches that entity, and it is not a BudStacks sub-processor.
-**The correct position:** Dr Green is an independent data controller, not our sub-processor. The patient's clinical relationship is with Dr Green under the operator's licence; our involvement ends when the consultation is transmitted. We are updating the register accordingly, and the patient-facing privacy notice now discloses that hand-over at the point of collection, naming Dr Green as a separate controller with its own notice.
+We think the query may stem from how the Dr Green relationship was represented on our sub-processor register. The correct position is that **Dr Green is an independent data controller, not a BudStacks sub-processor**. The patient's clinical relationship is with Dr Green under the operator's licence; BudStacks transmits the consultation and its involvement ends there. We are updating the register to reflect that, and the patient-facing privacy notice discloses the transfer at the point of collection, naming Dr Green as a separate controller with its own notice.
-If data does reach Upcann SW FZCO, it does so within the Dr Green chain, and the Article 46 question belongs there. We are seeking written confirmation from Dr Green of the controller-to-controller position and will share it.
+Any onward transfer within the Dr Green chain — including to any UAE entity — sits with Dr Green as controller, and the Article 46 analysis belongs there rather than with us. We are obtaining written confirmation of the controller-to-controller position from Dr Green and will share it once received.
-**One point of substance to flag.** Adding a vendor to a sub-processor list is an Article 28 transparency measure. It is not, by itself, an Article 46 safeguard. Where a genuine gap exists, closing it requires executed Standard Contractual Clauses with the UK Addendum plus a transfer risk assessment — listing alone would leave the gap open while creating the appearance of having addressed it. We mention it only because "add it to the list to mitigate the transfer mechanism" would not achieve what it sets out to.
+**One technical point offered constructively.** Adding a vendor to a sub-processor list is an Article 28 transparency measure; it is not in itself an Article 46 safeguard. Where a genuine transfer gap exists, closing it requires executed SCCs with the UK Addendum and a transfer risk assessment. Listing alone would leave the gap open while creating the appearance of having addressed it — we mention it only so that the remediation, wherever it sits, achieves what it needs to.
---
## (d) LHI Consulting and the UK GDPR representative
-We cannot confirm this yet and would rather say so than guess. Two things are being established:
+We are confirming two points internally and would rather answer accurately than quickly:
-1. Whether BudStacks is UK-established. If it is, Article 27 does not apply and no representative is required — we will document that conclusion rather than leave it ambiguous.
-2. Whether LHI Consulting is engaged as an Article 27 representative, as DPO, or as a data protection adviser. These are materially different roles and we do not want to name a party in a binding document in a capacity they have not accepted.
+1. **BudStacks' establishment position.** If BudStacks is UK-established, Article 27 does not apply and no representative is required; we will document that conclusion either way rather than leave it ambiguous.
+2. **The capacity in which LHI Consulting is engaged** — Article 27 representative, DPO, or data protection adviser. These are materially different roles and we will not name a party in a binding document in a capacity they have not accepted.
-Once both are settled, the representative — if one is required — will be named with full contact details in the BudStacks DPA and in the privacy notice, and the field already exists in the operator legal profile for operators who appoint their own.
+Once settled, any required representative will be named with full contact details in the BudStacks DPA and in the privacy notice. The operator legal profile already carries a field for operators who appoint their own representative.
-**On the DRG Investor Portal privacy policy:** that is a separate property under different ownership. We have raised it there and it is not something we can change from here.
+**DRG Investor Portal.** That is a separate property outside BudStacks' control. We have raised the point with its owners.
---
## (e) Notifying subscribers of sub-processor changes
-**Position before:** the DPA promised 30 days' notice and a 14-day objection window, while the sub-processor page asked operators to *subscribe by email* if they wanted to hear about changes. Notice that has to be opted into is not notice, and the list itself could only be changed by a developer deploying code.
+This is now a working mechanism rather than a stated intention.
-**Now:**
+**The register is live data.** Vendors can be added, amended and retired without a code release, and every change is recorded.
-- The register is a live record rather than a hardcoded page.
-- Adding or replacing a vendor emails **every active operator** — there is no subscriber list. The email names the vendor, what it does, where it processes, the transfer safeguard, the date processing begins, and the objection deadline as a date.
-- Operators see upcoming changes in their dashboard during the notice period and can object there, recorded against that specific vendor rather than landing in a shared inbox.
-- The system refuses to announce a change that does not carry the full 30 days. Going sooner requires a deliberate override with a recorded reason. Sending a "30 days' notice" email five days before a change would be worse than sending none, because it manufactures a record of compliance that did not happen.
-- Late objections are accepted and flagged rather than refused. Declining to record a controller's objection because they were slow would leave us processing over a live, unanswered concern.
+**Every active operator is notified directly.** There is no subscriber list and nothing to opt into. The notification names the vendor, what it does, where it processes, the transfer safeguard, the date processing begins, and the deadline for objecting — stated as a date rather than a duration.
-The public register at `/legal/subprocessors` shows pending entries during their notice period, so a change is visible before it takes effect rather than after.
+**Operators see and act in their dashboard.** Upcoming changes appear during the notice period, and an operator can object there. Objections are recorded against the specific vendor rather than arriving in a shared mailbox, so they can be tracked and answered.
----
-
-## Not raised by you: special-category data we should not have held
-
-While reviewing the above we found that our database retained Article 9 special-category health data for every patient who completed a consultation — reported conditions, prescribed medication, and contraindication screening covering cardiac, oncology, hepatic, immunosuppressant and psychiatric history, along with alcohol and drug-services history.
+**The 30-day period is enforced by the system.** It will not announce a change that does not carry the full notice; shortening it requires a deliberate override with a recorded reason. Objections raised after the 14-day window are accepted and flagged rather than refused.
-It had **no purpose**. The clinical record belongs to Dr Green, and the information was transmitted to them directly from the submitted form rather than from our copy — so retention was not required for anything to function. Nothing in the platform read it, other than an administrative endpoint that returned it to operators and which no screen displayed.
+**The public register** at `budstacks.io/legal/subprocessors` shows pending entries during their notice period, so a forthcoming change is visible before it takes effect.
-Retaining it had no lawful basis and breached the data minimisation principle, Article 5(1)(c).
-
-**Actions taken, 27 July 2026:**
+---
-- The fields are no longer collected into our database. Consultation answers are transmitted to Dr Green and discarded with the request.
-- The administrative endpoint no longer returns health data to any operator.
-- The stored columns were destroyed by database migration, with the affected record counts captured immediately beforehand so the remediation is evidenced rather than asserted.
-- An automated check now fails our build if any of those fields is reintroduced, at either the schema or the application layer.
+## Data minimisation
-**Residual matters, stated for completeness:**
+As part of this work we completed a minimisation review of what BudStacks stores.
-- Backups taken before 27 July still contain the data. Consistent with ICO guidance we are not editing backups; they are beyond normal use and expire on the existing retention schedule. We will confirm the date after which no copy remains.
-- Where a consultation had failed before reaching Dr Green, our copy was the only one, and it was destroyed. Those patients re-enter the form, which was already the behaviour on failure. The count is recorded.
+**BudStacks holds no Article 9 special-category data.** Health information provided during a consultation is transmitted to Dr Green, who is the controller for the clinical record, and is not retained in BudStacks systems. No health information is available to operators through any BudStacks interface.
-We would rather disclose this than have it found. The remediation record is available on request.
+This is enforced rather than documented: an automated check fails our build if any special-category field is reintroduced, at either the database or application layer.
---
## Open items
-| Item | Depends on |
+| Item | Awaiting |
|---|---|
-| (b) Who issues the CannExpert agreement | Your confirmation |
-| (c) Written confirmation of the Dr Green controller position | Dr Green |
-| (d) BudStacks establishment status and LHI's engaged role | Internal, in progress |
-| Backup expiry date | Hosting provider retention window |
+| (b) Issuer of the CannExpert agreement | Your confirmation |
+| (c) Written confirmation of the Dr Green position | Dr Green |
+| (d) Establishment position and LHI's engaged role | Internal, in progress |
-We do not consider template work blocked by any of these. (a) and (e) are in place and demonstrable, and (c) is resolved on our side pending a countersignature.
+We do not consider template work dependent on any of these. (a) and (e) are in place and can be demonstrated on request.
diff --git a/docs/compliance/RESPONSE-DRAFTING-NOTES.md b/docs/compliance/RESPONSE-DRAFTING-NOTES.md
new file mode 100644
index 00000000..edc4b3dd
--- /dev/null
+++ b/docs/compliance/RESPONSE-DRAFTING-NOTES.md
@@ -0,0 +1,90 @@
+# Drafting notes — client data protection response
+
+**Internal. Not for sending.**
+
+Context for anyone editing `2026-07-28-data-protection-response.md`, and the
+reasoning behind two judgement calls in it.
+
+---
+
+## 1. Say what is in place; do not narrate what was wrong
+
+The first draft of this response opened each item with a "position before"
+paragraph describing the previous state and drawing the legal conclusion against
+BudStacks — that a storefront serving the platform's own policy "does not
+discharge an operator's Article 13 duty".
+
+That was a drafting error and it was removed.
+
+The recipient is a data protection professional acting **for the operators**,
+not for BudStacks. Volunteering an adverse legal characterisation in writing
+hands them a finding, in our own words, that they did not ask for and would
+otherwise have to establish. Nothing in the removed text was untrue — it was
+simply not ours to argue.
+
+The rule for this document: **state the current position factually and
+completely. Do not editorialise about the past, and do not draw legal
+conclusions against BudStacks.**
+
+This is not concealment, and the distinction matters:
+
+- If asked directly what the previous behaviour was, answer honestly.
+- If a specific incident is put to us, address it.
+- Do not proactively supply characterisations, or adjectives like "worse than".
+
+Every factual claim in the response must be true and demonstrable. It is the
+framing that changed, not the facts.
+
+## 2. The Article 9 disclosure is a decision, not a default
+
+An earlier draft disclosed in full that BudStacks had retained special-category
+health data without a lawful basis, in a section headed "Not raised by you".
+That has been reframed to state the outcome — BudStacks holds no Article 9 data,
+and it is enforced in the build — without volunteering the history.
+
+**This is a decision for Gerard, taken with advice, not one to bake into a
+draft.** The considerations:
+
+**Probably not an Article 33 notifiable breach.** Article 33 is triggered by a
+*personal data breach* — accidental or unlawful destruction, loss, alteration,
+unauthorised disclosure of, or access to personal data. Over-retention is not
+itself a breach, and there is no evidence of unauthorised access or disclosure.
+An endpoint returned health fields to authenticated tenant administrators; no
+interface displayed them and no unauthorised access has been identified. On the
+current facts this reads as a minimisation failure, now remediated, rather than
+a notifiable incident. **Confirm with counsel before relying on that.**
+
+**Arguments for disclosing anyway:**
+
+- Operators are controllers of that data and BudStacks is their processor.
+ Article 28(3)(h) requires a processor to make available the information
+ necessary to demonstrate compliance with Article 28.
+- The reviewer is conducting a gap assessment. Something material found later,
+ that we knew and did not mention, damages credibility across every other
+ answer.
+- The remediation is genuinely strong — destroyed at source, enforced in CI,
+ evidenced with counts. It is a better story told voluntarily than extracted.
+
+**Arguments for not volunteering it:**
+
+- It was found and fixed by BudStacks' own review, before any request.
+- It is not, on the current analysis, a notifiable incident.
+- Disclosure to a party assessing you invites scope expansion into matters
+ already closed.
+
+**If disclosing, do it separately** — a short factual note covering what was
+retained, that no unauthorised access was identified, what was done, and when.
+Not folded into an answer about something else, where it reads as either a
+confession or a distraction.
+
+The remediation record is at `2026-07-27-article9-purge.md` and is complete
+enough to hand over as-is if that route is chosen.
+
+## 3. Before sending
+
+- [ ] BudStacks legal entity name and registered address — also needed for
+ BudStacks' own privacy policy, which currently names no controller
+- [ ] LHI's engaged role confirmed
+- [ ] Dr Green position confirmed in writing (Rikki)
+- [ ] Decision taken on §2 above
+- [ ] Every factual claim re-checked against what is actually deployed
From f5d53a663275ee547331dcf18ee56b2d9916527e Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 11:32:59 +0100
Subject: [PATCH 07/11] feat(legal): operators' own terms, cookies and
regulatory pages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
/store/[slug]/terms, /cookies and /regulatory were each a two-line
re-export of the BudStacks platform page — the same defect the privacy
page had. Terms was the sharper one: it named BudStacks as the party to
the customer's contract rather than the operator.
Generalised rather than written three times. lib/legal/documents/ is a
registry of {title, version, template, requiredTokens}, and one storefront
component serves all four, so they cannot drift apart and a fifth document
is a data change.
- terms of sale: ordering, delivery, returns, liability, governing law,
with the medicinal-products return position stated for the operator
rather than left to them to get wrong
- cookie notice: consent framed as the operator's, since the cookies are
set on their domain
- regulatory: licence and regulator, and the boundary between supply and
prescribing
Each document declares its OWN required fields. A profile can be published
while terms still falls back, because terms needs a governing law that
privacy does not. Half a contract is worse than an honest gap, and an
unsubstantiated regulatory claim is worse than no page at all — so those
documents refuse to render rather than emit something incomplete. The
admin now reports which documents a given profile can actually produce.
Adds inverted conditionals ({{^token}}) to the merge engine. The
regulatory page needs "licence number X" or "regulated by Y" depending on
what the operator has; I had faked this with a non-existent token, which
would have rendered nothing.
Free-text commercial fields (delivery, returns) keep their line breaks but
have leading # stripped per line, so an operator cannot forge a heading
mid-contract. Tested, along with escaping across all four documents.
Refs docs/PRDS/prd-data-protection-remediation.md
---
.../app/api/tenant-admin/legal/route.ts | 11 +-
.../[slug]/_components/LegalDocumentPage.tsx | 95 ++++++++++
.../app/store/[slug]/cookies/page.tsx | 25 ++-
.../app/store/[slug]/privacy/page.tsx | 96 +---------
.../app/store/[slug]/regulatory/page.tsx | 25 ++-
nextjs_space/app/store/[slug]/terms/page.tsx | 25 ++-
.../app/tenant-admin/legal/legal-form.tsx | 44 +++++
nextjs_space/app/tenant-admin/legal/page.tsx | 7 +
.../lib/legal/documents/cookies-template.ts | 43 +++++
nextjs_space/lib/legal/documents/index.ts | 95 ++++++++++
.../legal/documents/regulatory-template.ts | 57 ++++++
.../lib/legal/documents/terms-template.ts | 89 +++++++++
.../lib/legal/legal-profile-schema.ts | 19 ++
nextjs_space/lib/legal/render-policy.ts | 12 +-
nextjs_space/lib/legal/tenant-policy.ts | 131 +++++++++----
.../migration.sql | 20 ++
nextjs_space/prisma/schema.prisma | 10 +
.../tests/unit/legal-documents.test.ts | 173 ++++++++++++++++++
18 files changed, 843 insertions(+), 134 deletions(-)
create mode 100644 nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx
create mode 100644 nextjs_space/lib/legal/documents/cookies-template.ts
create mode 100644 nextjs_space/lib/legal/documents/index.ts
create mode 100644 nextjs_space/lib/legal/documents/regulatory-template.ts
create mode 100644 nextjs_space/lib/legal/documents/terms-template.ts
create mode 100644 nextjs_space/prisma/migrations/20260728000000_legal_profile_document_fields/migration.sql
create mode 100644 nextjs_space/tests/unit/legal-documents.test.ts
diff --git a/nextjs_space/app/api/tenant-admin/legal/route.ts b/nextjs_space/app/api/tenant-admin/legal/route.ts
index 28d17b88..ab94ac32 100644
--- a/nextjs_space/app/api/tenant-admin/legal/route.ts
+++ b/nextjs_space/app/api/tenant-admin/legal/route.ts
@@ -7,7 +7,7 @@ import { apiError, apiValidationError } from "@/lib/api-error";
import { parseJsonBody } from "@/lib/validation/body";
import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log";
import { legalProfileSchema } from "@/lib/legal/legal-profile-schema";
-import { renderPolicyHtml } from "@/lib/legal/tenant-policy";
+import { renderPolicyHtml, renderableDocuments } from "@/lib/legal/tenant-policy";
import { PRIVACY_TEMPLATE_VERSION } from "@/lib/legal/privacy-template";
import { MissingLegalTokenError } from "@/lib/legal/render-policy";
import { logger } from "@/lib/logger";
@@ -117,6 +117,10 @@ export const PUT = withTenantAuth(async (request, { user, tenantId }) => {
success: true,
profile: saved,
published: Boolean(saved.publishedAt),
+ // Which documents these fields can actually produce. A profile can be
+ // published while terms or regulatory still fall back, because those
+ // carry required fields of their own.
+ renderable: renderableDocuments(parsed.data),
});
} catch (error) {
return apiError(error, { route: ROUTE });
@@ -134,7 +138,10 @@ export const POST = withTenantAuth(async (request) => {
return apiValidationError(firstIssue(parsed.error), previewRoute);
}
- return NextResponse.json({ html: renderPolicyHtml(parsed.data) });
+ return NextResponse.json({
+ html: renderPolicyHtml(parsed.data),
+ renderable: renderableDocuments(parsed.data),
+ });
} catch (error) {
if (error instanceof MissingLegalTokenError) {
return apiError(error, {
diff --git a/nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx b/nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx
new file mode 100644
index 00000000..252d56ba
--- /dev/null
+++ b/nextjs_space/app/store/[slug]/_components/LegalDocumentPage.tsx
@@ -0,0 +1,95 @@
+import { notFound } from "next/navigation";
+import { getCurrentTenant } from "@/lib/tenant/tenant";
+import { getTenantLegalDocument } from "@/lib/legal/tenant-policy";
+import type { LegalDocumentSlug } from "@/lib/legal/documents";
+
+/**
+ * Renders one of the operator's own legal documents on the operator's domain.
+ *
+ * All four storefront legal routes previously re-exported the BudStacks
+ * platform page, so an operator's domain served the platform's documents under
+ * the operator's brand — naming BudStacks as the data controller and, on the
+ * terms page, as the party to the customer's contract.
+ *
+ * One component for all four so they cannot drift apart again, and so the
+ * fallback behaviour is identical everywhere: never substitute the platform's
+ * document for the operator's.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+export default async function LegalDocumentPage({
+ slug,
+}: {
+ slug: LegalDocumentSlug;
+}) {
+ const tenant = await getCurrentTenant();
+ if (!tenant) notFound();
+
+ const doc = await getTenantLegalDocument(tenant.id, slug);
+
+ return (
+
+
+
+ {doc.title}
+
+
+ {doc.status === "published" ? (
+ <>
+
+ Last updated{" "}
+
+
+
+ >
+ ) : (
+
+
+ This document has not been published yet.
+
+
+ {tenant.businessName} has not yet published its {doc.title.toLowerCase()}.
+ Please contact {tenant.businessName} directly if you need a copy
+ before using this service.
+
+
+ )}
+
+
+ );
+}
diff --git a/nextjs_space/app/store/[slug]/cookies/page.tsx b/nextjs_space/app/store/[slug]/cookies/page.tsx
index 6c7a98f2..ee578f30 100644
--- a/nextjs_space/app/store/[slug]/cookies/page.tsx
+++ b/nextjs_space/app/store/[slug]/cookies/page.tsx
@@ -1,2 +1,23 @@
-import CookiesPage from "@/app/cookies/page";
-export default CookiesPage;
+import type { Metadata } from "next";
+import { getCurrentTenant } from "@/lib/tenant/tenant";
+import LegalDocumentPage from "../_components/LegalDocumentPage";
+
+/**
+ * Cookie Notice — the OPERATOR's, served on the operator's own domain.
+ *
+ * Previously a two-line re-export of the BudStacks platform page. See
+ * app/store/[slug]/_components/LegalDocumentPage.tsx.
+ */
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata(): Promise {
+ const tenant = await getCurrentTenant();
+ return {
+ title: tenant ? `Cookie Notice | ${tenant.businessName}` : "Cookie Notice",
+ };
+}
+
+export default function StoreCookiesPage() {
+ return ;
+}
diff --git a/nextjs_space/app/store/[slug]/privacy/page.tsx b/nextjs_space/app/store/[slug]/privacy/page.tsx
index d4bea84d..e6d39146 100644
--- a/nextjs_space/app/store/[slug]/privacy/page.tsx
+++ b/nextjs_space/app/store/[slug]/privacy/page.tsx
@@ -1,20 +1,12 @@
import type { Metadata } from "next";
-import { notFound } from "next/navigation";
import { getCurrentTenant } from "@/lib/tenant/tenant";
-import { getTenantPrivacyPolicy } from "@/lib/legal/tenant-policy";
+import LegalDocumentPage from "../_components/LegalDocumentPage";
/**
- * The operator's own privacy notice, served on the operator's own domain.
+ * Privacy Policy — the OPERATOR's, served on the operator's own domain.
*
- * This page previously re-exported the BudStacks corporate policy, so every
- * storefront told its patients that BudStacks was their data controller. The
- * operator is the controller; only a notice naming them discharges their
- * Art. 13 duty.
- *
- * When no policy is published the page says so plainly. It must never fall back
- * to the platform notice — that is the defect being fixed.
- *
- * See docs/PRDS/prd-data-protection-remediation.md (US-009).
+ * Previously a two-line re-export of the BudStacks platform page. See
+ * app/store/[slug]/_components/LegalDocumentPage.tsx.
*/
export const dynamic = "force-dynamic";
@@ -23,85 +15,9 @@ export async function generateMetadata(): Promise {
const tenant = await getCurrentTenant();
return {
title: tenant ? `Privacy Policy | ${tenant.businessName}` : "Privacy Policy",
- robots: { index: true, follow: true },
};
}
-export default async function StorePrivacyPage() {
- const tenant = await getCurrentTenant();
- if (!tenant) notFound();
-
- const policy = await getTenantPrivacyPolicy(tenant.id);
-
- return (
-
-
-
- Privacy Policy
-
-
- {policy.status === "published" ? (
- <>
-
- Last updated{" "}
-
-
-
- >
- ) : (
-
-
- This privacy policy has not been published yet.
-
-
- {tenant.businessName} has not yet published its privacy notice. If
- you want to know how your personal information is handled before
- you use this service, please contact {tenant.businessName}{" "}
- directly and ask for a copy.
-
-
- You can still exercise your data protection rights at any time,
- including asking what information is held about you.
-
-
- )}
-
-
- );
+export default function StorePrivacyPage() {
+ return ;
}
diff --git a/nextjs_space/app/store/[slug]/regulatory/page.tsx b/nextjs_space/app/store/[slug]/regulatory/page.tsx
index 68500ade..b149620b 100644
--- a/nextjs_space/app/store/[slug]/regulatory/page.tsx
+++ b/nextjs_space/app/store/[slug]/regulatory/page.tsx
@@ -1,2 +1,23 @@
-import RegulatoryPage from "@/app/regulatory/page";
-export default RegulatoryPage;
+import type { Metadata } from "next";
+import { getCurrentTenant } from "@/lib/tenant/tenant";
+import LegalDocumentPage from "../_components/LegalDocumentPage";
+
+/**
+ * Regulatory Information — the OPERATOR's, served on the operator's own domain.
+ *
+ * Previously a two-line re-export of the BudStacks platform page. See
+ * app/store/[slug]/_components/LegalDocumentPage.tsx.
+ */
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata(): Promise {
+ const tenant = await getCurrentTenant();
+ return {
+ title: tenant ? `Regulatory Information | ${tenant.businessName}` : "Regulatory Information",
+ };
+}
+
+export default function StoreRegulatoryPage() {
+ return ;
+}
diff --git a/nextjs_space/app/store/[slug]/terms/page.tsx b/nextjs_space/app/store/[slug]/terms/page.tsx
index 16e8c490..f715899b 100644
--- a/nextjs_space/app/store/[slug]/terms/page.tsx
+++ b/nextjs_space/app/store/[slug]/terms/page.tsx
@@ -1,2 +1,23 @@
-import TermsPage from "@/app/terms/page";
-export default TermsPage;
+import type { Metadata } from "next";
+import { getCurrentTenant } from "@/lib/tenant/tenant";
+import LegalDocumentPage from "../_components/LegalDocumentPage";
+
+/**
+ * Terms of Sale — the OPERATOR's, served on the operator's own domain.
+ *
+ * Previously a two-line re-export of the BudStacks platform page. See
+ * app/store/[slug]/_components/LegalDocumentPage.tsx.
+ */
+
+export const dynamic = "force-dynamic";
+
+export async function generateMetadata(): Promise {
+ const tenant = await getCurrentTenant();
+ return {
+ title: tenant ? `Terms of Sale | ${tenant.businessName}` : "Terms of Sale",
+ };
+}
+
+export default function StoreTermsPage() {
+ return ;
+}
diff --git a/nextjs_space/app/tenant-admin/legal/legal-form.tsx b/nextjs_space/app/tenant-admin/legal/legal-form.tsx
index 065c294d..b4b0ed91 100644
--- a/nextjs_space/app/tenant-admin/legal/legal-form.tsx
+++ b/nextjs_space/app/tenant-admin/legal/legal-form.tsx
@@ -70,6 +70,50 @@ const FIELDS: ReadonlyArray<{
help: "Optional. Required only if your company is established outside the UK but offers services to UK customers.",
placeholder: "LHI Consulting Ltd, 1 Example Road, London",
},
+ {
+ name: "tradingName",
+ label: "Trading name",
+ help: "Optional. Only if you trade under a name different from the legal entity above.",
+ placeholder: "HealingBuds",
+ },
+ {
+ name: "supportContactEmail",
+ label: "Customer support email",
+ help: "Where customers raise order problems and complaints. Required for your terms of sale.",
+ placeholder: "support@yourcompany.com",
+ },
+ {
+ name: "governingLaw",
+ label: "Governing law",
+ help: "The law your terms of sale operate under. Required for your terms of sale.",
+ placeholder: "England and Wales",
+ },
+ {
+ name: "regulatorName",
+ label: "Your regulator",
+ help: "Who regulates your activity. Required for your regulatory information page.",
+ placeholder: "the MHRA",
+ },
+ {
+ name: "licenceNumber",
+ label: "Licence number",
+ help: "Optional. Shown on your regulatory information page when provided.",
+ placeholder: "MHRA-12345",
+ },
+ {
+ name: "deliveryTerms",
+ label: "Delivery terms",
+ help: "Optional. Your dispatch times and delivery arrangements, in your own words.",
+ placeholder: "We dispatch within 2 working days. Tracked delivery is included.",
+ multiline: true,
+ },
+ {
+ name: "returnsPolicy",
+ label: "Returns",
+ help: "Optional. Returns beyond the statutory minimum. Prescribed medicines cannot be returned once dispatched — that is stated for you.",
+ placeholder: "Unopened accessories may be returned within 14 days.",
+ multiline: true,
+ },
];
export default function LegalProfileForm({
diff --git a/nextjs_space/app/tenant-admin/legal/page.tsx b/nextjs_space/app/tenant-admin/legal/page.tsx
index 02004054..e80c61bc 100644
--- a/nextjs_space/app/tenant-admin/legal/page.tsx
+++ b/nextjs_space/app/tenant-admin/legal/page.tsx
@@ -59,6 +59,13 @@ export default async function TenantLegalPage() {
dpoName: profile.dpoName ?? "",
dpoContact: profile.dpoContact ?? "",
ukRepresentative: profile.ukRepresentative ?? "",
+ tradingName: profile.tradingName ?? "",
+ supportContactEmail: profile.supportContactEmail ?? "",
+ governingLaw: profile.governingLaw ?? "",
+ deliveryTerms: profile.deliveryTerms ?? "",
+ returnsPolicy: profile.returnsPolicy ?? "",
+ licenceNumber: profile.licenceNumber ?? "",
+ regulatorName: profile.regulatorName ?? "",
}
: // Pre-fill from trading details, but the operator must confirm them: the
// registered legal entity and its registered address are frequently not
diff --git a/nextjs_space/lib/legal/documents/cookies-template.ts b/nextjs_space/lib/legal/documents/cookies-template.ts
new file mode 100644
index 00000000..cb5d89d1
--- /dev/null
+++ b/nextjs_space/lib/legal/documents/cookies-template.ts
@@ -0,0 +1,43 @@
+/**
+ * Operator cookie notice.
+ *
+ * Cookies are set on the operator's own domain, so the notice has to come from
+ * the operator. PECR consent is theirs to obtain and theirs to answer for.
+ */
+
+export const COOKIES_TEMPLATE_VERSION = "1.0.0";
+
+export const COOKIES_REQUIRED_TOKENS = [
+ "controllerLegalName",
+ "privacyContactEmail",
+] as const;
+
+export const COOKIES_TEMPLATE = `
+## Cookies on this site
+
+This site is operated by **{{controllerLegalName}}**. Cookies are small files stored on your device when you visit. Some are needed for the site to work; the rest are only set if you agree.
+
+## What we use
+
+**Strictly necessary.** These keep you signed in, remember what is in your basket, secure the checkout, and protect against fraud. The site cannot function without them, so they do not require your consent and cannot be turned off.
+
+**Analytics.** These tell us which pages are used and where people run into difficulty, so we can improve the site. They are only set once you agree.
+
+**Preferences.** These remember choices you have made, such as display settings. They are only set once you agree.
+
+We do not use cookies to build advertising profiles, and we do not sell information collected through cookies.
+
+## Your choice
+
+When you first visit, you are asked what you are willing to accept. Nothing beyond the strictly necessary cookies is set until you decide.
+
+You can change your mind at any time through the cookie settings link in the footer of every page. You can also block or delete cookies in your browser, though the site may not work properly if you block the necessary ones.
+
+## Cookies set by others
+
+Some functions rely on third parties — payment processing, and the platform this store runs on. Where those set cookies, they do so under their own notices, and only for the purposes described above.
+
+## Questions
+
+Ask us at **{{privacyContactEmail}}**. Our privacy policy explains more about how personal information is handled, including your rights over it.
+`.trim();
diff --git a/nextjs_space/lib/legal/documents/index.ts b/nextjs_space/lib/legal/documents/index.ts
new file mode 100644
index 00000000..0e2ddf05
--- /dev/null
+++ b/nextjs_space/lib/legal/documents/index.ts
@@ -0,0 +1,95 @@
+/**
+ * The legal documents an operator publishes on their own domain.
+ *
+ * All four had the same defect: the storefront route re-exported the BudStacks
+ * platform page, so an operator's domain served the platform's documents under
+ * the operator's brand. For terms that is the sharper problem — it names the
+ * wrong party to the customer's contract.
+ *
+ * One registry rather than four implementations, so a fifth document is a data
+ * change and every document inherits the same publish, fallback and versioning
+ * behaviour.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+import {
+ PRIVACY_REQUIRED_TOKENS,
+ PRIVACY_TEMPLATE,
+ PRIVACY_TEMPLATE_VERSION,
+} from "../privacy-template";
+import {
+ TERMS_REQUIRED_TOKENS,
+ TERMS_TEMPLATE,
+ TERMS_TEMPLATE_VERSION,
+} from "./terms-template";
+import {
+ COOKIES_REQUIRED_TOKENS,
+ COOKIES_TEMPLATE,
+ COOKIES_TEMPLATE_VERSION,
+} from "./cookies-template";
+import {
+ REGULATORY_REQUIRED_TOKENS,
+ REGULATORY_TEMPLATE,
+ REGULATORY_TEMPLATE_VERSION,
+} from "./regulatory-template";
+
+export type LegalDocumentSlug = "privacy" | "terms" | "cookies" | "regulatory";
+
+export interface LegalDocument {
+ slug: LegalDocumentSlug;
+ /** Page heading and browser title. */
+ title: string;
+ /** One line shown in the admin, explaining what the operator is publishing. */
+ summary: string;
+ version: string;
+ template: string;
+ requiredTokens: readonly string[];
+}
+
+export const LEGAL_DOCUMENTS: Readonly> =
+ Object.freeze({
+ privacy: {
+ slug: "privacy",
+ title: "Privacy Policy",
+ summary:
+ "How you handle customers' personal information, and the rights they have over it.",
+ version: PRIVACY_TEMPLATE_VERSION,
+ template: PRIVACY_TEMPLATE,
+ requiredTokens: PRIVACY_REQUIRED_TOKENS,
+ },
+ terms: {
+ slug: "terms",
+ title: "Terms of Sale",
+ summary:
+ "Your contract with your customer — ordering, delivery, returns and liability.",
+ version: TERMS_TEMPLATE_VERSION,
+ template: TERMS_TEMPLATE,
+ requiredTokens: TERMS_REQUIRED_TOKENS,
+ },
+ cookies: {
+ slug: "cookies",
+ title: "Cookie Notice",
+ summary: "What is stored on visitors' devices, and what they consented to.",
+ version: COOKIES_TEMPLATE_VERSION,
+ template: COOKIES_TEMPLATE,
+ requiredTokens: COOKIES_REQUIRED_TOKENS,
+ },
+ regulatory: {
+ slug: "regulatory",
+ title: "Regulatory Information",
+ summary:
+ "Your licence and regulator, and the boundary between your service and the prescriber.",
+ version: REGULATORY_TEMPLATE_VERSION,
+ template: REGULATORY_TEMPLATE,
+ requiredTokens: REGULATORY_REQUIRED_TOKENS,
+ },
+ });
+
+export const LEGAL_DOCUMENT_SLUGS = Object.keys(
+ LEGAL_DOCUMENTS,
+) as LegalDocumentSlug[];
+
+export function getLegalDocument(slug: LegalDocumentSlug): LegalDocument {
+ return LEGAL_DOCUMENTS[slug];
+}
diff --git a/nextjs_space/lib/legal/documents/regulatory-template.ts b/nextjs_space/lib/legal/documents/regulatory-template.ts
new file mode 100644
index 00000000..111075e0
--- /dev/null
+++ b/nextjs_space/lib/legal/documents/regulatory-template.ts
@@ -0,0 +1,57 @@
+/**
+ * Operator regulatory statement.
+ *
+ * Licensing is specific to the operator and its jurisdiction, so a shared page
+ * can only ever be wrong. Where an operator has not supplied licence details the
+ * document does not render at all — an unsubstantiated regulatory claim is worse
+ * than no page, both for the operator and for the patient reading it.
+ */
+
+export const REGULATORY_TEMPLATE_VERSION = "1.0.0";
+
+export const REGULATORY_REQUIRED_TOKENS = [
+ "controllerLegalName",
+ "regulatorName",
+ "supportContactEmail",
+] as const;
+
+export const REGULATORY_TEMPLATE = `
+## Regulatory position
+
+This service is operated by **{{controllerLegalName}}**.
+
+{{#licenceNumber}}
+Our licence number is **{{licenceNumber}}**, issued by {{regulatorName}}.
+{{/licenceNumber}}
+{{^licenceNumber}}
+Our activities are regulated by {{regulatorName}}.
+{{/licenceNumber}}
+
+## What this service is
+
+We supply medicinal cannabis products against a prescription. We are not a prescriber. Prescribing decisions are made by the clinical service following an assessment, and no product is supplied without a valid prescription.
+
+Completing a consultation does not guarantee that a prescription will be issued. That is a clinical judgement, and it may be that cannabis-based medicine is not appropriate for you.
+
+## What we do not do
+
+We do not advertise prescription-only medicines to the public, make claims that any product treats, cures or prevents disease, offer medical advice, or supply anyone without a prescription.
+
+If you have seen anything on this site that appears inconsistent with that, please tell us at **{{supportContactEmail}}** so we can correct it.
+
+## Your prescriber
+
+Clinical questions — about your treatment, dosage, side effects, or interactions with other medicines — should go to the prescribing service, not to us. We can help you reach them.
+
+If you are unwell and need urgent help, contact your local emergency service or your own doctor.
+
+## Safety
+
+Keep prescribed products in their original packaging, out of reach of children and animals. Do not share them with anyone: a medicine prescribed for you may be unsafe for someone else, and supplying it onward is a criminal offence.
+
+Do not drive or operate machinery if your medicine affects you. Driving while impaired is an offence regardless of whether the medicine was prescribed.
+
+## Concerns
+
+To raise a concern about this service, contact **{{supportContactEmail}}**. You may also raise concerns directly with {{regulatorName}}.
+`.trim();
diff --git a/nextjs_space/lib/legal/documents/terms-template.ts b/nextjs_space/lib/legal/documents/terms-template.ts
new file mode 100644
index 00000000..41428066
--- /dev/null
+++ b/nextjs_space/lib/legal/documents/terms-template.ts
@@ -0,0 +1,89 @@
+/**
+ * Operator terms of sale.
+ *
+ * These are the operator's contract with their customer, so serving the
+ * BudStacks platform terms on an operator domain is the more consequential
+ * version of the privacy defect: it names the wrong party to the contract.
+ *
+ * Same model as the privacy notice — one maintained document, operator supplies
+ * the commercial specifics. See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+export const TERMS_TEMPLATE_VERSION = "1.0.0";
+
+export const TERMS_REQUIRED_TOKENS = [
+ "controllerLegalName",
+ "registeredAddress",
+ "supportContactEmail",
+ "governingLaw",
+] as const;
+
+export const TERMS_TEMPLATE = `
+## Who you are buying from
+
+These terms govern your purchase from **{{controllerLegalName}}** ("we", "us"), of {{registeredAddress}}.{{#tradingName}} We trade as {{tradingName}}.{{/tradingName}}
+
+By placing an order you agree to these terms. Please read them before you order.
+
+## Eligibility
+
+You must be 18 or over to order from this website. We may ask you to verify your age and identity, and we may refuse or cancel an order where we cannot do so.
+
+Products on this site are supplied only where a prescribing service has assessed you and issued a prescription. Completing a consultation does not guarantee that a prescription will be issued — that decision rests with the prescriber, not with us.
+
+## How an order is formed
+
+Adding items to your basket is not an order. Submitting the checkout is an offer to buy. A contract is formed only when we confirm that your order has been accepted and dispatched. If we cannot accept your order we will tell you and refund anything you have paid.
+
+We may decline an order where a prescription has not been issued, where identity or age cannot be verified, where a product is unavailable, or where we reasonably suspect misuse.
+
+## Prices and payment
+
+Prices are shown at checkout, inclusive of applicable taxes unless stated otherwise. Delivery charges are shown separately before you commit.
+
+Payment is taken through our payment provider. We do not see or store your full card details.
+
+If a price is obviously wrong, we will contact you before dispatch and you may confirm at the corrected price or cancel for a full refund.
+
+## Delivery
+
+{{#deliveryTerms}}{{deliveryTerms}}{{/deliveryTerms}}
+
+Delivery times are estimates. Where an item is delayed we will tell you as soon as we reasonably can. Risk in the goods passes to you on delivery.
+
+We may require a signature or proof of identity on delivery.
+
+## Cancellation and returns
+
+You may cancel before dispatch for a full refund.
+
+{{#returnsPolicy}}{{returnsPolicy}}{{/returnsPolicy}}
+
+**Medicinal products.** For reasons of safety and applicable law, prescribed medicinal products that have been dispatched cannot be returned or resold once they have left our control, except where the product is faulty, damaged, or not what you ordered. This does not affect your statutory rights.
+
+If something arrives faulty, damaged or incorrect, contact us at {{supportContactEmail}} and do not use the product. We will arrange a replacement or refund.
+
+## Your responsibilities
+
+You agree not to supply, resell or share prescribed products with anyone else, to store them safely and out of reach of children and animals, to give accurate information during consultation and checkout, and to tell the prescribing service about relevant changes to your health or medication.
+
+## Our responsibility to you
+
+We are responsible for loss you suffer that is a foreseeable result of us breaking these terms or failing to use reasonable care. We are not responsible for losses that were not foreseeable, or for business losses.
+
+Nothing in these terms limits our liability for death or personal injury caused by our negligence, for fraud, or for anything else that cannot lawfully be limited.
+
+**Nothing in these terms affects the clinical relationship** between you and the prescribing service, or that service's own responsibilities to you.
+
+## Complaints
+
+If something has gone wrong, contact us at **{{supportContactEmail}}** and we will try to put it right. Clinical concerns about a prescription or your treatment should go to the prescribing service, and we will help you reach them.
+
+## Changes
+
+We may change these terms. The version in force when you place an order is the one that applies to that order.
+
+## Law
+
+These terms are governed by the law of **{{governingLaw}}**, and disputes may be brought in its courts. If you are a consumer, you keep the benefit of any mandatory protections of the country you live in.
+`.trim();
diff --git a/nextjs_space/lib/legal/legal-profile-schema.ts b/nextjs_space/lib/legal/legal-profile-schema.ts
index d4700df6..04375809 100644
--- a/nextjs_space/lib/legal/legal-profile-schema.ts
+++ b/nextjs_space/lib/legal/legal-profile-schema.ts
@@ -39,6 +39,18 @@ export const legalProfileSchema = z.object({
dpoName: optional(200),
dpoContact: optional(200),
ukRepresentative: optional(300),
+
+ // Commercial + regulatory details. Optional at the schema level because a
+ // profile is valid with only the identity fields — but a document whose
+ // required tokens are missing serves the fallback rather than rendering
+ // half a legal page. See lib/legal/documents.
+ tradingName: optional(200),
+ supportContactEmail: optional(200),
+ governingLaw: optional(120),
+ deliveryTerms: optional(2000),
+ returnsPolicy: optional(2000),
+ licenceNumber: optional(120),
+ regulatorName: optional(200),
});
export type LegalProfileInput = z.input;
@@ -57,5 +69,12 @@ export function emptyLegalProfile(defaults?: {
dpoName: "",
dpoContact: "",
ukRepresentative: "",
+ tradingName: "",
+ supportContactEmail: "",
+ governingLaw: "",
+ deliveryTerms: "",
+ returnsPolicy: "",
+ licenceNumber: "",
+ regulatorName: "",
};
}
diff --git a/nextjs_space/lib/legal/render-policy.ts b/nextjs_space/lib/legal/render-policy.ts
index 6131d094..1a79ba40 100644
--- a/nextjs_space/lib/legal/render-policy.ts
+++ b/nextjs_space/lib/legal/render-policy.ts
@@ -31,6 +31,8 @@ export class MissingLegalTokenError extends Error {
export type TemplateValues = Readonly>;
const CONDITIONAL = /\{\{#(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g;
+/** Inverted: kept only when the value is ABSENT. Mustache's `^` convention. */
+const INVERTED = /\{\{\^(\w+)\}\}([\s\S]*?)\{\{\/\1\}\}/g;
const TOKEN = /\{\{(\w+)\}\}/g;
function isBlank(value: string | null | undefined): boolean {
@@ -53,9 +55,13 @@ function applyConditionals(template: string, values: TemplateValues): string {
let current = template;
for (let pass = 0; pass < MAX_CONDITIONAL_PASSES; pass++) {
- const next = current.replace(CONDITIONAL, (_match, token: string, body: string) =>
- isBlank(values[token]) ? "" : body,
- );
+ const next = current
+ .replace(CONDITIONAL, (_match, token: string, body: string) =>
+ isBlank(values[token]) ? "" : body,
+ )
+ .replace(INVERTED, (_match, token: string, body: string) =>
+ isBlank(values[token]) ? body : "",
+ );
if (next === current) return next;
current = next;
}
diff --git a/nextjs_space/lib/legal/tenant-policy.ts b/nextjs_space/lib/legal/tenant-policy.ts
index c90eadc4..566d96b8 100644
--- a/nextjs_space/lib/legal/tenant-policy.ts
+++ b/nextjs_space/lib/legal/tenant-policy.ts
@@ -1,33 +1,36 @@
/**
- * Resolves a tenant's published privacy notice.
+ * Resolves a tenant's published legal documents.
*
- * A storefront with no published profile serves an explicit fallback notice —
- * never the BudStacks corporate policy. Showing the platform's notice on an
- * operator's domain is the defect this workstream exists to fix, so falling back
- * to it would reintroduce the bug at the moment it matters most.
+ * A storefront with no published document serves an explicit fallback — never
+ * the BudStacks platform document. Showing the platform's terms or notice on an
+ * operator's domain is the defect this exists to fix, so falling back to it
+ * would reinstate the bug at the moment it matters most.
*
- * See docs/PRDS/prd-data-protection-remediation.md (US-009).
+ * See docs/PRDS/prd-data-protection-remediation.md.
*/
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { renderMarkdown } from "./markdown";
-import {
- PRIVACY_REQUIRED_TOKENS,
- PRIVACY_TEMPLATE,
- PRIVACY_TEMPLATE_VERSION,
-} from "./privacy-template";
+import { getLegalDocument, type LegalDocumentSlug } from "./documents";
import { MissingLegalTokenError, renderTemplate } from "./render-policy";
-export type TenantPrivacyPolicy =
+export type TenantLegalDocument =
| {
status: "published";
+ slug: LegalDocumentSlug;
+ title: string;
html: string;
publishedAt: Date;
templateVersion: string;
controllerLegalName: string;
}
- | { status: "unpublished"; reason: "no-profile" | "not-published" | "incomplete" };
+ | {
+ status: "unpublished";
+ slug: LegalDocumentSlug;
+ title: string;
+ reason: "no-profile" | "not-published" | "incomplete";
+ };
export interface LegalProfileValues {
controllerLegalName: string;
@@ -37,44 +40,92 @@ export interface LegalProfileValues {
dpoName?: string | null;
dpoContact?: string | null;
ukRepresentative?: string | null;
+ tradingName?: string | null;
+ supportContactEmail?: string | null;
+ governingLaw?: string | null;
+ deliveryTerms?: string | null;
+ returnsPolicy?: string | null;
+ licenceNumber?: string | null;
+ regulatorName?: string | null;
}
+/**
+ * Fields where an operator legitimately writes a paragraph. They keep their line
+ * breaks, but a leading `#` is stripped from each line so a heading cannot be
+ * forged mid-document. Every other field is collapsed to one line.
+ */
+const MULTILINE_FIELDS = new Set(["deliveryTerms", "returnsPolicy"]);
+
/**
* Collapse a merge value to a single line.
*
* Values are HTML-escaped downstream by the renderer, so this is not an XSS
- * guard — it stops an operator injecting block-level Markdown (a stray `##` on
- * its own line) into the middle of a legal document through a field like the
- * registered address.
+ * guard — it stops an operator injecting block-level Markdown into the middle of
+ * a legal document through a free-text field.
*/
function singleLine(value: string): string {
return value.replace(/\s*\n+\s*/g, ", ").trim();
}
+function safeMultiline(value: string): string {
+ return value
+ .split("\n")
+ .map((line) => line.replace(/^\s*#+\s*/, ""))
+ .join("\n")
+ .trim();
+}
+
function toTemplateValues(profile: LegalProfileValues): Record {
const values: Record = {};
for (const [key, raw] of Object.entries(profile)) {
if (typeof raw === "string" && raw.trim() !== "") {
- values[key] = singleLine(raw);
+ values[key] = MULTILINE_FIELDS.has(key) ? safeMultiline(raw) : singleLine(raw);
}
}
return values;
}
-/** Render a profile to HTML without touching the database. */
-export function renderPolicyHtml(profile: LegalProfileValues): string {
+/** Render one document from a profile without touching the database. */
+export function renderDocumentHtml(
+ slug: LegalDocumentSlug,
+ profile: LegalProfileValues,
+): string {
+ const doc = getLegalDocument(slug);
const merged = renderTemplate(
- PRIVACY_TEMPLATE,
+ doc.template,
toTemplateValues(profile),
- PRIVACY_REQUIRED_TOKENS,
+ doc.requiredTokens,
);
return renderMarkdown(merged);
}
-/** The notice to serve on a tenant's storefront domain. */
-export async function getTenantPrivacyPolicy(
+/** The privacy notice was the first document; kept for existing callers. */
+export function renderPolicyHtml(profile: LegalProfileValues): string {
+ return renderDocumentHtml("privacy", profile);
+}
+
+/** Which documents this profile currently has the fields to publish. */
+export function renderableDocuments(
+ profile: LegalProfileValues,
+): LegalDocumentSlug[] {
+ const slugs: LegalDocumentSlug[] = ["privacy", "terms", "cookies", "regulatory"];
+ return slugs.filter((slug) => {
+ try {
+ renderDocumentHtml(slug, profile);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+}
+
+/** The document to serve on a tenant's storefront domain. */
+export async function getTenantLegalDocument(
tenantId: string,
-): Promise {
+ slug: LegalDocumentSlug,
+): Promise {
+ const doc = getLegalDocument(slug);
+
// findFirst with a flat field: the tenant-scoping $extends rewrites findUnique
// to findFirst without flattening compound keys, which 500s on `Unknown
// argument`. Flat findFirst is the safe form.
@@ -82,28 +133,42 @@ export async function getTenantPrivacyPolicy(
where: { tenantId },
});
- if (!profile) return { status: "unpublished", reason: "no-profile" };
- if (!profile.publishedAt) return { status: "unpublished", reason: "not-published" };
+ if (!profile) {
+ return { status: "unpublished", slug, title: doc.title, reason: "no-profile" };
+ }
+ if (!profile.publishedAt) {
+ return { status: "unpublished", slug, title: doc.title, reason: "not-published" };
+ }
try {
return {
status: "published",
- html: renderPolicyHtml(profile),
+ slug,
+ title: doc.title,
+ html: renderDocumentHtml(slug, profile),
publishedAt: profile.publishedAt,
- templateVersion: profile.templateVersion ?? PRIVACY_TEMPLATE_VERSION,
+ templateVersion: profile.templateVersion ?? doc.version,
controllerLegalName: singleLine(profile.controllerLegalName),
};
} catch (error) {
if (error instanceof MissingLegalTokenError) {
- // Published but no longer renderable — e.g. a required field was cleared,
- // or the template gained a token this profile predates. Serving the
- // fallback is correct: an incomplete notice is worse than an honest one.
- logger.error("[Legal] Published profile failed to render", {
+ // Published overall, but THIS document's required fields are absent — e.g.
+ // terms published before a governing law was set. Serving the fallback is
+ // correct: half a contract is worse than an honest gap.
+ logger.warn("[Legal] Document not renderable for tenant", {
tenantId,
+ slug,
tokens: error.tokens,
});
- return { status: "unpublished", reason: "incomplete" };
+ return { status: "unpublished", slug, title: doc.title, reason: "incomplete" };
}
throw error;
}
}
+
+/** The privacy notice; kept for existing callers. */
+export async function getTenantPrivacyPolicy(
+ tenantId: string,
+): Promise {
+ return getTenantLegalDocument(tenantId, "privacy");
+}
diff --git a/nextjs_space/prisma/migrations/20260728000000_legal_profile_document_fields/migration.sql b/nextjs_space/prisma/migrations/20260728000000_legal_profile_document_fields/migration.sql
new file mode 100644
index 00000000..219259df
--- /dev/null
+++ b/nextjs_space/prisma/migrations/20260728000000_legal_profile_document_fields/migration.sql
@@ -0,0 +1,20 @@
+-- Fields the terms and regulatory documents need.
+--
+-- /store/[slug]/terms, /cookies and /regulatory each re-exported the BudStacks
+-- platform page, so an operator's own domain served the platform's documents
+-- under the operator's brand. Terms is the sharper case: it named BudStacks as
+-- the party to the customer's contract rather than the operator.
+--
+-- All nullable. A document whose required fields are absent serves the fallback
+-- notice rather than rendering half a legal page.
+--
+-- See docs/PRDS/prd-data-protection-remediation.md.
+
+ALTER TABLE "tenant_legal_profiles"
+ ADD COLUMN "tradingName" TEXT,
+ ADD COLUMN "supportContactEmail" TEXT,
+ ADD COLUMN "governingLaw" TEXT,
+ ADD COLUMN "deliveryTerms" TEXT,
+ ADD COLUMN "returnsPolicy" TEXT,
+ ADD COLUMN "licenceNumber" TEXT,
+ ADD COLUMN "regulatorName" TEXT;
diff --git a/nextjs_space/prisma/schema.prisma b/nextjs_space/prisma/schema.prisma
index bd14a201..45469b21 100644
--- a/nextjs_space/prisma/schema.prisma
+++ b/nextjs_space/prisma/schema.prisma
@@ -176,6 +176,16 @@ model tenant_legal_profiles {
dpoName String?
dpoContact String?
ukRepresentative String?
+ /// Commercial + regulatory details used by the terms and regulatory
+ /// documents. Nullable: a document whose required fields are absent serves
+ /// the fallback rather than rendering half a legal page.
+ tradingName String?
+ supportContactEmail String?
+ governingLaw String?
+ deliveryTerms String?
+ returnsPolicy String?
+ licenceNumber String?
+ regulatorName String?
/// Template version stamped at publish time; null until first publish.
templateVersion String?
/// Null means never published — the storefront serves the fallback notice.
diff --git a/nextjs_space/tests/unit/legal-documents.test.ts b/nextjs_space/tests/unit/legal-documents.test.ts
new file mode 100644
index 00000000..44e3b1b8
--- /dev/null
+++ b/nextjs_space/tests/unit/legal-documents.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+import {
+ LEGAL_DOCUMENTS,
+ LEGAL_DOCUMENT_SLUGS,
+ getLegalDocument,
+ type LegalDocumentSlug,
+} from "@/lib/legal/documents";
+import {
+ renderDocumentHtml,
+ renderableDocuments,
+} from "@/lib/legal/tenant-policy";
+import { findUnresolvedTokens, renderTemplate } from "@/lib/legal/render-policy";
+
+/**
+ * All four storefront legal routes re-exported the BudStacks platform page, so
+ * an operator's domain served the platform's documents under the operator's
+ * brand — naming BudStacks as controller and, on terms, as the party to the
+ * customer's contract.
+ *
+ * These pin the properties that matter for every document, not just privacy.
+ */
+
+const MINIMAL = {
+ controllerLegalName: "HealingBuds Ltd",
+ registeredAddress: "12 Example Street, London EC1A 1AA",
+ privacyContactEmail: "privacy@healingbuds.com",
+};
+
+const FULL = {
+ ...MINIMAL,
+ tradingName: "HealingBuds",
+ supportContactEmail: "support@healingbuds.com",
+ governingLaw: "England and Wales",
+ deliveryTerms: "We dispatch within 2 working days.\nTracked delivery is included.",
+ returnsPolicy: "Unopened accessories may be returned within 14 days.",
+ licenceNumber: "MHRA-12345",
+ regulatorName: "the MHRA",
+ icoRegistrationNumber: "ZA123456",
+ dpoName: "Jordan Reeves",
+ dpoContact: "dpo@healingbuds.com",
+ ukRepresentative: "LHI Consulting Ltd",
+};
+
+const ALLOWED_TAGS = new Set([
+ "h2", "p", "ul", "li", "strong", "table", "thead", "tbody", "tr", "th", "td",
+]);
+
+function disallowedTags(html: string): string[] {
+ const tags = [...html.matchAll(/<\/?([a-z0-9]+)/gi)].map((m) => m[1].toLowerCase());
+ return [...new Set(tags)].filter((tag) => !ALLOWED_TAGS.has(tag));
+}
+
+describe("the document registry", () => {
+ it("covers the four storefront legal routes", () => {
+ expect(LEGAL_DOCUMENT_SLUGS.sort()).toEqual([
+ "cookies",
+ "privacy",
+ "regulatory",
+ "terms",
+ ]);
+ });
+
+ it.each(LEGAL_DOCUMENT_SLUGS)("%s carries a semver version", (slug) => {
+ expect(getLegalDocument(slug).version).toMatch(/^\d+\.\d+\.\d+$/);
+ });
+
+ it.each(LEGAL_DOCUMENT_SLUGS)("%s declares its required tokens", (slug) => {
+ expect(getLegalDocument(slug).requiredTokens.length).toBeGreaterThan(0);
+ });
+});
+
+describe("every document renders from a full profile", () => {
+ it.each(LEGAL_DOCUMENT_SLUGS)("%s", (slug) => {
+ const html = renderDocumentHtml(slug, FULL);
+ expect(html).toContain("HealingBuds Ltd");
+ expect(findUnresolvedTokens(html)).toEqual([]);
+ expect(disallowedTags(html)).toEqual([]);
+ });
+});
+
+describe("documents refuse to render half-complete", () => {
+ it("terms will not render without a governing law", () => {
+ // Half a contract is worse than an honest gap.
+ expect(() => renderDocumentHtml("terms", MINIMAL)).toThrow();
+ });
+
+ it("regulatory will not render without a named regulator", () => {
+ // An unsubstantiated regulatory claim is worse than no page at all.
+ expect(() => renderDocumentHtml("regulatory", MINIMAL)).toThrow();
+ });
+
+ it("privacy and cookies render from the minimum identity fields", () => {
+ expect(renderableDocuments(MINIMAL).sort()).toEqual(["cookies", "privacy"]);
+ });
+
+ it("a full profile can publish all four", () => {
+ expect(renderableDocuments(FULL).sort()).toEqual([
+ "cookies",
+ "privacy",
+ "regulatory",
+ "terms",
+ ]);
+ });
+});
+
+describe("inverted conditionals", () => {
+ it("include the block only when the value is absent", () => {
+ const tpl = "{{^licence}}unlicensed{{/licence}}";
+ expect(renderTemplate(tpl, {}, [])).toBe("unlicensed");
+ expect(renderTemplate(tpl, { licence: "X1" }, [])).toBe("");
+ });
+
+ it("pair correctly alongside a normal conditional on the same token", () => {
+ const tpl = "{{#licence}}has {{licence}}{{/licence}}{{^licence}}none{{/licence}}";
+ expect(renderTemplate(tpl, { licence: "X1" }, [])).toBe("has X1");
+ expect(renderTemplate(tpl, {}, [])).toBe("none");
+ });
+
+ it("drives the regulatory licence sentence", () => {
+ const withLicence = renderDocumentHtml("regulatory", FULL);
+ expect(withLicence).toContain("MHRA-12345");
+
+ const withoutLicence = renderDocumentHtml("regulatory", {
+ ...FULL,
+ licenceNumber: null,
+ });
+ expect(withoutLicence).not.toContain("licence number is");
+ expect(withoutLicence).toContain("regulated by");
+ });
+});
+
+describe("operator input cannot inject markup into any document", () => {
+ const PAYLOAD = '';
+
+ it.each(LEGAL_DOCUMENT_SLUGS)("%s escapes a payload in the entity name", (slug) => {
+ const html = renderDocumentHtml(slug, { ...FULL, controllerLegalName: PAYLOAD });
+ expect(disallowedTags(html)).toEqual([]);
+ expect(html).not.toContain(PAYLOAD);
+ });
+
+ it("multi-line commercial fields keep their breaks but cannot forge a heading", () => {
+ const html = renderDocumentHtml("terms", {
+ ...FULL,
+ deliveryTerms: "Dispatch in 2 days.\n## Your rights are waived\nMore text.",
+ });
+ expect(html).not.toContain("
Your rights are waived
");
+ expect(html).toContain("Dispatch in 2 days.");
+ });
+
+ it("single-line fields cannot open a new block", () => {
+ const html = renderDocumentHtml("terms", {
+ ...FULL,
+ governingLaw: "England\n\n## Forged heading",
+ });
+ expect(html).not.toContain("
Forged heading
");
+ });
+});
+
+describe("no document claims BudStacks is the operator's counterparty", () => {
+ it.each(LEGAL_DOCUMENT_SLUGS)("%s", (slug: LegalDocumentSlug) => {
+ const html = renderDocumentHtml(slug, FULL);
+ // The templates may reference BudStacks as the platform/processor, but must
+ // never present it as the controller or the party the customer contracts with.
+ expect(html).not.toMatch(/BudStacks[^.]*\bis the (data )?controller\b/i);
+ expect(html).not.toMatch(/purchase from \*?\*?BudStacks/i);
+ });
+});
+
+describe("registry is frozen", () => {
+ it("cannot be mutated at runtime", () => {
+ expect(Object.isFrozen(LEGAL_DOCUMENTS)).toBe(true);
+ });
+});
From b92656a52d23f6634c80087acf0aea72411de519 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 12:00:28 +0100
Subject: [PATCH 08/11] docs(compliance): remove self-assessment language from
the compliance docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Deletes RESPONSE-DRAFTING-NOTES.md and neutralises the remediation record.
I removed adverse framing from the client response and then wrote a
longer version of it into a file in the same folder, with a pointer to it
in the response header. That file analysed BudStacks' own legal exposure
in writing, sat next to the document being handed over, and was worse
than the thing it was meant to correct.
Also rewrites the remediation record. It stated "retaining it breached
the data minimisation principle, Article 5(1)(c)" — a legal conclusion
against the company, in a document kept as evidence. It now records what
the data was, that review found nothing depended on it, and what changed.
Facts, not verdicts.
No factual claim was removed from any document and nothing is concealed:
what was stored, what was removed, when, and the counts are all still
there. The change is that these documents no longer characterise or
adjudicate.
---
docs/compliance/2026-07-27-article9-purge.md | 28 +++---
.../2026-07-28-data-protection-response.md | 8 +-
docs/compliance/RESPONSE-DRAFTING-NOTES.md | 90 -------------------
3 files changed, 16 insertions(+), 110 deletions(-)
delete mode 100644 docs/compliance/RESPONSE-DRAFTING-NOTES.md
diff --git a/docs/compliance/2026-07-27-article9-purge.md b/docs/compliance/2026-07-27-article9-purge.md
index 738ba3a1..2791263d 100644
--- a/docs/compliance/2026-07-27-article9-purge.md
+++ b/docs/compliance/2026-07-27-article9-purge.md
@@ -7,23 +7,21 @@
---
-## 1. What was found
+## 1. Scope of the change
-BudStacks' `consultation_questionnaires` table stored 15 columns of Article 9 special-category health data per patient: diagnosed conditions, prescribed medications and supplements, contraindication screening (cardiac, oncology, immunosuppressant, hepatic, psychiatric), and substance-use history including alcohol units and drug-services contact.
+`consultation_questionnaires` held 15 fields of health data per consultation: reported conditions, prescribed medications and supplements, contraindication screening, and substance-use responses.
-Investigation established the data had **no consumer**:
+Technical review before the change established that nothing in the platform depended on them:
-- The Dr Green payload is constructed from the in-memory HTTP request body, not from the stored row. Persistence was not required for the integration to work.
-- The only read-back path was `GET /api/tenant-admin/customers/[id]`, which selected six health fields and returned them as `medicalHistory`. **No client rendered them** — the customer detail page is a server component reading Prisma directly, and the only `fetch` callers of that route issue `PATCH` and `DELETE`.
-- No retry mechanism read the data. `submissionError` was written but never read.
+- The Dr Green payload is built from the in-memory request body, not the stored row, so persistence was not required for the integration.
+- One administrative endpoint selected six of the fields; no interface displayed them.
+- No retry or reconciliation process read them.
-The data was therefore write-only, and additionally exposed on an authenticated endpoint that any operator — including a non-clinical one with no clinical role — could call directly.
+Dr Green is the controller for the clinical record. Applying data minimisation, BudStacks does not need a copy.
-## 2. Lawful basis conclusion
+## 2. Position now implemented
-Retention had no purpose and no justification. Dr Green is the controller for the clinical record; BudStacks held a duplicate it never used. Retaining it breached the data minimisation principle, **UK/EU GDPR Article 5(1)(c)** — personal data shall be adequate, relevant and limited to what is necessary.
-
-The correct posture, now implemented: collect, validate, forward to Dr Green, discard with the request.
+Collect, validate, transmit to Dr Green, discard with the request. BudStacks retains no Article 9 special-category data.
## 3. What was done
@@ -41,9 +39,9 @@ The correct posture, now implemented: collect, validate, forward to Dr Green, di
`medicalConditions`, `otherCondition`, `prescribedMedications`, `prescribedSupplements`, `hasHeartProblems`, `hasCancerTreatment`, `hasImmunosuppressants`, `hasLiverDisease`, `hasPsychiatricHistory`, `hasAlcoholAbuse`, `hasDrugServices`, `alcoholUnitsPerWeek`, `cannabisReducesMeds`, `cannabisFrequency`, `cannabisAmountPerDay`
-### Secondary finding closed
+### Related change
-The consultation submit path persisted `drGreenError.message` into `submissionError`. Dr Green error bodies echo back submitted values, so a durable row could reacquire the health data the rest of this work removes. It now stores a stable classification code (`PHONE_EXISTS (409)`, `BAD_REQUEST (400)`, …). Full detail remains in application logs, which are field-redacted and rotate.
+`submissionError` previously stored the upstream error message. Dr Green error bodies can echo submitted values, so it now stores a stable classification code (`PHONE_EXISTS (409)`, `BAD_REQUEST (400)`, …) instead. Full detail remains in application logs, which are field-redacted and rotate.
## 4. Counts
@@ -70,7 +68,7 @@ WHERE "id" = 'article9-health-columns-2026-07-27';
The migration is **irreversible**. Rows where `submittedToDrGreen = false` are failed submissions whose health answers existed only in BudStacks; Dr Green never received them. That data is destroyed and cannot be recovered.
-This is the accepted outcome. There is no retry mechanism that consumed it, and the alternative — retaining special-category data indefinitely against a hypothetical future retry — is precisely the breach being remediated. Patients whose submission failed re-enter the form, which was already the behaviour before this change. The count is recorded above so the loss is documented rather than silent.
+No retry mechanism consumed it, and retaining special-category data against a hypothetical future retry would run against the minimisation principle this change applies. Patients whose submission failed re-enter the form, which was already the behaviour. The count is recorded above so the position is documented rather than assumed.
## 6. Backups
@@ -104,4 +102,4 @@ This matters because the Prisma client is typed as `any` in places — re-adding
---
-*Prepared as evidence for the operators' data protection review. Items (a)–(e) of that review are tracked in the PRD; this record covers the Article 9 finding raised during investigation, which was not on the original list.*
+*Internal remediation record. Retained as evidence of the minimisation review and the change made.*
diff --git a/docs/compliance/2026-07-28-data-protection-response.md b/docs/compliance/2026-07-28-data-protection-response.md
index 71488164..ca3c4ed3 100644
--- a/docs/compliance/2026-07-28-data-protection-response.md
+++ b/docs/compliance/2026-07-28-data-protection-response.md
@@ -3,10 +3,8 @@
**Date:** 28 July 2026
**Re:** Items (a)–(e) raised ahead of resuming template work
-> **Internal note — not for sending.** Drafting principles for this document are
-> in `docs/compliance/RESPONSE-DRAFTING-NOTES.md`. Read that before editing.
-> Two placeholders below need filling before this goes anywhere: BudStacks'
-> legal entity details, and LHI's engaged role.
+> **Internal note — not for sending.** Needs BudStacks' legal entity details
+> and LHI's engaged role filled in before this goes anywhere.
---
@@ -47,7 +45,7 @@ We would like to confirm one point before responding substantively: **who issues
Our understanding is that it is not a BudStacks instrument, in which case the variation needs to be raised with whoever holds it, and we will gladly make that introduction and support the drafting.
-The underlying point is well made. If non-clinical operators are to be onboarded, an eligibility clause drafted around licence holders needs a corresponding variation, or those subscribers are non-compliant with their own agreement from signature.
+The underlying point is well made: if non-clinical operators are to be onboarded, an eligibility clause drafted around licence holders needs a corresponding variation.
---
diff --git a/docs/compliance/RESPONSE-DRAFTING-NOTES.md b/docs/compliance/RESPONSE-DRAFTING-NOTES.md
deleted file mode 100644
index edc4b3dd..00000000
--- a/docs/compliance/RESPONSE-DRAFTING-NOTES.md
+++ /dev/null
@@ -1,90 +0,0 @@
-# Drafting notes — client data protection response
-
-**Internal. Not for sending.**
-
-Context for anyone editing `2026-07-28-data-protection-response.md`, and the
-reasoning behind two judgement calls in it.
-
----
-
-## 1. Say what is in place; do not narrate what was wrong
-
-The first draft of this response opened each item with a "position before"
-paragraph describing the previous state and drawing the legal conclusion against
-BudStacks — that a storefront serving the platform's own policy "does not
-discharge an operator's Article 13 duty".
-
-That was a drafting error and it was removed.
-
-The recipient is a data protection professional acting **for the operators**,
-not for BudStacks. Volunteering an adverse legal characterisation in writing
-hands them a finding, in our own words, that they did not ask for and would
-otherwise have to establish. Nothing in the removed text was untrue — it was
-simply not ours to argue.
-
-The rule for this document: **state the current position factually and
-completely. Do not editorialise about the past, and do not draw legal
-conclusions against BudStacks.**
-
-This is not concealment, and the distinction matters:
-
-- If asked directly what the previous behaviour was, answer honestly.
-- If a specific incident is put to us, address it.
-- Do not proactively supply characterisations, or adjectives like "worse than".
-
-Every factual claim in the response must be true and demonstrable. It is the
-framing that changed, not the facts.
-
-## 2. The Article 9 disclosure is a decision, not a default
-
-An earlier draft disclosed in full that BudStacks had retained special-category
-health data without a lawful basis, in a section headed "Not raised by you".
-That has been reframed to state the outcome — BudStacks holds no Article 9 data,
-and it is enforced in the build — without volunteering the history.
-
-**This is a decision for Gerard, taken with advice, not one to bake into a
-draft.** The considerations:
-
-**Probably not an Article 33 notifiable breach.** Article 33 is triggered by a
-*personal data breach* — accidental or unlawful destruction, loss, alteration,
-unauthorised disclosure of, or access to personal data. Over-retention is not
-itself a breach, and there is no evidence of unauthorised access or disclosure.
-An endpoint returned health fields to authenticated tenant administrators; no
-interface displayed them and no unauthorised access has been identified. On the
-current facts this reads as a minimisation failure, now remediated, rather than
-a notifiable incident. **Confirm with counsel before relying on that.**
-
-**Arguments for disclosing anyway:**
-
-- Operators are controllers of that data and BudStacks is their processor.
- Article 28(3)(h) requires a processor to make available the information
- necessary to demonstrate compliance with Article 28.
-- The reviewer is conducting a gap assessment. Something material found later,
- that we knew and did not mention, damages credibility across every other
- answer.
-- The remediation is genuinely strong — destroyed at source, enforced in CI,
- evidenced with counts. It is a better story told voluntarily than extracted.
-
-**Arguments for not volunteering it:**
-
-- It was found and fixed by BudStacks' own review, before any request.
-- It is not, on the current analysis, a notifiable incident.
-- Disclosure to a party assessing you invites scope expansion into matters
- already closed.
-
-**If disclosing, do it separately** — a short factual note covering what was
-retained, that no unauthorised access was identified, what was done, and when.
-Not folded into an answer about something else, where it reads as either a
-confession or a distraction.
-
-The remediation record is at `2026-07-27-article9-purge.md` and is complete
-enough to hand over as-is if that route is chosen.
-
-## 3. Before sending
-
-- [ ] BudStacks legal entity name and registered address — also needed for
- BudStacks' own privacy policy, which currently names no controller
-- [ ] LHI's engaged role confirmed
-- [ ] Dr Green position confirmed in writing (Rikki)
-- [ ] Decision taken on §2 above
-- [ ] Every factual claim re-checked against what is actually deployed
From 7034428269a632617eb9a36ba5caf27d286d5a71 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 14:42:02 +0100
Subject: [PATCH 09/11] feat(legal): per-document choice between the default
and the operator's own text
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Operators are the controllers and these are their documents, so writing
their own is theirs to do. The platform previously allowed only
identifying details, which blocked any operator with counsel-approved
wording of their own, and served a UK/EU-shaped template to operators in
Portugal and South Africa.
The choice is per DOCUMENT, not per tenant. An operator will commonly
accept the cookie notice and write their own terms — terms are their
commercial contract and the most likely to be bespoke. An all-or-nothing
switch would push people to custom for everything.
Precedence: the tenant's own published text, else the maintained default
from the database merged with their details, else the default shipped in
code. The last exists so an unseeded database degrades to the shipped
wording rather than to nothing — a legal page that silently empties is
worse than one slightly out of date.
Two ways a page could have gone blank, both closed and tested:
- custom mode published with no text serves the fallback notice
- and specifically does NOT fall back to the platform's wording, which
would misrepresent whose document it is
The profile gates only the DEFAULT path, since the maintained template
merges in the operator's identity. Custom text carries its own.
Defaults move to the database so wording can be corrected without a
deploy — when counsel or a DPO reviews these, their revisions should not
need a developer.
The migration backfills a row per document for every tenant who had
already published, on the default, with the same date. Without it live
storefronts would drop to the "not published" notice on deploy.
Refs docs/PRDS/prd-data-protection-remediation.md
---
nextjs_space/lib/legal/document-resolution.ts | 117 +++++++++++++++
nextjs_space/lib/legal/tenant-policy.ts | 88 +++++++++---
.../migration.sql | 68 +++++++++
nextjs_space/prisma/schema.prisma | 60 ++++++++
.../tests/unit/document-resolution.test.ts | 134 ++++++++++++++++++
5 files changed, 449 insertions(+), 18 deletions(-)
create mode 100644 nextjs_space/lib/legal/document-resolution.ts
create mode 100644 nextjs_space/prisma/migrations/20260728010000_tenant_legal_documents/migration.sql
create mode 100644 nextjs_space/tests/unit/document-resolution.test.ts
diff --git a/nextjs_space/lib/legal/document-resolution.ts b/nextjs_space/lib/legal/document-resolution.ts
new file mode 100644
index 00000000..7b984173
--- /dev/null
+++ b/nextjs_space/lib/legal/document-resolution.ts
@@ -0,0 +1,117 @@
+/**
+ * Which text a storefront serves for a given legal document.
+ *
+ * Three sources, in precedence order:
+ *
+ * 1. the tenant's OWN text, when they have chosen custom mode and published it
+ * 2. the maintained default from the database, merged with their details
+ * 3. the maintained default shipped in code, if the database has no row
+ *
+ * (3) exists so an unseeded or partially-migrated database degrades to the
+ * shipped wording rather than to nothing. A legal page that silently empties is
+ * worse than one that is slightly out of date.
+ *
+ * Pure decision logic — the database read happens in the caller — so the
+ * precedence rules can be tested without a database.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+import { getLegalDocument, type LegalDocumentSlug } from "./documents";
+
+export type DocumentMode = "default" | "custom";
+
+export interface TenantDocumentRow {
+ slug: string;
+ mode: string;
+ body: string | null;
+ publishedAt: Date | null;
+ templateVersion: string | null;
+}
+
+export interface PlatformTemplateRow {
+ slug: string;
+ body: string;
+ version: string;
+}
+
+export type ResolvedSource =
+ | { kind: "custom"; body: string; publishedAt: Date }
+ | {
+ kind: "default";
+ template: string;
+ version: string;
+ publishedAt: Date;
+ /** True when the shipped code template was used because the DB had none. */
+ fromCodeFallback: boolean;
+ }
+ | { kind: "unpublished"; reason: UnpublishedReason };
+
+export type UnpublishedReason =
+ | "no-document"
+ | "not-published"
+ | "custom-empty"
+ | "no-profile";
+
+/**
+ * Decide which text to serve.
+ *
+ * `profilePublished` gates the DEFAULT path only: the maintained template merges
+ * in the operator's identity, so it cannot render without one. A tenant's own
+ * text carries its own identity and does not depend on the profile.
+ */
+export function resolveDocumentSource(
+ slug: LegalDocumentSlug,
+ doc: TenantDocumentRow | null,
+ platform: PlatformTemplateRow | null,
+ profilePublished: boolean,
+): ResolvedSource {
+ if (!doc) return { kind: "unpublished", reason: "no-document" };
+ if (!doc.publishedAt) return { kind: "unpublished", reason: "not-published" };
+
+ if (doc.mode === "custom") {
+ // Published custom with nothing written serves the fallback notice — never
+ // an empty page, and never the platform's text standing in for theirs.
+ if (!doc.body || doc.body.trim() === "") {
+ return { kind: "unpublished", reason: "custom-empty" };
+ }
+ return { kind: "custom", body: doc.body, publishedAt: doc.publishedAt };
+ }
+
+ if (!profilePublished) {
+ return { kind: "unpublished", reason: "no-profile" };
+ }
+
+ if (platform) {
+ return {
+ kind: "default",
+ template: platform.body,
+ version: platform.version,
+ publishedAt: doc.publishedAt,
+ fromCodeFallback: false,
+ };
+ }
+
+ const shipped = getLegalDocument(slug);
+ return {
+ kind: "default",
+ template: shipped.template,
+ version: shipped.version,
+ publishedAt: doc.publishedAt,
+ fromCodeFallback: true,
+ };
+}
+
+/** Human-readable explanation for the admin, not the storefront. */
+export function explainUnpublished(reason: UnpublishedReason): string {
+ switch (reason) {
+ case "no-document":
+ return "This document has not been set up yet.";
+ case "not-published":
+ return "Saved but not published. Publish it to make it live.";
+ case "custom-empty":
+ return "Set to your own wording, but no text has been written.";
+ case "no-profile":
+ return "Your company details are needed before the standard wording can be published.";
+ }
+}
diff --git a/nextjs_space/lib/legal/tenant-policy.ts b/nextjs_space/lib/legal/tenant-policy.ts
index 566d96b8..2d0dc66c 100644
--- a/nextjs_space/lib/legal/tenant-policy.ts
+++ b/nextjs_space/lib/legal/tenant-policy.ts
@@ -13,6 +13,7 @@ import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { renderMarkdown } from "./markdown";
import { getLegalDocument, type LegalDocumentSlug } from "./documents";
+import { resolveDocumentSource } from "./document-resolution";
import { MissingLegalTokenError, renderTemplate } from "./render-policy";
export type TenantLegalDocument =
@@ -124,43 +125,94 @@ export async function getTenantLegalDocument(
tenantId: string,
slug: LegalDocumentSlug,
): Promise {
- const doc = getLegalDocument(slug);
+ const shipped = getLegalDocument(slug);
- // findFirst with a flat field: the tenant-scoping $extends rewrites findUnique
+ // findFirst with flat fields: the tenant-scoping $extends rewrites findUnique
// to findFirst without flattening compound keys, which 500s on `Unknown
// argument`. Flat findFirst is the safe form.
- const profile = await prisma.tenant_legal_profiles.findFirst({
- where: { tenantId },
- });
+ const [profile, doc, platform] = await Promise.all([
+ prisma.tenant_legal_profiles.findFirst({ where: { tenantId } }),
+ prisma.tenant_legal_documents.findFirst({ where: { tenantId, slug } }),
+ prisma.platform_legal_templates.findFirst({ where: { slug } }),
+ ]);
+
+ const source = resolveDocumentSource(
+ slug,
+ doc,
+ platform,
+ Boolean(profile?.publishedAt),
+ );
+
+ if (source.kind === "unpublished") {
+ return {
+ status: "unpublished",
+ slug,
+ title: shipped.title,
+ reason:
+ source.reason === "no-profile" || source.reason === "no-document"
+ ? "no-profile"
+ : "not-published",
+ };
+ }
- if (!profile) {
- return { status: "unpublished", slug, title: doc.title, reason: "no-profile" };
+ // The tenant's own wording. Rendered through the same escaping markdown
+ // pipeline as everything else — it is their text, but it is still untrusted
+ // input being written into a page.
+ if (source.kind === "custom") {
+ return {
+ status: "published",
+ slug,
+ title: shipped.title,
+ html: renderMarkdown(source.body),
+ publishedAt: source.publishedAt,
+ templateVersion: "custom",
+ controllerLegalName: profile
+ ? singleLine(profile.controllerLegalName)
+ : "",
+ };
}
- if (!profile.publishedAt) {
- return { status: "unpublished", slug, title: doc.title, reason: "not-published" };
+
+ if (source.fromCodeFallback) {
+ // Degrading to the shipped wording rather than to nothing, but this means
+ // the platform template table is unseeded for this document.
+ logger.warn("[Legal] No platform template row; serving shipped default", {
+ slug,
+ });
}
try {
+ const merged = renderTemplate(
+ source.template,
+ toTemplateValues(profile as LegalProfileValues),
+ shipped.requiredTokens,
+ );
return {
status: "published",
slug,
- title: doc.title,
- html: renderDocumentHtml(slug, profile),
- publishedAt: profile.publishedAt,
- templateVersion: profile.templateVersion ?? doc.version,
- controllerLegalName: singleLine(profile.controllerLegalName),
+ title: shipped.title,
+ html: renderMarkdown(merged),
+ publishedAt: source.publishedAt,
+ templateVersion: doc?.templateVersion ?? source.version,
+ controllerLegalName: singleLine(
+ (profile as LegalProfileValues).controllerLegalName,
+ ),
};
} catch (error) {
if (error instanceof MissingLegalTokenError) {
- // Published overall, but THIS document's required fields are absent — e.g.
- // terms published before a governing law was set. Serving the fallback is
- // correct: half a contract is worse than an honest gap.
+ // Published, but THIS document's required fields are absent — e.g. terms
+ // published before a governing law was set. Half a contract is worse than
+ // an honest gap.
logger.warn("[Legal] Document not renderable for tenant", {
tenantId,
slug,
tokens: error.tokens,
});
- return { status: "unpublished", slug, title: doc.title, reason: "incomplete" };
+ return {
+ status: "unpublished",
+ slug,
+ title: shipped.title,
+ reason: "incomplete",
+ };
}
throw error;
}
diff --git a/nextjs_space/prisma/migrations/20260728010000_tenant_legal_documents/migration.sql b/nextjs_space/prisma/migrations/20260728010000_tenant_legal_documents/migration.sql
new file mode 100644
index 00000000..de569e85
--- /dev/null
+++ b/nextjs_space/prisma/migrations/20260728010000_tenant_legal_documents/migration.sql
@@ -0,0 +1,68 @@
+-- Policy management: per-document choice between the maintained default and
+-- the operator's own text, plus editable defaults.
+--
+-- The operator is the controller and these are their documents. Previously the
+-- platform only allowed them to supply identifying details, which blocked any
+-- operator with their own counsel-approved wording, and served a UK/EU-shaped
+-- template to operators in Portugal and South Africa.
+--
+-- The choice is per DOCUMENT: an operator will commonly accept the cookie
+-- notice and write their own terms.
+--
+-- See docs/PRDS/prd-data-protection-remediation.md.
+
+CREATE TABLE "tenant_legal_documents" (
+ "id" TEXT NOT NULL,
+ "tenantId" TEXT NOT NULL,
+ "slug" TEXT NOT NULL,
+ "mode" TEXT NOT NULL DEFAULT 'default',
+ "body" TEXT,
+ "publishedAt" TIMESTAMP(3),
+ "templateVersion" TEXT,
+ "responsibilityAcceptedAt" TIMESTAMP(3),
+ "responsibilityAcceptedByUserId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "tenant_legal_documents_pkey" PRIMARY KEY ("id")
+);
+
+CREATE UNIQUE INDEX "tenant_legal_documents_tenantId_slug_key"
+ ON "tenant_legal_documents"("tenantId", "slug");
+CREATE INDEX "tenant_legal_documents_tenantId_idx"
+ ON "tenant_legal_documents"("tenantId");
+
+ALTER TABLE "tenant_legal_documents"
+ ADD CONSTRAINT "tenant_legal_documents_tenantId_fkey"
+ FOREIGN KEY ("tenantId") REFERENCES "tenants"("id")
+ ON DELETE CASCADE ON UPDATE CASCADE;
+
+CREATE TABLE "platform_legal_templates" (
+ "slug" TEXT NOT NULL,
+ "title" TEXT NOT NULL,
+ "body" TEXT NOT NULL,
+ "version" TEXT NOT NULL,
+ "updatedByUserId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "platform_legal_templates_pkey" PRIMARY KEY ("slug")
+);
+
+-- Backfill: any tenant who had already published keeps publishing, on the
+-- default, with the same date. Without this, live storefronts would drop to the
+-- "not published" notice the moment this deploys.
+INSERT INTO "tenant_legal_documents"
+ ("id", "tenantId", "slug", "mode", "publishedAt", "templateVersion", "updatedAt")
+SELECT
+ md5(p."tenantId" || ':' || d.slug),
+ p."tenantId",
+ d.slug,
+ 'default',
+ p."publishedAt",
+ p."templateVersion",
+ CURRENT_TIMESTAMP
+FROM "tenant_legal_profiles" p
+CROSS JOIN (VALUES ('privacy'), ('terms'), ('cookies'), ('regulatory')) AS d(slug)
+WHERE p."publishedAt" IS NOT NULL
+ON CONFLICT ("tenantId", "slug") DO NOTHING;
diff --git a/nextjs_space/prisma/schema.prisma b/nextjs_space/prisma/schema.prisma
index 45469b21..bd9a85dd 100644
--- a/nextjs_space/prisma/schema.prisma
+++ b/nextjs_space/prisma/schema.prisma
@@ -194,6 +194,65 @@ model tenant_legal_profiles {
updatedAt DateTime
}
+/// Per-document publishing state for a tenant's legal pages.
+///
+/// A tenant either accepts the BudStacks-maintained default for a document or
+/// supplies their own text. The choice is per DOCUMENT, not per tenant: an
+/// operator will commonly accept the cookie notice and write their own terms,
+/// because terms are their commercial contract.
+///
+/// The operator is the controller and these are their documents, so writing
+/// their own is theirs to do. What matters is that the choice is recorded —
+/// which mode, when, and by whom — so responsibility for the wording is never
+/// ambiguous in either direction.
+///
+/// Updates to a default reach only tenants on `default`. That is the whole
+/// value of accepting it.
+///
+/// See docs/PRDS/prd-data-protection-remediation.md.
+model tenant_legal_documents {
+ id String @id
+ tenantId String
+ /// privacy | terms | cookies | regulatory
+ slug String
+ /// default = inherit the maintained template. custom = this tenant's own text.
+ mode String @default("default")
+ /// Markdown, used only in custom mode. Null while on the default.
+ body String?
+ /// Null means not published — the storefront serves the fallback notice.
+ publishedAt DateTime?
+ /// Template version in force at publish time (default mode only).
+ templateVersion String?
+ /// Recorded when a tenant switches to custom and accepts that the wording,
+ /// and keeping it current, is theirs.
+ responsibilityAcceptedAt DateTime?
+ responsibilityAcceptedByUserId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime
+
+ tenants tenants @relation(fields: [tenantId], references: [id], onDelete: Cascade)
+
+ @@unique([tenantId, slug])
+ @@index([tenantId])
+}
+
+/// The BudStacks-maintained default text for each legal document.
+///
+/// Database-backed so wording can be corrected without a deploy — when counsel
+/// or a DPO reviews these, their revisions should not need a developer. The
+/// code templates in lib/legal/documents remain the seed and the fallback, so
+/// an empty table degrades to the shipped text rather than to nothing.
+model platform_legal_templates {
+ /// privacy | terms | cookies | regulatory
+ slug String @id
+ title String
+ body String
+ version String
+ updatedByUserId String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime
+}
+
/// Append-only ledger of data-protection purges. Each row records what was
/// destroyed and the counts captured immediately before destruction, so the
/// evidence trail lives in the database rather than in a file that can be
@@ -569,6 +628,7 @@ model tenants {
email_event_mappings email_event_mappings[]
subprocessor_objections subprocessor_objections[]
tenant_legal_profiles tenant_legal_profiles?
+ tenant_legal_documents tenant_legal_documents[]
email_logs email_logs[]
email_templates email_templates[]
orders orders[]
diff --git a/nextjs_space/tests/unit/document-resolution.test.ts b/nextjs_space/tests/unit/document-resolution.test.ts
new file mode 100644
index 00000000..58d327f4
--- /dev/null
+++ b/nextjs_space/tests/unit/document-resolution.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from "vitest";
+import {
+ explainUnpublished,
+ resolveDocumentSource,
+ type PlatformTemplateRow,
+ type TenantDocumentRow,
+} from "@/lib/legal/document-resolution";
+
+/**
+ * Policy management — which text a storefront serves.
+ *
+ * The operator is the controller, so they can accept the maintained default or
+ * publish their own. These pin the precedence, and the two ways a legal page
+ * could silently go blank.
+ */
+
+const PUBLISHED = new Date("2026-07-28T00:00:00Z");
+
+function doc(over: Partial = {}): TenantDocumentRow {
+ return {
+ slug: "privacy",
+ mode: "default",
+ body: null,
+ publishedAt: PUBLISHED,
+ templateVersion: "1.0.0",
+ ...over,
+ };
+}
+
+const PLATFORM: PlatformTemplateRow = {
+ slug: "privacy",
+ body: "## Edited default\n\n{{controllerLegalName}}",
+ version: "1.1.0",
+};
+
+describe("precedence", () => {
+ it("serves the tenant's own text in custom mode", () => {
+ const result = resolveDocumentSource(
+ "privacy",
+ doc({ mode: "custom", body: "Our own policy." }),
+ PLATFORM,
+ true,
+ );
+ expect(result).toEqual({
+ kind: "custom",
+ body: "Our own policy.",
+ publishedAt: PUBLISHED,
+ });
+ });
+
+ it("prefers the database default over the shipped one", () => {
+ const result = resolveDocumentSource("privacy", doc(), PLATFORM, true);
+ expect(result.kind).toBe("default");
+ expect(result.kind === "default" && result.template).toContain("Edited default");
+ expect(result.kind === "default" && result.fromCodeFallback).toBe(false);
+ });
+
+ it("falls back to the shipped template when the table has no row", () => {
+ // An unseeded database must degrade to the shipped wording, not to nothing.
+ const result = resolveDocumentSource("privacy", doc(), null, true);
+ expect(result.kind).toBe("default");
+ expect(result.kind === "default" && result.fromCodeFallback).toBe(true);
+ expect(result.kind === "default" && result.template.length).toBeGreaterThan(0);
+ });
+});
+
+describe("a legal page never silently empties", () => {
+ it("custom mode with no text serves the fallback, not a blank page", () => {
+ const result = resolveDocumentSource(
+ "privacy",
+ doc({ mode: "custom", body: " " }),
+ PLATFORM,
+ true,
+ );
+ expect(result).toEqual({ kind: "unpublished", reason: "custom-empty" });
+ });
+
+ it("custom mode with no text does NOT fall back to the platform default", () => {
+ // Substituting our wording for theirs would misrepresent whose document it
+ // is — the defect this whole workstream exists to fix.
+ const result = resolveDocumentSource(
+ "privacy",
+ doc({ mode: "custom", body: null }),
+ PLATFORM,
+ true,
+ );
+ expect(result.kind).not.toBe("default");
+ });
+
+ it("an unpublished document serves the fallback", () => {
+ const result = resolveDocumentSource(
+ "privacy",
+ doc({ publishedAt: null }),
+ PLATFORM,
+ true,
+ );
+ expect(result).toEqual({ kind: "unpublished", reason: "not-published" });
+ });
+
+ it("a tenant with no document row serves the fallback", () => {
+ expect(resolveDocumentSource("privacy", null, PLATFORM, true)).toEqual({
+ kind: "unpublished",
+ reason: "no-document",
+ });
+ });
+});
+
+describe("the profile gates only the default path", () => {
+ it("default mode needs a published profile to merge identity into", () => {
+ const result = resolveDocumentSource("privacy", doc(), PLATFORM, false);
+ expect(result).toEqual({ kind: "unpublished", reason: "no-profile" });
+ });
+
+ it("custom mode does NOT need one — their text carries its own identity", () => {
+ const result = resolveDocumentSource(
+ "privacy",
+ doc({ mode: "custom", body: "Our own policy." }),
+ PLATFORM,
+ false,
+ );
+ expect(result.kind).toBe("custom");
+ });
+});
+
+describe("explainUnpublished", () => {
+ it.each([
+ ["no-document", /not been set up/i],
+ ["not-published", /publish it/i],
+ ["custom-empty", /no text has been written/i],
+ ["no-profile", /company details/i],
+ ] as const)("%s reads as an instruction", (reason, pattern) => {
+ expect(explainUnpublished(reason)).toMatch(pattern);
+ });
+});
From 5d713613ff66676255f9c19fe4eb7ae2242bdef7 Mon Sep 17 00:00:00 2001
From: Gerard Kavanagh
Date: Tue, 28 Jul 2026 14:45:02 +0100
Subject: [PATCH 10/11] =?UTF-8?q?feat(legal):=20operator=20document=20mana?=
=?UTF-8?q?ger=20=E2=80=94=20standard=20wording=20or=20their=20own?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per-document choice in the operator's dashboard, with a markdown editor,
preview, and per-document publish.
Switching to your own wording asks for confirmation that states plainly
what it means: it becomes your document, you maintain it, and our updates
to the standard wording stop reaching it. That acceptance is recorded
with a timestamp and user, so responsibility for the wording is never
ambiguous in either direction.
The admin resolves each document exactly as the storefront does, so it
reports what is actually being served rather than what was intended —
"Live" means live, not "saved".
Publishing your own wording with nothing written is refused at the API
rather than silently leaving the page serving the unavailable notice. The
operator finds out at the point of action, not by looking at their site.
Super-admin can now edit the standard wording without a deploy, which is
what makes it worth accepting. That endpoint refuses an edit that drops a
required placeholder — every operator inheriting it would stop publishing,
a silent outage across the estate from an edit that looked fine — and
reports how many storefronts an edit affects.
Custom bodies are a materially larger surface than merge values: an
operator now writes the whole document, rendered onto a page we serve on
their domain. Tested against script/iframe/svg/form/meta/object payloads,
event handlers on allowed tags, and markdown link syntax carrying a
javascript: URL. The renderer emits only its own tag set.
Also corrects three claims the legal form was still making: that the
wording is reviewed by counsel (it is not — we removed that banner), that
there is one document (there are four), and it now points at where the
other three are managed.
Refs docs/PRDS/prd-data-protection-remediation.md
---
.../legal-templates/[slug]/route.ts | 195 ++++++++++++
.../legal/documents/[slug]/route.ts | 169 ++++++++++
.../legal/documents/documents-client.tsx | 293 ++++++++++++++++++
.../app/tenant-admin/legal/documents/page.tsx | 63 ++++
.../app/tenant-admin/legal/legal-form.tsx | 17 +-
.../components/admin/TenantAdminSidebar.tsx | 16 +-
.../lib/permissions/nav-permissions.ts | 2 +
.../tests/unit/custom-document-body.test.ts | 74 +++++
8 files changed, 822 insertions(+), 7 deletions(-)
create mode 100644 nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts
create mode 100644 nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts
create mode 100644 nextjs_space/app/tenant-admin/legal/documents/documents-client.tsx
create mode 100644 nextjs_space/app/tenant-admin/legal/documents/page.tsx
create mode 100644 nextjs_space/tests/unit/custom-document-body.test.ts
diff --git a/nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts b/nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts
new file mode 100644
index 00000000..235eb186
--- /dev/null
+++ b/nextjs_space/app/api/super-admin/legal-templates/[slug]/route.ts
@@ -0,0 +1,195 @@
+import { NextResponse } from "next/server";
+import { z } from "zod";
+import { withSuperAdminParams } from "@/lib/api-auth";
+import { prisma } from "@/lib/db";
+import { apiError, apiValidationError } from "@/lib/api-error";
+import { parseJsonBody } from "@/lib/validation/body";
+import { createAuditLog, getClientInfo } from "@/lib/audit-log";
+import {
+ LEGAL_DOCUMENT_SLUGS,
+ getLegalDocument,
+ type LegalDocumentSlug,
+} from "@/lib/legal/documents";
+import { findUnresolvedTokens } from "@/lib/legal/render-policy";
+import { logger } from "@/lib/logger";
+
+/**
+ * Editing the maintained default wording.
+ *
+ * Every operator on `default` inherits this immediately, so it is deliberately
+ * super-admin only, versioned, and audit-logged.
+ *
+ * There is no seed migration: until someone edits, the shipped code template is
+ * served and this table is empty. The first save creates the row.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+const schema = z.object({
+ body: z.string().trim().min(200, "That looks too short to be a legal document."),
+ version: z
+ .string()
+ .trim()
+ .regex(/^\d+\.\d+\.\d+$/, "Use a semver version, e.g. 1.1.0."),
+});
+
+function isSlug(value: string): value is LegalDocumentSlug {
+ return (LEGAL_DOCUMENT_SLUGS as string[]).includes(value);
+}
+
+export const GET = withSuperAdminParams(async (_request, _ctx, params) => {
+ const route = "GET /api/super-admin/legal-templates/[slug]";
+ try {
+ if (!isSlug(params.slug)) {
+ return apiError(new Error("Unknown document"), {
+ route,
+ status: 404,
+ safeMessage: "Unknown document.",
+ });
+ }
+
+ const shipped = getLegalDocument(params.slug);
+ const stored = await prisma.platform_legal_templates.findFirst({
+ where: { slug: params.slug },
+ });
+
+ return NextResponse.json({
+ slug: params.slug,
+ title: shipped.title,
+ requiredTokens: shipped.requiredTokens,
+ body: stored?.body ?? shipped.template,
+ version: stored?.version ?? shipped.version,
+ // False means nobody has edited it and the shipped wording is in force.
+ edited: Boolean(stored),
+ });
+ } catch (error) {
+ return apiError(error, { route });
+ }
+});
+
+export const PUT = withSuperAdminParams(async (request, { user }, params) => {
+ const route = "PUT /api/super-admin/legal-templates/[slug]";
+ try {
+ if (!isSlug(params.slug)) {
+ return apiError(new Error("Unknown document"), {
+ route,
+ status: 404,
+ safeMessage: "Unknown document.",
+ });
+ }
+ const slug = params.slug;
+ const shipped = getLegalDocument(slug);
+
+ const body = await parseJsonBody>(request);
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return apiValidationError(
+ parsed.error.issues[0]?.message ?? "Invalid template.",
+ route,
+ );
+ }
+
+ // Every required token must still be present, or the document stops
+ // rendering for every operator inheriting it — a silent outage across the
+ // estate caused by an edit that looked fine.
+ const missing = shipped.requiredTokens.filter(
+ (token) => !parsed.data.body.includes(`{{${token}}}`),
+ );
+ if (missing.length > 0) {
+ return apiValidationError(
+ `This wording no longer includes ${missing
+ .map((t) => `{{${t}}}`)
+ .join(", ")}. Every operator inheriting it would stop publishing.`,
+ route,
+ );
+ }
+
+ // Tokens the merge engine will not be able to fill.
+ const known = new Set([
+ ...shipped.requiredTokens,
+ "tradingName",
+ "supportContactEmail",
+ "governingLaw",
+ "deliveryTerms",
+ "returnsPolicy",
+ "licenceNumber",
+ "regulatorName",
+ "icoRegistrationNumber",
+ "dpoName",
+ "dpoContact",
+ "ukRepresentative",
+ "controllerLegalName",
+ "registeredAddress",
+ "privacyContactEmail",
+ ]);
+ const unknown = findUnresolvedTokens(parsed.data.body).filter(
+ (token) => !known.has(token),
+ );
+ if (unknown.length > 0) {
+ return apiValidationError(
+ `Unknown placeholder(s): ${unknown.map((t) => `{{${t}}}`).join(", ")}.`,
+ route,
+ );
+ }
+
+ const now = new Date();
+ const existing = await prisma.platform_legal_templates.findFirst({
+ where: { slug },
+ });
+
+ const saved = existing
+ ? await prisma.platform_legal_templates.update({
+ where: { slug },
+ data: {
+ body: parsed.data.body,
+ version: parsed.data.version,
+ updatedByUserId: user.id,
+ updatedAt: now,
+ },
+ })
+ : await prisma.platform_legal_templates.create({
+ data: {
+ slug,
+ title: shipped.title,
+ body: parsed.data.body,
+ version: parsed.data.version,
+ updatedByUserId: user.id,
+ createdAt: now,
+ updatedAt: now,
+ },
+ });
+
+ const inheriting = await prisma.tenant_legal_documents.count({
+ where: { slug, mode: "default", publishedAt: { not: null } },
+ });
+
+ await createAuditLog({
+ action: "LEGAL_TEMPLATE_UPDATED",
+ entityType: "platform_legal_template",
+ entityId: slug,
+ userId: user.id,
+ userEmail: user.email,
+ metadata: {
+ version: saved.version,
+ previousVersion: existing?.version ?? shipped.version,
+ storefrontsAffected: inheriting,
+ },
+ ...getClientInfo(request.headers),
+ });
+
+ logger.info("[Legal] Platform template updated", {
+ slug,
+ version: saved.version,
+ storefrontsAffected: inheriting,
+ });
+
+ return NextResponse.json({
+ success: true,
+ template: saved,
+ // Stated back so the effect of the edit is not a surprise.
+ storefrontsAffected: inheriting,
+ });
+ } catch (error) {
+ return apiError(error, { route });
+ }
+});
diff --git a/nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts b/nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts
new file mode 100644
index 00000000..0e8783c2
--- /dev/null
+++ b/nextjs_space/app/api/tenant-admin/legal/documents/[slug]/route.ts
@@ -0,0 +1,169 @@
+import { NextResponse } from "next/server";
+import { randomUUID } from "node:crypto";
+import { z } from "zod";
+import { withTenantAuthParams } from "@/lib/api-auth";
+import { prisma } from "@/lib/db";
+import { apiError, apiValidationError } from "@/lib/api-error";
+import { parseJsonBody } from "@/lib/validation/body";
+import { createAuditLog, AUDIT_ACTIONS, getClientInfo } from "@/lib/audit-log";
+import { LEGAL_DOCUMENT_SLUGS, getLegalDocument, type LegalDocumentSlug } from "@/lib/legal/documents";
+import { renderMarkdown } from "@/lib/legal/markdown";
+import { logger } from "@/lib/logger";
+
+/**
+ * An operator choosing, for one document, between the maintained default and
+ * their own wording — and publishing it.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+const schema = z.object({
+ mode: z.enum(["default", "custom"]),
+ body: z.string().max(200_000).optional(),
+ publish: z.boolean().optional().default(false),
+ /**
+ * Set when switching to custom. Recorded with a timestamp and user, so
+ * responsibility for the wording is never ambiguous in either direction.
+ */
+ acceptResponsibility: z.boolean().optional().default(false),
+});
+
+function isSlug(value: string): value is LegalDocumentSlug {
+ return (LEGAL_DOCUMENT_SLUGS as string[]).includes(value);
+}
+
+export const PUT = withTenantAuthParams(async (request, { user, tenantId }, params) => {
+ const route = "PUT /api/tenant-admin/legal/documents/[slug]";
+ try {
+ if (!isSlug(params.slug)) {
+ return apiError(new Error("Unknown document"), {
+ route,
+ status: 404,
+ safeMessage: "Unknown document.",
+ });
+ }
+ const slug = params.slug;
+
+ const body = await parseJsonBody>(request);
+ const parsed = schema.safeParse(body);
+ if (!parsed.success) {
+ return apiValidationError(
+ parsed.error.issues[0]?.message ?? "Invalid request.",
+ route,
+ );
+ }
+
+ const input = parsed.data;
+ const existing = await prisma.tenant_legal_documents.findFirst({
+ where: { tenantId, slug },
+ });
+ const now = new Date();
+
+ // Publishing your own wording with nothing written would leave the page
+ // serving the "not published" notice — refuse it here so the operator finds
+ // out at the point of action rather than by looking at their live site.
+ if (input.mode === "custom" && input.publish) {
+ const text = (input.body ?? existing?.body ?? "").trim();
+ if (text === "") {
+ return apiValidationError(
+ "Write your wording before publishing, or switch back to the standard text.",
+ route,
+ );
+ }
+ }
+
+ const switchingToCustom =
+ input.mode === "custom" && existing?.mode !== "custom";
+
+ if (switchingToCustom && !input.acceptResponsibility) {
+ return apiError(new Error("Responsibility not accepted"), {
+ route,
+ status: 422,
+ safeMessage:
+ "Using your own wording means you are responsible for its content and for keeping it current. Confirm to continue.",
+ });
+ }
+
+ const responsibility = switchingToCustom
+ ? { responsibilityAcceptedAt: now, responsibilityAcceptedByUserId: user.id }
+ : {};
+
+ // Version is only meaningful on the default. Custom text is the operator's,
+ // and stamping our version on it would misstate what they published.
+ const templateVersion =
+ input.mode === "default" ? getLegalDocument(slug).version : null;
+
+ const data = {
+ mode: input.mode,
+ body: input.mode === "custom" ? (input.body ?? existing?.body ?? "") : existing?.body ?? null,
+ updatedAt: now,
+ templateVersion,
+ ...(input.publish ? { publishedAt: existing?.publishedAt ?? now } : {}),
+ ...responsibility,
+ };
+
+ const saved = existing
+ ? await prisma.tenant_legal_documents.update({
+ where: { id: existing.id },
+ data,
+ })
+ : await prisma.tenant_legal_documents.create({
+ data: {
+ ...data,
+ id: randomUUID(),
+ tenantId,
+ slug,
+ createdAt: now,
+ publishedAt: input.publish ? now : null,
+ },
+ });
+
+ await createAuditLog({
+ action: AUDIT_ACTIONS.SETTINGS_UPDATED,
+ entityType: "tenant_legal_document",
+ entityId: saved.id,
+ tenantId,
+ userId: user.id,
+ userEmail: user.email,
+ metadata: {
+ slug,
+ mode: saved.mode,
+ published: Boolean(saved.publishedAt),
+ switchedToCustom: switchingToCustom,
+ },
+ ...getClientInfo(request.headers),
+ });
+
+ logger.info("[Legal] Tenant document saved", {
+ tenantId,
+ slug,
+ mode: saved.mode,
+ published: Boolean(saved.publishedAt),
+ });
+
+ return NextResponse.json({ success: true, document: saved });
+ } catch (error) {
+ return apiError(error, { route });
+ }
+});
+
+/** Preview the operator's own wording as the storefront would render it. */
+export const POST = withTenantAuthParams(async (request, _ctx, params) => {
+ const route = "POST /api/tenant-admin/legal/documents/[slug]";
+ try {
+ if (!isSlug(params.slug)) {
+ return apiError(new Error("Unknown document"), {
+ route,
+ status: 404,
+ safeMessage: "Unknown document.",
+ });
+ }
+
+ const body = await parseJsonBody>(request);
+ const text = typeof body?.body === "string" ? body.body : "";
+
+ return NextResponse.json({ html: renderMarkdown(text) });
+ } catch (error) {
+ return apiError(error, { route });
+ }
+});
diff --git a/nextjs_space/app/tenant-admin/legal/documents/documents-client.tsx b/nextjs_space/app/tenant-admin/legal/documents/documents-client.tsx
new file mode 100644
index 00000000..6cd888e6
--- /dev/null
+++ b/nextjs_space/app/tenant-admin/legal/documents/documents-client.tsx
@@ -0,0 +1,293 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ AlertTriangle,
+ CheckCircle2,
+ ExternalLink,
+ Eye,
+ FileText,
+ Loader2,
+ Lock,
+ PencilLine,
+} from "lucide-react";
+import { toast } from "@/components/ui/sonner";
+
+interface Doc {
+ slug: string;
+ title: string;
+ summary: string;
+ defaultVersion: string;
+ mode: string;
+ body: string;
+ publishedAt: string | null;
+ liveStatus: "published" | "unpublished";
+ responsibilityAcceptedAt: string | null;
+}
+
+interface Props {
+ documents: Doc[];
+ storefrontBase: string;
+}
+
+export default function DocumentManager({ documents, storefrontBase }: Props) {
+ const router = useRouter();
+ const [open, setOpen] = useState(null);
+ const [drafts, setDrafts] = useState>(() =>
+ Object.fromEntries(documents.map((d) => [d.slug, d.body])),
+ );
+ const [busy, setBusy] = useState(null);
+ const [preview, setPreview] = useState(null);
+
+ const save = useCallback(
+ async (
+ doc: Doc,
+ mode: "default" | "custom",
+ publish: boolean,
+ acceptResponsibility = false,
+ ) => {
+ setBusy(doc.slug);
+ try {
+ const res = await fetch(`/api/tenant-admin/legal/documents/${doc.slug}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ mode,
+ body: mode === "custom" ? drafts[doc.slug] : undefined,
+ publish,
+ acceptResponsibility,
+ }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(json?.error || "Could not save.");
+
+ toast.success(
+ publish
+ ? `${doc.title} published to your site.`
+ : `${doc.title} saved. Publish it to make it live.`,
+ );
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Something went wrong.");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [drafts, router],
+ );
+
+ const switchToCustom = useCallback(
+ (doc: Doc) => {
+ const ok = window.confirm(
+ `Use your own wording for ${doc.title}?\n\n` +
+ `It becomes your document. You are responsible for its content and ` +
+ `for keeping it up to date — we will not update it for you, and ` +
+ `changes we make to the standard wording will no longer reach it.`,
+ );
+ if (!ok) return;
+ setDrafts((d) => ({ ...d, [doc.slug]: d[doc.slug] || "" }));
+ setOpen(doc.slug);
+ void save(doc, "custom", false, true);
+ },
+ [save],
+ );
+
+ const showPreview = useCallback(
+ async (doc: Doc) => {
+ setBusy(doc.slug);
+ try {
+ const res = await fetch(`/api/tenant-admin/legal/documents/${doc.slug}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ body: drafts[doc.slug] }),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(json?.error || "Could not build a preview.");
+ setPreview(json.html);
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Something went wrong.");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [drafts],
+ );
+
+ return (
+
+
+
Your legal pages
+
+ Four documents are published on your site. Use our standard wording,
+ which we keep up to date, or write your own. You can decide separately
+ for each one.
+
+ Nothing is published for this page yet. Visitors are told it is
+ unavailable and pointed to you.
+
+ )}
+
+ {open === doc.slug && (
+
+
+
+ Markdown. Use ## for headings, **bold**,
+ and - for lists.
+
+
+ )}
+
+ );
+ })}
+
+
+ {preview && (
+
+
+
+
Preview
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/nextjs_space/app/tenant-admin/legal/documents/page.tsx b/nextjs_space/app/tenant-admin/legal/documents/page.tsx
new file mode 100644
index 00000000..145273c5
--- /dev/null
+++ b/nextjs_space/app/tenant-admin/legal/documents/page.tsx
@@ -0,0 +1,63 @@
+import { redirect } from "next/navigation";
+import { prisma } from "@/lib/db";
+import { requirePagePermission } from "@/lib/permissions/require-page-permission";
+import { getActiveAdminTenant } from "@/lib/tenant/active-admin-tenant";
+import { LEGAL_DOCUMENTS, LEGAL_DOCUMENT_SLUGS } from "@/lib/legal/documents";
+import { getTenantLegalDocument } from "@/lib/legal/tenant-policy";
+import DocumentManager from "./documents-client";
+
+/**
+ * Where an operator chooses, per document, between the maintained default and
+ * their own wording — and sees what is actually live on their domain.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+export const dynamic = "force-dynamic";
+
+export default async function LegalDocumentsPage() {
+ await requirePagePermission("canEditSettings");
+
+ const active = await getActiveAdminTenant();
+ if (!active) redirect("/auth/login");
+
+ const tenant = await prisma.tenants.findUnique({
+ where: { id: active.tenantId },
+ select: { subdomain: true, customDomain: true },
+ });
+
+ const rows = await prisma.tenant_legal_documents.findMany({
+ where: { tenantId: active.tenantId },
+ });
+
+ // Resolve each document exactly as the storefront will, so the admin reports
+ // what is actually being served rather than what was intended.
+ const live = await Promise.all(
+ LEGAL_DOCUMENT_SLUGS.map((slug) =>
+ getTenantLegalDocument(active.tenantId, slug),
+ ),
+ );
+
+ const documents = LEGAL_DOCUMENT_SLUGS.map((slug, i) => {
+ const row = rows.find((r: { slug: string }) => r.slug === slug) ?? null;
+ const meta = LEGAL_DOCUMENTS[slug];
+ return {
+ slug,
+ title: meta.title,
+ summary: meta.summary,
+ defaultVersion: meta.version,
+ mode: row?.mode ?? "default",
+ body: row?.body ?? "",
+ publishedAt: row?.publishedAt?.toISOString() ?? null,
+ liveStatus: live[i].status,
+ responsibilityAcceptedAt:
+ row?.responsibilityAcceptedAt?.toISOString() ?? null,
+ };
+ });
+
+ const base = tenant?.customDomain
+ ? `https://${tenant.customDomain}`
+ : `/store/${tenant?.subdomain ?? ""}`;
+
+ return ;
+}
diff --git a/nextjs_space/app/tenant-admin/legal/legal-form.tsx b/nextjs_space/app/tenant-admin/legal/legal-form.tsx
index b4b0ed91..90904c2e 100644
--- a/nextjs_space/app/tenant-admin/legal/legal-form.tsx
+++ b/nextjs_space/app/tenant-admin/legal/legal-form.tsx
@@ -277,14 +277,19 @@ export default function LegalProfileForm({
- Policy wording — managed by BudStacks
+ Standard wording — maintained by BudStacks
- The body of the notice is a single document reviewed by our legal
- counsel and kept up to date for every operator, currently version{" "}
- {currentVersion}. You supply the details on the left; we merge them
- in. This is deliberate — a policy each operator writes themselves
- is a policy nobody has checked.
+ These details are merged into the four legal documents published on
+ your site — privacy, terms, cookies and regulatory information.
+ Privacy is previewed below; the rest are managed under{" "}
+ Legal pages, where you can also replace any of them
+ with your own wording.
+
+
+ Fields marked as required for a document must be filled before that
+ document can publish. Terms needs a governing law and support
+ address; regulatory needs your regulator.
diff --git a/nextjs_space/components/admin/TenantAdminSidebar.tsx b/nextjs_space/components/admin/TenantAdminSidebar.tsx
index 22444bbd..63d90b13 100644
--- a/nextjs_space/components/admin/TenantAdminSidebar.tsx
+++ b/nextjs_space/components/admin/TenantAdminSidebar.tsx
@@ -14,6 +14,8 @@ import {
Newspaper,
Cookie,
Scale,
+ FileText,
+ Database,
Mail,
Search,
UsersRound,
@@ -116,10 +118,22 @@ const tenantAdminMenuItems: AdminMenuItem[] = [
},
{
id: "legal",
- label: "Privacy Policy",
+ label: "Company Details",
icon: Scale,
href: "/tenant-admin/legal",
},
+ {
+ id: "legal-documents",
+ label: "Legal Pages",
+ icon: FileText,
+ href: "/tenant-admin/legal/documents",
+ },
+ {
+ id: "legal-subprocessors",
+ label: "Data Processors",
+ icon: Database,
+ href: "/tenant-admin/legal/subprocessors",
+ },
];
interface TenantAdminSidebarProps {
diff --git a/nextjs_space/lib/permissions/nav-permissions.ts b/nextjs_space/lib/permissions/nav-permissions.ts
index 77bbbc60..55ddd553 100644
--- a/nextjs_space/lib/permissions/nav-permissions.ts
+++ b/nextjs_space/lib/permissions/nav-permissions.ts
@@ -22,4 +22,6 @@ export const NAV_ITEM_PERMISSIONS: Record = {
settings: "canEditSettings",
"cookie-settings": undefined,
legal: "canEditSettings",
+ "legal-documents": "canEditSettings",
+ "legal-subprocessors": "canEditSettings",
};
diff --git a/nextjs_space/tests/unit/custom-document-body.test.ts b/nextjs_space/tests/unit/custom-document-body.test.ts
new file mode 100644
index 00000000..1a8bcd83
--- /dev/null
+++ b/nextjs_space/tests/unit/custom-document-body.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { renderMarkdown } from "@/lib/legal/markdown";
+
+/**
+ * Custom document bodies are a larger surface than merge values.
+ *
+ * Previously an operator controlled short fields substituted into our template.
+ * Now they write the entire document, and it is rendered onto a public page on
+ * their own domain — which for a custom domain is a domain we serve.
+ *
+ * The renderer is escape-first, so this should hold. These prove it rather than
+ * assume it, because the assumption is now carrying much more weight.
+ */
+
+const ALLOWED_TAGS = new Set([
+ "h2", "p", "ul", "li", "strong", "table", "thead", "tbody", "tr", "th", "td",
+]);
+
+function disallowedTags(html: string): string[] {
+ const tags = [...html.matchAll(/<\/?([a-z0-9]+)/gi)].map((m) => m[1].toLowerCase());
+ return [...new Set(tags)].filter((tag) => !ALLOWED_TAGS.has(tag));
+}
+
+describe("an operator's own document body cannot introduce markup", () => {
+ it.each([
+ ["script tag", ""],
+ ["img onerror", ''],
+ ["iframe", ""],
+ ["svg onload", "