feat(sync): cn event coverage, bootstrap and outbox sender

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 15:21:41 +08:00
parent 3d6e9ce175
commit 65726702f3
43 changed files with 3333 additions and 181 deletions
+79
View File
@@ -0,0 +1,79 @@
// packages/contracts/src/internal-sync.ts —— /internal/** 服务间同步接口的
// HMAC 鉴权单一实现(P2 Task 3)。签名规则(openapi.yaml [internal] tag 描述):
// X-Sync-Signature = hex(HMAC-SHA256(原始请求体字节, SYNC_INTERNAL_KEY))
// X-Sync-Timestamp = Unix 秒,与服务端时差 ±300s 内有效(防重放)
// 发送方(services/sync sender)与接收方(cn bootstrap/reconcile、ov
// content-batches 的 InternalSyncGuard)共用本模块,杜绝两侧规则漂移。
// 注意签名覆盖的是**原始请求体字节**(rawBody):接收端必须经 body-parser 的
// verify 钩子(NestFactory rawBody: true)在 JSON 解析前捕获原始字节,重序列
// 化的 body 与签名不一致会 401。
import { createHmac, timingSafeEqual } from 'node:crypto';
/** 内部同步 HMAC 密钥的 env 键名(仅 env 注入,不入库不入码)。 */
export const SYNC_INTERNAL_KEY_ENV = 'SYNC_INTERNAL_KEY';
/** HMAC 签名/时间戳请求头(小写;Node req.headers 已归一小写)。 */
export const SYNC_SIGNATURE_HEADER = 'x-sync-signature';
export const SYNC_TIMESTAMP_HEADER = 'x-sync-timestamp';
/** 时间戳容忍窗(秒,防重放;契约 [internal] tag 描述 ±300s)。 */
export const SYNC_TIMESTAMP_TOLERANCE_SECONDS = 300;
/**
* 计算请求体签名:hex(HMAC-SHA256(rawBody, key))。
* rawBody 必须是与线上传输完全一致的原始字节(发送侧:JSON.stringify 的产物
* 原样作为 fetch body;接收侧:body-parser verify 捕获的 Buffer)。
*/
export function signSyncBody(
key: string,
rawBody: string | Buffer,
): string {
return createHmac('sha256', key).update(rawBody).digest('hex');
}
/** 验签输入:头取值原样传入(数组/缺失由本函数判 false,不抛错)。 */
export interface VerifySyncSignatureInput {
key: string;
rawBody: Buffer | string;
timestampHeader: unknown;
signatureHeader: unknown;
/** 可注入时钟(测试用);缺省 Date.now()。 */
nowMs?: number;
}
/**
* 接收端验签:时间戳在窗内 + 签名 hex(64) 形状 + 常时比较全过才 true。
* key 未配置(空串)恒 false——密钥缺失时内部端点整体不可用而非裸奔。
*/
export function verifySyncSignature(input: VerifySyncSignatureInput): boolean {
const { key, rawBody, timestampHeader, signatureHeader } = input;
if (key === '') {
return false;
}
const timestamp =
typeof timestampHeader === 'string'
? Number(timestampHeader.trim())
: Number.NaN;
if (!Number.isFinite(timestamp)) {
return false;
}
const nowSeconds = Math.floor((input.nowMs ?? Date.now()) / 1000);
if (
Math.abs(nowSeconds - timestamp) > SYNC_TIMESTAMP_TOLERANCE_SECONDS
) {
return false;
}
if (
typeof signatureHeader !== 'string' ||
!/^[0-9a-f]{64}$/.test(signatureHeader.trim())
) {
return false;
}
const expected = signSyncBody(key, rawBody);
const provided = signatureHeader.trim();
// 两串均为 64 hex 字符(上方正则保证),等长 Buffer,timingSafeEqual 安全。
return timingSafeEqual(
Buffer.from(expected, 'hex'),
Buffer.from(provided, 'hex'),
);
}