Phần 3 · Chương 3.5
Webhook sống sót qua phía khách hàng
Bạn sẽ tạo ra: Một dispatcher service: ký HMAC, lịch retry theo thời điểm tới hạn, dead letter · khoảng 100 phút, bao gồm bài tập
Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm (tiếng Anh)
Đây là toàn bộ handler của chương 3.4:
export async function handleEvent(event: EventEnvelope, logger: Logger) {
logger.log("info", "event.handled", { event_id: event.id, type: event.type });
}Khi ấy nó được gọi là scaffold, và từ này được lựa chọn rất cẩn thận. Mọi guarantee mà chương 3.4 chứng minh — claim event, chạy effect, commit cả hai cùng lúc để một cú crash rollback claim cùng với công việc — đều được chứng minh trên một effect chỉ ghi một dòng ra standard output.
Chương này thay nó bằng một effect nằm trên cỗ máy của người khác.
Chỉ một thay đổi ấy đã khiến pattern không còn áp dụng được ở hai nơi riêng biệt, và không trường hợp nào là bug cần sửa. Effect không thể tham gia transaction của claim nữa vì nó xảy ra trên một server không thuộc quyền kiểm soát của chúng ta. Claim cũng không còn do chính đoạn code thực hiện công việc tạo ra, bởi Constitution IV dành riêng quyền ghi PostgreSQL cho API service — còn component gửi request lại không phải API.
Pattern không còn áp dụng được — ở hai nơi
Lập luận về tính đúng đắn của chương 3.4 chỉ có một moving part. Claim và effect nằm trong cùng một transaction nên không hề có khoảng trống giữa chúng:
// 3.4: the claim and the effect commit together or not at all.
await claimEvent(db, CONSUMER, event.id, async () => {
await handleEvent(event, logger);
});Bây giờ hãy thử viết lại đoạn này với một HTTP request ở bên trong. Lệnh await
ở giữa là một POST tới server của khách hàng. Request đã xảy ra trước thời điểm
transaction commit, còn rollback transaction không thể thu hồi nó. Không cách
sắp xếp nào biến hai operation này thành atomic, bởi một operation không thuộc
quyền rollback của chúng ta.
Vì vậy câu hỏi đã thay đổi. Khoảng trống không thể khép lại, nên chỉ còn một điều phải quyết định: ta chấp nhận sai theo hướng nào:
| Thứ tự | Cái giá của một cú crash trong khoảng trống |
|---|---|
| claim, rồi post | webhook mất lặng lẽ. Khách hàng không bao giờ nhận được và không ai biết |
| post, rồi claim | webhook gửi hai lần. Khách hàng nhận bản trùng và có thể nhận ra |
sequenceDiagram
participant B as Broker (DELIVERIES)
participant D as Dispatcher
participant C as Server của khách hàng
participant A as api service
Note over D,A: 3.4 claim work và chạy effect trong MỘT<br/>transaction. Ở đây không có nửa nào dùng được:<br/>effect nằm trên cỗ máy ta không sở hữu, còn<br/>claim là một lời gọi sang service khác.
D->>C: POST /hook · đã ký
C-->>D: 200
Note over D,A: KHOẢNG HỞ — khách hàng đã có webhook,<br/>nền tảng chưa ghi nhận,<br/>và chưa có gì báo sai
D->>A: report outcome
A-->>D: delivered
D--xB: ack
Note over D: nếu process chết trong khoảng hở,<br/>delivery được gửi lại và POST THÊM LẦN NỮA.<br/>Khách hàng hấp thụ nó bằng event idNền tảng chọn chấp nhận duplicate. Một mất mát không ai phát hiện được tệ hơn một duplicate mà recipient đã được cung cấp cách xử lý. Chương 3.3 đã dành trọn một chương để loại bỏ đúng loại failure đầu tiên khỏi publish path; đưa nó trở lại ở last hop — nơi khách hàng thực sự cảm nhận hậu quả — sẽ phá bỏ thành quả ấy ngay tại điểm quan trọng nhất.
Quyết định ấy đi kèm một nghĩa vụ, và chính nghĩa vụ này khiến nó trở thành lựa chọn trung thực thay vì chỉ tiện lợi. Nếu chọn at-least-once, bạn phải trao cho recipient phương tiện để deduplicate. Event id nằm trong envelope, được ghi rõ trong documentation và giữ nguyên qua mọi attempt của cùng một delivery. Khách hàng lưu id rồi bỏ qua duplicate là hành vi đúng; nếu không làm vậy, họ đã được thông báo rõ mình đang lựa chọn điều gì.
// claim BEFORE posting → a crash in the gap loses the webhook silently.
// post BEFORE reporting → a crash in the gap re-posts. The customer receives
// it twice and CAN tell, because the envelope carries
// the event id they deduplicate on.Endpoint và secret bắt buộc phải khôi phục được
Một endpoint gồm URL, danh sách event type và một signing secret. Secret là column đáng chú ý, bởi đây là giá trị đầu tiên trong nền tảng bắt buộc phải khôi phục được ở dạng plaintext.
Chương 3.2 lưu API key dưới dạng salted hash, và cách đó hoàn toàn phù hợp: key được trình ra để so sánh chứ không bao giờ cần tái tạo. Signing secret thì khác. Dispatcher phải dùng chính secret để tính HMAC ở mỗi attempt; một hash không thể dùng để ký.
Vì vậy secret dùng envelope encryption chứ không phải hashing — đúng hai nhánh mà NFR-SEC-02 đã dự liệu: một cho credential cần so sánh và một cho secret cần được sử dụng:
import {
createCipheriv,
createDecipheriv,
randomBytes,
} from "node:crypto";
// Webhook signing secrets at rest (chapter 3.5).
//
// DECISION (chapter 3.5): envelope encryption, not the salted hash chapter 3.2
// used for API keys. NFR-SEC-02 permits either — "salted hashes or under
// envelope encryption" — and this is the branch that applies, for a reason worth
// stating because the two credentials look so alike:
//
// an API key is VERIFIED. A caller presents it, we hash what arrived and
// compare digests. The original is never needed again, so keeping it would be
// a liability with no upside.
//
// a signing secret is USED. Every delivery computes an HMAC with it. A hash
// cannot be used, only compared — so hashing it here would not be "more
// secure", it would make the feature impossible.
//
// The cost is real and is not hidden: this is the first customer credential the
// platform can turn back into plaintext. That raises obligations a hash did not.
// The key lives in configuration and never in the database (a key stored beside
// the ciphertext it protects is a filing convention, not encryption), the
// plaintext exists only in memory and only for the duration of a signature, and
// it appears in no log line at any level (NFR-SEC-06).
const ALGORITHM = "aes-256-gcm";
const KEY_BYTES = 32;
const IV_BYTES = 12; // 96 bits, the size GCM is specified for
const TAG_BYTES = 16;
const SECRET_BYTES = 32; // 256 bits, the budget 3.2 gave an API key secret
/** The stored value is `iv | tag | ciphertext`, base64. One column, no schema
* for the reader to decode, and no second place for the IV to drift out of sync
* with the payload it belongs to. */
const IV_END = IV_BYTES;
const TAG_END = IV_BYTES + TAG_BYTES;
export const WEBHOOK_SECRET_KEY_ENV = "RELAY_WEBHOOK_SECRET_KEY";
/** Resolved per call rather than at import time: a module that throws on import
* takes the whole service down at startup over a feature most requests never
* touch, and it makes the failure unreachable in a unit test. */
function encryptionKey(): Buffer {
const configured = process.env[WEBHOOK_SECRET_KEY_ENV];
if (!configured) {
// Development only, and deliberately loud about it. A silent default here
// would mean production quietly encrypting with a key from a tutorial.
if (process.env.NODE_ENV === "production") {
throw new Error(
`${WEBHOOK_SECRET_KEY_ENV} is required in production: webhook signing secrets cannot be stored without it`,
);
}
return Buffer.alloc(KEY_BYTES, "relay-development-key-not-for-production");
}
const key = Buffer.from(configured, "base64");
if (key.length !== KEY_BYTES) {
throw new Error(
`${WEBHOOK_SECRET_KEY_ENV} must decode to ${KEY_BYTES} bytes, got ${key.length}`,
);
}
return key;
}
/** A new signing secret, shown to the customer once and never again. */
export function mintSigningSecret(): string {
return randomBytes(SECRET_BYTES).toString("base64url");
}
export function encryptSecret(secret: string): string {
// A fresh IV per call, which is what makes two encryptions of the same secret
// differ. A deterministic ciphertext would leak equality: an operator reading
// the table could see which endpoints share a secret without decrypting one.
const iv = randomBytes(IV_BYTES);
const cipher = createCipheriv(ALGORITHM, encryptionKey(), iv);
const ciphertext = Buffer.concat([
cipher.update(secret, "utf8"),
cipher.final(),
]);
return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString("base64");
}
export function decryptSecret(stored: string): string {
const raw = Buffer.from(stored, "base64");
if (raw.length <= TAG_END) {
throw new Error("webhook secret ciphertext is too short to be valid");
}
const decipher = createDecipheriv(
ALGORITHM,
encryptionKey(),
raw.subarray(0, IV_END),
);
// GCM's tag is why a tampered value THROWS rather than returning plausible
// garbage. Signing with silently-wrong bytes would break every signature for
// that endpoint while looking like it worked.
decipher.setAuthTag(raw.subarray(IV_END, TAG_END));
return Buffer.concat([
decipher.update(raw.subarray(TAG_END)),
decipher.final(),
]).toString("utf8");
}
/** The rotation window: 24 hours (contracts/webhooks.md §Rotation).
*
* Long enough that a customer can roll a configuration change across their fleet
* without a deploy window becoming an outage; short enough that a secret they
* rotated *because it leaked* stops working the same day. Fixed here because it
* is a promise a recipient writes code against, not a tuning parameter. */
export const ROTATION_WINDOW_MS = 24 * 60 * 60 * 1000;
/** Which secrets a delivery must be signed with right now.
*
* Two during the window, one after it — and the "after it" half is the part that
* matters. A previous secret that never expires is not a rotation, it is a
* second permanent credential, and a customer who rotated because of a leak
* would still be accepting the leaked one. */
export function activeSigningSecrets(
endpoint: {
secretCiphertext: string;
secretPreviousCiphertext: string | null;
secretRotatedAt: Date | null;
},
now: Date = new Date(),
): string[] {
const current = decryptSecret(endpoint.secretCiphertext);
if (!endpoint.secretPreviousCiphertext || !endpoint.secretRotatedAt) {
return [current];
}
const elapsed = now.getTime() - endpoint.secretRotatedAt.getTime();
if (elapsed >= ROTATION_WINDOW_MS) return [current];
return [current, decryptSecret(endpoint.secretPreviousCiphertext)];
}Hai chi tiết ở đây là những quyết định thiết kế, không đơn thuần là phần cơ khí.
Rotation window là chi tiết thứ nhất. activeSigningSecrets trả về một secret
trong điều kiện bình thường và hai secret trong vòng hai mươi tư giờ sau khi
rotate; request mang một signature cho mỗi secret. Khi rotate secret, khách hàng
phải cập nhật cả verifier lẫn configuration, và trong một system thực tế hai việc
đó không bao giờ xảy ra đồng thời. Không có window, rotate đồng nghĩa với việc
chủ động chọn ra một phút mà verification sẽ fail; có window, cả hai secret cùng
hoạt động rồi secret cũ tự hết hạn.
Key resolution là chi tiết thứ hai. Nó nhỏ hơn, nhưng repository này từng
phải trả giá vì nó. encryptionKey() đọc environment ở mỗi lần gọi thay vì ngay
lúc import. Một module throw ngay trong quá trình import sẽ làm sập toàn bộ
service khi khởi động chỉ vì một feature mà phần lớn request không bao giờ dùng.
Tệ hơn nữa, unit test cũng không thể kiểm tra chính error ấy vì test file không
import nổi module mà nó muốn assert.
Migration và API surface được xây trên đó
Ba table xuất hiện cùng lúc, và migration được review trước khi chạy — kỷ luật mà chương 2.1 thiết lập sau khi một migration được generate tự động từng bị apply mà không ai đọc:
-- Chapter 3.5 — webhook endpoints, the delivery schedule, and dead letters.
--
-- REVIEW DISPOSITION: drizzle-kit generated this from schema.ts and it was read
-- line by line before being applied (the ADR-16 workflow). Nothing was
-- rewritten. Five things were checked rather than assumed:
--
-- * all three tables carry environment_id NOT NULL with a foreign key to
-- environments. Unlike 3.3's outbox and 3.4's consumed_events, these are
-- tenant data, and the rule distinguishing the two cases is stated in
-- schema.ts rather than left for the next chapter to infer;
-- * webhook_deliveries has UNIQUE (event_id, endpoint_id). That constraint IS
-- the idempotence of expansion — one event produces at most one delivery per
-- endpoint however many times the event is redelivered, enforced by the
-- database rather than by care;
-- * webhook_deliveries_due_idx is PARTIAL, on next_attempt_at WHERE state =
-- 'pending'. It covers the relay's only query and nothing else, so delivered
-- rows cost nothing to keep — the same shape as 3.3's outbox index;
-- * the state CHECK admits exactly pending / delivered / dead. There is no
-- fourth state to get stuck in;
-- * webhook_endpoints.deleted_at exists because DELETION IS SOFT. The foreign
-- keys from deliveries and dead letters are ON DELETE NO ACTION on purpose:
-- a hard delete would have to cascade, and cascading would erase a
-- customer's dead letters, which FR-WHK-04 says to retain for seven days.
-- Chapter 3.2 reached the same conclusion for api_keys.revoked_at.
CREATE TABLE "webhook_dead_letters" (
"id" uuid PRIMARY KEY NOT NULL,
"environment_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"event_id" uuid NOT NULL,
"payload" jsonb NOT NULL,
"last_status" integer,
"last_error" text,
"attempts" integer NOT NULL,
"dead_lettered_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "webhook_deliveries" (
"id" uuid PRIMARY KEY NOT NULL,
"environment_id" uuid NOT NULL,
"endpoint_id" uuid NOT NULL,
"event_id" uuid NOT NULL,
"payload" jsonb NOT NULL,
"attempt" integer DEFAULT 1 NOT NULL,
"next_attempt_at" timestamp with time zone DEFAULT now() NOT NULL,
"dispatched_at" timestamp with time zone,
"state" text DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "webhook_deliveries_event_endpoint_unique" UNIQUE("event_id","endpoint_id"),
CONSTRAINT "webhook_deliveries_state_check" CHECK ("webhook_deliveries"."state" IN ('pending','delivered','dead'))
);
--> statement-breakpoint
CREATE TABLE "webhook_endpoints" (
"id" uuid PRIMARY KEY NOT NULL,
"environment_id" uuid NOT NULL,
"url" text NOT NULL,
"event_types" jsonb NOT NULL,
"secret_ciphertext" text NOT NULL,
"secret_previous_ciphertext" text,
"secret_rotated_at" timestamp with time zone,
"enabled" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "webhook_dead_letters" ADD CONSTRAINT "webhook_dead_letters_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_dead_letters" ADD CONSTRAINT "webhook_dead_letters_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_deliveries" ADD CONSTRAINT "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk" FOREIGN KEY ("endpoint_id") REFERENCES "public"."webhook_endpoints"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "webhook_endpoints" ADD CONSTRAINT "webhook_endpoints_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "webhook_dead_letters_environment_idx" ON "webhook_dead_letters" USING btree ("environment_id");--> statement-breakpoint
CREATE INDEX "webhook_deliveries_due_idx" ON "webhook_deliveries" USING btree ("next_attempt_at") WHERE "webhook_deliveries"."state" = 'pending';--> statement-breakpoint
CREATE INDEX "webhook_endpoints_environment_idx" ON "webhook_endpoints" USING btree ("environment_id");Bề mặt quản trị là CRUD bình thường với ba quyết định nằm trong đó:
import {
Injectable,
NotFoundException,
UnprocessableEntityException,
} from "@nestjs/common";
import { Repository, type WebhookEndpointRow } from "../db/repository";
import { encryptSecret, mintSigningSecret } from "./secret";
// The management surface's rules (chapter 3.5). Two of them are worth stating
// here rather than leaving to the controller, because both are the kind of thing
// that looks like validation and is actually a security boundary.
/** FR-WHK-01, and the error must say the number. Chapter 3.2's lesson about
* error messages that name the mistake applies to a limit as much as to a
* credential: "too many endpoints" leaves the reader counting. */
export const MAX_ENDPOINTS_PER_ENVIRONMENT = 5;
/** DECISION (chapter 3.5, research R9): no requirement mandates this check, and
* without it the dispatcher is a request-forgery primitive pointed at whatever
* it can reach — a tenant supplies the URL, and the platform fetches it.
*
* Deliberately NOT built: DNS re-resolution at delivery time to defeat
* rebinding, an egress proxy, an allowlist. Each is a real hardening step and
* each needs infrastructure this platform does not have. Naming them is more
* honest than implying the simple check is complete; NFR-SEC-07's OWASP scan is
* where this comes back. */
const BLOCKED_HOSTS = /^(localhost|0\.0\.0\.0|\[?::1\]?)$/i;
const BLOCKED_RANGES = [
/^127\./,
/^10\./,
/^192\.168\./,
/^172\.(1[6-9]|2\d|3[01])\./,
/^169\.254\./, // link-local, and the cloud metadata endpoint lives here
/^fc00:/i,
/^fe80:/i,
];
export interface CreateEndpointInput {
url: string;
event_types: string[];
}
/** What a customer receives once and never again. */
export interface EndpointWithSecret extends WebhookEndpointRow {
secret: string;
}
@Injectable()
export class WebhooksService {
constructor(private readonly repo: Repository) {}
async create(input: CreateEndpointInput): Promise<EndpointWithSecret> {
this.assertDeliverableUrl(input.url);
this.assertEventTypes(input.event_types);
const existing = await this.repo.countEndpoints();
if (existing >= MAX_ENDPOINTS_PER_ENVIRONMENT) {
throw new UnprocessableEntityException(
`an environment may have at most ${MAX_ENDPOINTS_PER_ENVIRONMENT} webhook endpoints; this one already has ${existing}`,
);
}
// Minted here, encrypted here, returned here — and never readable again.
// The plaintext exists in this method's scope and nowhere else.
const secret = mintSigningSecret();
const row = await this.repo.createEndpoint({
url: input.url,
eventTypes: input.event_types,
secretCiphertext: encryptSecret(secret),
});
return { ...row, secret };
}
list(): Promise<WebhookEndpointRow[]> {
return this.repo.listEndpoints();
}
async get(id: string): Promise<WebhookEndpointRow> {
const row = await this.repo.getEndpoint(id);
// 404 rather than 403, for an endpoint that exists in another environment as
// much as for one that never existed: existence itself must not leak
// (FR-TEN-05).
if (!row) throw new NotFoundException("no such webhook endpoint");
return row;
}
async rotateSecret(id: string): Promise<EndpointWithSecret> {
const secret = mintSigningSecret();
const row = await this.repo.rotateEndpointSecret(id, encryptSecret(secret));
if (!row) throw new NotFoundException("no such webhook endpoint");
return { ...row, secret };
}
async setEnabled(id: string, enabled: boolean): Promise<WebhookEndpointRow> {
const row = await this.repo.setEndpointEnabled(id, enabled);
if (!row) throw new NotFoundException("no such webhook endpoint");
return row;
}
async remove(id: string): Promise<void> {
const deleted = await this.repo.deleteEndpoint(id);
if (!deleted) throw new NotFoundException("no such webhook endpoint");
}
private assertDeliverableUrl(raw: string): void {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
throw new UnprocessableEntityException("url must be a valid absolute URL");
}
if (parsed.protocol !== "https:") {
throw new UnprocessableEntityException(
"url must use https — a signature over a plaintext channel protects the body, not the reader",
);
}
const host = parsed.hostname;
if (BLOCKED_HOSTS.test(host) || BLOCKED_RANGES.some((r) => r.test(host))) {
throw new UnprocessableEntityException(
"url must not point at a loopback, link-local or private address",
);
}
}
private assertEventTypes(types: string[]): void {
if (!Array.isArray(types) || types.length === 0) {
throw new UnprocessableEntityException(
"event_types must list at least one event type",
);
}
}
}URL bắt buộc phải dùng HTTPS và không được trỏ vào internal network. Nền tảng sẽ fetch endpoint thay mặt khách hàng từ bên trong chính network của mình — đúng định nghĩa của server-side request forgery nếu không kiểm tra URL trỏ tới đâu. Loopback, private range và link-local address đều bị từ chối bằng validation error mà khách hàng đọc được, thay vì một timeout không thể giải thích.
Năm endpoint cho mỗi environment. Con số năm không có gì đặc biệt, nhưng "không giới hạn" cũng là một con số — và là con số sai. Expansion ghi một row cho mỗi endpoint phù hợp, nên một environment có mười nghìn endpoint sẽ biến một event thành mười nghìn row trong cùng một transaction.
Secret chỉ được trả về một lần duy nhất. Đó là lúc tạo endpoint, và không bao giờ lặp lại — column lưu ciphertext, còn plaintext chỉ tồn tại trong khoảng thời gian response được serialize:
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import { WebhooksService } from "./webhooks.service";
import type { CreateEndpointInput } from "./webhooks.service";
// The webhook management surface (chapter 3.5, FR-WHK-01 and FR-WHK-08).
//
// `Accepts("application")` and nothing else: configuring where a tenant's events
// are delivered is the tenant's software acting for itself, not an end user
// acting for themselves. An end-user token reaching this route would mean any
// logged-in person in a customer's product could redirect that customer's
// events, which is a very short sentence describing a very large incident.
@Controller("v1/webhooks")
@UseGuards(CredentialGuard)
@Accepts("application")
export class WebhooksController {
constructor(private readonly webhooks: WebhooksService) {}
/** 201 with the secret — the only time it is ever returned. */
@Post()
create(@Body() body: CreateEndpointInput) {
return this.webhooks.create(body);
}
@Get()
list() {
return this.webhooks.list();
}
@Get(":id")
get(@Param("id") id: string) {
return this.webhooks.get(id);
}
/** 200, not Nest's default 201: rotation replaces a secret on an endpoint that
* already exists. A 201 would tell a client something was created and hand it
* no location for it.
*
* Opens the 24-hour rotation window and returns the new secret once. The
* outgoing secret keeps signing until the window closes, so a recipient
* accepting either is correct throughout (contracts/webhooks.md §Rotation). */
@Post(":id/rotate-secret")
@HttpCode(200)
rotateSecret(@Param("id") id: string) {
return this.webhooks.rotateSecret(id);
}
@Post(":id/enable")
@HttpCode(200)
enable(@Param("id") id: string) {
return this.webhooks.setEnabled(id, true);
}
@Post(":id/disable")
@HttpCode(200)
disable(@Param("id") id: string) {
return this.webhooks.setEnabled(id, false);
}
/** SOFT delete. The endpoint stops receiving deliveries and disappears from
* every read; the row survives because its dead letters must (FR-WHK-04). */
@Delete(":id")
@HttpCode(204)
async remove(@Param("id") id: string): Promise<void> {
await this.webhooks.remove(id);
}
}import {
Inject,
Injectable,
Module,
Scope,
type OnModuleDestroy,
} from "@nestjs/common";
import { REQUEST } from "@nestjs/core";
import { createLogger } from "@relay/service-kit";
import { AuthModule } from "../auth/auth.module";
import { createDb, createPool, type Db } from "../db/client";
import { Repository } from "../db/repository";
import type { RequestWithTenant } from "../messages/request-with-tenant";
import { createJetStreamPublisher } from "../outbox/jetstream.publisher";
import {
createDeliveryRelay,
ensureDeliveriesStream,
type DeliveryRelay,
} from "./delivery-relay";
import { WebhooksController } from "./webhooks.controller";
import { WebhooksService } from "./webhooks.service";
export const DELIVERY_RELAY = "DELIVERY_RELAY";
/** The api's SECOND relay (chapter 3.5, research R13), started with the service
* exactly as 3.3's is. An event spine that only runs when someone remembers is
* not a spine, and the same is true of a retry schedule.
*
* `RELAY_DELIVERY_RELAY=off` is the sibling of `RELAY_OUTBOX_RELAY=off`, and it
* exists for the same reason: suites that assert on delivery rows would flap if
* a background loop marked them dispatched mid-assertion. Chapter 3.3's finding
* 4 — a background daemon and a test lane do not share a table quietly — applies
* again, and this time it is anticipated rather than discovered. */
export function deliveryRelayEnabled(): boolean {
return (process.env.RELAY_DELIVERY_RELAY ?? "on").toLowerCase() !== "off";
}
@Injectable()
export class DeliveryRelayService implements OnModuleDestroy {
constructor(@Inject(DELIVERY_RELAY) private readonly relay: DeliveryRelay) {}
start(): void {
if (deliveryRelayEnabled()) this.relay.start();
}
async onModuleDestroy(): Promise<void> {
await this.relay.stop();
}
}
// The same wiring chapter 2.2 established and 3.2 re-pointed: the repository is
// the plain class, constructed per request with the environment the middleware
// resolved from a verified credential. A request cannot name a tenant it has not
// proved it may act for, and this module adds no new way to try.
@Module({
imports: [AuthModule],
controllers: [WebhooksController],
providers: [
{
provide: "DB",
useFactory: (): Db => createDb(createPool()),
scope: Scope.DEFAULT,
},
{
provide: Repository,
scope: Scope.REQUEST,
inject: ["DB", REQUEST],
useFactory: (db: Db, req: RequestWithTenant) =>
new Repository(db, req.principal?.environmentId ?? ""),
},
WebhooksService,
{
provide: DELIVERY_RELAY,
useFactory: (): DeliveryRelay =>
createDeliveryRelay({
db: createDb(createPool()) as Db,
// Its OWN stream, not the events one. Chapter 3.3's publisher
// ensured EVENTS because that was the only stream; this one must
// bring DELIVERIES into existence or every publish is a 503.
publisher: createJetStreamPublisher({ ensure: ensureDeliveriesStream }),
logger: createLogger("deliveries"),
}),
},
DeliveryRelayService,
],
exports: [DELIVERY_RELAY, DeliveryRelayService],
})
export class WebhooksModule {}Ký đúng thứ mình thực sự gửi đi
Signature bao phủ một canonical string được dựng từ raw byte của body — chính xác các byte sẽ được truyền qua network:
import { createHmac } from "node:crypto";
// The signature a customer verifies (chapter 3.5, FR-WHK-08).
//
// This is the platform's first contract addressed to code it did not write and
// cannot read. A REST consumer can retry against a sandbox and read an error
// message; a webhook consumer finds out at 3 a.m. that their check has been
// wrong for a week. So the construction below is deliberately dull: no length
// prefixes, no canonical JSON, no nested encoding. Everything a recipient needs
// is the request and the shared secret, and the recipe fits in five lines of
// their language.
//
// Kept in its own file, and behind a function, for SAD risk R7's reason: "isolate
// HMAC/crypto behind an interface" so a profiling-driven swap stays contained.
/** Travels with the value, not the header name, so a second algorithm is an
* additional signature rather than a breaking change to the header set. */
export const SIGNATURE_SCHEME = "v1";
export const TIMESTAMP_HEADER = "relay-webhook-timestamp";
export const SIGNATURE_HEADER = "relay-webhook-signature";
/** `<scheme>:<timestamp>:<raw body>`.
*
* The timestamp is INSIDE the signed string rather than merely alongside it —
* otherwise it is decoration, and a captured request replays forever. The body
* is the raw bytes as they will be transmitted: signing a parsed-and-
* re-serialised body is the single most common way a first integration fails,
* and it fails in the direction that looks like the platform's bug. */
function canonicalString(timestamp: string, rawBody: string): string {
return `${SIGNATURE_SCHEME}:${timestamp}:${rawBody}`;
}
export function signDelivery({
rawBody,
timestamp,
secret,
}: {
rawBody: string;
timestamp: string;
secret: string;
}): string {
return createHmac("sha256", secret)
.update(canonicalString(timestamp, rawBody))
.digest("hex");
}
/** One signature per valid secret. During a rotation window an endpoint has two,
* and a recipient holding either must be able to verify — that is what makes a
* 24-hour window survivable without a synchronised deploy on the customer's
* side (contracts/webhooks.md §Rotation). */
export function signatureHeaders({
rawBody,
timestamp,
secrets,
}: {
rawBody: string;
timestamp: string;
secrets: string[];
}): Record<string, string> {
const signatures = secrets.map(
(secret) =>
`${SIGNATURE_SCHEME}=${signDelivery({ rawBody, timestamp, secret })}`,
);
return {
[TIMESTAMP_HEADER]: timestamp,
[SIGNATURE_HEADER]: signatures.join(","),
};
}Timestamp nằm bên trong dữ liệu được ký chứ không chỉ đặt cạnh signature; nhờ vậy một request bị bắt lại không thể bị replay vô thời hạn. Body được serialize đúng một lần, rồi cùng một string vừa được ký vừa được gửi. Đây không phải một optimization.
Test cho contract này được viết từ documentation, không dùng implementation của signer. Đây là cách duy nhất để test chứng minh điều nó tuyên bố:
it("verifies against a verifier written from the documentation, not from the signer", () => {Một test gọi signDelivery hai lần rồi so sánh kết quả chỉ chứng minh function
ấy deterministic. Nó không chứng minh khách hàng có verify được signature hay
không — trong khi đó mới là property thực sự quan trọng.
Retry schedule nằm trong một column — và lý do đến từ phép đo
Server của khách hàng đôi lúc sẽ ngừng hoạt động. Nền tảng retry theo schedule giãn dần rồi cuối cùng từ bỏ — requirements đã quy định phần đó. Câu hỏi đáng quan tâm là trạng thái chờ nằm ở đâu.
Câu trả lời hiển nhiên là broker. NATS cho phép consumer nack một message kèm delay; broker giữ message và trả lại khi delay kết thúc. Không cần table mới, không cần loop mới. Chương 3.4 đã xây dựng sẵn consumer.
Trước khi viết điều đó, chúng ta đã đo:
Q1 delayed redelivery survives a restart?
NAKKED delay_ms=90000 at 1786500140980 (process then exits)
ARRIVED at 1786500230983 in a fresh process — 3 ms late. YES.
Q2 do delayed messages hold the ack-pending budget?
max_ack_pending=3; nak'd 3 with a 300 s delay; fetched afterwards: 0
num_ack_pending=3 num_pending=2 YES — and that is the problem.Q1 pass hoàn toàn — delayed redelivery sống sót qua một lần process restart với sai số ba mili giây, tốt hơn mức thiết kế yêu cầu. Nếu chỉ xét Q1, broker-held delay là câu trả lời đúng.
Q2 loại bỏ phương án đó. Một message đang chờ delay kết thúc vẫn giữ
acknowledgement slot. Ba message đang chờ trên max_ack_pending: 3 khiến hai
message sẵn sàng khác không thể fetch được — không phải fetch chậm, mà hoàn toàn
không thể fetch. Ở quy mô platform, endpoint chết trong hai giờ của một khách
hàng sẽ chiếm delivery capacity vốn dành cho tất cả khách hàng khác; chính các
endpoint khỏe mạnh lại ngừng được phục vụ. FR-WHK-05 yêu cầu endpoint của một
khách hàng không được làm chậm delivery tới khách hàng khác, còn broker-held
delay vi phạm requirement này ngay trong cấu trúc.
Vì vậy chính delivery row là retry schedule:
@@ -1,10 +1,11 @@
import { sql } from "drizzle-orm";
import {
bigserial,
bigint,
+ boolean,
check,
index,
integer,
jsonb,
pgTable,
primaryKey,
@@ -356,6 +357,159 @@ export const consumedEvents = pgTable(
handledAt: timestamp("handled_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [primaryKey({ columns: [t.consumer, t.eventId] })],
);
+
+// ---------------------------------------------------------------------------
+// Webhooks (chapter 3.5). Three tables, and all three carry `environment_id`.
+//
+// DECISION (chapter 3.5): 3.3's `outbox` and 3.4's `consumed_events` each
+// omitted the tenant column and each recorded it as a deliberate exception. Two
+// exceptions in consecutive chapters is a pattern, and a pattern without a
+// stated rule is how a third chapter gets it wrong by resemblance. THE RULE: a
+// table below the tenant boundary may omit `environment_id` only when it holds
+// the platform's own bookkeeping AND no tenant-visible content. An endpoint is
+// customer configuration; a dead letter holds a payload that was being sent to a
+// customer. Both fail the test on both halves, so both are scoped and both join
+// chapter 3.7's cross-tenant gauntlet as targets.
+// ---------------------------------------------------------------------------
+
+// DECISION (chapter 3.5): no source document defines this table. FR-WHK-01 and
+// FR-WHK-08 require the behaviour — up to five endpoints per environment, each
+// with an independently rotatable signing secret — and leave the shape open.
+//
+// The secret is stored ENCRYPTED, not hashed, and the difference is the point.
+// An API key (3.2) is VERIFIED: a caller presents it, we hash what arrived and
+// compare. A signing secret is USED: we must compute an HMAC with it, which
+// needs the secret itself. A hash cannot be used, only compared. NFR-SEC-02
+// permits "salted hashes OR envelope encryption" and this is the branch that
+// applies — two credentials, one requirement, two mechanisms, because the verbs
+// differ (research R3).
+export const webhookEndpoints = pgTable(
+ "webhook_endpoints",
+ {
+ id: uuid("id").primaryKey(),
+ environmentId: uuid("environment_id")
+ .notNull()
+ .references(() => environments.id),
+ url: text("url").notNull(),
+ // The subscription set. An endpoint receives only these (FR-WHK-02).
+ eventTypes: jsonb("event_types").notNull(),
+ secretCiphertext: text("secret_ciphertext").notNull(),
+ // Non-null only during a rotation window: both secrets sign, so a recipient
+ // accepting either is correct throughout (contracts/webhooks.md §Rotation).
+ secretPreviousCiphertext: text("secret_previous_ciphertext"),
+ secretRotatedAt: timestamp("secret_rotated_at", { withTimezone: true }),
+ // An owner can pause an endpoint. What disables one AUTOMATICALLY after
+ // continuous failure is FR-WHK-07's, in the follow-on chapter — the column
+ // exists now so that chapter adds a rule rather than a migration.
+ enabled: boolean("enabled").notNull().default(true),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ // DELETION IS SOFT, and this column is why. Chapter 3.2 made the same
+ // choice for `api_keys.revokedAt` and gave the reason: "a deleted row loses
+ // the record of what once had access". Here the stakes are higher — a hard
+ // delete would have to cascade, and cascading would erase the customer's
+ // dead letters, which FR-WHK-04 says to retain for seven days. So a deleted
+ // endpoint stops receiving deliveries and stops appearing in listings, and
+ // the record of what it was survives its deletion.
+ deletedAt: timestamp("deleted_at", { withTimezone: true }),
+ },
+ (t) => [index("webhook_endpoints_environment_idx").on(t.environmentId)],
+);
+
+// The retry schedule — and it is chapter 3.3's outbox with one more column.
+//
+// DECISION (chapter 3.5, research R1, MEASURED): the obvious implementation is
+// to let the broker hold the delay between attempts. It was measured against a
+// real broker and disqualified: a delayed redelivery survives a restart to
+// within 3 ms, but a message waiting out its delay HOLDS AN ACKNOWLEDGEMENT
+// SLOT the whole time. With `max_ack_pending=3`, three messages nak'd for five
+// minutes made two available messages unfetchable. Scaled up, a handful of dead
+// customer endpoints starve deliveries to healthy ones — exactly what FR-WHK-05
+// forbids, and invisible until an incident.
+//
+// So a delivery that is not due yet is a ROW, not a message the broker holds.
+// The whole schedule is `next_attempt_at`; the api's relay publishes a delivery
+// only once it is already due. Nothing waits in the broker.
+export const webhookDeliveries = pgTable(
+ "webhook_deliveries",
+ {
+ id: uuid("id").primaryKey(),
+ environmentId: uuid("environment_id")
+ .notNull()
+ .references(() => environments.id),
+ endpointId: uuid("endpoint_id")
+ .notNull()
+ .references(() => webhookEndpoints.id),
+ // Chapter 3.3's envelope id — the customer's deduplication key, stable
+ // across every attempt and across a dead-letter replay (spec FR-018).
+ eventId: uuid("event_id").notNull(),
+ payload: jsonb("payload").notNull(),
+ // 1..7 — the INDEX INTO THE TIER TABLE, not a free-running counter.
+ // `attempt = 5` means "the 5-minute tier", which is what makes recomputing
+ // `next_attempt_at` total rather than incremental. Seven, not six: FR-WHK-03
+ // names six retry delays and the initial delivery makes seven requests —
+ // see webhooks/schedule.ts's DECISION for why the delay list won.
+ attempt: integer("attempt").notNull().default(1),
+ nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ // Set when the relay publishes it; cleared when the next attempt is
+ // scheduled. The relay's claim, in the shape `outbox.published_at` has.
+ dispatchedAt: timestamp("dispatched_at", { withTimezone: true }),
+ state: text("state").notNull().default("pending"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (t) => [
+ // One event produces at most one delivery per endpoint. This is what makes
+ // expansion idempotent at the database rather than by care (research R2).
+ unique("webhook_deliveries_event_endpoint_unique").on(t.eventId, t.endpointId),
+ check(
+ "webhook_deliveries_state_check",
+ sql`${t.state} IN ('pending','delivered','dead')`,
+ ),
+ // The relay's only query: pending rows that are due, oldest first. Partial,
+ // for the reason 3.3's outbox index is partial — it covers only what the
+ // relay reads, so delivered rows cost nothing to keep.
+ index("webhook_deliveries_due_idx")
+ .on(t.nextAttemptAt)
+ .where(sql`${t.state} = 'pending'`),
+ ],
+);
+
+// DECISION (chapter 3.5): FR-WHK-04 requires exhausted events to be retained
+// for seven days, inspectable and replayable, and leaves the shape open.
+//
+// This is the first store in the platform whose PURPOSE is retaining data that
+// failed to leave. Retention is therefore a liability rather than a feature: a
+// dead-letter table with no expiry is a place tenant data accumulates until an
+// audit finds it. Pruning is named and deferred; the chapter says what happens
+// on day eight and means it.
+export const webhookDeadLetters = pgTable(
+ "webhook_dead_letters",
+ {
+ id: uuid("id").primaryKey(),
+ environmentId: uuid("environment_id")
+ .notNull()
+ .references(() => environments.id),
+ endpointId: uuid("endpoint_id")
+ .notNull()
+ .references(() => webhookEndpoints.id),
+ // Reused on replay, so a customer who deduplicates correctly is unharmed by
+ // an operator replaying something they already received.
+ eventId: uuid("event_id").notNull(),
+ payload: jsonb("payload").notNull(),
+ lastStatus: integer("last_status"),
+ lastError: text("last_error"),
+ attempts: integer("attempts").notNull(),
+ deadLetteredAt: timestamp("dead_lettered_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (t) => [index("webhook_dead_letters_environment_idx").on(t.environmentId)],
+);flowchart TB
ev["một event trên events.><br/>(envelope của chương 3.3)"]
claim{"claim event<br/>consumed_events"}
dupe["ack — đã nở rồi<br/>bởi một lần deliver trước"]
rows[("N dòng delivery,<br/>mỗi endpoint khớp một dòng,<br/>ghi BÊN TRONG claim")]
due{"next_attempt_at <= now()<br/>VÀ dispatched_at IS NULL?"}
wait["chưa tới hạn — là một DÒNG, không phải<br/>message broker đang giữ.<br/>Không chiếm slot acknowledgement"]
post["dispatcher post, đã ký"]
out{"khách hàng nói gì?"}
ok["state = delivered"]
again["attempt + 1 · next_attempt_at<br/>= now + tier[attempt]"]
dead["cạn 7 lần thử:<br/>state = dead, ghi dead letter"]
ev --> claim
claim -- "không (trùng)" --> dupe
claim -- có --> rows --> due
due -- chưa --> wait --> due
due -- rồi --> post --> out
out -- "2xx" --> ok
out -- "bất kỳ thứ gì khác, hoặc không gì" --> again
again --> due
again -- "hết tier" --> deadPartial index gói trọn lập luận về performance trong một dòng: drain query chỉ
đọc các row pending, nên index chỉ chứa row pending sẽ luôn nhỏ, bất kể có
bao nhiêu row delivered tích lại phía sau.
Tier table nằm trong API, không phải dispatcher — thoạt nhìn có vẻ sai cho
đến khi ta xét component nào đọc nó. recordAttemptOutcome tra cứu các tier để
tính next_attempt_at tiếp theo. Đây là một database write nên Constitution IV
đặt nó trong API. Đặt retry schedule trong dispatcher có vẻ tự nhiên nhưng lại
sai vị trí:
// The retry schedule (chapter 3.5, FR-WHK-03).
//
// Seven attempts — one immediate, then FR-WHK-03's six retries — and the delays
// are data rather than arithmetic. An exponential
// formula would be shorter and would not let each step carry its reason — and
// the reasons are the part a reader needs, because "exponential backoff" is a
// shape, not a decision.
//
// ---------------------------------------------------------------------------
// DECISION (chapter 3.5, taken by the author on 2026-08-10): FR-WHK-03 is
// internally inconsistent, and this is the reading taken.
//
// It says: "Failed deliveries shall be retried with exponential backoff at
// approximately 1 s, 5 s, 30 s, 5 min, 30 min, 2 h — six attempts in total."
//
// Six delays are listed, and an initial delivery followed by six retries is
// SEVEN requests — which the same sentence's "six attempts in total" forbids.
// The two halves cannot both be satisfied.
//
// THE DELAY LIST WINS. "Six attempts" is read as the six RETRIES, which is how
// the sentence's first clause uses the word ("shall be retried ... at"), and it
// keeps every number the requirement actually names — including the 2 h tier
// that gives a customer a working day's outage before their events are
// dead-lettered. Dropping that tier to preserve a count would have quietly
// shortened the platform's most customer-visible promise from two hours to
// thirty-six minutes.
//
// So: attempt 1 is immediate, attempts 2-7 follow at 1 s, 5 s, 30 s, 5 min,
// 30 min and 2 h, and the schedule spans about two hours thirty-six minutes.
//
// The SRS wording should be corrected to say "six retries" rather than "six
// attempts in total"; that is an amendment, not a chapter's decision to take
// alone, and it is recorded here until it happens.
//
// Worth noticing that this switch cost one table entry and one constant. The
// column, the relay and every invariant were untouched — which is a property of
// R1's re-plan, where the schedule stopped being broker state and became data.
// ---------------------------------------------------------------------------
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
/** Delay before each attempt, indexed by `attempt - 1`.
*
* `attempt` is the INDEX INTO THIS TABLE, not a free-running counter. That is
* what makes recomputing `next_attempt_at` total rather than incremental: given
* a delivery row, the next due time is a lookup, and a bug in one branch cannot
* leave a delivery drifting on a schedule nobody can reconstruct. */
export const RETRY_TIERS_MS: readonly number[] = [
// Attempt 1 — immediate. A webhook that waits before its FIRST try would make
// every customer's integration feel broken for a reason none of them could
// see.
0,
// 1 s — the deploy-restart tier. Most first failures are a customer's process
// being replaced, and a second is long enough for the new one to be listening.
1 * SECOND,
// 5 s — the transient tier: a connection reset, a cold lambda, a brief 502.
5 * SECOND,
// 30 s — long enough that a customer's own retry-and-recover has had a turn.
30 * SECOND,
// 5 min — the deploy tier: long enough for a rollback or a restart to have
// finished, rather than catching a customer mid-deploy twice.
5 * MINUTE,
// 30 min — past here the endpoint is not blinking, it is down, and a human is
// already involved on the customer's side.
30 * MINUTE,
// 2 h — the last one, and the reason the delay list won over the count. It
// gives a customer most of a working day's outage to be noticed and fixed
// before their events stop being retried at all. Anything still failing here
// needs the dead-letter store and its seven days.
2 * HOUR,
];
/** Seven requests: the initial delivery plus FR-WHK-03's six retries.
* Exceeding it dead-letters. */
export const MAX_ATTEMPTS = RETRY_TIERS_MS.length;
/** When attempt N falls due, measured from the moment its predecessor failed.
*
* Returns null when there is no attempt N — the caller's signal to dead-letter
* rather than to schedule. Making "no next tier" a value rather than an
* exception keeps the outcome path a single expression. */
export function nextAttemptAt(attempt: number, from: Date = new Date()): Date | null {
if (attempt < 1 || attempt > MAX_ATTEMPTS) return null;
const delay = RETRY_TIERS_MS[attempt - 1];
if (delay === undefined) return null;
return new Date(from.getTime() + delay);
}Expansion nằm bên trong claim
Một event có thể khớp nhiều endpoint. Fan-out từ một thành nhiều là thời điểm duplicate trở nên đắt đỏ: nếu một event redelivered được expand lần nữa, mọi khách hàng phù hợp đều nhận thêm một webhook.
Kế hoạch ban đầu publish N message lên broker, mỗi delivery một message. Đây là một dual write: claim commit vào PostgreSQL rồi N lần publish tới NATS; một cú crash ở giữa sẽ khiến hai phía bất đồng. Thay vào đó, ghi N row sẽ đặt expansion vào claim transaction, nơi guarantee của chương 3.4 đã bao phủ sẵn:
@@ -1,9 +1,9 @@
import { randomUUID } from "node:crypto";
-import { and, asc, desc, eq, gt, lt, sql, type SQL } from "drizzle-orm";
+import { and, asc, desc, eq, gt, isNull, lt, sql, type SQL } from "drizzle-orm";
import type { Db } from "./client";
import {
apiKeys,
applications,
channels,
@@ -13,14 +13,19 @@ import {
members,
memberships,
messages,
organisations,
outbox,
users,
+ webhookDeadLetters,
+ webhookDeliveries,
+ webhookEndpoints,
} from "./schema";
import { messageCreatedEvent } from "../outbox/event";
+import { nextAttemptAt } from "../webhooks/schedule";
+import { activeSigningSecrets } from "../webhooks/secret";
import {
mintApiKey,
parseApiKeyCredential,
prefixMatchesKind,
secretMatches,
type EnvironmentKind,
@@ -292,12 +297,393 @@ export async function drainOutbox(
return published.length;
});
}
/** How far behind the relay is. The single number worth alarming on later, and
* the one the chapter shows going up while the broker is down. */
+/** The name this consumer claims events under. One name, because the ledger is
+ * keyed per consumer and the dispatcher is one consumer however many processes
+ * run it (chapter 3.4's data model). */
+export const DISPATCHER_CONSUMER = "dispatcher";
+
+/** Turn one event into one delivery per matching endpoint — **in one
+ * transaction** (chapter 3.5, research R2).
+ *
+ * Admin surface, like `drainOutbox`: one dispatcher serves every environment, so
+ * this cannot go through the scoped Repository. It is still safe, because the
+ * environment comes from the EVENT rather than from a caller's parameter.
+ *
+ * The claim is chapter 3.4's, unchanged, and it is doing more work here than it
+ * did there. The broker will redeliver — that is what at-least-once means — and
+ * an event expanded twice would double every webhook it produced. Because the
+ * claim and the N inserts share a transaction, "expansion runs exactly once"
+ * stops being something the code must be careful about and becomes a property of
+ * the database.
+ *
+ * An event no endpoint subscribes to is still CLAIMED, with zero rows created.
+ * Leaving it unclaimed would make every redelivery re-ask the same question
+ * forever. */
+export async function expandEventToDeliveries(
+ db: Db,
+ event: {
+ eventId: string;
+ environmentId: string;
+ type: string;
+ payload: unknown;
+ },
+): Promise<{ created: number; duplicate: boolean }> {
+ let created = 0;
+ const result = await claimEvent(
+ db,
+ DISPATCHER_CONSUMER,
+ event.eventId,
+ async () => {
+ const endpoints = await db
+ .select({
+ id: webhookEndpoints.id,
+ eventTypes: webhookEndpoints.eventTypes,
+ })
+ .from(webhookEndpoints)
+ .where(
+ and(
+ eq(webhookEndpoints.environmentId, event.environmentId),
+ eq(webhookEndpoints.enabled, true),
+ isNull(webhookEndpoints.deletedAt),
+ ),
+ );
+
+ // Subscription filtering happens HERE rather than at delivery time: a
+ // delivery row that exists and is never sent is a retry schedule with a
+ // permanent no-op in it, and an operator reading the table would have no
+ // way to tell it from work that is stuck.
+ const matching = endpoints.filter((e) =>
+ (e.eventTypes as string[]).includes(event.type),
+ );
+ if (matching.length === 0) return;
+
+ await db.insert(webhookDeliveries).values(
+ matching.map((endpoint) => ({
+ id: randomUUID(),
+ environmentId: event.environmentId,
+ endpointId: endpoint.id,
+ eventId: event.eventId,
+ payload: event.payload,
+ })),
+ );
+ created = matching.length;
+ },
+ );
+ return { created, duplicate: result === "duplicate" };
+}
+
+/** What the api did with an attempt's outcome. */
+export type DeliveryOutcome = "delivered" | "rescheduled" | "dead_lettered";
+
+/** Record one attempt's result, and decide what happens next — **in one
+ * transaction** (chapter 3.5).
+ *
+ * The three terminal paths are here together on purpose. Splitting "record the
+ * failure" from "schedule the next attempt" would allow a delivery marked failed
+ * with no next attempt scheduled: a webhook that stops without anyone being
+ * told, which is the failure mode a retry system exists to prevent.
+ *
+ * IDEMPOTENT on `(delivery_id, attempt)`. The dispatcher posts, then reports,
+ * then acknowledges; a crash between the POST and the acknowledgement means the
+ * delivery is redelivered and reported again. Recognising the repeat and
+ * returning the same answer is what makes that redelivery harmless — the POST
+ * itself may duplicate, and the customer absorbs it on the event id, but the
+ * SCHEDULE must not advance twice for one attempt or the tiers would collapse.
+ */
+export async function recordAttemptOutcome(
+ db: Db,
+ input: {
+ deliveryId: string;
+ attempt: number;
+ status?: number;
+ error?: string;
+ },
+): Promise<{ outcome: DeliveryOutcome; nextAttemptAt: Date | null }> {
+ return db.transaction(async (tx) => {
+ const [delivery] = await tx
+ .select({
+ id: webhookDeliveries.id,
+ environmentId: webhookDeliveries.environmentId,
+ endpointId: webhookDeliveries.endpointId,
+ eventId: webhookDeliveries.eventId,
+ payload: webhookDeliveries.payload,
+ attempt: webhookDeliveries.attempt,
+ state: webhookDeliveries.state,
+ nextAttemptAt: webhookDeliveries.nextAttemptAt,
+ dispatchedAt: webhookDeliveries.dispatchedAt,
+ })
+ .from(webhookDeliveries)
+ .where(eq(webhookDeliveries.id, input.deliveryId))
+ .for("update");
+
+ if (!delivery) throw new DeliveryNotFoundError(input.deliveryId);
+
+ // The idempotence check. A report for an attempt this delivery has already
+ // moved past is a repeat: answer with what was decided the first time and
+ // change nothing.
+ if (delivery.state !== "pending" || delivery.attempt !== input.attempt) {
+ return {
+ outcome:
+ delivery.state === "delivered"
+ ? ("delivered" as const)
+ : delivery.state === "dead"
+ ? ("dead_lettered" as const)
+ : ("rescheduled" as const),
+ nextAttemptAt:
+ delivery.state === "pending" ? delivery.nextAttemptAt : null,
+ };
+ }
+
+ const succeeded =
+ input.status !== undefined && input.status >= 200 && input.status < 300;
+
+ if (succeeded) {
+ await tx
+ .update(webhookDeliveries)
+ .set({ state: "delivered", dispatchedAt: null })
+ .where(eq(webhookDeliveries.id, delivery.id));
+ return { outcome: "delivered" as const, nextAttemptAt: null };
+ }
+
+ const next = nextAttemptAt(delivery.attempt + 1);
+ if (next) {
+ await tx
+ .update(webhookDeliveries)
+ .set({
+ attempt: delivery.attempt + 1,
+ nextAttemptAt: next,
+ // Cleared so the relay can pick it up again when it falls due.
+ dispatchedAt: null,
+ })
+ .where(eq(webhookDeliveries.id, delivery.id));
+ return { outcome: "rescheduled" as const, nextAttemptAt: next };
+ }
+
+ // Attempts exhausted. The dead letter and the state change commit together —
+ // a delivery marked dead with no dead letter behind it would be a failure
+ // with no record, which is exactly what FR-WHK-04's seven days are for.
+ await tx.insert(webhookDeadLetters).values({
+ id: randomUUID(),
+ environmentId: delivery.environmentId,
+ endpointId: delivery.endpointId,
+ eventId: delivery.eventId,
+ payload: delivery.payload,
+ lastStatus: input.status ?? null,
+ lastError: input.error ?? null,
+ attempts: delivery.attempt,
+ });
+ await tx
+ .update(webhookDeliveries)
+ .set({ state: "dead", dispatchedAt: null })
+ .where(eq(webhookDeliveries.id, delivery.id));
+ return { outcome: "dead_lettered" as const, nextAttemptAt: null };
+ });
+}
+
+/** Everything the dispatcher needs to sign and post one delivery.
+ *
+ * Admin surface: one dispatcher serves every environment. The environment is
+ * read from the DELIVERY rather than supplied by the caller, so this cannot be
+ * pointed at a tenant by anyone who does not already hold a delivery id.
+ *
+ * Returns DECRYPTED secrets — one, or two inside a 24-hour rotation window. This
+ * is the only place in the platform that hands a customer credential back in
+ * plaintext, and the obligations that come with it are stated in
+ * contracts/dispatcher.md rather than assumed. */
+export async function deliveryMaterial(
+ db: Db,
+ deliveryId: string,
+): Promise<{
+ delivery_id: string;
+ endpoint_id: string;
+ environment_id: string;
+ event_id: string;
+ url: string;
+ attempt: number;
+ secrets: string[];
+ payload: unknown;
+} | null> {
+ const [row] = await db
+ .select({
+ id: webhookDeliveries.id,
+ environmentId: webhookDeliveries.environmentId,
+ endpointId: webhookDeliveries.endpointId,
+ eventId: webhookDeliveries.eventId,
+ attempt: webhookDeliveries.attempt,
+ payload: webhookDeliveries.payload,
+ url: webhookEndpoints.url,
+ enabled: webhookEndpoints.enabled,
+ deletedAt: webhookEndpoints.deletedAt,
+ secretCiphertext: webhookEndpoints.secretCiphertext,
+ secretPreviousCiphertext: webhookEndpoints.secretPreviousCiphertext,
+ secretRotatedAt: webhookEndpoints.secretRotatedAt,
+ })
+ .from(webhookDeliveries)
+ .innerJoin(
+ webhookEndpoints,
+ eq(webhookDeliveries.endpointId, webhookEndpoints.id),
+ )
+ .where(eq(webhookDeliveries.id, deliveryId));
+
+ if (!row) return null;
+ // An endpoint paused or removed after the delivery was scheduled gets nothing.
+ // The spec's edge case: events already in the retry schedule for a removed
+ // endpoint must not be delivered.
+ if (!row.enabled || row.deletedAt) return null;
+
+ return {
+ delivery_id: row.id,
+ endpoint_id: row.endpointId,
+ environment_id: row.environmentId,
+ event_id: row.eventId,
+ url: row.url,
+ attempt: row.attempt,
+ secrets: activeSigningSecrets({
+ secretCiphertext: row.secretCiphertext,
+ secretPreviousCiphertext: row.secretPreviousCiphertext,
+ secretRotatedAt: row.secretRotatedAt,
+ }),
+ payload: row.payload,
+ };
+}
+
+/** A delivery that is due, as the relay claims it. */
+export interface DueDeliveryRow {
+ id: string;
+ environment_id: string;
+ endpoint_id: string;
+ event_id: string;
+ attempt: number;
+}
+
+/** Claim the deliveries that are due and hand each to `publish` — chapter 3.3's
+ * `drainOutbox` with ONE MORE PREDICATE (research R13).
+ *
+ * That is the whole point, and it is worth not obscuring: the reader built this
+ * loop two chapters ago. `SELECT … FOR UPDATE SKIP LOCKED`, publish, mark. The
+ * only difference is `AND next_attempt_at <= now()`, and that difference is the
+ * entire retry schedule.
+ *
+ * `SKIP LOCKED` matters here for the reason it mattered there: the api runs more
+ * than once, so two relays draining one table is the ordinary deployment rather
+ * than an edge case.
+ *
+ * NOTHING WAITS IN THE BROKER. A delivery enters the stream only once it is
+ * already due — which is the property research R1 measured the alternative
+ * against and found it wanting: a broker-held delay holds an acknowledgement
+ * slot the whole time it waits, so dead endpoints starve healthy ones. */
+export async function drainDueDeliveries(
+ db: Db,
+ limit: number,
+ publish: (row: DueDeliveryRow) => Promise<void>,
+): Promise<number> {
+ return db.transaction(async (tx) => {
+ const claimed = (await tx.execute(
+ sql`SELECT id, environment_id, endpoint_id, event_id, attempt
+ FROM webhook_deliveries
+ WHERE state = 'pending'
+ AND dispatched_at IS NULL
+ AND next_attempt_at <= now()
+ ORDER BY next_attempt_at, id
+ LIMIT ${limit}
+ FOR UPDATE SKIP LOCKED`,
+ )) as unknown as { rows: DueDeliveryRow[] };
+
+ const dispatched: string[] = [];
+ try {
+ for (const row of claimed.rows) {
+ await publish(row);
+ dispatched.push(row.id);
+ }
+ } finally {
+ // In the `finally` for 3.3's reason: whatever went wrong with row N+1,
+ // rows 1..N really did reach the broker and must not be published twice by
+ // this instance's next pass.
+ if (dispatched.length > 0) {
+ await tx.execute(
+ sql`UPDATE webhook_deliveries SET dispatched_at = now()
+ WHERE id = ANY(${sql.raw(
+ `ARRAY[${dispatched.map((id) => `'${id}'`).join(",")}]::uuid[]`,
+ )})`,
+ );
+ }
+ }
+ return dispatched.length;
+ });
+}
+
+/** How many deliveries are waiting to become due. The number an operator watches
+ * when a customer says "we stopped receiving webhooks". */
+export async function pendingDeliveryDepth(db: Db): Promise<number> {
+ const result = (await db.execute(
+ sql`SELECT count(*)::int AS pending
+ FROM webhook_deliveries
+ WHERE state = 'pending'`,
+ )) as unknown as { rows: { pending: number }[] };
+ return result.rows[0]?.pending ?? 0;
+}
+
+/** Put a dead-lettered delivery back on the schedule.
+ *
+ * Resets the EXISTING delivery row rather than inserting a new one, and that is
+ * forced by the shape of the data rather than chosen: `UNIQUE (event_id,
+ * endpoint_id)` is what makes expansion idempotent, so a second row for the same
+ * pair cannot exist. Reusing the row therefore preserves the original
+ * `event_id` — the identifier the customer deduplicates on — by construction
+ * instead of by remembering to copy it.
+ *
+ * **Current configuration, automatically.** The URL and the signing secrets are
+ * read from the endpoint at SEND time by `deliveryMaterial`, never stored on the
+ * delivery. So a replay of something that failed against a broken URL goes to
+ * whatever the endpoint says today, which is the whole reason anyone asks for a
+ * replay. Nothing here has to arrange that.
+ *
+ * **The dead-letter record is left alone.** FR-WHK-04 retains it for seven days,
+ * and a replay is a new attempt rather than an erasure of the fact that the
+ * attempts once ran out. Deleting it would remove the only evidence a customer's
+ * endpoint was broken at the moment somebody retried it.
+ *
+ * Returns false when there is no such dead letter — the controller's 404. */
+export async function replayDeadLetter(
+ db: Db,
+ deadLetterId: string,
+): Promise<boolean> {
+ return db.transaction(async (tx) => {
+ const [dead] = await tx
+ .select({
+ eventId: webhookDeadLetters.eventId,
+ endpointId: webhookDeadLetters.endpointId,
+ })
+ .from(webhookDeadLetters)
+ .where(eq(webhookDeadLetters.id, deadLetterId));
+ if (!dead) return false;
+
+ await tx
+ .update(webhookDeliveries)
+ .set({
+ state: "pending",
+ // Tier 1: a replay is a fresh chance, not a continuation of a schedule
+ // that has already run out.
+ attempt: 1,
+ nextAttemptAt: new Date(),
+ dispatchedAt: null,
+ })
+ .where(
+ and(
+ eq(webhookDeliveries.eventId, dead.eventId),
+ eq(webhookDeliveries.endpointId, dead.endpointId),
+ ),
+ );
+ return true;
+ });
+}
+
export async function outboxDepth(db: Db): Promise<number> {
const result = (await db.execute(
sql`SELECT count(*)::int AS pending FROM outbox WHERE published_at IS NULL`,
)) as unknown as { rows: { pending: number }[] };
return result.rows[0]?.pending ?? 0;
}
@@ -608,21 +994,257 @@ export class ChannelNotFoundError extends Error {
* millisecond precision) — the driver hands back a Date or a string
* depending on the column and the query shape. */
function toIso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : String(value);
}
+/** A webhook endpoint as the management surface returns it. The ciphertext is
+ * absent by construction rather than by filtering — a read path that can return
+ * it is one refactor away from a response that does. */
+export interface WebhookEndpointRow {
+ id: string;
+ url: string;
+ event_types: string[];
+ enabled: boolean;
+ secret_rotated_at: string | null;
+ created_at: string;
+}
+
+/** Raised when an outcome names a delivery that is not there. A caller error,
+ * not a platform one — the controller turns it into a 404. */
+export class DeliveryNotFoundError extends Error {
+ constructor(id: string) {
+ super(`no such delivery: ${id}`);
+ this.name = "DeliveryNotFoundError";
+ }
+}
+
+export interface WebhookDeliveryRow {
+ id: string;
+ endpoint_id: string;
+ event_id: string;
+ attempt: number;
+ state: string;
+ next_attempt_at: string;
+ /** Non-null once the relay has published it. Exposed because the drain is
+ * GLOBAL — one dispatcher serves every environment — so a test that asserts on
+ * what ITS call to the drain returned is asserting on which suite got there
+ * first. Chapter 3.3's finding 3, in its third chapter. */
+ dispatched_at: string | null;
+}
+
+export interface WebhookDeadLetterRow {
+ id: string;
+ endpoint_id: string;
+ event_id: string;
+ last_status: number | null;
+ last_error: string | null;
+ attempts: number;
+ dead_lettered_at: string;
+}
+
export class Repository {
// Constructor parameter properties — the shorthand chapter 1.4 released
// for this service when ADR-15 spent erasableSyntaxOnly on decorator
// metadata. The guarantee still holds in the gateway and every package.
constructor(
private readonly db: Db,
private readonly environmentId: string,
) {}
+ // ---------------------------------------------------------------------
+ // Webhook endpoints (chapter 3.5). Scoped like everything else on this class:
+ // the environment comes from the constructor and never from a caller, so a
+ // handler cannot ask for another tenant's endpoints even by accident
+ // (constitution I).
+ //
+ // Every read here excludes soft-deleted rows. That is the whole contract of
+ // `deleted_at`: the row survives so its dead letters can, and it is invisible
+ // to everything else.
+ // ---------------------------------------------------------------------
+
+ private get liveEndpoints() {
+ return and(
+ eq(webhookEndpoints.environmentId, this.environmentId),
+ isNull(webhookEndpoints.deletedAt),
+ );
+ }
+
+ async countEndpoints(): Promise<number> {
+ const rows = await this.db
+ .select({ id: webhookEndpoints.id })
+ .from(webhookEndpoints)
+ .where(this.liveEndpoints);
+ return rows.length;
+ }
+
+ async createEndpoint(input: {
+ url: string;
+ eventTypes: string[];
+ secretCiphertext: string;
+ }): Promise<WebhookEndpointRow> {
+ const id = randomUUID();
+ await this.db.insert(webhookEndpoints).values({
+ id,
+ environmentId: this.environmentId,
+ url: input.url,
+ eventTypes: input.eventTypes,
+ secretCiphertext: input.secretCiphertext,
+ });
+ const row = await this.getEndpoint(id);
+ if (!row) throw new Error("endpoint vanished immediately after insert");
+ return row;
+ }
+
+ async listEndpoints(): Promise<WebhookEndpointRow[]> {
+ return this.selectEndpoints(this.liveEndpoints);
+ }
+
+ async getEndpoint(id: string): Promise<WebhookEndpointRow | null> {
+ const rows = await this.selectEndpoints(
+ and(eq(webhookEndpoints.id, id), this.liveEndpoints),
+ );
+ return rows[0] ?? null;
+ }
+
+ /** Opens a 24-hour rotation window: the outgoing secret keeps signing until it
+ * closes, so a recipient accepting either is correct throughout
+ * (contracts/webhooks.md §Rotation). */
+ async rotateEndpointSecret(
+ id: string,
+ secretCiphertext: string,
+ ): Promise<WebhookEndpointRow | null> {
+ const current = await this.getEndpoint(id);
+ if (!current) return null;
+ const [previous] = await this.db
+ .select({ secretCiphertext: webhookEndpoints.secretCiphertext })
+ .from(webhookEndpoints)
+ .where(and(eq(webhookEndpoints.id, id), this.liveEndpoints));
+ await this.db
+ .update(webhookEndpoints)
+ .set({
+ secretCiphertext,
+ secretPreviousCiphertext: previous?.secretCiphertext ?? null,
+ secretRotatedAt: new Date(),
+ })
+ .where(and(eq(webhookEndpoints.id, id), this.liveEndpoints));
+ return this.getEndpoint(id);
+ }
+
+ async setEndpointEnabled(
+ id: string,
+ enabled: boolean,
+ ): Promise<WebhookEndpointRow | null> {
+ await this.db
+ .update(webhookEndpoints)
+ .set({ enabled })
+ .where(and(eq(webhookEndpoints.id, id), this.liveEndpoints));
+ return this.getEndpoint(id);
+ }
+
+ /** SOFT. A hard delete would have to cascade, and cascading would erase the
+ * customer's dead letters — which FR-WHK-04 says to retain for seven days.
+ * Returns false when there was nothing live to delete, which the controller
+ * turns into the same 404 a foreign tenant gets. */
+ async deleteEndpoint(id: string): Promise<boolean> {
+ const existing = await this.getEndpoint(id);
+ if (!existing) return false;
+ await this.db
+ .update(webhookEndpoints)
+ .set({ deletedAt: new Date() })
+ .where(and(eq(webhookEndpoints.id, id), this.liveEndpoints));
+ return true;
+ }
+
+ /** Every delivery this event produced, for this environment. Scoped like
+ * everything else on this class — the drain is global, but a tenant's view of
+ * its own deliveries is not. */
+ async listDeliveriesForEvent(eventId: string): Promise<WebhookDeliveryRow[]> {
+ const rows = await this.db
+ .select({
+ id: webhookDeliveries.id,
+ endpointId: webhookDeliveries.endpointId,
+ eventId: webhookDeliveries.eventId,
+ attempt: webhookDeliveries.attempt,
+ state: webhookDeliveries.state,
+ nextAttemptAt: webhookDeliveries.nextAttemptAt,
+ dispatchedAt: webhookDeliveries.dispatchedAt,
+ })
+ .from(webhookDeliveries)
+ .where(
+ and(
+ eq(webhookDeliveries.environmentId, this.environmentId),
+ eq(webhookDeliveries.eventId, eventId),
+ ),
+ )
+ .orderBy(asc(webhookDeliveries.id));
+ return rows.map((r) => ({
+ id: r.id,
+ endpoint_id: r.endpointId,
+ event_id: r.eventId,
+ attempt: r.attempt,
+ state: r.state,
+ next_attempt_at: r.nextAttemptAt.toISOString(),
+ dispatched_at: r.dispatchedAt?.toISOString() ?? null,
+ }));
+ }
+
+ /** A tenant's dead letters, newest first. Scoped: a dead letter holds a
+ * payload that was being sent to this customer, which is why the table carries
+ * `environment_id` where 3.3's outbox and 3.4's ledger did not. */
+ async listDeadLetters(): Promise<WebhookDeadLetterRow[]> {
+ const rows = await this.db
+ .select({
+ id: webhookDeadLetters.id,
+ endpointId: webhookDeadLetters.endpointId,
+ eventId: webhookDeadLetters.eventId,
+ lastStatus: webhookDeadLetters.lastStatus,
+ lastError: webhookDeadLetters.lastError,
+ attempts: webhookDeadLetters.attempts,
+ deadLetteredAt: webhookDeadLetters.deadLetteredAt,
+ })
+ .from(webhookDeadLetters)
+ .where(eq(webhookDeadLetters.environmentId, this.environmentId))
+ .orderBy(desc(webhookDeadLetters.deadLetteredAt));
+ return rows.map((r) => ({
+ id: r.id,
+ endpoint_id: r.endpointId,
+ event_id: r.eventId,
+ last_status: r.lastStatus,
+ last_error: r.lastError,
+ attempts: r.attempts,
+ dead_lettered_at: r.deadLetteredAt.toISOString(),
+ }));
+ }
+
+ private async selectEndpoints(
+ where: ReturnType<typeof and>,
+ ): Promise<WebhookEndpointRow[]> {
+ const rows = await this.db
+ .select({
+ id: webhookEndpoints.id,
+ url: webhookEndpoints.url,
+ eventTypes: webhookEndpoints.eventTypes,
+ enabled: webhookEndpoints.enabled,
+ secretRotatedAt: webhookEndpoints.secretRotatedAt,
+ createdAt: webhookEndpoints.createdAt,
+ })
+ .from(webhookEndpoints)
+ .where(where);
+ // Never the ciphertext. A read path that can return it is one refactor away
+ // from a response that does.
+ return rows.map((r) => ({
+ id: r.id,
+ url: r.url,
+ event_types: r.eventTypes as string[],
+ enabled: r.enabled,
+ secret_rotated_at: r.secretRotatedAt?.toISOString() ?? null,
+ created_at: r.createdAt.toISOString(),
+ }));
+ }
+
async createUser(externalId: string, displayName?: string): Promise<UserRow> {
const id = randomUUID();
await this.db.insert(users).values({
id,
environmentId: this.environmentId,
externalId,Cả số created lẫn flag duplicate đều được trả về và dispatcher log cả hai.
Duplicate không phải error — đó là dấu hiệu claim đang hoạt động đúng. Khi thấy
duplicate=true trong log, operator đang quan sát cơ chế vận hành chứ không phải
một sự cố cần điều tra.
Vì sao dispatcher là service riêng
Chương 3.3 thêm outbox relay, chương 3.4 thêm consumer runtime, và cả hai đều chạy bên trong API. Chương này bổ sung background worker thứ ba nhưng đặt nó trong một deployable mới. Sự khác biệt đó cần được giải thích; nếu không, nó chỉ là sở thích cá nhân.
Lý do là đây là worker đầu tiên có effect không phải một database write.
Relay đọc outbox rồi publish; consumer claim event rồi ghi row. Cả hai đều truy cập PostgreSQL, còn Constitution IV quy định API sở hữu các database write, nên chúng phải nằm cùng nơi với write operation. Effect của dispatcher là HTTP request tới server khách hàng. Dispatcher không cần quyền truy cập database; cấp quyền ấy cho nó sẽ tạo ra service thứ hai ghi PostgreSQL chỉ vì tiện lợi.
flowchart LR
subgraph api["api service — người DUY NHẤT ghi Postgres (hiến pháp IV)"]
http["HTTP handlers<br/>CRUD webhook endpoint"]
relay["delivery relay<br/>rút cạn thứ TỚI HẠN"]
internal["/internal/dispatch<br/>expand · material · outcome"]
pg[("webhook_endpoints<br/>webhook_deliveries<br/>webhook_dead_letters")]
end
subgraph disp["dispatcher service — KHÔNG ghi gì"]
expand["expand consumer<br/>events.>"]
deliver["deliver consumer<br/>deliveries.>"]
end
js[("JetStream<br/>EVENTS · DELIVERIES")]
cust["server của khách hàng"]
http --> pg
relay --> pg
internal --> pg
relay --> js
js --> expand
js --> deliver
expand -->|HTTP| internal
deliver -->|HTTP| internal
deliver -->|"POST, đã ký"| custĐiều service split này mua được là một property vốn chỉ có thể tranh luận bằng lý thuyết. Nếu dispatcher nằm trong API, câu "webhook không làm chậm end user" chỉ là khẳng định về event loop và connection pool — điều ta lập luận rồi hy vọng. Khi có process boundary, nó trở thành một khẳng định có thể kiểm chứng bằng cách không khởi động process ấy:
import { createRequire } from "node:module";
import { createServer, type Server } from "node:http";
import { dirname, join } from "node:path";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { boot, type System } from "./harness.js";
// Invariant 14, first half (chapter 3.5): with the dispatcher absent, end users
// are served exactly as before.
//
// This is the journey that shows what the service split BOUGHT. Inside the api,
// "webhooks do not delay end users" would be a claim about event loops and
// connection pools — something you argue for. Across a process boundary it is a
// claim about processes, and you settle it by not starting one.
//
// The dispatcher is never started in this file. It does not exist for the
// duration, and the platform is expected not to notice.
//
// THE OTHER HALF — that a dispatcher starting later drains the backlog — lives
// in the dispatcher's own suite, which can start and stop one. Asserting it here
// would mean this journey managing a service the harness does not own, and a
// journey that boots half a platform to prove something a unit of it can prove
// is a slower test with a weaker claim.
const require_ = createRequire(import.meta.url);
const REPO = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
/** A customer's server. Nothing should reach it while the dispatcher is absent,
* and that silence is the assertion. */
function customerEndpoint() {
const received: string[] = [];
const server: Server = createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += String(c)));
req.on("end", () => {
received.push(body);
res.writeHead(200).end("ok");
});
});
return {
received,
listen: () =>
new Promise<string>((resolve) =>
server.listen(0, () => {
const addr = server.address();
resolve(
`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`,
);
}),
),
close: () => server.close(),
};
}
function apiInternals() {
const dist = join(REPO, "services", "api", "dist");
const client = require_(join(dist, "db", "client.js")) as {
createDb: (p: unknown) => unknown;
createPool: () => unknown;
};
const seeder = require_(join(dist, "db", "repository.js")) as {
Repository: new (
db: unknown,
env: string,
) => {
createEndpoint: (i: {
url: string;
eventTypes: string[];
secretCiphertext: string;
}) => Promise<{ id: string }>;
};
};
const secrets = require_(join(dist, "webhooks", "secret.js")) as {
encryptSecret: (s: string) => string;
mintSigningSecret: () => string;
};
return { db: client.createDb(client.createPool()), seeder, secrets };
}
describe("journey: webhooks never stand between the platform and its users", () => {
let system: System;
let endpoint: ReturnType<typeof customerEndpoint>;
let endpointUrl: string;
beforeAll(async () => {
endpoint = customerEndpoint();
endpointUrl = await endpoint.listen();
// No dispatcher. The api and one gateway come up; nothing consumes
// deliveries, and nothing is supposed to need to.
system = await boot({ gateways: 1 });
}, 120_000);
afterAll(async () => {
await system?.stop();
endpoint?.close();
});
it("invariant 14: an end user is served while the dispatcher does not exist", async () => {
// `dispatcher` here is the FLEET DISPATCHER — a person, from chapter 2.8's
// cast — not this chapter's service. Renamed locally, because a file about
// the webhook dispatcher that also has a variable called `dispatcher`
// meaning something else is a trap for whoever reads it next.
const {
environmentId,
channel,
dispatcher: fleetDispatcher,
tuan,
} = await system.seedConversation();
// The clients are constructed, not connected — the journey connects them to
// the gateways it wants them on.
await fleetDispatcher.connect(system.gateways[0]!);
await tuan.connect(system.gateways[0]!);
// A configured endpoint, so there IS webhook work for the absent service to
// be failing to do. Seeded through the repository on purpose: this journey
// is about the delivery path, and the management surface has its own suite.
const { db, seeder, secrets } = apiInternals();
const repo = new seeder.Repository(db, environmentId);
await repo.createEndpoint({
url: `${endpointUrl}/hook`,
eventTypes: ["message.created"],
secretCiphertext: secrets.encryptSecret(secrets.mintSigningSecret()),
});
const text = `north ramp ${randomUUID().slice(0, 6)}`;
fleetDispatcher.send(channel, text);
// The whole assertion: message delivery does not consult the webhook path,
// does not wait for it, and does not care that the service which would run
// it was never started.
await tuan.waitFor(
(frame) =>
frame.type === "message.created" &&
(frame.payload as { text?: string }).text === text,
`tuan hears "${text}" with no dispatcher running`,
15_000,
);
expect(tuan.timeline(channel).some((m) => m.text === text)).toBe(true);
// And the customer's endpoint heard nothing, because nothing is dispatching.
// If this ever fails, some other process is delivering webhooks and every
// other assertion in this file is measuring the wrong thing.
expect(endpoint.received).toHaveLength(0);
}, 60_000);
});Dispatcher không được khởi động trong file này. Nó hoàn toàn không tồn tại trong suốt journey, còn platform được kỳ vọng không nhận thấy điều đó: message vẫn đến end user, trong khi endpoint của khách hàng không nhận được gì.
Cái giá của service split cũng cần được nói rõ; một chương chỉ liệt kê lợi ích của quyết định do chính nó đưa ra thì chỉ là quảng cáo:
- Một network hop thay cho function call trước đây. Mọi thay đổi state mà dispatcher thực hiện đều cần một HTTP round trip tới API. Điều này tạo thêm latency, một connection pool và một failure mode mới — API không thể truy cập — cần được xử lý rõ ràng.
- Một credential mới phải được quản lý. Trước đây platform chưa có service-to-service authentication; giờ có thêm một secret cần quy trình rotate.
- Một deployable cần vận hành. Thêm một image, một health check và thêm một component có thể chết. Compose file lớn hơn, CI cũng vậy.
- Internal API trở thành một contract.
/internal/dispatchcó hai consumer — dispatcher và test — nên thay đổi nó đồng nghĩa phải thay đổi cả hai phía.
Trade-off có đáng hay không phụ thuộc vào property nhận được. Ở đây, property ấy là isolation giữa server hỏng của một khách hàng và service phục vụ mọi khách hàng khác; nó đáng giá một network hop. Outbox relay không nhận được lợi ích đủ lớn như vậy, đó là lý do relay vẫn nằm trong API.
Internal seam và credential đi qua nó
Dispatcher không được phép ghi PostgreSQL, vì vậy mọi state change đều phải đi qua API. Điều đó tạo ra một caller type mới và một principal type mới:
@@ -18,13 +18,46 @@ export interface ApplicationPrincipal {
export interface UserPrincipal {
kind: "user";
environmentId: string;
userExternalId: string;
}
-export type Principal = ApplicationPrincipal | UserPrincipal;
+/** The platform acting for itself (chapter 3.5).
+ *
+ * The dispatcher is the first caller of the internal seam that is neither a
+ * tenant's software nor an end user: it consumes every environment's events and
+ * asks the api to write on behalf of all of them.
+ *
+ * THE SHORTCUT THAT WOULD HAVE WORKED AND BEEN WRONG: mint the dispatcher an API
+ * key. It would authenticate on the first try — and an `application` principal
+ * is scoped to exactly ONE environment by construction (3.1, 3.2), so a
+ * dispatcher holding one either cannot serve other tenants or has been quietly
+ * granted cross-tenant reach through the credential type whose entire meaning is
+ * that it has none. Principle I is a correctness property, and that is the shape
+ * its erosion would take.
+ *
+ * So this kind carries NO `environmentId`. That is not an omission — it is what
+ * stops it being usable anywhere a tenant is expected: every environment-scoped
+ * provider reads `environmentId`, and a principal without one cannot silently
+ * become a tenant's. It is accepted only where a route opts in, and never on a
+ * public route. */
+export interface PlatformPrincipal {
+ kind: "platform";
+ /** Which internal service presented it, for logs. Never the credential. */
+ service: string;
+ /** Present and always undefined, so the environment-scoped providers that read
+ * `principal?.environmentId` keep compiling AND keep getting nothing. A
+ * platform principal reaching a tenant-scoped repository yields the empty
+ * scope, which the guard has already refused before any handler runs. */
+ environmentId?: undefined;
+}
+
+export type Principal =
+ | ApplicationPrincipal
+ | UserPrincipal
+ | PlatformPrincipal;
export type PrincipalKind = Principal["kind"];
/** The request as everything downstream of the middleware sees it. The
* principal is optional at the type level for one honest reason: a request that
* presented nothing has none, and pre-credential routes (signup) are reached
* exactly that way. */
@@ -34,13 +67,15 @@ export interface RequestWithPrincipal {
}
/** How a credential class is named to a human. Used by the wrong-credential
* error, which must say what was presented and what was expected — and must
* never quote the credential (NFR-SEC-06). */
export function describePrincipalKind(kind: PrincipalKind): string {
- return kind === "application" ? "an API key" : "an end-user token";
+ if (kind === "application") return "an API key";
+ if (kind === "platform") return "an internal platform credential";
+ return "an end-user token";
}
/** The `Bearer <credential>` half of RFC 6750, and nothing else. Query strings
* are refused by omission: URLs reach logs and referrer headers, and NFR-SEC-06
* forbids a credential in either. (The WebSocket upgrade is the one exception,
* and it lives in the gateway where a browser gives no other choice.) */Hãy nhìn environmentId?: undefined trên platform principal. Đó không phải trang
trí — đó là hệ thống kiểu được bắt phải thực thi hiến pháp I. Một platform
credential với tới mọi environment, nên nó không có environment đơn lẻ nào để bị
giới hạn vào, và bất kỳ đoạn code nào đọc .environmentId trên union principal giờ
buộc phải xử lý trường hợp không có. Compiler tìm ra chỗ đầu tiên như vậy ngay lập
tức, trong một controller viết từ ba chương trước.
Cách sửa là thôi hỏi environment và bắt đầu quyết định:
@@ -330,7 +330,34 @@ describe("credentials", () => {
const channel = await repo.createChannel("signup-key", "public");
expect(
(await post({ text: "bootstrapped" }, first.apiKey!.secret, channel.id))
.status,
).toBe(201);
});
+
+ // --- chapter 3.5: the third principal ----------------------------------
+
+ describe("the internal platform credential", () => {
+ // SET, not read. This began as `process.env.RELAY_INTERNAL_CREDENTIAL` with
+ // an early return when it was absent — and CI never set it, so the one
+ // assertion standing between a platform credential and a public route
+ // silently did nothing on every build. A security test that skips itself is
+ // worse than no test: it reports green about a question it never asked.
+ const PLATFORM = "rk_svc_credentials_itest_0123456789abcdef01234";
+ process.env["RELAY_INTERNAL_CREDENTIAL"] = PLATFORM;
+
+ it("is refused on a public route, whatever else it can do", async () => {
+
+ // The whole point of the kind. It reaches every environment, so a public
+ // route accepting it would be a cross-tenant hole with a valid credential
+ // in front of it. 403 `wrong_credential_type` — the route's default is
+ // application-or-user, and platform is neither.
+ const res = await post({ text: "should never land" }, PLATFORM);
+
+ expect(res.status).toBe(403);
+ const body = (await res.json()) as { code?: string; message?: string };
+ expect(body.code).toBe("wrong_credential_type");
+ // And it must not quote the credential back (NFR-SEC-06).
+ expect(JSON.stringify(body)).not.toContain(PLATFORM);
+ });
+ });
});Compiler tự tìm ra hệ quả còn lại của principal mới. Một controller viết ở chương
3.2 đọc .environmentId trên một principal mà giờ có thể là platform, và ngừng
biên dịch ngay khoảnh khắc union đổi — đó chính là toàn bộ giá trị của việc biến
sự phân biệt ấy thành một kiểu thay vì một quy ước:
@@ -52,12 +52,20 @@ export class DevTokenController {
@UseGuards(CredentialGuard)
async mint(
@Body(new ZodValidationPipe(devTokenRequestSchema)) body: DevTokenRequest,
@Req() req: RequestWithPrincipal,
): Promise<{ token: string; expires_at: string }> {
const principal = req.principal!;
+ // The guard above already refused anything but an API key, so this is
+ // unreachable — but chapter 3.5 added a third principal kind that carries no
+ // environment at all, and an assumption the compiler cannot see is one a
+ // later refactor can quietly break. Narrowing here costs a line and makes
+ // `@Accepts("application")` a fact rather than a promise.
+ if (principal.kind !== "application") {
+ throw new BadRequestException("an API key is required");
+ }
const environment = await environmentSigningSecret(
this.db,
principal.environmentId,
);
if (!environment) throw new BadRequestException("unknown environment");Bản thân đường nối gồm ba thao tác, và chúng được định hình bởi những gì dispatcher không được phép làm, chứ không phải bởi những gì nó muốn. Nó không nở được event, nên nó hỏi. Nó không đọc được secret, nên nó hỏi. Nó không ghi được kết quả, nên nó báo cáo:
@@ -136,12 +136,124 @@ export const internalMembershipsResponseSchema = z.strictObject({
export const internalSessionResponseSchema = z.strictObject({
environment_id: z.string().min(1),
user: z.string().min(1),
channel_ids: z.array(z.string().min(1)),
});
+/** The deliveries stream (chapter 3.5), and its subject grammar.
+ *
+ * Here rather than in either service, for the reason chapter 3.4 moved the event
+ * grammar here: a consumer that assembles its own subject filter receives
+ * nothing the day the grammar changes — no error, no warning, just an empty
+ * stream position. Two sides, one definition.
+ *
+ * The stream carries only work that is ALREADY DUE. A delivery waiting out a
+ * retry tier is a row in Postgres, not a message the broker is holding — which
+ * is what keeps a dead endpoint from occupying an acknowledgement slot for two
+ * hours (research R1, measured). */
+export const DELIVERIES_STREAM = "DELIVERIES";
+export const DELIVERY_SUBJECT_PREFIX = "deliveries";
+export const ALL_DELIVERIES_SUBJECT = `${DELIVERY_SUBJECT_PREFIX}.>`;
+
+/** One subject per environment, so a future per-tenant dispatcher shard is a
+ * filter change rather than a redesign. */
+export function deliverySubjectFor(environmentId: string): string {
+ if (!environmentId) throw new Error("an environment id is required");
+ return `${DELIVERY_SUBJECT_PREFIX}.${environmentId}`;
+}
+
+// ---------------------------------------------------------------------------
+// The dispatch contract (chapter 3.5, constitution IV).
+//
+// The dispatcher owns no database. "Only the API service writes to PostgreSQL…
+// Other services obtain writes and backfill reads via the API service's internal
+// endpoints." These are those endpoints, and they live here for the reason
+// chapter 2.5 put the gateway's here: both sides validate against ONE definition,
+// so the day a field is renamed the other side fails loudly instead of reading
+// `undefined` three layers away.
+// ---------------------------------------------------------------------------
+
+/** dispatcher → api: turn one event into one delivery per matching endpoint.
+ *
+ * A CLAIMED write: the api reuses chapter 3.4's deduplication ledger, so an event
+ * expanded twice would double every webhook it produced and cannot. The claim and
+ * all N delivery rows commit in one transaction (research R2). */
+export const internalExpandRequestSchema = z.strictObject({
+ event_id: z.string().uuid(),
+ environment_id: z.string().min(1),
+ type: z.string().min(1),
+ /** The envelope as it will be delivered, byte-identical to what 3.3 published.
+ * The dispatcher does not author payloads; it moves them. */
+ payload: z.unknown(),
+});
+
+export const internalExpandResponseSchema = z.strictObject({
+ /** How many deliveries this event produced. Zero is normal and not an error:
+ * no endpoint in that environment subscribes to this type. */
+ created: z.number().int().nonnegative(),
+ /** True when the ledger recognised the event as already expanded. The
+ * dispatcher acknowledges either way — that is what makes a redelivery safe. */
+ duplicate: z.boolean(),
+});
+
+/** dispatcher → api: everything needed to sign and post one delivery.
+ *
+ * The response carries DECRYPTED signing secrets, which is a real widening of
+ * where a customer credential exists. Internal network only, never logged, held
+ * for the signature and no longer (contracts/dispatcher.md). */
+export const internalDeliveryMaterialSchema = z.strictObject({
+ delivery_id: z.string().uuid(),
+ endpoint_id: z.string().uuid(),
+ environment_id: z.string().min(1),
+ event_id: z.string().uuid(),
+ url: z.string().url(),
+ attempt: z.number().int().positive(),
+ /** One or two: two during a 24-hour rotation window, so a recipient holding
+ * either can verify (contracts/webhooks.md §Rotation). */
+ secrets: z.array(z.string().min(1)).min(1).max(2),
+ payload: z.unknown(),
+});
+
+/** dispatcher → api: what happened when we posted.
+ *
+ * NOT a claim — the POST already happened on somebody else's machine and cannot
+ * be undone. Idempotent on `(delivery_id, attempt)`, so a redelivery arriving
+ * after a successful report is recognised and simply acknowledged rather than
+ * posted again (research R5). */
+export const internalDeliveryOutcomeRequestSchema = z.strictObject({
+ delivery_id: z.string().uuid(),
+ attempt: z.number().int().positive(),
+ /** Absent when the attempt never got a response — a timeout or a refused
+ * connection. The platform can only believe a status code it received. */
+ status: z.number().int().optional(),
+ error: z.string().max(2000).optional(),
+ latency_ms: z.number().int().nonnegative(),
+});
+
+export const internalDeliveryOutcomeResponseSchema = z.strictObject({
+ /** What the api did with it: delivered, scheduled for another tier, or moved
+ * to the dead-letter store because the attempts are exhausted. */
+ outcome: z.enum(["delivered", "rescheduled", "dead_lettered"]),
+ /** Present when rescheduled — when the next attempt falls due. */
+ next_attempt_at: z.iso.datetime().optional(),
+});
+
+export type InternalExpandRequest = z.infer<typeof internalExpandRequestSchema>;
+export type InternalExpandResponse = z.infer<
+ typeof internalExpandResponseSchema
+>;
+export type InternalDeliveryMaterial = z.infer<
+ typeof internalDeliveryMaterialSchema
+>;
+export type InternalDeliveryOutcomeRequest = z.infer<
+ typeof internalDeliveryOutcomeRequestSchema
+>;
+export type InternalDeliveryOutcomeResponse = z.infer<
+ typeof internalDeliveryOutcomeResponseSchema
+>;
+
export type InternalSendRequest = z.infer<typeof internalSendRequestSchema>;
export type InternalSessionResponse = z.infer<
typeof internalSessionResponseSchema
>;
export type InternalSendResponse = z.infer<typeof internalSendResponseSchema>;
export type InternalMembershipsResponse = z.infer<Cả hai phía cùng import các schema đó, và đó là thứ chặn hợp đồng trôi dạt: dispatcher không thể gửi một hình dạng mà api không parse nổi, bởi hình dạng ấy là một định nghĩa duy nhất chứ không phải hai bản hôm nay còn khớp nhau.
import {
Body,
Controller,
Inject,
NotFoundException,
Post,
HttpCode,
UseGuards,
} from "@nestjs/common";
import {
internalDeliveryOutcomeRequestSchema,
internalExpandRequestSchema,
type InternalDeliveryOutcomeRequest,
type InternalDeliveryOutcomeResponse,
type InternalExpandRequest,
type InternalExpandResponse,
} from "@relay/protocol";
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import type { Db } from "../db/client";
import {
DeliveryNotFoundError,
deliveryMaterial,
expandEventToDeliveries,
recordAttemptOutcome,
replayDeadLetter,
} from "../db/repository";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
// The dispatcher's only road to state (chapter 3.5, constitution IV).
//
// "Only the API service writes to PostgreSQL… Other services obtain writes and
// backfill reads via the API service's internal endpoints." The dispatcher owns
// no database, so everything it needs is here — and that constraint is not a
// workaround, it is the reason chapter 3.4's claim-and-effect-in-one-transaction
// pattern stops applying and the chapter has something to say.
//
// `@Accepts("platform")` and nothing else. These routes reach EVERY environment,
// which is exactly why no tenant credential may use them: an API key is scoped to
// one environment by construction, and a route that accepted one here would
// either be useless to the dispatcher or would have to ignore the scope — and
// ignoring a tenant scope is the shape a cross-tenant hole takes.
@Controller("internal/dispatch")
@UseGuards(CredentialGuard)
@Accepts("platform")
export class DispatchController {
constructor(@Inject("DB") private readonly db: Db) {}
/** One event becomes one delivery per matching endpoint — claimed, so it
* happens exactly once however often the broker redelivers (research R2). */
@Post("expand")
@HttpCode(200)
async expand(
@Body(new ZodValidationPipe(internalExpandRequestSchema))
body: InternalExpandRequest,
): Promise<InternalExpandResponse> {
const result = await expandEventToDeliveries(this.db, {
eventId: body.event_id,
environmentId: body.environment_id,
type: body.type,
payload: body.payload,
});
return { created: result.created, duplicate: result.duplicate };
}
/** Everything needed to sign and post one delivery, including the DECRYPTED
* signing secrets — one, or two during a rotation window.
*
* This is the one response in the platform that carries a customer credential
* in plaintext. Internal network only, never logged at any level, and held by
* the dispatcher for the duration of a signature and no longer
* (contracts/dispatcher.md §The secret crosses a process boundary). */
@Post("material")
@HttpCode(200)
async material(@Body() body: { delivery_id?: string }) {
if (!body?.delivery_id) throw new NotFoundException("no such delivery");
const material = await deliveryMaterial(this.db, body.delivery_id);
if (!material) throw new NotFoundException("no such delivery");
return material;
}
/** What happened when we posted. Idempotent on `(delivery_id, attempt)`: the
* dispatcher posts, reports, then acknowledges, so a crash in the last gap
* means this arrives twice. The POST may duplicate — the customer absorbs that
* on the event id — but the SCHEDULE must not advance twice for one attempt. */
@Post("outcome")
@HttpCode(200)
async outcome(
@Body(new ZodValidationPipe(internalDeliveryOutcomeRequestSchema))
body: InternalDeliveryOutcomeRequest,
): Promise<InternalDeliveryOutcomeResponse> {
try {
// Spread rather than assign: `exactOptionalPropertyTypes` is on, so an
// explicit `undefined` is not the same as an absent key — and the
// difference is real here, because "no status" means the attempt never got
// a response at all.
const result = await recordAttemptOutcome(this.db, {
deliveryId: body.delivery_id,
attempt: body.attempt,
...(body.status !== undefined ? { status: body.status } : {}),
...(body.error !== undefined ? { error: body.error } : {}),
});
return {
outcome: result.outcome,
...(result.nextAttemptAt
? { next_attempt_at: result.nextAttemptAt.toISOString() }
: {}),
};
} catch (error) {
if (error instanceof DeliveryNotFoundError) {
throw new NotFoundException("no such delivery");
}
throw error;
}
}
/** Put a dead-lettered delivery back on the schedule, with the endpoint's
* CURRENT url and secret — which is automatic, because delivery material is
* read at send time rather than frozen into the delivery.
*
* Internal for now. FR-WHK-04 also asks for dead letters to be inspectable and
* replayable FROM THE DASHBOARD, and the dashboard is chapter 5.2's; this is
* the mechanism that screen will call, built where it can be tested today. */
@Post("replay")
@HttpCode(200)
async replay(@Body() body: { dead_letter_id?: string }) {
if (!body?.dead_letter_id) throw new NotFoundException("no such dead letter");
const replayed = await replayDeadLetter(this.db, body.dead_letter_id);
if (!replayed) throw new NotFoundException("no such dead letter");
return { replayed: true };
}
}@@ -1,16 +1,38 @@
-import { Module } from "@nestjs/common";
+import { Module, Scope } from "@nestjs/common";
import { MessagesModule } from "../messages/messages.module";
import { AuthModule } from "../auth/auth.module";
+import { createDb, createPool, type Db } from "../db/client";
import { BackfillController } from "./backfill.controller";
import { InternalController } from "./internal.controller";
+import { DispatchController } from "./dispatch.controller";
import { SessionController } from "./session.controller";
// The internal routes reuse MessagesModule's providers wholesale — the
// request-scoped Repository, the guard, the service. One write path, two
// doors (ADR-04/05).
+//
+// Chapter 3.5 adds the dispatch controller, which needs an UNSCOPED connection
+// rather than the request-scoped Repository: one dispatcher serves every
+// environment, so its operations take the tenant from the row they touch rather
+// than from a principal. `MessagesModule` provides "DB" but does not export it,
+// so this module declares its own — the same DEFAULT-scoped factory every other
+// module here uses, and a smaller change than widening 2.2's exports for a
+// reason 2.2 has nothing to do with.
@Module({
imports: [MessagesModule, AuthModule],
- controllers: [InternalController, BackfillController, SessionController],
+ controllers: [
+ InternalController,
+ BackfillController,
+ SessionController,
+ DispatchController,
+ ],
+ providers: [
+ {
+ provide: "DB",
+ useFactory: (): Db => createDb(createPool()),
+ scope: Scope.DEFAULT,
+ },
+ ],
})
export class InternalModule {}Relay, một lần nữa
Delivery relay chính là loop của chương 3.3 với thêm một predicate. Chỉ có vậy; việc không cần abstraction mới chứng minh chương 3.3 đã đặt seam đúng chỗ:
import {
ALL_DELIVERIES_SUBJECT,
DELIVERIES_STREAM,
deliverySubjectFor,
} from "@relay/protocol";
import type { Logger } from "@relay/service-kit";
import {
DiscardPolicy,
RetentionPolicy,
StorageType,
type NatsConnection,
} from "nats";
import type { Db } from "../db/client";
import { drainDueDeliveries, type DueDeliveryRow } from "../db/repository";
import type { Publisher } from "../outbox/publisher";
// The second relay (chapter 3.5, research R13).
//
// This is the moment chapter 3.3's outbox stops being a thing that moves EVENTS
// and becomes a shape: `SELECT … FOR UPDATE SKIP LOCKED`, publish, mark, for any
// work the platform owes itself and must not lose. The second instance is what
// makes it a pattern rather than a trick, and the reader has already built it.
//
// The one difference from 3.3's relay is a predicate — `next_attempt_at <=
// now()` — and that predicate IS the retry schedule. Nothing waits in the
// broker; a delivery enters the stream only once it is already due (research R1,
// measured).
/** One delivery, as it reaches the dispatcher, is deliberately thin: the id and
* the attempt, and nothing else. The dispatcher fetches the URL, the payload and
* the signing secrets over the internal seam when it is ready to send, so a
* customer credential never sits in a broker (contracts/dispatcher.md).
*
* The subject grammar lives in `@relay/protocol` — 3.4's lesson, applied again:
* two sides, one definition, or a consumer silently receives nothing. */
/** The api creates this stream because the api publishes to it. A publisher
* whose stream does not exist gets a 503 back from the broker, which is a
* confusing way to discover that JetStream does not create streams on demand.
*
* Its age bound is sized for "how long may the DISPATCHER be down", not "how
* long is the longest retry tier" — because nothing waits here. Conflating those
* two numbers is how the first design went wrong (research R1). */
const DELIVERIES_MAX_AGE_NS = 7 * 24 * 60 * 60 * 1_000_000_000;
const DELIVERIES_MAX_BYTES = 1024 * 1024 * 1024;
export async function ensureDeliveriesStream(nc: NatsConnection): Promise<void> {
const jsm = await nc.jetstreamManager();
const mutable = {
subjects: [ALL_DELIVERIES_SUBJECT],
max_age: DELIVERIES_MAX_AGE_NS,
max_bytes: DELIVERIES_MAX_BYTES,
discard: DiscardPolicy.Old,
};
const existing = await jsm.streams.info(DELIVERIES_STREAM).catch(() => null);
if (existing === null) {
await jsm.streams.add({
name: DELIVERIES_STREAM,
retention: RetentionPolicy.Limits,
storage: StorageType.File,
...mutable,
});
return;
}
// Retention and storage are immutable on an existing stream — chapter 3.4
// measured that (its research R1), and the lesson transfers unchanged.
await jsm.streams.update(DELIVERIES_STREAM, { ...existing.config, ...mutable });
}
/** Small, for 3.3's reason: a batch is held inside one transaction, and a long
* one holds row locks while it publishes. */
const BATCH_SIZE = 50;
/** The poll interval when there is nothing due. Deliveries become due on a clock
* rather than on an insert, so unlike 3.3's relay there is no "wake me on write"
* shortcut to be tempted by — a timer is the correctness path here, not a
* fallback behind one. */
const IDLE_INTERVAL_MS = 250;
export interface DeliveryRelay {
start(): void;
stop(): Promise<void>;
/** One pass, for tests and for the walk script — the same code path `start`
* runs, so nothing is proven about a loop only tests exercise. */
drainOnce(): Promise<number>;
}
export function createDeliveryRelay({
db,
publisher,
logger,
batchSize = BATCH_SIZE,
intervalMs = IDLE_INTERVAL_MS,
}: {
db: Db;
publisher: Publisher;
logger: Logger;
batchSize?: number;
intervalMs?: number;
}): DeliveryRelay {
let running = false;
let loop: Promise<void> = Promise.resolve();
async function drainOnce(): Promise<number> {
return drainDueDeliveries(db, batchSize, async (row: DueDeliveryRow) => {
// The same three-field port chapter 3.3 defined, unchanged. That the
// second relay needed no new seam is the evidence that 3.3's abstraction
// was drawn in the right place.
await publisher.publish({
subject: deliverySubjectFor(row.environment_id),
// The deduplication key is the delivery id AND THE ATTEMPT, and the
// second half was missing until a walk against a failing endpoint went
// looking for attempt 2 and found nothing had been sent.
//
// `row.id` alone is stable across all seven attempts, so every retry
// republished under the key the first attempt had already used and
// JetStream collapsed it inside the duplicate window. The publish
// reported success, no message reached the dispatcher, and the row kept
// the `dispatched_at` its claim had set — which only an outcome report
// clears, and no outcome was ever coming. Every failing delivery stopped
// dead after one attempt and the retry schedule below it never ran.
//
// Per attempt, a republish after a crash is still recognisably the same
// work, which is what the deduplication is for. A NEW attempt is
// genuinely new work and has to be allowed to say so.
id: `${row.id}:${row.attempt}`,
payload: {
delivery_id: row.id,
endpoint_id: row.endpoint_id,
event_id: row.event_id,
attempt: row.attempt,
},
});
});
}
async function run(): Promise<void> {
while (running) {
try {
const dispatched = await drainOnce();
if (dispatched > 0) {
// Counts, never payloads — and never a signing secret, which is why
// this relay publishes ids rather than material (NFR-SEC-06).
logger.log("info", "deliveries.dispatched", { count: dispatched });
continue; // straight back for more; a backlog should not wait out the idle interval
}
} catch (error) {
// A broker that is down is an expected state, not a crash. Rows stay
// pending and due, so the backlog drains when it returns — the same
// buffering 3.3's relay promises for events.
logger.log("error", "deliveries.drain_failed", { error: String(error) });
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
return {
start() {
if (running) return;
running = true;
loop = run();
},
async stop() {
running = false;
await loop;
},
drainOnce,
};
}SELECT … FOR UPDATE SKIP LOCKED vẫn làm công việc như ở chương 3.3: hai API
instance cùng drain một table sẽ lấy các row khác nhau thay vì tranh chấp cùng
một row. Phần mới là next_attempt_at <= now(). Row chưa đến hạn đơn giản không
được chọn; nó không giữ hay chặn tài nguyên nào và chỉ tốn một index entry.
Publisher của chương 3.3 chỉ cần thay đổi đúng một chỗ: điều chỉnh một assumption
nhỏ chứ không thêm cơ chế mới. Publisher từng bảo đảm stream EVENTS tồn tại khi
kết nối vì ở chương 3.3 đó là stream duy nhất. Stream thứ hai biến câu hỏi "cần
bảo đảm stream nào tồn tại" thành một parameter:
@@ -93,24 +93,33 @@ export async function ensureStream(nc: NatsConnection): Promise<void> {
}
await jsm.streams.update(STREAM, { ...existing.config, ...mutable });
}
export function createJetStreamPublisher({
url = process.env.RELAY_NATS_URL ?? DEFAULT_NATS_URL,
-}: { url?: string } = {}): Publisher {
+ ensure = ensureStream,
+}: {
+ url?: string;
+ /** Which stream this publisher is responsible for bringing into existence.
+ * Defaults to the EVENTS stream this chapter created. Chapter 3.5 passes its
+ * own: a publisher that ensures the wrong stream publishes into nothing and
+ * gets a 503 back, which is a confusing way to learn that streams are not
+ * created on demand. */
+ ensure?: (nc: NatsConnection) => Promise<void>;
+} = {}): Publisher {
let connection: NatsConnection | null = null;
let js: JetStreamClient | null = null;
/** Connection is LAZY and re-attempted. The api must start and accept writes
* with the broker unreachable — a service that refuses to boot without its
* event spine has made the spine a dependency of the write path, which is the
* opposite of what an outbox is for (research R9). */
async function client(): Promise<JetStreamClient> {
if (js && connection && !connection.isClosed()) return js;
const nc = await connect({ servers: url });
- await ensureStream(nc);
+ await ensure(nc);
connection = nc;
js = nc.jetstream();
return js;
}
return {Nếu thiếu parameter này, lần publish đầu tiên vào deliveries.* sẽ nhận 503 từ
một broker chưa từng được cấu hình để tạo stream tương ứng.
Dispatcher
Hai consumer đảm nhiệm hai việc khác nhau. Consumer thứ nhất expand event thành delivery row; consumer thứ hai POST các delivery mà relay xác định đã đến hạn:
import {
AckPolicy,
connect,
DeliverPolicy,
type JsMsg,
type NatsConnection,
} from "nats";
import {
ALL_DELIVERIES_SUBJECT,
ALL_EVENTS_SUBJECT,
DELIVERIES_STREAM,
} from "@relay/protocol";
import { createLogger, type Logger } from "@relay/service-kit";
import { createApiClient, type ApiClient } from "./api-client.js";
import { ATTEMPT_TIMEOUT_MS, deliverOnce, type DeliveryJob } from "./deliver.js";
import { expandOnce } from "./expand.js";
// The dispatcher (chapter 3.5) — the first service in this platform that exists
// because of constitution IV rather than in spite of it.
//
// TWO CONSUMERS, and they do different jobs on purpose:
//
// events.> -> expand one event into one delivery row per matching
// endpoint. A claimed write, so a redelivered event cannot
// double a customer's webhooks.
// deliveries.> -> post one delivery that the api's relay has decided is DUE.
// Nothing waits here: a delivery not yet due is a row, not a
// message the broker is holding (research R1, measured).
//
// Frameworkless and ESM, mirroring the gateway. ADR-15 binds NestJS to the API
// service only, and a second Nest application would be adopting a framework by
// momentum rather than by decision.
const EVENTS_STREAM = "EVENTS";
/** One durable per job. A durable name is a POSITION in a stream shared by every
* instance using it, so two dispatchers divide the work rather than each
* receiving everything (chapter 3.4's research R8).
*
* Overridable, and chapter 3.4's suites explain why: a test that shares the
* production durable inherits every message every previous run left behind, and
* a batch of twenty-five is quickly all backlog. A per-run durable with
* `DeliverPolicy.New` is the same trick 2.1 used for environments and 2.6 for
* subjects — the only namespace a stream position has is its name. */
export const EXPAND_DURABLE = "dispatcher-expand";
export const DELIVER_DURABLE = "dispatcher-deliver";
const BATCH = 25;
/** How long the broker waits for an acknowledgement before deciding the
* dispatcher died and handing the message to someone else. Overridable ONLY so
* tests can observe redelivery: at thirty seconds, an assertion that an
* unacknowledged message comes back has to wait thirty seconds, so in practice
* it is written with a short window, never observes anything, and passes no
* matter what the code does. That is how the `term` decision below went
* unmeasured — the test looked right and proved nothing. */
export const ACK_WAIT_MS = 30_000;
/** The DELIVERY attempt budget lives in the api's tier table, not here. This
* bound is only about how many times the BROKER redelivers a job the dispatcher
* failed to process at all — a crash, or an api that was unreachable. Confusing
* the two would let a broker redelivery consume a customer's retry. */
const MAX_DELIVER = 10;
const MAX_ACK_PENDING = 100;
const DEFAULT_NATS_URL = "nats://localhost:4222";
const DEFAULT_API_URL = "http://127.0.0.1:4000";
export interface Dispatcher {
ready(): Promise<void>;
start(): void;
stop(): Promise<void>;
/** One pass of each consumer, for tests and the walk script — the same code
* path `start` runs, so nothing is proven about a loop only tests exercise. */
pollOnce(): Promise<{ expanded: number; delivered: number }>;
}
export function createDispatcher({
natsUrl = process.env["RELAY_NATS_URL"] ?? DEFAULT_NATS_URL,
apiUrl = process.env["RELAY_API_URL"] ?? DEFAULT_API_URL,
credential = process.env["RELAY_INTERNAL_CREDENTIAL"] ?? "",
logger = createLogger("dispatcher"),
api = createApiClient(apiUrl, credential),
attemptTimeoutMs = ATTEMPT_TIMEOUT_MS,
durables = { expand: EXPAND_DURABLE, deliver: DELIVER_DURABLE },
deliverPolicy = DeliverPolicy.All,
ackWaitMs = { expand: ACK_WAIT_MS, deliver: ACK_WAIT_MS },
}: {
natsUrl?: string;
apiUrl?: string;
credential?: string;
logger?: Logger;
api?: ApiClient;
attemptTimeoutMs?: number;
durables?: { expand: string; deliver: string };
deliverPolicy?: DeliverPolicy;
/** PER CONSUMER, and the split is load-bearing. Shortening this for the whole
* dispatcher to make one expand assertion observable also shortened it for
* the DELIVER consumer, whose attempts run to a timeout — so under coverage
* instrumentation a batch outlived its acknowledgement window, the broker
* decided the dispatcher had died, and redelivered work that was in fact in
* progress. Two customers received the same webhook twice and two unrelated
* tests failed. One knob for two consumers with different time budgets is the
* bug; this is the fix. */
ackWaitMs?: { expand: number; deliver: number };
} = {}): Dispatcher {
let connection: NatsConnection | null = null;
let running = false;
let loop: Promise<void> = Promise.resolve();
/** Lazy, like every other broker client in this workspace: an unreachable
* broker must not stop the service from starting, and an unreachable API
* service must not either — the work is durable in both directions. */
async function connection_(): Promise<NatsConnection> {
if (connection && !connection.isClosed()) return connection;
const nc = await connect({ servers: natsUrl });
const jsm = await nc.jetstreamManager();
// The DELIVERIES stream is created by the API SERVICE, which publishes to
// it. The dispatcher only consumes, so it does not define the stream — two
// definitions of one stream is a drift waiting for the day they disagree.
// Consumer creation below simply fails until the api has been up once, and
// the poll loop retries; nothing is lost, because nothing is due yet either.
for (const [stream, durable, ackWait] of [
[EVENTS_STREAM, durables.expand, ackWaitMs.expand],
[DELIVERIES_STREAM, durables.deliver, ackWaitMs.deliver],
] as const) {
await jsm.consumers
.add(stream, {
durable_name: durable,
ack_policy: AckPolicy.Explicit,
deliver_policy: deliverPolicy,
ack_wait: ackWait * 1_000_000,
max_deliver: MAX_DELIVER,
max_ack_pending: MAX_ACK_PENDING,
...(stream === EVENTS_STREAM
? { filter_subject: ALL_EVENTS_SUBJECT }
: { filter_subject: ALL_DELIVERIES_SUBJECT }),
})
// Created if absent, left alone if present: two dispatchers starting
// together must share a position rather than fight over it.
.catch(() => undefined);
}
connection = nc;
return nc;
}
async function drain(
stream: string,
durable: string,
handle: (msg: JsMsg) => Promise<"ack" | "term" | "retry">,
): Promise<number> {
const nc = await connection_();
const consumer = await nc.jetstream().consumers.get(stream, durable);
const messages = await consumer.fetch({ max_messages: BATCH, expires: 1_000 });
let handled = 0;
for await (const msg of messages) {
let decision: "ack" | "term" | "retry" = "retry";
try {
decision = await handle(msg);
} catch (error) {
// Not acknowledging IS how this asks for the work back. The api being
// unreachable lands here and must not consume anything.
logger.log("error", "dispatcher.handle_failed", {
stream,
error: String(error),
});
}
if (decision === "ack") {
msg.ack();
handled++;
} else if (decision === "term") {
msg.term();
handled++;
}
}
return handled;
}
/** Deliveries, grouped by endpoint and run in parallel.
*
* THIS IS THE ISOLATION PROPERTY, and it is structural rather than a tuning
* choice. FR-WHK-05 says one customer's endpoint must not delay deliveries to
* another; processing a batch one message at a time makes that false the
* moment a single endpoint hangs, because every delivery behind it waits out
* a timeout it had nothing to do with.
*
* ONE in-flight attempt per endpoint, endpoints concurrent. Serialising WITHIN
* an endpoint matters too: two attempts at once against the same customer
* would let a retry overtake the attempt it is retrying, and a customer whose
* server is already struggling is the last one to hand extra concurrency
* (research R7). */
async function drainByEndpoint(): Promise<number> {
const nc = await connection_();
const consumer = await nc.jetstream().consumers.get(
DELIVERIES_STREAM,
durables.deliver,
);
const messages = await consumer.fetch({ max_messages: BATCH, expires: 1_000 });
const byEndpoint = new Map<string, { msg: JsMsg; job: DeliveryJob }[]>();
for await (const msg of messages) {
let job: DeliveryJob;
try {
job = msg.json<DeliveryJob>();
} catch {
logger.log("error", "deliver.undecodable", { seq: msg.seq });
msg.term();
continue;
}
const queue = byEndpoint.get(job.endpoint_id) ?? [];
queue.push({ msg, job });
byEndpoint.set(job.endpoint_id, queue);
}
let handled = 0;
await Promise.all(
[...byEndpoint.values()].map(async (queue) => {
for (const { msg, job } of queue) {
try {
await deliverOnce(api, logger, job, attemptTimeoutMs);
} catch (error) {
// The API SERVICE is unreachable — the one failure this must not
// absorb. Leaving the message unacknowledged is how the work comes
// back; a customer's 500 never lands here, because that is an
// outcome rather than an error.
logger.log("error", "dispatcher.handle_failed", {
stream: DELIVERIES_STREAM,
error: String(error),
});
continue;
}
// Acknowledged whatever the customer said. A 500 is not the
// dispatcher's failure to handle the job — it is the job's OUTCOME,
// already recorded by the api, which has scheduled the next tier or
// dead-lettered it. Redelivering here would retry outside the
// schedule the customer was promised.
msg.ack();
handled++;
}
}),
);
return handled;
}
async function pollOnce(): Promise<{ expanded: number; delivered: number }> {
const expanded = await drain(EVENTS_STREAM, durables.expand, async (msg) => {
// `msg.json()` THROWS on bytes that are not JSON, and a throw here would
// land in drain()'s catch and be treated as "ask for the work back" — so
// bytes that can never parse would be redelivered until the broker's
// attempt budget ran out. Chapter 3.4 settled this: the same bytes fail the
// same way every time, so an unparseable payload is terminated on the FIRST
// attempt. Decoding defensively is what keeps that promise here.
let raw: unknown;
try {
raw = msg.json();
} catch {
logger.log("error", "expand.undecodable", { seq: msg.seq });
return "term";
}
const outcome = await expandOnce(api, logger, raw);
// A duplicate is acknowledged because it genuinely has been handled — by a
// previous delivery of this same event.
return outcome === "unparseable" ? "term" : "ack";
});
const delivered = await drainByEndpoint();
return { expanded, delivered };
}
async function run(): Promise<void> {
while (running) {
try {
const { expanded, delivered } = await pollOnce();
if (expanded > 0 || delivered > 0) continue;
} catch (error) {
logger.log("error", "dispatcher.poll_failed", { error: String(error) });
}
await new Promise((resolve) => setTimeout(resolve, 200));
}
}
return {
/** Force the consumers into existence without processing anything. A suite
* using `DeliverPolicy.New` must create its position BEFORE it publishes, or
* the message it is about to send lands before the consumer exists and is
* never seen. */
async ready(): Promise<void> {
await connection_();
},
start() {
if (running) return;
running = true;
loop = run();
},
async stop() {
running = false;
await loop;
if (connection && !connection.isClosed()) await connection.drain();
connection = null;
},
pollOnce,
};
}
// Entry point. `import.meta` rather than `require.main`: this package is ESM,
// unlike the api (ADR-15's dialect split).
if (import.meta.url === `file://${process.argv[1]}`) {
const dispatcher = createDispatcher();
dispatcher.start();
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void dispatcher.stop().then(() => process.exit(0));
});
}
}Việc gom nhóm trong drainByEndpoint chính là tính chất cô lập, và nó mang tính
cấu trúc chứ không phải một lựa chọn tinh chỉnh. FR-WHK-05 nói endpoint của một
khách hàng không được làm chậm delivery tới khách hàng khác. Xử lý một batch từng
message một thì điều đó sai ngay khoảnh khắc một endpoint treo, bởi mọi delivery
xếp sau nó phải chờ hết một timeout chẳng liên quan gì tới mình. Gom theo endpoint
rồi chạy các nhóm song song khiến tính chất ấy đúng ngay từ trong cấu trúc.
Tuần tự hoá bên trong một endpoint cũng quan trọng, theo chiều ngược lại. Hai lần thử cùng lúc vào cùng một khách hàng sẽ để một retry vượt mặt chính lần thử mà nó đang retry — và một khách hàng có server vốn đã chật vật là người cuối cùng đáng bị trao thêm concurrency.
Về phía dispatcher, expansion chỉ là một request và không gì khác. Nó không ghi được các dòng, nên toàn bộ công việc của nó là giải mã một event nó không tin và nhờ api làm phần việc:
import type { Logger } from "@relay/service-kit";
import type { ApiClient } from "./api-client.js";
// Turning an event into deliveries (chapter 3.5).
//
// The dispatcher does not write the rows. It cannot — constitution IV reserves
// PostgreSQL writes to the API service — so it asks, and the api does the work
// inside chapter 3.4's claim transaction. What looks like an inconvenience is
// what makes the operation exactly-once: the claim and the N delivery rows
// commit together or not at all, so an event the broker redelivers cannot
// produce a second set of webhooks (research R2).
/** The envelope as chapter 3.3 publishes it. Parsed here rather than trusted,
* for chapter 2.5's reason: a message that has been sitting in a stream has had
* time to stop matching the code that reads it. */
export interface EventEnvelope {
id: string;
type: string;
environment_id: string;
}
export function parseEventEnvelope(raw: unknown): EventEnvelope | null {
if (typeof raw !== "object" || raw === null) return null;
const e = raw as Record<string, unknown>;
if (
typeof e["id"] !== "string" ||
typeof e["type"] !== "string" ||
typeof e["environment_id"] !== "string"
) {
return null;
}
return {
id: e["id"],
type: e["type"],
environment_id: e["environment_id"],
};
}
export type ExpandOutcome = "expanded" | "duplicate" | "unparseable";
/** One event, expanded.
*
* `unparseable` is terminal and deliberately so — the same bytes fail the same
* way every time, so retrying spends delivery attempts to reach a conclusion
* that was available on the first. Chapter 3.4 made the same call for the same
* reason. */
export async function expandOnce(
api: ApiClient,
logger: Logger,
raw: unknown,
): Promise<ExpandOutcome> {
const event = parseEventEnvelope(raw);
if (!event) {
logger.log("error", "expand.unparseable", {});
return "unparseable";
}
const result = await api.expand({
event_id: event.id,
environment_id: event.environment_id,
type: event.type,
payload: raw,
});
// Identifiers and counts. `raw` is a tenant's event and never reaches a log
// line (NFR-SEC-06).
logger.log("info", "expand.done", {
event_id: event.id,
type: event.type,
created: result.created,
duplicate: result.duplicate,
});
return result.duplicate ? "duplicate" : "expanded";
}Envelope được parse chứ không được tin, vì lý do của chương 2.5: một message nằm
trong stream đủ lâu thì đã có thời gian để thôi khớp với đoạn code đang đọc nó. Và
unparseable là chung cuộc — cùng những byte ấy sẽ hỏng theo đúng một kiểu ở mọi
lần, nên retry là tiêu ngân sách thử của broker để đi tới một kết luận vốn đã có
sẵn ngay từ lượt đầu.
Client mang tất cả những thứ đó thì nhỏ, và nhỏ có chủ đích:
import {
internalDeliveryMaterialSchema,
internalDeliveryOutcomeResponseSchema,
internalExpandResponseSchema,
type InternalDeliveryMaterial,
type InternalDeliveryOutcomeRequest,
type InternalDeliveryOutcomeResponse,
type InternalExpandRequest,
type InternalExpandResponse,
} from "@relay/protocol";
// The dispatcher's only road to state (chapter 3.5, constitution IV).
//
// "Only the API service writes to PostgreSQL… Other services obtain writes and
// backfill reads via the API service's internal endpoints." This file is the
// whole of that road — there is no database client in this service, and the lint
// rule that forbids importing `pg` or `drizzle-orm` outside the api's `db/`
// makes that a property of the build rather than of anyone's discipline.
//
// Shaped exactly like the gateway's (chapter 2.5), for the reason 2.5 gave:
// request and response types come from `@relay/protocol`, so the day a field is
// renamed both sides fail loudly instead of one reading `undefined` three layers
// away. And responses are PARSED, not assumed — an internal caller has no more
// right to trust a payload's shape than an external one does.
export class ApiError extends Error {
readonly status: number;
constructor(what: string, status: number) {
super(`${what} failed: ${status}`);
this.name = "ApiError";
// Declared and assigned rather than a parameter property:
// `erasableSyntaxOnly` is on everywhere except the api (ADR-15).
this.status = status;
}
}
export interface ApiClient {
/** One event becomes one delivery per matching endpoint. Claimed on the api's
* side, so a redelivered event expands exactly once. */
expand(event: InternalExpandRequest): Promise<InternalExpandResponse>;
/** Everything needed to sign and post — including the decrypted signing
* secrets. Null means the delivery is no longer deliverable: its endpoint was
* paused or removed after the delivery was scheduled. */
material(deliveryId: string): Promise<InternalDeliveryMaterial | null>;
/** What happened. Idempotent on `(delivery_id, attempt)` at the api. */
reportOutcome(
outcome: InternalDeliveryOutcomeRequest,
): Promise<InternalDeliveryOutcomeResponse>;
}
export function createApiClient(
baseUrl: string,
credential: string,
): ApiClient {
// The platform credential, not an API key. An `application` principal is
// scoped to ONE environment by construction, and the dispatcher serves every
// environment — so a route accepting one here would either be useless or would
// have to ignore the scope, and ignoring a tenant scope is the shape a
// cross-tenant hole takes (research R6).
const headers = {
"content-type": "application/json",
authorization: `Bearer ${credential}`,
};
async function post(path: string, body: unknown): Promise<Response> {
return fetch(`${baseUrl}/internal/dispatch/${path}`, {
method: "POST",
headers,
body: JSON.stringify(body),
});
}
return {
async expand(event) {
const res = await post("expand", event);
if (!res.ok) throw new ApiError("expand", res.status);
return internalExpandResponseSchema.parse(await res.json());
},
async material(deliveryId) {
const res = await post("material", { delivery_id: deliveryId });
// 404 is not an error here: an endpoint paused or deleted after the
// delivery was scheduled makes it undeliverable, and the spec's edge case
// says such a delivery must not be sent. A throw would turn a normal
// outcome into a retry loop.
if (res.status === 404) return null;
if (!res.ok) throw new ApiError("material", res.status);
return internalDeliveryMaterialSchema.parse(await res.json());
},
async reportOutcome(outcome) {
const res = await post("outcome", outcome);
if (!res.ok) throw new ApiError("outcome", res.status);
return internalDeliveryOutcomeResponseSchema.parse(await res.json());
},
};
}Bản thân một lần thử thì buồn tẻ có chủ đích, và đó mới là điểm mấu chốt — mọi thứ thú vị đã được quyết trước khi nó chạy:
import type { Logger } from "@relay/service-kit";
import type { ApiClient } from "./api-client.js";
import { signatureHeaders } from "./signature.js";
// Posting to a machine the platform does not own (chapter 3.5).
//
// THE ORDER IS THE ARGUMENT: post, then report, then acknowledge.
//
// Chapter 3.4's consumer claimed the event and ran its effect in ONE
// transaction, so a crash between them rolled both back. Nothing here can do
// that. The effect is an HTTP request that has already happened on somebody
// else's machine, and the claim would be a call to another service. They cannot
// share a transaction and no arrangement of them can be made atomic.
//
// So the pattern does not degrade — it stops applying, and what replaces it is a
// choice about which way to be wrong:
//
// claim BEFORE posting → a crash in the gap loses the webhook silently. The
// customer never receives it and nobody can tell.
// post BEFORE reporting → a crash in the gap re-posts on redelivery. The
// customer receives it twice and CAN tell, because the
// envelope carries the event id they deduplicate on.
//
// The platform takes the duplicate. A loss nobody can detect is worse than a
// duplicate the recipient was handed the means to absorb — and chapter 3.3 spent
// itself removing exactly the first kind of failure, so reintroducing it at the
// last hop would undo that work where a customer would feel it.
/** Long enough for a slow-but-working customer, short enough that a hanging one
* cannot hold a worker while six tiers of schedule wait behind it. Stated as a
* decision because both directions cost something: too short fails a customer
* who is merely slow, too long lets one endpoint occupy capacity that belongs to
* everyone else (research R7). */
export const ATTEMPT_TIMEOUT_MS = 10_000;
export interface DeliveryJob {
delivery_id: string;
endpoint_id: string;
event_id: string;
attempt: number;
}
export interface DeliveryResult {
/** What the api decided: delivered, rescheduled onto the next tier, or
* dead-lettered because the attempts are exhausted. `skipped` means the
* delivery was no longer deliverable — its endpoint was paused or removed. */
outcome: "delivered" | "rescheduled" | "dead_lettered" | "skipped";
}
/** One attempt, end to end.
*
* Never throws for a customer's failure: a 500, a timeout and a refused
* connection are all normal inputs to a retry system. It throws only when the
* API SERVICE cannot be reached, because that is the one failure the dispatcher
* must not absorb — the delivery is still due, and the caller must leave the
* message unacknowledged so the work comes back. */
export async function deliverOnce(
api: ApiClient,
logger: Logger,
job: DeliveryJob,
timeoutMs: number = ATTEMPT_TIMEOUT_MS,
): Promise<DeliveryResult> {
const material = await api.material(job.delivery_id);
if (!material) {
// The endpoint was paused or removed after this delivery was scheduled. The
// spec's edge case: events already in the retry schedule for a removed
// endpoint must not be delivered, and must not accumulate forever.
logger.log("info", "delivery.skipped", {
delivery_id: job.delivery_id,
reason: "endpoint_unavailable",
});
return { outcome: "skipped" };
}
// Signed over the EXACT bytes that will be transmitted. Serialising once and
// reusing the string is not an optimisation — signing one rendering and
// sending another is the re-serialisation trap, pointed at ourselves.
const rawBody = JSON.stringify(material.payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const started = Date.now();
let status: number | undefined;
let error: string | undefined;
try {
const response = await fetch(material.url, {
method: "POST",
headers: {
"content-type": "application/json",
...signatureHeaders({ rawBody, timestamp, secrets: material.secrets }),
},
body: rawBody,
signal: AbortSignal.timeout(timeoutMs),
});
status = response.status;
} catch (cause) {
// A timeout or a refused connection. The platform can only believe a status
// code it received, and it received none — so this is a failure, however the
// request may have ended on the customer's side.
error = cause instanceof Error ? cause.message : String(cause);
}
const latencyMs = Date.now() - started;
// Counts, identifiers and durations. Never the payload, never a signature, and
// never `material.secrets` — this is the log line that would leak a customer's
// credential if anyone widened it "just for debugging" (NFR-SEC-06).
logger.log("info", "delivery.attempted", {
delivery_id: job.delivery_id,
endpoint_id: job.endpoint_id,
event_id: job.event_id,
attempt: material.attempt,
status: status ?? null,
latency_ms: latencyMs,
});
// THE GAP. A crash between the POST above and the report below means this
// delivery is redelivered and posted again. That duplicate is the accepted
// failure, and the customer absorbs it on the event id.
const reported = await api.reportOutcome({
delivery_id: job.delivery_id,
attempt: material.attempt,
...(status !== undefined ? { status } : {}),
...(error !== undefined ? { error } : {}),
latency_ms: latencyMs,
});
return { outcome: reported.outcome };
}deliverOnce không bao giờ ném lỗi vì một thất bại của khách hàng. Một 500, một
timeout và một kết nối bị từ chối đều là đầu vào bình thường của một hệ thống
retry, và biến chúng thành exception nghĩa là caller không còn phân biệt được chúng
với đúng một thất bại tuyệt đối không được hấp thụ: api không với tới. Cái đó thì
ném, và message được để nguyên không acknowledge để công việc quay trở lại.
Dead letter
Bảy lần thử không phải "cho tới khi thành công". Đó là một quyết định rằng có những delivery sẽ không bao giờ thành công, và nền tảng dừng lại thay vì retry mãi mãi:
// Attempts exhausted. The dead letter and the state change commit together —
// a delivery marked dead with no dead letter behind it would be a failure
// with no record, which is exactly what FR-WHK-04's seven days are for.
await tx.insert(webhookDeadLetters).values({
id: randomUUID(),
environmentId: delivery.environmentId,
endpointId: delivery.endpointId,
eventId: delivery.eventId,
payload: delivery.payload,
lastStatus: input.status ?? null,
lastError: input.error ?? null,
attempts: delivery.attempt,
});
await tx
.update(webhookDeliveries)
.set({ state: "dead", dispatchedAt: null })
.where(eq(webhookDeliveries.id, delivery.id));
return { outcome: "dead_lettered" as const, nextAttemptAt: null };Dead letter giữ lại payload và status cuối, bởi rồi khách hàng sẽ hỏi chuyện gì đã xảy ra và "nó hỏng" không phải một câu trả lời. Nó được giữ bảy ngày và replay được — và bản replay dùng lại đúng event id gốc, đó mới là chi tiết đáng kể. Một lần replay là chính event ấy được gửi lại, không phải một event mới, nên khách hàng đang deduplicate theo event id sẽ xử lý đúng mà không cần được dặn thêm gì.
Một walk script tìm ra bug thực sự
Mọi chương trong loạt bài này đều ship một script người đọc chạy được. Chương này ship hai: một endpoint cư xử tệ theo lệnh, và một walk đưa đúng một event đi trọn đường tới đó.
// A customer's webhook endpoint, behaving badly on purpose (chapter 3.5).
//
// node scripts/hostile-endpoint.mjs --mode=ok # 200, and prints what arrived
// node scripts/hostile-endpoint.mjs --mode=fail # always 500
// node scripts/hostile-endpoint.mjs --mode=hang # accepts, never responds
// node scripts/hostile-endpoint.mjs --mode=flaky # fails twice, then succeeds
//
// --port=4555 fixed port (default). --port=0 asks the OS for a free one.
// --quiet one line per request instead of the full envelope
// --host=0.0.0.0 bind beyond loopback, so a container can reach it
// --secret=SECRET verify every signature, the way a customer would
// --reserialize verify against a RE-SERIALISED body, and watch it fail
//
// THREE MODES, because a retry system has three interesting inputs and only one
// of them is "the customer is down". A 500 is a server that answered; a hang is
// a server that took the request and said nothing, which is the one that costs
// the platform a worker rather than a round trip; and `flaky` is the ordinary
// case the schedule exists for — a customer who was briefly unwell and recovers,
// where the whole point is that nobody had to intervene.
//
// This is the same artifact the integration suite drives. One endpoint, run by a
// reader by hand and by the tests in CI, so neither can rot without the other
// noticing — 3.3's dual-write walk and 3.4's consumer walk made the same
// argument, and this script prints the same MARKER lines a parent process can
// watch for.
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const arg = (name, fallback) => {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.slice(name.length + 3) : fallback;
};
const flag = (name) => process.argv.includes(`--${name}`);
const MODE = arg("mode", "ok");
const SECRET = arg("secret", "");
const RESERIALIZE = flag("reserialize");
const PORT = Number(arg("port", "4555"));
const QUIET = flag("quiet");
/** Loopback by default — this is a toy server that prints request bodies, and it
* should not be on the network unless somebody asks. `--host=0.0.0.0` is what the
* quickstart's V6 needs, where the dispatcher is in a container. */
const HOST = arg("host", "127.0.0.1");
if (!["ok", "fail", "hang", "flaky"].includes(MODE)) {
console.error(`unknown --mode=${MODE} (expected ok, fail, hang or flaky)`);
process.exit(2);
}
/** Held open deliberately. A hanging endpoint that closed its sockets would be a
* refused connection — a different failure, and a much cheaper one. */
const held = new Set();
let count = 0;
const server = createServer((req, res) => {
let body = "";
req.on("data", (chunk) => (body += String(chunk)));
req.on("end", () => {
count += 1;
const n = count;
// The headers are the interesting part, so they are printed by default. A
// reader comparing two attempts sees the SAME signature and the same
// timestamp only if the platform re-sent an identical request; the retry
// schedule re-signs, so they differ, and that is worth seeing.
if (QUIET) {
console.log(
`#${n} ${req.method} ${req.url} event=${req.headers["relay-event-id"] ?? "?"}`,
);
} else {
console.log(`\n--- request #${n} -------------------------------------`);
for (const [key, value] of Object.entries(req.headers)) {
if (key.startsWith("relay-") || key === "content-type") {
console.log(` ${key}: ${value}`);
}
}
console.log(` body: ${body}`);
}
// The line a parent process watches for. Chapter 3.4's walk established the
// convention; keeping it means a test can count arrivals without parsing
// whatever the pretty output happens to look like this year.
console.log(`MARKER received n=${n} mode=${MODE}`);
// VERIFICATION, done here on purpose. This script imports nothing from the
// platform — `node:crypto` and the documented recipe are the whole
// dependency list. If a signature can only be checked with our own code then
// what we published is not a contract, and the way to find that out is to
// write the customer's side without looking at ours.
if (SECRET) {
const timestamp = req.headers["relay-webhook-timestamp"];
const header = String(req.headers["relay-webhook-signature"] ?? "");
// One header can carry SEVERAL signatures during a rotation — the customer
// accepts the request if any of them matches, which is what lets a secret
// be replaced without a synchronised deployment.
const offered = header.split(",").map((p) => p.trim().replace(/^v1=/, ""));
// --reserialize parses and re-stringifies with the top-level keys SORTED:
// the same DATA, a different rendering.
//
// The sort is not gratuitous. A plain `JSON.stringify(JSON.parse(body))`
// in this runtime gives back the bytes it was handed — which is precisely
// why this bug survives every test written in the same language as the
// service, and then appears the day a customer verifies in Go, or a proxy
// normalises the JSON, or somebody spreads the object into a new one to
// add a field. The sort makes that day happen now.
const parsed = RESERIALIZE ? JSON.parse(body) : null;
const body_ = RESERIALIZE
? JSON.stringify(
Object.fromEntries(Object.keys(parsed).sort().map((k) => [k, parsed[k]])),
)
: body;
const expected = createHmac("sha256", SECRET)
.update(`v1:${timestamp}:${body_}`)
.digest("hex");
const ok = offered.some((candidate) => {
const a = Buffer.from(candidate, "hex");
const b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
});
console.log(
`MARKER signature n=${n} ${ok ? "VERIFIED" : "FAILED"}` +
(RESERIALIZE ? " (body re-serialised)" : ""),
);
if (!ok && RESERIALIZE) {
console.log(
" ^ the data is identical and the signature does not match. Sign and\n" +
" verify the BYTES that were transmitted, never the object.",
);
}
}
if (MODE === "hang") {
// Accepted and abandoned. No response, no close — the dispatcher's attempt
// timeout is the only thing that ends this, which is exactly the property
// FR-WHK-05 is about.
held.add(res);
console.log(`MARKER hanging n=${n}`);
return;
}
// `flaky` recovers on the third attempt: far enough in that the reader has
// watched the schedule widen, well short of the seventh where it would
// dead-letter instead.
const status = MODE === "fail" ? 500 : MODE === "flaky" && n < 3 ? 503 : 200;
console.log(`MARKER answered n=${n} status=${status}`);
res.writeHead(status, { "content-type": "text/plain" }).end(String(status));
});
});
// A port already held is the likeliest way this script fails, and the default
// unhandled-error dump buries that under a stack trace. It usually means a
// hostile endpoint from an earlier run is still up — which will happily receive
// the walk's webhooks and log them somewhere the reader is not looking.
server.on("error", (error) => {
if (error.code === "EADDRINUSE") {
console.error(
`port ${PORT} is already in use — another hostile endpoint is probably still running.\n` +
"stop it, or pass --port=0 to take whatever the OS offers.",
);
process.exit(1);
}
throw error;
});
server.listen(PORT, HOST, () => {
const { port } = server.address();
// Printed in a fixed shape so a parent can read the port back when --port=0
// handed the choice to the OS.
console.log(`MARKER listening url=http://${HOST}:${port}/hook mode=${MODE}`);
console.log(`hostile endpoint on http://${HOST}:${port}/hook (mode: ${MODE})`);
console.log("ctrl-c to stop\n");
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
for (const res of held) res.destroy();
server.close(() => process.exit(0));
});
}Endpoint tự verify chữ ký khi được đưa secret, và nó làm điều đó trong chừng mười
lăm dòng với toàn bộ danh sách phụ thuộc là node:crypto. Đó chính là mục đích của
nó: nếu một chữ ký chỉ kiểm được bằng code của chính nền tảng, thì thứ ta công bố
không phải một hợp đồng. Cách để biết điều đó là viết phía khách hàng mà không
nhìn phía mình.
Walk tự spawn api của riêng nó, bởi dispatcher chạm tới state qua HTTP và phải có thứ gì đó ở đầu bên kia. Một walk bảo người đọc khởi động ba service trước là một walk không ai chạy:
// The chapter 3.5 walk: one event, all the way to a customer's server.
//
// # in one terminal
// node scripts/hostile-endpoint.mjs --mode=ok
//
// # in another
// node scripts/webhook-walk.mjs
// node scripts/webhook-walk.mjs --print-signing-material
// node scripts/webhook-walk.mjs --fast-forward # against --mode=fail
// node scripts/webhook-walk.mjs --send-only # leave it for the real dispatcher
//
// --url=http://127.0.0.1:4555/hook where to point the endpoint
// --api-port=4141 the api this walk spawns for itself
// --secret=SECRET pin the signing secret instead of minting
// one, so the endpoint can be started with
// the same value and verify what arrives:
//
// node scripts/hostile-endpoint.mjs --secret=hunter2
// node scripts/webhook-walk.mjs --secret=hunter2 --print-signing-material
//
// WHAT THIS SHOWS, in order: an endpoint is registered with a signing secret the
// platform stores encrypted; an event is expanded into one delivery row per
// matching endpoint INSIDE the claim transaction, so a redelivered event cannot
// double a customer's webhooks; the api's relay publishes what is due; the
// dispatcher posts it, signed, and reports the outcome back over the internal
// seam — because constitution IV does not let it touch PostgreSQL itself.
//
// The walk SPAWNS ITS OWN API. The dispatcher reaches state over HTTP and there
// has to be something at the other end, and a walk that told the reader to start
// three services first would be a walk nobody runs. Its stores are the compose
// ones; nothing else is assumed.
import { spawn } from "node:child_process";
import { createHmac, randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
const API_DIST = join(ROOT, "services", "api", "dist");
const { createDb, createPool } = await import(join(API_DIST, "db", "client.js"));
const { createEnvironment, Repository, expandEventToDeliveries, deliveryMaterial } =
await import(join(API_DIST, "db", "repository.js"));
const { encryptSecret, mintSigningSecret } = await import(
join(API_DIST, "webhooks", "secret.js")
);
const { createDeliveryRelay, ensureDeliveriesStream } = await import(
join(API_DIST, "webhooks", "delivery-relay.js")
);
const { createJetStreamPublisher } = await import(
join(API_DIST, "outbox", "jetstream.publisher.js")
);
const { createLogger } = await import(
join(ROOT, "packages", "service-kit", "dist", "index.js")
);
const { createDispatcher } = await import(
join(ROOT, "services", "dispatcher", "dist", "main.js")
);
const { SIGNATURE_SCHEME } = await import(
join(ROOT, "services", "dispatcher", "dist", "signature.js")
);
const { RETRY_TIERS_MS, MAX_ATTEMPTS } = await import(
join(API_DIST, "webhooks", "schedule.js")
);
const { DeliverPolicy } = await import(
join(ROOT, "services", "dispatcher", "node_modules", "nats", "lib", "src", "mod.js")
);
const arg = (name, fallback) => {
const hit = process.argv.find((a) => a.startsWith(`--${name}=`));
return hit ? hit.slice(name.length + 3) : fallback;
};
const flag = (name) => process.argv.includes(`--${name}`);
const ENDPOINT_URL = arg("url", "http://127.0.0.1:4555/hook");
const API_PORT = Number(arg("api-port", "4141"));
const PRINT_MATERIAL = flag("print-signing-material");
const SEND_ONLY = flag("send-only");
const FAST_FORWARD = flag("fast-forward");
const PINNED_SECRET = arg("secret", "");
const CREDENTIAL = "rk_svc_walk_0123456789abcdef0123456789abcd";
const show = (label, value) => console.log(`${label.padEnd(26)} ${value}`);
const rule = (title) => console.log(`\n=== ${title} ${"=".repeat(Math.max(0, 46 - title.length))}`);
const pool = createPool();
const db = createDb(pool);
// ---------------------------------------------------------------------------
// SETUP, before the narrative starts, and the order is not cosmetic.
//
// The dispatcher's consumers are created HERE — before anything is published —
// because they start at `New`. A durable name is a POSITION in a stream, and a
// consumer created afterwards would begin after the message this walk is about
// and wait forever for something it had already missed. (Starting at `All`
// instead is worse in a different way: the walk replays every event the stream
// has ever held, which on a development machine is every test run since 3.3.)
rule("0. the api and the dispatcher this walk drives");
const api = spawn("node", [join(API_DIST, "main.js")], {
env: {
...process.env,
PORT: String(API_PORT),
RELAY_INTERNAL_CREDENTIAL: CREDENTIAL,
// This walk drives both loops by hand, so the background copies would race it.
RELAY_OUTBOX_RELAY: "off",
RELAY_EVENT_CONSUMER: "off",
RELAY_DELIVERY_RELAY: "off",
},
stdio: ["ignore", "ignore", "inherit"],
});
const stop = async () => {
api.kill();
await pool.end().catch(() => {});
};
const deadline = Date.now() + 30_000;
for (;;) {
try {
if ((await fetch(`http://127.0.0.1:${API_PORT}/healthz`)).ok) break;
} catch {
// not up yet
}
if (Date.now() > deadline) {
console.error("the api never became healthy");
await stop();
process.exit(1);
}
await new Promise((r) => setTimeout(r, 100));
}
show("api", `http://127.0.0.1:${API_PORT} (spawned by this walk)`);
const dispatcher = createDispatcher({
apiUrl: `http://127.0.0.1:${API_PORT}`,
credential: CREDENTIAL,
durables: {
expand: `walk-expand-${randomUUID().slice(0, 8)}`,
deliver: `walk-deliver-${randomUUID().slice(0, 8)}`,
},
deliverPolicy: DeliverPolicy.New,
// Short, because the relay below is GLOBAL: it publishes every delivery in the
// database that is due, including rows an old test run left pointing at
// `example.test`. Those are not this walk's business and must not cost it ten
// seconds each on the way past.
attemptTimeoutMs: 3_000,
logger: createLogger("walk-dispatcher"),
});
await dispatcher.ready();
show("dispatcher", "consumers created, positioned at NEW");
// ---------------------------------------------------------------------------
rule("1. an endpoint, with a secret the platform encrypts");
const env = await createEnvironment(db, { name: `webhook-walk-${randomUUID().slice(0, 8)}` });
const repo = new Repository(db, env.id);
// Minted here so the walk can PRINT it. In the platform this value exists in
// plaintext exactly twice: once when it is shown to the customer at creation,
// and once inside the dispatcher for as long as it takes to sign. What is
// stored is the ciphertext.
const secret = PINNED_SECRET || mintSigningSecret();
const endpoint = await repo.createEndpoint({
url: ENDPOINT_URL,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(secret),
});
show("environment", env.id);
show("endpoint", endpoint.id);
show("url", ENDPOINT_URL);
show("secret (shown once)", `${secret}${PINNED_SECRET ? " (pinned by --secret)" : ""}`);
show("stored as", `${encryptSecret(secret).slice(0, 32)}… (iv|tag|ciphertext, AES-256-GCM)`);
// ---------------------------------------------------------------------------
rule("2. one event becomes one delivery per matching endpoint");
const eventId = randomUUID();
const payload = {
id: eventId,
type: "message.created",
environment_id: env.id,
occurred_at: new Date().toISOString(),
data: { id: randomUUID(), seq: 1, user: "tuan", text: "B2, north ramp" },
};
const expansion = await expandEventToDeliveries(db, {
eventId,
environmentId: env.id,
type: "message.created",
payload,
});
show("event", eventId);
show("delivery rows created", expansion.created);
// The same event again. Not a demonstration of a bug — a demonstration that the
// claim transaction absorbs it, which is the whole reason expansion writes rows
// instead of publishing N messages.
const again = await expandEventToDeliveries(db, {
eventId,
environmentId: env.id,
type: "message.created",
payload,
});
show("expanded a second time", `created=${again.created} duplicate=${again.duplicate}`);
// ---------------------------------------------------------------------------
if (PRINT_MATERIAL) {
rule("the signing recipe, in full");
// THE BYTES THAT ACTUALLY GO OUT, fetched the way the dispatcher fetches them.
//
// This block used to sign `payload` — the object this script built a few lines
// above — and it was WRONG in a way that took a run against a real endpoint to
// see. The payload is stored as `jsonb`, and PostgreSQL does not preserve key
// order, so what comes back out is the same object and a different rendering.
// A reader following this recipe computed a signature over one ordering while
// the platform had signed another, and the mismatch would have looked like a
// bug in the platform rather than in the walk.
//
// That is the re-serialisation trap, aimed at ourselves, from the one direction
// that catches everybody: nobody re-serialised anything on purpose. The
// database did it in the round trip.
const [row] = await repo.listDeliveriesForEvent(eventId);
const material = await deliveryMaterial(db, row.id);
const rawBody = JSON.stringify(material.payload);
const timestamp = Math.floor(Date.now() / 1000).toString();
const canonical = `${SIGNATURE_SCHEME}:${timestamp}:${rawBody}`;
const signature = createHmac("sha256", material.secrets[0]).update(canonical).digest("hex");
console.log("\n canonical string = v1:{timestamp}:{raw body}");
console.log(" signature = hex(HMAC-SHA256(secret, canonical string))\n");
show(" secret", material.secrets[0]);
show(" timestamp", timestamp);
show(" raw body", rawBody);
show(" canonical", canonical);
show(" signature", signature);
// The point of V5. If this can only be verified with the platform's own code,
// it is not a contract — so here it is with a tool that knows nothing about us.
console.log("\n verify it without any of our code:\n");
console.log(
` printf '%s' ${JSON.stringify(canonical)} | openssl dgst -sha256 -hmac ${JSON.stringify(material.secrets[0])}\n`,
);
console.log(" The TIMESTAMP above is this moment, not the one the live attempt used —");
console.log(" every attempt re-signs, which is what stops a captured request being");
console.log(" replayed forever. To check a real one, run the endpoint with");
console.log(` --secret=${material.secrets[0]} and it verifies each arrival itself.\n`);
console.log(" Then re-serialise the body — reorder a key, add a space — and watch it");
console.log(" fail. That failure is the reason the raw bytes are signed, not the object.\n");
}
// ---------------------------------------------------------------------------
rule("3. the api's relay publishes what is DUE");
const relay = createDeliveryRelay({
db,
publisher: createJetStreamPublisher({ ensure: ensureDeliveriesStream }),
logger: createLogger("walk-relay"),
});
// GLOBAL by design — the relay drains what is due across the platform, not what
// this walk happens to have created. A number larger than one is other rows
// falling due, which is worth seeing rather than hiding.
show("published to the stream", await relay.drainOnce());
if (SEND_ONLY) {
// V6: the rows exist and are due, and this process is walking away without
// delivering them. Start the dispatcher and it drains them with nobody
// intervening — which is the demonstration that the split bought something.
console.log("\n--send-only: the deliveries are due and nothing has posted them.");
console.log("start the dispatcher and it drains the backlog on its own.\n");
await pool.end();
process.exit(0);
}
// ---------------------------------------------------------------------------
rule("4. the dispatcher posts it, and reports back over the seam");
const report = async () => {
const rows = await repo.listDeliveriesForEvent(eventId);
for (const row of rows) {
show(
` delivery ${row.id.slice(0, 8)}`,
`attempt=${row.attempt} state=${row.state} next=${row.next_attempt_at}`,
);
}
return rows;
};
await dispatcher.pollOnce();
console.log("");
await report();
// ---------------------------------------------------------------------------
if (FAST_FORWARD) {
rule("5. the whole schedule, without waiting for it");
console.log(
` tiers: ${RETRY_TIERS_MS.map((ms) => (ms === 0 ? "now" : `${ms / 1000}s`)).join(" → ")}`,
);
console.log(` ${MAX_ATTEMPTS} attempts, then the delivery is dead-lettered.\n`);
console.log(" the waits are REAL in production. This rewrites next_attempt_at so a");
console.log(" reader can watch the end of the schedule without waiting two hours —");
console.log(" it is a fast-forward through the clock, not a shortcut around the logic.\n");
/** POLL, don't peek. `drainOnce` publishes and returns; the message reaches the
* consumer a moment later, so a single `pollOnce` after it is a race — one
* that passes on a quiet machine and stalls on a busy one. The integration
* suite learned this three times; the walk gets it for free by copying. */
const pollUntil = async (done, timeoutMs = 15_000) => {
const until = Date.now() + timeoutMs;
while (!(await done()) && Date.now() < until) await dispatcher.pollOnce();
};
const stateOf = async () => (await repo.listDeliveriesForEvent(eventId))[0];
for (let i = 0; i < MAX_ATTEMPTS + 2; i++) {
const before = await stateOf();
if (!before || before.state !== "pending") break;
// Drag everything still pending into the past, then run the two loops the
// way they run in production.
await pool.query(
`UPDATE webhook_deliveries SET next_attempt_at = now() - interval '1 second'
WHERE event_id = $1 AND state = 'pending'`,
[eventId],
);
await relay.drainOnce();
// Wait for THIS delivery to move — a new attempt number, or out of pending
// altogether. Counting published messages would not do: the relay is global,
// so the number it returns is mostly other people's work.
await pollUntil(async () => {
const now_ = await stateOf();
return !now_ || now_.state !== "pending" || now_.attempt !== before.attempt;
});
const after = await stateOf();
show(
` attempt ${before.attempt}`,
after && after.attempt !== before.attempt
? `failed → rescheduled as attempt ${after.attempt}`
: `failed → ${after ? after.state : "gone"}`,
);
}
console.log("");
await report();
const dead = (await repo.listDeadLetters()).filter((d) => d.event_id === eventId);
console.log("");
show("dead letters", dead.length);
for (const d of dead) {
show(` ${d.id.slice(0, 8)}`, `attempts=${d.attempts} last_status=${d.last_status ?? "none"}`);
}
}
rule("what to take from this");
console.log(`
The delivery row is the schedule. Not a message the broker is holding — that
was measured in research R1 and rejected, because a sleeping message keeps its
acknowledgement slot and dead endpoints would starve healthy ones.
The dispatcher never wrote to PostgreSQL. Every state change above went through
the api over HTTP, because constitution IV reserves those writes to one service
and a second one would make "who owns this row" a question again.
And the last hop is at-least-once, deliberately. The dispatcher posts, THEN
reports; a crash in that gap re-posts. The customer absorbs it on the event id
they were handed, which is a duplicate they can see rather than a loss nobody
can.
`);
await dispatcher.stop();
await stop();
// Explicit, as in every other walk in this directory: the spawned api is a live
// child handle and the pool holds sockets, so a script that merely stops doing
// work looks to a reader exactly like a script that hung.
process.exit(0);Chạy walk với --mode=fail và nhìn lịch trình giãn ra:
tiers: now → 1s → 5s → 30s → 300s → 1800s → 7200s
7 attempts, then the delivery is dead-lettered.
attempt 2 failed → rescheduled as attempt 3
attempt 3 failed → rescheduled as attempt 4
attempt 4 failed → rescheduled as attempt 5
attempt 5 failed → rescheduled as attempt 6
attempt 6 failed → rescheduled as attempt 7
attempt 7 failed → dead
dead letters 1
d1d7012d attempts=7 last_status=500Chỉ có điều lần đầu tiên nó không làm thế. Nó làm thế này:
attempt 2 failed → pending
attempt 2 failed → pending
attempt 2 failed → pending (nine times, going nowhere)
delivery b3ced241 attempt=2 state=pending
dead letters 0
=== endpoint attempts: 1 ===Một lần thử, rồi không gì nữa, mãi mãi.
Nguyên nhân nằm ở nền tảng chứ không phải ở script. Delivery relay deduplicate các
lần publish theo delivery id — mà một delivery id thì giống hệt nhau qua cả bảy
lần thử. JetStream gộp mọi lần retry vào chính message của lần thử đầu tiên, bên
trong cửa sổ trùng lặp của nó. Lệnh publish báo thành công. Không message nào tới
được dispatcher. Và dòng dữ liệu vẫn giữ nguyên dispatched_at mà claim của nó đã
đặt — thứ chỉ được xoá bởi một báo cáo kết quả, và báo cáo ấy thì không bao giờ tới.
Mọi webhook thất bại đã được retry đúng không lần nào, và toàn bộ lịch trình là không thể chạm tới.
Bài test hồi quy chính là walk, được đem vào CI — và nó đã được xác nhận là thất bại trước khoá cũ rồi mới được giữ lại:
it("invariant 10: a FAILED delivery is retried — the same row, a second attempt", async () => {Các test suite thực sự bảo vệ điều gì
Ba suite unit và ba suite integration tới cùng chương này. Nhóm unit thì rẻ và ghim chặt phần số học — bảng tier, vòng mã hoá, công thức chữ ký:
import { describe, expect, it } from "vitest";
import { MAX_ATTEMPTS, RETRY_TIERS_MS, nextAttemptAt } from "./schedule";
// The tier table, pure. What the broker-backed design could never have offered:
// the schedule is a lookup, so it can be asserted without a broker, a database
// or two hours of waiting.
const AT = new Date("2026-08-10T12:00:00.000Z");
describe("the tiers", () => {
it("has seven: one immediate attempt and FR-WHK-03's six retries", () => {
// See schedule.ts's DECISION for why the delay list wins over the count when
// the requirement contradicts itself — keeping the 2 h tier keeps the
// platform's most customer-visible promise at two hours.
expect(MAX_ATTEMPTS).toBe(7);
expect(RETRY_TIERS_MS).toHaveLength(7);
});
it("delivers the first attempt immediately", () => {
// A webhook that waits before its first try makes every integration feel
// broken for a reason no customer can see.
expect(nextAttemptAt(1, AT)).toEqual(AT);
});
it("widens strictly, so no two tiers are the same wait", () => {
for (let i = 1; i < RETRY_TIERS_MS.length; i++) {
expect(RETRY_TIERS_MS[i]!).toBeGreaterThan(RETRY_TIERS_MS[i - 1]!);
}
});
it("schedules each attempt from the moment its predecessor failed", () => {
expect(nextAttemptAt(2, AT)).toEqual(new Date(AT.getTime() + 1_000));
expect(nextAttemptAt(3, AT)).toEqual(new Date(AT.getTime() + 5_000));
expect(nextAttemptAt(4, AT)).toEqual(new Date(AT.getTime() + 30_000));
expect(nextAttemptAt(5, AT)).toEqual(new Date(AT.getTime() + 300_000));
expect(nextAttemptAt(6, AT)).toEqual(new Date(AT.getTime() + 1_800_000));
expect(nextAttemptAt(7, AT)).toEqual(new Date(AT.getTime() + 7_200_000));
});
it("returns null past the last tier — the signal to dead-letter", () => {
// A value rather than an exception, so the outcome path stays one
// expression: schedule if there is a tier, dead-letter if there is not.
expect(nextAttemptAt(MAX_ATTEMPTS + 1, AT)).toBeNull();
expect(nextAttemptAt(0, AT)).toBeNull();
});
it("is recomputable from the attempt number alone", () => {
// `attempt` is the index into the table, not a running counter — so a
// delivery's next due time is a lookup, and no branch can leave one drifting
// on a schedule nobody can reconstruct.
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
expect(nextAttemptAt(attempt, AT)).toEqual(nextAttemptAt(attempt, AT));
}
});
});import { describe, expect, it } from "vitest";
import { hashSecret } from "../auth/api-key";
import {
activeSigningSecrets,
decryptSecret,
encryptSecret,
mintSigningSecret,
ROTATION_WINDOW_MS,
} from "./secret";
// Why a webhook signing secret is NOT stored the way chapter 3.2 stored an API
// key — and the resemblance between the two is exactly the trap.
//
// NFR-SEC-02 covers both in one sentence: "API key secrets and webhook signing
// secrets shall be stored only as salted hashes OR under envelope encryption."
// Two credentials, one requirement, two mechanisms — and the reason is not that
// one is more sensitive than the other. It is that the VERBS differ:
//
// an API key is VERIFIED — a caller presents it, we hash what arrived and
// compare, and we never need the original again;
//
// a signing secret is USED — we must compute an HMAC with it on every
// delivery, which requires the secret itself.
//
// A hash cannot be used. It can only be compared. That is the whole argument,
// and the first test states it as a property rather than a comment.
describe("a signing secret must survive storage, not merely be recognisable", () => {
it("comes back out of storage byte-for-byte", () => {
const secret = mintSigningSecret();
const recovered = decryptSecret(encryptSecret(secret));
// The property FR-WHK-08 needs and a hash cannot provide. If this ever
// fails, no delivery can be signed and every customer's verification breaks
// at once.
expect(recovered).toBe(secret);
});
it("cannot be recovered from chapter 3.2's treatment of the other credential", () => {
const secret = mintSigningSecret();
// Hashing is what 3.2 does to an API key, and it is one-way on purpose. The
// digest is not the secret and no amount of care turns it back into one, so
// an implementation that reached for `hashSecret` here would compile, pass a
// careless review, and be unable to sign anything.
const digest = hashSecret(secret, "some-salt");
expect(digest).not.toBe(secret);
expect(() => decryptSecret(digest)).toThrow();
});
});
describe("encryption at rest, not obfuscation", () => {
it("does not leave the secret readable in the stored value", () => {
const secret = mintSigningSecret();
const stored = encryptSecret(secret);
// The point of the requirement, not of the algorithm: whatever lands in the
// column must not contain the plaintext. A base64 round-trip would satisfy
// "not equal" and fail this.
expect(stored).not.toContain(secret);
expect(Buffer.from(stored, "base64").toString("utf8")).not.toContain(secret);
});
it("produces a different ciphertext every time, so equal secrets are not detectable", () => {
const secret = mintSigningSecret();
// A deterministic ciphertext leaks equality: an operator reading the table
// could tell which endpoints share a secret without decrypting anything.
expect(encryptSecret(secret)).not.toBe(encryptSecret(secret));
});
it("refuses a tampered ciphertext rather than returning wrong bytes", () => {
const stored = encryptSecret(mintSigningSecret());
const tampered = stored.slice(0, -4) + (stored.endsWith("A") ? "BBBB" : "AAAA");
// Authenticated encryption, so a modified value is an error rather than
// plausible-looking garbage that would be signed with and silently break
// every signature for that endpoint.
expect(() => decryptSecret(tampered)).toThrow();
});
});
describe("minting", () => {
it("produces a secret with enough entropy to be worth protecting", () => {
const a = mintSigningSecret();
const b = mintSigningSecret();
expect(a).not.toBe(b);
// 256 bits, the same budget 3.2 gave an API key secret.
expect(Buffer.from(a, "base64url").length).toBeGreaterThanOrEqual(32);
});
});
describe("the rotation window closes", () => {
const build = (rotatedAt: Date | null, withPrevious = true) => {
const current = mintSigningSecret();
const previous = mintSigningSecret();
return {
current,
previous,
endpoint: {
secretCiphertext: encryptSecret(current),
secretPreviousCiphertext: withPrevious ? encryptSecret(previous) : null,
secretRotatedAt: rotatedAt,
},
};
};
it("signs with both secrets inside the window", () => {
const rotatedAt = new Date("2026-08-10T00:00:00.000Z");
const { current, previous, endpoint } = build(rotatedAt);
const active = activeSigningSecrets(
endpoint,
new Date(rotatedAt.getTime() + ROTATION_WINDOW_MS - 1000),
);
expect(active).toEqual([current, previous]);
});
it("drops the previous secret once the window has passed", () => {
const rotatedAt = new Date("2026-08-10T00:00:00.000Z");
const { current, endpoint } = build(rotatedAt);
const active = activeSigningSecrets(
endpoint,
new Date(rotatedAt.getTime() + ROTATION_WINDOW_MS),
);
// The half that matters. A previous secret that never expires is not a
// rotation — it is a second permanent credential, and a customer who
// rotated because of a leak would still be accepting the leaked one.
expect(active).toEqual([current]);
});
it("signs with one secret when nothing has been rotated", () => {
const { current, endpoint } = build(null, false);
expect(activeSigningSecrets(endpoint)).toEqual([current]);
});
});import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import { SIGNATURE_SCHEME, signDelivery, signatureHeaders } from "./signature.js";
/** Hardcoded, NOT imported. A customer reads "v1" out of the documentation and
* types it into their own code; if the platform ever changes it, their
* verification breaks — so this file must break too. Importing the constant
* would let a breaking contract change slip through green. */
const SCHEME_FROM_THE_DOCS = "v1";
// Invariants 4 and 5 (contracts/dispatcher.md).
//
// THE RULE FOR THIS FILE: the verifying side below is written from
// `contracts/webhooks.md` §Verifying and from nothing else. It does not import
// the signing code, and it must not — a test that verifies with `signDelivery`
// proves only that the function agrees with itself, which is the single thing a
// customer cannot rely on. A customer has the documentation and a language of
// their choice; so does this file.
/** The recipe, transcribed:
* 1. take the raw body bytes, before any JSON parsing
* 2. canonical string = "<scheme>:<timestamp>:<raw body>"
* 3. HMAC-SHA256 with the shared secret, hex
* 4. compare in constant time (a test may compare directly) */
function verifyFromTheDocumentation(
rawBody: string,
timestamp: string,
secret: string,
candidate: string,
): boolean {
const canonical = `${SCHEME_FROM_THE_DOCS}:${timestamp}:${rawBody}`;
const expected = createHmac("sha256", secret).update(canonical).digest("hex");
return expected === candidate;
}
const SECRET = "whsec_test_2f4b8c1e9a7d3f6b5c0e8a2d4f6b8c1e";
const BODY = JSON.stringify({
id: "8f14e45f-ceea-4f6a-9b2c-1d2e3f4a5b6c",
type: "message.created",
environment_id: "3f2a0000-0000-0000-0000-000000000001",
occurred_at: "2026-08-10T09:15:00.000Z",
data: { id: "57d5cdf0", channel_id: "ce419dc5", seq: 1, user: "tuan", text: "B2, north ramp", created_at: "2026-08-10T09:15:00.000Z" },
});
describe("invariant 4: a recipient can verify with only the request and the secret", () => {
it("verifies against a verifier written from the documentation, not from the signer", () => {
const timestamp = "1786500000";
const signature = signDelivery({ rawBody: BODY, timestamp, secret: SECRET });
expect(verifyFromTheDocumentation(BODY, timestamp, SECRET, signature)).toBe(
true,
);
});
it("fails against the wrong secret", () => {
const timestamp = "1786500000";
const signature = signDelivery({ rawBody: BODY, timestamp, secret: SECRET });
expect(
verifyFromTheDocumentation(BODY, timestamp, "whsec_not_the_one", signature),
).toBe(false);
});
it("binds the timestamp, so a captured request cannot be replayed indefinitely", () => {
const signature = signDelivery({
rawBody: BODY,
timestamp: "1786500000",
secret: SECRET,
});
// Same body, same secret, different timestamp — the signature must not carry
// over, or the timestamp is decoration rather than a replay bound.
expect(
verifyFromTheDocumentation(BODY, "1786599999", SECRET, signature),
).toBe(false);
});
it("publishes the scheme the documentation names", () => {
// The one place the constant and the docs are checked against each other.
// Everything else in this file uses the documented literal.
expect(SIGNATURE_SCHEME).toBe(SCHEME_FROM_THE_DOCS);
});
it("emits headers a recipient can find the parts in", () => {
const headers = signatureHeaders({
rawBody: BODY,
timestamp: "1786500000",
secrets: [SECRET],
});
const timestamp = headers["relay-webhook-timestamp"] ?? "";
const signature = headers["relay-webhook-signature"] ?? "";
expect(timestamp).toBe("1786500000");
// The scheme travels with the value, so a future algorithm is additive
// rather than a breaking change to the header set.
expect(signature).toMatch(new RegExp(`^${SCHEME_FROM_THE_DOCS}=[0-9a-f]{64}$`));
const hex = signature.split("=")[1] ?? "";
expect(verifyFromTheDocumentation(BODY, timestamp, SECRET, hex)).toBe(true);
});
it("carries one signature per valid secret during a rotation window", () => {
const OLD = "whsec_the_previous_one";
const headers = signatureHeaders({
rawBody: BODY,
timestamp: "1786500000",
secrets: [SECRET, OLD],
});
// A recipient that still holds the old secret must be able to verify, and
// one that has taken the new one must too. That is what makes a 24-hour
// window survivable without a synchronised deploy.
const parts: string[] = (headers["relay-webhook-signature"] ?? "").split(",");
expect(parts).toHaveLength(2);
const hexes: string[] = parts.map((p: string) => p.split("=")[1] ?? "");
expect(
hexes.some((h: string) =>
verifyFromTheDocumentation(BODY, "1786500000", SECRET, h),
),
).toBe(true);
expect(
hexes.some((h: string) =>
verifyFromTheDocumentation(BODY, "1786500000", OLD, h),
),
).toBe(true);
});
});
describe("invariant 5: the trap, asserted rather than warned about", () => {
it("does not verify when the body is parsed and re-serialised first", () => {
const timestamp = "1786500000";
const signature = signDelivery({ rawBody: BODY, timestamp, secret: SECRET });
// The mistake almost every first integration makes. Round-tripping through
// JSON is semantically identity-preserving and byte-wise is not: key order
// and whitespace move, so the HMAC input changes while the payload looks
// identical in a log.
const reserialised = JSON.stringify(JSON.parse(BODY), ["type", "id"]);
expect(reserialised).not.toBe(BODY);
expect(
verifyFromTheDocumentation(reserialised, timestamp, SECRET, signature),
).toBe(false);
});
it("is sensitive to whitespace alone", () => {
const timestamp = "1786500000";
const signature = signDelivery({ rawBody: BODY, timestamp, secret: SECRET });
// Even a pretty-printer defeats it. This is the version that bites people
// whose framework helpfully formats bodies before handing them over.
const pretty = JSON.stringify(JSON.parse(BODY), null, 2);
expect(verifyFromTheDocumentation(pretty, timestamp, SECRET, signature)).toBe(
false,
);
});
});Nhóm integration mới là nơi các bảo đảm thực sự sống, bởi mỗi bảo đảm trong đó đều nói về một thứ chỉ tồn tại khi một broker thật, một database thật và một server thật của khách hàng cùng nằm trong một câu chuyện:
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { createDb, createPool, type Db } from "../db/client";
import {
createEnvironment,
deliveryMaterial,
DeliveryNotFoundError,
drainDueDeliveries,
expandEventToDeliveries,
pendingDeliveryDepth,
recordAttemptOutcome,
replayDeadLetter,
Repository,
timesHandled,
} from "../db/repository";
import { MAX_ATTEMPTS, RETRY_TIERS_MS } from "./schedule";
import { encryptSecret, mintSigningSecret } from "./secret";
// The delivery schedule (chapter 3.5). Invariant 8 lives here; 9, 10 and 12
// join it as the tiers and the dead-letter path arrive.
//
// Every environment is minted in this file — the drain is global, so a
// per-environment assertion is the only kind that survives another suite
// running beside it. Chapter 3.3's finding 4 is the reason that sentence exists.
const DISPATCHER = "dispatcher";
describe("expansion", () => {
let db: Db;
let env: { id: string };
let repo: Repository;
const seedEndpoint = async (eventTypes: string[]) => {
const secret = mintSigningSecret();
return repo.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes,
secretCiphertext: encryptSecret(secret),
});
};
const event = (environmentId: string) => ({
eventId: randomUUID(),
environmentId,
type: "message.created",
payload: { id: randomUUID(), type: "message.created" },
});
beforeAll(async () => {
db = createDb(createPool());
env = await createEnvironment(db, { name: "deliveries-itest" });
repo = new Repository(db, env.id);
});
afterAll(async () => {
// nothing to close: the pool is process-lived, as in every other suite
});
it("invariant 8: one event matching N endpoints produces N delivery rows", async () => {
await seedEndpoint(["message.created"]);
await seedEndpoint(["message.created"]);
// Subscribed to something else — must not receive this event (invariant 7's
// half that is decided at expansion rather than at delivery).
await seedEndpoint(["channel.created"]);
const e = event(env.id);
const result = await expandEventToDeliveries(db, e);
expect(result.duplicate).toBe(false);
expect(result.created).toBe(2);
const rows = await repo.listDeliveriesForEvent(e.eventId);
expect(rows).toHaveLength(2);
// Every row starts due immediately, on tier 1, pending.
for (const row of rows) {
expect(row.attempt).toBe(1);
expect(row.state).toBe("pending");
}
});
it("invariant 8: expansion runs once however often the event is redelivered", async () => {
await seedEndpoint(["message.created"]);
const e = event(env.id);
const first = await expandEventToDeliveries(db, e);
const second = await expandEventToDeliveries(db, e);
const third = await expandEventToDeliveries(db, e);
expect(first.duplicate).toBe(false);
// The broker will redeliver — that is what at-least-once means. Doubling
// every webhook on a redelivery is the failure this prevents, and it is
// prevented by the database rather than by care.
expect(second.duplicate).toBe(true);
expect(third.duplicate).toBe(true);
expect(second.created).toBe(0);
expect(third.created).toBe(0);
expect(await repo.listDeliveriesForEvent(e.eventId)).toHaveLength(
first.created,
);
// And the claim is chapter 3.4's ledger, unchanged.
expect(await timesHandled(db, DISPATCHER, e.eventId)).toBe(1);
});
it("invariant 8: an event no endpoint subscribes to expands to nothing, and that is not an error", async () => {
const quiet = await createEnvironment(db, { name: "deliveries-itest-quiet" });
const e = { ...event(quiet.id), type: "message.created" };
const result = await expandEventToDeliveries(db, e);
expect(result.duplicate).toBe(false);
expect(result.created).toBe(0);
// Still claimed. An event with no subscribers is handled, not pending — or
// every redelivery would re-ask the same question forever.
expect(await timesHandled(db, DISPATCHER, e.eventId)).toBe(1);
});
it("invariant 8: a disabled or deleted endpoint receives nothing", async () => {
const scratch = await createEnvironment(db, { name: "deliveries-itest-off" });
const scratchRepo = new Repository(db, scratch.id);
const secret = encryptSecret(mintSigningSecret());
const live = await scratchRepo.createEndpoint({
url: "https://example.test/live",
eventTypes: ["message.created"],
secretCiphertext: secret,
});
const paused = await scratchRepo.createEndpoint({
url: "https://example.test/paused",
eventTypes: ["message.created"],
secretCiphertext: secret,
});
const removed = await scratchRepo.createEndpoint({
url: "https://example.test/removed",
eventTypes: ["message.created"],
secretCiphertext: secret,
});
await scratchRepo.setEndpointEnabled(paused.id, false);
await scratchRepo.deleteEndpoint(removed.id);
const e = event(scratch.id);
const result = await expandEventToDeliveries(db, e);
expect(result.created).toBe(1);
const rows = await scratchRepo.listDeliveriesForEvent(e.eventId);
expect(rows.map((r) => r.endpoint_id)).toEqual([live.id]);
});
});
describe("the outcome of an attempt", () => {
let db: Db;
let env: { id: string };
let repo: Repository;
const seed = async () => {
await repo.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(mintSigningSecret()),
});
const e = {
eventId: randomUUID(),
environmentId: env.id,
type: "message.created",
payload: { id: randomUUID() },
};
await expandEventToDeliveries(db, e);
const [delivery] = await repo.listDeliveriesForEvent(e.eventId);
return delivery!;
};
beforeAll(async () => {
db = createDb(createPool());
env = await createEnvironment(db, { name: "outcome-itest" });
repo = new Repository(db, env.id);
});
it("marks a 2xx delivered and schedules nothing further", async () => {
const delivery = await seed();
const result = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 200,
});
expect(result.outcome).toBe("delivered");
expect(result.nextAttemptAt).toBeNull();
});
it("reschedules a failure onto the next tier", async () => {
const delivery = await seed();
const before = Date.now();
const result = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 500,
});
expect(result.outcome).toBe("rescheduled");
// Tier 2 is one second out. Asserted as a bound rather than an equality:
// the row's clock is the database's, not this process's.
const due = result.nextAttemptAt!.getTime();
expect(due).toBeGreaterThanOrEqual(before);
expect(due).toBeLessThanOrEqual(before + RETRY_TIERS_MS[1]! + 2_000);
});
it("treats a timeout — no status at all — as a failure", async () => {
const delivery = await seed();
// The platform can only believe a status code it received. Nothing received
// is not success, however the request may have ended on the customer's side.
const result = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
error: "connect ETIMEDOUT",
});
expect(result.outcome).toBe("rescheduled");
});
it("is idempotent on (delivery, attempt) — a repeat does not advance the schedule", async () => {
const delivery = await seed();
const first = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 500,
});
const repeat = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 500,
});
// The dispatcher posts, reports, then acknowledges. A crash between the
// report and the acknowledgement means this report arrives twice. The POST
// may duplicate and the customer absorbs it on the event id — but the
// SCHEDULE must not advance twice for one attempt, or the tiers collapse.
expect(repeat.outcome).toBe(first.outcome);
const rows = await repo.listDeliveriesForEvent(delivery.event_id);
expect(rows[0]!.attempt).toBe(2);
});
it("dead-letters once the attempts are exhausted, with the record behind it", async () => {
const delivery = await seed();
let outcome = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
const result = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt,
status: 503,
error: "customer endpoint is down",
});
outcome = result.outcome;
}
expect(outcome).toBe("dead_lettered");
const dead = await repo.listDeadLetters();
const mine = dead.filter((d) => d.event_id === delivery.event_id);
expect(mine).toHaveLength(1);
expect(mine[0]!.attempts).toBe(MAX_ATTEMPTS);
expect(mine[0]!.last_status).toBe(503);
});
});
describe("the relay drains only what is due", () => {
let db: Db;
let env: { id: string };
let repo: Repository;
/** Run the drain, then ask the ROW what happened.
*
* The drain is GLOBAL — one dispatcher serves every environment — so a test
* that asserts on what its own call returned is really asserting that no other
* suite got there first. Chapter 3.3's finding 3 met this twice; this is the
* third time, and the fix is the same one: assert the property, not the
* observer. Whoever claims the row, a due delivery ends up dispatched and a
* not-yet-due one does not. */
const drainEverythingDue = async (): Promise<void> => {
await drainDueDeliveries(db, 500, async () => {});
};
const stateOf = async (delivery: { id: string; event_id: string }) => {
const rows = await repo.listDeliveriesForEvent(delivery.event_id);
return rows.find((r) => r.id === delivery.id)!;
};
const seed = async () => {
await repo.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(mintSigningSecret()),
});
const e = {
eventId: randomUUID(),
environmentId: env.id,
type: "message.created",
payload: { id: randomUUID() },
};
await expandEventToDeliveries(db, e);
const [delivery] = await repo.listDeliveriesForEvent(e.eventId);
return delivery!;
};
beforeAll(async () => {
db = createDb(createPool());
env = await createEnvironment(db, { name: "due-itest" });
repo = new Repository(db, env.id);
});
it("invariant 10: publishes a delivery that is due", async () => {
const delivery = await seed();
await drainEverythingDue();
expect((await stateOf(delivery)).dispatched_at).not.toBeNull();
});
it("invariant 10: does NOT publish one that is not yet due", async () => {
const delivery = await seed();
// Fail it once: attempt 2 falls due a second from now, so it is pending but
// not due — and the outcome clears `dispatched_at`.
await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 500,
});
await drainEverythingDue();
expect((await stateOf(delivery)).dispatched_at).toBeNull();
});
it("invariant 10: a not-yet-due delivery holds no acknowledgement slot", async () => {
// THE ASSERTION THAT WOULD HAVE CAUGHT THE ORIGINAL DESIGN.
//
// Research R1 measured the alternative: a broker-held delay survives a
// restart to within 3 ms, and holds an acknowledgement slot the whole time
// it waits. Three messages nak'd for five minutes made two available ones
// unfetchable — so a handful of dead customer endpoints would starve
// deliveries to healthy ones, which is what FR-WHK-05 forbids.
//
// Here the waiting happens in a row, so the test is not a timing
// measurement: a healthy endpoint's delivery is published while several
// others sit on the two-hour tier. Nothing is holding anything.
const sleeping = [];
for (let i = 0; i < 3; i++) {
const d = await seed();
for (let attempt = 1; attempt <= 6; attempt++) {
await recordAttemptOutcome(db, { deliveryId: d.id, attempt, status: 503 });
}
sleeping.push(d);
}
const healthy = await seed();
await drainEverythingDue();
expect((await stateOf(healthy)).dispatched_at).not.toBeNull();
for (const s of sleeping) {
expect((await stateOf(s)).dispatched_at).toBeNull();
}
});
it("invariant 9: a claimed delivery is not claimed twice", async () => {
const delivery = await seed();
await drainEverythingDue();
const first = await stateOf(delivery);
await drainEverythingDue();
const second = await stateOf(delivery);
// `dispatched_at` is the relay's mark, in the shape `outbox.published_at`
// has. Without it the same delivery would be republished on every pass until
// the dispatcher happened to report an outcome — so the mark must not move.
expect(first.dispatched_at).not.toBeNull();
expect(second.dispatched_at).toBe(first.dispatched_at);
});
it("invariant 9: a rescheduled delivery becomes claimable again when it falls due", async () => {
const delivery = await seed();
await drainEverythingDue();
await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 500,
});
// The outcome clears the mark, so the next tier is genuinely deliverable
// rather than stuck behind a flag nobody resets.
expect((await stateOf(delivery)).dispatched_at).toBeNull();
expect((await stateOf(delivery)).attempt).toBe(2);
// Tier 2 is one second out.
await new Promise((resolve) => setTimeout(resolve, 1_500));
await drainEverythingDue();
expect((await stateOf(delivery)).dispatched_at).not.toBeNull();
});
});
describe("dead letters", () => {
let db: Db;
let env: { id: string };
let repo: Repository;
const exhaust = async () => {
const endpoint = await repo.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(mintSigningSecret()),
});
const eventId = randomUUID();
await expandEventToDeliveries(db, {
eventId,
environmentId: env.id,
type: "message.created",
payload: { id: eventId, type: "message.created" },
});
const [delivery] = await repo.listDeliveriesForEvent(eventId);
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
await recordAttemptOutcome(db, {
deliveryId: delivery!.id,
attempt,
status: 503,
error: "customer endpoint is down",
});
}
return { endpointId: endpoint.id, eventId, deliveryId: delivery!.id };
};
beforeAll(async () => {
db = createDb(createPool());
env = await createEnvironment(db, { name: "deadletter-itest" });
repo = new Repository(db, env.id);
});
it("invariant 12: an exhausted delivery is retained and retrievable", async () => {
const { eventId, deliveryId } = await exhaust();
const [dead] = (await repo.listDeadLetters()).filter(
(d) => d.event_id === eventId,
);
expect(dead).toBeDefined();
expect(dead!.attempts).toBe(MAX_ATTEMPTS);
expect(dead!.last_status).toBe(503);
// The failure a human can act on. "It stopped" is not a record; a status and
// an error are.
expect(dead!.last_error).toContain("down");
const [delivery] = await repo.listDeliveriesForEvent(eventId);
expect(delivery!.id).toBe(deliveryId);
expect(delivery!.state).toBe("dead");
});
it("invariant 12: a replay reuses the ORIGINAL event id", async () => {
const { eventId } = await exhaust();
const [dead] = (await repo.listDeadLetters()).filter(
(d) => d.event_id === eventId,
);
const replayed = await replayDeadLetter(db, dead!.id);
expect(replayed).toBe(true);
const [delivery] = await repo.listDeliveriesForEvent(eventId);
// THE POINT. An operator replaying something a customer already received
// must not harm a customer who deduplicates correctly — so the replay
// carries the identifier they deduplicate ON, rather than a fresh one that
// would look like a new event.
expect(delivery!.event_id).toBe(eventId);
// Live again, from the first tier: a replay is a fresh chance, not a
// continuation of an exhausted schedule.
expect(delivery!.state).toBe("pending");
expect(delivery!.attempt).toBe(1);
expect(delivery!.dispatched_at).toBeNull();
});
it("invariant 12: a replayed delivery is claimable by the relay", async () => {
const { eventId } = await exhaust();
const [dead] = (await repo.listDeadLetters()).filter(
(d) => d.event_id === eventId,
);
await replayDeadLetter(db, dead!.id);
await drainDueDeliveries(db, 500, async () => {});
const [delivery] = await repo.listDeliveriesForEvent(eventId);
// Not merely marked pending — actually picked up. A replay that reappears in
// the table and never leaves it is a button that does nothing.
expect(delivery!.dispatched_at).not.toBeNull();
});
it("invariant 12: the dead-letter record survives the replay", async () => {
const { eventId } = await exhaust();
const [dead] = (await repo.listDeadLetters()).filter(
(d) => d.event_id === eventId,
);
await replayDeadLetter(db, dead!.id);
// FR-WHK-04 retains dead letters for seven days. A replay is a new attempt,
// not an erasure of the record that the attempts once ran out — otherwise
// the only evidence a customer's endpoint was broken disappears the moment
// somebody retries it.
const still = (await repo.listDeadLetters()).filter(
(d) => d.event_id === eventId,
);
expect(still).toHaveLength(1);
});
});
// The material for one attempt.
//
// This block exists because coverage said the two functions below were never
// executed by any test in the instrument. They ARE exercised constantly — the
// dispatcher calls both over HTTP on every pass — but the dispatcher's suite
// runs the api as a CHILD PROCESS, and a child's coverage is not attributable.
// So the one function in the platform that hands a customer's signing secret
// back in plaintext was, by the only measure the constitution names, untested.
describe("the material for one attempt", () => {
let db: Db;
/** A FRESH ENVIRONMENT per delivery, and the reason is a bug this suite had
* for exactly one run: expansion matches every subscribed endpoint in the
* environment, so a second test seeding a second endpoint made
* `listDeliveriesForEvent[0]` the FIRST test's delivery. The assertions then
* rotated one endpoint's secret and read another one's material. One
* environment per case is the same isolation the top of this file describes,
* applied one level down. */
const seedDelivery = async (): Promise<{
deliveryId: string;
endpointId: string;
secret: string;
eventId: string;
envId: string;
repo: Repository;
}> => {
const env = await createEnvironment(db, {
name: `material-itest-${randomUUID().slice(0, 8)}`,
});
const repo = new Repository(db, env.id);
const secret = mintSigningSecret();
const endpoint = await repo.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(secret),
});
const eventId = randomUUID();
await expandEventToDeliveries(db, {
eventId,
environmentId: env.id,
type: "message.created",
payload: { id: eventId, type: "message.created" },
});
const deliveries = await repo.listDeliveriesForEvent(eventId);
expect(deliveries).toHaveLength(1);
return {
deliveryId: deliveries[0]!.id,
endpointId: endpoint.id,
secret,
eventId,
envId: env.id,
repo,
};
};
beforeAll(() => {
db = createDb(createPool());
});
it("hands back the url, the payload and the DECRYPTED secret", async () => {
const { deliveryId, endpointId, secret, eventId, envId } = await seedDelivery();
const material = await deliveryMaterial(db, deliveryId);
expect(material).not.toBeNull();
expect(material!.endpoint_id).toBe(endpointId);
expect(material!.event_id).toBe(eventId);
expect(material!.environment_id).toBe(envId);
expect(material!.attempt).toBe(1);
// Plaintext, on purpose and exactly once in the platform. The dispatcher
// cannot sign with a ciphertext, and it holds no key.
expect(material!.secrets).toEqual([secret]);
});
it("hands back BOTH secrets inside the rotation window", async () => {
const { deliveryId, endpointId, secret: original, repo } = await seedDelivery();
const replacement = mintSigningSecret();
await repo.rotateEndpointSecret(endpointId, encryptSecret(replacement));
const material = await deliveryMaterial(db, deliveryId);
// The new one FIRST — a customer verifying against the current secret
// succeeds on the first comparison, and the old one is only there so a
// deployment still running yesterday's configuration is not cut off
// mid-rotation.
expect(material!.secrets).toEqual([replacement, original]);
});
it("hands back nothing for a disabled endpoint", async () => {
const { deliveryId, endpointId, repo } = await seedDelivery();
await repo.setEndpointEnabled(endpointId, false);
// The spec's edge case: deliveries already in the schedule for an endpoint
// the customer paused must not be delivered. `null` is what the dispatcher
// turns into `skipped`, which acknowledges the message rather than retrying
// a delivery that can never succeed.
expect(await deliveryMaterial(db, deliveryId)).toBeNull();
});
it("hands back nothing for a deleted endpoint", async () => {
const { deliveryId, endpointId, repo } = await seedDelivery();
await repo.deleteEndpoint(endpointId);
// Soft-deleted, so the row is still joinable — which is exactly why this
// check has to be explicit. A hard delete would have made the join fail and
// hidden the requirement behind a foreign key.
expect(await deliveryMaterial(db, deliveryId)).toBeNull();
});
it("hands back nothing for a delivery id that does not exist", async () => {
expect(await deliveryMaterial(db, randomUUID())).toBeNull();
});
it("counts what is pending and stops counting it once it is delivered", async () => {
const { deliveryId } = await seedDelivery();
// GLOBAL, and asserted as a delta for that reason — this is the number an
// operator watches, so it counts every tenant's backlog, and another suite
// seeding rows beside this one must not be able to break it.
const before = await pendingDeliveryDepth(db);
expect(before).toBeGreaterThan(0);
await recordAttemptOutcome(db, { deliveryId, attempt: 1, status: 200 });
expect(await pendingDeliveryDepth(db)).toBe(before - 1);
});
});
// The answers nobody asks for on a good day.
//
// Every case here is a branch the happy path never reaches: a delivery id that
// is not there, a report that arrives after the schedule has already finished
// with it, a dead-letter with no status because there was never a response. They
// are cheap to write and they are exactly what an on-call engineer meets first,
// because the ordinary paths are the ones that do not page anyone.
describe("the outcome of an attempt, off the happy path", () => {
let db: Db;
/** Fresh environment per delivery — one endpoint, one delivery row, no
* borrowing another case's rows. */
const seedOne = async () => {
const env = await createEnvironment(db, {
name: `outcome-edge-${randomUUID().slice(0, 8)}`,
});
const repo = new Repository(db, env.id);
await repo.createEndpoint({
url: `https://example.test/${randomUUID()}`,
eventTypes: ["message.created"],
secretCiphertext: encryptSecret(mintSigningSecret()),
});
const eventId = randomUUID();
await expandEventToDeliveries(db, {
eventId,
environmentId: env.id,
type: "message.created",
payload: { id: eventId },
});
const rows = await repo.listDeliveriesForEvent(eventId);
expect(rows).toHaveLength(1);
return { delivery: rows[0]!, repo };
};
beforeAll(() => {
db = createDb(createPool());
});
it("refuses an outcome for a delivery that does not exist", async () => {
// A CALLER error, not a platform one. The dispatcher can only produce this
// by reporting against an id it invented, so it becomes a 404 rather than a
// 500 — and the distinction matters, because a 500 would tell the dispatcher
// to leave the message unacknowledged and try the same impossible report
// until the broker gave up.
await expect(
recordAttemptOutcome(db, { deliveryId: randomUUID(), attempt: 1, status: 200 }),
).rejects.toBeInstanceOf(DeliveryNotFoundError);
});
it("answers a late report about a DELIVERED delivery with what was decided", async () => {
const { delivery } = await seedOne();
await recordAttemptOutcome(db, { deliveryId: delivery.id, attempt: 1, status: 200 });
// The dispatcher crashed after reporting and before acknowledging, so the
// broker handed the job back and it reported again. The answer must be the
// ORIGINAL decision — anything else would have it retry a delivery the
// customer already received.
const late = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: 1,
status: 200,
});
expect(late.outcome).toBe("delivered");
expect(late.nextAttemptAt).toBeNull();
});
it("answers a late report about a DEAD delivery with what was decided", async () => {
const { delivery } = await seedOne();
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
await recordAttemptOutcome(db, { deliveryId: delivery.id, attempt, status: 503 });
}
const late = await recordAttemptOutcome(db, {
deliveryId: delivery.id,
attempt: MAX_ATTEMPTS,
status: 503,
});
expect(late.outcome).toBe("dead_lettered");
expect(late.nextAttemptAt).toBeNull();
});
it("dead-letters a delivery that never produced a status at all", async () => {
const { delivery, repo } = await seedOne();
// Every attempt a timeout: no response, so no status and no body. The
// dead-letter record has to be writable from nothing but the fact that
// nothing came back, or the endpoints that fail most completely are the ones
// that leave the least evidence.
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
await recordAttemptOutcome(db, { deliveryId: delivery.id, attempt });
}
const mine = (await repo.listDeadLetters()).filter(
(d) => d.event_id === delivery.event_id,
);
expect(mine).toHaveLength(1);
expect(mine[0]!.last_status).toBeNull();
expect(mine[0]!.attempts).toBe(MAX_ATTEMPTS);
});
it("refuses to replay a dead letter that does not exist", async () => {
// `false`, not a throw: the controller turns it into the same 404 a foreign
// tenant gets, so a probe cannot tell "no such record" from "not yours".
expect(await replayDeadLetter(db, randomUUID())).toBe(false);
});
});import { spawn, type ChildProcess } from "node:child_process";
import { createServer, type Server } from "node:http";
import { createRequire } from "node:module";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { createHmac, randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { connect, DeliverPolicy, type NatsConnection } from "nats";
import { subjectFor } from "@relay/protocol";
import { ACK_WAIT_MS, createDispatcher, type Dispatcher } from "./main.js";
// The dispatcher against a real api, a real broker and a real customer endpoint
// (chapter 3.5). Invariant 7 lives here; 11, 13, 15 and 16 join it.
//
// The api runs as a CHILD PROCESS, not in-process — the same choice the
// gateway's socket suite made in 3.2 and for the same reason. The dispatcher's
// whole point is that it reaches state over the internal seam rather than
// through a database client, and a suite that imported the api's modules would
// prove that over a function call instead of over HTTP.
const encoder = new TextEncoder();
const require_ = createRequire(import.meta.url);
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, "..", "..", "..");
const API_DIST = join(REPO, "services", "api", "dist");
const CREDENTIAL =
process.env["RELAY_INTERNAL_CREDENTIAL"] ??
"rk_svc_dispatcher_itest_0123456789abcdef";
interface Seeded {
environmentId: string;
endpointId: string;
secret: string;
}
/** A customer's server. Records what arrives and answers however the test says.
* The same shape `scripts/hostile-endpoint.mjs` gives a reader by hand. */
function customerEndpoint() {
const received: { headers: Record<string, string>; body: string; at: number }[] =
[];
let reply: number | "hang" = 200;
const held: import("node:http").ServerResponse[] = [];
const server: Server = createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += String(c)));
req.on("end", () => {
received.push({
headers: req.headers as Record<string, string>,
body,
at: Date.now(),
});
if (reply === "hang") {
// Accepts the request and never answers. The failure mode a timeout
// exists for, and the one a customer never notices on their side.
held.push(res);
return;
}
res.writeHead(reply).end("ok");
});
});
return {
received,
listen: () =>
new Promise<string>((resolve) =>
server.listen(0, () => {
const addr = server.address();
resolve(
`http://127.0.0.1:${typeof addr === "object" && addr ? addr.port : 0}`,
);
}),
),
answerWith: (status: number | "hang") => {
reply = status;
},
close: () => {
for (const res of held) res.destroy();
server.close();
},
};
}
async function waitForHealth(url: string): Promise<void> {
const deadline = Date.now() + 30_000;
for (;;) {
try {
if ((await fetch(url)).ok) return;
} catch {
// not up yet
}
if (Date.now() > deadline) throw new Error("api never became healthy");
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/** The api, as a child process. Extracted so invariant 11 can kill it and start
* a new one — the point of that test is that neither process holds the retry
* schedule, and a suite that could not restart the api could not show it. */
function spawnApi(port: number, credential: string): ChildProcess {
return spawn("node", [join(API_DIST, "main.js")], {
env: {
...process.env,
PORT: String(port),
RELAY_INTERNAL_CREDENTIAL: credential,
// Chapter 3.3's finding 4, for the third time: this suite drives the relay
// explicitly, so a background copy draining the same table would race it.
RELAY_OUTBOX_RELAY: "off",
RELAY_EVENT_CONSUMER: "off",
RELAY_DELIVERY_RELAY: "off",
},
stdio: ["ignore", "pipe", "pipe"],
});
}
/** Keeps every line the dispatcher writes, so invariant 15 can assert on the
* logs themselves rather than on the request that went out. A secret leaks into
* an operator's terminal through a log line, not through a header. */
function capturingLogger() {
const lines: string[] = [];
return {
lines,
logger: {
log(level: "info" | "error", msg: string, fields?: Record<string, unknown>) {
lines.push(JSON.stringify({ level, msg, ...(fields ?? {}) }));
},
},
};
}
describe("the dispatcher", () => {
let child: ChildProcess;
let apiUrl: string;
let apiPort: number;
/** The per-run durable names. A restarted dispatcher must reuse them — a
* durable IS a position, and taking a new name would be starting over rather
* than resuming. */
let durables: { expand: string; deliver: string };
const captured = capturingLogger();
let dispatcher: Dispatcher;
let endpoint: ReturnType<typeof customerEndpoint>;
/** A second customer, so "does not delay deliveries to OTHER endpoints" has an
* other to be delayed. */
let second: ReturnType<typeof customerEndpoint>;
let secondUrl: string;
/** Short on purpose: the isolation property is about whether a healthy
* endpoint waits behind a hanging one, and ten real seconds per assertion buys
* nothing but a slower suite. */
const TEST_TIMEOUT_MS = 2_000;
const ACK_WAIT_TEST_MS = 4_000;
let seeder: {
createEnvironment: (db: unknown, o: { name: string }) => Promise<{ id: string }>;
Repository: new (db: unknown, envId: string) => {
createEndpoint: (i: {
url: string;
eventTypes: string[];
secretCiphertext: string;
}) => Promise<{ id: string }>;
deleteEndpoint: (id: string) => Promise<boolean>;
listDeliveriesForEvent: (
eventId: string,
) => Promise<
{
id: string;
attempt: number;
state: string;
next_attempt_at: string;
dispatched_at: string | null;
}[]
>;
};
expandEventToDeliveries: (db: unknown, e: unknown) => Promise<unknown>;
drainDueDeliveries: (
db: unknown,
n: number,
f: (r: unknown) => Promise<void>,
) => Promise<number>;
};
let secrets: { encryptSecret: (s: string) => string; mintSigningSecret: () => string };
/** The real tier table, loaded from the api's build like everything else here
* — the api is CommonJS and this package is ESM (ADR-15's dialect split), so
* importing its source directly is not available to us. */
let retryTiersMs: number[];
let db: unknown;
/** Only the expansion tests need one, so it is opened lazily and drained in
* `afterAll` — an undrained connection keeps the process alive past the run. */
let nats: NatsConnection | null = null;
const NATS_URL = process.env["RELAY_NATS_URL"] ?? "nats://localhost:4222";
const seed = async (eventTypes: string[], url?: string): Promise<Seeded> => {
const env = await seeder.createEnvironment(db, {
name: `dispatcher-itest-${randomUUID().slice(0, 8)}`,
});
const repo = new seeder.Repository(db, env.id);
const secret = secrets.mintSigningSecret();
const created = await repo.createEndpoint({
url: `${url ?? (await endpointUrl)}/hook`,
eventTypes,
secretCiphertext: secrets.encryptSecret(secret),
});
return { environmentId: env.id, endpointId: created.id, secret };
};
let endpointUrl: Promise<string>;
/** Poll until the expectation holds, or the deadline passes. Bounded, so a
* genuine failure is a failure rather than a hang — and a negative assertion
* ("nothing should arrive") simply spends the whole budget and moves on. */
const pollUntil = async (
done: () => boolean | Promise<boolean>,
timeoutMs = 8_000,
): Promise<void> => {
const deadline = Date.now() + timeoutMs;
// Awaited, so a predicate that has to ASK the database — "are the delivery
// rows there yet?" — works as naturally as one that reads an array.
while (!(await done()) && Date.now() < deadline) {
await dispatcher.pollOnce();
}
};
/** The captured log lines are JSON strings; this is the read side. */
const logged = (msg: string): Record<string, unknown>[] =>
captured.lines
.map((l) => JSON.parse(l) as Record<string, unknown>)
.filter((l) => l["msg"] === msg);
/** Publish a real event onto the EVENTS stream, exactly as chapter 3.3's
* outbox relay does.
*
* WHY THIS EXISTS. Every other helper in this file reaches expansion by
* calling `expandEventToDeliveries` against the database directly — fine for a
* suite about DELIVERY, which is what those tests are about, but it meant the
* dispatcher's own expand consumer was never once executed by its own suite.
* Coverage said so plainly: `expand.ts`, 0%. The tests below go in through the
* broker instead, so the consumer that decodes an event, asks the api to
* expand it, and decides ack-or-terminate is actually the thing under test. */
const publishEvent = async (
subject: string,
bytes: Uint8Array,
): Promise<void> => {
nats ??= await connect({ servers: NATS_URL });
await nats.jetstream().publish(subject, bytes);
};
/** Run the api's delivery relay once: everything due goes onto the stream.
* The real loop, driven explicitly — this suite turns the background copy off
* so the two cannot race (chapter 3.3's finding 4, third occurrence). */
const publishDue = async (): Promise<number> => {
const relay = require_(join(API_DIST, "webhooks", "delivery-relay.js")) as {
createDeliveryRelay: (o: unknown) => { drainOnce: () => Promise<number> };
ensureDeliveriesStream: unknown;
};
const publisherMod = require_(
join(API_DIST, "outbox", "jetstream.publisher.js"),
) as { createJetStreamPublisher: (o: unknown) => unknown };
const kit = require_(
join(REPO, "packages", "service-kit", "dist", "index.js"),
) as { createLogger: (n: string) => unknown };
const r = relay.createDeliveryRelay({
db,
publisher: publisherMod.createJetStreamPublisher({
ensure: relay.ensureDeliveriesStream,
}),
logger: kit.createLogger("itest-relay"),
});
return r.drainOnce();
};
/** As `deliverEvent`, but with a tenant's message text in the payload — so
* invariant 15 has something that must NOT appear in a log line. */
const deliverEventWithText = async (
seeded: Seeded,
text: string,
): Promise<void> => {
const seenBefore = endpoint.received.length;
const eventId = randomUUID();
await seeder.expandEventToDeliveries(db, {
eventId,
environmentId: seeded.environmentId,
type: "message.created",
payload: {
id: eventId,
type: "message.created",
environment_id: seeded.environmentId,
data: { id: randomUUID(), seq: 1, user: "tuan", text },
},
});
await publishDue();
await pollUntil(() => endpoint.received.length > seenBefore);
};
/** Publish an event, let the relay put its due deliveries on the stream, then
* run one dispatcher pass — the same code path `start()` runs. */
const deliverEvent = async (
seeded: Seeded,
type: string,
): Promise<{ eventId: string }> => {
const seenBefore = endpoint.received.length;
const eventId = randomUUID();
await seeder.expandEventToDeliveries(db, {
eventId,
environmentId: seeded.environmentId,
type,
payload: {
id: eventId,
type,
environment_id: seeded.environmentId,
occurred_at: new Date().toISOString(),
data: { id: randomUUID(), seq: 1, user: "tuan", text: "B2, north ramp" },
},
});
await publishDue();
// POLL, don't peek. A single pass races the broker: `fetch` returns whatever
// is available at that instant, and a message published a moment earlier may
// not be yet. Production polls in a loop, so a test that polls once is
// testing something the service never does — and it fails intermittently,
// which is worse than failing.
await pollUntil(() => endpoint.received.length > seenBefore);
return { eventId };
};
beforeAll(async () => {
if (!existsSync(join(API_DIST, "main.js"))) {
throw new Error(
"the api is not built — run `pnpm build` before this lane " +
"(the suite talks to the real service over HTTP, not a stub)",
);
}
const client = require_(join(API_DIST, "db", "client.js")) as {
createDb: (p: unknown) => unknown;
createPool: () => unknown;
};
seeder = require_(join(API_DIST, "db", "repository.js")) as typeof seeder;
secrets = require_(join(API_DIST, "webhooks", "secret.js")) as typeof secrets;
retryTiersMs = (
require_(join(API_DIST, "webhooks", "schedule.js")) as { RETRY_TIERS_MS: number[] }
).RETRY_TIERS_MS;
db = client.createDb(client.createPool());
endpoint = customerEndpoint();
endpointUrl = endpoint.listen();
await endpointUrl;
second = customerEndpoint();
secondUrl = await second.listen();
apiPort = Number(process.env["RELAY_DISPATCHER_ITEST_API_PORT"] ?? 4131);
child = spawnApi(apiPort, CREDENTIAL);
apiUrl = `http://127.0.0.1:${apiPort}`;
await waitForHealth(`${apiUrl}/healthz`);
// A per-run position, and only messages published after it exists. Sharing
// the production durable would hand this suite every delivery every earlier
// run left behind — and a batch of twenty-five is quickly all backlog, which
// is exactly how this suite first failed. Chapter 2.1 did the same for
// environments, 2.6 for subjects, 3.4 for its own durables.
const run = randomUUID().slice(0, 8);
durables = { expand: `itest-expand-${run}`, deliver: `itest-deliver-${run}` };
dispatcher = createDispatcher({
apiUrl,
credential: CREDENTIAL,
attemptTimeoutMs: TEST_TIMEOUT_MS,
durables,
deliverPolicy: DeliverPolicy.New,
// ONLY the expand consumer is shortened, so "an unacknowledged message
// comes back" is observable inside a test's patience. The deliver
// consumer keeps the production window: its attempts run to a timeout,
// and a short window there makes the broker redeliver work that is still
// in progress — which is how this suite briefly delivered two webhooks to
// one customer under the coverage lane's slower clock.
ackWaitMs: { expand: ACK_WAIT_TEST_MS, deliver: ACK_WAIT_MS },
logger: captured.logger,
});
// Created BEFORE anything is published, or "New" would skip the first event.
await dispatcher.ready();
}, 60_000);
afterAll(async () => {
await dispatcher?.stop();
if (nats && !nats.isClosed()) await nats.drain();
child?.kill();
endpoint?.close();
second?.close();
});
it("invariant 7: delivers an event the endpoint subscribes to", async () => {
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
const before = endpoint.received.length;
await deliverEvent(seeded, "message.created");
expect(endpoint.received.length).toBeGreaterThan(before);
});
it("invariant 7: delivers nothing for an event type it does not subscribe to", async () => {
endpoint.answerWith(200);
const seeded = await seed(["channel.created"]);
const before = endpoint.received.length;
await deliverEvent(seeded, "message.created");
// Filtered at EXPANSION, so there is no delivery row at all — not a row that
// exists and is never sent, which would be a permanent no-op in the retry
// schedule that an operator could not tell from work that is stuck.
expect(endpoint.received.length).toBe(before);
});
it("invariant 16: the delivered body is 3.3's envelope and carries the event id", async () => {
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
const { eventId } = await deliverEvent(seeded, "message.created");
const last = endpoint.received.at(-1)!;
const body = JSON.parse(last.body) as { id: string; type: string };
// The field every claim this chapter makes about at-least-once rests on: the
// identifier a customer deduplicates the accepted duplicate on.
expect(body.id).toBe(eventId);
expect(body.type).toBe("message.created");
});
it("invariant 15: no log line carries a signing secret or a message body", async () => {
// NFR-SEC-06, and the reason it is asserted against the LOGS rather than the
// outgoing request: a secret reaches an operator's terminal through a log
// line. The header assertion below is the easy half; the log line is the one
// a well-meaning "just for debugging" change would break.
//
// Driven through the ERROR PATHS on purpose — a success path rarely prints
// what it was working with, and an error path frequently does.
const body = "B2, north ramp — a tenant's message text";
// 1. a success
endpoint.answerWith(200);
const ok = await seed(["message.created"]);
await deliverEventWithText(ok, body);
// 2. a customer failure
endpoint.answerWith(500);
const failing = await seed(["message.created"]);
await deliverEventWithText(failing, body);
// 3. a customer that hangs, so the timeout path logs too
endpoint.answerWith("hang");
const hanging = await seed(["message.created"]);
await deliverEventWithText(hanging, body);
endpoint.answerWith(200);
expect(captured.lines.length).toBeGreaterThan(0);
const all = captured.lines.join("\n");
for (const seeded of [ok, failing, hanging]) {
expect(all).not.toContain(seeded.secret);
}
// Nor a tenant's message text. Identifiers, counts and durations are the
// whole vocabulary of these lines.
expect(all).not.toContain(body);
});
it("invariant 15: no request header carries the secret either", async () => {
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
await deliverEvent(seeded, "message.created");
const last = endpoint.received.at(-1)!;
// The signature travels; the secret never does. A header carrying the shared
// secret would hand it to anyone who can read one request.
expect(JSON.stringify(last.headers)).not.toContain(seeded.secret);
expect(last.body).not.toContain(seeded.secret);
});
it("invariant 13: a hanging endpoint is abandoned on the timeout", async () => {
endpoint.answerWith("hang");
const seeded = await seed(["message.created"]);
const started = Date.now();
await deliverEvent(seeded, "message.created");
const elapsed = Date.now() - started;
// Abandoned, not waited on forever. The customer accepted the request and
// never answered — the failure a timeout exists for, and the one they never
// notice on their side.
expect(endpoint.received.length).toBeGreaterThan(0);
expect(elapsed).toBeLessThan(TEST_TIMEOUT_MS * 3);
endpoint.answerWith(200);
});
it("invariant 13: a hanging endpoint does not delay deliveries to another", async () => {
// THE CLAUSE FR-WHK-05 ACTUALLY CARES ABOUT. "Abandoned on the timeout" is
// the easy half; this is the half that fails if deliveries are processed one
// after another, because the healthy customer then waits out somebody else's
// silence before hearing anything at all.
endpoint.answerWith("hang");
second.answerWith(200);
const hanging = await seed(["message.created"]);
const healthy = await seed(["message.created"], secondUrl);
// ORDER MATTERS, and the test is worthless without fixing it. The relay
// drains by `next_attempt_at, id`, so expanding the hanging endpoint FIRST
// puts it at the head of the batch. If deliveries were processed one after
// another, the healthy customer would then sit behind a full timeout it had
// nothing to do with — which is precisely the failure being ruled out.
//
// Left unordered, this test passes whether or not the isolation exists: with
// sequential processing the healthy delivery might simply happen to go
// first. That was the first version, and the sabotage check caught it.
await seeder.expandEventToDeliveries(db, {
eventId: randomUUID(),
environmentId: hanging.environmentId,
type: "message.created",
payload: { id: randomUUID(), type: "message.created" },
});
await seeder.expandEventToDeliveries(db, {
eventId: randomUUID(),
environmentId: healthy.environmentId,
type: "message.created",
payload: { id: randomUUID(), type: "message.created" },
});
await publishDue();
const hangingBefore = endpoint.received.length;
const healthyBefore = second.received.length;
await dispatcher.pollOnce();
expect(endpoint.received.length).toBeGreaterThan(hangingBefore);
expect(second.received.length).toBeGreaterThan(healthyBefore);
// THE PROPERTY, and it does not depend on how long the setup took: the
// healthy customer heard from us while the other request was still hanging.
// The hanging endpoint is first in the batch, so sequential processing would
// put this arrival a whole timeout later.
const hangingAt = endpoint.received.at(-1)!.at;
const healthyAt = second.received.at(-1)!.at;
expect(healthyAt - hangingAt).toBeLessThan(TEST_TIMEOUT_MS / 2);
endpoint.answerWith(200);
});
it("invariant 11: a pending retry survives a restart of BOTH processes", async () => {
// THE INVARIANT THAT DECIDED THE DESIGN.
//
// Research R1 measured the obvious implementation — a broker-held delay —
// and found it durable to within 3 ms but fatal in aggregate: a waiting
// message holds an acknowledgement slot, so dead endpoints starve healthy
// ones. The schedule became a `next_attempt_at` column instead.
//
// The consequence is this test. A row is held by NEITHER process, so both
// can be destroyed between an attempt and its retry and the retry still
// falls due. Under the broker-held design the api's restart would not even
// have been a question — the api held nothing. Now it holds everything, and
// may still be killed.
//
// WHAT THIS ASSERTS, precisely: that the SCHEDULE survives and becomes
// claimable again. That the claimed delivery then reaches the customer is
// invariants 7 and 13's subject, proven against this same endpoint above;
// repeating it here would tangle the schedule's durability with the
// dispatcher's stream position, which is a different property with a
// different failure mode.
endpoint.answerWith(500);
const seeded = await seed(["message.created"]);
const repo = new seeder.Repository(db, seeded.environmentId);
// Attempt 1 fails, so attempt 2 is scheduled one second out.
const { eventId } = await deliverEvent(seeded, "message.created");
const [afterAttempt1] = await repo.listDeliveriesForEvent(eventId);
expect(afterAttempt1!.attempt).toBe(2);
expect(afterAttempt1!.state).toBe("pending");
// Destroy BOTH processes. Nothing in memory anywhere survives this.
await dispatcher.stop();
child.kill("SIGKILL");
await new Promise((resolve) => setTimeout(resolve, 250));
child = spawnApi(apiPort, CREDENTIAL);
await waitForHealth(`${apiUrl}/healthz`);
// The schedule is exactly where it was, in a database neither process was
// holding — same tier, same due time, still unclaimed.
const [survived] = await repo.listDeliveriesForEvent(eventId);
expect(survived!.attempt).toBe(2);
expect(survived!.state).toBe("pending");
expect(survived!.next_attempt_at).toBe(afterAttempt1!.next_attempt_at);
expect(survived!.dispatched_at).toBeNull();
// And when the tier falls due, the relay in the RESTARTED api claims it —
// so the retry is live again, not merely remembered.
await new Promise((resolve) => setTimeout(resolve, 1_500));
await publishDue();
const [claimed] = await repo.listDeliveriesForEvent(eventId);
expect(claimed!.dispatched_at).not.toBeNull();
endpoint.answerWith(200);
// Leave a working dispatcher behind for anything that runs after this.
dispatcher = createDispatcher({
apiUrl,
credential: CREDENTIAL,
attemptTimeoutMs: TEST_TIMEOUT_MS,
durables,
deliverPolicy: DeliverPolicy.New,
logger: captured.logger,
});
await dispatcher.ready();
});
it("invariant 14: a backlog accumulated while not consuming drains when it resumes", async () => {
// The other half of invariant 14. The e2e journey shows end users are served
// while the dispatcher does not exist; this shows the work it was not doing
// was WAITING rather than lost.
//
// "Absent" is modelled as NOT CONSUMING, which is what absence means to a
// durable consumer: the position stays where it is and the stream keeps the
// messages. Process lifecycle — killing and restarting both services — is
// invariant 11's subject, and it holds the schedule rather than the stream.
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
const before = endpoint.received.length;
// Three events expand and become due, and the relay puts them on the stream.
// Nothing consumes in this stretch.
const events = [randomUUID(), randomUUID(), randomUUID()];
for (const eventId of events) {
await seeder.expandEventToDeliveries(db, {
eventId,
environmentId: seeded.environmentId,
type: "message.created",
payload: { id: eventId, type: "message.created" },
});
}
await publishDue();
// Consuming resumes. The backlog is on the stream and the deliveries are
// rows; neither needed the dispatcher to be watching.
const deadline = Date.now() + 30_000;
while (
endpoint.received.length - before < events.length &&
Date.now() < deadline
) {
await dispatcher.pollOnce();
}
expect(endpoint.received.length - before).toBeGreaterThanOrEqual(
events.length,
);
}, 90_000);
// ---------------------------------------------------------------------------
// The EXPAND consumer, driven through the broker.
//
// These three are the reason `publishEvent` exists. Everything above reaches
// the delivery path by seeding rows; nothing above ever ran `expand.ts`.
// ---------------------------------------------------------------------------
it("invariant 8: one event becomes one delivery per matching endpoint", async () => {
endpoint.answerWith(200);
// Two endpoints in ONE environment, subscribed to the same type. The
// fan-out is the property: N endpoints, N delivery rows, one event.
const seeded = await seed(["message.created"]);
const repo = new seeder.Repository(db, seeded.environmentId);
await repo.createEndpoint({
url: `${secondUrl}/hook`,
eventTypes: ["message.created"],
secretCiphertext: secrets.encryptSecret(secrets.mintSigningSecret()),
});
// A third endpoint that subscribes to something else, so "matching" is
// doing work rather than being satisfied by every endpoint in the row.
await repo.createEndpoint({
url: `${secondUrl}/unwanted`,
eventTypes: ["channel.created"],
secretCiphertext: secrets.encryptSecret(secrets.mintSigningSecret()),
});
const eventId = randomUUID();
await publishEvent(
subjectFor("message.created", seeded.environmentId),
encoder.encode(
JSON.stringify({
id: eventId,
type: "message.created",
environment_id: seeded.environmentId,
occurred_at: new Date().toISOString(),
data: { id: randomUUID(), seq: 1, user: "tuan", text: "north ramp" },
}),
),
);
// The DISPATCHER expands it — no direct database write anywhere in this test.
let rows: Awaited<ReturnType<typeof repo.listDeliveriesForEvent>> = [];
await pollUntil(async () => {
rows = await repo.listDeliveriesForEvent(eventId);
return rows.length >= 2;
});
expect(rows).toHaveLength(2);
}, 60_000);
it("invariant 9: a redelivered event does not double the customer's webhooks", async () => {
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
const repo = new seeder.Repository(db, seeded.environmentId);
const eventId = randomUUID();
const bytes = encoder.encode(
JSON.stringify({
id: eventId,
type: "message.created",
environment_id: seeded.environmentId,
occurred_at: new Date().toISOString(),
data: { id: randomUUID(), seq: 1, user: "tuan", text: "B2" },
}),
);
const subject = subjectFor("message.created", seeded.environmentId);
// The SAME event id, twice. This is what a broker redelivery looks like from
// the consumer's side, and research R2's claim is that the api's claim
// transaction absorbs it: the second expansion creates nothing.
await publishEvent(subject, bytes);
let rows: Awaited<ReturnType<typeof repo.listDeliveriesForEvent>> = [];
await pollUntil(async () => {
rows = await repo.listDeliveriesForEvent(eventId);
return rows.length >= 1;
});
expect(rows).toHaveLength(1);
await publishEvent(subject, bytes);
await pollUntil(() => false, 2_000); // spend the budget; nothing to wait for
// Still one. A second row here would be a second webhook to a customer who
// was promised one, which is the failure this whole design is built around.
expect(await repo.listDeliveriesForEvent(eventId)).toHaveLength(1);
expect(logged("expand.done").some((l) => l["duplicate"] === true)).toBe(true);
}, 60_000);
it("bytes that can never parse are terminated on the first attempt", async () => {
const seeded = await seed(["message.created"]);
// Not JSON at all. `msg.json()` throws, and the consumer must TERMINATE
// rather than leave it unacknowledged — the same bytes fail the same way
// every time, so redelivering them spends the broker's attempt budget to
// reach a conclusion that was available on the first pass (chapter 3.4).
await publishEvent(
subjectFor("message.created", seeded.environmentId),
encoder.encode("{ this is not json"),
);
await pollUntil(() => logged("expand.undecodable").length > 0);
expect(logged("expand.undecodable").length).toBeGreaterThan(0);
// Terminated, so it does not come back — and the window below outlasts
// `ackWaitMs`, which is the only reason this assertion can fail. Left
// unacknowledged, the broker would hand it back and the count would climb.
const seen = logged("expand.undecodable").length;
await pollUntil(() => false, ACK_WAIT_TEST_MS + 3_000);
expect(logged("expand.undecodable").length).toBe(seen);
}, 60_000);
it("JSON that is not an envelope is terminated too, not retried", async () => {
const seeded = await seed(["message.created"]);
// Parses cleanly, so it gets past `msg.json()` — and then fails the SHAPE
// check inside `parseEventEnvelope`, which is a different rejection at a
// different layer. Chapter 2.5's reason for parsing rather than trusting: a
// message that has been sitting in a stream has had time to stop matching
// the code that reads it, and "it was valid JSON" is not the same as "it is
// still an event".
await publishEvent(
subjectFor("message.created", seeded.environmentId),
encoder.encode(JSON.stringify({ id: randomUUID(), type: "message.created" })),
);
await pollUntil(() => logged("expand.unparseable").length > 0);
expect(logged("expand.unparseable").length).toBeGreaterThan(0);
const seen = logged("expand.unparseable").length;
await pollUntil(() => false, ACK_WAIT_TEST_MS + 3_000);
expect(logged("expand.unparseable").length).toBe(seen);
}, 60_000);
it("invariant 10: a FAILED delivery is retried — the same row, a second attempt", async () => {
// THE REGRESSION TEST FOR THE BUG THIS SUITE DID NOT HAVE.
//
// Every other case here uses a fresh delivery, so nothing ever published the
// SAME delivery to the broker twice — and the relay's deduplication key was
// the delivery id, which is stable across all seven attempts. JetStream
// collapsed every retry into the first attempt's message, the publish
// reported success, and the delivery sat `pending` with a `dispatched_at`
// that only an outcome report clears. Failing endpoints were never retried
// at all, and the entire schedule in FR-WHK-03 was unreachable.
//
// A walk against `--mode=fail` found it. This is that walk, made to run in CI.
endpoint.answerWith(500);
const seeded = await seed(["message.created"]);
const repo = new seeder.Repository(db, seeded.environmentId);
const eventId = randomUUID();
await seeder.expandEventToDeliveries(db, {
eventId,
environmentId: seeded.environmentId,
type: "message.created",
payload: { id: eventId, type: "message.created" },
});
const before = endpoint.received.length;
await publishDue();
await pollUntil(() => endpoint.received.length > before);
const [afterFirst] = await repo.listDeliveriesForEvent(eventId);
expect(afterFirst!.attempt).toBe(2); // rescheduled, not abandoned
expect(afterFirst!.state).toBe("pending");
// The second tier is one second away, so this waits it out rather than
// rewriting the clock — the schedule under test is the real one.
await new Promise((resolve) => setTimeout(resolve, retryTiersMs[1]! + 300));
const beforeSecond = endpoint.received.length;
await publishDue();
await pollUntil(() => endpoint.received.length > beforeSecond);
// The customer was contacted a SECOND time about the same delivery. Without
// the attempt in the deduplication key this is where it stops forever.
expect(endpoint.received.length).toBeGreaterThan(beforeSecond);
const [afterSecond] = await repo.listDeliveriesForEvent(eventId);
expect(afterSecond!.attempt).toBe(3);
}, 60_000);
it("a delivery for a REMOVED endpoint is skipped, not delivered", async () => {
// The spec's edge case: events already in the retry schedule for an endpoint
// the customer removed must not be delivered, and must not accumulate.
//
// Covered deliberately because it was covered ACCIDENTALLY before — by
// leftover deliveries other suites had left pointing at endpoints they had
// deleted. That made `deliver.ts`'s coverage a function of which suites ran
// first, and a ratchet pinned on it moved on its own.
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
const repo = new seeder.Repository(db, seeded.environmentId);
const eventId = randomUUID();
await seeder.expandEventToDeliveries(db, {
eventId,
environmentId: seeded.environmentId,
type: "message.created",
payload: { id: eventId, type: "message.created" },
});
// Removed AFTER the delivery was scheduled — the whole point. Soft-deleted,
// so the row is still joinable and the check has to be explicit.
await repo.deleteEndpoint(seeded.endpointId);
const before = endpoint.received.length;
await publishDue();
await pollUntil(() =>
logged("delivery.skipped").some((l) => l["reason"] === "endpoint_unavailable"),
);
expect(
logged("delivery.skipped").some((l) => l["reason"] === "endpoint_unavailable"),
).toBe(true);
// And nothing was posted to the customer who asked to stop hearing from us.
expect(endpoint.received.length).toBe(before);
}, 60_000);
it("invariants 4 and 5: the signature ON THE WIRE verifies, over the bytes as sent", async () => {
// The unit tests prove `signDelivery` is correct. They cannot prove that
// `deliverOnce` calls it over the RIGHT BYTES and puts the result on the
// wire — and that gap is exactly where the re-serialisation trap lives, so
// the one mutation the sabotage list aims at it would have passed.
//
// Verified here the way a customer verifies: `node:crypto`, the documented
// recipe, and the raw body as received. Nothing from the signing path.
endpoint.answerWith(200);
const seeded = await seed(["message.created"]);
const { eventId } = await deliverEvent(seeded, "message.created");
const request = endpoint.received.find((r) => r.body.includes(eventId));
expect(request).toBeDefined();
const timestamp = request!.headers["relay-webhook-timestamp"];
const offered = String(request!.headers["relay-webhook-signature"])
.split(",")
.map((part) => part.trim().replace(/^v1=/, ""));
// The RAW body, byte for byte as it arrived. Parsing it and re-serialising
// would be the customer making the same mistake the platform must not.
const expected = createHmac("sha256", seeded.secret)
.update(`v1:${timestamp}:${request!.body}`)
.digest("hex");
expect(offered).toContain(expected);
}, 60_000);
});Ba service, ba container
Dispatcher là thứ đầu tiên trong repository này buộc phải được deploy chứ không
chỉ chạy, và điều đó hoá ra làm lộ ra một chuyện. Hai service đã chạy suốt tám
chương mà không có lấy một container image nào, bởi pnpm dev luôn là đủ. Service
thứ ba biến câu "thứ này thực sự ship ra sao" thành một câu hỏi không hoãn thêm
được nữa.
Nên cả ba cùng có Dockerfile trong một chương, và cái của api là cái đáng đọc — hai cái kia cùng hình dạng nhưng ít thứ hơn:
# The api (chapter 3.5). The repository's containers arrive with the dispatcher:
# adding one image for the new service alone would have made the newest
# deployable the only packaged one, which is a distinction nothing justifies.
#
# Multi-stage, and the stages are doing real work rather than ceremony: the
# build stage needs the whole workspace because `@relay/api` depends on
# `@relay/protocol` and `@relay/service-kit` by `workspace:*`, and the runtime
# stage needs none of that — only the compiled output and the production
# dependency tree.
FROM node:22-alpine AS build
RUN corepack enable
WORKDIR /repo
# The manifests first, so a source-only change does not re-run the install. The
# lockfile is copied with them and `--frozen-lockfile` makes the build fail
# rather than silently resolve something new.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/protocol/package.json packages/protocol/
COPY packages/service-kit/package.json packages/service-kit/
COPY packages/config/package.json packages/config/
COPY packages/e2e/package.json packages/e2e/
COPY services/api/package.json services/api/
COPY services/gateway/package.json services/gateway/
COPY services/dispatcher/package.json services/dispatcher/
RUN pnpm install --frozen-lockfile
COPY . .
# TURBO, not `pnpm --filter`. `@relay/api` compiles against `@relay/protocol`'s
# and `@relay/service-kit`'s emitted types, and pnpm's filter runs one package's
# script without building what it depends on. Turbo's `dependsOn: ["^build"]` is
# the thing that knows the order.
RUN pnpm exec turbo run build --filter=@relay/api
# `pnpm deploy` resolves the workspace links into a self-contained directory, so
# the runtime image carries no symlinks into a repo that will not be there.
# `--legacy` because pnpm 10 otherwise expects `inject-workspace-packages`, and
# turning that on would change how every install in this workspace resolves
# `workspace:*` — a repository-wide change to serve one build step.
RUN pnpm --filter @relay/api --prod deploy --legacy /app
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app ./
# Migrations travel with the service that owns them (ADR-04): this image is the
# only thing that may apply them.
COPY --from=build /repo/services/api/migrations ./migrations
USER node
CMD ["node", "dist/main.js"]Dispatcher là một workspace package mới, không framework và dùng ESM — ADR-15 buộc NestJS chỉ thuộc về api service, và một ứng dụng Nest thứ hai sẽ là chuyện chọn framework theo quán tính chứ không phải theo quyết định:
{
"name": "@relay/dispatcher",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.mts"
},
"dependencies": {
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*",
"nats": "^2.29.3"
},
"devDependencies": {
"@relay/api": "workspace:*",
"tsx": "^4.23.1"
}
}Gateway cần thêm một thứ nữa mới containerise được — suốt thời gian qua nó chạy qua
tsx và không hề có script build hay start của riêng mình:
@@ -2,12 +2,14 @@
"name": "@relay/gateway",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/main.ts",
+ "build": "tsc -p tsconfig.build.json",
+ "start": "node dist/main.js",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:integration": "vitest run --config vitest.integration.config.mts"
},
"dependencies": {
"@relay/protocol": "workspace:*",Package mới gia nhập đồ thị build, một thay đổi gọn một dòng mà sẽ vô hình cho tới lúc có thứ gì đó không chịu rebuild:
@@ -25,12 +25,15 @@
"RELAY_POSTGRES_PORT",
"RELAY_REDIS_URL",
"RELAY_REDIS_PORT",
"RELAY_NATS_URL",
"RELAY_NATS_PORT",
"RELAY_OUTBOX_RELAY",
+ "RELAY_DELIVERY_RELAY",
+ "RELAY_INTERNAL_CREDENTIAL",
+ "RELAY_WEBHOOK_SECRET_KEY",
"RELAY_EVENT_CONSUMER",
"RELAY_NATS_REPLICAS",
"RELAY_E2E_API_PORT"
]
},
"//#lint:root": {@@ -9,12 +9,13 @@ import { AuthModule } from "./auth/auth.module";
import { AuthenticateMiddleware } from "./auth/authenticate.middleware";
import { HealthController } from "./health.controller";
import { InternalModule } from "./internal/internal.module";
import { MessagesModule } from "./messages/messages.module";
import { ConsumerModule } from "./consumer/consumer.module";
import { OutboxModule } from "./outbox/outbox.module";
+import { WebhooksModule } from "./webhooks/webhooks.module";
import { TenancyModule } from "./tenancy/tenancy.module";
import { LOGGER, apiLogger } from "./logger";
import { ProtocolErrorFilter } from "./protocol-error.filter";
import { RequestContextMiddleware } from "./request-context.middleware";
// The application described as a module graph — ADR-15's convention for the
@@ -26,12 +27,13 @@ import { RequestContextMiddleware } from "./request-context.middleware";
AuthModule,
MessagesModule,
InternalModule,
TenancyModule,
OutboxModule,
ConsumerModule,
+ WebhooksModule,
],
controllers: [HealthController],
providers: [
{ provide: LOGGER, useFactory: apiLogger },
{ provide: APP_FILTER, useClass: ProtocolErrorFilter },
RequestContextMiddleware,@@ -3,12 +3,13 @@ import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { createLogger } from "@relay/service-kit";
import { AppModule } from "./app.module";
import { EventConsumerService } from "./consumer/consumer.module";
import { OutboxRelayService } from "./outbox/outbox.module";
+import { DeliveryRelayService } from "./webhooks/webhooks.module";
// Nest's own banner logger stays off: this workspace already decided what a
// log line looks like (one JSON object, NFR-OBS-01), and the framework does
// not get a second opinion.
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule, { logger: false });
@@ -16,12 +17,16 @@ async function bootstrap(): Promise<void> {
await app.listen(port);
// The relay starts AFTER the server is listening, and starting it cannot
// fail: the publisher connects lazily, so an unreachable broker leaves events
// accumulating in Postgres instead of preventing the api from serving writes
// (chapter 3.3, research R9).
app.get(OutboxRelayService).start();
+ // And the second relay (chapter 3.5): the same loop over a different table,
+ // publishing deliveries that have become due. Started here for 3.3's reason —
+ // a retry schedule that only runs when someone remembers is not a schedule.
+ app.get(DeliveryRelayService).start();
// And the first thing that reads what the relay publishes (chapter 3.4).
// Same placement, same reason, same lazy connection: an unreachable broker
// leaves the api serving writes.
app.get(EventConsumerService).start();
// Nest calls onModuleDestroy on shutdown hooks; without this the relay's loop
// would outlive the process's intent to stop.File compose có thêm ba service nấp sau một profile, nên docker compose up -d --wait mặc định vẫn chỉ dựng bốn store và không gì khác — người đọc đi theo chương
1.2 nhận đúng thứ chương đó đã hứa:
@@ -1,9 +1,16 @@
# The whole local world, one command: docker compose up -d --wait
-# Four stores, each here by a recorded decision (SAD §9). Host ports are env
-# knobs with the standard defaults; the container side never changes.
+# Four stores, each here by a recorded decision (SAD §9), and — since chapter
+# 3.5 — the three services themselves. Host ports are env knobs with the
+# standard defaults; the container side never changes.
+#
+# The services are in a PROFILE. `docker compose up -d --wait` still brings up
+# only the stores, which is what every chapter from 1.2 onwards tells a reader to
+# run and what the test lanes expect: the suites start their own api and their
+# own gateways, and a second copy holding the same tables would race them
+# (chapter 3.3's finding 4). `--profile services` runs the platform instead.
name: relay
services:
postgres:
image: postgres:18-alpine
environment:
@@ -63,10 +70,73 @@ services:
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8123/ping"]
interval: 5s
timeout: 3s
retries: 5
start_period: 15s
+
+ # --- the services (chapter 3.5) -----------------------------------------
+ # Behind `--profile services`, for the reason above.
+
+ api:
+ profiles: ["services"]
+ build:
+ context: .
+ dockerfile: services/api/Dockerfile
+ environment:
+ DATABASE_URL: postgres://relay:relay@postgres:5432/relay
+ RELAY_NATS_URL: nats://nats:4222
+ # Development values. Both are secrets in anything that is not a laptop,
+ # and the api refuses to start in production without the first.
+ RELAY_WEBHOOK_SECRET_KEY: ${RELAY_WEBHOOK_SECRET_KEY:-}
+ RELAY_INTERNAL_CREDENTIAL: ${RELAY_INTERNAL_CREDENTIAL:-rk_svc_local_development_credential_0000}
+ PORT: "4000"
+ ports:
+ - "${RELAY_API_PORT:-4000}:4000"
+ depends_on:
+ postgres: { condition: service_healthy }
+ nats: { condition: service_healthy }
+ healthcheck:
+ test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:4000/healthz"]
+ interval: 5s
+ timeout: 3s
+ retries: 5
+ start_period: 20s
+
+ gateway:
+ profiles: ["services"]
+ build:
+ context: .
+ dockerfile: services/gateway/Dockerfile
+ environment:
+ RELAY_API_URL: http://api:4000
+ RELAY_REDIS_URL: redis://redis:6379
+ PORT: "4001"
+ ports:
+ - "${RELAY_GATEWAY_PORT:-4001}:4001"
+ depends_on:
+ api: { condition: service_healthy }
+ redis: { condition: service_healthy }
+
+ dispatcher:
+ profiles: ["services"]
+ build:
+ context: .
+ dockerfile: services/dispatcher/Dockerfile
+ environment:
+ RELAY_API_URL: http://api:4000
+ RELAY_NATS_URL: nats://nats:4222
+ RELAY_INTERNAL_CREDENTIAL: ${RELAY_INTERNAL_CREDENTIAL:-rk_svc_local_development_credential_0000}
+ # So a webhook endpoint running on the DEVELOPER'S machine is reachable from
+ # inside the container. Without it, `scripts/hostile-endpoint.mjs` listens on
+ # the host's loopback and the dispatcher resolves 127.0.0.1 to its own — the
+ # quickstart's V6 step could not be run as written.
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+ depends_on:
+ api: { condition: service_healthy }
+ nats: { condition: service_healthy }
+
volumes:
postgres-data:
nats-data:
clickhouse-data:Mục extra_hosts có mặt vì đúng một lý do. Một dispatcher bên trong container phân
giải 127.0.0.1 thành loopback của chính container, nên một webhook endpoint đang
chạy trên máy của lập trình viên là không với tới được. Không có dòng đó, bước "dừng
dispatcher rồi nhìn backlog rút cạn" của quickstart không chạy được đúng như đã
viết — endpoint sẽ chẳng nhận được gì, và màn trình diễn trông sẽ giống một cái bug
của nền tảng.
Harness end-to-end cũng cần khoá mã hoá, bởi nó spawn service thật:
@@ -332,24 +332,34 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
"RELAY_REDIS_PORT",
// Chapter 3.3: the api's relay needs the broker's address. Forwarded,
// never composed here — a harness that invents a URL becomes a second
// source of truth, which is exactly how this suite first failed.
"RELAY_NATS_URL",
"RELAY_NATS_PORT",
+ // Chapter 3.5: the api decrypts webhook signing secrets and authenticates
+ // the dispatcher. Both are configuration, and a child that invents either
+ // would be a second source of truth for a credential.
+ "RELAY_WEBHOOK_SECRET_KEY",
+ "RELAY_INTERNAL_CREDENTIAL",
),
// Chapter 3.3: the api children run WITHOUT the outbox relay. This journey
// asserts message delivery, and a background loop draining the outbox while
// 3.3's own suite asserts on that same table is a race between two test
// files, not a property of the system. The relay has its own suite, which
// drives it explicitly.
RELAY_OUTBOX_RELAY: "off",
// Chapter 3.4: no event consumer in these children either, for the reason
// the line above exists — this journey asserts message delivery, and a
// background consumer writing to a table 3.4's suite asserts on is a race
// between test files rather than a property of the system.
RELAY_EVENT_CONSUMER: "off",
+ // Chapter 3.5: nor the delivery relay, for the third time and the same
+ // reason. Three background loops now share tables that other suites assert
+ // on, and each one had to be silenced here the moment it existed — which is
+ // the general form of 3.3's finding 4 rather than a coincidence.
+ RELAY_DELIVERY_RELAY: "off",
};
const apiPort = Number(process.env.RELAY_E2E_API_PORT ?? 4100);
children.push(
capture(
"api",