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/app/legal/subprocessors/page.tsx b/nextjs_space/app/legal/subprocessors/page.tsx index 954f01aa..8decaee4 100644 --- a/nextjs_space/app/legal/subprocessors/page.tsx +++ b/nextjs_space/app/legal/subprocessors/page.tsx @@ -1,87 +1,39 @@ 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"; +import type { SubprocessorRecord } from "@/lib/legal/subprocessor-notice"; 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() { + // 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" }], + }); -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: Date | null, entry: SubprocessorRecord) => + !latest || entry.updatedAt > latest ? entry.updatedAt : latest, + null, + ); return (
@@ -98,7 +50,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 +72,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 +103,27 @@ export default function SubprocessorsPage() { - {SUBPROCESSORS.map((s) => ( + {entries.map((s: SubprocessorRecord) => ( - + - +
{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-announce.ts b/nextjs_space/lib/legal/subprocessor-announce.ts new file mode 100644 index 00000000..9de83b89 --- /dev/null +++ b/nextjs_space/lib/legal/subprocessor-announce.ts @@ -0,0 +1,228 @@ +/** + * 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.`, + ); + } + + // Annotated because `prisma` is exported as `any`. + interface NotifiableTenant { + id: string; + businessName: string; + users: Array<{ email: string; role: string | null }>; + } + + const tenants: NotifiableTenant[] = 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: Array<{ id: string; name: string }> = 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/lib/legal/subprocessor-notice.ts b/nextjs_space/lib/legal/subprocessor-notice.ts new file mode 100644 index 00000000..dbb13fd8 --- /dev/null +++ b/nextjs_space/lib/legal/subprocessor-notice.ts @@ -0,0 +1,100 @@ +/** + * 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"; + +/** + * 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 { + 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/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), +}); 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 d7a3db82..fb51ad4e 100644 --- a/nextjs_space/prisma/schema.prisma +++ b/nextjs_space/prisma/schema.prisma @@ -195,6 +195,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 @@ -499,7 +557,7 @@ model tenants { drgreen_webhook_logs drgreen_webhook_logs[] kyc_journey_logs kyc_journey_logs[] email_event_mappings email_event_mappings[] - tenant_legal_profiles tenant_legal_profiles? + subprocessor_objections subprocessor_objections[] email_logs email_logs[] email_templates email_templates[] orders orders[] 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"); + }); +}); 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); + }); +});