From 614341a3f54fad27c6ff588cdb23765169d34a62 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Tue, 28 Jul 2026 11:06:31 +0100 Subject: [PATCH 01/11] feat(legal): stand behind the legal documents; schedule the register tick MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things, both about promises the platform was making but not keeping. 1. Removes the "Draft — pending counsel review… not yet binding" banner from /privacy, /dpa, /terms, /aup and /legal/subprocessors, and deletes the component. Asking operators to accept a DPA that the same page declares non- binding is a weaker position than standing behind it. No "reviewed by" line is added in its place — claiming a review that has not happened would be worse than the banner was. Counsel or a DPO can be engaged later and credited then. 2. Schedules the sub-processor activation tick. activateDueSubprocessors existed but NOTHING called it, so a pending vendor would never go live on its effective date — the register told operators a date and nothing made that date mean anything. The endpoint is idempotent and safe to miss: a late run activates the same entries, and there is no per-run state to lose. It authenticates on a constant-time CRON_SECRET header rather than an api-auth wrapper, because an external scheduler has no user session, and it FAILS CLOSED when the secret is unset — an unconfigured deploy is inert rather than open. Added to the reviewed auth allow-list with that reasoning, and a test pins both the exemption and the fail-closed justification so neither can be quietly widened. Still needs a scheduler pointed at it daily (Railway cron or equivalent); I cannot add the GitHub Actions workflow file here because pushes touching .github/workflows are rejected over HTTPS on this remote. Refs docs/PRDS/prd-data-protection-remediation.md (WS3 US-013, WS4 US-017) --- .../app/api/cron/subprocessors/route.ts | 68 +++++++++++++++++++ nextjs_space/app/aup/page.tsx | 2 - nextjs_space/app/dpa/page.tsx | 2 - nextjs_space/app/legal/subprocessors/page.tsx | 2 - nextjs_space/app/privacy/page.tsx | 2 - nextjs_space/app/terms/page.tsx | 2 - .../components/legal/LegalDraftNotice.tsx | 32 --------- nextjs_space/lib/auth-public-routes.ts | 7 ++ .../unit/cron-subprocessors-auth.test.ts | 29 ++++++++ 9 files changed, 104 insertions(+), 42 deletions(-) create mode 100644 nextjs_space/app/api/cron/subprocessors/route.ts delete mode 100644 nextjs_space/components/legal/LegalDraftNotice.tsx create mode 100644 nextjs_space/tests/unit/cron-subprocessors-auth.test.ts diff --git a/nextjs_space/app/api/cron/subprocessors/route.ts b/nextjs_space/app/api/cron/subprocessors/route.ts new file mode 100644 index 00000000..99900b55 --- /dev/null +++ b/nextjs_space/app/api/cron/subprocessors/route.ts @@ -0,0 +1,68 @@ +import { NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import type { NextRequest } from "next/server"; +import { activateDueSubprocessors } from "@/lib/legal/subprocessor-announce"; +import { apiError } from "@/lib/api-error"; +import { logger } from "@/lib/logger"; + +/** + * Scheduled tick for the sub-processor register. + * + * Flips pending entries to active once their effective date arrives. Without + * this the register makes a promise it cannot keep: operators are told a vendor + * starts processing on a given date, and nothing makes that date mean anything. + * + * Idempotent — safe to call repeatedly, and safe to miss. A run that is a day + * late activates the same entries; there is no per-run state to lose. + * + * Point a scheduler at this daily: + * curl -X POST https:///api/cron/subprocessors \ + * -H "x-cron-secret: $CRON_SECRET" + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-013). + */ + +export const dynamic = "force-dynamic"; + +/** Constant-time compare so the secret cannot be probed byte by byte. */ +function secretMatches(provided: string, expected: string): boolean { + const a = Buffer.from(provided); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export async function POST(request: NextRequest) { + const route = "POST /api/cron/subprocessors"; + try { + const expected = process.env.CRON_SECRET; + + // Fail CLOSED. An unset secret must not leave the endpoint open — it is the + // difference between "not scheduled yet" and "anyone can drive the register". + if (!expected) { + logger.error("[Cron] CRON_SECRET is not configured; refusing to run"); + return apiError(new Error("CRON_SECRET not configured"), { + route, + status: 503, + safeMessage: "Scheduled tasks are not configured.", + }); + } + + const provided = request.headers.get("x-cron-secret"); + if (!provided || !secretMatches(provided, expected)) { + // Deliberately terse: a caller without the secret learns nothing about + // whether the endpoint or the secret was wrong. + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const activated = await activateDueSubprocessors(); + + logger.info("[Cron] Sub-processor register tick", { + activated: activated.length, + }); + + return NextResponse.json({ success: true, activated }); + } catch (error) { + return apiError(error, { route }); + } +} diff --git a/nextjs_space/app/aup/page.tsx b/nextjs_space/app/aup/page.tsx index a5ea2e14..e69a4ffe 100644 --- a/nextjs_space/app/aup/page.tsx +++ b/nextjs_space/app/aup/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { ShieldAlert, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Acceptable Use Policy | BudStacks", @@ -200,7 +199,6 @@ export default function AupPage() {

-
diff --git a/nextjs_space/app/dpa/page.tsx b/nextjs_space/app/dpa/page.tsx index 50c7a575..284d35e6 100644 --- a/nextjs_space/app/dpa/page.tsx +++ b/nextjs_space/app/dpa/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { FileSignature, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Data Processing Agreement | BudStacks", @@ -290,7 +289,6 @@ export default function DpaPage() {

-
diff --git a/nextjs_space/app/legal/subprocessors/page.tsx b/nextjs_space/app/legal/subprocessors/page.tsx index 8decaee4..654acad6 100644 --- a/nextjs_space/app/legal/subprocessors/page.tsx +++ b/nextjs_space/app/legal/subprocessors/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Clock, Database, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; import { prisma } from "@/lib/db"; import type { SubprocessorRecord } from "@/lib/legal/subprocessor-notice"; @@ -59,7 +58,6 @@ export default async function SubprocessorsPage() {

-

diff --git a/nextjs_space/app/privacy/page.tsx b/nextjs_space/app/privacy/page.tsx index b2d3df13..acab266c 100644 --- a/nextjs_space/app/privacy/page.tsx +++ b/nextjs_space/app/privacy/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Shield, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Privacy Policy | BudStacks", @@ -318,7 +317,6 @@ export default function PrivacyPage() {

- {/* Content Card */}
diff --git a/nextjs_space/app/terms/page.tsx b/nextjs_space/app/terms/page.tsx index 52137b94..215ae29d 100644 --- a/nextjs_space/app/terms/page.tsx +++ b/nextjs_space/app/terms/page.tsx @@ -3,7 +3,6 @@ import Link from "next/link"; import { Scale, FileText } from "lucide-react"; import Navbar from "@/components/landing/Navbar"; import Footer from "@/components/landing/Footer"; -import { LegalDraftNotice } from "@/components/legal/LegalDraftNotice"; export const metadata: Metadata = { title: "Terms of Service | BudStacks", @@ -357,7 +356,6 @@ export default function TermsPage() {

- {/* Content Card */}
diff --git a/nextjs_space/components/legal/LegalDraftNotice.tsx b/nextjs_space/components/legal/LegalDraftNotice.tsx deleted file mode 100644 index f4379020..00000000 --- a/nextjs_space/components/legal/LegalDraftNotice.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { AlertTriangle } from "lucide-react"; - -/** - * Banner shown above legal pages that have not yet completed counsel review. - * Remove (or replace with a "Reviewed by [firm]") banner once counsel signs off. - */ -export function LegalDraftNotice({ documentName }: { documentName: string }) { - return ( -
-
- -
-

- Draft — pending counsel review -

-

- This {documentName} is a working draft prepared for review by - BudStacks' legal counsel. It is not yet binding and may change - substantially before publication. For questions, contact{" "} - - legal@budstacks.io - - . -

-
-
-
- ); -} diff --git a/nextjs_space/lib/auth-public-routes.ts b/nextjs_space/lib/auth-public-routes.ts index 7b62c3c3..d3dbf8d2 100644 --- a/nextjs_space/lib/auth-public-routes.ts +++ b/nextjs_space/lib/auth-public-routes.ts @@ -101,6 +101,13 @@ export const AUTH_PUBLIC_ROUTES: readonly PublicRoute[] = [ pattern: "/api/store/[slug]/products/featured", reason: "Public storefront read: featured products by tenant slug.", }, + { + pattern: "/api/cron/subprocessors", + reason: + "Scheduled tick called by an external scheduler with no user session; " + + "authenticates on a constant-time CRON_SECRET header and fails CLOSED when " + + "the secret is unset, so an unconfigured deploy is inert rather than open.", + }, ]; /** diff --git a/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts b/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts new file mode 100644 index 00000000..ac33a7d0 --- /dev/null +++ b/nextjs_space/tests/unit/cron-subprocessors-auth.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { AUTH_PUBLIC_ROUTES, n as isAuthPublicRoute } from "@/lib/auth-public-routes"; + +/** + * WS3 US-013 — the scheduled tick is deliberately outside the api-auth + * wrappers, because an external scheduler has no user session. That makes its + * own auth the only thing standing between the internet and the register, so + * the exemption is pinned here with the reason it exists. + */ + +describe("the cron tick is an intentional, documented exemption", () => { + it("is on the reviewed allow-list", () => { + expect(isAuthPublicRoute("/api/cron/subprocessors")).toBe(true); + }); + + it("carries a justification naming its own auth", () => { + const entry = AUTH_PUBLIC_ROUTES.find( + (route) => route.pattern === "/api/cron/subprocessors", + ); + expect(entry).toBeDefined(); + expect(entry!.reason).toMatch(/CRON_SECRET/); + // The fail-closed property is the whole reason this is safe to exempt. + expect(entry!.reason).toMatch(/fails CLOSED/i); + }); + + it("does not accidentally exempt the whole /api/cron namespace", () => { + expect(isAuthPublicRoute("/api/cron/anything-else")).toBe(false); + }); +}); From 645c1a2a9485bd0749bf046da0410947836ff111 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Tue, 28 Jul 2026 11:09:13 +0100 Subject: [PATCH 02/11] feat(legal): super-admin register screen + purge-ledger script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the remaining gaps. Register screen (US-012). The register was API-only, so changing it meant curl. The screen makes the distinction the API enforces visible: saving a vendor tells nobody, announcing emails every operator and opens an objection window that cannot be closed again — so announcing asks for confirmation and says what it will do. - the date picker will not offer a date inside the notice period - open objections are surfaced at the top, with the objecting operator named, because an objection nobody reads is the same as no mechanism - retire prompts for a reason, which is recorded on the register Purge-ledger script (unblocks US-005). The counts a migration captured before destroying data were sitting in compliance_purge_records, but reading them meant hand-writing SQL against production — which is why they went uncollected for a week and the evidence record still says "pending". Now `pnpm compliance:purge-record`. It also says something useful when the table is empty: that the migration may not have run against the database you are pointed at, which is the likely cause and not obvious from a blank result. Refs docs/PRDS/prd-data-protection-remediation.md (WS3 US-012, WS1 US-005) --- .../app/super-admin/subprocessors/page.tsx | 52 +++ .../subprocessors/register-client.tsx | 345 ++++++++++++++++++ .../components/admin/SuperAdminSidebar.tsx | 8 + nextjs_space/package.json | 3 +- .../scripts/compliance-purge-record.ts | 100 +++++ 5 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 nextjs_space/app/super-admin/subprocessors/page.tsx create mode 100644 nextjs_space/app/super-admin/subprocessors/register-client.tsx create mode 100644 nextjs_space/scripts/compliance-purge-record.ts diff --git a/nextjs_space/app/super-admin/subprocessors/page.tsx b/nextjs_space/app/super-admin/subprocessors/page.tsx new file mode 100644 index 00000000..ca3f0963 --- /dev/null +++ b/nextjs_space/app/super-admin/subprocessors/page.tsx @@ -0,0 +1,52 @@ +import { currentUser } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; +import { prisma } from "@/lib/db"; +import { MIN_NOTICE_DAYS, type SubprocessorRecord } from "@/lib/legal/subprocessor-notice"; +import SubprocessorRegister from "./register-client"; + +/** + * Sub-processor register management. + * + * Adding a vendor here starts a clock that ends in an email to every operator, + * so the screen is deliberately explicit about the difference between saving a + * draft and announcing it. See docs/PRDS/prd-data-protection-remediation.md + * (WS3, US-012). + */ + +export const dynamic = "force-dynamic"; + +interface RegisterRow extends SubprocessorRecord { + _count: { objections: number }; +} + +export default async function SubprocessorsAdminPage() { + const user = await currentUser(); + + if (!user || user.publicMetadata.role !== "SUPER_ADMIN") { + redirect("/auth/login"); + } + + // Annotated because `prisma` is exported as `any`. + const entries: RegisterRow[] = await prisma.subprocessors.findMany({ + orderBy: [{ status: "asc" }, { name: "asc" }], + include: { _count: { select: { objections: { where: { status: "open" } } } } }, + }); + + const openObjections = await prisma.subprocessor_objections.findMany({ + where: { status: "open" }, + orderBy: { createdAt: "desc" }, + include: { + subprocessor: { select: { name: true } }, + tenants: { select: { businessName: true } }, + }, + }); + + return ( + + ); +} diff --git a/nextjs_space/app/super-admin/subprocessors/register-client.tsx b/nextjs_space/app/super-admin/subprocessors/register-client.tsx new file mode 100644 index 00000000..60894513 --- /dev/null +++ b/nextjs_space/app/super-admin/subprocessors/register-client.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + AlertTriangle, + CheckCircle2, + Clock, + Loader2, + Megaphone, + Plus, + Archive, +} from "lucide-react"; +import { toast } from "@/components/ui/sonner"; + +interface Entry { + id: string; + name: string; + purpose: string; + region: string; + transferMechanism: string; + dpaUrl: string | null; + status: string; + effectiveFrom: string; + announcedAt: string | null; + notes: string | null; + _count?: { objections: number }; +} + +interface Objection { + id: string; + reason: string; + outOfWindow: boolean; + createdAt: string; + subprocessor: { name: string }; + tenants: { businessName: string }; +} + +interface Props { + entries: Entry[]; + objections: Objection[]; + minNoticeDays: number; + todayIso: string; +} + +const BLANK = { + id: "", + name: "", + purpose: "", + region: "", + transferMechanism: "", + dpaUrl: "", + notes: "", +}; + +function fmt(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); +} + +export default function SubprocessorRegister({ + entries, + objections, + minNoticeDays, + todayIso, +}: Props) { + const router = useRouter(); + const [draft, setDraft] = useState(BLANK); + const [effectiveFrom, setEffectiveFrom] = useState(""); + const [busy, setBusy] = useState(null); + const [showForm, setShowForm] = useState(false); + + // The earliest date that still gives operators the notice the DPA promises. + const earliest = useMemo(() => { + const d = new Date(todayIso); + d.setDate(d.getDate() + minNoticeDays); + return d.toISOString().slice(0, 10); + }, [todayIso, minNoticeDays]); + + const call = useCallback( + async (url: string, method: string, body?: unknown) => { + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json?.error || "Something went wrong."); + return json; + }, + [], + ); + + const onCreate = useCallback(async () => { + setBusy("create"); + try { + await call("/api/super-admin/subprocessors", "POST", { + ...draft, + effectiveFrom: effectiveFrom || earliest, + }); + toast.success("Draft saved. Nobody has been told yet — announce when ready."); + setDraft(BLANK); + setEffectiveFrom(""); + setShowForm(false); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not save."); + } finally { + setBusy(null); + } + }, [call, draft, effectiveFrom, earliest, router]); + + const onAnnounce = useCallback( + async (entry: Entry) => { + const ok = window.confirm( + `Email every active operator about ${entry.name}?\n\n` + + `This starts the objection window and cannot be undone.`, + ); + if (!ok) return; + + setBusy(entry.id); + try { + const result = await call( + `/api/super-admin/subprocessors/${entry.id}`, + "POST", + ); + toast.success( + `Announced to ${result.announced} operator(s)` + + (result.failed ? `, ${result.failed} failed — check the logs.` : "."), + ); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not announce."); + } finally { + setBusy(null); + } + }, + [call, router], + ); + + const onRetire = useCallback( + async (entry: Entry) => { + const reason = window.prompt( + `Retire ${entry.name}? Give a reason — it is recorded on the register.`, + ); + if (!reason) return; + + setBusy(entry.id); + try { + await call(`/api/super-admin/subprocessors/${entry.id}`, "DELETE", { reason }); + toast.success(`${entry.name} retired.`); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Could not retire."); + } finally { + setBusy(null); + } + }, + [call, router], + ); + + return ( +
+
+
+

Sub-processors

+

+ Vendors that process operator data. Operators are entitled to{" "} + {minNoticeDays} days' notice before a new one starts, and to + object. Saving an entry tells nobody — announcing does. +

+
+ +
+ + {objections.length > 0 && ( +
+
+ + {objections.length} open objection{objections.length === 1 ? "" : "s"} +
+
    + {objections.map((o) => ( +
  • + {o.tenants.businessName} objected + to {o.subprocessor.name} on{" "} + {fmt(o.createdAt)} + {o.outOfWindow && ( + (outside the window) + )} +
    {o.reason}
    +
  • + ))} +
+
+ )} + + {showForm && ( +
+ {[ + { k: "id", label: "Slug", placeholder: "postmark" }, + { k: "name", label: "Vendor name", placeholder: "Postmark" }, + { k: "purpose", label: "What they do", placeholder: "Transactional email delivery" }, + { k: "region", label: "Where they process", placeholder: "United States" }, + { k: "transferMechanism", label: "Transfer safeguard", placeholder: "EU SCCs + UK addendum" }, + { k: "dpaUrl", label: "DPA URL (optional)", placeholder: "https://…" }, + ].map((f) => ( +
+ + )[f.k]} + placeholder={f.placeholder} + onChange={(e) => setDraft({ ...draft, [f.k]: e.target.value })} + className="mt-1.5 w-full rounded-lg border border-bs-border bg-transparent px-3 py-2 text-sm text-bs-fg outline-none focus:border-bs-green" + /> +
+ ))} + +
+ + setEffectiveFrom(e.target.value)} + className="mt-1.5 w-full rounded-lg border border-bs-border bg-transparent px-3 py-2 text-sm text-bs-fg outline-none focus:border-bs-green" + /> +

+ Earliest is {earliest} — {minNoticeDays} days from today. +

+
+ +
+ +
+
+ )} + +
+ + + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
VendorStatusAnnouncedStartsActions
+
{entry.name}
+
{entry.purpose}
+
+ {entry.region} · {entry.transferMechanism} +
+ {entry.notes && ( +
{entry.notes}
+ )} +
+ {entry.status === "active" && ( + + In force + + )} + {entry.status === "pending" && ( + + + {entry.announcedAt ? "In notice" : "Not announced"} + + )} + {entry.status === "retired" && ( + Retired + )} + {entry._count && entry._count.objections > 0 && ( +
+ {entry._count.objections} objection(s) +
+ )} +
{fmt(entry.announcedAt)}{fmt(entry.effectiveFrom)} +
+ {entry.status === "pending" && !entry.announcedAt && ( + + )} + {entry.status !== "retired" && ( + + )} +
+
+
+
+ ); +} diff --git a/nextjs_space/components/admin/SuperAdminSidebar.tsx b/nextjs_space/components/admin/SuperAdminSidebar.tsx index daf6f274..a44ef2df 100644 --- a/nextjs_space/components/admin/SuperAdminSidebar.tsx +++ b/nextjs_space/components/admin/SuperAdminSidebar.tsx @@ -11,6 +11,7 @@ import { Settings, Mail, GraduationCap, + Database, } from "lucide-react"; import { AdminSidebar, type AdminMenuItem } from "./AdminSidebar"; @@ -28,6 +29,7 @@ export enum PanelType { LEARNING = "learning", EMAILS = "emails", SETTINGS = "settings", + SUBPROCESSORS = "subprocessors", } /** @@ -91,6 +93,12 @@ const superAdminMenuItems: AdminMenuItem[] = [ icon: Mail, href: "/super-admin/emails", }, + { + id: PanelType.SUBPROCESSORS, + label: "Sub-processors", + icon: Database, + href: "/super-admin/subprocessors", + }, { id: PanelType.SETTINGS, label: "Settings", diff --git a/nextjs_space/package.json b/nextjs_space/package.json index 544942b3..bb31e43a 100644 --- a/nextjs_space/package.json +++ b/nextjs_space/package.json @@ -30,7 +30,8 @@ "postinstall": "prisma generate", "email:worker": "tsx scripts/email-worker.ts", "sync-templates": "tsx scripts/sync-template-registry.ts", - "sync-s3-templates": "tsx scripts/sync-templates-from-s3.ts" + "sync-s3-templates": "tsx scripts/sync-templates-from-s3.ts", + "compliance:purge-record": "npx tsx scripts/compliance-purge-record.ts" }, "prisma": { "seed": "npx tsx scripts/seed.ts" diff --git a/nextjs_space/scripts/compliance-purge-record.ts b/nextjs_space/scripts/compliance-purge-record.ts new file mode 100644 index 00000000..ca07c5a3 --- /dev/null +++ b/nextjs_space/scripts/compliance-purge-record.ts @@ -0,0 +1,100 @@ +/** + * Print the data-protection purge ledger. + * + * The counts a migration captured immediately before destroying data live in + * `compliance_purge_records`. They are the evidence a purge happened and what it + * cost — the thing a data protection reviewer actually asks for — but reading + * them meant hand-writing SQL against production, which is why they sat + * uncollected. + * + * Usage: + * npx tsx scripts/compliance-purge-record.ts + * npx tsx scripts/compliance-purge-record.ts --json + * + * Read-only. Touches nothing. + * + * See docs/compliance/2026-07-27-article9-purge.md + */ + +import { PrismaClient } from "@prisma/client"; + +interface PurgeRecord { + id: string; + purgeName: string; + executedAt: Date; + details: Record; +} + +function formatValue(value: unknown): string { + if (Array.isArray(value)) return `${value.length} item(s)`; + if (value === null || value === undefined) return "—"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +async function main(): Promise { + const asJson = process.argv.includes("--json"); + const prisma = new PrismaClient(); + + try { + const records: PurgeRecord[] = await prisma.$queryRawUnsafe( + `SELECT "id", "purgeName", "executedAt", "details" + FROM "compliance_purge_records" + ORDER BY "executedAt" DESC`, + ); + + if (records.length === 0) { + console.log( + "No purge records found.\n\n" + + "If you expected one, the migration may not have run on this database. " + + "Check that the deploy carrying 20260727000000_drop_article9_health_columns " + + "completed against the environment you are pointed at (DATABASE_URL).", + ); + return; + } + + if (asJson) { + console.log(JSON.stringify(records, null, 2)); + return; + } + + for (const record of records) { + console.log(`\n${"=".repeat(72)}`); + console.log(record.purgeName); + console.log(`${"=".repeat(72)}`); + console.log(`id ${record.id}`); + console.log(`executed at ${record.executedAt.toISOString()}`); + console.log(""); + + const details = record.details ?? {}; + const width = Math.max(...Object.keys(details).map((k) => k.length), 12); + + // Counts first — they are what gets pasted into the evidence record. + for (const [key, value] of Object.entries(details)) { + if (typeof value !== "string" || value.length <= 80) { + console.log(` ${key.padEnd(width)} ${formatValue(value)}`); + } + } + + // Long prose (lawful-basis conclusions and the like) reads better after. + for (const [key, value] of Object.entries(details)) { + if (typeof value === "string" && value.length > 80) { + console.log(`\n ${key}:\n ${value}`); + } + } + console.log(""); + } + + console.log( + `${"-".repeat(72)}\n` + + "Paste these into docs/compliance/.md §4 to close the evidence gap.\n", + ); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error) => { + console.error("Failed to read the purge ledger:", error); + process.exit(1); +}); From 9e7cf3c84b53188e52512880223ee6f39e7d39ae Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Tue, 28 Jul 2026 11:10:25 +0100 Subject: [PATCH 03/11] feat(legal): operator-facing sub-processor view and objection form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The objection endpoint existed with nothing reaching it. A right that can only be exercised by finding an email address on a legal page is not much of a right. Operators now see, in their own dashboard, which vendors process their customers' data, what is changing and when it starts — and can object in place, with the objection recorded against that specific vendor. The announcement email now points at this screen rather than only at a mailbox, so the notice and the mechanism to respond to it are joined up. Refs docs/PRDS/prd-data-protection-remediation.md (WS3, US-014) --- .../legal/subprocessors/objections-client.tsx | 214 ++++++++++++++++++ .../tenant-admin/legal/subprocessors/page.tsx | 46 ++++ .../lib/legal/subprocessor-announce.ts | 10 +- 3 files changed, 266 insertions(+), 4 deletions(-) create mode 100644 nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx create mode 100644 nextjs_space/app/tenant-admin/legal/subprocessors/page.tsx diff --git a/nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx b/nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx new file mode 100644 index 00000000..0b64611c --- /dev/null +++ b/nextjs_space/app/tenant-admin/legal/subprocessors/objections-client.tsx @@ -0,0 +1,214 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, Clock, Loader2, ShieldQuestion } from "lucide-react"; +import { toast } from "@/components/ui/sonner"; + +interface Entry { + id: string; + name: string; + purpose: string; + region: string; + transferMechanism: string; + status: string; + effectiveFrom: string; + announcedAt: string | null; +} + +interface Objection { + id: string; + reason: string; + status: string; + outOfWindow: boolean; + createdAt: string; + subprocessor: { name: string }; +} + +interface Props { + entries: Entry[]; + objections: Objection[]; + objectionWindowDays: number; +} + +function fmt(iso: string | null): string { + if (!iso) return "—"; + return new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +export default function OperatorSubprocessorView({ + entries, + objections, + objectionWindowDays, +}: Props) { + const router = useRouter(); + const [objectingTo, setObjectingTo] = useState(null); + const [reason, setReason] = useState(""); + const [busy, setBusy] = useState(false); + + const pending = entries.filter((e) => e.status === "pending"); + + const submit = useCallback(async () => { + if (!objectingTo) return; + setBusy(true); + try { + const res = await fetch("/api/tenant-admin/subprocessor-objections", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ subprocessorId: objectingTo.id, reason }), + }); + const json = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(json?.error || "Could not record your objection."); + + toast.success(json.message ?? "Objection recorded."); + setObjectingTo(null); + setReason(""); + router.refresh(); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Something went wrong."); + } finally { + setBusy(false); + } + }, [objectingTo, reason, router]); + + return ( +
+
+

Who processes your data

+

+ These are the vendors BudStacks uses to run your storefront. You are + told at least 30 days before a new one starts, and you have{" "} + {objectionWindowDays} days from that notice to object. +

+
+ + {pending.length > 0 && ( +
+
+ + {pending.length} upcoming change{pending.length === 1 ? "" : "s"} +
+

+ Not processing yet. If you object, do it before the start date below. +

+
+ )} + + {objections.length > 0 && ( +
+

Your objections

+
    + {objections.map((o) => ( +
  • + {o.subprocessor.name} —{" "} + {fmt(o.createdAt)} · {o.status} + {o.outOfWindow && ( + (raised late) + )} +
    {o.reason}
    +
  • + ))} +
+
+ )} + +
+ + + + + + + + + + + {entries.map((entry) => ( + + + + + + + + ))} + +
VendorWhereSafeguardStatus +
+
{entry.name}
+
{entry.purpose}
+
{entry.region}{entry.transferMechanism} + {entry.status === "active" ? ( + "In use" + ) : ( + + From {fmt(entry.effectiveFrom)} + + )} + + +
+
+ + {objectingTo && ( +
+
+
+ +
+

+ Object to {objectingTo.name} +

+

+ Tell us why. We record it against this vendor and respond + before the change takes effect. +

+
+
+ +