@domandigital/synthetic

中文文档
0.2.2发布于 2026/11/02Apache-2.0TypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 5140

Signed synthetic-submission protocol for Doman Digital client forms: sign and verify, replay guard, recipient lock, Turnstile config self-check, beacons and purge receipts. Web Crypto only.

原始使用文档
文档目录10 节
  1. Install
  2. In a form handler
  3. Rotation
  4. Signing (dd-checks)
  5. The wire format
  6. Recipient lock
  7. Turnstile config self-check
  8. Beacons and purge receipts
  9. Keeping the headers out of Sentry and logs
  10. Development

@domandigital/synthetic

The signed synthetic-submission protocol for Doman Digital client forms. DD Checks (dd-checks) submits a real enquiry through a client's real form handler on a schedule; Turnstile blocks synthetic browsers, so the handler accepts a signed request that skips only the Turnstile token.

Web Crypto only, no dependencies: it runs in Cloudflare Workers, Node 22+ and the browser-side signing helpers in dd-checks. Tests run in both Node and workerd.

The protocol is specified in the "DD Checks: plan" document on DOM-487, section 3. This package is that section as code.

Install

pnpm add @domandigital/synthetic

In a form handler

import {
  verifyRequest, keysFromEnv, turnstileConfigCheck, assertCanaryRecipient,
  syntheticReport, sendBeacon, upstashReplayGuard,
} from "@domandigital/synthetic";

export async function POST(request: Request) {
  const verified = await verifyRequest(request, {
    keys: keysFromEnv(env),                 // empty => synthetic path off
    host: "www.example.co.uk",              // the public host
    path: "/api/enquiry",
    contentTypes: ["application/json"],
    replayGuard: upstashReplayGuard({ url: env.UPSTASH_REDIS_REST_URL, token: env.UPSTASH_REDIS_REST_TOKEN }),
  });

  if (!verified.ok && verified.rejected) {
    // Handle as an ordinary request (Turnstile still applies) and tell dd-checks.
    ctx.waitUntil(sendBeacon(reporter, { form: "enquiry", code: "synthetic_rejected", reason: verified.reason }));
  }

  const synthetic = verified.ok ? verified.synthetic : null; // frozen; the only source of truth
  // 1. same-origin  2. schema validation
  // 3. synthetic ? turnstileConfigCheck({...}) : verify the Turnstile token
  // 4. the real rate limiter, identifier `synthetic:<form>` when synthetic
  // 5. full mode only: DB write (record_origin='synthetic', synthetic_run_id, purge_after),
  //    staff email redirected to canaryAddress("s", runId); submitter email to
  //    canaryAddress("u", runId) on the daily run only. assertCanaryRecipient() first.
  // 6. skip SMS, CRM, marketing lists, trackLead; report them as "skipped".
  // 7. respond with { ..., synthetic: syntheticReport(synthetic, stages) } only when synthetic
}

Rules the code enforces for you:

  • Off by default. Without DD_SYNTHETIC_SECRET (and DD_SYNTHETIC_KID) every request is ordinary.
  • Shape before signature. Method, host, path and content type are checked before the body is hashed.
  • A rejected request is an ordinary request, never a failure. verify never throws.
  • The replay guard is required and runs after the signature, so a forged request cannot burn a real run id.
  • The body and query are never read for synthetic. The SyntheticContext is built only from verified headers and is frozen.

Replay guard options: upstashReplayGuard (SET synth:<runId> NX EX 300), or your own function over a unique constraint on synthetic_run_id:

replayGuard: async (runId) => {
  try { await db.insert(syntheticRuns).values({ syntheticRunId: runId }); return true; }
  catch (e) { if (isUniqueViolation(e)) return false; throw e; }
}

Rotation

A site holds at most two keys: DD_SYNTHETIC_SECRET + DD_SYNTHETIC_KID, and DD_SYNTHETIC_SECRET_NEXT + DD_SYNTHETIC_KID_NEXT. Set the new key as _NEXT with a new kid, switch the platform over, promote it, then remove the old one. An unknown kid is rejected. The secret is 32 random bytes, base64url:

node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"

Signing (dd-checks)

const { headers, runId } = await sign({
  key: { kid: "example-2026-10", secret },
  url: "https://www.example.co.uk/api/enquiry",
  contentType: "application/json",
  body: rawBodyString,
  client: "example", form: "enquiry", mode: "probe",
});

runId is 16 characters of [a-z2-7], generated fresh for every call from the CSPRNG. It is the replay nonce and goes into the canary address, so every run is unique and no idempotency key on the client's side can dedupe one.

The wire format

Headers: X-DD-Synth: v1, X-DD-Synth-Kid, -Ts (unix seconds), -Run, -Mode (probe or full), -Client, -Form, -Sig: v1=<hex>.

The signature is HMAC-SHA256 with the 32-byte secret over:

DD-SYNTH-V1\n<kid>\n<ts>\n<runId>\n<mode>\n<client>\n<form>\nPOST\n<host>\n<path>\n<content-type>\n<hex sha256(raw body)>

host is lower-case and includes a port only if the URL has one. path has the query string removed. content-type is the header exactly as sent (a multipart boundary is part of it). Window: ±120 s. Comparison: constant time.

Test vectors are published in test-vectors.json (@domandigital/synthetic/test-vectors.json): a test secret and, per case, the inputs, the canonical string, the body hash and the expected signature. They are generated with node:crypto by scripts/gen-vectors.mjs, independent of this package's code. Any other implementation (a Python checker, a Go service) must reproduce them.

Recipient lock

assertCanaryRecipient(submitterEmail);   // throws SyntheticRecipientError

Only @canary.domandigital.co.uk passes, as one plain address. Display names, lists, subdomains and look-alike domains all fail. Call it before anything is sent.

Turnstile config self-check

turnstileConfigCheck({ siteKey, secretKey, expectedHostname, publicHost }) replaces the token on a verified synthetic request. It returns { ok, outcome }, with outcome one of:

outcomemeaning
okkeys present, hostname right, secret accepted by siteverify
missing_keysa key or TURNSTILE_EXPECTED_HOSTNAME is unset
hostname_mismatchTURNSTILE_EXPECTED_HOSTNAME, normalised, is not the public host (the failure that went unnoticed for weeks on a client site)
test_key_on_productiona Cloudflare test key on a production host
invalid-input-secretsiteverify says the secret is wrong
siteverify_unexpectedsiteverify answered something else (for example success for a dummy token on production)
siteverify_unreachablenetwork error, timeout or non-JSON; says nothing about the config

Hostnames compare lower-case with scheme, port, path and trailing dot removed; www. is kept, because it is a different host. Staging hosts accept the test keys, since staging browser journeys use Cloudflare's always-pass keys.

Beacons and purge receipts

const reporter = { key, client: "example", waitUntil: ctx.waitUntil.bind(ctx) };
sendBeacon(reporter, { form: "enquiry", code: "accepted" });
sendPurgeReceipt(reporter, { form: "enquiry", deleted: 24, remaining: 0 });

Both are signed with the site's key (-Mode: beacon / receipt), post to https://checks.domandigital.co.uk/beacon and /purge-receipt, are fire-and-forget, and never throw or reject: a bad argument is dropped. A beacon holds codes only (accepted, hostname_mismatch, invalid-input-secret, misconfigured, service_unavailable, delivery_failed, captcha_failed, synthetic_rejected plus a reason), and never personal data.

Keeping the headers out of Sentry and logs

Sentry.init({ beforeSend: scrubSentryEvent, beforeSendTransaction: scrubSentryEvent });
logger.info(scrubText(headerDump));
const safe = scrubHeaders(request.headers);

Development

pnpm --filter @domandigital/synthetic test    # Node, then workerd

版本与发布记录

@domandigital/synthetic 共发布 5 个版本,首次发布于 2026/10/08;过去 12 个月发布了 5 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.2.2latest2026/11/0211 天前
0.2.12026/10/1925 天前
0.2.02026/10/101 个月前
0.1.02026/10/081 个月前
0.0.0-stage2026/10/081 个月前

关于 @domandigital/synthetic 的常见问题

@domandigital/synthetic 是什么?

Signed synthetic-submission protocol for Doman Digital client forms: sign and verify, replay guard, recipient lock, Turnstile config self-check, beacons and purge receipts. Web Crypto only.

如何安装 @domandigital/synthetic?

在项目目录运行 npm install @domandigital/synthetic,或使用 pnpm add / yarn add / bun add。

@domandigital/synthetic 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@domandigital/synthetic 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@domandigital/synthetic 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.12。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@domandigital/synthetic 可以商用吗?

@domandigital/synthetic 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@domandigital/synthetic 的下载量有多少?

根据 npm 官方下载统计,@domandigital/synthetic 在 2026/10/13 – 2026/11/11 这 30 天内被下载 5,140 次,日均约 171 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@domandigital/synthetic 还在维护吗?

最新版本 0.2.2 发布于 2026/11/02(11 天前);至今共发布 5 个版本,其中过去 12 个月发布了 5 个;首次发布于 2026/10/08。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。