feat(dpa): self-serve Data Processing Agreement generator (#22243)

## What

A single, region-aware DPA that serves all customers, generated
automatically from the customer's deployment. Two layers:

1. **Click-through DPA** — recorded at signup (acceptance = execution),
resolving merge fields from the deployment region. Cloud only.
2. **In-app signed-PDF generator** — Settings → Legal → Generate DPA:
preview the agreement, enter legal entity + authorized signatory,
download a PDF pre-signed by Twenty, and store the executed copy against
the workspace with its template version + timestamp. Deep-linkable at
`/dpa` (login-gated) for `twenty.com/dpa`.

## How it resolves

A typed variable matrix (`dpa-region-config.constant.ts`) maps the
deployment region to the contracting Processor entity and terms:

- **EU (default)** → Twenty.com SAS, hosting EU/Frankfurt, governing law
France, SCC section dormant.
- **US (custom)** → Twenty, Inc., hosting US, SCC section active.

Region is a deployment-wide setting (`DPA_DEPLOYMENT_REGION`, default
EU) behind a `DpaRegionService` seam so it can later become
per-workspace without touching callers. The legal text is verbatim from
the template (generated into `dpa-template.constant.ts` directly from
the source `.docx`); only the 6 merge fields are filled and the SCC
sections (7.2–7.5) stay in the document for every region per the spec —
only field values branch. Sub-processors are deferred to
trust.twenty.com (not enumerated). Billing stays decoupled (Twenty, Inc.
remains merchant of record regardless of Processor).

## UI

Standard list + create-page pattern (mirrors API keys / webhooks): a
list of executed copies (with re-download) — or the agreement preview
when none exists — and a top-right blue **Generate DPA** CTA opening a
standard create page. The "Legal" item is intentionally **not** in the
settings menu; the page is reached via the `/dpa` deep link.

## Notable implementation details

- **PDF** is rendered server-side with `@react-pdf/renderer`. The
built-in standard-14 fonts only encode ASCII and crash on the template's
curly quotes / em–en dashes / accented Latin, so Liberation Sans (OFL)
is **subset to a Latin glyph set and embedded as base64 data: URLs** —
no font files to ship or resolve at runtime (works in dev, prod-Docker
and CI).
- New `core.dpaAgreement` table via a fast instance command (FK hash
reproduced to match TypeORM).
- Self-hosted deployments (billing disabled) skip click-through
recording and stamp a prominent "not a valid agreement" banner on the
preview and PDF.

## Tests

- Unit: resolver (per-region entity/law/SCC state, EU default, no
unresolved `{{ }}`, SCC sections present in both regions, self-hosted
notice) and HTML renderer.
- Integration (`test/integration/graphql/suites/dpa`): preview has no
unresolved fields; `generateSignedDpa` renders + persists + returns a
downloadable PDF (asserted with accented input to guard the font
regression); list re-download.

## ⚠ Needs legal input before go-live (marked `TODO_CONFIRM` in
`dpa-region-config.constant.ts`)

- Registered-office addresses for Twenty.com SAS and Twenty, Inc.
- US deployment governing law (the template only specifies France).
- DPO name and the Twenty pre-signed authorized signatory name/title.

## Out of scope (flagged per spec)

Intra-group legal agreement and any Stripe/billing-entity changes. A
future e-sign provider would plug in at `DpaService.generateSignedDpa` +
the signatory input.

> Draft until the integration test passes in CI and the legal
`TODO_CONFIRM` values are supplied.

https://claude.ai/code/session_01Ahjydxx6J1souz1s1NeA9a

---
_Generated by [Claude
Code](https://claude.ai/code/session_01Ahjydxx6J1souz1s1NeA9a)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22243?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-06-27 17:46:38 +02:00
committed by GitHub
parent 77c84815ef
commit 538b180824
64 changed files with 3621 additions and 3 deletions
@@ -0,0 +1,55 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
// Creates the core."dpaAgreement" table backing DpaAgreementEntity.
//
// NOTE: this was authored by hand (no DB available at authoring time). Before
// release, verify against a database with
// npx nx run twenty-server:database:migrate:generate --name create-dpa-agreement --type fast
// which should report no diff. The FK name below reproduces TypeORM's default
// hash for (dpaAgreement.workspaceId) so the entity and this table stay in sync.
@RegisteredInstanceCommand('2.17.0', 1801000020000)
export class CreateDpaAgreementCoreTableFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DO $$ BEGIN CREATE TYPE "core"."dpaAgreement_type_enum" AS ENUM ('CLICK_THROUGH', 'SIGNED'); EXCEPTION WHEN duplicate_object THEN null; END $$`,
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "core"."dpaAgreement" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"type" "core"."dpaAgreement_type_enum" NOT NULL,
"templateVersion" character varying NOT NULL,
"region" character varying NOT NULL,
"processorEntity" character varying NOT NULL,
"customerLegalEntityName" character varying,
"signatoryName" character varying,
"signatoryTitle" character varying,
"signedFileId" uuid,
"acceptedByUserId" uuid,
"acceptedByEmail" character varying,
"acceptedAt" TIMESTAMP WITH TIME ZONE NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"workspaceId" uuid NOT NULL,
CONSTRAINT "PK_dpaAgreement_id" PRIMARY KEY ("id"),
-- FK name must match TypeORM's generated hash for the workspace relation.
CONSTRAINT "FK_abba2f6707bd2bc18bbd52f3c3e" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE
)`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_DPA_AGREEMENT_WORKSPACE_ID"
ON "core"."dpaAgreement" ("workspaceId")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "core"."dpaAgreement"`);
await queryRunner.query(
`DROP TYPE IF EXISTS "core"."dpaAgreement_type_enum"`,
);
}
}
@@ -80,6 +80,7 @@ import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instanc
import { AddPrimaryPublicDomainToApplicationFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782281874768-add-primary-public-domain-to-application';
import { MakePublicDomainApplicationIdNotNullSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-slow-1782281874769-make-public-domain-application-id-not-null';
import { AddServerTriggerSettingsToLogicFunctionFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-16/2-16-instance-command-fast-1782211913427-add-server-trigger-settings-to-logic-function';
import { CreateDpaAgreementCoreTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-17/2-17-instance-command-fast-1801000020000-create-dpa-agreement-core-table';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -162,4 +163,5 @@ export const INSTANCE_COMMANDS = [
AddUniversalIdentifierAndApplicationIdToSearchFieldMetadataFastInstanceCommand,
AddPrimaryPublicDomainToApplicationFastInstanceCommand,
MakePublicDomainApplicationIdNotNullSlowInstanceCommand,
CreateDpaAgreementCoreTableFastInstanceCommand,
];