@things-factory/webhook 10.1.147 → 10.1.150

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -92,8 +92,11 @@ export type DeliveryOutcome = {
92
92
  } | {
93
93
  kind: 'backpressure';
94
94
  retryAfterMs: number;
95
- } | {
95
+ }
96
+ /** `reason` is what the receiver said in `code` and `error` — absent when it said nothing we can read. */
97
+ | {
96
98
  kind: 'unauthorized';
99
+ reason?: string;
97
100
  }
98
101
  /** 다시 보내도 같다. 사람이 고쳐야 풀린다 — 되풀이하면 받는 쪽을 두드리며 로그만 채운다. */
99
102
  | {
@@ -120,18 +123,6 @@ export declare function signingInput(timestamp: string, body: string): string;
120
123
  export declare function sign(secret: string, timestamp: string, body: string): string;
121
124
  /** 시각까지 함께 본다 — 계약이 오래된 서명을 만료로 가른다. 재생을 막는 것이 서명의 절반이다. */
122
125
  export declare function verifySignature(secret: string, timestamp: string, body: string, signature: string, nowMs?: number): boolean;
123
- /**
124
- * 응답을 보고 다음에 무엇을 할지 정한다 — **표는 계약이 갖는다.**
125
- *
126
- * ── 왜 우리 표를 두지 않나 ─────────────────────────────────────────────────
127
- * 코드마다 무엇을 할지는 받는 쪽과 보내는 쪽이 **같은 표**를 봐야 정해진다. 여기 따로 적어 두면 계약이
128
- * 한 줄 바뀔 때 이 파일이 남고, 그때부터 두 쪽이 다른 규칙으로 움직인다. 실제로 그렇게 벌어졌다 —
129
- * 422 가 생겼는데 여기는 「모르는 코드」로 두어 **떨어진 것을 되풀이해 보낼 뻔했다.**
130
- *
131
- * ── 400 과 500 을 가른다 ───────────────────────────────────────────────────
132
- * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다
133
- * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.
134
- */
135
126
  /**
136
127
  * 받는 쪽이 본문에 담은 이유를 꺼낸다.
137
128
  *
@@ -139,6 +130,28 @@ export declare function verifySignature(secret: string, timestamp: string, body:
139
130
  * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.
140
131
  */
141
132
  export declare function detailOf(payload: Record<string, unknown>): string;
133
+ /**
134
+ * Why the receiver refused our signature, from its `code` and `error` (`signature-rejected: webhook rejected:
135
+ * expired`).
136
+ *
137
+ * On 2026-10-11 the warehouse → twin queue stood at 401 and the reason had to be guessed: the receiver had said
138
+ * `expired` or `signature mismatch` in the body and this side threw the body away. Only those two fields are read,
139
+ * and only as short text — the body of a refusal is never copied whole, because a receiver may echo what it was
140
+ * sent, and what we sent carries our signature.
141
+ */
142
+ export declare function signatureRefusalOf(payload: Record<string, unknown>): string | undefined;
143
+ /**
144
+ * The receiver's reply body, as `interpretResponse` reads it.
145
+ *
146
+ * A JSON object is used as it came. Anything else — the plain `Method Not Allowed` a fallback middleware
147
+ * answers, a proxy's HTML error page — is kept as `{ error }`: its text with tags stripped, whitespace folded
148
+ * and cut to a few hundred characters. On 2026-10-11 the twin answered 405 to a wrong path and the sender
149
+ * read only JSON, so the one sentence that named the cause was thrown away.
150
+ *
151
+ * `redact` is what we sent that must not come back into our log — the signature. It is taken out of the raw
152
+ * text before anything else reads it, so a receiver that echoes our request cannot put it in a reason.
153
+ */
154
+ export declare function replyOf(text: string, redact?: readonly string[]): Record<string, unknown>;
142
155
  export declare function refusalReason(status: number, payload: Record<string, unknown>): string;
143
156
  /**
144
157
  * Whether the receiver told us something about rejections we cannot read.
@@ -6,6 +6,8 @@ exports.signingInput = signingInput;
6
6
  exports.sign = sign;
7
7
  exports.verifySignature = verifySignature;
8
8
  exports.detailOf = detailOf;
9
+ exports.signatureRefusalOf = signatureRefusalOf;
10
+ exports.replyOf = replyOf;
9
11
  exports.refusalReason = refusalReason;
10
12
  exports.tallyOf = tallyOf;
11
13
  exports.describeTally = describeTally;
@@ -70,6 +72,7 @@ function verifySignature(secret, timestamp, body, signature, nowMs = Date.now())
70
72
  * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다
71
73
  * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.
72
74
  */
75
+ const SECRET_LOOKING = /signature|secret|token|authorization|password|key/i;
73
76
  /**
74
77
  * 받는 쪽이 본문에 담은 이유를 꺼낸다.
75
78
  *
@@ -77,19 +80,71 @@ function verifySignature(secret, timestamp, body, signature, nowMs = Date.now())
77
80
  * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.
78
81
  */
79
82
  function detailOf(payload) {
83
+ const code = typeof payload.code === 'string' && payload.code.trim() ? `${payload.code.trim()}: ` : '';
80
84
  for (const key of ['error', 'message', 'reason', 'detail']) {
81
85
  const value = payload[key];
82
86
  if (typeof value === 'string' && value.trim()) {
83
- return ` — ${value.trim()}`;
87
+ return ` — ${code}${value.trim().slice(0, SAID_AT_MOST)}`;
84
88
  }
85
89
  }
90
+ if (code)
91
+ return ` — ${code.slice(0, -2)}`;
86
92
  const reasons = payload.reasons;
87
93
  if (Array.isArray(reasons) && reasons.length) {
88
94
  return ` — ${reasons.map(String).join(' · ')}`;
89
95
  }
90
- const text = JSON.stringify(payload);
96
+ /* A receiver may echo what it was sent; our signature and anything secret-looking stays out of the log. */
97
+ const text = JSON.stringify(payload, (key, value) => (key && SECRET_LOOKING.test(key) ? undefined : value));
91
98
  return text && text !== '{}' ? ` — ${text.slice(0, 300)}` : '';
92
99
  }
100
+ /**
101
+ * Why the receiver refused our signature, from its `code` and `error` (`signature-rejected: webhook rejected:
102
+ * expired`).
103
+ *
104
+ * On 2026-10-11 the warehouse → twin queue stood at 401 and the reason had to be guessed: the receiver had said
105
+ * `expired` or `signature mismatch` in the body and this side threw the body away. Only those two fields are read,
106
+ * and only as short text — the body of a refusal is never copied whole, because a receiver may echo what it was
107
+ * sent, and what we sent carries our signature.
108
+ */
109
+ function signatureRefusalOf(payload) {
110
+ const said = ['code', 'error']
111
+ .map(key => payload[key])
112
+ .filter((value) => typeof value === 'string' && value.trim() !== '')
113
+ .map(value => value.trim().slice(0, 200));
114
+ return said.length ? said.join(': ') : undefined;
115
+ }
116
+ /** How much of a receiver's own words a reason carries. A row's last error is read by a person, not parsed. */
117
+ const SAID_AT_MOST = 300;
118
+ /**
119
+ * The receiver's reply body, as `interpretResponse` reads it.
120
+ *
121
+ * A JSON object is used as it came. Anything else — the plain `Method Not Allowed` a fallback middleware
122
+ * answers, a proxy's HTML error page — is kept as `{ error }`: its text with tags stripped, whitespace folded
123
+ * and cut to a few hundred characters. On 2026-10-11 the twin answered 405 to a wrong path and the sender
124
+ * read only JSON, so the one sentence that named the cause was thrown away.
125
+ *
126
+ * `redact` is what we sent that must not come back into our log — the signature. It is taken out of the raw
127
+ * text before anything else reads it, so a receiver that echoes our request cannot put it in a reason.
128
+ */
129
+ function replyOf(text, redact = []) {
130
+ const clean = redact.filter(Boolean).reduce((said, secret) => said.split(secret).join('[redacted]'), text);
131
+ if (!clean.trim())
132
+ return {};
133
+ try {
134
+ const parsed = JSON.parse(clean);
135
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
136
+ return parsed;
137
+ }
138
+ catch {
139
+ /* Not JSON — read below as text. */
140
+ }
141
+ const plain = clean
142
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ')
143
+ .replace(/<[^>]*>/g, ' ')
144
+ .replace(/\s+/g, ' ')
145
+ .trim();
146
+ return plain ? { error: plain.slice(0, SAID_AT_MOST) } : {};
147
+ }
93
148
  function refusalReason(status, payload) {
94
149
  return `the receiver refused with ${status} — resending will not change it${detailOf(payload)}`;
95
150
  }
@@ -216,7 +271,8 @@ function interpretResponse(status, body) {
216
271
  const rejected = Array.isArray(payload.rejected) ? payload.rejected : [];
217
272
  /* 서명이 문제인 것은 사람에게 할 말이 달라 따로 든다. 행동은 `stop` 과 같다. */
218
273
  if (status === 401 || status === 403) {
219
- return { kind: 'unauthorized' };
274
+ const reason = signatureRefusalOf(payload);
275
+ return reason ? { kind: 'unauthorized', reason } : { kind: 'unauthorized' };
220
276
  }
221
277
  /*
222
278
  * The receiver said something about rejections in a shape this does not know. Retrying is the
@@ -1 +1 @@
1
- {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../../../server/service/outbox/delivery.ts"],"names":[],"mappings":"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","sourcesContent":["\nimport { webhookSenderAction, type WebhookLane } from '@operato/ops-contract'\n\nimport { WEBHOOK_HEADER, signWebhook, verifyWebhookSignature, webhookSigningInput } from '@operato/ops-contract/webhook'\n\n/**\n * A queued row, as far as building an envelope is concerned.\n *\n * The pure half of delivery does not know the entity. It reads these columns off whatever the store\n * hands it — this module's `WebhookOutboxEvent`, or a product's own table while that product is still\n * moving over (ADR-0078 decision 2). `payload` is the only product-specific value (ADR-0041), so it\n * stays `unknown` here: what a record means is the sender's business and the receiver's.\n */\nexport interface OutboxRow {\n seq: number | string\n /** The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`. */\n scope: string\n lane?: WebhookLane | null\n eventId: string\n eventTime: Date | string\n recordTime: Date | string\n /** Declared only when this did not actually happen — the contract's `CanonicalEnvelope.provenance`. */\n provenance?: 'actual' | 'simulated' | null\n payload: unknown\n}\n\n/**\n * 전송의 순수 부분 — 서명 · 봉투 · 응답 해석.\n *\n * 데이터베이스도 네트워크도 만지지 않는다. 여기서 갈라 두면 「받는 쪽이 429 를 주면 어떻게 하나」 같은\n * 판단을 서버를 띄우지 않고 테스트할 수 있다.\n *\n * 계약: `@operato/ops-contract/webhook`. 이 파일은 operato-plant 의 `mes-outbox-delivery.ts` 를 옮긴 것이다(ADR-0078 결정 2 ①) —\n * 주석의 날짜와 사건은 그 기계가 plant 에서 겪은 것이고, 그래서 지우지 않았다.\n */\n\n/**\n * 트윈으로 보내는 봉투 하나.\n *\n * ── `scope` 가 왜 필수인가 ─────────────────────────────────────────────────\n * 번호를 매기는 단위가 **(도메인, 채널)** 이다(`ix_mes_outbox_event_0`). 도메인은 주소가 가른다(목적지가\n * 도메인마다 다르다). 그래서 봉투가 말해야 하는 것은 **채널**이고, 그것이 계약의 `scope` 다.\n *\n * 봉투에 그것이 없으면 받는 쪽은 그 번호가 어느 줄의 번호인지 알 수 없다. 지금은 한 채널만 흐르고 있어\n * 드러나지 않는다 — **master 채널이 흐르기 시작하는 날** 두 줄의 번호가 한 커서에 섞여 끊임없이\n * 「빠졌다」가 된다.\n *\n * 비어 있을 수 없다. 받는 쪽이 기본값을 정하면 그 기본값이 두 번째 규칙이 된다. 주소에서 유추하게\n * 두지도 않는다 — 주소는 어디로 갈지를 정하고 `scope` 는 어느 줄인지를 정한다. 다른 일이다.\n *\n * **이름은 계약이 정한다**(`WebhookEnvelope.scope`). 처음에 `channel` 로 보냈는데 받는 쪽은 계약대로\n * `scope` 를 읽는다 — 같은 뜻에 이름이 둘이면 한쪽은 그 값을 못 본다.\n *\n * ── `streamId` 를 지웠다 ───────────────────────────────────────────────────\n * 설계 문서가 「순서는 스트림 안에서만 보장한다」고 적어 두었는데 **코드가 하지 않는 보장이었다.**\n * 아무도 채우지 않았고 번호에 참여하지도 않았다. 쓰지 않는 두 번째 이름이 문서에서 보장으로 적혀\n * 있는 것이 사고의 원인이라, 이름을 하나로 줄인다.\n */\nexport interface DeliveryEnvelope {\n seq: number\n /** 이 번호가 어느 줄의 번호인가. 커서의 키다. 계약의 `WebhookEnvelope.scope`. */\n scope: string\n lane?: WebhookLane\n eventId: string\n eventTime: string\n recordTime: string\n /** 정말 일어났나 — **예외만 실린다.** 계약의 `CanonicalEnvelope.provenance`. */\n provenance?: 'actual' | 'simulated'\n record: unknown\n}\n\nexport interface DeliveryBatch {\n batch: DeliveryEnvelope[]\n}\n\n/** 받는 쪽 응답을 보내는 쪽이 할 일로 옮긴 것. */\nexport type DeliveryOutcome =\n | { kind: 'accepted'; lastSeq: number; tally?: Tally }\n | { kind: 'partial'; lastSeq: number; rejected: string[]; tally?: Tally }\n /**\n * 번호가 비었다. `expectedSeq` 부터 다시 보낸다.\n *\n * `lastSeq` 는 **구멍 앞까지 실제로 들어간 것**이다. 그것을 보냄으로 표시하지 않으면 매 주기마다 같은\n * 봉투를 다시 보내고, 받는 쪽이 중복으로 거르면서 처리량만 쓴다.\n *\n * `rejected` 가 함께 올 수 있다 — 구멍과 떨어진 것이 한 응답에 실린다.\n */\n | { kind: 'gap'; expectedSeq: number; lastSeq?: number; rejected: string[] }\n | { kind: 'backpressure'; retryAfterMs: number }\n | { kind: 'unauthorized' }\n /** 다시 보내도 같다. 사람이 고쳐야 풀린다 — 되풀이하면 받는 쪽을 두드리며 로그만 채운다. */\n | { kind: 'stop'; reason: string }\n | { kind: 'retry'; reason: string }\n\n/** 기본 대기 시간 — 받는 쪽이 `retryAfterMs` 를 주지 않았을 때만 쓴다. */\nexport const DEFAULT_RETRY_AFTER_MS = 1_000\n\nexport function buildBatch(events: readonly OutboxRow[]): DeliveryBatch {\n return {\n batch: events.map(event => ({\n seq: Number(event.seq),\n /* 번호의 단위를 봉투가 이름으로 말한다 — 받는 쪽이 추측하지 않게. */\n scope: event.scope,\n ...(event.lane === 'ATTENTIONS' ? { lane: 'ATTENTIONS' as const } : {}),\n eventId: event.eventId,\n eventTime: new Date(event.eventTime).toISOString(),\n recordTime: new Date(event.recordTime).toISOString(),\n /*\n * **예외만 싣는다.** 안 적힌 봉투는 「일어났다」이고, `'actual'` 을 전부 붙이면 안 붙은 것이\n * 지어낸 것처럼 보인다 — 계약이 셋을 가르라고 한 자리다(§`CanonicalEnvelope.provenance`).\n */\n ...(event.provenance ? { provenance: event.provenance } : {}),\n record: event.payload\n }))\n }\n}\n\n/**\n * 서명 대상은 `타임스탬프 + '.' + 본문` 이다. 타임스탬프를 빼면 재생을 막을 수 없다.\n *\n * ── 식도 이름도 계약이 갖는다 ───────────────────────────────────────────────\n * 이 식은 원래 여기서 났고 계약이 그것을 그대로 가져갔다. 그런데 **두 벌이 되자 헤더 이름이 갈라졌다** —\n * 우리는 `x-mes-*` 로 보내고 받는 쪽은 `x-ops-*` 를 찾아, 서명이 맞아도 401 이 났다. 값이 같아도 두\n * 군데 적혀 있으면 언젠가 갈라진다.\n *\n * 그래서 여기 남은 것은 **계약을 부르는 껍데기**다. 식이 바뀌는 날 양쪽이 함께 움직인다.\n */\nexport function signingInput(timestamp: string, body: string): string {\n return webhookSigningInput(timestamp, body)\n}\n\nexport function sign(secret: string, timestamp: string, body: string): string {\n if (!secret) {\n throw new Error('sign: secret is required — an unsigned webhook cannot be told apart from a forged one')\n }\n\n return signWebhook(secret, timestamp, body)\n}\n\n/** 시각까지 함께 본다 — 계약이 오래된 서명을 만료로 가른다. 재생을 막는 것이 서명의 절반이다. */\nexport function verifySignature(\n secret: string,\n timestamp: string,\n body: string,\n signature: string,\n nowMs: number = Date.now()\n): boolean {\n return verifyWebhookSignature({ secret, timestamp, signature, body, nowMs }).ok\n}\n\n/**\n * 응답을 보고 다음에 무엇을 할지 정한다 — **표는 계약이 갖는다.**\n *\n * ── 왜 우리 표를 두지 않나 ─────────────────────────────────────────────────\n * 코드마다 무엇을 할지는 받는 쪽과 보내는 쪽이 **같은 표**를 봐야 정해진다. 여기 따로 적어 두면 계약이\n * 한 줄 바뀔 때 이 파일이 남고, 그때부터 두 쪽이 다른 규칙으로 움직인다. 실제로 그렇게 벌어졌다 —\n * 422 가 생겼는데 여기는 「모르는 코드」로 두어 **떨어진 것을 되풀이해 보낼 뻔했다.**\n *\n * ── 400 과 500 을 가른다 ───────────────────────────────────────────────────\n * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다\n * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.\n */\n/**\n * 받는 쪽이 본문에 담은 이유를 꺼낸다.\n *\n * 어느 칸에 담을지는 받는 쪽이 정하므로 흔한 이름들을 본다. 아무 데도 없으면 **본문을 그대로 잘라\n * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.\n */\nexport function detailOf(payload: Record<string, unknown>): string {\n for (const key of ['error', 'message', 'reason', 'detail'] as const) {\n const value = payload[key]\n\n if (typeof value === 'string' && value.trim()) {\n return ` — ${value.trim()}`\n }\n }\n\n const reasons = payload.reasons\n\n if (Array.isArray(reasons) && reasons.length) {\n return ` — ${reasons.map(String).join(' · ')}`\n }\n\n const text = JSON.stringify(payload)\n\n return text && text !== '{}' ? ` — ${text.slice(0, 300)}` : ''\n}\n\nexport function refusalReason(status: number, payload: Record<string, unknown>): string {\n return `the receiver refused with ${status} — resending will not change it${detailOf(payload)}`\n}\n\n/**\n * Whether the receiver told us something about rejections we cannot read.\n *\n * `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }` — the connector's own id\n * when it carried one, the offending record when it did not. This reader still expects a list of\n * envelope ids, which is a guess made when the contract named `rejected` in prose and declared no\n * shape for it; three sibling modules pinned `{ record, errors }` and `webhook` was the one that\n * did not. It says so now, so this reader is the side that is wrong.\n *\n * Reading a shape it does not know as \"none rejected\" marks every envelope in the batch as\n * delivered, and a fact that was refused is recorded here as stored. That is the worst shape a\n * mismatch can take: both sides think they agree. Until this reads `WebhookRejection` properly,\n * an unknown shape goes to retry instead — losing the reason, but not the fact.\n *\n * `offered` against `applied + duplicates` closes on its own and needs no rejection list at all.\n * On seq 178 it was three against two while the list was being misread. Worth standing up next to\n * this, not instead of it: the sum says something was dropped, `errors` says why.\n *\n * Absent is not unreadable. A receiver that rejected nothing says nothing, and that is the\n * ordinary case.\n */\n/**\n * What the receiver said it did with the batch, and whether those numbers add up.\n *\n * ── Why the sender keeps its own copy of this ──────────────────────────────\n * The receiver reports `offered`, `applied`, `duplicates`, `vocabulary` and a rejection list, and\n * `offered` has to equal the rest. Until now this sender read none of it: 870 envelopes went out\n * and there was no record on this side of what the far end said it stored (2026-09-08). The far\n * end can check its own arithmetic, but a disagreement between the two sides is exactly what one\n * side checking alone cannot find.\n *\n * ── Vocabulary is in the sum ───────────────────────────────────────────────\n * Vocabulary rides the same channel as events but has no time axis, so it never reaches the\n * journal. It is still offered, so leaving it out of the sum makes a healthy batch look short.\n *\n * ── Absent is not zero ────────────────────────────────────────────────────\n * A receiver that reports nothing gets `undefined`, not a tally of zeroes. A zero tally would\n * read as \"it offered nothing\", and that is a different fact from \"it did not say\".\n */\nexport interface Tally {\n offered: number\n applied: number\n duplicates: number\n vocabulary: number\n rejected: number\n /** `offered === applied + duplicates + vocabulary + rejected`. */\n balanced: boolean\n}\n\nexport function tallyOf(payload: Record<string, unknown>, rejectedCount: number): Tally | undefined {\n const offered = Number(payload.offered)\n\n if (!Number.isFinite(offered)) {\n /* The receiver did not say. Nothing to check, and nothing to complain about. */\n return undefined\n }\n\n /*\n * The three terms default to 0 because a receiver that stored none of a kind may leave that key\n * out. `offered` is the one that may not be guessed — it is the left side of the equation.\n */\n const applied = Number(payload.applied) || 0\n const duplicates = Number(payload.duplicates) || 0\n const vocabulary = Number(payload.vocabulary) || 0\n\n return {\n offered,\n applied,\n duplicates,\n vocabulary,\n rejected: rejectedCount,\n balanced: offered === applied + duplicates + vocabulary + rejectedCount\n }\n}\n\n/** One line for the log — short enough to print on every batch. */\nexport function describeTally(tally: Tally): string {\n const sum = `${tally.applied} + ${tally.duplicates} + ${tally.vocabulary} + ${tally.rejected}`\n\n return tally.balanced\n ? `offered ${tally.offered} = ${sum}`\n : `⚠ offered ${tally.offered} ≠ ${sum} — 받는 쪽이 센 것과 합이 안 맞습니다`\n}\n\nfunction unreadableRejections(said: unknown): boolean {\n if (said === undefined || said === null) {\n return false\n }\n\n if (!Array.isArray(said)) {\n return true\n }\n\n /*\n * An array is not enough. The receiver sends `{ record, errors }` objects while this expects\n * envelope ids, and `Array.isArray` passes both — so the ids match nothing, every envelope in\n * the batch is marked delivered, and the reasons in `errors` are dropped with them. That is\n * what happened to seq 178: HTTP 422 with ok:false, recorded here as SENT with no attempts.\n */\n return said.some(one => typeof one !== 'string')\n}\n\n/**\n * Pull the stated reasons out of a rejection list, whatever shape it came in.\n *\n * The contract declares `{ eventId, errors }` and `{ record, errors }`; older senders were told\n * to expect plain ids. This reads `errors` wherever it finds it and ignores the rest — it is for\n * telling a person what happened, not for deciding what was stored.\n */\nexport interface ReadRejections {\n /** Envelopes the receiver named, so they can be settled as refused. */\n eventIds: string[]\n /** What it said was wrong, once each. */\n reasons: string[]\n /** Refusals that named no envelope — these cannot be settled, only retried. */\n unidentified: number\n}\n\n/**\n * Read the contract's `WebhookRejection` — `{ eventId, errors }` or `{ record, errors }`.\n *\n * ── Why this matters more than it looked ──────────────────────────────────\n * Until this existed, a refusal this reader could not parse went to retry with the reason thrown\n * away. That is safe for one envelope and fatal for a queue: on 2026-09-08 one permanently invalid\n * envelope sat at the head of the batch, the whole batch was refused every five seconds, and 61\n * envelopes behind it — including valid ones — never moved. 113 attempts, no progress, and the\n * response had been naming the culprit the whole time.\n *\n * Naming which envelope was refused is what lets the queue move past it. A refusal that names no\n * envelope still cannot be settled, and that is counted rather than assumed away.\n */\nexport function readRejections(said: unknown): ReadRejections {\n const found: ReadRejections = { eventIds: [], reasons: [], unidentified: 0 }\n\n if (!Array.isArray(said)) {\n return found\n }\n\n const reasons: string[] = []\n\n for (const one of said) {\n if (typeof one === 'string') {\n /* The shape older senders were told to expect: a bare envelope id. */\n found.eventIds.push(one)\n continue\n }\n\n if (!one || typeof one !== 'object') {\n found.unidentified++\n continue\n }\n\n const row = one as Record<string, unknown>\n const errors = row.errors\n\n if (Array.isArray(errors)) {\n reasons.push(...errors.map(each => String(each)))\n } else if (typeof errors === 'string') {\n reasons.push(errors)\n }\n\n /*\n * `eventId` may be named directly, or sit inside the echoed record. Both are the contract's\n * shapes; neither is guessed at beyond reading a field of that name.\n */\n const record = (row.record ?? {}) as Record<string, unknown>\n const eventId = row.eventId ?? record.eventId\n\n if (typeof eventId === 'string' && eventId) {\n found.eventIds.push(eventId)\n } else {\n found.unidentified++\n }\n }\n\n /* Same reason repeated for every envelope in a batch is one reason, not forty. */\n found.reasons = [...new Set(reasons)]\n\n return found\n}\n\nexport function reasonsIn(said: unknown): string[] {\n if (!Array.isArray(said)) {\n return []\n }\n\n const reasons: string[] = []\n\n for (const one of said) {\n if (!one || typeof one !== 'object') {\n continue\n }\n\n const errors = (one as Record<string, unknown>).errors\n\n if (Array.isArray(errors)) {\n reasons.push(...errors.map(each => String(each)))\n } else if (typeof errors === 'string') {\n reasons.push(errors)\n }\n }\n\n /* Same reason repeated for every envelope in a batch is one reason, not forty. */\n return [...new Set(reasons)]\n}\n\nexport function interpretResponse(status: number, body: unknown): DeliveryOutcome {\n const payload = (body ?? {}) as Record<string, unknown>\n const rejected = Array.isArray(payload.rejected) ? (payload.rejected as string[]) : []\n\n /* 서명이 문제인 것은 사람에게 할 말이 달라 따로 든다. 행동은 `stop` 과 같다. */\n if (status === 401 || status === 403) {\n return { kind: 'unauthorized' }\n }\n\n /*\n * The receiver said something about rejections in a shape this does not know. Retrying is the\n * honest answer: we cannot say which of these were stored, and marking them delivered would\n * lose whichever were refused with nobody able to find out (2026-09-07 — an envelope read as\n * SENT with no attempts had in fact been refused, and it took another lane reading the twin's\n * journal to notice).\n */\n if (unreadableRejections(payload.rejected)) {\n /*\n * ── Say why it was refused, even when we cannot say which ─────────────\n *\n * The contract's `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }`. This\n * reader still expects envelope ids, so it cannot settle these — retrying is right, the fact\n * is not lost. But throwing the `errors` away with them was not right: on 2026-09-08 the twin\n * refused every genealogy envelope this plant had just started sending, and the queue climbed\n * to 59 attempts saying only \"cannot read\". The reason was in the response the whole time.\n *\n * So the reasons are read out of whichever shape arrived and carried in the retry message.\n * Reading them does not mean this understands the shape — settling still waits for that.\n */\n if (!Array.isArray(payload.rejected)) {\n /*\n * A count, or an object with a count in it. There is no list to read and nothing names an\n * envelope, so there is nothing to settle — only the fact that the two sides disagree about\n * the shape of this field.\n */\n return {\n kind: 'retry',\n reason: `response carried a \"rejected\" that is not a list (${typeof payload.rejected}) — cannot tell what was stored`\n }\n }\n\n const read = readRejections(payload.rejected)\n const why = read.reasons.length ? ` 받는 쪽이 말한 까닭: ${read.reasons.slice(0, 3).join(' · ')}` : ''\n\n if (read.unidentified) {\n /*\n * Some refusal named no envelope. Settling the ones that were named would leave the\n * unnamed ones marked delivered, and one of them is what the receiver refused.\n */\n return {\n kind: 'retry',\n reason: `${read.unidentified} of the refusals named no envelope — cannot tell what was stored.${why}`\n }\n }\n\n /*\n * Every refusal named an envelope. Those settle as refused and everything else in the batch\n * settles as delivered — which is what lets the queue move past a permanently invalid\n * envelope instead of retrying the whole batch behind it forever.\n */\n const lastSeq = Number(payload.lastSeq)\n\n if (!Number.isFinite(lastSeq)) {\n return { kind: 'retry', reason: `refusals were readable but no lastSeq came with them.${why}` }\n }\n\n return { kind: 'partial', lastSeq, rejected: read.eventIds, ...(tallyOf(payload, read.eventIds.length) ? { tally: tallyOf(payload, read.eventIds.length)! } : {}) }\n }\n\n switch (webhookSenderAction(status)) {\n case 'accepted': {\n const lastSeq = Number(payload.lastSeq)\n if (!Number.isFinite(lastSeq)) {\n /* 무엇까지 들어갔는지 모르면 보냄으로 표시할 수 없다. 표시하면 그 사실이 사라진다. */\n return { kind: 'retry', reason: 'accepted response carried no lastSeq — cannot tell what was stored' }\n }\n /*\n * When the receiver said nothing about counts, the key is left off entirely rather than set\n * to `undefined`. An outcome carrying `tally: undefined` is not the same object as one\n * without the key — `deepStrictEqual` tells them apart, and so does anything that walks\n * the keys. Absent means absent.\n */\n const counted = tallyOf(payload, rejected.length)\n const tally = counted ? { tally: counted } : {}\n\n return rejected.length\n ? { kind: 'partial', lastSeq, rejected, ...tally }\n : { kind: 'accepted', lastSeq, ...tally }\n }\n\n case 'partial': {\n const lastSeq = Number(payload.lastSeq)\n if (!Number.isFinite(lastSeq)) {\n return { kind: 'retry', reason: 'partial response carried no lastSeq — cannot tell what was stored' }\n }\n const counted = tallyOf(payload, rejected.length)\n\n return { kind: 'partial', lastSeq, rejected, ...(counted ? { tally: counted } : {}) }\n }\n\n case 'resend-from': {\n const expectedSeq = Number(payload.expectedSeq)\n if (!Number.isFinite(expectedSeq)) {\n return { kind: 'retry', reason: 'gap response carried no expectedSeq — cannot tell where to resume' }\n }\n const lastSeq = Number(payload.lastSeq)\n /* 구멍 앞까지 들어간 것은 보냄으로 표시한다 — 아니면 매 주기마다 그것을 다시 보낸다. */\n return { kind: 'gap', expectedSeq, lastSeq: Number.isFinite(lastSeq) ? lastSeq : undefined, rejected }\n }\n\n case 'wait': {\n /*\n * 503(아직 안 돈다)과 429(따라오지 못한다)가 함께 여기로 온다.\n *\n * 429 는 앞에서 따로 받아 두고 있었다 — 계약이 `stop` 을 답하던 때는 그래야 했다. 0.9.13 이\n * 429 를 `wait` 로 답하게 되어 그 분기가 두 벌이 됐으므로 지웠다. **다시 앞에 두지 말 것** —\n * 두 벌이 되면 한쪽만 고치는 날이 온다.\n */\n const retryAfterMs = Number(payload.retryAfterMs)\n return {\n kind: 'backpressure',\n retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : DEFAULT_RETRY_AFTER_MS\n }\n }\n\n case 'stop':\n /*\n * **받는 쪽이 말해 준 이유를 버리지 않는다.**\n *\n * 앞서는 상태 코드만 적었다. 그러면 「트윈이 400 으로 거절했다」만 남고 왜인지는 아무 데도\n * 없어서, 고치려면 같은 요청을 손으로 다시 보내 봐야 했다. 실제로 그렇게 했다(2026-09-02).\n */\n return { kind: 'stop', reason: refusalReason(status, payload) }\n\n default:\n return { kind: 'retry', reason: `the receiver failed with ${status}${detailOf(payload)}` }\n }\n}\n"]}
1
+ {"version":3,"file":"delivery.js","sourceRoot":"","sources":["../../../server/service/outbox/delivery.ts"],"names":[],"mappings":"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","sourcesContent":["\nimport { webhookSenderAction, type WebhookLane } from '@operato/ops-contract'\n\nimport { WEBHOOK_HEADER, signWebhook, verifyWebhookSignature, webhookSigningInput } from '@operato/ops-contract/webhook'\n\n/**\n * A queued row, as far as building an envelope is concerned.\n *\n * The pure half of delivery does not know the entity. It reads these columns off whatever the store\n * hands it — this module's `WebhookOutboxEvent`, or a product's own table while that product is still\n * moving over (ADR-0078 decision 2). `payload` is the only product-specific value (ADR-0041), so it\n * stays `unknown` here: what a record means is the sender's business and the receiver's.\n */\nexport interface OutboxRow {\n seq: number | string\n /** The unit the numbers are counted in — the contract's `WebhookEnvelope.scope`. */\n scope: string\n lane?: WebhookLane | null\n eventId: string\n eventTime: Date | string\n recordTime: Date | string\n /** Declared only when this did not actually happen — the contract's `CanonicalEnvelope.provenance`. */\n provenance?: 'actual' | 'simulated' | null\n payload: unknown\n}\n\n/**\n * 전송의 순수 부분 — 서명 · 봉투 · 응답 해석.\n *\n * 데이터베이스도 네트워크도 만지지 않는다. 여기서 갈라 두면 「받는 쪽이 429 를 주면 어떻게 하나」 같은\n * 판단을 서버를 띄우지 않고 테스트할 수 있다.\n *\n * 계약: `@operato/ops-contract/webhook`. 이 파일은 operato-plant 의 `mes-outbox-delivery.ts` 를 옮긴 것이다(ADR-0078 결정 2 ①) —\n * 주석의 날짜와 사건은 그 기계가 plant 에서 겪은 것이고, 그래서 지우지 않았다.\n */\n\n/**\n * 트윈으로 보내는 봉투 하나.\n *\n * ── `scope` 가 왜 필수인가 ─────────────────────────────────────────────────\n * 번호를 매기는 단위가 **(도메인, 채널)** 이다(`ix_mes_outbox_event_0`). 도메인은 주소가 가른다(목적지가\n * 도메인마다 다르다). 그래서 봉투가 말해야 하는 것은 **채널**이고, 그것이 계약의 `scope` 다.\n *\n * 봉투에 그것이 없으면 받는 쪽은 그 번호가 어느 줄의 번호인지 알 수 없다. 지금은 한 채널만 흐르고 있어\n * 드러나지 않는다 — **master 채널이 흐르기 시작하는 날** 두 줄의 번호가 한 커서에 섞여 끊임없이\n * 「빠졌다」가 된다.\n *\n * 비어 있을 수 없다. 받는 쪽이 기본값을 정하면 그 기본값이 두 번째 규칙이 된다. 주소에서 유추하게\n * 두지도 않는다 — 주소는 어디로 갈지를 정하고 `scope` 는 어느 줄인지를 정한다. 다른 일이다.\n *\n * **이름은 계약이 정한다**(`WebhookEnvelope.scope`). 처음에 `channel` 로 보냈는데 받는 쪽은 계약대로\n * `scope` 를 읽는다 — 같은 뜻에 이름이 둘이면 한쪽은 그 값을 못 본다.\n *\n * ── `streamId` 를 지웠다 ───────────────────────────────────────────────────\n * 설계 문서가 「순서는 스트림 안에서만 보장한다」고 적어 두었는데 **코드가 하지 않는 보장이었다.**\n * 아무도 채우지 않았고 번호에 참여하지도 않았다. 쓰지 않는 두 번째 이름이 문서에서 보장으로 적혀\n * 있는 것이 사고의 원인이라, 이름을 하나로 줄인다.\n */\nexport interface DeliveryEnvelope {\n seq: number\n /** 이 번호가 어느 줄의 번호인가. 커서의 키다. 계약의 `WebhookEnvelope.scope`. */\n scope: string\n lane?: WebhookLane\n eventId: string\n eventTime: string\n recordTime: string\n /** 정말 일어났나 — **예외만 실린다.** 계약의 `CanonicalEnvelope.provenance`. */\n provenance?: 'actual' | 'simulated'\n record: unknown\n}\n\nexport interface DeliveryBatch {\n batch: DeliveryEnvelope[]\n}\n\n/** 받는 쪽 응답을 보내는 쪽이 할 일로 옮긴 것. */\nexport type DeliveryOutcome =\n | { kind: 'accepted'; lastSeq: number; tally?: Tally }\n | { kind: 'partial'; lastSeq: number; rejected: string[]; tally?: Tally }\n /**\n * 번호가 비었다. `expectedSeq` 부터 다시 보낸다.\n *\n * `lastSeq` 는 **구멍 앞까지 실제로 들어간 것**이다. 그것을 보냄으로 표시하지 않으면 매 주기마다 같은\n * 봉투를 다시 보내고, 받는 쪽이 중복으로 거르면서 처리량만 쓴다.\n *\n * `rejected` 가 함께 올 수 있다 — 구멍과 떨어진 것이 한 응답에 실린다.\n */\n | { kind: 'gap'; expectedSeq: number; lastSeq?: number; rejected: string[] }\n | { kind: 'backpressure'; retryAfterMs: number }\n /** `reason` is what the receiver said in `code` and `error` — absent when it said nothing we can read. */\n | { kind: 'unauthorized'; reason?: string }\n /** 다시 보내도 같다. 사람이 고쳐야 풀린다 — 되풀이하면 받는 쪽을 두드리며 로그만 채운다. */\n | { kind: 'stop'; reason: string }\n | { kind: 'retry'; reason: string }\n\n/** 기본 대기 시간 — 받는 쪽이 `retryAfterMs` 를 주지 않았을 때만 쓴다. */\nexport const DEFAULT_RETRY_AFTER_MS = 1_000\n\nexport function buildBatch(events: readonly OutboxRow[]): DeliveryBatch {\n return {\n batch: events.map(event => ({\n seq: Number(event.seq),\n /* 번호의 단위를 봉투가 이름으로 말한다 — 받는 쪽이 추측하지 않게. */\n scope: event.scope,\n ...(event.lane === 'ATTENTIONS' ? { lane: 'ATTENTIONS' as const } : {}),\n eventId: event.eventId,\n eventTime: new Date(event.eventTime).toISOString(),\n recordTime: new Date(event.recordTime).toISOString(),\n /*\n * **예외만 싣는다.** 안 적힌 봉투는 「일어났다」이고, `'actual'` 을 전부 붙이면 안 붙은 것이\n * 지어낸 것처럼 보인다 — 계약이 셋을 가르라고 한 자리다(§`CanonicalEnvelope.provenance`).\n */\n ...(event.provenance ? { provenance: event.provenance } : {}),\n record: event.payload\n }))\n }\n}\n\n/**\n * 서명 대상은 `타임스탬프 + '.' + 본문` 이다. 타임스탬프를 빼면 재생을 막을 수 없다.\n *\n * ── 식도 이름도 계약이 갖는다 ───────────────────────────────────────────────\n * 이 식은 원래 여기서 났고 계약이 그것을 그대로 가져갔다. 그런데 **두 벌이 되자 헤더 이름이 갈라졌다** —\n * 우리는 `x-mes-*` 로 보내고 받는 쪽은 `x-ops-*` 를 찾아, 서명이 맞아도 401 이 났다. 값이 같아도 두\n * 군데 적혀 있으면 언젠가 갈라진다.\n *\n * 그래서 여기 남은 것은 **계약을 부르는 껍데기**다. 식이 바뀌는 날 양쪽이 함께 움직인다.\n */\nexport function signingInput(timestamp: string, body: string): string {\n return webhookSigningInput(timestamp, body)\n}\n\nexport function sign(secret: string, timestamp: string, body: string): string {\n if (!secret) {\n throw new Error('sign: secret is required — an unsigned webhook cannot be told apart from a forged one')\n }\n\n return signWebhook(secret, timestamp, body)\n}\n\n/** 시각까지 함께 본다 — 계약이 오래된 서명을 만료로 가른다. 재생을 막는 것이 서명의 절반이다. */\nexport function verifySignature(\n secret: string,\n timestamp: string,\n body: string,\n signature: string,\n nowMs: number = Date.now()\n): boolean {\n return verifyWebhookSignature({ secret, timestamp, signature, body, nowMs }).ok\n}\n\n/**\n * 응답을 보고 다음에 무엇을 할지 정한다 — **표는 계약이 갖는다.**\n *\n * ── 왜 우리 표를 두지 않나 ─────────────────────────────────────────────────\n * 코드마다 무엇을 할지는 받는 쪽과 보내는 쪽이 **같은 표**를 봐야 정해진다. 여기 따로 적어 두면 계약이\n * 한 줄 바뀔 때 이 파일이 남고, 그때부터 두 쪽이 다른 규칙으로 움직인다. 실제로 그렇게 벌어졌다 —\n * 422 가 생겼는데 여기는 「모르는 코드」로 두어 **떨어진 것을 되풀이해 보낼 뻔했다.**\n *\n * ── 400 과 500 을 가른다 ───────────────────────────────────────────────────\n * 앞서는 200·409·429·401 말고 전부 「다시 보낸다」였다. 그러면 본문 모양이 틀려 400 을 받는 날 5초마다\n * 영원히 두드린다. 계약의 `webhookSenderAction` 이 그 둘을 가른다.\n */\nconst SECRET_LOOKING = /signature|secret|token|authorization|password|key/i\n\n/**\n * 받는 쪽이 본문에 담은 이유를 꺼낸다.\n *\n * 어느 칸에 담을지는 받는 쪽이 정하므로 흔한 이름들을 본다. 아무 데도 없으면 **본문을 그대로 잘라\n * 붙인다** — 모양을 모른다고 버리면 고칠 실마리가 사라진다.\n */\nexport function detailOf(payload: Record<string, unknown>): string {\n const code = typeof payload.code === 'string' && payload.code.trim() ? `${payload.code.trim()}: ` : ''\n\n for (const key of ['error', 'message', 'reason', 'detail'] as const) {\n const value = payload[key]\n\n if (typeof value === 'string' && value.trim()) {\n return ` — ${code}${value.trim().slice(0, SAID_AT_MOST)}`\n }\n }\n\n if (code) return ` — ${code.slice(0, -2)}`\n\n const reasons = payload.reasons\n\n if (Array.isArray(reasons) && reasons.length) {\n return ` — ${reasons.map(String).join(' · ')}`\n }\n\n /* A receiver may echo what it was sent; our signature and anything secret-looking stays out of the log. */\n const text = JSON.stringify(payload, (key, value) => (key && SECRET_LOOKING.test(key) ? undefined : value))\n\n return text && text !== '{}' ? ` — ${text.slice(0, 300)}` : ''\n}\n\n/**\n * Why the receiver refused our signature, from its `code` and `error` (`signature-rejected: webhook rejected:\n * expired`).\n *\n * On 2026-10-11 the warehouse → twin queue stood at 401 and the reason had to be guessed: the receiver had said\n * `expired` or `signature mismatch` in the body and this side threw the body away. Only those two fields are read,\n * and only as short text — the body of a refusal is never copied whole, because a receiver may echo what it was\n * sent, and what we sent carries our signature.\n */\nexport function signatureRefusalOf(payload: Record<string, unknown>): string | undefined {\n const said = (['code', 'error'] as const)\n .map(key => payload[key])\n .filter((value): value is string => typeof value === 'string' && value.trim() !== '')\n .map(value => value.trim().slice(0, 200))\n\n return said.length ? said.join(': ') : undefined\n}\n\n/** How much of a receiver's own words a reason carries. A row's last error is read by a person, not parsed. */\nconst SAID_AT_MOST = 300\n\n/**\n * The receiver's reply body, as `interpretResponse` reads it.\n *\n * A JSON object is used as it came. Anything else — the plain `Method Not Allowed` a fallback middleware\n * answers, a proxy's HTML error page — is kept as `{ error }`: its text with tags stripped, whitespace folded\n * and cut to a few hundred characters. On 2026-10-11 the twin answered 405 to a wrong path and the sender\n * read only JSON, so the one sentence that named the cause was thrown away.\n *\n * `redact` is what we sent that must not come back into our log — the signature. It is taken out of the raw\n * text before anything else reads it, so a receiver that echoes our request cannot put it in a reason.\n */\nexport function replyOf(text: string, redact: readonly string[] = []): Record<string, unknown> {\n const clean = redact.filter(Boolean).reduce((said, secret) => said.split(secret).join('[redacted]'), text)\n\n if (!clean.trim()) return {}\n\n try {\n const parsed = JSON.parse(clean)\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed as Record<string, unknown>\n } catch {\n /* Not JSON — read below as text. */\n }\n\n const plain = clean\n .replace(/<(script|style)[\\s\\S]*?<\\/\\1>/gi, ' ')\n .replace(/<[^>]*>/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim()\n\n return plain ? { error: plain.slice(0, SAID_AT_MOST) } : {}\n}\n\nexport function refusalReason(status: number, payload: Record<string, unknown>): string {\n return `the receiver refused with ${status} — resending will not change it${detailOf(payload)}`\n}\n\n/**\n * Whether the receiver told us something about rejections we cannot read.\n *\n * `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }` — the connector's own id\n * when it carried one, the offending record when it did not. This reader still expects a list of\n * envelope ids, which is a guess made when the contract named `rejected` in prose and declared no\n * shape for it; three sibling modules pinned `{ record, errors }` and `webhook` was the one that\n * did not. It says so now, so this reader is the side that is wrong.\n *\n * Reading a shape it does not know as \"none rejected\" marks every envelope in the batch as\n * delivered, and a fact that was refused is recorded here as stored. That is the worst shape a\n * mismatch can take: both sides think they agree. Until this reads `WebhookRejection` properly,\n * an unknown shape goes to retry instead — losing the reason, but not the fact.\n *\n * `offered` against `applied + duplicates` closes on its own and needs no rejection list at all.\n * On seq 178 it was three against two while the list was being misread. Worth standing up next to\n * this, not instead of it: the sum says something was dropped, `errors` says why.\n *\n * Absent is not unreadable. A receiver that rejected nothing says nothing, and that is the\n * ordinary case.\n */\n/**\n * What the receiver said it did with the batch, and whether those numbers add up.\n *\n * ── Why the sender keeps its own copy of this ──────────────────────────────\n * The receiver reports `offered`, `applied`, `duplicates`, `vocabulary` and a rejection list, and\n * `offered` has to equal the rest. Until now this sender read none of it: 870 envelopes went out\n * and there was no record on this side of what the far end said it stored (2026-09-08). The far\n * end can check its own arithmetic, but a disagreement between the two sides is exactly what one\n * side checking alone cannot find.\n *\n * ── Vocabulary is in the sum ───────────────────────────────────────────────\n * Vocabulary rides the same channel as events but has no time axis, so it never reaches the\n * journal. It is still offered, so leaving it out of the sum makes a healthy batch look short.\n *\n * ── Absent is not zero ────────────────────────────────────────────────────\n * A receiver that reports nothing gets `undefined`, not a tally of zeroes. A zero tally would\n * read as \"it offered nothing\", and that is a different fact from \"it did not say\".\n */\nexport interface Tally {\n offered: number\n applied: number\n duplicates: number\n vocabulary: number\n rejected: number\n /** `offered === applied + duplicates + vocabulary + rejected`. */\n balanced: boolean\n}\n\nexport function tallyOf(payload: Record<string, unknown>, rejectedCount: number): Tally | undefined {\n const offered = Number(payload.offered)\n\n if (!Number.isFinite(offered)) {\n /* The receiver did not say. Nothing to check, and nothing to complain about. */\n return undefined\n }\n\n /*\n * The three terms default to 0 because a receiver that stored none of a kind may leave that key\n * out. `offered` is the one that may not be guessed — it is the left side of the equation.\n */\n const applied = Number(payload.applied) || 0\n const duplicates = Number(payload.duplicates) || 0\n const vocabulary = Number(payload.vocabulary) || 0\n\n return {\n offered,\n applied,\n duplicates,\n vocabulary,\n rejected: rejectedCount,\n balanced: offered === applied + duplicates + vocabulary + rejectedCount\n }\n}\n\n/** One line for the log — short enough to print on every batch. */\nexport function describeTally(tally: Tally): string {\n const sum = `${tally.applied} + ${tally.duplicates} + ${tally.vocabulary} + ${tally.rejected}`\n\n return tally.balanced\n ? `offered ${tally.offered} = ${sum}`\n : `⚠ offered ${tally.offered} ≠ ${sum} — 받는 쪽이 센 것과 합이 안 맞습니다`\n}\n\nfunction unreadableRejections(said: unknown): boolean {\n if (said === undefined || said === null) {\n return false\n }\n\n if (!Array.isArray(said)) {\n return true\n }\n\n /*\n * An array is not enough. The receiver sends `{ record, errors }` objects while this expects\n * envelope ids, and `Array.isArray` passes both — so the ids match nothing, every envelope in\n * the batch is marked delivered, and the reasons in `errors` are dropped with them. That is\n * what happened to seq 178: HTTP 422 with ok:false, recorded here as SENT with no attempts.\n */\n return said.some(one => typeof one !== 'string')\n}\n\n/**\n * Pull the stated reasons out of a rejection list, whatever shape it came in.\n *\n * The contract declares `{ eventId, errors }` and `{ record, errors }`; older senders were told\n * to expect plain ids. This reads `errors` wherever it finds it and ignores the rest — it is for\n * telling a person what happened, not for deciding what was stored.\n */\nexport interface ReadRejections {\n /** Envelopes the receiver named, so they can be settled as refused. */\n eventIds: string[]\n /** What it said was wrong, once each. */\n reasons: string[]\n /** Refusals that named no envelope — these cannot be settled, only retried. */\n unidentified: number\n}\n\n/**\n * Read the contract's `WebhookRejection` — `{ eventId, errors }` or `{ record, errors }`.\n *\n * ── Why this matters more than it looked ──────────────────────────────────\n * Until this existed, a refusal this reader could not parse went to retry with the reason thrown\n * away. That is safe for one envelope and fatal for a queue: on 2026-09-08 one permanently invalid\n * envelope sat at the head of the batch, the whole batch was refused every five seconds, and 61\n * envelopes behind it — including valid ones — never moved. 113 attempts, no progress, and the\n * response had been naming the culprit the whole time.\n *\n * Naming which envelope was refused is what lets the queue move past it. A refusal that names no\n * envelope still cannot be settled, and that is counted rather than assumed away.\n */\nexport function readRejections(said: unknown): ReadRejections {\n const found: ReadRejections = { eventIds: [], reasons: [], unidentified: 0 }\n\n if (!Array.isArray(said)) {\n return found\n }\n\n const reasons: string[] = []\n\n for (const one of said) {\n if (typeof one === 'string') {\n /* The shape older senders were told to expect: a bare envelope id. */\n found.eventIds.push(one)\n continue\n }\n\n if (!one || typeof one !== 'object') {\n found.unidentified++\n continue\n }\n\n const row = one as Record<string, unknown>\n const errors = row.errors\n\n if (Array.isArray(errors)) {\n reasons.push(...errors.map(each => String(each)))\n } else if (typeof errors === 'string') {\n reasons.push(errors)\n }\n\n /*\n * `eventId` may be named directly, or sit inside the echoed record. Both are the contract's\n * shapes; neither is guessed at beyond reading a field of that name.\n */\n const record = (row.record ?? {}) as Record<string, unknown>\n const eventId = row.eventId ?? record.eventId\n\n if (typeof eventId === 'string' && eventId) {\n found.eventIds.push(eventId)\n } else {\n found.unidentified++\n }\n }\n\n /* Same reason repeated for every envelope in a batch is one reason, not forty. */\n found.reasons = [...new Set(reasons)]\n\n return found\n}\n\nexport function reasonsIn(said: unknown): string[] {\n if (!Array.isArray(said)) {\n return []\n }\n\n const reasons: string[] = []\n\n for (const one of said) {\n if (!one || typeof one !== 'object') {\n continue\n }\n\n const errors = (one as Record<string, unknown>).errors\n\n if (Array.isArray(errors)) {\n reasons.push(...errors.map(each => String(each)))\n } else if (typeof errors === 'string') {\n reasons.push(errors)\n }\n }\n\n /* Same reason repeated for every envelope in a batch is one reason, not forty. */\n return [...new Set(reasons)]\n}\n\nexport function interpretResponse(status: number, body: unknown): DeliveryOutcome {\n const payload = (body ?? {}) as Record<string, unknown>\n const rejected = Array.isArray(payload.rejected) ? (payload.rejected as string[]) : []\n\n /* 서명이 문제인 것은 사람에게 할 말이 달라 따로 든다. 행동은 `stop` 과 같다. */\n if (status === 401 || status === 403) {\n const reason = signatureRefusalOf(payload)\n return reason ? { kind: 'unauthorized', reason } : { kind: 'unauthorized' }\n }\n\n /*\n * The receiver said something about rejections in a shape this does not know. Retrying is the\n * honest answer: we cannot say which of these were stored, and marking them delivered would\n * lose whichever were refused with nobody able to find out (2026-09-07 — an envelope read as\n * SENT with no attempts had in fact been refused, and it took another lane reading the twin's\n * journal to notice).\n */\n if (unreadableRejections(payload.rejected)) {\n /*\n * ── Say why it was refused, even when we cannot say which ─────────────\n *\n * The contract's `WebhookRejection` is `{ eventId, errors }` or `{ record, errors }`. This\n * reader still expects envelope ids, so it cannot settle these — retrying is right, the fact\n * is not lost. But throwing the `errors` away with them was not right: on 2026-09-08 the twin\n * refused every genealogy envelope this plant had just started sending, and the queue climbed\n * to 59 attempts saying only \"cannot read\". The reason was in the response the whole time.\n *\n * So the reasons are read out of whichever shape arrived and carried in the retry message.\n * Reading them does not mean this understands the shape — settling still waits for that.\n */\n if (!Array.isArray(payload.rejected)) {\n /*\n * A count, or an object with a count in it. There is no list to read and nothing names an\n * envelope, so there is nothing to settle — only the fact that the two sides disagree about\n * the shape of this field.\n */\n return {\n kind: 'retry',\n reason: `response carried a \"rejected\" that is not a list (${typeof payload.rejected}) — cannot tell what was stored`\n }\n }\n\n const read = readRejections(payload.rejected)\n const why = read.reasons.length ? ` 받는 쪽이 말한 까닭: ${read.reasons.slice(0, 3).join(' · ')}` : ''\n\n if (read.unidentified) {\n /*\n * Some refusal named no envelope. Settling the ones that were named would leave the\n * unnamed ones marked delivered, and one of them is what the receiver refused.\n */\n return {\n kind: 'retry',\n reason: `${read.unidentified} of the refusals named no envelope — cannot tell what was stored.${why}`\n }\n }\n\n /*\n * Every refusal named an envelope. Those settle as refused and everything else in the batch\n * settles as delivered — which is what lets the queue move past a permanently invalid\n * envelope instead of retrying the whole batch behind it forever.\n */\n const lastSeq = Number(payload.lastSeq)\n\n if (!Number.isFinite(lastSeq)) {\n return { kind: 'retry', reason: `refusals were readable but no lastSeq came with them.${why}` }\n }\n\n return { kind: 'partial', lastSeq, rejected: read.eventIds, ...(tallyOf(payload, read.eventIds.length) ? { tally: tallyOf(payload, read.eventIds.length)! } : {}) }\n }\n\n switch (webhookSenderAction(status)) {\n case 'accepted': {\n const lastSeq = Number(payload.lastSeq)\n if (!Number.isFinite(lastSeq)) {\n /* 무엇까지 들어갔는지 모르면 보냄으로 표시할 수 없다. 표시하면 그 사실이 사라진다. */\n return { kind: 'retry', reason: 'accepted response carried no lastSeq — cannot tell what was stored' }\n }\n /*\n * When the receiver said nothing about counts, the key is left off entirely rather than set\n * to `undefined`. An outcome carrying `tally: undefined` is not the same object as one\n * without the key — `deepStrictEqual` tells them apart, and so does anything that walks\n * the keys. Absent means absent.\n */\n const counted = tallyOf(payload, rejected.length)\n const tally = counted ? { tally: counted } : {}\n\n return rejected.length\n ? { kind: 'partial', lastSeq, rejected, ...tally }\n : { kind: 'accepted', lastSeq, ...tally }\n }\n\n case 'partial': {\n const lastSeq = Number(payload.lastSeq)\n if (!Number.isFinite(lastSeq)) {\n return { kind: 'retry', reason: 'partial response carried no lastSeq — cannot tell what was stored' }\n }\n const counted = tallyOf(payload, rejected.length)\n\n return { kind: 'partial', lastSeq, rejected, ...(counted ? { tally: counted } : {}) }\n }\n\n case 'resend-from': {\n const expectedSeq = Number(payload.expectedSeq)\n if (!Number.isFinite(expectedSeq)) {\n return { kind: 'retry', reason: 'gap response carried no expectedSeq — cannot tell where to resume' }\n }\n const lastSeq = Number(payload.lastSeq)\n /* 구멍 앞까지 들어간 것은 보냄으로 표시한다 — 아니면 매 주기마다 그것을 다시 보낸다. */\n return { kind: 'gap', expectedSeq, lastSeq: Number.isFinite(lastSeq) ? lastSeq : undefined, rejected }\n }\n\n case 'wait': {\n /*\n * 503(아직 안 돈다)과 429(따라오지 못한다)가 함께 여기로 온다.\n *\n * 429 는 앞에서 따로 받아 두고 있었다 — 계약이 `stop` 을 답하던 때는 그래야 했다. 0.9.13 이\n * 429 를 `wait` 로 답하게 되어 그 분기가 두 벌이 됐으므로 지웠다. **다시 앞에 두지 말 것** —\n * 두 벌이 되면 한쪽만 고치는 날이 온다.\n */\n const retryAfterMs = Number(payload.retryAfterMs)\n return {\n kind: 'backpressure',\n retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : DEFAULT_RETRY_AFTER_MS\n }\n }\n\n case 'stop':\n /*\n * **받는 쪽이 말해 준 이유를 버리지 않는다.**\n *\n * 앞서는 상태 코드만 적었다. 그러면 「트윈이 400 으로 거절했다」만 남고 왜인지는 아무 데도\n * 없어서, 고치려면 같은 요청을 손으로 다시 보내 봐야 했다. 실제로 그렇게 했다(2026-09-02).\n */\n return { kind: 'stop', reason: refusalReason(status, payload) }\n\n default:\n return { kind: 'retry', reason: `the receiver failed with ${status}${detailOf(payload)}` }\n }\n}\n"]}
@@ -20,6 +20,7 @@ async function dispatchPending(store, config, deps) {
20
20
  const body = JSON.stringify((0, delivery_js_1.buildBatch)(pending));
21
21
  const timestamp = now().toISOString();
22
22
  const trace = (0, trace_context_js_1.traceFor)(undefined);
23
+ const signature = (0, delivery_js_1.sign)(config.secret, timestamp, body);
23
24
  let status;
24
25
  let parsed;
25
26
  try {
@@ -29,13 +30,14 @@ async function dispatchPending(store, config, deps) {
29
30
  ...(config.headers ?? {}),
30
31
  'Content-Type': 'application/json',
31
32
  [webhook_1.WEBHOOK_HEADER.timestamp]: timestamp,
32
- [webhook_1.WEBHOOK_HEADER.signature]: (0, delivery_js_1.sign)(config.secret, timestamp, body),
33
+ [webhook_1.WEBHOOK_HEADER.signature]: signature,
33
34
  [trace_context_js_1.TRACEPARENT]: (0, trace_context_js_1.toTraceparent)(trace)
34
35
  },
35
36
  body
36
37
  });
37
38
  status = response.status;
38
- parsed = await response.json().catch(() => ({}));
39
+ /* Text first: a refusal that is not JSON still says why (`replyOf`). */
40
+ parsed = (0, delivery_js_1.replyOf)(await response.text().catch(() => ''), [signature, config.secret]);
39
41
  (0, clock_offset_js_1.noteClockReading)(config.clockKey, (0, clock_offset_js_1.offsetFrom)(response.headers.get('date') ?? undefined, Date.now()));
40
42
  }
41
43
  catch (error) {
@@ -1 +1 @@
1
- {"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../server/service/outbox/dispatcher.ts"],"names":[],"mappings":";;AAwDA,0CAqGC;AA7JD,2DAA8D;AAE9D,+CAAwG;AACxG,mDAA4C;AAC5C,mDAA6C;AAE7C,sEAA+E;AAC/E,wEAAkG;AA+ClG,MAAM,kBAAkB,GAAG,GAAG,CAAA;AAEvB,KAAK,UAAU,eAAe,CAAC,KAAkB,EAAE,MAAsB,EAAE,IAAkB;IAClG,MAAM,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;IACpB,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;IAC1C,MAAM,IAAI,GAAG,MAAM,CAAC,SAAS,IAAI,kBAAkB,CAAA;IAEnD,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,CAAA;IAEzC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC;QACpB,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,CAAA;IAC/C,CAAC;IAED,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC,CAAA;IACtC,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,IAAA,wBAAU,EAAC,OAAO,CAAC,CAAC,CAAA;IAChD,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,WAAW,EAAE,CAAA;IACrC,MAAM,KAAK,GAAG,IAAA,2BAAQ,EAAC,SAAS,CAAC,CAAA;IAEjC,IAAI,MAAc,CAAA;IAClB,IAAI,MAAe,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,QAAQ,EAAE;YACjD,MAAM,EAAE,MAAM;YACd,OAAO,EAAE;gBACP,GAAG,CAAC,MAAM,CAAC,OAAO,IAAI,EAAE,CAAC;gBACzB,cAAc,EAAE,kBAAkB;gBAClC,CAAC,wBAAc,CAAC,SAAS,CAAC,EAAE,SAAS;gBACrC,CAAC,wBAAc,CAAC,SAAS,CAAC,EAAE,IAAA,kBAAI,EAAC,MAAM,CAAC,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC;gBAChE,CAAC,8BAAW,CAAC,EAAE,IAAA,gCAAa,EAAC,KAAK,CAAC;aACpC;YACD,IAAI;SACL,CAAC,CAAA;QACF,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAA;QACxB,MAAM,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAA;QAEhD,IAAA,kCAAgB,EAAC,MAAM,CAAC,QAAQ,EAAE,IAAA,4BAAU,EAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;IACtG,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;QACrE,GAAG,CAAC,IAAI,CAAC,0BAA0B,IAAA,2BAAQ,EAAC,KAAK,CAAC,iCAAiC,MAAM,EAAE,CAAC,CAAA;QAC5F,MAAM,KAAK,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,CAAA;QACtC,OAAO,EAAE,OAAO,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,EAAE,CAAA;IACxD,CAAC;IAED,MAAM,OAAO,GAAG,IAAA,+BAAiB,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAEjD,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACrB,KAAK,UAAU,CAAC;QAChB,KAAK,SAAS,CAAC,CAAC,CAAC;YACf,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,EAAE,IAAI,CAAC,CAAA;YACpH,MAAM,KAAK,CAAC,SAAS,EAAE,CAAA;YAEvB,IAAI,OAAO,CAAC,KAAK,EAAE,CAAC;gBAClB,MAAM,IAAI,GAAG,aAAa,IAAA,2BAAa,EAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAA;gBAExD,IAAI,OAAO,CAAC,KAAK,CAAC,QAAQ,EAAE,CAAC;oBAC3B,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;gBAChB,CAAC;qBAAM,CAAC;oBACN,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;gBAChB,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,GAAG,CAAC,IAAI,CAAC,sFAAsF,CAAC,CAAA;YAClG,CAAC;YAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;QAC1B,CAAC;QAED,KAAK,KAAK,CAAC,CAAC,CAAC;YACX,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAA;YAEtH,MAAM,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;YAC3C,MAAM,KAAK,CAAC,SAAS,EAAE,CAAA;YACvB,GAAG,CAAC,IAAI,CAAC,mDAAmD,OAAO,CAAC,WAAW,EAAE,CAAC,CAAA;YAClF,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAA;QAC1B,CAAC;QAED,OAAO,CAAC,CAAC,CAAC;YACR,+FAA+F;YAC/F,MAAM,MAAM,GAAG,IAAA,2BAAW,EAAC,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,CAAA;YAEnD,IAAI,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;gBAC3B,MAAM,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,YAAY,EAAE,GAAG,EAAE,CAAC,CAAA;gBAChD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;gBACtC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,YAAY,EAAE,MAAM,CAAC,YAAY,EAAE,CAAA;YAChE,CAAC;YAED,IAAI,MAAM,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;gBAC3B,MAAM,KAAK,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;gBAC7C,MAAM,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,CAAA;gBAC3B,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;gBACtC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,CAAA;YAC7B,CAAC;YAED,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS,EAAE,CAAC;gBAC9B,MAAM,KAAK,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,CAAC,CAAA;gBAE7C,IAAI,MAAM,CAAC,GAAG,EAAE,CAAC;oBACf,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;gBACxC,CAAC;YACH,CAAC;YAED,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,CAAA;QAC7B,CAAC;IACH,CAAC;AACH,CAAC;AAED,KAAK,UAAU,MAAM,CACnB,KAAkB,EAClB,OAAmC,EACnC,OAAe,EACf,WAA8B,EAC9B,IAAkB;IAElB,MAAM,EAAE,OAAO,EAAE,SAAS,EAAE,GAAG,IAAA,0BAAU,EAAC,OAAO,EAAE,OAAO,EAAE,WAAW,CAAC,CAAA;IAExE,IAAI,OAAO,CAAC,MAAM,EAAE,CAAC;QACnB,MAAM,KAAK,CAAC,QAAQ,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,EAAE,CAAC,CAAA;IACnE,CAAC;IAED,IAAI,SAAS,CAAC,MAAM,EAAE,CAAC;QACrB,MAAM,KAAK,CAAC,YAAY,CAAC,SAAS,EAAE,4CAA4C,CAAC,CAAA;QACjF,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,mCAAmC,SAAS,CAAC,MAAM,8CAA8C,CAAC,CAAA;IAClH,CAAC;IAED,OAAO,OAAO,CAAC,MAAM,CAAA;AACvB,CAAC","sourcesContent":["import { WEBHOOK_HEADER } from '@operato/ops-contract/webhook'\n\nimport { buildBatch, describeTally, interpretResponse, sign, type DeliveryOutcome } from './delivery.js'\nimport { settlement } from './settlement.js'\nimport { rowEffectOf } from './row-effect.js'\nimport type { OutboxStore, StoredOutboxRow } from './store.js'\nimport { noteClockReading, offsetFrom } from '../observability/clock-offset.js'\nimport { TRACEPARENT, toTraceparent, traceFor, traceTag } from '../observability/trace-context.js'\n\n/**\n * One delivery: take what is pending on a stream, offer it to the peer, and do to our rows what the\n * peer's answer says.\n *\n * Moved from operato-plant's `mes-outbox-dispatcher.ts` (ADR-0078 decision 2 ②). The judgement was already\n * outside this function — `interpretResponse`, `settlement`, `rowEffectOf` — so what is left is the order\n * of things. Three changes on the way, and no others:\n *\n * - it reads and writes through an `OutboxStore` instead of an `EntityManager`;\n * - the headers a receiver wants beside the contract's (`X-Mes-Site` and the like) come in as `headers`.\n * They belong to the destination, not to the machine;\n * - being asked to wait is written down (`store.noteHeld`), and a batch that goes in clears it.\n *\n * `fetch` and `say` are arguments so that this function can be run by a test. It could not be before: it\n * needed an EntityManager and a receiver answering 503, and the branch that matters most — backpressure —\n * carried a comment saying no test reached it.\n */\nexport interface DispatchConfig {\n endpoint: string\n secret: string\n /** Extra request headers this receiver asks for. The contract's own headers are added here. */\n headers?: Record<string, string>\n /** The name clock readings are kept under — one per peer, so two peers' clocks are not mixed. */\n clockKey: string\n batchSize?: number\n}\n\nexport interface DispatchReport {\n outcome: DeliveryOutcome | { kind: 'idle' }\n sent: number\n retryAfterMs?: number\n}\n\nexport interface Say {\n info(what: string): void\n warn(what: string): void\n error(what: string): void\n}\n\nexport interface DispatchDeps {\n fetch: typeof fetch\n say: Say\n now?: () => Date\n}\n\nconst DEFAULT_BATCH_SIZE = 200\n\nexport async function dispatchPending(store: OutboxStore, config: DispatchConfig, deps: DispatchDeps): Promise<DispatchReport> {\n const { say } = deps\n const now = deps.now ?? (() => new Date())\n const take = config.batchSize ?? DEFAULT_BATCH_SIZE\n\n const pending = await store.pending(take)\n\n if (!pending.length) {\n return { outcome: { kind: 'idle' }, sent: 0 }\n }\n\n const ids = pending.map(row => row.id)\n const body = JSON.stringify(buildBatch(pending))\n const timestamp = now().toISOString()\n const trace = traceFor(undefined)\n\n let status: number\n let parsed: unknown\n try {\n const response = await deps.fetch(config.endpoint, {\n method: 'POST',\n headers: {\n ...(config.headers ?? {}),\n 'Content-Type': 'application/json',\n [WEBHOOK_HEADER.timestamp]: timestamp,\n [WEBHOOK_HEADER.signature]: sign(config.secret, timestamp, body),\n [TRACEPARENT]: toTraceparent(trace)\n },\n body\n })\n status = response.status\n parsed = await response.json().catch(() => ({}))\n\n noteClockReading(config.clockKey, offsetFrom(response.headers.get('date') ?? undefined, Date.now()))\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error)\n say.warn(`[webhook] outbox batch ${traceTag(trace)} did not reach the receiver — ${reason}`)\n await store.markAttempted(ids, reason)\n return { outcome: { kind: 'retry', reason }, sent: 0 }\n }\n\n const outcome = interpretResponse(status, parsed)\n\n switch (outcome.kind) {\n case 'accepted':\n case 'partial': {\n const sent = await settle(store, pending, outcome.lastSeq, outcome.kind === 'partial' ? outcome.rejected : [], deps)\n await store.noteTaken()\n\n if (outcome.tally) {\n const line = `[webhook] ${describeTally(outcome.tally)}`\n\n if (outcome.tally.balanced) {\n say.info(line)\n } else {\n say.warn(line)\n }\n } else {\n say.info('[webhook] the receiver did not send its counts — the offered total cannot be checked')\n }\n\n return { outcome, sent }\n }\n\n case 'gap': {\n const sent = outcome.lastSeq === undefined ? 0 : await settle(store, pending, outcome.lastSeq, outcome.rejected, deps)\n\n await store.rewindFrom(outcome.expectedSeq)\n await store.noteTaken()\n say.info(`[webhook] the receiver asked to resume from seq ${outcome.expectedSeq}`)\n return { outcome, sent }\n }\n\n default: {\n /* What an answer does to our rows is judged in one place. This switch does not judge again. */\n const effect = rowEffectOf(outcome, pending.length)\n\n if (effect.rows === 'hold') {\n await store.noteHeld(effect.retryAfterMs, now())\n say[effect.say.level](effect.say.what)\n return { outcome, sent: 0, retryAfterMs: effect.retryAfterMs }\n }\n\n if (effect.rows === 'fail') {\n await store.markAttempted(ids, effect.reason)\n await store.markFailed(ids)\n say[effect.say.level](effect.say.what)\n return { outcome, sent: 0 }\n }\n\n if (effect.rows === 'attempt') {\n await store.markAttempted(ids, effect.reason)\n\n if (effect.say) {\n say[effect.say.level](effect.say.what)\n }\n }\n\n return { outcome, sent: 0 }\n }\n }\n}\n\nasync function settle(\n store: OutboxStore,\n pending: readonly StoredOutboxRow[],\n lastSeq: number,\n rejectedIds: readonly string[],\n deps: DispatchDeps\n): Promise<number> {\n const { sentIds, failedIds } = settlement(pending, lastSeq, rejectedIds)\n\n if (sentIds.length) {\n await store.markSent(sentIds, (deps.now ?? (() => new Date()))())\n }\n\n if (failedIds.length) {\n await store.markRejected(failedIds, 'rejected by the receiver during validation')\n deps.say.warn(`[webhook] the receiver rejected ${failedIds.length} record(s) — see the payload log on its side`)\n }\n\n return sentIds.length\n}\n"]}
1
+ {"version":3,"file":"dispatcher.js","sourceRoot":"","sources":["../../../server/service/outbox/dispatcher.ts"],"names":[],"mappings":"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","sourcesContent":["import { WEBHOOK_HEADER } from '@operato/ops-contract/webhook'\n\nimport { buildBatch, describeTally, interpretResponse, replyOf, sign, type DeliveryOutcome } from './delivery.js'\nimport { settlement } from './settlement.js'\nimport { rowEffectOf } from './row-effect.js'\nimport type { OutboxStore, StoredOutboxRow } from './store.js'\nimport { noteClockReading, offsetFrom } from '../observability/clock-offset.js'\nimport { TRACEPARENT, toTraceparent, traceFor, traceTag } from '../observability/trace-context.js'\n\n/**\n * One delivery: take what is pending on a stream, offer it to the peer, and do to our rows what the\n * peer's answer says.\n *\n * Moved from operato-plant's `mes-outbox-dispatcher.ts` (ADR-0078 decision 2 ②). The judgement was already\n * outside this function — `interpretResponse`, `settlement`, `rowEffectOf` — so what is left is the order\n * of things. Three changes on the way, and no others:\n *\n * - it reads and writes through an `OutboxStore` instead of an `EntityManager`;\n * - the headers a receiver wants beside the contract's (`X-Mes-Site` and the like) come in as `headers`.\n * They belong to the destination, not to the machine;\n * - being asked to wait is written down (`store.noteHeld`), and a batch that goes in clears it.\n *\n * `fetch` and `say` are arguments so that this function can be run by a test. It could not be before: it\n * needed an EntityManager and a receiver answering 503, and the branch that matters most — backpressure —\n * carried a comment saying no test reached it.\n */\nexport interface DispatchConfig {\n endpoint: string\n secret: string\n /** Extra request headers this receiver asks for. The contract's own headers are added here. */\n headers?: Record<string, string>\n /** The name clock readings are kept under — one per peer, so two peers' clocks are not mixed. */\n clockKey: string\n batchSize?: number\n}\n\nexport interface DispatchReport {\n outcome: DeliveryOutcome | { kind: 'idle' }\n sent: number\n retryAfterMs?: number\n}\n\nexport interface Say {\n info(what: string): void\n warn(what: string): void\n error(what: string): void\n}\n\nexport interface DispatchDeps {\n fetch: typeof fetch\n say: Say\n now?: () => Date\n}\n\nconst DEFAULT_BATCH_SIZE = 200\n\nexport async function dispatchPending(store: OutboxStore, config: DispatchConfig, deps: DispatchDeps): Promise<DispatchReport> {\n const { say } = deps\n const now = deps.now ?? (() => new Date())\n const take = config.batchSize ?? DEFAULT_BATCH_SIZE\n\n const pending = await store.pending(take)\n\n if (!pending.length) {\n return { outcome: { kind: 'idle' }, sent: 0 }\n }\n\n const ids = pending.map(row => row.id)\n const body = JSON.stringify(buildBatch(pending))\n const timestamp = now().toISOString()\n const trace = traceFor(undefined)\n\n const signature = sign(config.secret, timestamp, body)\n\n let status: number\n let parsed: unknown\n try {\n const response = await deps.fetch(config.endpoint, {\n method: 'POST',\n headers: {\n ...(config.headers ?? {}),\n 'Content-Type': 'application/json',\n [WEBHOOK_HEADER.timestamp]: timestamp,\n [WEBHOOK_HEADER.signature]: signature,\n [TRACEPARENT]: toTraceparent(trace)\n },\n body\n })\n status = response.status\n /* Text first: a refusal that is not JSON still says why (`replyOf`). */\n parsed = replyOf(await response.text().catch(() => ''), [signature, config.secret])\n\n noteClockReading(config.clockKey, offsetFrom(response.headers.get('date') ?? undefined, Date.now()))\n } catch (error) {\n const reason = error instanceof Error ? error.message : String(error)\n say.warn(`[webhook] outbox batch ${traceTag(trace)} did not reach the receiver — ${reason}`)\n await store.markAttempted(ids, reason)\n return { outcome: { kind: 'retry', reason }, sent: 0 }\n }\n\n const outcome = interpretResponse(status, parsed)\n\n switch (outcome.kind) {\n case 'accepted':\n case 'partial': {\n const sent = await settle(store, pending, outcome.lastSeq, outcome.kind === 'partial' ? outcome.rejected : [], deps)\n await store.noteTaken()\n\n if (outcome.tally) {\n const line = `[webhook] ${describeTally(outcome.tally)}`\n\n if (outcome.tally.balanced) {\n say.info(line)\n } else {\n say.warn(line)\n }\n } else {\n say.info('[webhook] the receiver did not send its counts — the offered total cannot be checked')\n }\n\n return { outcome, sent }\n }\n\n case 'gap': {\n const sent = outcome.lastSeq === undefined ? 0 : await settle(store, pending, outcome.lastSeq, outcome.rejected, deps)\n\n await store.rewindFrom(outcome.expectedSeq)\n await store.noteTaken()\n say.info(`[webhook] the receiver asked to resume from seq ${outcome.expectedSeq}`)\n return { outcome, sent }\n }\n\n default: {\n /* What an answer does to our rows is judged in one place. This switch does not judge again. */\n const effect = rowEffectOf(outcome, pending.length)\n\n if (effect.rows === 'hold') {\n await store.noteHeld(effect.retryAfterMs, now())\n say[effect.say.level](effect.say.what)\n return { outcome, sent: 0, retryAfterMs: effect.retryAfterMs }\n }\n\n if (effect.rows === 'fail') {\n await store.markAttempted(ids, effect.reason)\n await store.markFailed(ids)\n say[effect.say.level](effect.say.what)\n return { outcome, sent: 0 }\n }\n\n if (effect.rows === 'attempt') {\n await store.markAttempted(ids, effect.reason)\n\n if (effect.say) {\n say[effect.say.level](effect.say.what)\n }\n }\n\n return { outcome, sent: 0 }\n }\n }\n}\n\nasync function settle(\n store: OutboxStore,\n pending: readonly StoredOutboxRow[],\n lastSeq: number,\n rejectedIds: readonly string[],\n deps: DispatchDeps\n): Promise<number> {\n const { sentIds, failedIds } = settlement(pending, lastSeq, rejectedIds)\n\n if (sentIds.length) {\n await store.markSent(sentIds, (deps.now ?? (() => new Date()))())\n }\n\n if (failedIds.length) {\n await store.markRejected(failedIds, 'rejected by the receiver during validation')\n deps.say.warn(`[webhook] the receiver rejected ${failedIds.length} record(s) — see the payload log on its side`)\n }\n\n return sentIds.length\n}\n"]}
@@ -87,7 +87,19 @@ async function pumpOnce(deps, state, options) {
87
87
  pause(stream, exports.PAUSE_AFTER_UNRECOVERABLE_MS, report.outcome.reason);
88
88
  }
89
89
  else if (report.outcome.kind === 'unauthorized') {
90
- pause(signer, exports.PAUSE_AFTER_UNRECOVERABLE_MS, `${step.endpoint} did not accept our signature for ${step.stream.subdomain} - check the key on both sides, then it will try again`);
90
+ pause(signer, exports.PAUSE_AFTER_UNRECOVERABLE_MS, `${step.endpoint} did not accept our signature for ${step.stream.subdomain}${report.outcome.reason ? ` (${report.outcome.reason})` : ''} - check the key on both sides, then it will try again`);
91
+ }
92
+ else if (report.outcome.kind === 'retry') {
93
+ /*
94
+ * A retry rests nowhere, so without this it reached only the row: on 2026-10-11 a 405 from a wrong path
95
+ * retried for hours and the log said nothing. Said once while the reason stands, like the notes above.
96
+ */
97
+ const key = `retry:${stream}:${report.outcome.reason}`;
98
+ saidThisRound.add(key);
99
+ if (!state.said.has(key)) {
100
+ state.said.add(key);
101
+ deps.say.warn(`[webhook] ${step.endpoint} - ${report.outcome.reason} - will try again`);
102
+ }
91
103
  }
92
104
  }
93
105
  /* A note whose cause did not come up this round is forgotten. If the cause comes back, it is said again. */
@@ -1 +1 @@
1
- {"version":3,"file":"pump.js","sourceRoot":"","sources":["../../../server/service/outbox/pump.ts"],"names":[],"mappings":"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","sourcesContent":["import { dispatchPending, type Say } from './dispatcher.js'\nimport { planRound, type DestinationReading, type PendingStream, type RoundOptions } from './pump-round.js'\nimport type { OutboxStore } from './store.js'\n\n/**\n * **One round of the pump** — every stream with facts waiting, one delivery each (ADR-0078 decision 6).\n *\n * ═════════════════════════════════════════════════════════════════════════\n * Built against operato-plant's `mes-outbox-pump.ts`, which learned these by running:\n *\n * - **Asked to wait, nothing more goes to that receiver until the wait is over.** Ignoring a 429 does not crash\n * anything - the receiver drops facts instead.\n * - **An answer only a person can fix pauses that stream.** A wrong key gets the same 401 every five seconds,\n * knocks on the receiver and buries the log. It pauses only what it is about - see `PumpState.pauses`.\n * - **Each cause of facts piling up is said once.** Said every round, the line covers everything else.\n *\n * Nothing here touches TypeORM, the process or a timer - the source, the environment, `fetch` and the clock are\n * handed in, so every branch runs in a test. The loop, the lease and the tables are in `pump-loop.ts`.\n * ═════════════════════════════════════════════════════════════════════════\n */\n\n/** Where a round reads from. `pump-loop.ts` supplies one over this module's tables. */\nexport interface PumpSource {\n pendingStreams(): Promise<PendingStream[]>\n destinationOf(domainId: string, peer: string): Promise<DestinationReading | undefined>\n storeFor(stream: PendingStream): OutboxStore\n}\n\nexport interface PumpDeps {\n source: PumpSource\n env: Record<string, string | undefined>\n fetch: typeof fetch\n say: Say\n nowMs?: () => number\n}\n\n/**\n * One pause: until when, and why. The same reason is not said twice in a row.\n */\ninterface Pause {\n until: number\n why: string\n}\n\n/** What a pump remembers between rounds. One per running pump. */\nexport interface PumpState {\n /**\n * Pauses **by what they are about** (Plant, 2026-09-24). The first cut held one pause for the whole pump, as\n * operato-plant's pump does: a tenant with a *wrong* key paused everything, and since the round goes in a fixed\n * order the tenants behind it never sent again - measured, five minutes, zero sent. So each cause pauses its\n * own unit:\n *\n * ```\n * receiver:<endpoint> asked to wait it is that receiver's load, whoever the stream belongs to\n * stream:<domain>/<peer> signature refused a key is per domain and peer\n * stream:<domain>/<peer>/<scope> stop the answer was about what this stream sent\n * ```\n *\n * Waiting is keyed by the endpoint rather than the peer's name: two domains may both call their twin `twin`\n * and mean two different servers.\n */\n pauses: Map<string, Pause>\n /** Notes said, by key. A key whose cause is gone this round is forgotten, so a cause that returns is said again. */\n said: Set<string>\n}\n\nexport function newPumpState(): PumpState {\n return { pauses: new Map(), said: new Set() }\n}\n\n/**\n * How much longer the longest pause stands - what the lease should be held past. Zero or less when nothing rests.\n *\n * Holding past the longest keeps the rule the single pause had: while anything rests, no other instance takes over\n * and knocks on it. Streams that are not paused still go out - they go out from this instance.\n */\nexport function restingFor(state: PumpState, nowMs: number): number {\n let latest = 0\n for (const pause of state.pauses.values()) {\n latest = Math.max(latest, pause.until)\n }\n return latest - nowMs\n}\n\n/**\n * What a round tells the periodic loop (task-lease `startPeriodicTask`).\n *\n * A pause is about one stream, so it is `holdMs` - other instances stay off while it stands, and this one\n * goes on sending the streams that are not paused at the usual interval. Never `restMs`: that would stop\n * every stream behind one tenant's wrong key.\n */\nexport function afterRound(state: PumpState, nowMs: number): { holdMs: number } | undefined {\n const resting = restingFor(state, nowMs)\n return resting > 0 ? { holdMs: resting } : undefined\n}\n\n/** How long to rest after an answer that resending will not change. */\nexport const PAUSE_AFTER_UNRECOVERABLE_MS = 60_000\n\nexport interface PumpOptions extends RoundOptions {\n batchSize: number\n}\n\nexport async function pumpOnce(deps: PumpDeps, state: PumpState, options: PumpOptions): Promise<void> {\n const now = deps.nowMs ?? (() => Date.now())\n\n /* A pause that is over is forgotten - the next time the same answer comes, it has to be said again. */\n for (const [key, pause] of state.pauses) {\n if (pause.until <= now()) state.pauses.delete(key)\n }\n\n const resting = (key: string) => (state.pauses.get(key)?.until ?? 0) > now()\n\n const pause = (key: string, forMs: number, why: string) => {\n const standing = state.pauses.get(key)\n if (standing?.why !== why) {\n deps.say.warn(`[webhook] ${why} - waiting ${Math.round(forMs / 1000)}s`)\n }\n state.pauses.set(key, { until: Math.max(standing?.until ?? 0, now() + forMs), why })\n }\n\n const streams = await deps.source.pendingStreams()\n\n const destinations = new Map<string, DestinationReading | undefined>()\n for (const stream of streams) {\n const key = `${stream.domainId}\\u0000${stream.peer}`\n if (!destinations.has(key)) {\n destinations.set(key, await deps.source.destinationOf(stream.domainId, stream.peer))\n }\n }\n\n const steps = planRound(streams, (domainId, peer) => destinations.get(`${domainId}\\u0000${peer}`), deps.env, options)\n const saidThisRound = new Set<string>()\n\n for (const step of steps) {\n if (step.note) {\n saidThisRound.add(step.note.key)\n if (!state.said.has(step.note.key)) {\n state.said.add(step.note.key)\n deps.say.warn(step.note.line)\n }\n }\n\n if (step.kind === 'wait') {\n continue\n }\n\n const { domainId, peer, scope } = step.stream\n const receiver = `receiver:${step.endpoint}`\n const signer = `stream:${domainId}/${peer}`\n const stream = `stream:${domainId}/${peer}/${scope}`\n\n if (resting(receiver) || resting(signer) || resting(stream)) {\n continue\n }\n\n const report = await dispatchPending(\n deps.source.storeFor(step.stream),\n { endpoint: step.endpoint, secret: step.secret, headers: step.headers, clockKey: step.clockKey, batchSize: options.batchSize },\n { fetch: deps.fetch, say: deps.say, now: () => new Date(now()) }\n )\n\n if (report.retryAfterMs) {\n pause(receiver, report.retryAfterMs, `${step.endpoint} asked us to slow down`)\n } else if (report.outcome.kind === 'stop') {\n pause(stream, PAUSE_AFTER_UNRECOVERABLE_MS, report.outcome.reason)\n } else if (report.outcome.kind === 'unauthorized') {\n pause(signer, PAUSE_AFTER_UNRECOVERABLE_MS, `${step.endpoint} did not accept our signature for ${step.stream.subdomain} - check the key on both sides, then it will try again`)\n }\n }\n\n /* A note whose cause did not come up this round is forgotten. If the cause comes back, it is said again. */\n for (const key of [...state.said]) {\n if (!saidThisRound.has(key)) state.said.delete(key)\n }\n}\n"]}
1
+ {"version":3,"file":"pump.js","sourceRoot":"","sources":["../../../server/service/outbox/pump.ts"],"names":[],"mappings":"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","sourcesContent":["import { dispatchPending, type Say } from './dispatcher.js'\nimport { planRound, type DestinationReading, type PendingStream, type RoundOptions } from './pump-round.js'\nimport type { OutboxStore } from './store.js'\n\n/**\n * **One round of the pump** — every stream with facts waiting, one delivery each (ADR-0078 decision 6).\n *\n * ═════════════════════════════════════════════════════════════════════════\n * Built against operato-plant's `mes-outbox-pump.ts`, which learned these by running:\n *\n * - **Asked to wait, nothing more goes to that receiver until the wait is over.** Ignoring a 429 does not crash\n * anything - the receiver drops facts instead.\n * - **An answer only a person can fix pauses that stream.** A wrong key gets the same 401 every five seconds,\n * knocks on the receiver and buries the log. It pauses only what it is about - see `PumpState.pauses`.\n * - **Each cause of facts piling up is said once.** Said every round, the line covers everything else.\n *\n * Nothing here touches TypeORM, the process or a timer - the source, the environment, `fetch` and the clock are\n * handed in, so every branch runs in a test. The loop, the lease and the tables are in `pump-loop.ts`.\n * ═════════════════════════════════════════════════════════════════════════\n */\n\n/** Where a round reads from. `pump-loop.ts` supplies one over this module's tables. */\nexport interface PumpSource {\n pendingStreams(): Promise<PendingStream[]>\n destinationOf(domainId: string, peer: string): Promise<DestinationReading | undefined>\n storeFor(stream: PendingStream): OutboxStore\n}\n\nexport interface PumpDeps {\n source: PumpSource\n env: Record<string, string | undefined>\n fetch: typeof fetch\n say: Say\n nowMs?: () => number\n}\n\n/**\n * One pause: until when, and why. The same reason is not said twice in a row.\n */\ninterface Pause {\n until: number\n why: string\n}\n\n/** What a pump remembers between rounds. One per running pump. */\nexport interface PumpState {\n /**\n * Pauses **by what they are about** (Plant, 2026-09-24). The first cut held one pause for the whole pump, as\n * operato-plant's pump does: a tenant with a *wrong* key paused everything, and since the round goes in a fixed\n * order the tenants behind it never sent again - measured, five minutes, zero sent. So each cause pauses its\n * own unit:\n *\n * ```\n * receiver:<endpoint> asked to wait it is that receiver's load, whoever the stream belongs to\n * stream:<domain>/<peer> signature refused a key is per domain and peer\n * stream:<domain>/<peer>/<scope> stop the answer was about what this stream sent\n * ```\n *\n * Waiting is keyed by the endpoint rather than the peer's name: two domains may both call their twin `twin`\n * and mean two different servers.\n */\n pauses: Map<string, Pause>\n /** Notes said, by key. A key whose cause is gone this round is forgotten, so a cause that returns is said again. */\n said: Set<string>\n}\n\nexport function newPumpState(): PumpState {\n return { pauses: new Map(), said: new Set() }\n}\n\n/**\n * How much longer the longest pause stands - what the lease should be held past. Zero or less when nothing rests.\n *\n * Holding past the longest keeps the rule the single pause had: while anything rests, no other instance takes over\n * and knocks on it. Streams that are not paused still go out - they go out from this instance.\n */\nexport function restingFor(state: PumpState, nowMs: number): number {\n let latest = 0\n for (const pause of state.pauses.values()) {\n latest = Math.max(latest, pause.until)\n }\n return latest - nowMs\n}\n\n/**\n * What a round tells the periodic loop (task-lease `startPeriodicTask`).\n *\n * A pause is about one stream, so it is `holdMs` - other instances stay off while it stands, and this one\n * goes on sending the streams that are not paused at the usual interval. Never `restMs`: that would stop\n * every stream behind one tenant's wrong key.\n */\nexport function afterRound(state: PumpState, nowMs: number): { holdMs: number } | undefined {\n const resting = restingFor(state, nowMs)\n return resting > 0 ? { holdMs: resting } : undefined\n}\n\n/** How long to rest after an answer that resending will not change. */\nexport const PAUSE_AFTER_UNRECOVERABLE_MS = 60_000\n\nexport interface PumpOptions extends RoundOptions {\n batchSize: number\n}\n\nexport async function pumpOnce(deps: PumpDeps, state: PumpState, options: PumpOptions): Promise<void> {\n const now = deps.nowMs ?? (() => Date.now())\n\n /* A pause that is over is forgotten - the next time the same answer comes, it has to be said again. */\n for (const [key, pause] of state.pauses) {\n if (pause.until <= now()) state.pauses.delete(key)\n }\n\n const resting = (key: string) => (state.pauses.get(key)?.until ?? 0) > now()\n\n const pause = (key: string, forMs: number, why: string) => {\n const standing = state.pauses.get(key)\n if (standing?.why !== why) {\n deps.say.warn(`[webhook] ${why} - waiting ${Math.round(forMs / 1000)}s`)\n }\n state.pauses.set(key, { until: Math.max(standing?.until ?? 0, now() + forMs), why })\n }\n\n const streams = await deps.source.pendingStreams()\n\n const destinations = new Map<string, DestinationReading | undefined>()\n for (const stream of streams) {\n const key = `${stream.domainId}\\u0000${stream.peer}`\n if (!destinations.has(key)) {\n destinations.set(key, await deps.source.destinationOf(stream.domainId, stream.peer))\n }\n }\n\n const steps = planRound(streams, (domainId, peer) => destinations.get(`${domainId}\\u0000${peer}`), deps.env, options)\n const saidThisRound = new Set<string>()\n\n for (const step of steps) {\n if (step.note) {\n saidThisRound.add(step.note.key)\n if (!state.said.has(step.note.key)) {\n state.said.add(step.note.key)\n deps.say.warn(step.note.line)\n }\n }\n\n if (step.kind === 'wait') {\n continue\n }\n\n const { domainId, peer, scope } = step.stream\n const receiver = `receiver:${step.endpoint}`\n const signer = `stream:${domainId}/${peer}`\n const stream = `stream:${domainId}/${peer}/${scope}`\n\n if (resting(receiver) || resting(signer) || resting(stream)) {\n continue\n }\n\n const report = await dispatchPending(\n deps.source.storeFor(step.stream),\n { endpoint: step.endpoint, secret: step.secret, headers: step.headers, clockKey: step.clockKey, batchSize: options.batchSize },\n { fetch: deps.fetch, say: deps.say, now: () => new Date(now()) }\n )\n\n if (report.retryAfterMs) {\n pause(receiver, report.retryAfterMs, `${step.endpoint} asked us to slow down`)\n } else if (report.outcome.kind === 'stop') {\n pause(stream, PAUSE_AFTER_UNRECOVERABLE_MS, report.outcome.reason)\n } else if (report.outcome.kind === 'unauthorized') {\n pause(signer, PAUSE_AFTER_UNRECOVERABLE_MS, `${step.endpoint} did not accept our signature for ${step.stream.subdomain}${report.outcome.reason ? ` (${report.outcome.reason})` : ''} - check the key on both sides, then it will try again`)\n } else if (report.outcome.kind === 'retry') {\n /*\n * A retry rests nowhere, so without this it reached only the row: on 2026-10-11 a 405 from a wrong path\n * retried for hours and the log said nothing. Said once while the reason stands, like the notes above.\n */\n const key = `retry:${stream}:${report.outcome.reason}`\n saidThisRound.add(key)\n if (!state.said.has(key)) {\n state.said.add(key)\n deps.say.warn(`[webhook] ${step.endpoint} - ${report.outcome.reason} - will try again`)\n }\n }\n }\n\n /* A note whose cause did not come up this round is forgotten. If the cause comes back, it is said again. */\n for (const key of [...state.said]) {\n if (!saidThisRound.has(key)) state.said.delete(key)\n }\n}\n"]}
@@ -29,16 +29,19 @@ function rowEffectOf(outcome, count) {
29
29
  `${outcome.retryAfterMs}ms — held, not failed, and not counted as an attempt`
30
30
  }
31
31
  };
32
- case 'unauthorized':
32
+ case 'unauthorized': {
33
+ /* What the receiver said decides where to look: a mismatch is the key, `expired` is a clock. */
34
+ const said = outcome.reason ? ` — ${outcome.reason}` : '';
33
35
  return {
34
36
  rows: 'attempt',
35
- reason: 'signature was not accepted',
37
+ reason: `signature was not accepted${said}`,
36
38
  countsAsAttempt: true,
37
39
  say: {
38
40
  level: 'error',
39
- what: '[webhook] the receiver did not accept our signature — check the shared secret before retrying'
41
+ what: `[webhook] the receiver did not accept our signature${said} — check the shared secret before retrying`
40
42
  }
41
43
  };
44
+ }
42
45
  /*
43
46
  * `retry` 와 앞으로 늘어날 갈래. **이유를 지어내지 않는다** — 이유 칸이 없는 답이 오면 그 사실을
44
47
  * 그대로 적는다. 「알 수 없음」을 로그가 말하는 것과, 없는 이유를 우리가 채워 넣는 것은 다르다.
@@ -1 +1 @@
1
- {"version":3,"file":"row-effect.js","sourceRoot":"","sources":["../../../server/service/outbox/row-effect.ts"],"names":[],"mappings":";;AAqDA,kCAqDC;AAtDD,uDAAuD;AACvD,SAAgB,WAAW,CAAC,OAAwB,EAAE,KAAa;IACjE,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACrB,KAAK,UAAU,CAAC;QAChB,KAAK,SAAS,CAAC;QACf,KAAK,KAAK;YACR,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,KAAK,EAAE,CAAA;QAEnD,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,MAAM;gBACZ,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,eAAe,EAAE,IAAI;gBACrB,GAAG,EAAE;oBACH,KAAK,EAAE,OAAO;oBACd,IAAI,EAAE,aAAa,OAAO,CAAC,MAAM,QAAQ,KAAK,uBAAuB;iBACtE;aACF,CAAA;QAEH,KAAK,cAAc;YACjB,OAAO;gBACL,IAAI,EAAE,MAAM;gBACZ,YAAY,EAAE,OAAO,CAAC,YAAY;gBAClC,eAAe,EAAE,KAAK;gBACtB,GAAG,EAAE;oBACH,KAAK,EAAE,MAAM;oBACb,IAAI,EACF,2CAA2C,KAAK,iBAAiB;wBACjE,GAAG,OAAO,CAAC,YAAY,sDAAsD;iBAChF;aACF,CAAA;QAEH,KAAK,cAAc;YACjB,OAAO;gBACL,IAAI,EAAE,SAAS;gBACf,MAAM,EAAE,4BAA4B;gBACpC,eAAe,EAAE,IAAI;gBACrB,GAAG,EAAE;oBACH,KAAK,EAAE,OAAO;oBACd,IAAI,EAAE,+FAA+F;iBACtG;aACF,CAAA;QAEH;;;WAGG;QACH;YACE,OAAO;gBACL,IAAI,EAAE,SAAS;gBACf,MAAM,EAAG,OAA+B,CAAC,MAAM,IAAI,yBAAyB,OAAO,CAAC,IAAI,mBAAmB;gBAC3G,eAAe,EAAE,IAAI;aACtB,CAAA;IACL,CAAC;AACH,CAAC","sourcesContent":["import type { DeliveryOutcome } from './delivery.js'\n\n/**\n * 받는 쪽의 답이 **우리 행에 무슨 일을 하나** — 저장소를 모르는 판단.\n *\n * ═══════════════════════════════════════════════════════════════════════════\n * ## 왜 떼어 냈나 (2026-09-18)\n *\n * 이 판단이 `dispatchPending` 의 `switch` 안에 살았다. 그래서 **어느 시험도 못 지났다** — 그 갈래에\n * 닿으려면 EntityManager 와 503 을 주는 받는 쪽이 있어야 하고, 이 꾸러미의 시험은 가볍게 import 하는\n * 것을 규율로 삼는다. 배압 갈래의 주석이 그것을 스스로 적어 두었다(「⚠ 이 줄을 도는 시험이 없다 …\n * outcome→동작 판단을 떼어 내는 사람이 그때 못 박아라」). 이 파일이 그 자리다.\n *\n * ## 배압만 시도로 세지 않는다\n *\n * ```\n * accepted · partial · gap 범위를 갈라 표시한다 — 어디까지 닿았나는 부르는 쪽이 안다\n * stop 다시 보내도 같다 → 실패로 두고 흐름에서 뺀다\n * backpressure 「나중에 오라」 → 아무것도 안 바꾼다. 시도로 세지 않는다\n * unauthorized · retry · 그 밖 시도 횟수와 이유만 남기고 상태는 둔다 — 다음에 다시 보낸다\n * ```\n *\n * 배압을 시도로 세면 **기다려 달라는 받는 쪽에게 재시도 예산을 쓴다.** 그래서 안 센다. 대가는\n * `attempts: 0` 이 증거가 못 되는 것이다 — 백 번 붙들려도 행은 `attempts 0` 이고 `lastError` 가\n * 없다. 그 사실을 아는 유일한 자리가 로그 한 줄이라, **세지 않고 되풀이된다**: 한 줄은 정상이고\n * 벽처럼 쌓이면 영영 못 가는 보내는 쪽이다.\n *\n * ## `stop` 은 상태까지 바꾼다\n *\n * 시도 횟수만 남기고 상태를 안 바꿔 두 가지가 났다(2026-09-06). 매 주기 다시 보내 seq 1~20 이\n * 시도 382회가 됐고, 나중 배치에 딸려 들어가 `SENT` 로 찍혔다 — **받는 쪽에는 없는데 우리 표는 다\n * 보냈다고 말한다.** 그래서 이 갈래는 실패로 두고 흐름에서 뺀다. 봉투는 그대로 남으므로 backfill 은\n * `since: 0` 으로 다시 물어 가면 된다.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\nexport type RowEffect =\n /** 어디까지 닿았는지로 갈라 표시한다. 범위는 `outcome` 이 들고 있고 부르는 쪽이 읽는다. */\n | { rows: 'settle'; countsAsAttempt: false }\n /** 상태를 실패로 바꾸고 시도도 남긴다 — 다시 보내지 않는다. */\n | { rows: 'fail'; reason: string; countsAsAttempt: true; say: SayIt }\n /** 시도 횟수와 이유만 남긴다. 상태는 그대로 — 다음 주기에 다시 보낸다. */\n | { rows: 'attempt'; reason: string; countsAsAttempt: true; say?: SayIt }\n /** 아무것도 안 바꾼다. **시도로 세지 않는다.** */\n | { rows: 'hold'; retryAfterMs: number; countsAsAttempt: false; say: SayIt }\n\n/** 남길 말 — 수준까지 판단에 든다. 부르는 쪽은 고르지 않는다. */\nexport interface SayIt {\n level: 'info' | 'warn' | 'error'\n what: string\n}\n\n/** 이 답이 우리 행에 하는 일. `count` 는 이 배치의 봉투 수이고 말에만 쓰인다. */\nexport function rowEffectOf(outcome: DeliveryOutcome, count: number): RowEffect {\n switch (outcome.kind) {\n case 'accepted':\n case 'partial':\n case 'gap':\n return { rows: 'settle', countsAsAttempt: false }\n\n case 'stop':\n return {\n rows: 'fail',\n reason: outcome.reason,\n countsAsAttempt: true,\n say: {\n level: 'error',\n what: `[webhook] ${outcome.reason} — 이 ${count}건을 실패로 두고 다시 보내지 않습니다`\n }\n }\n\n case 'backpressure':\n return {\n rows: 'hold',\n retryAfterMs: outcome.retryAfterMs,\n countsAsAttempt: false,\n say: {\n level: 'warn',\n what:\n `[webhook] the receiver asked us to hold ${count} record(s) for ` +\n `${outcome.retryAfterMs}ms — held, not failed, and not counted as an attempt`\n }\n }\n\n case 'unauthorized':\n return {\n rows: 'attempt',\n reason: 'signature was not accepted',\n countsAsAttempt: true,\n say: {\n level: 'error',\n what: '[webhook] the receiver did not accept our signature — check the shared secret before retrying'\n }\n }\n\n /*\n * `retry` 와 앞으로 늘어날 갈래. **이유를 지어내지 않는다** — 이유 칸이 없는 답이 오면 그 사실을\n * 그대로 적는다. 「알 수 없음」을 로그가 말하는 것과, 없는 이유를 우리가 채워 넣는 것은 다르다.\n */\n default:\n return {\n rows: 'attempt',\n reason: (outcome as { reason?: string }).reason ?? `the receiver answered ${outcome.kind} without a reason`,\n countsAsAttempt: true\n }\n }\n}\n"]}
1
+ {"version":3,"file":"row-effect.js","sourceRoot":"","sources":["../../../server/service/outbox/row-effect.ts"],"names":[],"mappings":";;AAqDA,kCAyDC;AA1DD,uDAAuD;AACvD,SAAgB,WAAW,CAAC,OAAwB,EAAE,KAAa;IACjE,QAAQ,OAAO,CAAC,IAAI,EAAE,CAAC;QACrB,KAAK,UAAU,CAAC;QAChB,KAAK,SAAS,CAAC;QACf,KAAK,KAAK;YACR,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,KAAK,EAAE,CAAA;QAEnD,KAAK,MAAM;YACT,OAAO;gBACL,IAAI,EAAE,MAAM;gBACZ,MAAM,EAAE,OAAO,CAAC,MAAM;gBACtB,eAAe,EAAE,IAAI;gBACrB,GAAG,EAAE;oBACH,KAAK,EAAE,OAAO;oBACd,IAAI,EAAE,aAAa,OAAO,CAAC,MAAM,QAAQ,KAAK,uBAAuB;iBACtE;aACF,CAAA;QAEH,KAAK,cAAc;YACjB,OAAO;gBACL,IAAI,EAAE,MAAM;gBACZ,YAAY,EAAE,OAAO,CAAC,YAAY;gBAClC,eAAe,EAAE,KAAK;gBACtB,GAAG,EAAE;oBACH,KAAK,EAAE,MAAM;oBACb,IAAI,EACF,2CAA2C,KAAK,iBAAiB;wBACjE,GAAG,OAAO,CAAC,YAAY,sDAAsD;iBAChF;aACF,CAAA;QAEH,KAAK,cAAc,CAAC,CAAC,CAAC;YACpB,gGAAgG;YAChG,MAAM,IAAI,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;YAEzD,OAAO;gBACL,IAAI,EAAE,SAAS;gBACf,MAAM,EAAE,6BAA6B,IAAI,EAAE;gBAC3C,eAAe,EAAE,IAAI;gBACrB,GAAG,EAAE;oBACH,KAAK,EAAE,OAAO;oBACd,IAAI,EAAE,sDAAsD,IAAI,4CAA4C;iBAC7G;aACF,CAAA;QACH,CAAC;QAED;;;WAGG;QACH;YACE,OAAO;gBACL,IAAI,EAAE,SAAS;gBACf,MAAM,EAAG,OAA+B,CAAC,MAAM,IAAI,yBAAyB,OAAO,CAAC,IAAI,mBAAmB;gBAC3G,eAAe,EAAE,IAAI;aACtB,CAAA;IACL,CAAC;AACH,CAAC","sourcesContent":["import type { DeliveryOutcome } from './delivery.js'\n\n/**\n * 받는 쪽의 답이 **우리 행에 무슨 일을 하나** — 저장소를 모르는 판단.\n *\n * ═══════════════════════════════════════════════════════════════════════════\n * ## 왜 떼어 냈나 (2026-09-18)\n *\n * 이 판단이 `dispatchPending` 의 `switch` 안에 살았다. 그래서 **어느 시험도 못 지났다** — 그 갈래에\n * 닿으려면 EntityManager 와 503 을 주는 받는 쪽이 있어야 하고, 이 꾸러미의 시험은 가볍게 import 하는\n * 것을 규율로 삼는다. 배압 갈래의 주석이 그것을 스스로 적어 두었다(「⚠ 이 줄을 도는 시험이 없다 …\n * outcome→동작 판단을 떼어 내는 사람이 그때 못 박아라」). 이 파일이 그 자리다.\n *\n * ## 배압만 시도로 세지 않는다\n *\n * ```\n * accepted · partial · gap 범위를 갈라 표시한다 — 어디까지 닿았나는 부르는 쪽이 안다\n * stop 다시 보내도 같다 → 실패로 두고 흐름에서 뺀다\n * backpressure 「나중에 오라」 → 아무것도 안 바꾼다. 시도로 세지 않는다\n * unauthorized · retry · 그 밖 시도 횟수와 이유만 남기고 상태는 둔다 — 다음에 다시 보낸다\n * ```\n *\n * 배압을 시도로 세면 **기다려 달라는 받는 쪽에게 재시도 예산을 쓴다.** 그래서 안 센다. 대가는\n * `attempts: 0` 이 증거가 못 되는 것이다 — 백 번 붙들려도 행은 `attempts 0` 이고 `lastError` 가\n * 없다. 그 사실을 아는 유일한 자리가 로그 한 줄이라, **세지 않고 되풀이된다**: 한 줄은 정상이고\n * 벽처럼 쌓이면 영영 못 가는 보내는 쪽이다.\n *\n * ## `stop` 은 상태까지 바꾼다\n *\n * 시도 횟수만 남기고 상태를 안 바꿔 두 가지가 났다(2026-09-06). 매 주기 다시 보내 seq 1~20 이\n * 시도 382회가 됐고, 나중 배치에 딸려 들어가 `SENT` 로 찍혔다 — **받는 쪽에는 없는데 우리 표는 다\n * 보냈다고 말한다.** 그래서 이 갈래는 실패로 두고 흐름에서 뺀다. 봉투는 그대로 남으므로 backfill 은\n * `since: 0` 으로 다시 물어 가면 된다.\n * ═══════════════════════════════════════════════════════════════════════════\n */\n\nexport type RowEffect =\n /** 어디까지 닿았는지로 갈라 표시한다. 범위는 `outcome` 이 들고 있고 부르는 쪽이 읽는다. */\n | { rows: 'settle'; countsAsAttempt: false }\n /** 상태를 실패로 바꾸고 시도도 남긴다 — 다시 보내지 않는다. */\n | { rows: 'fail'; reason: string; countsAsAttempt: true; say: SayIt }\n /** 시도 횟수와 이유만 남긴다. 상태는 그대로 — 다음 주기에 다시 보낸다. */\n | { rows: 'attempt'; reason: string; countsAsAttempt: true; say?: SayIt }\n /** 아무것도 안 바꾼다. **시도로 세지 않는다.** */\n | { rows: 'hold'; retryAfterMs: number; countsAsAttempt: false; say: SayIt }\n\n/** 남길 말 — 수준까지 판단에 든다. 부르는 쪽은 고르지 않는다. */\nexport interface SayIt {\n level: 'info' | 'warn' | 'error'\n what: string\n}\n\n/** 이 답이 우리 행에 하는 일. `count` 는 이 배치의 봉투 수이고 말에만 쓰인다. */\nexport function rowEffectOf(outcome: DeliveryOutcome, count: number): RowEffect {\n switch (outcome.kind) {\n case 'accepted':\n case 'partial':\n case 'gap':\n return { rows: 'settle', countsAsAttempt: false }\n\n case 'stop':\n return {\n rows: 'fail',\n reason: outcome.reason,\n countsAsAttempt: true,\n say: {\n level: 'error',\n what: `[webhook] ${outcome.reason} — 이 ${count}건을 실패로 두고 다시 보내지 않습니다`\n }\n }\n\n case 'backpressure':\n return {\n rows: 'hold',\n retryAfterMs: outcome.retryAfterMs,\n countsAsAttempt: false,\n say: {\n level: 'warn',\n what:\n `[webhook] the receiver asked us to hold ${count} record(s) for ` +\n `${outcome.retryAfterMs}ms — held, not failed, and not counted as an attempt`\n }\n }\n\n case 'unauthorized': {\n /* What the receiver said decides where to look: a mismatch is the key, `expired` is a clock. */\n const said = outcome.reason ? ` — ${outcome.reason}` : ''\n\n return {\n rows: 'attempt',\n reason: `signature was not accepted${said}`,\n countsAsAttempt: true,\n say: {\n level: 'error',\n what: `[webhook] the receiver did not accept our signature${said} — check the shared secret before retrying`\n }\n }\n }\n\n /*\n * `retry` 와 앞으로 늘어날 갈래. **이유를 지어내지 않는다** — 이유 칸이 없는 답이 오면 그 사실을\n * 그대로 적는다. 「알 수 없음」을 로그가 말하는 것과, 없는 이유를 우리가 채워 넣는 것은 다르다.\n */\n default:\n return {\n rows: 'attempt',\n reason: (outcome as { reason?: string }).reason ?? `the receiver answered ${outcome.kind} without a reason`,\n countsAsAttempt: true\n }\n }\n}\n"]}