From 768b1036540219173b484ef65d6512aa4fd067ae Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 27 Jul 2026 20:35:56 +0100 Subject: [PATCH 1/4] feat(legal): database-backed sub-processor register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list was a hardcoded array in the page component, so it could only change with a deploy, and nothing could start the 30-day notice clock the DPA promises operators. Art. 28(2)/(4) obligations that live only in prose are obligations nobody can evidence. - subprocessors table, seeded with the nine vendors already published. announcedAt is set to the existing page's publication date so objection windows are computed from when operators could actually have seen the entry, not from today - subprocessor_objections, recorded against the specific vendor rather than left in an inbox — an objection that cannot be evidenced is one the operator cannot rely on - lib/legal/subprocessor-notice.ts: the DPA §6 windows as pure rules with an injected clock, so 30-day and 14-day behaviour is tested exhaustively rather than by waiting a month - the page renders from the database and shows PENDING entries during their notice period. Advance notice is the point: an operator cannot object to a change they only see once it is already in force Also corrects the page's own contradiction. It told operators to subscribe by email to hear about changes, while the DPA promised they would be notified. Notice you have to opt into is not notice; the page now states that every active operator is emailed. Dr Green is seeded AS-IS. The agreed position is that it is an independent controller rather than our sub-processor, but that is pending written confirmation — and retiring an entry is exactly what this register is for, with a changelog entry and operator notice, rather than a silent migration asserting a legal position. Refs docs/PRDS/prd-data-protection-remediation.md (WS3, US-011) --- nextjs_space/app/legal/subprocessors/page.tsx | 154 ++++++++---------- nextjs_space/lib/legal/subprocessor-notice.ts | 76 +++++++++ .../migration.sql | 123 ++++++++++++++ nextjs_space/prisma/schema.prisma | 59 +++++++ .../tests/unit/subprocessor-notice.test.ts | 124 ++++++++++++++ 5 files changed, 454 insertions(+), 82 deletions(-) create mode 100644 nextjs_space/lib/legal/subprocessor-notice.ts create mode 100644 nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql create mode 100644 nextjs_space/tests/unit/subprocessor-notice.test.ts diff --git a/nextjs_space/app/legal/subprocessors/page.tsx b/nextjs_space/app/legal/subprocessors/page.tsx index 954f01aa..bcaff84a 100644 --- a/nextjs_space/app/legal/subprocessors/page.tsx +++ b/nextjs_space/app/legal/subprocessors/page.tsx @@ -1,87 +1,36 @@ import { Metadata } from "next"; import Link from "next/link"; -import { Database, FileText } from "lucide-react"; +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"; export const metadata: Metadata = { title: "Sub-processors | BudStacks", description: "Vendors that BudStacks engages to deliver the platform — purpose, region, and transfer mechanism.", }; -interface Subprocessor { - name: string; - purpose: string; - region: string; - transfer: string; - dpaUrl?: string; -} +// The register is database-backed (WS3 US-011). It was a hardcoded array, so it +// could only change with a deploy and nothing could start the 30-day notice +// clock the DPA promises operators. +export const dynamic = "force-dynamic"; -const SUBPROCESSORS: Subprocessor[] = [ - { - name: "Clerk", - purpose: "Authentication, session management, user identity", - region: "United States", - transfer: "EU SCCs + UK addendum", - dpaUrl: "https://clerk.com/legal/dpa", - }, - { - name: "Railway", - purpose: "Application hosting, build pipelines, deployment", - region: "United States", - transfer: "EU SCCs + UK addendum", - dpaUrl: "https://railway.com/legal/dpa", - }, - { - name: "Amazon Web Services (AWS S3)", - purpose: "Object storage for tenant assets and backups", - region: "EU (eu-west-1) primary; US for cross-region replication", - transfer: "EU SCCs + UK addendum", - dpaUrl: "https://aws.amazon.com/service-terms/", - }, - { - name: "PostgreSQL (managed by Railway)", - purpose: "Primary application database", - region: "United States (Railway-managed)", - transfer: "EU SCCs + UK addendum", - }, - { - name: "Redis (managed by Railway)", - purpose: "Cache, session store, background-job queues", - region: "United States (Railway-managed)", - transfer: "EU SCCs + UK addendum", - }, - { - name: "Stripe", - purpose: "Payment processing for platform subscription fees", - region: "United States / Ireland", - transfer: "EU SCCs + UK addendum; adequacy where applicable", - dpaUrl: "https://stripe.com/legal/dpa", - }, - { - name: "Resend", - purpose: "Transactional email delivery (system notifications)", - region: "United States", - transfer: "EU SCCs + UK addendum", - dpaUrl: "https://resend.com/legal/dpa", - }, - { - name: "Dr. Green API", - purpose: "Product catalogue and order routing for partner storefronts", - region: "Portugal / European Union", - transfer: "Within EEA — no SCCs required", - }, - { - name: "Sentry", - purpose: "Error monitoring and performance telemetry", - region: "United States / EU", - transfer: "EU SCCs + UK addendum", - dpaUrl: "https://sentry.io/legal/dpa/", - }, -]; +export default async function SubprocessorsPage() { + const entries = await prisma.subprocessors.findMany({ + where: { status: { in: ["active", "pending"] } }, + orderBy: [{ status: "asc" }, { name: "asc" }], + }); -export default function SubprocessorsPage() { + // Pending entries are shown deliberately: advance notice is the point, and + // an operator cannot exercise the objection right over a change they cannot + // see until it is already in force. + const pending = entries.filter((entry) => entry.status === "pending"); + const lastChange = entries.reduce( + (latest, entry) => + !latest || entry.updatedAt > latest ? entry.updatedAt : latest, + null, + ); return (
@@ -98,7 +47,12 @@ export default function SubprocessorsPage() { Sub-processors

- Last updated: April 25, 2026 + Last updated:{" "} + {(lastChange ?? new Date()).toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + })}

@@ -115,6 +69,25 @@ export default function SubprocessorsPage() { . Operators may object to a new sub-processor as set out in the DPA.

+ {pending.length > 0 && ( +
+ +
+

+ {pending.length === 1 + ? "One upcoming change" + : `${pending.length} upcoming changes`} +

+

+ The vendors marked below are announced but not yet + processing. They are listed here during the notice + period so operators can object before the change + takes effect, rather than after. +

+
+
+ )} +
@@ -127,15 +100,27 @@ export default function SubprocessorsPage() { - {SUBPROCESSORS.map((s) => ( + {entries.map((s) => ( - + - + - {entries.map((s) => ( + {entries.map((s: SubprocessorRecord) => ( ; + } + + const tenants: NotifiableTenant[] = await prisma.tenants.findMany({ where: { isActive: true, deletedAt: null }, select: { id: true, businessName: true, users: { select: { email: true, role: true } } }, }); @@ -200,7 +207,7 @@ export async function announceSubprocessor( * begin on a vendor operators were never told about, whatever the date says. */ export async function activateDueSubprocessors(now = new Date()): Promise { - const due = await prisma.subprocessors.findMany({ + const due: Array<{ id: string; name: string }> = await prisma.subprocessors.findMany({ where: { status: "pending", announcedAt: { not: null }, diff --git a/nextjs_space/lib/legal/subprocessor-notice.ts b/nextjs_space/lib/legal/subprocessor-notice.ts index e77aa21c..dbb13fd8 100644 --- a/nextjs_space/lib/legal/subprocessor-notice.ts +++ b/nextjs_space/lib/legal/subprocessor-notice.ts @@ -20,6 +20,30 @@ export const OBJECTION_WINDOW_DAYS = 14; export type SubprocessorStatus = "pending" | "active" | "retired"; +/** + * A register row. + * + * Declared by hand because `prisma` is exported as `any` (lib/db.ts), so query + * results carry no type and every callback parameter over them lands as an + * implicit `any`. Annotating the query result restores checking at the one + * boundary that matters. + */ +export interface SubprocessorRecord { + id: string; + name: string; + purpose: string; + region: string; + transferMechanism: string; + dpaUrl: string | null; + status: string; + effectiveFrom: Date; + announcedAt: Date | null; + retiredAt: Date | null; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} + const DAY_MS = 24 * 60 * 60 * 1000; export function addDays(date: Date, days: number): Date {
{s.name} + {s.name} + {s.status === "pending" && ( + + From{" "} + {s.effectiveFrom.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + })} + + )} + {s.purpose} {s.region}{s.transfer}{s.transferMechanism} {s.dpaUrl ? (

- To be notified of changes, subscribe by emailing{" "} + Operators do not need to subscribe to hear about changes. Every + active operator is emailed at least 30 days before a vendor is + added or replaced, at the contact address on their account — + notice you have to opt into is not notice. Changes are also + recorded in the{" "} + + legal changelog + + . To object to a sub-processor, or to ask anything about this + list, contact{" "} legal@budstacks.io - . You can also follow updates in the{" "} - - legal changelog - .

diff --git a/nextjs_space/lib/legal/subprocessor-notice.ts b/nextjs_space/lib/legal/subprocessor-notice.ts new file mode 100644 index 00000000..e77aa21c --- /dev/null +++ b/nextjs_space/lib/legal/subprocessor-notice.ts @@ -0,0 +1,76 @@ +/** + * Notice and objection rules for the sub-processor register. + * + * The DPA (§6) makes two promises to operators: at least 30 days' notice before + * a new sub-processor starts processing, and 14 days from announcement to + * object. Both were prose with no mechanism behind them. These are the rules + * that give them effect. + * + * Pure — no database, no clock of its own — so the windows can be tested + * exhaustively rather than by waiting a month. + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012/013/014). + */ + +/** DPA §6: minimum advance notice before a sub-processor may begin processing. */ +export const MIN_NOTICE_DAYS = 30; + +/** DPA §6: how long an operator has to object after announcement. */ +export const OBJECTION_WINDOW_DAYS = 14; + +export type SubprocessorStatus = "pending" | "active" | "retired"; + +const DAY_MS = 24 * 60 * 60 * 1000; + +export function addDays(date: Date, days: number): Date { + return new Date(date.getTime() + days * DAY_MS); +} + +export function daysBetween(from: Date, to: Date): number { + return (to.getTime() - from.getTime()) / DAY_MS; +} + +/** The earliest compliant effective date for something announced now. */ +export function earliestEffectiveFrom(now: Date): Date { + return addDays(now, MIN_NOTICE_DAYS); +} + +/** + * Whether `effectiveFrom` gives operators the notice the DPA promises. + * + * Measured from announcement, not from creation: a record that sits unannounced + * for a fortnight has given nobody anything. + */ +export function hasSufficientNotice(announcedAt: Date, effectiveFrom: Date): boolean { + return daysBetween(announcedAt, effectiveFrom) >= MIN_NOTICE_DAYS; +} + +/** Whether an objection arrived after the DPA's 14-day window. */ +export function isObjectionOutOfWindow(announcedAt: Date, raisedAt: Date): boolean { + return daysBetween(announcedAt, raisedAt) > OBJECTION_WINDOW_DAYS; +} + +/** + * Whether a pending entry has reached its effective date and should flip to + * active. Unannounced entries never activate — processing must not begin on a + * vendor operators were never told about, whatever the date says. + */ +export function shouldActivate( + entry: { status: string; effectiveFrom: Date; announcedAt: Date | null }, + now: Date, +): boolean { + if (entry.status !== "pending") return false; + if (!entry.announcedAt) return false; + return now >= entry.effectiveFrom; +} + +/** Human-readable summary of where a pending entry is in its notice period. */ +export function noticeState( + entry: { status: string; effectiveFrom: Date; announcedAt: Date | null }, + now: Date, +): "in-force" | "retired" | "awaiting-announcement" | "in-notice-period" | "due-to-activate" { + if (entry.status === "active") return "in-force"; + if (entry.status === "retired") return "retired"; + if (!entry.announcedAt) return "awaiting-announcement"; + return now >= entry.effectiveFrom ? "due-to-activate" : "in-notice-period"; +} diff --git a/nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql b/nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql new file mode 100644 index 00000000..56d52a42 --- /dev/null +++ b/nextjs_space/prisma/migrations/20260727020000_add_subprocessor_register/migration.sql @@ -0,0 +1,123 @@ +-- Sub-processor register (GDPR Art. 28(2)/(4)). +-- +-- The list was a hardcoded array in app/legal/subprocessors/page.tsx, so it +-- could only change with a deploy and nothing could start the 30-day notice +-- clock the DPA promises operators. Moving it into the database makes both +-- possible, and makes every change auditable. +-- +-- Seeds the nine vendors currently published, all already in force. Dr Green is +-- seeded AS-IS: the agreed position is that it is an independent controller +-- rather than our sub-processor, but that is pending written confirmation, and +-- retiring an entry is exactly the operation this register exists to handle — +-- with a changelog entry and operator notice — rather than a silent migration. +-- +-- See docs/PRDS/prd-data-protection-remediation.md (WS3, US-011). + +CREATE TABLE "subprocessors" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "purpose" TEXT NOT NULL, + "region" TEXT NOT NULL, + "transferMechanism" TEXT NOT NULL, + "dpaUrl" TEXT, + "status" TEXT NOT NULL DEFAULT 'pending', + "effectiveFrom" TIMESTAMP(3) NOT NULL, + "announcedAt" TIMESTAMP(3), + "retiredAt" TIMESTAMP(3), + "notes" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "subprocessors_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "subprocessors_status_effectiveFrom_idx" + ON "subprocessors"("status", "effectiveFrom"); + +CREATE TABLE "subprocessor_objections" ( + "id" TEXT NOT NULL, + "subprocessorId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "raisedByUserId" TEXT, + "reason" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'open', + "outOfWindow" BOOLEAN NOT NULL DEFAULT false, + "resolution" TEXT, + "resolvedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "subprocessor_objections_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "subprocessor_objections_subprocessorId_status_idx" + ON "subprocessor_objections"("subprocessorId", "status"); +CREATE INDEX "subprocessor_objections_tenantId_idx" + ON "subprocessor_objections"("tenantId"); + +ALTER TABLE "subprocessor_objections" + ADD CONSTRAINT "subprocessor_objections_subprocessorId_fkey" + FOREIGN KEY ("subprocessorId") REFERENCES "subprocessors"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "subprocessor_objections" + ADD CONSTRAINT "subprocessor_objections_tenantId_fkey" + FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Seed: the vendors already published and in force. `announcedAt` is set to the +-- publication date of the existing page so the objection window is computed +-- from when operators could actually have seen the list, not from today. +INSERT INTO "subprocessors" + ("id", "name", "purpose", "region", "transferMechanism", "dpaUrl", + "status", "effectiveFrom", "announcedAt", "notes", "updatedAt") +VALUES + ('clerk', 'Clerk', + 'Authentication, session management, user identity', + 'United States', 'EU SCCs + UK addendum', 'https://clerk.com/legal/dpa', + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('railway', 'Railway', + 'Application hosting, build pipelines, deployment', + 'United States', 'EU SCCs + UK addendum', 'https://railway.com/legal/dpa', + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('aws-s3', 'Amazon Web Services (AWS S3)', + 'Object storage for tenant assets and backups', + 'EU (eu-west-1) primary; US for cross-region replication', + 'EU SCCs + UK addendum', 'https://aws.amazon.com/service-terms/', + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('postgres-railway', 'PostgreSQL (managed by Railway)', + 'Primary application database', + 'United States (Railway-managed)', 'EU SCCs + UK addendum', NULL, + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('redis-railway', 'Redis (managed by Railway)', + 'Cache, session store, background-job queues', + 'United States (Railway-managed)', 'EU SCCs + UK addendum', NULL, + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('stripe', 'Stripe', + 'Payment processing for platform subscription fees', + 'United States / Ireland', + 'EU SCCs + UK addendum; adequacy where applicable', + 'https://stripe.com/legal/dpa', + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('resend', 'Resend', + 'Transactional email delivery (system notifications)', + 'United States', 'EU SCCs + UK addendum', 'https://resend.com/legal/dpa', + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP), + + ('dr-green-api', 'Dr. Green API', + 'Product catalogue and order routing for partner storefronts', + 'Portugal / European Union', 'Within EEA — no SCCs required', NULL, + 'active', '2026-04-25', '2026-04-25', + 'Classification under review: the agreed position is that Dr Green is an independent controller rather than a BudStacks sub-processor, pending written confirmation. Retire this entry through the register once confirmed, so operators receive notice of the change.', + CURRENT_TIMESTAMP), + + ('sentry', 'Sentry', + 'Error monitoring and performance telemetry', + 'United States / EU', 'EU SCCs + UK addendum', 'https://sentry.io/legal/dpa/', + 'active', '2026-04-25', '2026-04-25', NULL, CURRENT_TIMESTAMP); diff --git a/nextjs_space/prisma/schema.prisma b/nextjs_space/prisma/schema.prisma index 0465ca45..af43da1e 100644 --- a/nextjs_space/prisma/schema.prisma +++ b/nextjs_space/prisma/schema.prisma @@ -165,6 +165,64 @@ model compliance_purge_records { details Json } +/// Vendors BudStacks engages to deliver the platform (GDPR Art. 28(2)/(4)). +/// +/// Database-backed rather than hardcoded so the list can change without a +/// deploy, and — more importantly — so adding or replacing a vendor can start +/// the 30-day notice clock the DPA promises operators. A list nobody can change +/// without an engineer is a list that silently goes stale. +/// +/// See docs/PRDS/prd-data-protection-remediation.md (WS3, US-011). +model subprocessors { + id String @id + name String + purpose String + region String + transferMechanism String + dpaUrl String? + /// pending = announced, not yet in force. active = processing. retired = gone. + status String @default("pending") + /// The date processing may begin. Must be >= 30 days after announcedAt + /// unless a super admin overrides, which is written to the audit log. + effectiveFrom DateTime + /// When operators were notified. Null until the notification job has run. + announcedAt DateTime? + /// Set when status becomes retired, so the register keeps its history. + retiredAt DateTime? + notes String? + createdAt DateTime @default(now()) + updatedAt DateTime + + objections subprocessor_objections[] + + @@index([status, effectiveFrom]) +} + +/// An operator's objection to a sub-processor, per DPA §6. Recorded against the +/// specific vendor rather than left in an inbox — an objection that cannot be +/// evidenced is one the operator cannot rely on. +model subprocessor_objections { + id String @id + subprocessorId String + tenantId String + raisedByUserId String? + reason String + /// open | acknowledged | resolved | withdrawn + status String @default("open") + /// True when raised more than 14 days after announcement (DPA §6 window). + outOfWindow Boolean @default(false) + resolution String? + resolvedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime + + subprocessor subprocessors @relation(fields: [subprocessorId], references: [id], onDelete: Cascade) + tenants tenants @relation(fields: [tenantId], references: [id], onDelete: Cascade) + + @@index([subprocessorId, status]) + @@index([tenantId]) +} + model consultations { id String @id userId String @@ -469,6 +527,7 @@ model tenants { drgreen_webhook_logs drgreen_webhook_logs[] kyc_journey_logs kyc_journey_logs[] email_event_mappings email_event_mappings[] + subprocessor_objections subprocessor_objections[] email_logs email_logs[] email_templates email_templates[] orders orders[] diff --git a/nextjs_space/tests/unit/subprocessor-notice.test.ts b/nextjs_space/tests/unit/subprocessor-notice.test.ts new file mode 100644 index 00000000..a5f0cd0c --- /dev/null +++ b/nextjs_space/tests/unit/subprocessor-notice.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; +import { + MIN_NOTICE_DAYS, + OBJECTION_WINDOW_DAYS, + addDays, + earliestEffectiveFrom, + hasSufficientNotice, + isObjectionOutOfWindow, + noticeState, + shouldActivate, +} from "@/lib/legal/subprocessor-notice"; + +/** + * WS3 — the DPA §6 windows. + * + * These were prose with no mechanism behind them: 30 days' notice before a new + * sub-processor processes, 14 days to object. Testing them by waiting a month + * is not an option, so the rules are pure and the clock is injected. + */ + +const ANNOUNCED = new Date("2026-08-01T00:00:00Z"); + +describe("windows match the DPA", () => { + it("requires 30 days' notice", () => { + expect(MIN_NOTICE_DAYS).toBe(30); + }); + + it("gives 14 days to object", () => { + expect(OBJECTION_WINDOW_DAYS).toBe(14); + }); +}); + +describe("earliestEffectiveFrom", () => { + it("is 30 days out", () => { + expect(earliestEffectiveFrom(ANNOUNCED).toISOString()).toBe( + "2026-08-31T00:00:00.000Z", + ); + }); +}); + +describe("hasSufficientNotice", () => { + it("accepts exactly 30 days", () => { + expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, 30))).toBe(true); + }); + + it("accepts more than 30 days", () => { + expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, 45))).toBe(true); + }); + + it("rejects 29 days", () => { + expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, 29))).toBe(false); + }); + + it("rejects an effective date before the announcement", () => { + expect(hasSufficientNotice(ANNOUNCED, addDays(ANNOUNCED, -1))).toBe(false); + }); +}); + +describe("isObjectionOutOfWindow", () => { + it("accepts an objection on day 14", () => { + expect(isObjectionOutOfWindow(ANNOUNCED, addDays(ANNOUNCED, 14))).toBe(false); + }); + + it("flags an objection on day 15", () => { + expect(isObjectionOutOfWindow(ANNOUNCED, addDays(ANNOUNCED, 15))).toBe(true); + }); + + it("accepts an objection raised the same day", () => { + expect(isObjectionOutOfWindow(ANNOUNCED, ANNOUNCED)).toBe(false); + }); +}); + +describe("shouldActivate", () => { + const pending = { + status: "pending", + effectiveFrom: addDays(ANNOUNCED, 30), + announcedAt: ANNOUNCED, + }; + + it("activates on the effective date", () => { + expect(shouldActivate(pending, addDays(ANNOUNCED, 30))).toBe(true); + }); + + it("does not activate before the effective date", () => { + expect(shouldActivate(pending, addDays(ANNOUNCED, 29))).toBe(false); + }); + + it("never activates an unannounced entry, whatever the date", () => { + // Processing must not begin on a vendor operators were never told about. + expect( + shouldActivate({ ...pending, announcedAt: null }, addDays(ANNOUNCED, 365)), + ).toBe(false); + }); + + it("leaves an already-active entry alone", () => { + expect( + shouldActivate({ ...pending, status: "active" }, addDays(ANNOUNCED, 30)), + ).toBe(false); + }); + + it("does not resurrect a retired entry", () => { + expect( + shouldActivate({ ...pending, status: "retired" }, addDays(ANNOUNCED, 30)), + ).toBe(false); + }); +}); + +describe("noticeState", () => { + const base = { + status: "pending", + effectiveFrom: addDays(ANNOUNCED, 30), + announcedAt: ANNOUNCED, + }; + + it.each([ + ["in-force", { ...base, status: "active" }, ANNOUNCED], + ["retired", { ...base, status: "retired" }, ANNOUNCED], + ["awaiting-announcement", { ...base, announcedAt: null }, ANNOUNCED], + ["in-notice-period", base, addDays(ANNOUNCED, 5)], + ["due-to-activate", base, addDays(ANNOUNCED, 31)], + ])("reports %s", (expected, entry, now) => { + expect(noticeState(entry, now as Date)).toBe(expected); + }); +}); From 1519361480b830feee265fb51b6e223f6bd499a6 Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 27 Jul 2026 20:37:40 +0100 Subject: [PATCH 2/4] feat(legal): announce sub-processor changes to every active operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism behind DPA §6. Before this the promise was prose: the page told operators to subscribe by email to hear about changes, while the DPA said we would notify them. A controller who never heard about a change cannot exercise the objection right they were granted. Every active tenant is emailed. There is no subscriber list, deliberately. - refuses to announce a change that does not carry the promised notice. Sending a "30 days' notice" email 5 days out is worse than sending nothing, because it manufactures a record of having complied - one operator's bad address does not stop the rest being told; failures are counted, logged and returned rather than aborting the run - announcedAt is stamped even on partial failure. The objection window has started for everyone who did receive it, and re-announcing would restart their clock; failures are surfaced to be chased individually - activateDueSubprocessors flips pending to active on the effective date, and never activates an unannounced entry whatever the date says — processing must not begin on a vendor nobody was told about - the email states the objection deadline as a DATE. A reader should not have to do arithmetic to find out how long they have Refs docs/PRDS/prd-data-protection-remediation.md (WS3, US-013) --- .../lib/legal/subprocessor-announce.ts | 221 ++++++++++++++++++ .../tests/unit/subprocessor-announce.test.ts | 62 +++++ 2 files changed, 283 insertions(+) create mode 100644 nextjs_space/lib/legal/subprocessor-announce.ts create mode 100644 nextjs_space/tests/unit/subprocessor-announce.test.ts diff --git a/nextjs_space/lib/legal/subprocessor-announce.ts b/nextjs_space/lib/legal/subprocessor-announce.ts new file mode 100644 index 00000000..8b265905 --- /dev/null +++ b/nextjs_space/lib/legal/subprocessor-announce.ts @@ -0,0 +1,221 @@ +/** + * Announce a sub-processor change to every active operator. + * + * This is the mechanism behind DPA §6. Before it existed the promise was prose: + * the page told operators to *subscribe* by emailing us, while the DPA said we + * would notify them. Notice you have to opt into is not notice, and a controller + * who never heard about a change cannot exercise the objection right they were + * granted. + * + * Every active tenant is emailed. There is no subscriber list, deliberately. + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-013). + */ + +import { prisma } from "@/lib/db"; +import { logger } from "@/lib/logger"; +import { sendEmail } from "@/lib/email/email"; +import { createAuditLog } from "@/lib/audit-log"; +import { OBJECTION_WINDOW_DAYS, hasSufficientNotice } from "./subprocessor-notice"; + +export interface AnnounceableSubprocessor { + id: string; + name: string; + purpose: string; + region: string; + transferMechanism: string; + effectiveFrom: Date; +} + +export interface AnnounceResult { + announced: number; + failed: number; + skipped: string[]; +} + +function formatDate(date: Date): string { + return date.toLocaleDateString("en-GB", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +/** + * The notice body. + * + * Written to be read by an operator, not a lawyer: what is changing, when it + * starts, and what they can do about it. The objection deadline is stated as a + * date rather than "14 days from announcement", because a reader should not + * have to do arithmetic to find out how long they have. + */ +export function buildAnnouncementEmail( + entry: AnnounceableSubprocessor, + objectionDeadline: Date, +): { subject: string; html: string } { + return { + subject: `Change to BudStacks sub-processors: ${entry.name}`, + html: ` +

Hello,

+

+ We are writing to tell you in advance about a change to the vendors + BudStacks uses to run your storefront. Under our Data Processing + Agreement you are entitled to at least 30 days' notice of this, and to + object. +

+ + + + + + +
Vendor${entry.name}
What they do${entry.purpose}
Where${entry.region}
Transfer safeguard${entry.transferMechanism}
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. +

+

+ You do not need to do anything if you are content with the change. The + full list is always at + budstacks.io/legal/subprocessors. +

+

— BudStacks

+ `.trim(), + }; +} + +/** + * Send the announcement and stamp `announcedAt`. + * + * Refuses to announce a change that does not carry the notice the DPA promises: + * sending a "30 days' notice" email 5 days before the change is worse than not + * sending one, because it creates a record of having complied. + * + * Individual send failures do not abort the run — one operator's bad address + * must not stop the other ninety-nine being told. Failures are counted, logged + * and surfaced by the caller. + */ +export async function announceSubprocessor( + entryId: string, + now = new Date(), +): Promise { + const entry = await prisma.subprocessors.findFirst({ where: { id: entryId } }); + if (!entry) throw new Error(`Sub-processor ${entryId} not found`); + + if (entry.announcedAt) { + logger.warn("[Legal] Sub-processor already announced; not re-sending", { + entryId, + announcedAt: entry.announcedAt, + }); + return { announced: 0, failed: 0, skipped: ["already-announced"] }; + } + + if (!hasSufficientNotice(now, entry.effectiveFrom)) { + throw new Error( + `Refusing to announce ${entry.name}: effective ${entry.effectiveFrom.toISOString()} ` + + `does not give operators the notice the DPA promises. Move the date out.`, + ); + } + + const tenants = await prisma.tenants.findMany({ + where: { isActive: true, deletedAt: null }, + select: { id: true, businessName: true, users: { select: { email: true, role: true } } }, + }); + + const objectionDeadline = new Date( + now.getTime() + OBJECTION_WINDOW_DAYS * 24 * 60 * 60 * 1000, + ); + const { subject, html } = buildAnnouncementEmail(entry, objectionDeadline); + + let announced = 0; + let failed = 0; + + for (const tenant of tenants) { + const recipient = tenant.users.find((user) => user.role === "TENANT_ADMIN")?.email; + if (!recipient) { + failed++; + logger.error("[Legal] No admin contact for tenant; sub-processor notice not sent", { + tenantId: tenant.id, + entryId, + }); + continue; + } + + try { + await sendEmail({ + to: recipient, + subject, + html, + tenantId: tenant.id, + templateName: "subprocessor-change", + metadata: { subprocessorId: entry.id, effectiveFrom: entry.effectiveFrom }, + }); + announced++; + } catch (error) { + failed++; + logger.error("[Legal] Sub-processor notice failed to send", { + tenantId: tenant.id, + entryId, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + // Stamped even when some sends failed: the objection window has started for + // everyone who did receive it, and re-announcing would restart the clock for + // them. Failures are surfaced so they can be chased individually. + await prisma.subprocessors.update({ + where: { id: entry.id }, + data: { announcedAt: now, updatedAt: now }, + }); + + await createAuditLog({ + action: "SUBPROCESSOR_ANNOUNCED", + entityType: "subprocessor", + entityId: entry.id, + metadata: { + name: entry.name, + announced, + failed, + effectiveFrom: entry.effectiveFrom.toISOString(), + }, + }); + + logger.info("[Legal] Sub-processor change announced", { + entryId, + name: entry.name, + announced, + failed, + }); + + return { announced, failed, skipped: [] }; +} + +/** + * Flip pending entries whose effective date has arrived. + * + * Unannounced entries are left alone by `shouldActivate` — processing must not + * begin on a vendor operators were never told about, whatever the date says. + */ +export async function activateDueSubprocessors(now = new Date()): Promise { + const due = await prisma.subprocessors.findMany({ + where: { + status: "pending", + announcedAt: { not: null }, + effectiveFrom: { lte: now }, + }, + select: { id: true, name: true }, + }); + + for (const entry of due) { + await prisma.subprocessors.update({ + where: { id: entry.id }, + data: { status: "active", updatedAt: now }, + }); + logger.info("[Legal] Sub-processor now in force", { entryId: entry.id, name: entry.name }); + } + + return due.map((entry) => entry.id); +} diff --git a/nextjs_space/tests/unit/subprocessor-announce.test.ts b/nextjs_space/tests/unit/subprocessor-announce.test.ts new file mode 100644 index 00000000..88e81e17 --- /dev/null +++ b/nextjs_space/tests/unit/subprocessor-announce.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { buildAnnouncementEmail } from "@/lib/legal/subprocessor-announce"; +import { addDays } from "@/lib/legal/subprocessor-notice"; + +/** + * WS3 US-013 — the notice operators actually receive. + * + * The point of the email is that a controller can act on it. So it has to name + * the vendor, say when processing starts, and state the objection deadline as a + * date — a reader should not have to do arithmetic to find out how long they + * have. + */ + +const ENTRY = { + id: "postmark", + name: "Postmark", + purpose: "Transactional email delivery", + region: "United States", + transferMechanism: "EU SCCs + UK addendum", + effectiveFrom: new Date("2026-09-15T00:00:00Z"), +}; + +const DEADLINE = addDays(new Date("2026-08-01T00:00:00Z"), 14); + +describe("buildAnnouncementEmail", () => { + const { subject, html } = buildAnnouncementEmail(ENTRY, DEADLINE); + + it("names the vendor in the subject, so it is not mistaken for marketing", () => { + expect(subject).toContain("Postmark"); + expect(subject.toLowerCase()).toContain("sub-processor"); + }); + + it("states what the vendor does and where", () => { + expect(html).toContain("Transactional email delivery"); + expect(html).toContain("United States"); + }); + + it("states the transfer safeguard", () => { + expect(html).toContain("EU SCCs + UK addendum"); + }); + + it("gives the date processing starts", () => { + expect(html).toContain("15 September 2026"); + }); + + it("gives the objection deadline as a date, not a duration", () => { + expect(html).toContain("15 August 2026"); + expect(html).not.toMatch(/within 14 days/i); + }); + + it("tells the operator how to object", () => { + expect(html).toContain("legal@budstacks.io"); + }); + + it("links the full register", () => { + expect(html).toContain("/legal/subprocessors"); + }); + + it("says the 30-day entitlement out loud", () => { + expect(html).toContain("30 days"); + }); +}); From 37112abfb777e51f2c7bdd6ccfe20a3886c30cfa Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 27 Jul 2026 20:39:39 +0100 Subject: [PATCH 3/4] feat(legal): super-admin register CRUD + operator objections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the DPA §6 loop: a vendor can be added, announced, objected to and retired, with every step evidenced. Creating does NOT announce. They are separate calls so an entry can be drafted and checked before an email goes to the entire customer base and an objection window opens that cannot be un-opened. - the 30-day floor is enforced server-side, not trusted to the caller. Going sooner is possible but needs an explicit override AND a reason, both written to the audit log — taking away notice operators were already promised should leave a trace with a name against it - amending an already-announced entry logs a warning: operators were told something that is no longer true - retiring sets retiredAt and keeps the row. The register is the evidence that a vendor once processed operator data and for how long; deleting destroys the only record the relationship existed - objections are recorded against the specific vendor, never an inbox. "We never received it" should not be a position we can take - late objections are ACCEPTED and flagged, not rejected. Refusing to record a controller's objection because they were slow would leave us processing over a live, unanswered concern Refs docs/PRDS/prd-data-protection-remediation.md (WS3, US-012/014) --- .../super-admin/subprocessors/[id]/route.ts | 156 ++++++++++++++++++ .../api/super-admin/subprocessors/route.ts | 140 ++++++++++++++++ .../subprocessor-objections/route.ts | 130 +++++++++++++++ nextjs_space/lib/legal/subprocessor-schema.ts | 63 +++++++ 4 files changed, 489 insertions(+) create mode 100644 nextjs_space/app/api/super-admin/subprocessors/[id]/route.ts create mode 100644 nextjs_space/app/api/super-admin/subprocessors/route.ts create mode 100644 nextjs_space/app/api/tenant-admin/subprocessor-objections/route.ts create mode 100644 nextjs_space/lib/legal/subprocessor-schema.ts diff --git a/nextjs_space/app/api/super-admin/subprocessors/[id]/route.ts b/nextjs_space/app/api/super-admin/subprocessors/[id]/route.ts new file mode 100644 index 00000000..52285f06 --- /dev/null +++ b/nextjs_space/app/api/super-admin/subprocessors/[id]/route.ts @@ -0,0 +1,156 @@ +import { NextResponse } from "next/server"; +import type { ZodError } 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 { + retireSchema, + subprocessorUpdateSchema, +} from "@/lib/legal/subprocessor-schema"; +import { announceSubprocessor } from "@/lib/legal/subprocessor-announce"; +import { logger } from "@/lib/logger"; + +/** + * Amend, announce or retire a register entry. + * + * Announcing is a POST to this route rather than a side effect of saving, + * because it emails every operator and starts an objection window that cannot + * be un-started. + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012). + */ + +function firstIssue(error: ZodError): string { + return error.issues[0]?.message ?? "Invalid request."; +} + +/** Announce the entry to every active operator. */ +export const POST = withSuperAdminParams(async (request, { user }, params) => { + const route = "POST /api/super-admin/subprocessors/[id]"; + try { + const result = await announceSubprocessor(params.id); + + await createAuditLog({ + action: "SUBPROCESSOR_ANNOUNCE_REQUESTED", + entityType: "subprocessor", + entityId: params.id, + userId: user.id, + userEmail: user.email, + metadata: { ...result }, + ...getClientInfo(request.headers), + }); + + return NextResponse.json({ success: true, ...result }); + } catch (error) { + // announceSubprocessor throws when the notice period is too short — that is + // operator-correctable, not a server fault. + const message = error instanceof Error ? error.message : ""; + if (message.includes("Refusing to announce")) { + return apiError(error, { route, status: 422, safeMessage: message }); + } + return apiError(error, { route }); + } +}); + +export const PATCH = withSuperAdminParams(async (request, { user }, params) => { + const route = "PATCH /api/super-admin/subprocessors/[id]"; + try { + const body = await parseJsonBody>(request); + const parsed = subprocessorUpdateSchema.safeParse(body); + + if (!parsed.success) { + return apiValidationError(firstIssue(parsed.error), route); + } + + const existing = await prisma.subprocessors.findFirst({ + where: { id: params.id }, + }); + if (!existing) { + return apiError(new Error("Not found"), { + route, + status: 404, + safeMessage: "Register entry not found.", + }); + } + + const now = new Date(); + const updated = await prisma.subprocessors.update({ + where: { id: params.id }, + data: { ...parsed.data, updatedAt: now }, + }); + + await createAuditLog({ + action: "SUBPROCESSOR_UPDATED", + entityType: "subprocessor", + entityId: params.id, + userId: user.id, + userEmail: user.email, + metadata: { + changed: Object.keys(parsed.data), + // An already-announced entry changing its terms matters: operators were + // told something that is no longer true. + alreadyAnnounced: Boolean(existing.announcedAt), + }, + ...getClientInfo(request.headers), + }); + + if (existing.announcedAt) { + logger.warn("[Legal] Announced sub-processor amended after notice went out", { + entryId: params.id, + changed: Object.keys(parsed.data), + }); + } + + return NextResponse.json({ success: true, entry: updated }); + } catch (error) { + return apiError(error, { route }); + } +}); + +/** Retire an entry. Kept in the register with a retiredAt, never deleted. */ +export const DELETE = withSuperAdminParams(async (request, { user }, params) => { + const route = "DELETE /api/super-admin/subprocessors/[id]"; + try { + const body = await parseJsonBody>(request); + const parsed = retireSchema.safeParse(body); + + if (!parsed.success) { + return apiValidationError(firstIssue(parsed.error), route); + } + + const now = new Date(); + // Retired rather than deleted: the register is the evidence that a vendor + // once processed operator data, and for how long. Deleting the row destroys + // the only record that the relationship existed. + const retired = await prisma.subprocessors.update({ + where: { id: params.id }, + data: { + status: "retired", + retiredAt: now, + updatedAt: now, + notes: parsed.data.reason, + }, + }); + + await createAuditLog({ + action: "SUBPROCESSOR_RETIRED", + entityType: "subprocessor", + entityId: params.id, + userId: user.id, + userEmail: user.email, + metadata: { name: retired.name, reason: parsed.data.reason }, + ...getClientInfo(request.headers), + }); + + logger.info("[Legal] Sub-processor retired", { + entryId: params.id, + name: retired.name, + }); + + return NextResponse.json({ success: true, entry: retired }); + } catch (error) { + return apiError(error, { route }); + } +}); diff --git a/nextjs_space/app/api/super-admin/subprocessors/route.ts b/nextjs_space/app/api/super-admin/subprocessors/route.ts new file mode 100644 index 00000000..b9b04796 --- /dev/null +++ b/nextjs_space/app/api/super-admin/subprocessors/route.ts @@ -0,0 +1,140 @@ +import { NextResponse } from "next/server"; +import type { ZodError } from "zod"; +import { withSuperAdmin } 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 { subprocessorSchema } from "@/lib/legal/subprocessor-schema"; +import { + MIN_NOTICE_DAYS, + earliestEffectiveFrom, + hasSufficientNotice, +} from "@/lib/legal/subprocessor-notice"; +import { logger } from "@/lib/logger"; + +/** + * Sub-processor register — super-admin CRUD. + * + * Adding a vendor here starts the DPA §6 clock. It does NOT announce: creation + * and announcement are separate so an entry can be drafted, checked and only + * then sent to every operator. An email to the whole customer base is not + * something to trigger by saving a form. + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012). + */ + +const ROUTE = "POST /api/super-admin/subprocessors"; + +function firstIssue(error: ZodError): string { + return error.issues[0]?.message ?? "Invalid sub-processor."; +} + +export const GET = withSuperAdmin(async () => { + try { + const entries = await prisma.subprocessors.findMany({ + orderBy: [{ status: "asc" }, { name: "asc" }], + include: { + _count: { select: { objections: { where: { status: "open" } } } }, + }, + }); + return NextResponse.json({ entries, minNoticeDays: MIN_NOTICE_DAYS }); + } catch (error) { + return apiError(error, { route: "GET /api/super-admin/subprocessors" }); + } +}); + +export const POST = withSuperAdmin(async (request, { user }) => { + try { + const body = await parseJsonBody>(request); + const parsed = subprocessorSchema.safeParse(body); + + if (!parsed.success) { + return apiValidationError(firstIssue(parsed.error), ROUTE); + } + + const input = parsed.data; + const now = new Date(); + + const existing = await prisma.subprocessors.findFirst({ + where: { id: input.id }, + select: { id: true }, + }); + if (existing) { + return apiValidationError( + `A register entry with id "${input.id}" already exists.`, + ROUTE, + ); + } + + // The 30-day floor is enforced here rather than trusted to the caller. + // Shortening it is possible but must be deliberate and reasoned, because it + // takes away notice the DPA already promised operators. + if (!hasSufficientNotice(now, input.effectiveFrom)) { + if (!input.overrideNoticePeriod) { + return apiValidationError( + `Operators are entitled to ${MIN_NOTICE_DAYS} days' notice. The earliest ` + + `effective date is ${earliestEffectiveFrom(now).toISOString().slice(0, 10)}. ` + + `To go sooner, set overrideNoticePeriod with a reason.`, + ROUTE, + ); + } + if (!input.overrideReason) { + return apiValidationError( + "Shortening the notice period requires a reason.", + ROUTE, + ); + } + } + + const created = await prisma.subprocessors.create({ + data: { + id: input.id, + name: input.name, + purpose: input.purpose, + region: input.region, + transferMechanism: input.transferMechanism, + dpaUrl: input.dpaUrl ?? null, + effectiveFrom: input.effectiveFrom, + notes: input.notes ?? null, + status: "pending", + announcedAt: null, + createdAt: now, + updatedAt: now, + }, + }); + + await createAuditLog({ + action: "SUBPROCESSOR_CREATED", + entityType: "subprocessor", + entityId: created.id, + userId: user.id, + userEmail: user.email, + metadata: { + name: created.name, + effectiveFrom: created.effectiveFrom.toISOString(), + noticeDays: Math.round( + (created.effectiveFrom.getTime() - now.getTime()) / 86_400_000, + ), + noticeOverridden: Boolean(input.overrideNoticePeriod), + overrideReason: input.overrideReason ?? null, + }, + ...getClientInfo(request.headers), + }); + + logger.info("[Legal] Sub-processor drafted", { + entryId: created.id, + effectiveFrom: created.effectiveFrom, + }); + + return NextResponse.json({ + success: true, + entry: created, + // Explicit: saving does not tell anyone. Announcing does. + announced: false, + next: "Announce this entry to notify operators and start the objection window.", + }); + } catch (error) { + return apiError(error, { route: ROUTE }); + } +}); diff --git a/nextjs_space/app/api/tenant-admin/subprocessor-objections/route.ts b/nextjs_space/app/api/tenant-admin/subprocessor-objections/route.ts new file mode 100644 index 00000000..d9b4b5d4 --- /dev/null +++ b/nextjs_space/app/api/tenant-admin/subprocessor-objections/route.ts @@ -0,0 +1,130 @@ +import { NextResponse } from "next/server"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { withTenantAuth } 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 { + OBJECTION_WINDOW_DAYS, + isObjectionOutOfWindow, +} from "@/lib/legal/subprocessor-notice"; +import { logger } from "@/lib/logger"; + +/** + * Operator objections to a sub-processor (DPA §6). + * + * Recorded against the specific vendor rather than left in a shared inbox. An + * objection that cannot be evidenced is one the operator cannot rely on, and + * "we never received it" is not a position we should be able to take. + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-014). + */ + +const ROUTE = "POST /api/tenant-admin/subprocessor-objections"; + +const objectionSchema = z.object({ + subprocessorId: z.string().trim().min(1).max(64), + reason: z + .string() + .trim() + .min(10, "Tell us why you object, so we can respond properly.") + .max(2000), +}); + +export const GET = withTenantAuth(async (_request, { tenantId }) => { + try { + const objections = await prisma.subprocessor_objections.findMany({ + where: { tenantId }, + orderBy: { createdAt: "desc" }, + include: { subprocessor: { select: { name: true, status: true } } }, + }); + return NextResponse.json({ objections }); + } catch (error) { + return apiError(error, { route: "GET /api/tenant-admin/subprocessor-objections" }); + } +}); + +export const POST = withTenantAuth(async (request, { user, tenantId }) => { + try { + const body = await parseJsonBody>(request); + const parsed = objectionSchema.safeParse(body); + + if (!parsed.success) { + return apiValidationError( + parsed.error.issues[0]?.message ?? "Invalid objection.", + ROUTE, + ); + } + + const entry = await prisma.subprocessors.findFirst({ + where: { id: parsed.data.subprocessorId }, + select: { id: true, name: true, announcedAt: true }, + }); + + if (!entry) { + return apiError(new Error("Not found"), { + route: ROUTE, + status: 404, + safeMessage: "That sub-processor is not on the register.", + }); + } + + const now = new Date(); + + // Late objections are ACCEPTED and flagged, never rejected. The DPA gives a + // 14-day window, but refusing to record a controller's objection because + // they were slow would leave us processing over a live, unanswered concern. + const outOfWindow = entry.announcedAt + ? isObjectionOutOfWindow(entry.announcedAt, now) + : false; + + const objection = await prisma.subprocessor_objections.create({ + data: { + id: randomUUID(), + subprocessorId: entry.id, + tenantId, + raisedByUserId: user.id, + reason: parsed.data.reason, + status: "open", + outOfWindow, + createdAt: now, + updatedAt: now, + }, + }); + + await createAuditLog({ + action: "SUBPROCESSOR_OBJECTION_RAISED", + entityType: "subprocessor_objection", + entityId: objection.id, + tenantId, + userId: user.id, + userEmail: user.email, + metadata: { + subprocessorId: entry.id, + subprocessorName: entry.name, + outOfWindow, + }, + ...getClientInfo(request.headers), + }); + + logger.warn("[Legal] Operator objected to a sub-processor", { + tenantId, + subprocessorId: entry.id, + outOfWindow, + }); + + return NextResponse.json({ + success: true, + objection, + outOfWindow, + message: outOfWindow + ? `Recorded. This is outside the ${OBJECTION_WINDOW_DAYS}-day window in the DPA, ` + + `so we cannot guarantee a response before the change takes effect, but we will come back to you.` + : "Recorded. We will respond before this change takes effect.", + }); + } catch (error) { + return apiError(error, { route: ROUTE }); + } +}); diff --git a/nextjs_space/lib/legal/subprocessor-schema.ts b/nextjs_space/lib/legal/subprocessor-schema.ts new file mode 100644 index 00000000..14b2180d --- /dev/null +++ b/nextjs_space/lib/legal/subprocessor-schema.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; + +/** + * Validation for sub-processor register entries. + * + * Every field here is published on a public legal page and emailed to every + * operator, so blanks and placeholders are not acceptable input — an entry that + * says a vendor is in "US" with transfer mechanism "TBC" tells a controller + * nothing they can act on. + * + * See docs/PRDS/prd-data-protection-remediation.md (WS3, US-012). + */ + +export const subprocessorSchema = z.object({ + id: z + .string() + .trim() + .min(2) + .max(64) + .regex(/^[a-z0-9-]+$/, "Use a lower-case slug, e.g. postmark or aws-s3."), + name: z.string().trim().min(2, "Vendor name is required.").max(200), + purpose: z + .string() + .trim() + .min(10, "Describe what the vendor actually does with personal data.") + .max(500), + region: z.string().trim().min(2, "Where does the vendor process?").max(200), + transferMechanism: z + .string() + .trim() + .min(2, "State the safeguard, or that none is required.") + .max(200), + dpaUrl: z + .string() + .trim() + .url("Enter a full URL, or leave blank.") + .max(500) + .optional() + .or(z.literal("").transform(() => undefined)), + effectiveFrom: z.coerce.date(), + notes: z.string().trim().max(2000).optional(), + /** + * Set only to schedule a change sooner than the DPA's 30 days. Requires a + * reason, and both are written to the audit log — shortening operators' + * notice period should leave a trace with a name against it. + */ + overrideNoticePeriod: z.boolean().optional().default(false), + overrideReason: z.string().trim().max(1000).optional(), +}); + +export type SubprocessorInput = z.input; + +export const subprocessorUpdateSchema = subprocessorSchema + .omit({ id: true, overrideNoticePeriod: true, overrideReason: true }) + .partial(); + +export const retireSchema = z.object({ + reason: z + .string() + .trim() + .min(5, "Say why this vendor is being retired.") + .max(1000), +}); From bc9fd46ff1beef9ed8e2f797f92d8f23ef9a807c Mon Sep 17 00:00:00 2001 From: Gerard Kavanagh Date: Mon, 27 Jul 2026 20:43:24 +0100 Subject: [PATCH 4/4] fix(legal): type register query results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prisma` is exported as `any` (lib/db.ts), so query results carry no type and every callback parameter over them landed as an implicit `any` under noImplicitAny — TS7006 across the page and the announcer. Adds a hand-declared SubprocessorRecord and annotates the query results rather than sprinkling `: any` at each call site, so the callbacks infer and the register keeps type checking at the boundary where it matters. Refs docs/PRDS/prd-data-protection-remediation.md (WS3) --- nextjs_space/app/legal/subprocessors/page.tsx | 9 ++++--- .../lib/legal/subprocessor-announce.ts | 11 +++++++-- nextjs_space/lib/legal/subprocessor-notice.ts | 24 +++++++++++++++++++ 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/nextjs_space/app/legal/subprocessors/page.tsx b/nextjs_space/app/legal/subprocessors/page.tsx index bcaff84a..8decaee4 100644 --- a/nextjs_space/app/legal/subprocessors/page.tsx +++ b/nextjs_space/app/legal/subprocessors/page.tsx @@ -5,6 +5,7 @@ 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"; export const metadata: Metadata = { title: "Sub-processors | BudStacks", @@ -17,7 +18,9 @@ export const metadata: Metadata = { export const dynamic = "force-dynamic"; export default async function SubprocessorsPage() { - const entries = await prisma.subprocessors.findMany({ + // Annotated because `prisma` is exported as `any`, so the result would + // otherwise be untyped and every callback below an implicit `any`. + const entries: SubprocessorRecord[] = await prisma.subprocessors.findMany({ where: { status: { in: ["active", "pending"] } }, orderBy: [{ status: "asc" }, { name: "asc" }], }); @@ -27,7 +30,7 @@ export default async function SubprocessorsPage() { // see until it is already in force. const pending = entries.filter((entry) => entry.status === "pending"); const lastChange = entries.reduce( - (latest, entry) => + (latest: Date | null, entry: SubprocessorRecord) => !latest || entry.updatedAt > latest ? entry.updatedAt : latest, null, ); @@ -100,7 +103,7 @@ export default async function SubprocessorsPage() {