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
new file mode 100644
index 00000000..ca3c4ed3
--- /dev/null
+++ b/docs/compliance/2026-07-28-data-protection-response.md
@@ -0,0 +1,111 @@
+# Response to Data Protection Queries — BudStacks
+
+**Date:** 28 July 2026
+**Re:** Items (a)–(e) raised ahead of resuming template work
+
+> **Internal note — not for sending.** Needs BudStacks' legal entity details
+> and LHI's engaged role filled in before this goes anywhere.
+
+---
+
+## Summary
+
+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 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
+
+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.
+
+**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.
+
+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 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.
+
+**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
+
+We would like to confirm one point before responding substantively: **who issues the CannExpert subscriber agreement** — CannExpert, Dr Green, or BudStacks?
+
+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.
+
+---
+
+## (c) Upcann SW FZCO and the Article 46 transfer mechanism
+
+**BudStacks has no relationship with Upcann SW FZCO.** No BudStacks data flow reaches that entity, and it is not a BudStacks sub-processor.
+
+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.
+
+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 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 are confirming two points internally and would rather answer accurately than quickly:
+
+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 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.
+
+**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
+
+This is now a working mechanism rather than a stated intention.
+
+**The register is live data.** Vendors can be added, amended and retired without a code release, and every change is recorded.
+
+**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.
+
+**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.
+
+**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.
+
+**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.
+
+---
+
+## Data minimisation
+
+As part of this work we completed a minimisation review of what BudStacks stores.
+
+**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.
+
+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 | Awaiting |
+|---|---|
+| (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 dependent on any of these. (a) and (e) are in place and can be demonstrated on request.
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/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/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/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/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/super-admin/legal-templates/page.tsx b/nextjs_space/app/super-admin/legal-templates/page.tsx
new file mode 100644
index 00000000..cb03b87c
--- /dev/null
+++ b/nextjs_space/app/super-admin/legal-templates/page.tsx
@@ -0,0 +1,57 @@
+import { currentUser } from "@clerk/nextjs/server";
+import { redirect } from "next/navigation";
+import { prisma } from "@/lib/db";
+import { LEGAL_DOCUMENTS, LEGAL_DOCUMENT_SLUGS } from "@/lib/legal/documents";
+import TemplateEditor from "./templates-client";
+
+/**
+ * The standard wording every operator on `default` inherits.
+ *
+ * Database-backed so a solicitor's or DPO's revisions do not need a developer.
+ * Until a document is edited here the shipped wording is served, so this table
+ * starts empty rather than seeded — the first save creates the row.
+ *
+ * See docs/PRDS/prd-data-protection-remediation.md.
+ */
+
+export const dynamic = "force-dynamic";
+
+export default async function LegalTemplatesPage() {
+ const user = await currentUser();
+
+ if (!user || user.publicMetadata.role !== "SUPER_ADMIN") {
+ redirect("/auth/login");
+ }
+
+ const stored = await prisma.platform_legal_templates.findMany();
+
+ // How many live storefronts each edit would reach. Stated up front, because
+ // changing a document here changes it for every operator inheriting it.
+ const counts = await prisma.tenant_legal_documents.groupBy({
+ by: ["slug"],
+ where: { mode: "default", publishedAt: { not: null } },
+ _count: { _all: true },
+ });
+
+ const templates = LEGAL_DOCUMENT_SLUGS.map((slug) => {
+ const meta = LEGAL_DOCUMENTS[slug];
+ const row = stored.find((r: { slug: string }) => r.slug === slug) ?? null;
+ const count =
+ counts.find((c: { slug: string }) => c.slug === slug)?._count?._all ?? 0;
+
+ return {
+ slug,
+ title: meta.title,
+ summary: meta.summary,
+ requiredTokens: [...meta.requiredTokens],
+ body: row?.body ?? meta.template,
+ version: row?.version ?? meta.version,
+ shippedVersion: meta.version,
+ edited: Boolean(row),
+ storefrontsInheriting: count,
+ updatedAt: row?.updatedAt?.toISOString() ?? null,
+ };
+ });
+
+ return ;
+}
diff --git a/nextjs_space/app/super-admin/legal-templates/templates-client.tsx b/nextjs_space/app/super-admin/legal-templates/templates-client.tsx
new file mode 100644
index 00000000..37eed00b
--- /dev/null
+++ b/nextjs_space/app/super-admin/legal-templates/templates-client.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useCallback, useState } from "react";
+import { useRouter } from "next/navigation";
+import { AlertTriangle, FileText, Loader2, Users } from "lucide-react";
+import { toast } from "@/components/ui/sonner";
+
+interface Template {
+ slug: string;
+ title: string;
+ summary: string;
+ requiredTokens: string[];
+ body: string;
+ version: string;
+ shippedVersion: string;
+ edited: boolean;
+ storefrontsInheriting: number;
+ updatedAt: string | null;
+}
+
+export default function TemplateEditor({ templates }: { templates: Template[] }) {
+ const router = useRouter();
+ const [open, setOpen] = useState(null);
+ const [drafts, setDrafts] = useState>(
+ () =>
+ Object.fromEntries(
+ templates.map((t) => [t.slug, { body: t.body, version: t.version }]),
+ ),
+ );
+ const [busy, setBusy] = useState(null);
+
+ const save = useCallback(
+ async (tpl: Template) => {
+ const draft = drafts[tpl.slug];
+
+ if (draft.version === tpl.version) {
+ toast.error("Bump the version — operators inherit this immediately.");
+ return;
+ }
+
+ const ok = window.confirm(
+ `Publish new wording for ${tpl.title}?\n\n` +
+ `${tpl.storefrontsInheriting} live storefront(s) inherit this and will ` +
+ `serve the new text immediately. Operators using their own wording are ` +
+ `not affected.`,
+ );
+ if (!ok) return;
+
+ setBusy(tpl.slug);
+ try {
+ const res = await fetch(`/api/super-admin/legal-templates/${tpl.slug}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(draft),
+ });
+ const json = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(json?.error || "Could not save.");
+
+ toast.success(
+ `${tpl.title} updated — ${json.storefrontsAffected} storefront(s) now serving it.`,
+ );
+ router.refresh();
+ } catch (error) {
+ toast.error(error instanceof Error ? error.message : "Something went wrong.");
+ } finally {
+ setBusy(null);
+ }
+ },
+ [drafts, router],
+ );
+
+ return (
+
+
+
Standard legal wording
+
+ The documents every operator inherits unless they write their own.
+ Editing here changes what live storefronts serve, immediately.
+
+
+ {tpl.storefrontsInheriting} live storefront
+ {tpl.storefrontsInheriting === 1 ? "" : "s"} inherit this
+
+
+
+
+
+ {open === tpl.slug && (
+
+
+
+
+ These placeholders must stay in the text or every operator
+ inheriting it stops publishing:{" "}
+
+ {tpl.requiredTokens.map((t) => `{{${t}}}`).join(" ")}
+
+
+ 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/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 065c294d..90904c2e 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({
@@ -233,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.
+ 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.
+
- 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/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/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/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
diff --git a/nextjs_space/lib/legal/tenant-policy.ts b/nextjs_space/lib/legal/tenant-policy.ts
index c90eadc4..2d0dc66c 100644
--- a/nextjs_space/lib/legal/tenant-policy.ts
+++ b/nextjs_space/lib/legal/tenant-policy.ts
@@ -1,33 +1,37 @@
/**
- * 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 { resolveDocumentSource } from "./document-resolution";
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,73 +41,186 @@ 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 {
- // findFirst with a flat field: the tenant-scoping $extends rewrites findUnique
+ slug: LegalDocumentSlug,
+): Promise {
+ const shipped = getLegalDocument(slug);
+
+ // 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 } }),
+ ]);
- if (!profile) return { status: "unpublished", reason: "no-profile" };
- if (!profile.publishedAt) return { status: "unpublished", reason: "not-published" };
+ 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",
+ };
+ }
+
+ // 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 (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",
- html: renderPolicyHtml(profile),
- publishedAt: profile.publishedAt,
- templateVersion: profile.templateVersion ?? PRIVACY_TEMPLATE_VERSION,
- controllerLegalName: singleLine(profile.controllerLegalName),
+ slug,
+ 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 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, 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", reason: "incomplete" };
+ return {
+ status: "unpublished",
+ slug,
+ title: shipped.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/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/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/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/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 bd14a201..bd9a85dd 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.
@@ -184,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
@@ -559,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/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);
+});
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..90ed31c9
--- /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, 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);
+ });
+});
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", "