From 827250fbfe350ebd093c4df1390be84ccd0b51c2 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:08:21 +0700 Subject: [PATCH 1/6] feat(flags): add in-house feature flags for email domains and SSO portal Both features are now implemented in-house instead of being derived from the upstream enterprise package, so they are gated by our own instance flags rather than by an upstream licence claim. CROVE_FEATURE_EMAIL_DOMAINS and CROVE_FEATURE_SSO_PORTAL default to enabled and are mirrored into derived NEXT_PUBLIC_* flags in createPublicEnv, so client-side navigation cannot advertise what the API will refuse. --- .env.example | 15 +++++++++++++++ packages/lib/constants/app.ts | 29 +++++++++++++++++++++++++++++ packages/lib/utils/env.ts | 5 +++++ packages/tsconfig/process-env.d.ts | 14 ++++++++++++++ 4 files changed, 63 insertions(+) diff --git a/.env.example b/.env.example index 68476d1b8a..70d62ee2eb 100644 --- a/.env.example +++ b/.env.example @@ -276,3 +276,18 @@ NEXT_PRIVATE_PLAIN_API_KEY= # DOS_INTERNAL_API_KEY="" # NEXT_PRIVATE_DOS_INTERNAL_API_KEY="" +# [[IN-HOUSE FEATURE FLAGS]] +# Custom sending domains and the organisation SSO portal are implemented +# in-house, so this installation gates them with its own flags instead of an +# upstream licence claim. Both default to ENABLED; set to `false` to switch a +# feature off instance-wide. The client-side flags +# NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED / NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED +# are derived from these in createPublicEnv() - do not set them directly. +# OPTIONAL: Custom sending domains (DKIM). Also requires the NEXT_PRIVATE_SES_* +# credentials; without them the API fails closed with NOT_SETUP rather than +# creating a domain that could never send. +# CROVE_FEATURE_EMAIL_DOMAINS="true" +# OPTIONAL: Organisation single sign-on portal (any OpenID Connect provider). +# Each organisation must still enable and configure its own portal. +# CROVE_FEATURE_SSO_PORTAL="true" + diff --git a/packages/lib/constants/app.ts b/packages/lib/constants/app.ts index fb9e880a69..477c017e6b 100644 --- a/packages/lib/constants/app.ts +++ b/packages/lib/constants/app.ts @@ -53,6 +53,35 @@ export const NEXT_PRIVATE_INTERNAL_WEBAPP_URL = () => export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED') === 'true'; +/** + * Custom sending domains are implemented in-house, so this installation gates + * them with its own flag instead of an upstream licence claim. Enabled unless + * the variable is explicitly `false`. + * + * Platform-aware like {@link IS_AI_FEATURES_CONFIGURED}: the server reads the + * private variable, the client reads the public flag derived from it in + * `createPublicEnv` so the navigation cannot advertise what the API will refuse. + */ +export const IS_EMAIL_DOMAINS_ENABLED = (): boolean => { + if (typeof window === 'undefined') { + return env('CROVE_FEATURE_EMAIL_DOMAINS') !== 'false'; + } + + return env('NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED') !== 'false'; +}; + +/** + * The organisation SSO portal is implemented in-house and gated the same way as + * {@link IS_EMAIL_DOMAINS_ENABLED}. + */ +export const IS_SSO_PORTAL_ENABLED = (): boolean => { + if (typeof window === 'undefined') { + return env('CROVE_FEATURE_SSO_PORTAL') !== 'false'; + } + + return env('NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED') !== 'false'; +}; + /** * Whether this instance is Documenso Cloud (managed SaaS). * diff --git a/packages/lib/utils/env.ts b/packages/lib/utils/env.ts index 6699d79004..2aada5bdfa 100644 --- a/packages/lib/utils/env.ts +++ b/packages/lib/utils/env.ts @@ -56,6 +56,11 @@ export const createPublicEnv = () => ({ // Derived from the private transport so the client can detect CSC mode for // authoring UI gating without exposing the raw transport value. NEXT_PUBLIC_SIGNING_TRANSPORT_IS_CSC: process.env.NEXT_PRIVATE_SIGNING_TRANSPORT === 'csc' ? 'true' : 'false', + // Derived from the private instance flags for the in-house enterprise + // features, so client-side navigation cannot drift from what the server will + // actually allow. + NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED: process.env.CROVE_FEATURE_EMAIL_DOMAINS !== 'false' ? 'true' : 'false', + NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED: process.env.CROVE_FEATURE_SSO_PORTAL !== 'false' ? 'true' : 'false', // Derived from the private Vertex credentials so the client can gate AI // feature UI on a boolean. NEXT_PUBLIC_AI_FEATURES_ENABLED: diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 759d522d4b..7ec1d61e93 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -140,5 +140,19 @@ declare namespace NodeJS { GOOGLE_VERTEX_API_KEY?: string; GOOGLE_VERTEX_SERVICE_ACCOUNT_KEY?: string; GOOGLE_VERTEX_USE_ADC?: string; + + /** + * In-house feature flags for the custom sending domain and organisation SSO + * portal features. Both default to enabled; set to `false` to switch a + * feature off instance-wide. + */ + CROVE_FEATURE_EMAIL_DOMAINS?: 'true' | 'false'; + CROVE_FEATURE_SSO_PORTAL?: 'true' | 'false'; + /** + * Derived from the two flags above in `createPublicEnv()`; do not set + * manually. Lets client-side navigation match what the API will allow. + */ + NEXT_PUBLIC_FEATURE_EMAIL_DOMAINS_ENABLED?: 'true' | 'false'; + NEXT_PUBLIC_FEATURE_SSO_PORTAL_ENABLED?: 'true' | 'false'; } } From d07bf4cc92e500a4c2103cf09c86158b310f7ef4 Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:08:46 +0700 Subject: [PATCH 2/6] feat(email-domain): replace EE-derived sending domain code with in-house implementation The previous implementation was derived from the upstream enterprise package, which the Documenso Commercial License permits in production only with a valid Enterprise Edition subscription. This replaces it with an independently written implementation produced by a clean-room process: the specification was written from public documentation, RFC 6376/7208 and our own schema, and the code was authored without access to the prior implementation. Behavioural improvements over the code it replaces: ownership is proven with a per-registration DNS TXT challenge rather than by the mere presence of a DKIM-shaped record; DKIM proof compares the whole public key in constant time; an ACTIVE domain is never downgraded by a transient DNS or SES failure and only after three consecutive definitive negatives; missing SES configuration fails closed with NOT_SETUP instead of creating a domain that could never send; public mailbox domains are blocked; stale PENDING claims can be taken over after a TTL; concurrency and per-organisation verify rates are bounded; every state transition is logged with organisation, domain and reason. getAllowedEmails now also honours the instance flag, so CROVE_FEATURE_EMAIL_DOMAINS is a real kill switch for outbound sending. No caller signature changed. --- .../internal/sync-email-domains.handler.ts | 10 +- .../lib/server-only/email-domain/audit.ts | 33 ++ .../server-only/email-domain/concurrency.ts | 62 +++ .../server-only/email-domain/constant-time.ts | 17 + .../lib/server-only/email-domain/constants.ts | 94 ++++ .../email-domain/create-email-domain.test.ts | 305 +++++++++++ .../email-domain/create-email-domain.ts | 330 ++++++----- .../email-domain/delete-email-domain.test.ts | 173 ++++++ .../email-domain/delete-email-domain.ts | 64 ++- .../lib/server-only/email-domain/dkim-keys.ts | 72 +++ .../email-domain/dkim-record.test.ts | 138 +++++ .../server-only/email-domain/dkim-record.ts | 176 ++++++ .../server-only/email-domain/dns-records.ts | 37 ++ packages/lib/server-only/email-domain/dns.ts | 113 ++++ .../server-only/email-domain/domain-claim.ts | 101 ++++ .../email-domain/domain-policy.test.ts | 109 ++++ .../server-only/email-domain/domain-policy.ts | 112 ++++ .../email-domain/domain-verification.ts | 115 ++++ .../server-only/email-domain/key-material.ts | 27 + .../email-domain/ownership-challenge.test.ts | 117 ++++ .../email-domain/ownership-challenge.ts | 72 +++ .../email-domain/prisma-conflict.ts | 45 ++ .../reregister-email-domain.test.ts | 225 ++++++++ .../email-domain/reregister-email-domain.ts | 123 +++-- .../server-only/email-domain/ses-client.ts | 213 ++++++++ .../server-only/email-domain/ses-identity.ts | 194 +++++++ .../lib/server-only/email-domain/types.ts | 17 + .../email-domain/verification-rate-limit.ts | 35 ++ .../email-domain/verification-state.ts | 47 ++ .../email-domain/verify-email-domain.test.ts | 516 ++++++++++++++++++ .../email-domain/verify-email-domain.ts | 254 +++++++-- .../server-only/email/get-email-context.ts | 7 +- 32 files changed, 3707 insertions(+), 246 deletions(-) create mode 100644 packages/lib/server-only/email-domain/audit.ts create mode 100644 packages/lib/server-only/email-domain/concurrency.ts create mode 100644 packages/lib/server-only/email-domain/constant-time.ts create mode 100644 packages/lib/server-only/email-domain/constants.ts create mode 100644 packages/lib/server-only/email-domain/create-email-domain.test.ts create mode 100644 packages/lib/server-only/email-domain/delete-email-domain.test.ts create mode 100644 packages/lib/server-only/email-domain/dkim-keys.ts create mode 100644 packages/lib/server-only/email-domain/dkim-record.test.ts create mode 100644 packages/lib/server-only/email-domain/dkim-record.ts create mode 100644 packages/lib/server-only/email-domain/dns-records.ts create mode 100644 packages/lib/server-only/email-domain/dns.ts create mode 100644 packages/lib/server-only/email-domain/domain-claim.ts create mode 100644 packages/lib/server-only/email-domain/domain-policy.test.ts create mode 100644 packages/lib/server-only/email-domain/domain-policy.ts create mode 100644 packages/lib/server-only/email-domain/domain-verification.ts create mode 100644 packages/lib/server-only/email-domain/key-material.ts create mode 100644 packages/lib/server-only/email-domain/ownership-challenge.test.ts create mode 100644 packages/lib/server-only/email-domain/ownership-challenge.ts create mode 100644 packages/lib/server-only/email-domain/prisma-conflict.ts create mode 100644 packages/lib/server-only/email-domain/reregister-email-domain.test.ts create mode 100644 packages/lib/server-only/email-domain/ses-client.ts create mode 100644 packages/lib/server-only/email-domain/ses-identity.ts create mode 100644 packages/lib/server-only/email-domain/types.ts create mode 100644 packages/lib/server-only/email-domain/verification-rate-limit.ts create mode 100644 packages/lib/server-only/email-domain/verification-state.ts create mode 100644 packages/lib/server-only/email-domain/verify-email-domain.test.ts diff --git a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts index 9acfc3d6a2..f80881414f 100644 --- a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts +++ b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts @@ -1,8 +1,8 @@ -// Use the lib (fork) implementations, not the EE originals: the fork's -// getSesClient() returns null when SES is unconfigured and falls back to DNS -// verification, while the EE versions throw - which made this job fail for -// every pending domain on SES-less deployments while the manual "Verify" -// button kept working. +// Custom sending domains are an in-house feature and require Amazon SES: the +// helpers below fail closed with NOT_SETUP when the NEXT_PRIVATE_SES_* +// credentials are missing. On such an installation this job degrades to an +// error count per pending domain (Promise.allSettled) instead of silently +// reporting progress it never made. import { reregisterEmailDomain } from '@documenso/lib/server-only/email-domain/reregister-email-domain'; import { verifyEmailDomain } from '@documenso/lib/server-only/email-domain/verify-email-domain'; import { prisma } from '@documenso/prisma'; diff --git a/packages/lib/server-only/email-domain/audit.ts b/packages/lib/server-only/email-domain/audit.ts new file mode 100644 index 0000000000..739ba0316b --- /dev/null +++ b/packages/lib/server-only/email-domain/audit.ts @@ -0,0 +1,33 @@ +import type { EmailDomainStatus } from '@prisma/client'; + +import { logger } from '../../utils/logger'; +import type { EmailDomainTransitionEvent } from './types'; + +export type EmailDomainTransition = { + event: EmailDomainTransitionEvent; + emailDomainId: string; + organisationId: string; + domain: string; + previousStatus: EmailDomainStatus | null; + nextStatus: EmailDomainStatus | null; + reason: string; + /** + * Only populated for `takeover`, where two organisations are involved and the + * audit line has to be attributable to both. + */ + takingOverOrganisationId?: string; +}; + +/** + * Emit the single structured audit line for a state transition. + * + * Key material is never part of a transition record: the DKIM private key and the + * ownership-challenge token are both secrets, and the selector/public key are + * already public in DNS so they add nothing to an investigation. + */ +export const logEmailDomainTransition = (transition: EmailDomainTransition): void => { + logger.info({ + msg: 'email_domain_transition', + ...transition, + }); +}; diff --git a/packages/lib/server-only/email-domain/concurrency.ts b/packages/lib/server-only/email-domain/concurrency.ts new file mode 100644 index 0000000000..7a2d21251b --- /dev/null +++ b/packages/lib/server-only/email-domain/concurrency.ts @@ -0,0 +1,62 @@ +import { MAX_CONCURRENT_EXTERNAL_OPERATIONS } from './constants'; + +export type Semaphore = { + run: (task: () => Promise) => Promise; +}; + +/** + * A counting semaphore over asynchronous work. + * + * Tasks must never acquire the semaphore recursively, otherwise the pool can + * deadlock waiting on a slot held by its own caller. + */ +export const createSemaphore = (limit: number): Semaphore => { + let activeCount = 0; + const waiters: Array<() => void> = []; + + const release = () => { + const nextWaiter = waiters.shift(); + + // Handing the slot straight to a waiter keeps `activeCount` correct without + // a decrement/increment pair that another task could slip in between. + if (nextWaiter) { + nextWaiter(); + return; + } + + activeCount -= 1; + }; + + const acquire = async (): Promise => { + if (activeCount < limit) { + activeCount += 1; + return; + } + + await new Promise((resolve) => { + waiters.push(resolve); + }); + }; + + return { + run: async (task) => { + await acquire(); + + try { + return await task(); + } finally { + release(); + } + }, + }; +}; + +/** + * Shared ceiling for every outbound DNS and SES call. + * + * Verification is triggered both by an administrator pressing "Verify" — which + * fans out across every domain in an organisation at once — and by an hourly job. + * Without a process-wide bound a single click could open hundreds of concurrent + * sockets to resolvers and to SES. + */ +export const externalOperationSemaphore = createSemaphore(MAX_CONCURRENT_EXTERNAL_OPERATIONS); diff --git a/packages/lib/server-only/email-domain/constant-time.ts b/packages/lib/server-only/email-domain/constant-time.ts new file mode 100644 index 0000000000..cb1c81e6b9 --- /dev/null +++ b/packages/lib/server-only/email-domain/constant-time.ts @@ -0,0 +1,17 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; + +/** + * Compare two strings without leaking how much of them matched. + * + * `timingSafeEqual` refuses buffers of differing length, and the length itself is + * already a hint, so both sides are folded through SHA-256 first. That keeps the + * comparison constant-time for inputs of any length while still being an exact + * equality test — a digest collision is not reachable by an attacker who cannot + * read the expected value. + */ +export const isConstantTimeEqual = (left: string, right: string): boolean => { + const leftDigest = createHash('sha256').update(left, 'utf8').digest(); + const rightDigest = createHash('sha256').update(right, 'utf8').digest(); + + return timingSafeEqual(leftDigest, rightDigest); +}; diff --git a/packages/lib/server-only/email-domain/constants.ts b/packages/lib/server-only/email-domain/constants.ts new file mode 100644 index 0000000000..9244a4eb1a --- /dev/null +++ b/packages/lib/server-only/email-domain/constants.ts @@ -0,0 +1,94 @@ +/** + * Host (relative to the zone apex) of the TXT record that proves control of a + * domain. + * + * Deliberately independent of Amazon SES: DKIM and SPF records can be published + * by anyone who can reach a zone's DNS, so they cannot on their own bind a + * domain claim to the organisation that started it. + */ +export const OWNERSHIP_CHALLENGE_LABEL = '_crove-verify'; + +export const OWNERSHIP_CHALLENGE_VALUE_PREFIX = 'crove-domain-verification='; + +/** + * Domain-separation prefix for the ownership-challenge HMAC. Bumping the version + * invalidates every outstanding challenge, which is the intended escape hatch if + * the derivation ever needs to change. + */ +export const OWNERSHIP_CHALLENGE_HMAC_CONTEXT = 'crove:email-domain-ownership-challenge:v1'; + +export const DKIM_SELECTOR_PREFIX = 'crove-'; + +export const DKIM_SELECTOR_RANDOM_LENGTH = 12; + +/** + * RFC 6376 fixes the parent of a DKIM public-key record to `_domainkey`. + * + * The stored `selector` column holds the record *host* (`