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:
@@ -0,0 +1,79 @@
|
||||
name: DPA Sub-Processor Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1' # Weekly, Monday at 6 AM UTC
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sync-subprocessors:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/yarn-install
|
||||
|
||||
- name: Build dependencies
|
||||
run: npx nx build twenty-shared
|
||||
|
||||
- name: Run sub-processor sync
|
||||
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/dpa-sync-subprocessors.ts
|
||||
|
||||
- name: Check for changes
|
||||
id: changes
|
||||
run: |
|
||||
if git diff --quiet packages/twenty-server/src/engine/core-modules/dpa/constants/subprocessors.json; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
commit-message: 'chore: sync DPA sub-processors from trust center'
|
||||
title: 'chore: sync DPA sub-processors from trust center'
|
||||
body: |
|
||||
Automated weekly sync of `subprocessors.json` from Twenty's Trust Center (OneLeet).
|
||||
|
||||
This keeps the DPA's Annex C (the SCC Annex III list of Sub-Processors) in
|
||||
lockstep with the canonical list at https://trust.twenty.com — the Trust
|
||||
Center is the single source of truth; this file is generated from it.
|
||||
|
||||
**Please review before merging** — confirm the added/removed Sub-Processors
|
||||
are expected, and that customers were notified per Section 6.2 where required.
|
||||
branch: chore/dpa-subprocessors-sync
|
||||
base: main
|
||||
labels: automated
|
||||
delete-branch: true
|
||||
|
||||
- name: Mint twenty-infra dispatch token
|
||||
id: app-token
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.TWENTY_WORKFLOW_DISPATCHER_CLIENT_ID }}
|
||||
private-key: ${{ secrets.TWENTY_WORKFLOW_DISPATCHER_PRIVATE_KEY }}
|
||||
owner: twentyhq
|
||||
repositories: twenty-infra
|
||||
permission-actions: write
|
||||
|
||||
- name: Trigger automerge
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
gh workflow run automerge-i18n.yaml --repo twentyhq/twenty-infra --ref main
|
||||
@@ -4653,6 +4653,7 @@ enum FileFolder {
|
||||
EmailAttachment
|
||||
AppTarball
|
||||
GeneratedSdkClient
|
||||
Dpa
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
|
||||
@@ -3001,7 +3001,7 @@ export type WorkspaceMigrationActionType = 'delete' | 'create' | 'update'
|
||||
|
||||
export type AnalyticsType = 'PAGEVIEW' | 'TRACK'
|
||||
|
||||
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient'
|
||||
export type FileFolder = 'ProfilePicture' | 'WorkspaceLogo' | 'Attachment' | 'PersonPicture' | 'CorePicture' | 'File' | 'AgentChat' | 'BuiltLogicFunction' | 'BuiltFrontComponent' | 'PublicAsset' | 'Source' | 'FilesField' | 'Dependencies' | 'Workflow' | 'EmailAttachment' | 'AppTarball' | 'GeneratedSdkClient' | 'Dpa'
|
||||
|
||||
export interface Subscription {
|
||||
onEventSubscription?: EventSubscription
|
||||
@@ -9346,5 +9346,6 @@ export const enumFileFolder = {
|
||||
Workflow: 'Workflow' as const,
|
||||
EmailAttachment: 'EmailAttachment' as const,
|
||||
AppTarball: 'AppTarball' as const,
|
||||
GeneratedSdkClient: 'GeneratedSdkClient' as const
|
||||
GeneratedSdkClient: 'GeneratedSdkClient' as const,
|
||||
Dpa: 'Dpa' as const
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@ module.exports = {
|
||||
|
||||
'./src/modules/settings/**/graphql/**/*.{ts,tsx}',
|
||||
'!./src/modules/settings/admin-panel/**/graphql/**/*.{ts,tsx}',
|
||||
// DPA operations are served by the core (/graphql) schema via @CoreResolver,
|
||||
// not the metadata schema, so they must not be validated against /metadata.
|
||||
'!./src/modules/settings/legal/**/graphql/**/*.{ts,tsx}',
|
||||
'./src/modules/logic-functions/graphql/**/*.{ts,tsx}',
|
||||
|
||||
'./src/modules/databases/graphql/**/*.{ts,tsx}',
|
||||
|
||||
@@ -1896,6 +1896,7 @@ export enum FileFolder {
|
||||
BuiltLogicFunction = 'BuiltLogicFunction',
|
||||
CorePicture = 'CorePicture',
|
||||
Dependencies = 'Dependencies',
|
||||
Dpa = 'Dpa',
|
||||
EmailAttachment = 'EmailAttachment',
|
||||
File = 'File',
|
||||
FilesField = 'FilesField',
|
||||
|
||||
@@ -92,6 +92,60 @@ export type DeleteWorkflowVersionStepInput = {
|
||||
workflowVersionId: Scalars['UUID']['input'];
|
||||
};
|
||||
|
||||
export type DpaAgreement = {
|
||||
__typename?: 'DpaAgreement';
|
||||
acceptedAt: Scalars['DateTime']['output'];
|
||||
acceptedByEmail?: Maybe<Scalars['String']['output']>;
|
||||
createdAt: Scalars['DateTime']['output'];
|
||||
customerLegalEntityName?: Maybe<Scalars['String']['output']>;
|
||||
downloadUrl?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['UUID']['output'];
|
||||
processorEntity: Scalars['String']['output'];
|
||||
region: DpaRegion;
|
||||
signatoryName?: Maybe<Scalars['String']['output']>;
|
||||
signatoryTitle?: Maybe<Scalars['String']['output']>;
|
||||
signedFileId?: Maybe<Scalars['String']['output']>;
|
||||
templateVersion: Scalars['String']['output'];
|
||||
type: DpaAgreementType;
|
||||
updatedAt: Scalars['DateTime']['output'];
|
||||
};
|
||||
|
||||
export enum DpaAgreementType {
|
||||
CLICK_THROUGH = 'CLICK_THROUGH',
|
||||
SIGNED = 'SIGNED'
|
||||
}
|
||||
|
||||
export type DpaDocument = {
|
||||
__typename?: 'DpaDocument';
|
||||
blocks: Array<DpaDocumentBlock>;
|
||||
lastUpdatedLabel: Scalars['String']['output'];
|
||||
notice?: Maybe<Scalars['String']['output']>;
|
||||
processorEntity: Scalars['String']['output'];
|
||||
region: DpaRegion;
|
||||
sccSectionActive: Scalars['Boolean']['output'];
|
||||
templateVersion: Scalars['String']['output'];
|
||||
title: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type DpaDocumentBlock = {
|
||||
__typename?: 'DpaDocumentBlock';
|
||||
kind: DpaDocumentBlockKind;
|
||||
label?: Maybe<Scalars['String']['output']>;
|
||||
text: Scalars['String']['output'];
|
||||
value?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export enum DpaDocumentBlockKind {
|
||||
Heading = 'Heading',
|
||||
Paragraph = 'Paragraph',
|
||||
SignatureField = 'SignatureField'
|
||||
}
|
||||
|
||||
export enum DpaRegion {
|
||||
EU = 'EU',
|
||||
US = 'US'
|
||||
}
|
||||
|
||||
export type DuplicateWorkflowInput = {
|
||||
/** Workflow ID to duplicate */
|
||||
workflowIdToDuplicate: Scalars['UUID']['input'];
|
||||
@@ -109,6 +163,18 @@ export enum FilterIs {
|
||||
Null = 'Null'
|
||||
}
|
||||
|
||||
export type GenerateSignedDpaInput = {
|
||||
customerLegalEntityName: Scalars['String']['input'];
|
||||
signatoryName: Scalars['String']['input'];
|
||||
signatoryTitle: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
export type GenerateSignedDpaResult = {
|
||||
__typename?: 'GenerateSignedDpaResult';
|
||||
agreement: DpaAgreement;
|
||||
downloadUrl: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
export type LinkMetadata = {
|
||||
__typename?: 'LinkMetadata';
|
||||
label: Scalars['String']['output'];
|
||||
@@ -142,6 +208,7 @@ export type Mutation = {
|
||||
dismissReconnectAccountBanner: Scalars['Boolean']['output'];
|
||||
duplicateWorkflow: WorkflowVersionDto;
|
||||
duplicateWorkflowVersionStep: WorkflowVersionStepChanges;
|
||||
generateSignedDpa: GenerateSignedDpaResult;
|
||||
retryWorkflowRun: WorkflowRun;
|
||||
runWorkflowVersion: RunWorkflowVersion;
|
||||
stopWorkflowRun: WorkflowRun;
|
||||
@@ -208,6 +275,11 @@ export type MutationDuplicateWorkflowVersionStepArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationGenerateSignedDpaArgs = {
|
||||
input: GenerateSignedDpaInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationRetryWorkflowRunArgs = {
|
||||
workflowRunId: Scalars['UUID']['input'];
|
||||
};
|
||||
@@ -259,6 +331,8 @@ export type ObjectRecordFilterInput = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
dpaAgreements: Array<DpaAgreement>;
|
||||
dpaPreview: DpaDocument;
|
||||
/** @deprecated Use getTimelineCalendarEventsFromObjectRecord instead */
|
||||
getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal;
|
||||
getTimelineCalendarEventsFromObjectRecord: TimelineCalendarEventsWithTotal;
|
||||
|
||||
@@ -114,6 +114,18 @@ const SettingsGeneral = lazy(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsLegalDpa = lazy(() =>
|
||||
import('~/pages/settings/legal/SettingsLegalDpa').then((module) => ({
|
||||
default: module.SettingsLegalDpa,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsLegalDpaNew = lazy(() =>
|
||||
import('~/pages/settings/legal/SettingsLegalDpaNew').then((module) => ({
|
||||
default: module.SettingsLegalDpaNew,
|
||||
})),
|
||||
);
|
||||
|
||||
const SettingsWorkspaceEmail = lazy(() =>
|
||||
import('~/pages/settings/email/SettingsWorkspaceEmail').then((module) => ({
|
||||
default: module.SettingsWorkspaceEmail,
|
||||
@@ -682,6 +694,11 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
|
||||
path={SettingsPath.PublicDomain}
|
||||
element={<SettingPublicDomain />}
|
||||
/>
|
||||
<Route path={SettingsPath.LegalDpa} element={<SettingsLegalDpa />} />
|
||||
<Route
|
||||
path={SettingsPath.LegalDpaNew}
|
||||
element={<SettingsLegalDpaNew />}
|
||||
/>
|
||||
</Route>
|
||||
<Route
|
||||
element={
|
||||
|
||||
@@ -9,12 +9,14 @@ import { RecordIndexSkeletonLoader } from '@/object-record/record-index/componen
|
||||
import { BlankLayout } from '@/ui/layout/page/components/BlankLayout';
|
||||
import { DefaultLayout } from '@/ui/layout/page/components/DefaultLayout';
|
||||
import { MainAppLayoutWithSidePanel } from '@/ui/layout/page/components/MainAppLayoutWithSidePanel';
|
||||
import { AppPath } from 'twenty-shared/types';
|
||||
import { AppPath, SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
|
||||
import { lazy } from 'react';
|
||||
import {
|
||||
createBrowserRouter,
|
||||
createRoutesFromElements,
|
||||
Navigate,
|
||||
Route,
|
||||
} from 'react-router-dom';
|
||||
|
||||
@@ -270,6 +272,15 @@ export const useCreateAppRouter = (
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* Deep link for twenty.com/dpa → in-app generator. This route is
|
||||
inside the authenticated layout, so an unauthenticated hit is
|
||||
login-gated and returns here after sign-in. */}
|
||||
<Route
|
||||
path={AppPath.Dpa}
|
||||
element={
|
||||
<Navigate to={getSettingsPath(SettingsPath.LegalDpa)} replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={AppPath.NotFoundWildcard}
|
||||
element={
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import { type DpaDocument } from '@/settings/legal/types/Dpa';
|
||||
|
||||
const StyledDocument = styled.div`
|
||||
background: ${themeCssVariables.background.primary};
|
||||
border: 1px solid ${themeCssVariables.border.color.medium};
|
||||
border-radius: ${themeCssVariables.border.radius.md};
|
||||
color: ${themeCssVariables.font.color.primary};
|
||||
font-size: ${themeCssVariables.font.size.sm};
|
||||
line-height: 1.5;
|
||||
max-height: 460px;
|
||||
overflow-y: auto;
|
||||
padding: ${themeCssVariables.spacing[6]};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.h1`
|
||||
font-size: ${themeCssVariables.font.size.lg};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin: 0;
|
||||
`;
|
||||
|
||||
const StyledLastUpdated = styled.div`
|
||||
color: ${themeCssVariables.font.color.tertiary};
|
||||
font-size: ${themeCssVariables.font.size.xs};
|
||||
margin-bottom: ${themeCssVariables.spacing[4]};
|
||||
margin-top: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
const StyledHeading = styled.h2`
|
||||
font-size: ${themeCssVariables.font.size.md};
|
||||
font-weight: ${themeCssVariables.font.weight.semiBold};
|
||||
margin-bottom: ${themeCssVariables.spacing[1]};
|
||||
margin-top: ${themeCssVariables.spacing[4]};
|
||||
`;
|
||||
|
||||
const StyledParagraph = styled.p`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
margin: ${themeCssVariables.spacing[2]} 0;
|
||||
text-align: justify;
|
||||
`;
|
||||
|
||||
const StyledSignatureField = styled.div`
|
||||
margin: ${themeCssVariables.spacing[2]} 0;
|
||||
`;
|
||||
|
||||
const StyledSignatureLabel = styled.div`
|
||||
font-weight: ${themeCssVariables.font.weight.medium};
|
||||
`;
|
||||
|
||||
const StyledSignatureValue = styled.div`
|
||||
color: ${themeCssVariables.font.color.secondary};
|
||||
white-space: pre-wrap;
|
||||
`;
|
||||
|
||||
type DpaDocumentPreviewProps = {
|
||||
document: DpaDocument;
|
||||
};
|
||||
|
||||
export const DpaDocumentPreview = ({ document }: DpaDocumentPreviewProps) => (
|
||||
// tabIndex makes the scrollable region focusable so keyboard-only users can
|
||||
// scroll the agreement with the arrow keys.
|
||||
<StyledDocument tabIndex={0} role="region" aria-label={document.title}>
|
||||
<StyledTitle>{document.title}</StyledTitle>
|
||||
<StyledLastUpdated>
|
||||
Last Updated: {document.lastUpdatedLabel}
|
||||
</StyledLastUpdated>
|
||||
{document.blocks.map((block, index) => {
|
||||
if (block.kind === 'heading') {
|
||||
return <StyledHeading key={index}>{block.text}</StyledHeading>;
|
||||
}
|
||||
|
||||
if (block.kind === 'signatureField') {
|
||||
return (
|
||||
<StyledSignatureField key={index}>
|
||||
<StyledSignatureLabel>{block.label}</StyledSignatureLabel>
|
||||
<StyledSignatureValue>{block.value}</StyledSignatureValue>
|
||||
</StyledSignatureField>
|
||||
);
|
||||
}
|
||||
|
||||
return <StyledParagraph key={index}>{block.text}</StyledParagraph>;
|
||||
})}
|
||||
</StyledDocument>
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Info } from 'twenty-ui/feedback';
|
||||
|
||||
const StyledFullWidthInfo = styled.div`
|
||||
& > * {
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
type DpaNoticeProps = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export const DpaNotice = ({ text }: DpaNoticeProps) => (
|
||||
<StyledFullWidthInfo>
|
||||
<Info accent="danger" text={text} />
|
||||
</StyledFullWidthInfo>
|
||||
);
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { IconDownload } from 'twenty-ui/icon';
|
||||
|
||||
import { type DpaAgreement } from '@/settings/legal/types/Dpa';
|
||||
import { TableCell } from '@/ui/layout/table/components/TableCell';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { beautifyExactDateTime } from '~/utils/date-utils';
|
||||
|
||||
export const DPA_AGREEMENT_ROW_GRID_COLUMNS = '3fr 2fr 2fr 140px';
|
||||
|
||||
const StyledEllipsisLabel = styled.div`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
`;
|
||||
|
||||
type SettingsDpaAgreementRowProps = {
|
||||
agreement: DpaAgreement;
|
||||
onDownload: (agreement: DpaAgreement) => void;
|
||||
};
|
||||
|
||||
export const SettingsDpaAgreementRow = ({
|
||||
agreement,
|
||||
onDownload,
|
||||
}: SettingsDpaAgreementRowProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const label =
|
||||
agreement.type === 'SIGNED'
|
||||
? (agreement.customerLegalEntityName ?? t`Signed copy`)
|
||||
: t`Click-through acceptance`;
|
||||
|
||||
return (
|
||||
<TableRow gridAutoColumns={DPA_AGREEMENT_ROW_GRID_COLUMNS}>
|
||||
<TableCell whiteSpace="nowrap" overflow="hidden" textOverflow="ellipsis">
|
||||
<StyledEllipsisLabel>{label}</StyledEllipsisLabel>
|
||||
</TableCell>
|
||||
<TableCell>{agreement.templateVersion}</TableCell>
|
||||
<TableCell>{beautifyExactDateTime(agreement.acceptedAt)}</TableCell>
|
||||
<TableCell align="right">
|
||||
{agreement.downloadUrl ? (
|
||||
<Button
|
||||
Icon={IconDownload}
|
||||
title={t`Download`}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={() => onDownload(agreement)}
|
||||
/>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
};
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { styled } from '@linaria/react';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
import {
|
||||
DPA_AGREEMENT_ROW_GRID_COLUMNS,
|
||||
SettingsDpaAgreementRow,
|
||||
} from '@/settings/legal/components/SettingsDpaAgreementRow';
|
||||
import { type DpaAgreement } from '@/settings/legal/types/Dpa';
|
||||
import { Table } from '@/ui/layout/table/components/Table';
|
||||
import { TableBody } from '@/ui/layout/table/components/TableBody';
|
||||
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
|
||||
import { TableRow } from '@/ui/layout/table/components/TableRow';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { downloadFile } from '@/activities/files/utils/downloadFile';
|
||||
|
||||
const StyledTableBodyContainer = styled.div`
|
||||
border-bottom: 1px solid ${themeCssVariables.border.color.light};
|
||||
`;
|
||||
|
||||
type SettingsDpaAgreementsTableProps = {
|
||||
agreements: DpaAgreement[];
|
||||
};
|
||||
|
||||
export const SettingsDpaAgreementsTable = ({
|
||||
agreements,
|
||||
}: SettingsDpaAgreementsTableProps) => {
|
||||
const { t } = useLingui();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
|
||||
const handleDownload = async (agreement: DpaAgreement) => {
|
||||
if (!agreement.downloadUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadFile(
|
||||
agreement.downloadUrl,
|
||||
`Twenty-DPA-${agreement.templateVersion}-${agreement.customerLegalEntityName ?? 'copy'}.pdf`,
|
||||
);
|
||||
} catch {
|
||||
enqueueErrorSnackBar({ message: t`Could not download the document.` });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableRow gridAutoColumns={DPA_AGREEMENT_ROW_GRID_COLUMNS}>
|
||||
<TableHeader>
|
||||
<Trans>Document</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Trans>Version</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader>
|
||||
<Trans>Date</Trans>
|
||||
</TableHeader>
|
||||
<TableHeader></TableHeader>
|
||||
</TableRow>
|
||||
{agreements.length > 0 && (
|
||||
<StyledTableBodyContainer>
|
||||
<TableBody>
|
||||
{agreements.map((agreement) => (
|
||||
<SettingsDpaAgreementRow
|
||||
key={agreement.id}
|
||||
agreement={agreement}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</StyledTableBodyContainer>
|
||||
)}
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const GENERATE_SIGNED_DPA = gql`
|
||||
mutation GenerateSignedDpa($input: GenerateSignedDpaInput!) {
|
||||
generateSignedDpa(input: $input) {
|
||||
downloadUrl
|
||||
agreement {
|
||||
id
|
||||
type
|
||||
templateVersion
|
||||
region
|
||||
processorEntity
|
||||
customerLegalEntityName
|
||||
signatoryName
|
||||
signatoryTitle
|
||||
acceptedByEmail
|
||||
acceptedAt
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,20 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const GET_DPA_AGREEMENTS = gql`
|
||||
query GetDpaAgreements {
|
||||
dpaAgreements {
|
||||
id
|
||||
type
|
||||
templateVersion
|
||||
region
|
||||
processorEntity
|
||||
customerLegalEntityName
|
||||
signatoryName
|
||||
signatoryTitle
|
||||
acceptedByEmail
|
||||
acceptedAt
|
||||
createdAt
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,21 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const GET_DPA_PREVIEW = gql`
|
||||
query GetDpaPreview {
|
||||
dpaPreview {
|
||||
title
|
||||
lastUpdatedLabel
|
||||
templateVersion
|
||||
region
|
||||
processorEntity
|
||||
sccSectionActive
|
||||
notice
|
||||
blocks {
|
||||
kind
|
||||
text
|
||||
label
|
||||
value
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,39 @@
|
||||
export type DpaAgreementType = 'CLICK_THROUGH' | 'SIGNED';
|
||||
|
||||
export type DpaDocumentBlock = {
|
||||
kind: string;
|
||||
text: string;
|
||||
label?: string | null;
|
||||
value?: string | null;
|
||||
};
|
||||
|
||||
export type DpaDocument = {
|
||||
title: string;
|
||||
lastUpdatedLabel: string;
|
||||
templateVersion: string;
|
||||
region: string;
|
||||
processorEntity: string;
|
||||
sccSectionActive: boolean;
|
||||
notice?: string | null;
|
||||
blocks: DpaDocumentBlock[];
|
||||
};
|
||||
|
||||
export type DpaAgreement = {
|
||||
id: string;
|
||||
type: DpaAgreementType;
|
||||
templateVersion: string;
|
||||
region: string;
|
||||
processorEntity: string;
|
||||
customerLegalEntityName?: string | null;
|
||||
signatoryName?: string | null;
|
||||
signatoryTitle?: string | null;
|
||||
acceptedByEmail?: string | null;
|
||||
acceptedAt: string;
|
||||
createdAt: string;
|
||||
downloadUrl?: string | null;
|
||||
};
|
||||
|
||||
export type GenerateSignedDpaResult = {
|
||||
downloadUrl: string;
|
||||
agreement: DpaAgreement;
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useQuery } from '@apollo/client/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
|
||||
import { IconPlus } from 'twenty-ui/icon';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { UndecoratedLink } from 'twenty-ui/navigation';
|
||||
import { H2Title } from 'twenty-ui/typography';
|
||||
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { DpaDocumentPreview } from '@/settings/legal/components/DpaDocumentPreview';
|
||||
import { DpaNotice } from '@/settings/legal/components/DpaNotice';
|
||||
import { SettingsDpaAgreementsTable } from '@/settings/legal/components/SettingsDpaAgreementsTable';
|
||||
import { GET_DPA_AGREEMENTS } from '@/settings/legal/graphql/queries/getDpaAgreements';
|
||||
import { GET_DPA_PREVIEW } from '@/settings/legal/graphql/queries/getDpaPreview';
|
||||
import {
|
||||
type DpaAgreement,
|
||||
type DpaDocument,
|
||||
} from '@/settings/legal/types/Dpa';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
|
||||
export const SettingsLegalDpa = () => {
|
||||
const { t } = useLingui();
|
||||
// DPA queries are served by the core (/graphql) schema, not the default /metadata client.
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const { data: agreementsData, loading: agreementsLoading } = useQuery<{
|
||||
dpaAgreements: DpaAgreement[];
|
||||
}>(GET_DPA_AGREEMENTS, { client: apolloCoreClient });
|
||||
|
||||
const agreements = agreementsData?.dpaAgreements ?? [];
|
||||
const hasAgreements = agreements.length > 0;
|
||||
|
||||
const { data: previewData, error: previewError } = useQuery<{
|
||||
dpaPreview: DpaDocument;
|
||||
}>(GET_DPA_PREVIEW, {
|
||||
client: apolloCoreClient,
|
||||
skip: agreementsLoading || hasAgreements,
|
||||
});
|
||||
|
||||
const preview = previewData?.dpaPreview;
|
||||
const isPreviewSettled = isDefined(previewData) || isDefined(previewError);
|
||||
const isLoading = agreementsLoading || (!hasAgreements && !isPreviewSettled);
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={t`Data Processing Agreement`}
|
||||
links={[
|
||||
{ children: t`Workspace`, href: getSettingsPath(SettingsPath.General) },
|
||||
{ children: t`Legal` },
|
||||
]}
|
||||
actionButton={
|
||||
<UndecoratedLink to={getSettingsPath(SettingsPath.LegalDpaNew)}>
|
||||
<Button
|
||||
Icon={IconPlus}
|
||||
title={t`Generate DPA`}
|
||||
accent="blue"
|
||||
size="small"
|
||||
/>
|
||||
</UndecoratedLink>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{isLoading ? (
|
||||
<SettingsSkeletonLoader />
|
||||
) : hasAgreements ? (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Executed copies`}
|
||||
description={t`Accepted and signed DPAs for this workspace, with their template version and date.`}
|
||||
/>
|
||||
<SettingsDpaAgreementsTable agreements={agreements} />
|
||||
</Section>
|
||||
) : preview ? (
|
||||
<>
|
||||
{preview.notice && (
|
||||
<Section>
|
||||
<DpaNotice text={preview.notice} />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Data Processing Agreement`}
|
||||
description={t`No copy has been generated yet. This is the agreement that applies to your deployment — generate a signed copy from the top-right.`}
|
||||
/>
|
||||
<DpaDocumentPreview document={preview} />
|
||||
</Section>
|
||||
</>
|
||||
) : (
|
||||
<Section>
|
||||
<DpaNotice
|
||||
text={t`The Data Processing Agreement could not be loaded. Please try again.`}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</SettingsPageContainer>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useMutation, useQuery } from '@apollo/client/react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useState } from 'react';
|
||||
import { SettingsPath } from 'twenty-shared/types';
|
||||
import { getSettingsPath } from 'twenty-shared/utils';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { H2Title } from 'twenty-ui/typography';
|
||||
|
||||
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
|
||||
import { DpaDocumentPreview } from '@/settings/legal/components/DpaDocumentPreview';
|
||||
import { DpaNotice } from '@/settings/legal/components/DpaNotice';
|
||||
import { GENERATE_SIGNED_DPA } from '@/settings/legal/graphql/mutations/generateSignedDpa';
|
||||
import { GET_DPA_AGREEMENTS } from '@/settings/legal/graphql/queries/getDpaAgreements';
|
||||
import { GET_DPA_PREVIEW } from '@/settings/legal/graphql/queries/getDpaPreview';
|
||||
import {
|
||||
type DpaDocument,
|
||||
type GenerateSignedDpaResult,
|
||||
} from '@/settings/legal/types/Dpa';
|
||||
import { SaveAndCancelButtons } from '@/settings/components/SaveAndCancelButtons/SaveAndCancelButtons';
|
||||
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
|
||||
import { SettingsPageLayout } from '@/settings/components/layout/SettingsPageLayout';
|
||||
import { SettingsSkeletonLoader } from '@/settings/components/SettingsSkeletonLoader';
|
||||
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { downloadFile } from '@/activities/files/utils/downloadFile';
|
||||
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
|
||||
|
||||
export const SettingsLegalDpaNew = () => {
|
||||
const { t } = useLingui();
|
||||
const navigateSettings = useNavigateSettings();
|
||||
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
|
||||
// DPA operations live on the core (/graphql) schema, not the default /metadata client.
|
||||
const apolloCoreClient = useApolloCoreClient();
|
||||
|
||||
const [customerLegalEntityName, setCustomerLegalEntityName] = useState('');
|
||||
const [signatoryName, setSignatoryName] = useState('');
|
||||
const [signatoryTitle, setSignatoryTitle] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
|
||||
const { data: previewData, loading: previewLoading } = useQuery<{
|
||||
dpaPreview: DpaDocument;
|
||||
}>(GET_DPA_PREVIEW, { client: apolloCoreClient });
|
||||
|
||||
const [generateSignedDpa] = useMutation<{
|
||||
generateSignedDpa: GenerateSignedDpaResult;
|
||||
}>(GENERATE_SIGNED_DPA, {
|
||||
client: apolloCoreClient,
|
||||
refetchQueries: [{ query: GET_DPA_AGREEMENTS }],
|
||||
awaitRefetchQueries: true,
|
||||
});
|
||||
|
||||
const preview = previewData?.dpaPreview;
|
||||
|
||||
const canSave =
|
||||
customerLegalEntityName.trim() !== '' &&
|
||||
signatoryName.trim() !== '' &&
|
||||
signatoryTitle.trim() !== '' &&
|
||||
!preview?.notice &&
|
||||
!isGenerating;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
|
||||
const trimmedLegalEntityName = customerLegalEntityName.trim();
|
||||
const trimmedSignatoryName = signatoryName.trim();
|
||||
const trimmedSignatoryTitle = signatoryTitle.trim();
|
||||
|
||||
try {
|
||||
const { data } = await generateSignedDpa({
|
||||
variables: {
|
||||
input: {
|
||||
customerLegalEntityName: trimmedLegalEntityName,
|
||||
signatoryName: trimmedSignatoryName,
|
||||
signatoryTitle: trimmedSignatoryTitle,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = data?.generateSignedDpa;
|
||||
|
||||
if (!result) {
|
||||
throw new Error('No result returned');
|
||||
}
|
||||
|
||||
const safeLegalEntityName = trimmedLegalEntityName.replace(
|
||||
/[/\\:*?"<>|]+/g,
|
||||
'-',
|
||||
);
|
||||
|
||||
await downloadFile(
|
||||
result.downloadUrl,
|
||||
`Twenty-DPA-${result.agreement.templateVersion}-${safeLegalEntityName}.pdf`,
|
||||
);
|
||||
|
||||
enqueueSuccessSnackBar({
|
||||
message: t`Signed DPA generated and downloaded`,
|
||||
});
|
||||
navigateSettings(SettingsPath.LegalDpa);
|
||||
} catch {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Could not generate the signed DPA. Please try again.`,
|
||||
});
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (previewLoading) {
|
||||
return <SettingsSkeletonLoader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
title={t`Generate DPA`}
|
||||
links={[
|
||||
{ children: t`Workspace`, href: getSettingsPath(SettingsPath.General) },
|
||||
{ children: t`Legal`, href: getSettingsPath(SettingsPath.LegalDpa) },
|
||||
{ children: t`Generate` },
|
||||
]}
|
||||
actionButton={
|
||||
<SaveAndCancelButtons
|
||||
isSaveDisabled={!canSave}
|
||||
isLoading={isGenerating}
|
||||
onCancel={() => navigateSettings(SettingsPath.LegalDpa)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsPageContainer>
|
||||
{preview?.notice && (
|
||||
<Section>
|
||||
<DpaNotice text={preview.notice} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Your details`}
|
||||
description={t`The PDF is pre-signed by Twenty and executed with your legal entity and authorized signatory.`}
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId="dpa-legal-entity-name"
|
||||
label={t`Legal entity name`}
|
||||
placeholder={t`Acme GmbH`}
|
||||
value={customerLegalEntityName}
|
||||
onChange={setCustomerLegalEntityName}
|
||||
fullWidth
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId="dpa-signatory-name"
|
||||
label={t`Authorized signatory name`}
|
||||
placeholder={t`Jane Doe`}
|
||||
value={signatoryName}
|
||||
onChange={setSignatoryName}
|
||||
fullWidth
|
||||
/>
|
||||
<SettingsTextInput
|
||||
instanceId="dpa-signatory-title"
|
||||
label={t`Signatory title`}
|
||||
placeholder={t`Head of Legal`}
|
||||
value={signatoryTitle}
|
||||
onChange={setSignatoryTitle}
|
||||
fullWidth
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{preview && (
|
||||
<Section>
|
||||
<H2Title
|
||||
title={t`Preview`}
|
||||
description={t`The full agreement with fields resolved for your deployment.`}
|
||||
/>
|
||||
<DpaDocumentPreview document={preview} />
|
||||
</Section>
|
||||
)}
|
||||
</SettingsPageContainer>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
};
|
||||
@@ -6,4 +6,7 @@ export const UNTESTED_APP_PATHS = [
|
||||
AppPath.WorkspaceActivationV2,
|
||||
AppPath.CreateProfileV2,
|
||||
AppPath.SyncEmailsV2,
|
||||
// Public, unauthenticated redirect route handled in useCreateAppRouter — not
|
||||
// part of the onboarding/auth page-change navigation matrix.
|
||||
AppPath.Dpa,
|
||||
];
|
||||
|
||||
@@ -59,6 +59,10 @@
|
||||
{
|
||||
"include": "engine/metadata-modules/ai/ai-models/ai-providers.json",
|
||||
"outDir": "dist"
|
||||
},
|
||||
{
|
||||
"include": "engine/core-modules/dpa/constants/subprocessors.json",
|
||||
"outDir": "dist"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-graphql-npm-9.4.0-8e6f7894e1.patch",
|
||||
"@ptc-org/nestjs-query-typeorm": "patch:@ptc-org/nestjs-query-typeorm@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-typeorm-npm-9.4.0-ca3414967e.patch",
|
||||
"@react-email/render": "^1.2.3",
|
||||
"@react-pdf/renderer": "^4.1.6",
|
||||
"@sentry/nestjs": "^10.59.0",
|
||||
"@sentry/node": "^10.59.0",
|
||||
"@sentry/profiling-node": "^10.59.0",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as prettier from 'prettier';
|
||||
|
||||
import { ONELEET_TRUST_API_URL } from 'src/engine/core-modules/dpa/constants/oneleet-trust.constant';
|
||||
import {
|
||||
type Subprocessor,
|
||||
type SubprocessorList,
|
||||
} from 'src/engine/core-modules/dpa/types/subprocessor.type';
|
||||
|
||||
type TrustCenterResponse = {
|
||||
subprocessors?: Array<{
|
||||
name?: string;
|
||||
services?: unknown[];
|
||||
processingLocations?: unknown[];
|
||||
processesPii?: boolean;
|
||||
vendorUrl?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const normalizeSubprocessors = (
|
||||
raw: TrustCenterResponse['subprocessors'],
|
||||
): Subprocessor[] =>
|
||||
(raw ?? [])
|
||||
.map((entry) => {
|
||||
const subprocessor: Subprocessor = {
|
||||
name: (entry.name ?? '').trim(),
|
||||
services: (entry.services ?? [])
|
||||
.map((service) => String(service).trim())
|
||||
.filter(Boolean),
|
||||
processingLocations: (entry.processingLocations ?? [])
|
||||
.map((location) => String(location).trim())
|
||||
.filter(Boolean),
|
||||
processesPii: entry.processesPii === true,
|
||||
};
|
||||
|
||||
const vendorUrl = (entry.vendorUrl ?? '').trim();
|
||||
|
||||
if (vendorUrl) {
|
||||
subprocessor.vendorUrl = vendorUrl;
|
||||
}
|
||||
|
||||
return subprocessor;
|
||||
})
|
||||
.filter((subprocessor) => subprocessor.name.length > 0)
|
||||
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(`Fetching sub-processors from ${ONELEET_TRUST_API_URL}...`);
|
||||
|
||||
const response = await fetch(ONELEET_TRUST_API_URL);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TrustCenterResponse;
|
||||
const subprocessors = normalizeSubprocessors(data.subprocessors);
|
||||
|
||||
if (subprocessors.length === 0) {
|
||||
throw new Error(
|
||||
'Trust Center returned no sub-processors; refusing to overwrite the list.',
|
||||
);
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(
|
||||
`Found ${subprocessors.length} sub-processors: ${subprocessors
|
||||
.map((subprocessor) => subprocessor.name)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
const result: SubprocessorList = { subprocessors };
|
||||
const json = JSON.stringify(result, null, 2) + '\n';
|
||||
|
||||
if (dryRun) {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log('[DRY RUN] Would write subprocessors.json');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const outputPath = path.resolve(
|
||||
__dirname,
|
||||
'..',
|
||||
'src',
|
||||
'engine',
|
||||
'core-modules',
|
||||
'dpa',
|
||||
'constants',
|
||||
'subprocessors.json',
|
||||
);
|
||||
|
||||
const prettierConfig = await prettier.resolveConfig(outputPath);
|
||||
|
||||
const formatted = await prettier.format(json, {
|
||||
...prettierConfig,
|
||||
filepath: outputPath,
|
||||
});
|
||||
|
||||
fs.writeFileSync(outputPath, formatted, 'utf-8');
|
||||
// oxlint-disable-next-line no-console
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
// oxlint-disable-next-line no-console
|
||||
console.error('DPA sub-processor sync failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
+55
@@ -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"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
@@ -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,
|
||||
];
|
||||
|
||||
@@ -31,6 +31,10 @@ import {
|
||||
hashPassword,
|
||||
} from 'src/engine/core-modules/auth/auth.util';
|
||||
import { MAX_WORKSPACES_WITHOUT_ENTERPRISE_KEY } from 'src/engine/core-modules/auth/constants/max-workspaces-without-enterprise-key.constants';
|
||||
import { DEFAULT_DPA_REGION } from 'src/engine/core-modules/dpa/config/dpa-region-config.constant';
|
||||
import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
import { DpaAgreementType } from 'src/engine/core-modules/dpa/enums/dpa-agreement-type.enum';
|
||||
import { buildDpaAgreementRecord } from 'src/engine/core-modules/dpa/utils/build-dpa-agreement-record.util';
|
||||
import {
|
||||
type AuthProviderWithPasswordType,
|
||||
type ExistingUserOrPartialUserWithPicture,
|
||||
@@ -636,6 +640,31 @@ export class SignInUpService {
|
||||
queryRunner,
|
||||
);
|
||||
|
||||
// Click-through DPA: the DPA is incorporated by reference into the
|
||||
// ToS/signup, so acceptance = execution. Only relevant on Twenty's
|
||||
// managed cloud (multi-workspace), where Twenty is the Processor
|
||||
// hosting the data; on self-hosted deployments Twenty is not the
|
||||
// Processor, so there is nothing to record. Done atomically with
|
||||
// workspace creation so we can later prove what was agreed. (Billing
|
||||
// is an independent feature flag and must not be used to detect cloud.)
|
||||
if (
|
||||
this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED') === true
|
||||
) {
|
||||
await queryRunner.manager.save(
|
||||
DpaAgreementEntity,
|
||||
buildDpaAgreementRecord({
|
||||
workspaceId: workspace.id,
|
||||
type: DpaAgreementType.CLICK_THROUGH,
|
||||
region:
|
||||
this.twentyConfigService.get('DPA_DEPLOYMENT_REGION') ??
|
||||
DEFAULT_DPA_REGION,
|
||||
acceptedAt: new Date(),
|
||||
acceptedByUserId: user.id,
|
||||
acceptedByEmail: email,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return { user, workspace };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { CaptchaModule } from 'src/engine/core-modules/captcha/captcha.module';
|
||||
import { CloudflareModule } from 'src/engine/core-modules/cloudflare/cloudflare.module';
|
||||
import { CodeInterpreterModule } from 'src/engine/core-modules/code-interpreter/code-interpreter.module';
|
||||
import { DnsManagerModule } from 'src/engine/core-modules/dns-manager/dns-manager.module';
|
||||
import { DpaModule } from 'src/engine/core-modules/dpa/dpa.module';
|
||||
import { EmailModule } from 'src/engine/core-modules/email/email.module';
|
||||
import { EmailingDomainModule } from 'src/engine/core-modules/emailing-domain/emailing-domain.module';
|
||||
import { EmailingModule } from 'src/modules/emailing/emailing.module';
|
||||
@@ -158,6 +159,7 @@ import { FileModule } from './file/file.module';
|
||||
CodeInterpreterModule.forRoot(),
|
||||
SearchModule,
|
||||
ApiKeyModule,
|
||||
DpaModule,
|
||||
PageLayoutModule,
|
||||
ImpersonationModule,
|
||||
TrashCleanupModule,
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import subprocessorsData from 'src/engine/core-modules/dpa/constants/subprocessors.json';
|
||||
import { type SubprocessorList } from 'src/engine/core-modules/dpa/types/subprocessor.type';
|
||||
|
||||
describe('subprocessors.json integrity', () => {
|
||||
const { subprocessors } = subprocessorsData as SubprocessorList;
|
||||
|
||||
it('has at least one sub-processor', () => {
|
||||
expect(subprocessors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('every entry has a name, services, processing locations and a pii flag', () => {
|
||||
for (const subprocessor of subprocessors) {
|
||||
expect(typeof subprocessor.name).toBe('string');
|
||||
expect(subprocessor.name.length).toBeGreaterThan(0);
|
||||
|
||||
expect(Array.isArray(subprocessor.services)).toBe(true);
|
||||
expect(subprocessor.services.length).toBeGreaterThan(0);
|
||||
|
||||
expect(Array.isArray(subprocessor.processingLocations)).toBe(true);
|
||||
expect(subprocessor.processingLocations.length).toBeGreaterThan(0);
|
||||
|
||||
expect(typeof subprocessor.processesPii).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('is sorted by name so the synced diff stays stable', () => {
|
||||
const names = subprocessors.map((subprocessor) => subprocessor.name);
|
||||
const sorted = [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
|
||||
expect(names).toEqual(sorted);
|
||||
});
|
||||
});
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
import { type DpaRegionConfig } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
|
||||
export const DEFAULT_DPA_REGION: DpaRegion = DpaRegion.EU;
|
||||
|
||||
export const DPA_REGION_CONFIGS: Record<DpaRegion, DpaRegionConfig> = {
|
||||
[DpaRegion.EU]: {
|
||||
region: DpaRegion.EU,
|
||||
sccSectionActive: false,
|
||||
values: {
|
||||
PROCESSOR_ENTITY: 'Twenty.com SAS',
|
||||
PROCESSOR_LEGAL_FORM:
|
||||
'a société par actions simplifiée under the laws of France',
|
||||
PROCESSOR_ADDRESS: '9 Rue des Colonnes, 75002 Paris, France',
|
||||
HOSTING_REGION: 'EU (Frankfurt, Germany)',
|
||||
GOVERNING_LAW: 'France',
|
||||
DPO_NAME_AND_CONTACT: 'Stéphanie Joly, privacy@twenty.com',
|
||||
},
|
||||
},
|
||||
[DpaRegion.US]: {
|
||||
region: DpaRegion.US,
|
||||
sccSectionActive: true,
|
||||
values: {
|
||||
PROCESSOR_ENTITY: 'Twenty, Inc.',
|
||||
PROCESSOR_LEGAL_FORM:
|
||||
'a public benefit corporation under the laws of Delaware, USA',
|
||||
// Registered office is the Delaware registered agent; the SF notices
|
||||
// address is kept distinct so the two are not conflated under one label.
|
||||
PROCESSOR_ADDRESS:
|
||||
'c/o National Registered Agents, Inc., 1209 Orange Street, Wilmington, Delaware 19801, USA. For notices: 2261 Market Street #5275, San Francisco, California 94114, USA',
|
||||
HOSTING_REGION: 'United States',
|
||||
GOVERNING_LAW: 'the State of Delaware, USA',
|
||||
DPO_NAME_AND_CONTACT: 'Stéphanie Joly, privacy@twenty.com',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TWENTY_PRESIGNED_SIGNATORY = {
|
||||
name: 'Félix Malfait',
|
||||
title: 'Chief Executive Officer',
|
||||
};
|
||||
|
||||
export const getDpaRegionConfig = (region: DpaRegion): DpaRegionConfig =>
|
||||
DPA_REGION_CONFIGS[region] ?? DPA_REGION_CONFIGS[DEFAULT_DPA_REGION];
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
// Bump BOTH values whenever the rendered legal text changes — i.e. when the
|
||||
// verbatim template OR the resolved merge-field values (entities, addresses,
|
||||
// governing law, …) change — so a version always maps to one exact agreement.
|
||||
export const DPA_TEMPLATE_VERSION = '2026-06';
|
||||
|
||||
export const DPA_LAST_UPDATED_LABEL = 'June 2026';
|
||||
|
||||
export const DPA_DOCUMENT_TITLE = 'Twenty Data Processing Agreement (DPA)';
|
||||
+596
@@ -0,0 +1,596 @@
|
||||
// AUTO-GENERATED from the source DPA template (Twenty DPA — Universal Template.docx).
|
||||
// The legal text is VERBATIM from the executed template — do not edit wording here.
|
||||
// Merge fields use {{DOUBLE_BRACES}} and are resolved at render time.
|
||||
|
||||
import { type DpaTemplateBlock } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
|
||||
export const DPA_TEMPLATE_BLOCKS: DpaTemplateBlock[] = [
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'This Data Processing Agreement (“DPA”) is incorporated into and forms part of the master services agreement or Terms of Service (“Agreement”) between {{PROCESSOR_ENTITY}} (“Twenty” or “Processor”) and the customer entity (“Customer” or “Controller”) using Twenty’s services. This DPA reflects the parties’ agreement on the processing of personal data by Twenty on behalf of Customer in providing the Twenty CRM platform services. In case of any conflict between this DPA and the Agreement, the terms of this DPA will prevail to the extent of that conflict. The term of this DPA shall follow the term of the Agreement. Capitalized terms not defined herein have the meanings given in the Agreement.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'This DPA applies to all personal data processing by Twenty as a Processor on behalf of Customer under the Agreement, including use of Twenty’s cloud-hosted service and any self-hosted deployment of Twenty’s software. For self-hosted deployments, Customer generally retains full control over the data and Twenty does not routinely access or host Customer’s personal data; Twenty will only process such data to the extent necessary to provide agreed support or services (e.g. for enterprise license verification or optional telemetry). Each party undertakes to comply with all applicable Data Protection Laws (defined below) relative to its role under this DPA.',
|
||||
},
|
||||
{ kind: 'heading', text: '1. Definitions' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Personal Data” means any information relating to an identified or identifiable natural person (“Data Subject”) that is protected as personal data or personally identifiable information under applicable Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Processing” means any operation or set of operations performed on Personal Data, whether by automated means or not, such as collection, recording, organizing, structuring, storing, adapting, altering, retrieving, consulting, using, disclosing, disseminating, erasing, or destroying. The terms “Process” and “Processed” have corresponding meanings.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Controller” means the entity that determines the purposes and means of the Processing of Personal Data. For purposes of this DPA, Customer is the Controller of Customer Personal Data.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Processor” means the entity that Processes Personal Data on behalf of a Controller. For purposes of this DPA, Twenty is the Processor acting on behalf of Customer.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Customer Personal Data” means any Personal Data that Customer or its Authorized Users input into, upload to, or store in the Twenty CRM service (including on the cloud platform or a self-hosted instance), which Twenty Processes on behalf of Customer under the Agreement. Customer Personal Data does not include data that Twenty processes as a Controller (such as business contact details for Customer’s account administrators, billing information, or telemetry that does not include personal data).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Data Protection Laws” means all data protection and privacy laws and regulations applicable to the Processing of Personal Data under the Agreement, including, where applicable: (i) the EU General Data Protection Regulation 2016/679 (GDPR) and any applicable national implementing or supplementing laws; (ii) the UK Data Protection Act 2018 and the UK GDPR as defined in that Act; (iii) the Swiss Federal Act on Data Protection of 25 September 2020, in force since 1 September 2023, together with its implementing Ordinance (the “revised FADP” or “Swiss DPA”); (iv) the California Consumer Privacy Act of 2018 (CCPA) as amended by the California Privacy Rights Act (CPRA); (v) Brazil’s Lei Geral de Proteção de Dados (LGPD); and any other similar privacy laws in effect globally, in each case as amended, superseded or replaced from time to time.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Sub-Processor” means any third party (including any Twenty affiliate) engaged by Twenty to assist in Processing Customer Personal Data on behalf of Customer in accordance with Customer’s Instructions (as defined below).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Standard Contractual Clauses” or “SCCs” means the EU Standard Contractual Clauses for international transfers of personal data to third countries, as approved by European Commission Decision 2021/914 of 4 June 2021 (including the appropriate modular terms), or any successor clauses approved by the EU Commission.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“UK Addendum” means the UK International Data Transfer Addendum (version B.1.0, in force 21 March 2022) issued by the UK Information Commissioner’s Office, which supplements the SCCs for data transfers subject to UK Data Protection laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Customer Instruction” or “Instructions” means the written instructions of Customer directing Twenty to Process Customer Personal Data for specific purposes and in a manner consistent with the terms of the Agreement and this DPA. The Agreement (including this DPA), together with Customer’s use of the Twenty services, constitute Customer’s complete Instructions to Twenty for the Processing of Customer Personal Data.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '“Personal Data Breach” means a confirmed breach of security leading to accidental or unlawful destruction, loss, alteration, unauthorized disclosure of, or access to, Customer Personal Data transmitted, stored or otherwise Processed by Twenty or its Sub-Processors. For clarity, unsuccessful or attempted attacks that do not result in such compromise (e.g. blocked malware, pings, or failed login attempts) are not considered a Personal Data Breach for purposes of this DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Any other capitalized term used in this DPA shall have the meaning given to it in the GDPR or other applicable Data Protection Laws, or in the Agreement.',
|
||||
},
|
||||
{ kind: 'heading', text: '2. Details of Processing' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Purpose of Processing: Twenty shall Process Customer Personal Data solely for the purpose of providing and supporting the Twenty CRM software and related services to Customer under the Agreement, and not for any other purpose. This includes using the data as needed to host the CRM platform, to enable Customer-defined functionalities (such as managing customer relationships, tracking sales activities, and analyzing business performance), and to provide related technical support or services requested by Customer.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Nature of Processing: Twenty provides a cloud-based, software-as-a-service (SaaS) customer relationship management (CRM) platform (and also offers the software for self-hosting). In delivering these services, Twenty will perform operations such as storage and organization of data on Twenty’s systems, retrieval and viewing as instructed by Customer, and transmission or disclosure of data to authorized users or integrations as per the service functionality. Processing may include routine computer processing (e.g. cloud database storage, backups, caching via a content delivery network) necessary to provide and maintain the services. Twenty will Process Customer Personal Data only in accordance with Customer’s documented Instructions and the terms of the Agreement. Twenty will not “sell” or “share” Customer Personal Data for any purpose, nor Process it for Twenty’s own purposes or any purposes other than those defined by Customer in the Agreement.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Categories of Data Subjects: Because Customer controls what data is submitted to the Twenty platform, the categories of Data Subjects may include (but are not limited to):',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Individuals about whom Customer stores information in the CRM, such as Customer’s own customers, leads, prospects, end-users or business contacts (e.g. sales prospects, support contacts).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Employees, agents, advisors, freelancers, or contractors of Customer who are end users authorized to use the Twenty platform (whose own contact details may be stored for account management and collaboration).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Other individuals whose personal data is entered into the service by Customer at its discretion (for example, individuals who interact with Customer’s business and whose data is logged in the CRM).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Twenty does not determine or limit the categories of Data Subjects that Customer may input, except as otherwise provided in the Agreement or applicable law. Customer should not input personal data of sensitive nature or of individuals who have not been given proper notice, unless this is permitted under the Agreement and Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Categories of Personal Data: Customer Personal Data submitted to the Twenty service may vary by Customer’s use case. It typically includes common CRM-related personal data such as:',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Contact details (e.g. full name, email address, phone number, physical address, job title or role).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Business information and relationship details (e.g. employer or company affiliation, department, business contact preferences).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Communication records and correspondence (e.g. emails, chat logs, meeting notes or call notes stored in the CRM).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Sales and transaction data (e.g. records of orders, opportunities, invoices, or transaction history related to the Data Subject).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Any other personal information Customer or its users choose to import or record in the CRM (which is determined by Customer’s configuration and use of the services).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Twenty does not require or intend to collect any special categories of personal data (such as data revealing health, genetic, biometric, financial account, or other sensitive information) nor any data about children, except as Customer may incidentally decide to store such data. The services are not designed for processing highly sensitive data, and Customer is responsible for ensuring any such data is lawfully collected and appropriate safeguards are applied per Data Protection Laws and the Agreement.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Duration of Processing: Twenty will Process Customer Personal Data for the duration of the Agreement, until deletion of all Customer Personal Data in accordance with this DPA (see Section 8 on deletion).',
|
||||
},
|
||||
{ kind: 'heading', text: '3. Customer (Controller) Obligations' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '3.1 Compliance with Laws: Customer shall be responsible for ensuring that Customer’s use of the Twenty services and Customer’s Instructions to Twenty regarding Personal Data (including transfer to Twenty) comply with Data Protection Laws. Customer, in its role as Controller, is solely responsible for the lawfulness of the Processing and warrants that it has obtained and will maintain all necessary rights, consents, and legal bases to collect, use, and transfer the Customer Personal Data to Twenty for Processing as contemplated in the Agreement. In particular, Customer represents and warrants that it has provided all necessary notices to Data Subjects and obtained any required consents or authorizations for the Processing, including (if applicable) consent for use of the Twenty cloud service which may involve international data transfers.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '3.2 Lawfulness and Purpose Limitation: Customer shall ensure that it only instructs Twenty to Process Personal Data in a manner that is lawful, fair, and transparent to the Data Subjects. Customer shall not use the services to Process Personal Data in a way that is not permitted under applicable laws (for example, uploading personal data that is obscene, defamatory, or collected in violation of privacy laws). Customer is responsible for the accuracy, quality, and legality of the Personal Data that it provides to Twenty and the means by which it was obtained.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '3.3 Instructions: The parties agree that the Agreement (including this DPA), together with the functionality of the Twenty services, constitute Customer’s complete and final Instructions to Twenty for the Processing of Customer Personal Data. Any additional or alternate instructions must be agreed in writing. Customer shall ensure that its Instructions are at all times consistent with Data Protection Laws. If Customer wishes to modify or restrict Twenty’s processing Instructions, Customer must negotiate an appropriate amendment to this DPA. Customer shall inform Twenty without undue delay if Customer determines it cannot comply with its obligations under this DPA or Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '3.4 Data Subject and Regulator Interactions: Customer bears responsibility for handling any requests or inquiries from Data Subjects or supervisory authorities regarding Customer Personal Data. It is Customer’s obligation to inform Data Subjects about their rights and to handle Data Subject requests (access, rectification, deletion, etc.) in accordance with Data Protection Laws. However, Twenty will provide reasonable assistance as described in Section 5.6 of this DPA. Customer is also responsible for any communications or notifications to supervisory authorities or Data Subjects that may be required under Data Protection Laws due to incidents not caused by Twenty’s breach of this DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '3.5 Email and Marketing Use: If Customer uses the Twenty services to send emails or other communications to Data Subjects (e.g. marketing or sales communications), Customer is solely responsible for ensuring that such activities comply with all applicable laws, including anti-spam laws (such as CAN-SPAM, CASL, PECR) and that appropriate consents have been obtained. Twenty merely provides the platform and will not be liable for unsolicited or unlawful messages sent by Customer through the service.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(In summary, Customer as Controller is responsible for the legality of Personal Data collection and use, obtaining consents, providing notices, and otherwise complying with Data Protection Laws for the data it processes using Twenty’s services.)',
|
||||
},
|
||||
{ kind: 'heading', text: '4. Twenty (Processor) Obligations' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'As Processor of Customer Personal Data, Twenty agrees to the following obligations, in accordance with Article 28 of the GDPR and equivalent provisions of other Data Protection Laws:',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.1 Processing on Documented Instructions: Twenty will Process Customer Personal Data only on Customer’s documented Instructions and for the purposes specified in the Agreement. Twenty will not Process the data for any other purpose unless required to do so by European Union, Member State, or other applicable law. If such a legal requirement applies, Twenty will inform Customer of that requirement before Processing (unless the law prohibits such notice on important grounds of public interest). By entering into the Agreement, Customer instructs Twenty to Process Customer Personal Data to provide the services, consistent with the Agreement and this DPA. Twenty will promptly inform Customer if, in Twenty’s opinion, an Instruction violates applicable law.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.2 Personnel Confidentiality: Twenty will ensure that all personnel and agents authorized to Process Customer Personal Data are bound by appropriate obligations of confidentiality. Twenty limits access to Customer Personal Data to personnel who need access to deliver the services under the Agreement, and these individuals are trained in data protection and are subject to enforceable confidentiality duties.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.3 Security Measures: Twenty shall implement and maintain appropriate technical and organizational security measures to protect Customer Personal Data, as described in Section 8 (Security Measures) of this DPA. These measures are designed to ensure a level of security appropriate to the risk of the Processing, including protecting data against unauthorized or unlawful Processing and against accidental loss, destruction, or damage. Twenty regularly assesses and evaluates the effectiveness of these measures and will assist Customer in ensuring compliance with Customer’s security obligations under Article 32 GDPR (and analogous provisions of other Data Protection Laws) by providing the information and assistance described in this DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.4 Sub-Processor Management: Twenty will only engage Sub-Processors under the conditions outlined in Section 6 (Sub-Processors) below. Twenty will remain responsible for any acts or omissions of its authorized Sub-Processors that result in Twenty’s breach of any of its obligations under this DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.5 Assistance with Data Subject Requests: Taking into account the nature of the Processing and the functionality of the services, Twenty will assist Customer by appropriate technical and organizational measures, insofar as possible, to fulfill Customer’s obligations to respond to Data Subjects’ requests to exercise their rights under Data Protection Laws (e.g. rights of access, rectification, erasure, restriction, data portability, and objection). Twenty has built self-service features into the platform that enable Customer to autonomously retrieve, correct, or delete Personal Data (for example, tools to search, export, or delete records). To the extent Customer is unable to address a Data Subject’s request through the available features, Twenty will, upon Customer’s request, provide commercially reasonable additional assistance to support Customer in responding to the request. If a Data Subject sends a request directly to Twenty, Twenty will not independently respond to such request (except to direct the Data Subject to contact the Customer, or as required by law); Twenty will promptly inform Customer of the request, and await Customer’s Instructions.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.6 Notification of Personal Data Breach: In the event Twenty becomes aware of a Personal Data Breach involving Customer Personal Data, Twenty will notify Customer without undue delay. Specifically, Twenty will inform Customer’s account administrator or other designated contact as soon as possible (targeting no later than 72 hours from verification of the incident). Such notice will include relevant information about the nature of the breach and recommended remedial actions, to assist Customer in meeting any breach notification obligations under Data Protection Laws. Twenty will take prompt action to investigate and contain the breach, and will cooperate with Customer’s reasonable requests in connection with the investigation and any required notifications. Twenty’s notification of or response to a Personal Data Breach shall not be construed as an acknowledgment of fault or liability for the incident. Delays in notification may be necessary if required by law enforcement or if Twenty needs to determine the scope of the breach and ensure that any vulnerability has been mitigated.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.7 Data Protection Impact Assessments: Upon Customer’s request, Twenty will provide reasonable assistance (by way of available documentation, standard templates, or communication of necessary information) to Customer in conducting data protection impact assessments (DPIAs) and any required prior consultations with supervisory authorities, to the extent Customer does not otherwise have access to the relevant information and such information is available to Twenty. Such assistance is provided insofar as it pertains to the Processing of Customer Personal Data by Twenty and to Twenty’s role in facilitating Customer’s compliance with its obligations under Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.8 Cooperation with Authorities: Twenty will cooperate, upon Customer’s request, with supervisory authorities in the jurisdictions applicable to Customer Personal Data. Twenty will also reasonably assist Customer in responding to any request or investigation by data protection authorities that relates to Twenty’s Processing of Customer Personal Data or to this DPA, to the extent such cooperation is required of Customer by law. If a competent data protection authority makes an inquiry directly to Twenty about the Customer Personal Data, Twenty will (to the extent legally permissible) promptly inform Customer and assist Customer in responding, unless the inquiry obligates a direct response from Twenty.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.9 Retention, Return and Deletion: Upon termination or expiration of Customer’s subscription or upon Customer’s written request, Twenty will return or securely delete all Customer Personal Data in its possession or control, except as otherwise required by law or permitted under the Agreement. This obligation will not apply to data that has been archived on back-up systems, which Twenty shall securely isolate and protect from any further Processing, and delete in the ordinary course of backup rotation. Twenty follows a data deletion policy such that, if Customer has not requested return of the data, Customer Personal Data will be automatically deleted from live systems after a maximum of approximately 90 days following service termination (and typically sooner, e.g. deletion of automated accounts typically occurs after 3 weeks). Back-up and archival data containing Personal Data is typically overwritten or purged within an additional 90 days after the end of the retention period. Upon Customer’s request, Twenty will provide a certification of deletion once all Customer Personal Data has been purged. If return of data is requested, Twenty will provide the Customer Personal Data in a commonly used electronic format. Any costs associated with data retrieval or specific deletion assistance that deviate from the standard process may be chargeable to Customer on a reasonable basis. (For clarity, Customer is responsible for exporting any data it wishes to retain before the deletion effective date. Once deleted, data may not be recoverable.)',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.10 Purpose Limitation: Twenty will not “sell” (as defined by the CCPA) Customer Personal Data or otherwise Process Customer Personal Data for purposes other than those instructed by Customer. Twenty acknowledges and agrees that it acts as a “Service Provider” under the CCPA/CPRA with respect to any California Personal Information (as defined in CCPA) processed on Customer’s behalf, and as an “Operator” under Brazil’s LGPD for any Brazilian personal data on Customer’s behalf. Twenty will not retain, use, or disclose Personal Data outside of the direct business relationship between Twenty and Customer, and will refrain from combining Customer Personal Data with personal information of other sources (except as needed to perform the services or as permitted by CCPA). If Twenty receives a request from a Consumer (as defined in CCPA) to exercise rights under CCPA, Twenty will inform the Consumer that the request cannot be acted upon directly and should be submitted to the Customer as the business (per 11 CCR § 7051). Twenty certifies it understands its restrictions and obligations under this Section and under CCPA and will comply with them.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '4.11 Government and Law Enforcement Requests: If Twenty receives a legally binding demand from a public authority, court, or law enforcement agency to disclose Customer Personal Data (a “Request”), Twenty will, unless legally prohibited, promptly notify Customer of the Request so that Customer may seek a protective order or other appropriate remedy. If Twenty is legally prohibited from notifying Customer, Twenty will use reasonable lawful efforts to obtain a waiver of that prohibition in order to communicate as much information to Customer as soon as possible. Twenty will not voluntarily disclose Customer Personal Data to any government authority or law enforcement agency. Twenty will review each Request for legal validity, will challenge any Request that it assesses to be unlawful, overbroad, or inconsistent with Data Protection Laws or international comity, and will disclose only the minimum amount of Customer Personal Data necessary to respond to a valid and binding Request. Twenty does not grant any government authority direct, blanket, or unfettered access to Customer Personal Data, and has not built and will not build any “back doors” into its systems for that purpose. Twenty will keep records of the Requests it receives to the extent permitted by law and will, on Customer’s request and where legally permitted, provide information about such Requests.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(The above commitments of Twenty, as Processor, are intended to fulfill the requirements of GDPR Art. 28(3) and analogous provisions under other Data Protection Laws.)',
|
||||
},
|
||||
{ kind: 'heading', text: '5. Data Subject Rights and Requests' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Customer is responsible for responding to any Data Subject requests regarding Personal Data that Customer controls. However, taking into account the nature of the services, Twenty will provide reasonable assistance to facilitate Customer’s obligation to honor Data Subject rights under GDPR Chapter III and other laws (access, rectification, objection, erasure, restriction, portability, etc.), as described in Section 4.5. If a Data Subject submits a request directly to Twenty regarding Customer Personal Data, Twenty will not respond substantively except to notify the Data Subject to contact Customer (where permissible) and to assist Customer by providing any relevant information or action as per Customer’s Instructions. Twenty’s platform includes self-service features that allow Customer to directly fulfill many Data Subject requests (for example, search and export tools, deletion functions). Where further assistance is needed, Customer may request Twenty’s help via support channels. Twenty will promptly cooperate with Customer’s written requests to provide available information or to execute actions necessary to comply with Data Subject rights, to the extent Twenty can reasonably do so. Any such assistance beyond the standard functionality may be subject to applicable service fees if the effort is substantial.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Customer shall be responsible for reviewing the request and verifying the Data Subject’s identity (if required) and for determining the appropriate response under the law. Customer is also responsible for any communications or notifications to the Data Subject, except to the extent where a direct response from Twenty is required by law. The parties shall keep each other informed, as appropriate, of any issues arising from such requests.',
|
||||
},
|
||||
{ kind: 'heading', text: '6. Sub-Processors' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '6.1 Authorized Sub-Processors. Customer provides general authorization to Twenty to engage third-party Sub-Processors to Process Customer Personal Data, solely for the purposes of providing and supporting the services as defined in the Agreement. Twenty maintains an up-to-date list of its Sub-Processors involved in the Processing of Customer Personal Data. A current and authoritative list of the Sub-Processors engaged by Twenty to Process Customer Personal Data — including each Sub-Processor’s function and processing location — is maintained at Twenty’s Trust Center (https://trust.twenty.com) and is updated from time to time; an enumerated snapshot of these Sub-Processors as of the Last Updated date is set out in Annex C. The main Sub-Processors are all bound by written agreements that impose data protection obligations no less protective than those set forth in this DPA. Twenty shall be responsible for the performance of its Sub-Processors to the same extent Twenty would be liable if performing the services directly.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '6.2 Notice and Objection Rights. Twenty will notify Customer of any intended addition or replacement of Sub-Processors that will Process Customer Personal Data, giving Customer advance notice via email or via an in-app notification. Twenty will endeavor to provide such notice at least 10 days before the new Sub-Processor starts Processing Customer Personal Data, thereby giving Customer the opportunity to review and object (if necessary). If Customer has a reasonable, good-faith objection to Twenty’s use of a new Sub-Processor (on grounds related to data protection), Customer shall notify Twenty in writing within 10 calendar days of receipt of the notice. The parties will then discuss Customer’s concerns in good faith with the aim of achieving a commercially reasonable resolution. If no resolution is reached, Customer, as its sole remedy, may terminate the service (without penalty) with respect to the part of the services that cannot be provided without the disputed new Sub-Processor. In such case, upon Customer’s request, Twenty will refund any pre-paid fees covering the remainder of the term for the terminated portion of the services. If Customer does not object within the notice period, the new Sub-Processor will be deemed accepted.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '6.3 Sub-Processor Agreements. Twenty will enter into a written contract with each Sub-Processor having access to Customer Personal Data, which imposes data protection obligations equivalent in scope to those imposed on Twenty under this DPA, in particular providing sufficient guarantees to implement appropriate technical and organizational measures so that the Processing meets the requirements of Data Protection Laws. Twenty shall ensure that each Sub-Processor provides at least the level of data protection required of Twenty by this DPA and Data Protection Laws, including agreements to: (a) Process Personal Data only on Twenty’s behalf and only for the purposes Twenty instructs in providing the services; (b) implement appropriate security measures; (c) notify Twenty of any Personal Data Breach involving Customer Personal Data; and (d) delete or return Personal Data to Twenty (for return to Customer) upon termination of their services.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '6.4 International Transfers by Sub-Processors: Twenty will ensure that any Sub-Processor located in a country that does not provide an adequate level of data protection (as defined by applicable Data Protection Laws) will be subject to appropriate safeguards for any cross-border transfer of Personal Data, in accordance with Section 7 of this DPA. This may include, as applicable, execution of Standard Contractual Clauses (Module 3: Processor-to-Processor) between Twenty and the Sub-Processor, the Sub-Processor’s certification under an approved framework such as the EU-U.S. Data Privacy Framework (if used as a transfer mechanism), or binding corporate rules or another lawful transfer mechanism. Twenty will make information about such safeguards available to Customer on request.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '6.5 Liability for Sub-Processors: Twenty shall remain fully liable to Customer for the performance of any Sub-Processor that fails to fulfill its data protection obligations with respect to Customer Personal Data. In other words, Twenty will be liable for the acts and omissions of its Sub-Processors as if they were Twenty’s own acts or omissions. This provision does not limit any direct obligations or liability a Sub-Processor may owe to Customer under Data Protection Laws or a separate agreement.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(In summary, Twenty uses a few trusted Sub-Processors for sensitive data to support service delivery. Twenty will notify Customer of changes and allow objection. All Sub-Processors have contracts upholding GDPR-level protections, and Twenty remains responsible for their compliance.)',
|
||||
},
|
||||
{ kind: 'heading', text: '7. International Data Transfers' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '7.1 Data Hosting and Localization: Twenty offers EU-based hosting (Frankfurt, Germany) by default and, as an option, US-based hosting for customers who select a US deployment. The Twenty contracting entity (the “Processor”) and the hosting region applicable to Customer are those identified in the Order Form or Customer’s workspace configuration. To the extent the Processor is established in, and Customer Personal Data is hosted within, the EEA and accessed only by EEA-based personnel, providing the services does not require any transfer of Customer Personal Data to a third country, and the international-transfer provisions in Sections 7.2–7.5 apply only to the extent any such transfer nevertheless occurs. As of the effective date, Customer Personal Data in the Twenty cloud service is hosted in data centers in the {{HOSTING_REGION}} via Amazon Web Services (AWS). Customer acknowledges that Twenty and its Sub-Processors may access and Process Personal Data on a global basis as needed to provide the services, including for support and technical operations. This may involve transferring Customer Personal Data outside the country or region where it was originally collected, including to the United States. Twenty will ensure that all such transfers are made in compliance with Data Protection Laws governing cross-border data transfers. For regions with data residency commitments (if offered under the Agreement), Twenty will abide by those terms (for example, hosting EU Customer data in the EU region).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '7.2 European Data Transfers (EEA and UK): To the extent Customer Personal Data subject to EU GDPR or the UK GDPR is transferred from the European Economic Area (EEA), Switzerland, or the United Kingdom to Twenty in a country not deemed by the European Commission (or other relevant authority) to provide an adequate level of data protection, the parties agree that such transfers shall be governed by the applicable Standard Contractual Clauses. Specifically:',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'EU Transfers: For Personal Data transfers from the EEA (or Switzerland) to countries not covered by an adequacy decision, the EU Standard Contractual Clauses (Commission Implementing Decision (EU) 2021/914) are hereby incorporated into this DPA by reference, and will be deemed executed between Customer (as data exporter) and Twenty (as data importer). The SCCs shall be populated as follows: Module Two (Controller-to-Processor) terms apply (or Module Three for any transfers from Twenty to a Sub-Processor); in Clause 7 the optional docking clause is not used; in Clause 9, Option 2 (General Written Authorization) is selected with a notice period of 10 days for Sub-Processor changes (per Section 6 of this DPA); in Clause 11, the optional language on independent dispute resolution is considered included; in Clause 17, Option 1 is chosen and the governing law of the SCCs shall be the law of the EU Member State where the data exporter is established (or, if the data exporter is not established in an EU Member State, then the law of Germany); in Clause 18(b), disputes shall be resolved before the courts of that same jurisdiction. Annex I of the SCCs (List of Parties, Description of Transfer) is deemed completed by the details set out in Annex A of this DPA; Annex II (Technical and Organizational Measures) is deemed completed by the measures described in Section 8 (Security Measures) of this DPA (and any Annex B attached); Annex III (List of Sub-Processors) is set forth in Section 6.1 of this DPA. By agreeing to this DPA, the parties are deemed to have signed the SCCs where required.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'UK Transfers: For Personal Data transfers subject to UK Data Protection Laws, the UK Addendum (issued under s.119A(1) of the UK Data Protection Act 2018) is hereby incorporated. The UK Addendum is deemed executed with the information in Part 1 of the Addendum as follows: (i) the tables in Part 1 shall be completed using the information from the EU SCCs (as modified by this Section) and the options chosen above, (ii) either party may terminate the UK Addendum as set out in Section 19 of that Addendum if the UK Commissioner approves new transfer mechanisms that replace the SCCs, and (iii) the law of England and Wales will govern the Addendum. In the event of conflict between the SCCs and the UK Addendum, the UK Addendum shall prevail for data transfers from the UK.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Swiss Transfers: For Personal Data transfers subject to the Swiss DPA, the above EU SCCs (Module 2) will also apply with the following modifications to reflect Swiss law requirements: references to “GDPR” in the SCCs shall be understood as references to the Swiss DPA for data exclusively subject thereto; references to “EU”, “Member State” or “Supervisory Authority” shall be interpreted so as to include Switzerland and the Swiss Federal Data Protection and Information Commissioner (insofar as the Swiss DPA applies); the governing law in Clause 17 and jurisdiction in Clause 18 of the SCCs shall be the law and courts of Switzerland (if the Swiss DPA exclusively applies) or of the relevant EU Member State (if both Swiss and EU law apply to the transfer). Customer (as data exporter) and Twenty (as data importer) hereby agree that execution of the DPA also constitutes execution of the SCCs and applicable UK/Swiss adaptations, no further action is required to give effect to them.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '7.3 Transfers from Brazil: For transfers of Personal Data subject to Brazil’s LGPD (Lei Geral de Proteção de Dados) to Twenty in countries not deemed adequate by Brazil’s National Data Protection Authority (ANPD), the parties agree to rely on Brazil’s Standard Contractual Clauses (as set forth in ANPD Resolution No. 19 of 2024) as the transfer mechanism. The Brazil SCCs are incorporated by reference into this DPA, and will apply to the same data and parties as the EU SCCs, with appropriate role designations (Customer as the exporting Controller and Twenty as the importing Processor). The selections and options of the Brazil SCCs shall align, where possible, with those of the EU SCCs and this DPA. In particular, the description of the transfer and retention period in the Brazil SCCs can be found in Annex A of this DPA (mirroring Section 2 of the DPA), and Twenty’s technical and security measures are those in Section 7 of this DPA (mirroring Annex II of EU SCCs). By entering this DPA, the parties are deemed to have executed the Brazil SCCs as separate counterparts, and no further signature is required. If needed, Twenty will provide a copy of the Brazil SCCs upon request.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '7.4 Other Transfer Mechanisms: In addition to the foregoing, the parties may mutually agree in writing to implement an alternative or additional transfer mechanism that is recognized under Data Protection Laws as providing an adequate level of protection for Personal Data transferred across borders (such as Binding Corporate Rules, an approved certification or code of conduct, or new standard clauses issued by authorities). In the event Twenty adopts Binding Corporate Rules or self-certifies under a valid framework (such as any EU–US Data Privacy Framework or analogous Swiss/UK frameworks), and such mechanism covers the transfers of Customer Personal Data, Customer agrees that Twenty may rely on that mechanism in lieu of the SCCs, provided Twenty informs Customer of the switch. As of the Last Updated date of this DPA, Twenty’s U.S. affiliate is not a participant in the EU-U.S. Data Privacy Framework (DPF); therefore, the SCCs (with UK Addendum and Swiss modifications) and Brazil SCCs will primarily govern relevant international transfers. If Twenty in the future maintains a current DPF certification, Twenty shall inform Customer and ensure such certification and compliance with DPF Principles is maintained as required.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '7.5 Disclosure of SCCs: If Customer’s jurisdiction requires the parties to file or register the Standard Contractual Clauses or Brazil SCCs, or if any regulator requests copies, the parties will work together in good faith to comply, taking into account the need to redact any confidential information. The parties agree that any supplementary measures (technical, organizational, or contractual) needed to ensure transferred data is adequately protected will be taken, such as encryption in transit and at rest, data minimization, and rigorous access controls, as described in Section 8 (Security Measures). Twenty will monitor legal developments and, if required by law or a regulator, will promptly implement additional safeguards or modifications to this Section to ensure continued compliance with Data Protection Laws regarding cross-border transfers.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(In summary, Customer Personal Data may be processed globally as needed for the service. When data is transferred from jurisdictions like the EU, UK, Switzerland, or Brazil to countries without an adequacy decision (e.g. to the US), the parties are contractually bound by the appropriate Standard Contractual Clauses or equivalent mechanisms to protect the data. This ensures that Data Subjects continue to have rights and protections even after the transfer.)',
|
||||
},
|
||||
{ kind: 'heading', text: '8. Security Measures' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Twenty is committed to protecting the security, confidentiality, and integrity of Customer Personal Data. Taking into account the state of the art, the costs of implementation, the nature, scope, context and purposes of Processing, and the risk to Data Subjects, Twenty implements robust technical and organizational measures (TOMs) designed to ensure a level of security appropriate to the risk. These measures include, but are not limited to, the following:',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Access Control: Access to Customer Personal Data is restricted on a least-privilege basis. Twenty enforces authentication controls for its systems (strong passwords and/or multi-factor authentication) and limits employee access to data to those personnel who require it to perform their job duties. Processes are in place to promptly revoke access when personnel leave or no longer need access.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Encryption: All Customer Personal Data transmitted over public networks is encrypted using industry-standard protocols (e.g. TLS/SSL). For data at rest, Twenty uses encryption mechanisms (such as AES-256) to encrypt personal data stored in databases or storage systems, except where not feasible due to the nature of the data store (in which case alternative controls ensure data segmentation and security).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Physical Security: Twenty’s production systems are hosted in secure data centers (provided by AWS) with robust physical security controls, including 24/7 monitoring, access badges, biometric scanners, surveillance cameras, and security personnel. Physical access is limited to authorized data center staff. (For self-hosted deployments, physical security of the server environment is Customer’s responsibility.)',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Network Security & System Integrity: Twenty employs network security measures such as firewalls, intrusion detection/prevention systems, and regular network vulnerability scanning. The production environment is logically isolated and hardened. Anti-malware protections and security monitoring are implemented to detect and prevent unauthorized system access or anomalies.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Monitoring & Logging: Twenty maintains audit logs of relevant system and security events in the production infrastructure. Access to Customer Personal Data and actions performed by Twenty’s personnel are logged and monitored. Systems are configured to alert on suspicious activities or potential incidents so that Twenty’s security team can respond in a timely manner.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Data Resilience and Backup: Twenty performs regular backups of critical Customer data (with sensitive data encrypted in backup storage) to ensure data can be restored in case of accidental deletion or disaster. Backup procedures are tested periodically. Twenty’s hosting provider infrastructure is designed for high availability and resilience (including the use of redundant components, clustering, and failover mechanisms) to minimize the risk of data loss or downtime.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Vulnerability Management: Twenty regularly updates its software and systems with security patches. Twenty conducts periodic vulnerability assessments and penetration testing (both internal and external) to identify and remediate security weaknesses. Twenty also maintains a process for receiving and addressing vulnerability reports from third-party security researchers.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Organizational and Personnel Security: Twenty’s personnel with access to Customer Personal Data undergo background checks as permitted by law and are required to adhere to confidentiality agreements. Twenty provides training to its staff on data protection, privacy, and security best practices on a recurring basis. The company maintains up-to-date security policies and incident response plans.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Incident Management: Twenty has an incident response plan for addressing security incidents or Personal Data Breaches. This includes procedures for timely notification to affected customers (as described in Section 4.6), steps for containment and mitigation, and post-incident review to implement improvements. Twenty also maintains a business continuity and disaster recovery plan to ensure continuity of operations in the event of a serious incident or outage.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Regular Audits and Assessments: Twenty periodically reviews and assesses its security controls to ensure they remain effective and appropriate. This includes internal reviews and an independent SOC 2 Type II audit of the services performed by a third-party auditor. The resulting SOC 2 Type II report (or a summary of it) and other assessment results can be made available to Customer as described in Section 9.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(Additional details about Twenty’s security measures may be provided in a Security Policy or Annex B to this DPA, which can be updated from time to time to reflect evolutions in our practices, provided such updates do not degrade the overall security of the services.)',
|
||||
},
|
||||
{ kind: 'heading', text: '9. Audit Rights and Compliance' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Customer has the right to verify Twenty’s compliance with this DPA and its obligations under applicable Data Protection Laws. Twenty shall provide relevant information and documentation to demonstrate such compliance, and shall allow for and contribute to audits as described below:',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '9.1 Documentation and Trust Resources: Upon Customer’s written request, Twenty will provide Customer with reasonable documentation or summaries of certifications, external audit reports, or other evidence of Twenty’s compliance with its security and privacy obligations (subject to confidentiality). For example, Twenty maintains a SOC 2 Type II report and can provide that report (or a summary of it) and summaries of third-party penetration test results on request. Customer agrees to exercise any audit right by first reviewing such documentation, and if Customer finds it sufficient, this shall satisfy Customer’s audit request.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '9.2 Customer Audits: If the provided documentation is not sufficient to reasonably demonstrate compliance, Customer may perform an on-site audit of Twenty’s relevant procedures and systems no more than once per year (unless required more frequently by a supervisory authority or in case of a significant data incident). Customer must give at least 30 days’ prior written notice to request an audit, and the parties shall mutually agree on the scope, duration, and timing of the audit. Any audit shall be conducted during regular business hours, in a manner that does not unreasonably interfere with Twenty’s operations. Customer may perform the audit itself or via an independent third-party auditor approved by Twenty (such approval not to be unreasonably withheld). All auditors will be subject to a duty of confidentiality and shall not have access to non-Customer data or to Twenty’s proprietary information unrelated to the scope of the audit.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '9.3 Limitations: Customer is responsible for all costs and fees related to an audit it initiates. Twenty may charge a reasonable fee for supporting an audit to the extent such audit exceeds the normal resources made available to demonstrate compliance. Before the start of an on-site audit, the parties will agree in writing to reasonable guidelines (e.g. security measures to protect the premises, and reimbursement terms if applicable). Reports or findings from any audit will be considered Twenty’s Confidential Information and must be kept secret by Customer, except to the extent disclosure is required by law or regulatory authority.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '9.4 Remediation: If an audit reveals any material non-compliance, Twenty will take prompt action to address and remediate the issues identified. The parties will discuss the audit findings and an appropriate corrective action plan. Twenty will track the resolution of such findings and, upon request, report progress to Customer. If a material non-compliance cannot be cured within a reasonable time, Customer may exercise its termination rights as outlined in the Agreement.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '9.5 Regulators: Notwithstanding the foregoing, if a data protection supervisory authority requires that Customer (or Customer’s designee) conduct an on-site audit of Twenty’s processing facilities, Twenty will cooperate and permit such audit. This section does not limit any audit rights a supervisory authority may have under applicable law.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(In summary, Twenty will help verify its compliance by providing information and accepting audits under controlled conditions, to meet the obligations of Article 28(3)(h) GDPR and similar laws. Customer agrees to handle audit information as confidential and to minimize any impact on Twenty’s business.)',
|
||||
},
|
||||
{ kind: 'heading', text: '10. Return and Deletion of Data' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'As described in Section 4.9, upon termination or expiration of the Agreement (or at any time upon Customer’s request), Twenty will, at Customer’s choice, return or delete all Customer Personal Data in Twenty’s possession or control. This includes deleting or anonymizing Customer Personal Data from Twenty’s live systems and services, and instructing any Sub-Processors to do the same. If return is requested, Twenty will provide the data export in a commonly used format (e.g. CSV, JSON, or database backup file). After confirming that Customer has retrieved required data, Twenty will proceed to delete the data from its systems.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'If Customer does not make an election (return or deletion) within a reasonable time prior to contract termination, Twenty will proceed to delete the Customer Personal Data by default. Deletion shall be done in a secure manner, rendering the personal data unrecoverable. As noted, data on backup systems will be overwritten or deleted in accordance with Twenty’s data retention schedule. Twenty will not retain copies of Customer Personal Data except as required by law (and if so, such data will remain protected by this DPA and be isolated from further Processing).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Customer is advised that certain residual data may temporarily remain in transient memory or logs which will be purged in the normal course of operations. Twenty’s obligations of confidentiality, security, and protection continue to apply to any such data while it remains in Twenty’s possession. Upon Customer’s request, Twenty will confirm in writing that deletion is completed (this may be via a certification or through the absence of any recoverable data in the service accessible to Customer).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'If applicable law requires further retention of any Customer Personal Data by Twenty (for example, for compliance with legal record-keeping obligations), Twenty will notify Customer and will isolate and protect that data from any further processing except to the extent required by law.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Retrieval during Term: During the term of the Agreement, Customer may export or download its data at any time through the service’s provided interfaces or APIs. Twenty shall, upon request, reasonably assist with such data export (e.g. providing database dumps or additional formats) if Customer is unable to self-serve.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'This Section survives the termination of the Agreement. The parties agree that the certification of deletion provided by Twenty shall suffice as evidence that Twenty has complied with Customer’s directions to delete Personal Data as required by Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'heading',
|
||||
text: '11. Additional Provisions for California and Other Jurisdictions',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'California (CCPA/CPRA): In addition to and consistent with the relevant sections above, the following terms apply to “California Personal Information” (as defined in Section 1798.140(v) of CCPA) that Twenty processes on behalf of Customer. Twenty is a “Service Provider” to Customer (who is a “Business”) for purposes of CCPA. Twenty certifies that it shall not: (a) sell or share (for cross-context behavioral advertising) such personal information; (b) retain, use, or disclose the personal information for any purpose other than for the specific purpose of providing the services (including retaining, using, or disclosing the information for a commercial purpose other than providing the services as specified in the Agreement); (c) retain, use, or disclose the information outside of the direct business relationship between Twenty and Customer. Twenty acknowledges these restrictions and will comply with them. Twenty will enable Customer to comply with Consumer requests to exercise rights (access, deletion, opt-out of sale/sharing, correction, etc.) by providing appropriate tools or assistance (as described earlier). The parties acknowledge that Twenty’s access to personal information is as a service provider and not for the purposes of determining the means and purposes of processing. Twenty shall provide an attestation to the above, upon Customer’s request, as required by CPRA regulations. If Twenty receives any request from a California Consumer to exercise rights, it will refer that request to Customer and not respond directly (per § 7051 of CCPA Regs). For clarity, the CCPA-specific terms here prevail only with respect to California Personal Information if in conflict with any other DPA terms.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Brazil (LGPD): Twenty agrees that with respect to any personal data subject to Brazil’s LGPD (Law No. 13,709/2018), it will act in accordance with the instructions of Customer (the “Controller”) as an “Operator” under LGPD. Twenty will assist Customer in meeting its obligations under LGPD (such as responding to data subject requests and data protection impact assessments) in a manner similar to what is described for GDPR. If Brazilian authorities issue standard contractual clauses or other transfer requirements beyond what is addressed in Section 7.3, Twenty will adhere to such requirements for transfers from Brazil. Twenty will also comply with LGPD’s provisions regarding data security and breach notification (Articles 46-48) by implementing security measures and notifying Customer of incidents as described in this DPA. Any required communication to ANPD (Brazil’s National Data Protection Authority) due to a data incident will be coordinated with Customer.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Swiss DPA: With regard to personal data subject to the Swiss Federal Data Protection Act (FADP), the term “personal data” as used in this DPA includes “personal data” as defined in the FADP. Any references to GDPR articles or EU law in this DPA shall be deemed to include the equivalent provisions of the Swiss DPA, and references to supervisory authorities include the Swiss Federal Data Protection and Information Commissioner. Twenty will process Swiss personal data in compliance with the Swiss DPA, and the transfer provisions of Section 7.2 (Swiss Transfers) ensure compliance with the cross-border requirements of the Swiss DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Other Jurisdictions: If and to the extent Data Protection Laws from other jurisdictions (e.g. Canada’s PIPEDA, Australia’s Privacy Act, Singapore’s PDPA, etc.) apply to Customer Personal Data, Twenty will cooperate with Customer to address any additional requirements of those laws. This may include entering into additional contractual terms as required by such laws to legally Process or transfer personal data (for example, executing any required jurisdiction-specific addenda). The parties agree to negotiate in good faith to put in place such supplemental terms if needed. In any case, Twenty will maintain the privacy and security protections for all Customer Personal Data at a standard that meets the highest requirements among the applicable Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(The above provisions ensure that this DPA satisfies specific requirements of various laws like CCPA, LGPD, and the Swiss DPA. Twenty’s commitments are intended to meet the definitions of a “Service Provider” under CCPA and an “Operator” under LGPD, etc., and to affirm that Twenty only uses personal data as instructed by Customer.)',
|
||||
},
|
||||
{ kind: 'heading', text: '12. Limitation of Liability' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '12.1 Liability Cap: The parties agree that any liability arising under or in connection with this DPA shall be subject to the exclusions and limitations of liability set out in the Agreement. This means that neither party’s total liability for all claims under this DPA will exceed the liability cap (aggregate limit) that applies under the Agreement, except to the extent that mandatory law forbids such a cap for certain data protection liabilities. No provision of this DPA is intended to limit a Data Subject’s rights or remedies under Data Protection Laws against either party.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '12.2 Liability Between the Parties: As between Customer and Twenty, Customer agrees that it shall be solely responsible for any damages or claims arising from Twenty’s Processing of Customer Personal Data in compliance with Customer’s Instructions, and Customer shall indemnify Twenty for any third-party claims or fines (including by Data Subjects or regulators) resulting from Customer’s Instructions or Customer’s failure to comply with its obligations under Data Protection Laws. Conversely, Twenty remains responsible for any violations of this DPA or Data Protection Laws caused by Twenty’s failure to comply with its obligations, or those of its Sub-Processors. Each party remains responsible for its own violations of Data Protection Laws.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '12.3 No Third-Party Beneficiaries: This DPA is for the parties’ mutual benefit and does not confer any rights on any third party (except data subjects to the extent required by applicable SCCs). The Standard Contractual Clauses (where applicable) may grant enforceable rights to data subjects as third-party beneficiaries, as explicitly set out in those clauses. Apart from that, no person who is not a party to this DPA shall have any right to enforce any term of this DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(In summary, each party’s liability under this DPA is governed by the same limitations and exclusions as in the main Agreement. The intent is not to create unlimited liability for either party except as allowed by law. The parties will each bear responsibility for their respective obligations.)',
|
||||
},
|
||||
{ kind: 'heading', text: '13. General Provisions' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.1 Governing Law: This DPA is governed by the same law and jurisdiction as the Agreement ({{GOVERNING_LAW}}), unless otherwise required by the SCCs or other transfer addenda for specific international transfer matters (as noted in Section 7.2). In any case, no change in governing law is intended by this DPA except as necessary to incorporate the required international data transfer terms.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.2 Order of Precedence: With regard to the subject matter of data protection, in the event of any conflict between this DPA and any other agreement between the parties (including the Agreement and its exhibits or any privacy policy), the terms of this DPA shall prevail. In the event of a conflict between this DPA and the Standard Contractual Clauses or other prescribed data transfer terms, the latter (the SCCs, UK Addendum, etc.) shall prevail to the extent they apply to protect the rights of data subjects or comply with law.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.3 Changes to Data Protection Requirements: The parties acknowledge that data protection and privacy regulations may evolve. Twenty may update or amend this DPA from time to time as needed to comply with new legal requirements or to implement new Standard Contractual Clauses or other mechanisms approved under Data Protection Laws. If Twenty makes a material change to this DPA that significantly affects Customer’s rights, Twenty will provide notice (e.g. via email or service notification) to Customer. If Customer objects to the change and it materially diminishes privacy protections, Customer may notify Twenty and have the right to terminate the services to which the DPA applies, in which case Twenty will provide a pro-rated refund of any prepaid fees for the terminated period. Continued use of the services after the effective date of an updated DPA will constitute Customer’s consent to the updated DPA, to the extent permitted by law.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.4 Invalidity and Severability: If any provision of this DPA is found by a court of competent jurisdiction or regulatory authority to be invalid or unenforceable, that provision shall be deemed modified to the minimum extent necessary to make it valid and enforceable, or if it cannot be modified, it shall be severed, and the remainder of this DPA shall continue in full force and effect. The parties will negotiate in good faith a valid and enforceable provision to replace the invalid one, reflecting the original intent as closely as possible.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.5 Entire Agreement; Conflict: This DPA (including its Annexes and any incorporated SCCs or Addenda) sets forth the entire understanding of the parties with regard to the Processing of Customer Personal Data. It replaces and supersedes any prior or contemporaneous agreements or understandings relating to that subject matter (including any prior data processing addendum or privacy exhibit to the Agreement), except where explicitly stated otherwise. In no event shall any party (including any affiliate of Twenty) be expected to sign or adhere to an external data processing or data protection agreement provided by Customer, and any such document will be void unless separately negotiated and signed by both parties.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.6 Signatures and Execution: The parties agree that execution of the Agreement or an applicable Order Form incorporating this DPA by reference (including by electronic acceptance) shall constitute execution of this DPA. This DPA may be executed in counterparts or by affirmative acceptance through an online mechanism, each of which is deemed an original, and together constitute one and the same instrument. Where required, the parties shall be deemed to have signed the Standard Contractual Clauses (and UK Addendum, etc.) incorporated herein on the same date as the Agreement or applicable Order Form. No further handwritten signatures are required to give effect to this DPA or the SCCs, provided that if a regulatory authority or law requires this DPA (or SCCs) to be formally executed, the parties shall promptly do so.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.7 Contact and Data Protection Officer: Customer may contact Twenty regarding any issues arising under this DPA at privacy@twenty.com or through its primary account representative. Twenty’s Data Protection Officer (or designated privacy contact) can be reached at {{DPO_NAME_AND_CONTACT}}. Customer is responsible for providing Twenty with up-to-date contact information for a person (or team) authorized to handle Data Protection inquiries.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '13.8 Survival: Provisions of this DPA that by their nature should survive termination (such as obligations of confidentiality, return/deletion, and liability) shall survive expiration or termination of the Agreement until fully performed.',
|
||||
},
|
||||
{ kind: 'heading', text: 'ANNEX A – Details of Processing (Summary)' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'This Annex provides a summary of key Processing details as required by Article 28(3) GDPR and Annex I of the SCCs:',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Data Exporter (Controller): Customer, as identified in the Agreement. Contact details: as specified in the account or Order Form (or Customer’s privacy office). The Customer’s business is determined by the context of the Agreement (e.g. Customer is using Twenty’s CRM services for its business purposes). Customer acts as Controller (and a Business under CCPA, and the data exporter under SCCs) with respect to Customer Personal Data.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Data Importer (Processor): {{PROCESSOR_ENTITY}}, {{PROCESSOR_LEGAL_FORM}}. Registered office: {{PROCESSOR_ADDRESS}}. {{PROCESSOR_ENTITY}} provides cloud-based CRM software and related services and Processes Customer Personal Data in the {{HOSTING_REGION}}. Contact for privacy inquiries: privacy@twenty.com. {{PROCESSOR_ENTITY}} acts as Processor (and, where applicable, Service Provider under the CCPA and data importer under the SCCs). The applicable Processor entity, hosting region, and governing law are those specified in the Order Form or Customer’s workspace configuration.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Subject Matter of Processing: The subject matter is the Customer Personal Data entered into or collected via the Twenty CRM platform and processed by Twenty in order to provide the services to Customer, as described in the Agreement and this DPA.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Duration of Processing: For the duration of the Agreement until deletion of all Customer Personal Data by Twenty in accordance with the DPA (see Section 10). Some data may be retained for a limited period post-termination as described, solely for deletion or legal compliance purposes.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Nature and Purpose of Processing: See Section 2 of the DPA. In summary, Twenty will receive, host, store, organize, and otherwise process Customer Personal Data as necessary to provide the CRM software functionality and any related services (such as support, backups, etc.) to Customer. Processing includes operations such as computing, transmitting, and displaying the data per user actions, and producing analytics or reports as per the service features. Twenty also processes data as needed to ensure security, fix issues, and improve the services (in a manner consistent with the Agreement and not for independent purposes). All processing is performed according to Customer’s Instructions and solely for Customer’s business purposes.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Types of Personal Data Processed: Any Personal Data that Customer decides to import or create in the Twenty platform. Typical categories are listed in Section 2 above and include contact information, organizational details, communications, sales records, and other business-related personal information. The data may also include system-generated data like user IDs or metadata related to usage of the CRM. (Sensitive personal data is not anticipated, and special categories are not intentionally processed, except if uploaded by Customer contrary to recommendations.)',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Categories of Data Subjects: As detailed in Section 2, data subjects include Customer’s end-user personnel and Customer’s own customers or leads and other individuals whose data is stored in the CRM. Could also include any other individuals who correspond with or are recorded by Customer using the service.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Frequency of Transfer: Continuous/on-going basis – Personal Data is transferred to Twenty’s systems whenever Customer uses the cloud service (e.g. data is uploaded, collected, or generated during the term of the Agreement).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Data Retention: Twenty will retain Customer Personal Data for the duration of the Agreement and will delete or return data upon termination as described in Section 10. Certain logs or backups containing Personal Data may be retained for up to 90 days beyond deletion, then overwritten, and any data retained longer to meet legal obligations will be protected until deletion.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Sub-Processors: The current Sub-Processors are listed in Section 6.1. A current and authoritative list of Sub-Processors that Process Customer Personal Data — with each Sub-Processor’s function and processing location — is maintained at Twenty’s Trust Center (https://trust.twenty.com). Sub-Processors are bound by written agreements imposing data-protection obligations consistent with this DPA. Customer will be notified of any changes per Section 6.2.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Technical and Organizational Measures: See Section 8 (and any Annex B, if provided) for a description of the security measures implemented by Twenty, including: physical security of data centers, access controls, encryption, network security, monitoring, incident response, etc. These measures are intended to ensure ongoing confidentiality, integrity, availability, and resilience of processing systems and to enable restoration of data in case of incidents.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Cross-Border Transfers: For the default EU deployment (Processor: Twenty.com SAS; hosting in Frankfurt, Germany; EEA-based personnel), no transfer of Customer Personal Data to a third country is required to provide the services. Where Customer selects US hosting, or otherwise contracts with a Twenty entity established outside the EEA, Customer Personal Data may be transferred to and Processed in the United States; such transfers are governed by the SCCs and, where applicable, the UK Addendum and Swiss/Brazil terms in Section 7. Onward transfers to Sub-Processors are made under equivalent safeguards.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Contact Points: For Customer: the contact information provided in the Agreement or Order (e.g. legal or privacy contact). For Twenty: privacy@twenty.com; Data Protection Officer / privacy contact: {{DPO_NAME_AND_CONTACT}}.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(End of Annex A. This Annex may be considered the Appendix 1 of the SCCs, describing the parties, transfers, and processing operations.)',
|
||||
},
|
||||
{
|
||||
kind: 'heading',
|
||||
text: 'ANNEX B – Summary of Technical and Organizational Security Measures',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(Referencing Article 32 GDPR and Annex II of SCCs, this annex summarizes Twenty’s key security measures, as of the Last Updated date. Twenty may update these measures from time to time, maintaining equal or better protection.)',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Organizational Security: Twenty has appointed personnel responsible for security and privacy, including a security officer and, if required, a Data Protection Officer. Internal policies (reviewed at least annually) govern the confidentiality, integrity, availability, and resilience of personal data. Employees receive training on data protection and are bound by confidentiality agreements. Background checks are performed as law permits. Access to facilities and systems is limited to authorized personnel based on role.',
|
||||
},
|
||||
{ kind: 'paragraph', text: 'Access Controls:' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Physical Access: Data hosting is with AWS in secure facilities with multi-layer physical security controls (guarded premises, biometric access, surveillance, etc.). Office access for Twenty (if any data is accessible there) is secured via keycard and visitor protocols.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'System Access: Unique user IDs are required for Twenty employees accessing systems with Personal Data. Strong password policies and multi-factor authentication (MFA) are enforced on administrative access. Access rights are granted on least privilege and need-to-know principles, and promptly revoked upon role change or termination. Administrative access to production systems is logged and monitored.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Data Access: Within the application, data is segmented by Customer organization to prevent unauthorized data access across customers. Customer users are authenticated and authorization checks enforced (role-based access inside the app). Twenty staff access to Customer data (for support) is limited and performed only when necessary and with Customer permission whenever feasible. All such access is logged.',
|
||||
},
|
||||
{ kind: 'paragraph', text: 'Encryption:' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'In Transit: TLS encryption (HTTPS with strong ciphers) protects Personal Data in transit between Customer’s systems and Twenty’s service, and between data centers. For internal service communication, encryption or secure network protocols are used.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'At Rest: Personal Data in databases and storage is encrypted at rest (using AES-256 or equivalent) on AWS-managed storage. Encryption keys are managed securely (leveraging AWS KMS or equivalent). Certain sensitive fields can be additionally encrypted or hashed at the application level if needed.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Network Security & Isolation: Twenty uses virtual private cloud (VPC) architectures to isolate production systems. Firewalls and security groups restrict inbound network access to only required ports/protocols. The production environment is separated from development/test environments. Anti-DDoS protections and rate-limiting are in place via Cloudflare CDN and AWS services. Regular vulnerability scanning of network infrastructure is performed.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Monitoring & Incident Management: Twenty employs monitoring tools to track system performance and security events (e.g. intrusion detection systems, file integrity monitoring). Logs of key events (logins, data access, configuration changes) are maintained centrally and protected from tampering. Twenty has an incident response plan that includes 24/7 availability of key staff, defined escalation procedures, investigation steps, and communication plans (including Customer notification as per DPA requirements). Incidents are documented with root cause analysis and remediation steps.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Malware and Endpoint Security: Servers are hardened (minimal installed services, regular patching). Anti-malware and anti-virus solutions are deployed on servers and employee endpoints, with automatic updates. Email and file uploads can be scanned for known threats. Employees are instructed to use company-approved devices with up-to-date security patches and endpoint protection when accessing systems with Personal Data.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Data Backup and Recovery: Databases are backed up daily (with more frequent incremental backups for critical data). Backup copies are encrypted and stored in geographically separate location(s) to ensure redundancy (at least within the same jurisdiction when required, e.g. within EU for EU data). Restoration procedures are tested periodically to verify integrity and restoration time objectives. Twenty’s disaster recovery plan aims to quickly recover service in case of a major outage (with defined RTO/RPO targets, e.g. RPO of a few hours, RTO of a few hours for major incidents).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Software Development Security: Twenty follows secure development practices. Code changes are peer reviewed and tested in non-production environments. Dependencies are monitored for vulnerabilities (using scanning tools) and promptly updated. Penetration tests by independent experts are conducted at least annually, and critical findings are remediated with high priority. In-app data entry points are protected against common web vulnerabilities (OWASP Top 10) through input validation, sanitization, and use of modern frameworks. Regular dependency and container security scans are in place.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Audit and Compliance: Twenty maintains a SOC 2 Type II report covering the security of the services, performed by an independent third-party auditor and renewed periodically. A copy or summary of the current SOC 2 Type II report is available to Customer on request, subject to confidentiality (for example, under a non-disclosure agreement). The controls described above align with industry best practices, and Twenty conducts independent penetration testing at least annually. Audit logs and compliance evidence are retained as needed. Customer has rights to audit as per Section 9 of the DPA, and Twenty will cooperate by providing available audit results or letting Customer (or its auditor) verify controls in operation.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'Sub-Processor Security: Twenty conducts due diligence on Sub-Processors’ security practices. Twenty has contracts in place requiring Sub-Processors to implement appropriate security measures and notify Twenty in case of incidents. Many Sub-Processors (like AWS, Cloudflare) maintain high-standard security certifications and audits which Twenty reviews. Twenty monitors compliance of Sub-Processors and will take necessary actions (including suspension or replacement) if any Sub-Processor is found to be inadequate in protecting personal data.',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '(This Annex B is a high-level summary; more detailed information can be provided by Twenty upon request, such as a copy of its security whitepaper or policies. All measures are in place as of the last update and are subject to continuous improvement.)',
|
||||
},
|
||||
{
|
||||
kind: 'heading',
|
||||
text: 'ANNEX C – List of Sub-Processors',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'This Annex lists the Sub-Processors engaged by Twenty to Process Customer Personal Data as of the Last Updated date of this DPA, and serves as Annex III to the SCCs (the list of Sub-Processors referenced in Section 6.1). Each entry identifies the Sub-Processor, its processing location(s), and the nature of the Processing. The current and authoritative list is maintained at Twenty’s Trust Center (https://trust.twenty.com) and may change in accordance with Section 6.2 (Notice and Objection Rights).',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: '',
|
||||
expand: 'subprocessorList',
|
||||
},
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: 'By using or signing the Agreement, each party acknowledges it has read and understood this DPA and agrees to be legally bound by it.',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ONELEET_TRUST_API_URL =
|
||||
'https://api.oneleet.com/api/v1/tenants/twenty/trust';
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"subprocessors": [
|
||||
{
|
||||
"name": "Amazon Web Services",
|
||||
"services": [
|
||||
"Amazon Web Services stores a wide range of customer-owned data hosted on its cloud infrastructure. This includes application databases, file objects, backups, logs and analytics datasets, virtual machine images, and other compute and storage resources used by customers to run their services. Such data may contain user account information, CRM records, documents and files, email and collaboration data, and associated metadata like configuration settings, network logs, access logs, IP addresses and timestamps. The platform also stores company-owned data such as customer account details, billing and subscription information, usage metrics, service configuration data, and internal logs needed to operate, monitor, and secure the AWS environment."
|
||||
],
|
||||
"processingLocations": ["US", "DE", "FR"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://aws.amazon.com"
|
||||
},
|
||||
{
|
||||
"name": "Anthropic",
|
||||
"services": [
|
||||
"Anthropic stores customer-owned data such as prompts and conversation transcripts from interactions with its AI models, including the text of questions, instructions, responses, and user-provided context. This may include uploaded documents, code snippets, and other file attachments used in those interactions, as well as metadata like timestamps, session identifiers, IP addresses, and user identifiers. The platform also stores company-owned data like user account details, API keys, billing and subscription records, usage analytics, configuration settings, and internal logs needed to operate, monitor, and secure the service."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://www.anthropic.com"
|
||||
},
|
||||
{
|
||||
"name": "Cal.com",
|
||||
"services": [
|
||||
"Cal.com stores customer-owned data such as scheduling events, meeting titles, event times, attendee names and email addresses, and any notes provided. It also stores company-owned data such as user account details, billing information, preferences, and usage logs."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true
|
||||
},
|
||||
{
|
||||
"name": "ClickHouse",
|
||||
"services": [
|
||||
"ClickHouse stores customer-owned data such as analytics event data, log data, and other less-structured information ingested by clients (the structured part is in RDS). It also stores company-owned data like configuration metadata, usage metrics, user account details, and internal logs generated by its services."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://clickhouse.com/"
|
||||
},
|
||||
{
|
||||
"name": "Cloudflare",
|
||||
"services": [
|
||||
"Cloudflare stores network traffic logs, DNS records, cached content, IP addresses, HTTP request and response headers, and Web Application Firewall (WAF) logs generated by its content delivery and security services. It also stores analytics dashboards with visitor statistics, access logs, performance metrics, threat patterns and detections, and related metadata such as timestamps, geolocation, and user agent information. Additionally, Cloudflare retains customer account details, billing and subscription records, usage analytics, configuration settings, and security logs required to operate and secure the service."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://www.cloudflare.com"
|
||||
},
|
||||
{
|
||||
"name": "Framer",
|
||||
"services": [
|
||||
"Framer stores customer-owned data such as design files, prototypes, project assets, user-generated content, feedback comments, and collaboration history. The platform also stores company-owned data like user account details, subscription information, usage analytics, configuration settings, and internal logs necessary to operate the service."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true
|
||||
},
|
||||
{
|
||||
"name": "Front",
|
||||
"services": [
|
||||
"Front stores customer-owned data such as incoming and outgoing emails, chat messages, attachments, contact information, and conversation metadata. It also stores company-owned data like internal communications, team comments, user account details, usage logs, and configuration settings."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true
|
||||
},
|
||||
{
|
||||
"name": "Google Cloud Platform",
|
||||
"services": [
|
||||
"Google Cloud Platform stores customer-owned data including Google Maps search queries or login activities of users who logged in through Google. It also stores company-owned data such as user account and identity information, billing and payment details, project and organization configurations, usage analytics, service and audit logs, and internal operational metrics."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://cloud.google.com"
|
||||
},
|
||||
{
|
||||
"name": "Google Workspace",
|
||||
"services": [
|
||||
"Google Workspace stores customer-owned data such as emails and threads in Gmail; documents, spreadsheets, presentations, and forms created or shared via Google Docs, Sheets, Slides and Forms; files and folders uploaded to Google Drive; calendar events, meeting invitations and attendees from Google Calendar; chat messages and history from Google Chat; voice and video recordings and transcripts from Google Meet; contact information; and associated metadata like timestamps, revisions, collaborators, and access permissions. It also stores company-owned data like user account and identity information, billing and payment details, project and organization configurations, usage analytics, security logs, device information, and audit trails needed to provide, secure, and improve the Google Workspace services."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://workspace.google.com"
|
||||
},
|
||||
{
|
||||
"name": "OpenAI",
|
||||
"services": [
|
||||
"OpenAI stores customer-owned data such as prompts, chat transcripts, code snippets, uploaded documents, and other inputs provided by users along with generated outputs. This includes metadata such as timestamps, session identifiers, IP addresses, and user interaction context. OpenAI also stores company-owned data including user account details, API keys, billing and subscription information, usage analytics, model training datasets, configuration settings, and internal system logs for operational and security purposes."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://openai.com"
|
||||
},
|
||||
{
|
||||
"name": "Sentry",
|
||||
"services": [
|
||||
"Sentry stores customer-owned data such as error events, exception stack traces, crash dumps, performance metrics, logs, user context information, breadcrumbs, HTTP requests and responses, release identifiers, build artifacts, and associated metadata (timestamps, device information, operating system, browser data, IP addresses, user identifiers, email addresses or usernames). This data may include personally identifiable information if present in error reports or user context. Additionally, Sentry stores company-owned data such as organization and project settings, team member contact information, API keys, usage analytics, billing and subscription records, configuration settings, integration tokens, and internal logs needed to operate and secure the service."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://sentry.io"
|
||||
},
|
||||
{
|
||||
"name": "Stripe",
|
||||
"services": [
|
||||
"Stripe stores customer-owned data such as payment card details (card numbers, expiration dates, CVV tokens), transaction records, charges, refunds, invoices, receipts, subscription information, payment intents, dispute evidence, payout and settlement records, and associated metadata like amounts, currencies, timestamps, merchant IDs and customer identifiers. It also stores customer communication logs, KYC documents and tax forms when required. The service stores company-owned data like user account details, API keys, billing and subscription information, usage analytics, configuration settings, and internal logs used for fraud prevention, risk analysis, and service optimization."
|
||||
],
|
||||
"processingLocations": ["US"],
|
||||
"processesPii": true,
|
||||
"vendorUrl": "https://stripe.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
|
||||
import { DpaResolver } from 'src/engine/core-modules/dpa/dpa.resolver';
|
||||
import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
import { DpaRegionService } from 'src/engine/core-modules/dpa/services/dpa-region.service';
|
||||
import { DpaService } from 'src/engine/core-modules/dpa/services/dpa.service';
|
||||
import { FileUrlModule } from 'src/engine/core-modules/file/file-url/file-url.module';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
// FileStorageService and TwentyConfigService are provided by @Global() modules.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([DpaAgreementEntity]),
|
||||
FileUrlModule,
|
||||
ApplicationModule,
|
||||
PermissionsModule,
|
||||
],
|
||||
providers: [DpaService, DpaRegionService, DpaResolver],
|
||||
exports: [DpaService, DpaRegionService],
|
||||
})
|
||||
export class DpaModule {}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { UseFilters, UseGuards, UsePipes } from '@nestjs/common';
|
||||
import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
|
||||
import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator';
|
||||
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
|
||||
import { DpaDocumentDTO } from 'src/engine/core-modules/dpa/dtos/dpa-document.dto';
|
||||
import { GenerateSignedDpaInput } from 'src/engine/core-modules/dpa/dtos/generate-signed-dpa.input';
|
||||
import { GenerateSignedDpaResult } from 'src/engine/core-modules/dpa/dtos/generate-signed-dpa.result';
|
||||
import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
import { DpaService } from 'src/engine/core-modules/dpa/services/dpa.service';
|
||||
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
|
||||
|
||||
@CoreResolver(() => DpaAgreementEntity)
|
||||
@UseFilters(AuthGraphqlApiExceptionFilter, PermissionsGraphqlApiExceptionFilter)
|
||||
@UseGuards(
|
||||
WorkspaceAuthGuard,
|
||||
SettingsPermissionGuard(PermissionFlagType.WORKSPACE),
|
||||
)
|
||||
@UsePipes(ResolverValidationPipe)
|
||||
export class DpaResolver {
|
||||
constructor(private readonly dpaService: DpaService) {}
|
||||
|
||||
// Region/entity/law/SCC state are resolved server-side from the workspace
|
||||
// deployment; the customer cannot pick them.
|
||||
@Query(() => DpaDocumentDTO)
|
||||
dpaPreview(@AuthWorkspace() workspace: WorkspaceEntity): DpaDocumentDTO {
|
||||
return this.dpaService.getPreviewForWorkspace(workspace);
|
||||
}
|
||||
|
||||
@Query(() => [DpaAgreementEntity])
|
||||
async dpaAgreements(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<DpaAgreementEntity[]> {
|
||||
return this.dpaService.listAgreements(workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => GenerateSignedDpaResult)
|
||||
async generateSignedDpa(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@AuthUser() user: UserEntity,
|
||||
@Args('input') input: GenerateSignedDpaInput,
|
||||
): Promise<GenerateSignedDpaResult> {
|
||||
return this.dpaService.generateSignedDpa({
|
||||
workspace,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
input,
|
||||
});
|
||||
}
|
||||
|
||||
// Null for click-through records, which have no signed PDF.
|
||||
@ResolveField(() => String, { nullable: true })
|
||||
async downloadUrl(
|
||||
@Parent() agreement: DpaAgreementEntity,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<string | null> {
|
||||
return this.dpaService.getDownloadUrl(agreement, workspace.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { DpaDocumentBlockKind } from 'src/engine/core-modules/dpa/enums/dpa-document-block-kind.enum';
|
||||
|
||||
registerEnumType(DpaDocumentBlockKind, { name: 'DpaDocumentBlockKind' });
|
||||
|
||||
@ObjectType('DpaDocumentBlock')
|
||||
export class DpaDocumentBlockDTO {
|
||||
@Field(() => DpaDocumentBlockKind)
|
||||
kind: DpaDocumentBlockKind;
|
||||
|
||||
@Field()
|
||||
text: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
label?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
value?: string;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { DpaDocumentBlockDTO } from 'src/engine/core-modules/dpa/dtos/dpa-document-block.dto';
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
|
||||
@ObjectType('DpaDocument')
|
||||
export class DpaDocumentDTO {
|
||||
@Field()
|
||||
title: string;
|
||||
|
||||
@Field()
|
||||
lastUpdatedLabel: string;
|
||||
|
||||
@Field()
|
||||
templateVersion: string;
|
||||
|
||||
@Field(() => DpaRegion)
|
||||
region: DpaRegion;
|
||||
|
||||
@Field()
|
||||
processorEntity: string;
|
||||
|
||||
@Field()
|
||||
sccSectionActive: boolean;
|
||||
|
||||
@Field({ nullable: true })
|
||||
notice?: string;
|
||||
|
||||
@Field(() => [DpaDocumentBlockDTO])
|
||||
blocks: DpaDocumentBlockDTO[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class GenerateSignedDpaInput {
|
||||
@Field()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
customerLegalEntityName: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
signatoryName: string;
|
||||
|
||||
@Field()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(255)
|
||||
signatoryTitle: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
|
||||
@ObjectType('GenerateSignedDpaResult')
|
||||
export class GenerateSignedDpaResult {
|
||||
@Field(() => DpaAgreementEntity)
|
||||
agreement: DpaAgreementEntity;
|
||||
|
||||
@Field()
|
||||
downloadUrl: string;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { DpaAgreementType } from 'src/engine/core-modules/dpa/enums/dpa-agreement-type.enum';
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
registerEnumType(DpaAgreementType, { name: 'DpaAgreementType' });
|
||||
registerEnumType(DpaRegion, { name: 'DpaRegion' });
|
||||
|
||||
// The relation is declared explicitly (rather than extending WorkspaceRelatedEntity)
|
||||
// so the foreign-key name is deterministic and matches the create-table instance
|
||||
// command — TypeORM's default FK hash (FK_abba2f6707bd2bc18bbd52f3c3e) is
|
||||
// reproduced in that command.
|
||||
@Entity({ name: 'dpaAgreement', schema: 'core' })
|
||||
@Index('IDX_DPA_AGREEMENT_WORKSPACE_ID', ['workspaceId'])
|
||||
@ObjectType('DpaAgreement')
|
||||
export class DpaAgreementEntity {
|
||||
@IDField(() => UUIDScalarType)
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Field(() => DpaAgreementType)
|
||||
@Column({ type: 'enum', enum: Object.values(DpaAgreementType) })
|
||||
type: DpaAgreementType;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
templateVersion: string;
|
||||
|
||||
// Stored as varchar (a point-in-time snapshot, kept flexible) but exposed as the DpaRegion GraphQL enum.
|
||||
@Field(() => DpaRegion)
|
||||
@Column({ type: 'varchar' })
|
||||
region: DpaRegion;
|
||||
|
||||
@Field()
|
||||
@Column()
|
||||
processorEntity: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true })
|
||||
customerLegalEntityName?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true })
|
||||
signatoryName?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true })
|
||||
signatoryTitle?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
signedFileId?: string;
|
||||
|
||||
@Column({ type: 'uuid', nullable: true })
|
||||
acceptedByUserId?: string;
|
||||
|
||||
@Field({ nullable: true })
|
||||
@Column({ nullable: true })
|
||||
acceptedByEmail?: string;
|
||||
|
||||
@Field()
|
||||
@Column({ type: 'timestamptz' })
|
||||
acceptedAt: Date;
|
||||
|
||||
@Field()
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@Field()
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@Column({ nullable: false, type: 'uuid' })
|
||||
workspaceId: string;
|
||||
|
||||
@ManyToOne(() => WorkspaceEntity, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<WorkspaceEntity>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// CLICK_THROUGH: accepted by reference at signup (acceptance = execution), no signing UI.
|
||||
// SIGNED: generated in-app, pre-signed by Twenty with customer entity + signatory, stored as executed PDF.
|
||||
export enum DpaAgreementType {
|
||||
CLICK_THROUGH = 'CLICK_THROUGH',
|
||||
SIGNED = 'SIGNED',
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
export enum DpaDocumentBlockKind {
|
||||
Heading = 'heading',
|
||||
Paragraph = 'paragraph',
|
||||
SignatureField = 'signatureField',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Real enum (not a string literal) because the repo convention is string literals except for GraphQL enums.
|
||||
export enum DpaRegion {
|
||||
EU = 'EU',
|
||||
US = 'US',
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { createElement, type ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
Document,
|
||||
type DocumentProps,
|
||||
Font,
|
||||
Page,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
import {
|
||||
LIBERATION_SANS_BOLD_BASE64,
|
||||
LIBERATION_SANS_REGULAR_BASE64,
|
||||
} from 'src/engine/core-modules/dpa/pdf/fonts/liberation-sans.fonts';
|
||||
import { type ResolvedDpa } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
|
||||
// createElement (not JSX) because the twenty-server swc builder has tsx disabled (syntax: 'typescript').
|
||||
// Liberation Sans is embedded because react-pdf's built-in fonts only encode ASCII; the legal text's curly
|
||||
// quotes, dashes and accented Latin otherwise throw an "unsupported number" glyph-metric error.
|
||||
const FONT_FAMILY = 'Liberation Sans';
|
||||
|
||||
let fontsRegistered = false;
|
||||
|
||||
const registerFontsOnce = (): void => {
|
||||
if (fontsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
Font.register({
|
||||
family: FONT_FAMILY,
|
||||
fonts: [
|
||||
{
|
||||
src: `data:font/ttf;base64,${LIBERATION_SANS_REGULAR_BASE64}`,
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
{
|
||||
src: `data:font/ttf;base64,${LIBERATION_SANS_BOLD_BASE64}`,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fontsRegistered = true;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 48,
|
||||
paddingBottom: 64,
|
||||
paddingHorizontal: 56,
|
||||
fontSize: 9,
|
||||
fontFamily: FONT_FAMILY,
|
||||
lineHeight: 1.5,
|
||||
color: '#1a1a1a',
|
||||
},
|
||||
notice: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: '#b00020',
|
||||
borderStyle: 'solid',
|
||||
backgroundColor: '#fdecef',
|
||||
color: '#b00020',
|
||||
fontWeight: 'bold',
|
||||
fontSize: 10,
|
||||
padding: 8,
|
||||
marginBottom: 14,
|
||||
},
|
||||
title: { fontSize: 16, fontWeight: 'bold', marginBottom: 4 },
|
||||
lastUpdated: { fontSize: 9, color: '#555555', marginBottom: 16 },
|
||||
heading: { fontSize: 11, fontWeight: 'bold', marginTop: 14, marginBottom: 4 },
|
||||
paragraph: { marginBottom: 6, textAlign: 'justify' },
|
||||
signatureField: { marginBottom: 8 },
|
||||
signatureLabel: { fontWeight: 'bold', marginBottom: 2 },
|
||||
signatureValue: { color: '#333333' },
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 28,
|
||||
left: 56,
|
||||
right: 56,
|
||||
fontSize: 7,
|
||||
color: '#888888',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
});
|
||||
|
||||
const renderBlock = (
|
||||
block: ResolvedDpa['blocks'][number],
|
||||
index: number,
|
||||
): ReactElement => {
|
||||
if (block.kind === 'heading') {
|
||||
return createElement(
|
||||
Text,
|
||||
{ key: index, style: styles.heading },
|
||||
block.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (block.kind === 'signatureField') {
|
||||
const valueLines = (block.value ?? '')
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '');
|
||||
|
||||
return createElement(
|
||||
View,
|
||||
{ key: index, style: styles.signatureField, wrap: false },
|
||||
createElement(Text, { style: styles.signatureLabel }, block.label),
|
||||
...valueLines.map((line, lineIndex) =>
|
||||
createElement(
|
||||
Text,
|
||||
{ key: lineIndex, style: styles.signatureValue },
|
||||
line,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return createElement(
|
||||
Text,
|
||||
{ key: index, style: styles.paragraph },
|
||||
block.text,
|
||||
);
|
||||
};
|
||||
|
||||
export const buildDpaPdfDocumentElement = (
|
||||
resolved: ResolvedDpa,
|
||||
): ReactElement<DocumentProps> => {
|
||||
registerFontsOnce();
|
||||
|
||||
const noticeBlock =
|
||||
resolved.notice !== undefined
|
||||
? createElement(
|
||||
View,
|
||||
{ style: styles.notice, wrap: false },
|
||||
createElement(Text, {}, resolved.notice),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Static footer (no "Page x / y"): @react-pdf/renderer 4.x throws "unsupported number" for a bottom-fixed
|
||||
// element with a dynamic `render` prop in a multi-page document.
|
||||
const footer = createElement(
|
||||
View,
|
||||
{ style: styles.footer, fixed: true },
|
||||
createElement(
|
||||
Text,
|
||||
{},
|
||||
`${resolved.title} — template version ${resolved.templateVersion}`,
|
||||
),
|
||||
createElement(Text, {}, `Last Updated: ${resolved.lastUpdatedLabel}`),
|
||||
);
|
||||
|
||||
const page = createElement(
|
||||
Page,
|
||||
{ size: 'A4', style: styles.page, wrap: true },
|
||||
noticeBlock,
|
||||
createElement(Text, { style: styles.title }, resolved.title),
|
||||
createElement(
|
||||
Text,
|
||||
{ style: styles.lastUpdated },
|
||||
`Last Updated: ${resolved.lastUpdatedLabel}`,
|
||||
),
|
||||
...resolved.blocks.map((block, index) => renderBlock(block, index)),
|
||||
footer,
|
||||
);
|
||||
|
||||
return createElement(
|
||||
Document,
|
||||
{
|
||||
title: resolved.title,
|
||||
author: resolved.values.PROCESSOR_ENTITY,
|
||||
subject: `DPA template version ${resolved.templateVersion}`,
|
||||
},
|
||||
page,
|
||||
) as ReactElement<DocumentProps>;
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: Liberation Fonts
|
||||
Source: https://github.com/liberationfonts
|
||||
|
||||
Files: *
|
||||
Copyright:
|
||||
Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine.
|
||||
Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.
|
||||
License: SIL-OFL-1.1
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
.
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
.
|
||||
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
||||
worldwide development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to provide
|
||||
a free and open framework in which fonts may be shared and improved in
|
||||
partnership with others.
|
||||
.
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves.
|
||||
The fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
.
|
||||
.
|
||||
.
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such.
|
||||
This may include source files, build scripts and documentation.
|
||||
.
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
.
|
||||
"Original Version" refers to the collection of Font Software components
|
||||
as distributed by the Copyright Holder(s).
|
||||
.
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting ? in part or in whole ?
|
||||
any of the components of the Original Version, by changing formats or
|
||||
by porting the Font Software to a new environment.
|
||||
.
|
||||
"Author" refers to any designer, engineer, programmer, technical writer
|
||||
or other person who contributed to the Font Software.
|
||||
.
|
||||
.
|
||||
PERMISSION & CONDITIONS
|
||||
.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
.
|
||||
1) Neither the Font Software nor any of its individual components,in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
.
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
.
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
.
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
.
|
||||
5) The Font Software, modified or unmodified, in part or in whole, must
|
||||
be distributed entirely under this license, and must not be distributed
|
||||
under any other license. The requirement for fonts to remain under
|
||||
this license does not apply to any document created using the Font
|
||||
Software.
|
||||
.
|
||||
.
|
||||
.
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are not met.
|
||||
.
|
||||
.
|
||||
.
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
||||
DEALINGS IN THE FONT SOFTWARE.
|
||||
.
|
||||
|
||||
Files: debian/*
|
||||
Copyright:
|
||||
2008 Alan Baghumian <alan@technotux.org>
|
||||
2008-2018 Holger Levsen <holger@debian.org>
|
||||
2009-2012 Christian Perrier <bubulle@debian.org>
|
||||
2011-2023 Fabian Greffrath <fabian@debian.org>
|
||||
2018 Rene Engelhard <rene@debian.org>
|
||||
License: GPL-2+
|
||||
This package is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
.
|
||||
This package is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
.
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>
|
||||
.
|
||||
On Debian systems, the complete text of the GNU General
|
||||
Public License version 2 can be found in "/usr/share/common-licenses/GPL-2".
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,8 @@
|
||||
import { renderToBuffer } from '@react-pdf/renderer';
|
||||
|
||||
import { buildDpaPdfDocumentElement } from 'src/engine/core-modules/dpa/pdf/dpa-pdf-document';
|
||||
import { type ResolvedDpa } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
|
||||
// @react-pdf/renderer renders server-side via its own layout engine (no DOM/browser needed).
|
||||
export const renderDpaToPdfBuffer = (resolved: ResolvedDpa): Promise<Buffer> =>
|
||||
renderToBuffer(buildDpaPdfDocumentElement(resolved));
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
import { DpaRegionService } from 'src/engine/core-modules/dpa/services/dpa-region.service';
|
||||
import { DpaService } from 'src/engine/core-modules/dpa/services/dpa.service';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
|
||||
// @react-pdf/renderer (via the PDF util) is ESM-only and jest can't parse it;
|
||||
// mock the util so importing DpaService doesn't load it.
|
||||
jest.mock('src/engine/core-modules/dpa/pdf/render-dpa-to-pdf.util', () => ({
|
||||
renderDpaToPdfBuffer: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('DpaService', () => {
|
||||
let service: DpaService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DpaService,
|
||||
{
|
||||
provide: getRepositoryToken(DpaAgreementEntity),
|
||||
useValue: { find: jest.fn(), save: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: DpaRegionService,
|
||||
useValue: { getRegionForWorkspace: jest.fn() },
|
||||
},
|
||||
{ provide: FileStorageService, useValue: { writeFile: jest.fn() } },
|
||||
{ provide: FileUrlService, useValue: { signFileByIdUrl: jest.fn() } },
|
||||
{
|
||||
provide: ApplicationService,
|
||||
useValue: {
|
||||
findWorkspaceTwentyStandardAndCustomApplicationOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TwentyConfigService,
|
||||
useValue: { get: jest.fn().mockReturnValue(false) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<DpaService>(DpaService);
|
||||
});
|
||||
|
||||
it('refuses to generate a signed DPA on self-hosted deployments', async () => {
|
||||
const generate = service.generateSignedDpa({
|
||||
workspace: { id: 'ws-1' },
|
||||
userId: 'user-1',
|
||||
userEmail: 'admin@example.com',
|
||||
input: {
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatoryName: 'Jane Doe',
|
||||
signatoryTitle: 'Head of Legal',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(generate).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(generate).rejects.toThrow(/self-hosted deployments/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { DEFAULT_DPA_REGION } from 'src/engine/core-modules/dpa/config/dpa-region-config.constant';
|
||||
import { type DpaRegion } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
// Region is deployment-wide today; the unused per-workspace arg is kept so it can become a per-workspace override later without touching callers.
|
||||
@Injectable()
|
||||
export class DpaRegionService {
|
||||
constructor(private readonly twentyConfigService: TwentyConfigService) {}
|
||||
|
||||
getRegionForWorkspace(_workspace?: Pick<WorkspaceEntity, 'id'>): DpaRegion {
|
||||
return this.getDeploymentRegion();
|
||||
}
|
||||
|
||||
getDeploymentRegion(): DpaRegion {
|
||||
const configured = this.twentyConfigService.get('DPA_DEPLOYMENT_REGION');
|
||||
|
||||
return configured ?? DEFAULT_DPA_REGION;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { FileFolder } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { Repository } from 'typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
|
||||
import { DpaDocumentDTO } from 'src/engine/core-modules/dpa/dtos/dpa-document.dto';
|
||||
import { DpaDocumentBlockKind } from 'src/engine/core-modules/dpa/enums/dpa-document-block-kind.enum';
|
||||
import { type GenerateSignedDpaInput } from 'src/engine/core-modules/dpa/dtos/generate-signed-dpa.input';
|
||||
import { type GenerateSignedDpaResult } from 'src/engine/core-modules/dpa/dtos/generate-signed-dpa.result';
|
||||
import { DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
import { DpaAgreementType } from 'src/engine/core-modules/dpa/enums/dpa-agreement-type.enum';
|
||||
import { DpaRegionService } from 'src/engine/core-modules/dpa/services/dpa-region.service';
|
||||
import { type ResolvedDpa } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
import { buildDpaAgreementRecord } from 'src/engine/core-modules/dpa/utils/build-dpa-agreement-record.util';
|
||||
import { resolveDpa } from 'src/engine/core-modules/dpa/utils/resolve-dpa.util';
|
||||
import { renderDpaToPdfBuffer } from 'src/engine/core-modules/dpa/pdf/render-dpa-to-pdf.util';
|
||||
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
|
||||
import { FileUrlService } from 'src/engine/core-modules/file/file-url/file-url.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
const BLOCK_KIND_TO_DTO: Record<
|
||||
ResolvedDpa['blocks'][number]['kind'],
|
||||
DpaDocumentBlockKind
|
||||
> = {
|
||||
heading: DpaDocumentBlockKind.Heading,
|
||||
paragraph: DpaDocumentBlockKind.Paragraph,
|
||||
signatureField: DpaDocumentBlockKind.SignatureField,
|
||||
};
|
||||
|
||||
const toDocumentDto = (resolved: ResolvedDpa): DpaDocumentDTO => ({
|
||||
title: resolved.title,
|
||||
lastUpdatedLabel: resolved.lastUpdatedLabel,
|
||||
templateVersion: resolved.templateVersion,
|
||||
region: resolved.region,
|
||||
processorEntity: resolved.values.PROCESSOR_ENTITY,
|
||||
sccSectionActive: resolved.sccSectionActive,
|
||||
notice: resolved.notice,
|
||||
blocks: resolved.blocks.map((block) => ({
|
||||
kind: BLOCK_KIND_TO_DTO[block.kind],
|
||||
text: block.text,
|
||||
label: block.label,
|
||||
value: block.value,
|
||||
})),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class DpaService {
|
||||
constructor(
|
||||
// eslint-disable-next-line twenty/prefer-workspace-scoped-repository
|
||||
@InjectRepository(DpaAgreementEntity)
|
||||
private readonly dpaAgreementRepository: Repository<DpaAgreementEntity>,
|
||||
private readonly dpaRegionService: DpaRegionService,
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly fileUrlService: FileUrlService,
|
||||
private readonly applicationService: ApplicationService,
|
||||
private readonly twentyConfigService: TwentyConfigService,
|
||||
) {}
|
||||
|
||||
// Keys off IS_MULTIWORKSPACE_ENABLED, not billing — using billing here misclassified cloud as self-hosted.
|
||||
private isSelfHosted(): boolean {
|
||||
return this.twentyConfigService.get('IS_MULTIWORKSPACE_ENABLED') !== true;
|
||||
}
|
||||
|
||||
getPreviewForWorkspace(
|
||||
workspace: Pick<WorkspaceEntity, 'id'>,
|
||||
): DpaDocumentDTO {
|
||||
const region = this.dpaRegionService.getRegionForWorkspace(workspace);
|
||||
|
||||
return toDocumentDto(
|
||||
resolveDpa({
|
||||
region,
|
||||
mode: 'preview',
|
||||
isSelfHosted: this.isSelfHosted(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listAgreements(workspaceId: string): Promise<DpaAgreementEntity[]> {
|
||||
return this.dpaAgreementRepository.find({
|
||||
where: { workspaceId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
agreement: Pick<DpaAgreementEntity, 'signedFileId'>,
|
||||
workspaceId: string,
|
||||
): Promise<string | null> {
|
||||
if (!isDefined(agreement.signedFileId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.fileUrlService.signFileByIdUrl({
|
||||
fileId: agreement.signedFileId,
|
||||
workspaceId,
|
||||
fileFolder: FileFolder.Dpa,
|
||||
});
|
||||
}
|
||||
|
||||
async generateSignedDpa({
|
||||
workspace,
|
||||
userId,
|
||||
userEmail,
|
||||
input,
|
||||
}: {
|
||||
workspace: Pick<WorkspaceEntity, 'id'>;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
input: GenerateSignedDpaInput;
|
||||
}): Promise<GenerateSignedDpaResult> {
|
||||
if (this.isSelfHosted()) {
|
||||
throw new BadRequestException(
|
||||
'DPA signing is not available for self-hosted deployments: Twenty does not host or process Customer Personal Data and is not the Processor.',
|
||||
);
|
||||
}
|
||||
|
||||
const region = this.dpaRegionService.getRegionForWorkspace(workspace);
|
||||
const executedAt = new Date();
|
||||
|
||||
const resolved = resolveDpa({
|
||||
region,
|
||||
mode: 'signed',
|
||||
customerLegalEntityName: input.customerLegalEntityName,
|
||||
signatory: { name: input.signatoryName, title: input.signatoryTitle },
|
||||
executedAt: executedAt.toISOString(),
|
||||
isSelfHosted: this.isSelfHosted(),
|
||||
});
|
||||
|
||||
const fileId = v4();
|
||||
|
||||
const [pdfBuffer, { workspaceCustomFlatApplication }] = await Promise.all([
|
||||
renderDpaToPdfBuffer(resolved),
|
||||
this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
|
||||
{ workspaceId: workspace.id },
|
||||
),
|
||||
]);
|
||||
|
||||
await this.fileStorageService.writeFile({
|
||||
sourceFile: pdfBuffer,
|
||||
resourcePath: `${fileId}.pdf`,
|
||||
fileFolder: FileFolder.Dpa,
|
||||
applicationUniversalIdentifier:
|
||||
workspaceCustomFlatApplication.universalIdentifier,
|
||||
workspaceId: workspace.id,
|
||||
fileId,
|
||||
settings: {
|
||||
isTemporaryFile: false,
|
||||
toDelete: false,
|
||||
},
|
||||
});
|
||||
|
||||
const agreement = await this.dpaAgreementRepository.save(
|
||||
buildDpaAgreementRecord({
|
||||
workspaceId: workspace.id,
|
||||
type: DpaAgreementType.SIGNED,
|
||||
region,
|
||||
acceptedAt: executedAt,
|
||||
acceptedByUserId: userId,
|
||||
acceptedByEmail: userEmail,
|
||||
customerLegalEntityName: input.customerLegalEntityName,
|
||||
signatoryName: input.signatoryName,
|
||||
signatoryTitle: input.signatoryTitle,
|
||||
signedFileId: fileId,
|
||||
}),
|
||||
);
|
||||
|
||||
const downloadUrl = await this.fileUrlService.signFileByIdUrl({
|
||||
fileId,
|
||||
workspaceId: workspace.id,
|
||||
fileFolder: FileFolder.Dpa,
|
||||
});
|
||||
|
||||
return { agreement, downloadUrl };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
|
||||
export { DpaRegion };
|
||||
|
||||
export type DpaMergeField =
|
||||
| 'PROCESSOR_ENTITY'
|
||||
| 'PROCESSOR_LEGAL_FORM'
|
||||
| 'PROCESSOR_ADDRESS'
|
||||
| 'HOSTING_REGION'
|
||||
| 'GOVERNING_LAW'
|
||||
| 'DPO_NAME_AND_CONTACT';
|
||||
|
||||
// SCC transfer sections (7.2–7.5) are NEVER branched out of the document; only
|
||||
// field values change per region. This key exists so the conditional-clause
|
||||
// mechanism is available for future optional clauses and is exercised by tests.
|
||||
export type DpaConditionKey = 'sccSectionActive';
|
||||
|
||||
export type DpaBlockKind = 'heading' | 'paragraph';
|
||||
|
||||
export type DpaBlockExpansion = 'subprocessorList';
|
||||
|
||||
export type DpaTemplateBlock = {
|
||||
kind: DpaBlockKind;
|
||||
text: string;
|
||||
includeWhen?: DpaConditionKey;
|
||||
expand?: DpaBlockExpansion;
|
||||
};
|
||||
|
||||
export type DpaRegionConfig = {
|
||||
region: DpaRegion;
|
||||
values: Record<DpaMergeField, string>;
|
||||
sccSectionActive: boolean;
|
||||
};
|
||||
|
||||
export type DpaRenderMode = 'preview' | 'clickThrough' | 'signed';
|
||||
|
||||
export type DpaSignatory = {
|
||||
name: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type DpaResolveContext = {
|
||||
region: DpaRegion;
|
||||
mode: DpaRenderMode;
|
||||
customerLegalEntityName?: string;
|
||||
signatory?: DpaSignatory;
|
||||
// Passed in (never read from the clock here) so the resolver stays pure.
|
||||
executedAt?: string;
|
||||
// Twenty is not the Processor for self-hosted deployments, so the resolved
|
||||
// document carries a prominent "not a valid agreement" notice.
|
||||
isSelfHosted?: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedDpaBlock = {
|
||||
kind: 'heading' | 'paragraph' | 'signatureField';
|
||||
text: string;
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type ResolvedDpa = {
|
||||
region: DpaRegion;
|
||||
templateVersion: string;
|
||||
lastUpdatedLabel: string;
|
||||
title: string;
|
||||
sccSectionActive: boolean;
|
||||
values: Record<DpaMergeField, string>;
|
||||
blocks: ResolvedDpaBlock[];
|
||||
notice?: string;
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
// Synced into subprocessors.json from the Trust Center; do not hand-edit the JSON.
|
||||
export type Subprocessor = {
|
||||
name: string;
|
||||
services: string[];
|
||||
processingLocations: string[];
|
||||
processesPii: boolean;
|
||||
vendorUrl?: string;
|
||||
};
|
||||
|
||||
export type SubprocessorList = {
|
||||
subprocessors: Subprocessor[];
|
||||
};
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
import {
|
||||
type DpaResolveContext,
|
||||
type ResolvedDpa,
|
||||
} from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
import { resolveDpa } from 'src/engine/core-modules/dpa/utils/resolve-dpa.util';
|
||||
import { renderDpaToHtml } from 'src/engine/core-modules/dpa/utils/render-dpa-to-html.util';
|
||||
|
||||
// Synthetic doc to test the conditional-clause mechanism in isolation: the real template branches no clauses.
|
||||
const buildResolvedWithConditionalBlock = (
|
||||
sccSectionActive: boolean,
|
||||
): ResolvedDpa => ({
|
||||
region: sccSectionActive ? DpaRegion.US : DpaRegion.EU,
|
||||
templateVersion: 'test',
|
||||
lastUpdatedLabel: 'June 2026',
|
||||
title: 'Test DPA',
|
||||
sccSectionActive,
|
||||
values: {
|
||||
PROCESSOR_ENTITY: 'Twenty.com SAS',
|
||||
PROCESSOR_LEGAL_FORM: 'x',
|
||||
PROCESSOR_ADDRESS: 'x',
|
||||
HOSTING_REGION: 'x',
|
||||
GOVERNING_LAW: 'x',
|
||||
DPO_NAME_AND_CONTACT: 'x',
|
||||
},
|
||||
blocks: [
|
||||
{ kind: 'heading', text: 'Always Present' },
|
||||
...(sccSectionActive
|
||||
? [{ kind: 'paragraph' as const, text: 'CONDITIONAL_SCC_ONLY_BLOCK' }]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
describe('renderDpaToHtml', () => {
|
||||
const baseContext: DpaResolveContext = {
|
||||
region: DpaRegion.EU,
|
||||
mode: 'preview',
|
||||
};
|
||||
|
||||
it('renders the title and last-updated line', () => {
|
||||
const html = renderDpaToHtml(resolveDpa(baseContext));
|
||||
|
||||
expect(html).toContain('Twenty Data Processing Agreement (DPA)');
|
||||
expect(html).toContain('Last Updated: June 2026');
|
||||
});
|
||||
|
||||
it('contains no unresolved {{ }} merge fields in the output', () => {
|
||||
for (const region of [DpaRegion.EU, DpaRegion.US]) {
|
||||
const html = renderDpaToHtml(resolveDpa({ region, mode: 'signed' }));
|
||||
|
||||
expect(html).not.toMatch(/\{\{[^}]+\}\}/);
|
||||
}
|
||||
});
|
||||
|
||||
it('toggles conditional clauses correctly via the includeWhen mechanism', () => {
|
||||
const dormant = renderDpaToHtml(buildResolvedWithConditionalBlock(false));
|
||||
const active = renderDpaToHtml(buildResolvedWithConditionalBlock(true));
|
||||
|
||||
expect(dormant).not.toContain('CONDITIONAL_SCC_ONLY_BLOCK');
|
||||
expect(active).toContain('CONDITIONAL_SCC_ONLY_BLOCK');
|
||||
expect(dormant).toContain('Always Present');
|
||||
expect(active).toContain('Always Present');
|
||||
});
|
||||
|
||||
it('escapes HTML in legal text to prevent injection', () => {
|
||||
const resolved: ResolvedDpa = {
|
||||
...buildResolvedWithConditionalBlock(false),
|
||||
blocks: [{ kind: 'paragraph', text: '<script>alert(1)</script>' }],
|
||||
};
|
||||
|
||||
const html = renderDpaToHtml(resolved);
|
||||
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('renders the executed signatory and Processor entity in signed mode', () => {
|
||||
const html = renderDpaToHtml(
|
||||
resolveDpa({
|
||||
region: DpaRegion.EU,
|
||||
mode: 'signed',
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatory: { name: 'Jane Doe', title: 'CEO' },
|
||||
executedAt: '2026-06-26T00:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(html).toContain('Acme GmbH');
|
||||
expect(html).toContain('Jane Doe');
|
||||
expect(html).toContain('Twenty.com SAS');
|
||||
});
|
||||
|
||||
it('renders a clean cloud DPA: no merge tokens, drafting notes, placeholders, or self-hosted banner', () => {
|
||||
const html = renderDpaToHtml(
|
||||
resolveDpa({
|
||||
region: DpaRegion.EU,
|
||||
mode: 'signed',
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatory: { name: 'Jane Doe', title: 'Head of Legal' },
|
||||
executedAt: '2026-06-27T00:00:00.000Z',
|
||||
isSelfHosted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const forbidden of [
|
||||
'{{',
|
||||
'(default:',
|
||||
'for US deployments',
|
||||
'the the',
|
||||
'NOT A VALID AGREEMENT',
|
||||
'LEGAL ENTITY',
|
||||
'SIGNATORY NAME',
|
||||
]) {
|
||||
expect(html).not.toContain(forbidden);
|
||||
}
|
||||
|
||||
expect(html).toContain('Stéphanie Joly');
|
||||
expect(
|
||||
html.match(/7\.1 Data Hosting and Localization:/g) ?? [],
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows the self-hosted banner for a self-hosted deployment but not for cloud', () => {
|
||||
const selfHosted = renderDpaToHtml(
|
||||
resolveDpa({ region: DpaRegion.EU, mode: 'preview', isSelfHosted: true }),
|
||||
);
|
||||
const cloud = renderDpaToHtml(
|
||||
resolveDpa({
|
||||
region: DpaRegion.EU,
|
||||
mode: 'preview',
|
||||
isSelfHosted: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(selfHosted).toContain('NOT A VALID AGREEMENT');
|
||||
expect(cloud).not.toContain('NOT A VALID AGREEMENT');
|
||||
});
|
||||
});
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
DEFAULT_DPA_REGION,
|
||||
DPA_REGION_CONFIGS,
|
||||
} from 'src/engine/core-modules/dpa/config/dpa-region-config.constant';
|
||||
import { DPA_TEMPLATE_VERSION } from 'src/engine/core-modules/dpa/constants/dpa-template-version.constant';
|
||||
import subprocessorsData from 'src/engine/core-modules/dpa/constants/subprocessors.json';
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
import { type SubprocessorList } from 'src/engine/core-modules/dpa/types/subprocessor.type';
|
||||
import {
|
||||
findUnresolvedMergeFields,
|
||||
resolveDpa,
|
||||
} from 'src/engine/core-modules/dpa/utils/resolve-dpa.util';
|
||||
|
||||
describe('resolveDpa', () => {
|
||||
it('defaults to EU and resolves the EU Processor entity, law and dormant SCC state', () => {
|
||||
const resolved = resolveDpa({
|
||||
region: DEFAULT_DPA_REGION,
|
||||
mode: 'preview',
|
||||
});
|
||||
|
||||
expect(resolved.region).toBe('EU');
|
||||
expect(resolved.values.PROCESSOR_ENTITY).toBe('Twenty.com SAS');
|
||||
expect(resolved.values.PROCESSOR_LEGAL_FORM).toContain('France');
|
||||
expect(resolved.values.HOSTING_REGION).toContain('Frankfurt');
|
||||
expect(resolved.values.GOVERNING_LAW).toBe('France');
|
||||
expect(resolved.sccSectionActive).toBe(false);
|
||||
expect(resolved.templateVersion).toBe(DPA_TEMPLATE_VERSION);
|
||||
});
|
||||
|
||||
it('resolves the US Processor entity, hosting and active SCC state', () => {
|
||||
const resolved = resolveDpa({ region: DpaRegion.US, mode: 'preview' });
|
||||
|
||||
expect(resolved.region).toBe('US');
|
||||
expect(resolved.values.PROCESSOR_ENTITY).toBe('Twenty, Inc.');
|
||||
expect(resolved.values.PROCESSOR_LEGAL_FORM).toContain('Delaware');
|
||||
expect(resolved.values.HOSTING_REGION).toBe('United States');
|
||||
expect(resolved.sccSectionActive).toBe(true);
|
||||
});
|
||||
|
||||
it('substitutes the Processor entity into the contracting clause per region', () => {
|
||||
const eu = resolveDpa({ region: DpaRegion.EU, mode: 'preview' });
|
||||
const us = resolveDpa({ region: DpaRegion.US, mode: 'preview' });
|
||||
|
||||
// Assert on the contracting clause (block 0), not the whole doc: "Twenty, Inc."
|
||||
// also appears verbatim in Annex A's "for US deployments" note.
|
||||
expect(eu.blocks[0].text).toContain('between Twenty.com SAS (');
|
||||
expect(us.blocks[0].text).toContain('between Twenty, Inc. (');
|
||||
});
|
||||
|
||||
it('keeps the SCC/transfer sections (7.2–7.5) in the document for BOTH regions (document is not branched)', () => {
|
||||
const eu = resolveDpa({ region: DpaRegion.EU, mode: 'preview' });
|
||||
const us = resolveDpa({ region: DpaRegion.US, mode: 'preview' });
|
||||
|
||||
const headings = (blocks: typeof eu.blocks) =>
|
||||
blocks.filter((block) => block.kind === 'heading').map((b) => b.text);
|
||||
|
||||
for (const resolved of [eu, us]) {
|
||||
const text = resolved.blocks.map((b) => b.text).join('\n');
|
||||
|
||||
expect(headings(resolved.blocks)).toContain(
|
||||
'7. International Data Transfers',
|
||||
);
|
||||
expect(text).toContain('7.2 European Data Transfers');
|
||||
expect(text).toContain('7.3 Transfers from Brazil');
|
||||
expect(text).toContain('7.4 Other Transfer Mechanisms');
|
||||
expect(text).toContain('7.5 Disclosure of SCCs');
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves no unresolved {{ }} merge fields for any region', () => {
|
||||
for (const region of Object.keys(DPA_REGION_CONFIGS) as DpaRegion[]) {
|
||||
const resolved = resolveDpa({ region, mode: 'signed' });
|
||||
|
||||
expect(findUnresolvedMergeFields(resolved)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('only appends the execution / signature block in signed mode', () => {
|
||||
const preview = resolveDpa({ region: DpaRegion.EU, mode: 'preview' });
|
||||
const signed = resolveDpa({
|
||||
region: DpaRegion.EU,
|
||||
mode: 'signed',
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatory: { name: 'Jane Doe', title: 'CEO' },
|
||||
executedAt: '2026-06-26T00:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(preview.blocks.some((b) => b.kind === 'signatureField')).toBe(false);
|
||||
|
||||
const customerField = signed.blocks.find(
|
||||
(b) => b.kind === 'signatureField' && b.label === 'Customer (Controller)',
|
||||
);
|
||||
|
||||
expect(customerField?.value).toContain('Acme GmbH');
|
||||
expect(customerField?.value).toContain('Jane Doe');
|
||||
expect(customerField?.value).toContain('CEO');
|
||||
expect(
|
||||
signed.blocks.some(
|
||||
(b) => b.kind === 'signatureField' && b.label === 'Execution Date',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('marks self-hosted deployments as not a valid agreement', () => {
|
||||
const cloud = resolveDpa({ region: DpaRegion.EU, mode: 'preview' });
|
||||
const selfHosted = resolveDpa({
|
||||
region: DpaRegion.EU,
|
||||
mode: 'preview',
|
||||
isSelfHosted: true,
|
||||
});
|
||||
|
||||
expect(cloud.notice).toBeUndefined();
|
||||
expect(selfHosted.notice).toContain('NOT A VALID AGREEMENT');
|
||||
});
|
||||
|
||||
it('records the template version so we can prove what was agreed', () => {
|
||||
const signed = resolveDpa({ region: DpaRegion.US, mode: 'signed' });
|
||||
|
||||
const versionField = signed.blocks.find(
|
||||
(b) => b.kind === 'signatureField' && b.label === 'DPA template version',
|
||||
);
|
||||
|
||||
expect(versionField?.value).toContain(DPA_TEMPLATE_VERSION);
|
||||
});
|
||||
|
||||
const resolvedText = (region: DpaRegion, mode: 'preview' | 'signed') =>
|
||||
resolveDpa({
|
||||
region,
|
||||
mode,
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatory: { name: 'Jane Doe', title: 'Head of Legal' },
|
||||
executedAt: '2026-06-27T00:00:00.000Z',
|
||||
})
|
||||
.blocks.map((b) => `${b.text}\n${b.label ?? ''}\n${b.value ?? ''}`)
|
||||
.join('\n');
|
||||
|
||||
it('resolves clean, article-free hosting region values per region', () => {
|
||||
expect(
|
||||
resolveDpa({ region: DpaRegion.EU, mode: 'preview' }).values
|
||||
.HOSTING_REGION,
|
||||
).toBe('EU (Frankfurt, Germany)');
|
||||
expect(
|
||||
resolveDpa({ region: DpaRegion.US, mode: 'preview' }).values
|
||||
.HOSTING_REGION,
|
||||
).toBe('United States');
|
||||
|
||||
// Template phrasing "in the {{HOSTING_REGION}}" must not double the article.
|
||||
expect(resolvedText(DpaRegion.EU, 'signed')).toContain(
|
||||
'in the EU (Frankfurt, Germany)',
|
||||
);
|
||||
expect(resolvedText(DpaRegion.EU, 'signed')).not.toMatch(/\bthe the\b/);
|
||||
});
|
||||
|
||||
it('has exactly one "7.1 Data Hosting and Localization:" heading', () => {
|
||||
const matches =
|
||||
resolvedText(DpaRegion.EU, 'signed').match(
|
||||
/7\.1 Data Hosting and Localization:/g,
|
||||
) ?? [];
|
||||
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaks no internal drafting notes or placeholder tokens for either region', () => {
|
||||
for (const region of [DpaRegion.EU, DpaRegion.US]) {
|
||||
const text = resolvedText(region, 'signed');
|
||||
|
||||
expect(text).not.toContain('(default:');
|
||||
expect(text).not.toContain('for US deployments');
|
||||
expect(text).not.toContain('LEGAL ENTITY');
|
||||
expect(text).not.toContain('SIGNATORY NAME');
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the DPO name and contact', () => {
|
||||
const text = resolvedText(DpaRegion.EU, 'signed');
|
||||
|
||||
expect(text).toContain('Stéphanie Joly');
|
||||
expect(text).toContain('privacy@twenty.com');
|
||||
});
|
||||
|
||||
it('states the §7.1 hosting location per region without contradicting the deployment', () => {
|
||||
const eu = resolvedText(DpaRegion.EU, 'signed');
|
||||
const us = resolvedText(DpaRegion.US, 'signed');
|
||||
|
||||
for (const text of [eu, us]) {
|
||||
expect(text).not.toContain(
|
||||
'primarily stored in data centers located in the European Union',
|
||||
);
|
||||
}
|
||||
|
||||
expect(eu).toContain(
|
||||
'hosted in data centers in the EU (Frankfurt, Germany) via Amazon Web Services (AWS)',
|
||||
);
|
||||
expect(us).toContain(
|
||||
'hosted in data centers in the United States via Amazon Web Services (AWS)',
|
||||
);
|
||||
|
||||
expect(us).not.toContain(
|
||||
'stored in data centers located in the European Union (Frankfurt, Germany)',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a government / law enforcement request clause for both regions', () => {
|
||||
for (const region of [DpaRegion.EU, DpaRegion.US]) {
|
||||
const text = resolvedText(region, 'signed');
|
||||
|
||||
expect(text).toContain('Government and Law Enforcement Requests');
|
||||
expect(text).toContain('will not voluntarily disclose');
|
||||
expect(text).toContain('back doors');
|
||||
}
|
||||
});
|
||||
|
||||
it('states the SOC 2 Type II certification and does not claim ISO 27001', () => {
|
||||
const text = resolvedText(DpaRegion.EU, 'signed');
|
||||
|
||||
expect(text).toContain('SOC 2 Type II');
|
||||
expect(text).not.toContain('ISO 27001');
|
||||
});
|
||||
|
||||
it('renders Annex C with one entry per synced sub-processor and ties it to §6.1', () => {
|
||||
const text = resolvedText(DpaRegion.EU, 'signed');
|
||||
|
||||
expect(text).toContain('ANNEX C – List of Sub-Processors');
|
||||
expect(text).toContain('set out in Annex C');
|
||||
expect(text).toContain(
|
||||
'Amazon Web Services (https://aws.amazon.com) — Processing location(s): United States, Germany, France.',
|
||||
);
|
||||
expect(text).toContain('Anthropic');
|
||||
});
|
||||
|
||||
it('expands the sub-processor sentinel into exactly the synced entries', () => {
|
||||
const resolved = resolveDpa({ region: DpaRegion.EU, mode: 'preview' });
|
||||
|
||||
expect(
|
||||
resolved.blocks.some((b) => b.kind === 'paragraph' && b.text === ''),
|
||||
).toBe(false);
|
||||
|
||||
const entries = resolved.blocks.filter(
|
||||
(b) =>
|
||||
b.kind === 'paragraph' && / — Processing location\(s\): /.test(b.text),
|
||||
);
|
||||
const expectedCount = (subprocessorsData as SubprocessorList).subprocessors
|
||||
.length;
|
||||
|
||||
expect(entries).toHaveLength(expectedCount);
|
||||
});
|
||||
|
||||
it('never shows the self-hosted banner on an executed (signed) document', () => {
|
||||
expect(
|
||||
resolveDpa({ region: DpaRegion.EU, mode: 'preview', isSelfHosted: true })
|
||||
.notice,
|
||||
).toContain('NOT A VALID AGREEMENT');
|
||||
expect(
|
||||
resolveDpa({
|
||||
region: DpaRegion.EU,
|
||||
mode: 'signed',
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatory: { name: 'Jane Doe', title: 'Head of Legal' },
|
||||
executedAt: '2026-06-27T00:00:00.000Z',
|
||||
isSelfHosted: true,
|
||||
}).notice,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveDpa({ region: DpaRegion.EU, mode: 'preview', isSelfHosted: false })
|
||||
.notice,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import { getDpaRegionConfig } from 'src/engine/core-modules/dpa/config/dpa-region-config.constant';
|
||||
import { DPA_TEMPLATE_VERSION } from 'src/engine/core-modules/dpa/constants/dpa-template-version.constant';
|
||||
import { type DpaAgreementEntity } from 'src/engine/core-modules/dpa/entities/dpa-agreement.entity';
|
||||
import { type DpaAgreementType } from 'src/engine/core-modules/dpa/enums/dpa-agreement-type.enum';
|
||||
import { type DpaRegion } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
|
||||
type BuildDpaAgreementRecordArgs = {
|
||||
workspaceId: string;
|
||||
type: DpaAgreementType;
|
||||
region: DpaRegion;
|
||||
acceptedAt: Date;
|
||||
acceptedByUserId?: string;
|
||||
acceptedByEmail?: string;
|
||||
customerLegalEntityName?: string;
|
||||
signatoryName?: string;
|
||||
signatoryTitle?: string;
|
||||
signedFileId?: string;
|
||||
};
|
||||
|
||||
// Snapshots the Processor entity and template version at execution time so the record stays accurate if config/template change later.
|
||||
export const buildDpaAgreementRecord = (
|
||||
args: BuildDpaAgreementRecordArgs,
|
||||
): Partial<DpaAgreementEntity> => {
|
||||
const config = getDpaRegionConfig(args.region);
|
||||
|
||||
return {
|
||||
workspaceId: args.workspaceId,
|
||||
type: args.type,
|
||||
templateVersion: DPA_TEMPLATE_VERSION,
|
||||
region: args.region,
|
||||
processorEntity: config.values.PROCESSOR_ENTITY,
|
||||
acceptedAt: args.acceptedAt,
|
||||
acceptedByUserId: args.acceptedByUserId,
|
||||
acceptedByEmail: args.acceptedByEmail,
|
||||
customerLegalEntityName: args.customerLegalEntityName,
|
||||
signatoryName: args.signatoryName,
|
||||
signatoryTitle: args.signatoryTitle,
|
||||
signedFileId: args.signedFileId,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { escapeHtml } from 'src/engine/core-modules/emailing-domain/utils/escape-html.util';
|
||||
import { type ResolvedDpa } from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
|
||||
const renderMultiline = (value: string): string =>
|
||||
escapeHtml(value)
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.map((line) => `<span class="dpa-line">${line}</span>`)
|
||||
.join('<br />');
|
||||
|
||||
export const renderDpaToHtml = (resolved: ResolvedDpa): string => {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (resolved.notice !== undefined) {
|
||||
parts.push(
|
||||
`<div class="dpa-notice" role="alert"><strong>${escapeHtml(
|
||||
resolved.notice,
|
||||
)}</strong></div>`,
|
||||
);
|
||||
}
|
||||
|
||||
parts.push(`<h1 class="dpa-title">${escapeHtml(resolved.title)}</h1>`);
|
||||
parts.push(
|
||||
`<p class="dpa-last-updated">Last Updated: ${escapeHtml(
|
||||
resolved.lastUpdatedLabel,
|
||||
)}</p>`,
|
||||
);
|
||||
|
||||
for (const block of resolved.blocks) {
|
||||
if (block.kind === 'heading') {
|
||||
parts.push(`<h2>${escapeHtml(block.text)}</h2>`);
|
||||
} else if (block.kind === 'signatureField') {
|
||||
parts.push(
|
||||
`<div class="dpa-signature-field"><strong>${escapeHtml(
|
||||
block.label ?? '',
|
||||
)}</strong><div>${renderMultiline(block.value ?? '')}</div></div>`,
|
||||
);
|
||||
} else {
|
||||
parts.push(`<p>${escapeHtml(block.text)}</p>`);
|
||||
}
|
||||
}
|
||||
|
||||
return `<article class="dpa-document">${parts.join('\n')}</article>`;
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
TWENTY_PRESIGNED_SIGNATORY,
|
||||
getDpaRegionConfig,
|
||||
} from 'src/engine/core-modules/dpa/config/dpa-region-config.constant';
|
||||
import { DPA_TEMPLATE_BLOCKS } from 'src/engine/core-modules/dpa/constants/dpa-template.constant';
|
||||
import {
|
||||
DPA_DOCUMENT_TITLE,
|
||||
DPA_LAST_UPDATED_LABEL,
|
||||
DPA_TEMPLATE_VERSION,
|
||||
} from 'src/engine/core-modules/dpa/constants/dpa-template-version.constant';
|
||||
import subprocessorsData from 'src/engine/core-modules/dpa/constants/subprocessors.json';
|
||||
import {
|
||||
type DpaResolveContext,
|
||||
type ResolvedDpa,
|
||||
type ResolvedDpaBlock,
|
||||
} from 'src/engine/core-modules/dpa/types/dpa.types';
|
||||
import { type SubprocessorList } from 'src/engine/core-modules/dpa/types/subprocessor.type';
|
||||
|
||||
const MERGE_FIELD_PATTERN = /\{\{([A-Z_]+)\}\}/g;
|
||||
|
||||
const COUNTRY_NAMES: Record<string, string> = {
|
||||
US: 'United States',
|
||||
DE: 'Germany',
|
||||
FR: 'France',
|
||||
};
|
||||
|
||||
const formatLocations = (codes: string[]): string =>
|
||||
codes.map((code) => COUNTRY_NAMES[code] ?? code).join(', ');
|
||||
|
||||
const SUBPROCESSOR_BLOCKS: ResolvedDpaBlock[] = (
|
||||
subprocessorsData as SubprocessorList
|
||||
).subprocessors.map((subprocessor) => ({
|
||||
kind: 'paragraph',
|
||||
text: `${subprocessor.name}${subprocessor.vendorUrl ? ` (${subprocessor.vendorUrl})` : ''} — Processing location(s): ${formatLocations(subprocessor.processingLocations)}. ${subprocessor.services.join(' ')}`,
|
||||
}));
|
||||
|
||||
const SELF_HOSTED_NOTICE =
|
||||
'NOT A VALID AGREEMENT — SELF-HOSTED DEPLOYMENT. This Twenty instance is self-hosted. For self-hosted deployments Twenty does not host or process Customer Personal Data and is not the Processor, so this Data Processing Agreement does not apply. This copy is generated for reference only and does not constitute an executed agreement with Twenty.';
|
||||
|
||||
// Unknown fields are left untouched so they surface in the unresolved-merge-field test instead of vanishing silently.
|
||||
const fillMergeFields = (
|
||||
text: string,
|
||||
values: Record<string, string>,
|
||||
): string =>
|
||||
text.replace(MERGE_FIELD_PATTERN, (match, fieldName: string) =>
|
||||
fieldName in values ? values[fieldName] : match,
|
||||
);
|
||||
|
||||
const buildExecutionBlocks = (
|
||||
context: DpaResolveContext,
|
||||
values: Record<string, string>,
|
||||
): ResolvedDpaBlock[] => {
|
||||
const blocks: ResolvedDpaBlock[] = [
|
||||
{ kind: 'heading', text: 'Execution' },
|
||||
{
|
||||
kind: 'paragraph',
|
||||
text: `This Data Processing Agreement is executed by the parties as set out below. Acceptance constitutes execution of this DPA (and, where applicable, the Standard Contractual Clauses incorporated by reference) as of the Execution Date, in accordance with Section 13.6.`,
|
||||
},
|
||||
{
|
||||
kind: 'signatureField',
|
||||
text: '',
|
||||
label: `Processor — ${values.PROCESSOR_ENTITY}`,
|
||||
value: `Signed on behalf of ${values.PROCESSOR_ENTITY} (pre-signed by Twenty)\nName: ${TWENTY_PRESIGNED_SIGNATORY.name}\nTitle: ${TWENTY_PRESIGNED_SIGNATORY.title}`,
|
||||
},
|
||||
{
|
||||
kind: 'signatureField',
|
||||
text: '',
|
||||
label: 'Customer (Controller)',
|
||||
value: `Legal entity: ${context.customerLegalEntityName ?? ''}\nName: ${context.signatory?.name ?? ''}\nTitle: ${context.signatory?.title ?? ''}`,
|
||||
},
|
||||
{
|
||||
kind: 'signatureField',
|
||||
text: '',
|
||||
label: 'Execution Date',
|
||||
value: context.executedAt ?? '',
|
||||
},
|
||||
{
|
||||
kind: 'signatureField',
|
||||
text: '',
|
||||
label: 'DPA template version',
|
||||
value: `${DPA_TEMPLATE_VERSION} (Last Updated: ${DPA_LAST_UPDATED_LABEL})`,
|
||||
},
|
||||
];
|
||||
|
||||
return blocks;
|
||||
};
|
||||
|
||||
// Pure: no I/O or clock access — everything time-dependent is passed in via context.
|
||||
export const resolveDpa = (context: DpaResolveContext): ResolvedDpa => {
|
||||
const config = getDpaRegionConfig(context.region);
|
||||
|
||||
const bodyBlocks: ResolvedDpaBlock[] = DPA_TEMPLATE_BLOCKS.filter(
|
||||
(block) =>
|
||||
block.includeWhen === undefined ||
|
||||
(block.includeWhen === 'sccSectionActive' && config.sccSectionActive),
|
||||
).flatMap((block) =>
|
||||
block.expand === 'subprocessorList'
|
||||
? SUBPROCESSOR_BLOCKS
|
||||
: [
|
||||
{
|
||||
kind: block.kind,
|
||||
text: fillMergeFields(block.text, config.values),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const executionBlocks =
|
||||
context.mode === 'signed'
|
||||
? buildExecutionBlocks(context, config.values)
|
||||
: [];
|
||||
|
||||
return {
|
||||
region: config.region,
|
||||
templateVersion: DPA_TEMPLATE_VERSION,
|
||||
lastUpdatedLabel: DPA_LAST_UPDATED_LABEL,
|
||||
title: DPA_DOCUMENT_TITLE,
|
||||
sccSectionActive: config.sccSectionActive,
|
||||
values: { ...config.values },
|
||||
blocks: [...bodyBlocks, ...executionBlocks],
|
||||
// Banner only in preview: an executed/signed copy must never also say the DPA does not apply.
|
||||
notice:
|
||||
context.isSelfHosted === true && context.mode !== 'signed'
|
||||
? SELF_HOSTED_NOTICE
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const findUnresolvedMergeFields = (resolved: ResolvedDpa): string[] => {
|
||||
const unresolved = new Set<string>();
|
||||
|
||||
for (const block of resolved.blocks) {
|
||||
const haystack = `${block.text}\n${block.value ?? ''}`;
|
||||
const matches = haystack.match(/\{\{[^}]+\}\}/g);
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
unresolved.add(match);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...unresolved];
|
||||
};
|
||||
@@ -14,6 +14,7 @@ export const SUPPORTED_FILE_FOLDERS = [
|
||||
FileFolder.AgentChat,
|
||||
FileFolder.EmailAttachment,
|
||||
FileFolder.AppTarball,
|
||||
FileFolder.Dpa,
|
||||
] as const;
|
||||
|
||||
export type SupportedFileFolder = (typeof SUPPORTED_FILE_FOLDERS)[number];
|
||||
|
||||
+3
@@ -63,6 +63,9 @@ export const fileFolderConfigs: Record<FileFolder, FileFolderConfig> = {
|
||||
[FileFolder.GeneratedSdkClient]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
[FileFolder.Dpa]: {
|
||||
ignoreExpirationToken: false,
|
||||
},
|
||||
};
|
||||
|
||||
export type AllowedFolders = KebabCase<keyof typeof FileFolder>;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { plainToClass } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsDefined,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -21,6 +22,7 @@ import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interface
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { DpaRegion } from 'src/engine/core-modules/dpa/enums/dpa-region.enum';
|
||||
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
|
||||
import { EmailDriver } from 'src/engine/core-modules/email/enums/email-driver.enum';
|
||||
import { EmailingDomainDriver } from 'src/engine/core-modules/emailing-domain/drivers/types/emailing-domain-driver.type';
|
||||
@@ -475,6 +477,21 @@ export class ConfigVariables {
|
||||
@IsOptional()
|
||||
IS_WORKSPACE_CREATION_LIMITED_TO_SERVER_ADMINS = true;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.SERVER_CONFIG,
|
||||
description:
|
||||
'Deployment region that determines the contracting DPA Processor entity, hosting region and governing law. EU (default) = Twenty.com SAS / Frankfurt / France; US = Twenty, Inc. / United States. Must match where Customer Personal Data actually lives.',
|
||||
type: ConfigVariableType.ENUM,
|
||||
options: Object.values(DpaRegion),
|
||||
// Deployment-fixed: must mirror where data actually lives. Allowing a
|
||||
// runtime DB/admin override could produce a legally incorrect Processor
|
||||
// entity, so this is only configurable via environment variable.
|
||||
isEnvOnly: true,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(DpaRegion)
|
||||
DPA_DEPLOYMENT_REGION: DpaRegion = DpaRegion.EU;
|
||||
|
||||
@ConfigVariablesMetadata({
|
||||
group: ConfigVariablesGroup.STORAGE_CONFIG,
|
||||
description: 'Type of storage to use (local or S3)',
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import gql from 'graphql-tag';
|
||||
import request from 'supertest';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
|
||||
// End-to-end coverage for the self-serve DPA generator. The generate mutation
|
||||
// renders the PDF with @react-pdf/renderer on the server, so this also guards
|
||||
// against the embedded-font regression ("unsupported number" on non-ASCII
|
||||
// glyphs) — the input below intentionally contains accents and an em dash.
|
||||
describe('DPA resolver (integration)', () => {
|
||||
const createdAgreementIds: string[] = [];
|
||||
|
||||
afterAll(async () => {
|
||||
for (const id of createdAgreementIds) {
|
||||
await global.testDataSource
|
||||
.query('DELETE FROM core."dpaAgreement" WHERE id = $1', [id])
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
describe('dpaPreview query', () => {
|
||||
it('resolves the deployment document with no unresolved merge fields', async () => {
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: gql`
|
||||
query DpaPreview {
|
||||
dpaPreview {
|
||||
title
|
||||
region
|
||||
processorEntity
|
||||
templateVersion
|
||||
sccSectionActive
|
||||
blocks {
|
||||
kind
|
||||
text
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const preview = response.body.data.dpaPreview;
|
||||
|
||||
expect(preview).toBeDefined();
|
||||
expect(['EU', 'US']).toContain(preview.region);
|
||||
expect(preview.processorEntity).toBeTruthy();
|
||||
expect(preview.templateVersion).toBeTruthy();
|
||||
expect(preview.blocks.length).toBeGreaterThan(0);
|
||||
|
||||
const joined = preview.blocks
|
||||
.map((block: { text: string }) => block.text)
|
||||
.join('\n');
|
||||
|
||||
expect(joined).not.toMatch(/\{\{[^}]+\}\}/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSignedDpa mutation', () => {
|
||||
it('generates a signed PDF, returns a download URL and persists the record', async () => {
|
||||
const input = {
|
||||
customerLegalEntityName: 'Société Générale — Genève',
|
||||
signatoryName: 'José Peña',
|
||||
signatoryTitle: 'Directeur Général',
|
||||
};
|
||||
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: gql`
|
||||
mutation GenerateSignedDpa($input: GenerateSignedDpaInput!) {
|
||||
generateSignedDpa(input: $input) {
|
||||
downloadUrl
|
||||
agreement {
|
||||
id
|
||||
type
|
||||
region
|
||||
processorEntity
|
||||
templateVersion
|
||||
customerLegalEntityName
|
||||
signatoryName
|
||||
signatoryTitle
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
// No errors here means renderToBuffer succeeded with the embedded font.
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
|
||||
const result = response.body.data.generateSignedDpa;
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result.downloadUrl).toBe('string');
|
||||
expect(result.downloadUrl).toContain('/file/');
|
||||
expect(result.agreement.id).toBeDefined();
|
||||
expect(result.agreement.type).toBe('SIGNED');
|
||||
expect(result.agreement.customerLegalEntityName).toBe(
|
||||
input.customerLegalEntityName,
|
||||
);
|
||||
expect(result.agreement.templateVersion).toBeTruthy();
|
||||
|
||||
createdAgreementIds.push(result.agreement.id);
|
||||
|
||||
// The stored copy is downloadable and is a real PDF.
|
||||
const downloadUrl = new URL(result.downloadUrl);
|
||||
const downloadResponse = await request(
|
||||
`http://localhost:${APP_PORT}`,
|
||||
).get(`${downloadUrl.pathname}${downloadUrl.search}`);
|
||||
|
||||
expect(downloadResponse.status).toBe(200);
|
||||
|
||||
const body = downloadResponse.body;
|
||||
const header = Buffer.isBuffer(body)
|
||||
? body.subarray(0, 5).toString()
|
||||
: '';
|
||||
|
||||
// Local storage streams the bytes; S3 would 30x-redirect. Only assert the
|
||||
// PDF magic bytes when we actually received the file body.
|
||||
if (header !== '') {
|
||||
expect(header).toBe('%PDF-');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects blank execution fields via server-side validation', async () => {
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: gql`
|
||||
mutation GenerateSignedDpa($input: GenerateSignedDpaInput!) {
|
||||
generateSignedDpa(input: $input) {
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
input: {
|
||||
customerLegalEntityName: 'Acme GmbH',
|
||||
signatoryName: '',
|
||||
signatoryTitle: 'Head of Legal',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dpaAgreements query', () => {
|
||||
it('lists executed copies with a re-download URL', async () => {
|
||||
const response = await makeGraphqlAPIRequest({
|
||||
query: gql`
|
||||
query DpaAgreements {
|
||||
dpaAgreements {
|
||||
id
|
||||
type
|
||||
templateVersion
|
||||
downloadUrl
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(Array.isArray(response.body.data.dpaAgreements)).toBe(true);
|
||||
|
||||
const signed = response.body.data.dpaAgreements.find(
|
||||
(agreement: { id: string }) =>
|
||||
createdAgreementIds.includes(agreement.id),
|
||||
);
|
||||
|
||||
expect(signed).toBeDefined();
|
||||
expect(signed.type).toBe('SIGNED');
|
||||
expect(typeof signed.downloadUrl).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,9 @@ export enum AppPath {
|
||||
|
||||
Authorize = '/authorize',
|
||||
|
||||
// Deep link for twenty.com/dpa → in-app DPA generator (login-gated redirect).
|
||||
Dpa = '/dpa',
|
||||
|
||||
// 404 page not found
|
||||
NotFoundWildcard = '*',
|
||||
NotFound = '/not-found',
|
||||
|
||||
@@ -16,4 +16,5 @@ export enum FileFolder {
|
||||
EmailAttachment = 'email-attachment',
|
||||
AppTarball = 'app-tarball',
|
||||
GeneratedSdkClient = 'generated-sdk-client',
|
||||
Dpa = 'dpa',
|
||||
}
|
||||
|
||||
@@ -93,4 +93,8 @@ export enum SettingsPath {
|
||||
RoleDetail = 'members/roles/:roleId',
|
||||
RoleObjectLevel = 'members/roles/:roleId/object/:objectMetadataId',
|
||||
RoleAddObjectLevel = 'members/roles/:roleId/add-object-permission',
|
||||
|
||||
Legal = 'legal',
|
||||
LegalDpa = 'legal/dpa',
|
||||
LegalDpaNew = 'legal/dpa/new',
|
||||
}
|
||||
|
||||
@@ -53096,6 +53096,7 @@ __metadata:
|
||||
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-graphql-npm-9.4.0-8e6f7894e1.patch"
|
||||
"@ptc-org/nestjs-query-typeorm": "patch:@ptc-org/nestjs-query-typeorm@npm%3A9.4.0#~/.yarn/patches/@ptc-org-nestjs-query-typeorm-npm-9.4.0-ca3414967e.patch"
|
||||
"@react-email/render": "npm:^1.2.3"
|
||||
"@react-pdf/renderer": "npm:^4.1.6"
|
||||
"@sentry/nestjs": "npm:^10.59.0"
|
||||
"@sentry/node": "npm:^10.59.0"
|
||||
"@sentry/profiling-node": "npm:^10.59.0"
|
||||
|
||||
Reference in New Issue
Block a user