settld 0.2.4 → 0.2.6

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.
Files changed (128) hide show
  1. package/Dockerfile +2 -2
  2. package/docs/CONFIG.md +12 -0
  3. package/docs/README.md +3 -0
  4. package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
  5. package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
  6. package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
  7. package/package.json +4 -1
  8. package/packages/api-sdk/README.md +71 -0
  9. package/packages/api-sdk/src/client.js +1021 -0
  10. package/packages/api-sdk/src/express-middleware.js +163 -0
  11. package/packages/api-sdk/src/index.d.ts +1662 -0
  12. package/packages/api-sdk/src/index.js +10 -0
  13. package/packages/api-sdk/src/webhook-signature.js +182 -0
  14. package/packages/api-sdk/src/x402-autopay.js +210 -0
  15. package/scripts/ci/cli-pack-smoke.mjs +2 -0
  16. package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
  17. package/scripts/setup/login.mjs +73 -2
  18. package/scripts/setup/onboard.mjs +173 -28
  19. package/scripts/setup/onboarding-failure-taxonomy.mjs +107 -0
  20. package/scripts/setup/onboarding-state-machine.mjs +102 -0
  21. package/services/magic-link/README.md +352 -0
  22. package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
  23. package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
  24. package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
  25. package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
  26. package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
  27. package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
  28. package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
  29. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
  30. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
  31. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
  32. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
  33. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
  34. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
  35. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
  36. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
  37. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
  38. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
  39. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
  40. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
  41. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
  42. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
  43. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
  44. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
  45. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
  46. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
  47. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
  48. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
  49. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
  50. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
  51. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
  52. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
  53. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
  54. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
  55. package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
  56. package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
  57. package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
  58. package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
  59. package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
  60. package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
  61. package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
  62. package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
  63. package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
  64. package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
  65. package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
  66. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
  67. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
  68. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
  69. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
  70. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
  71. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
  72. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
  73. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
  74. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
  75. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
  76. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
  77. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
  78. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
  79. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
  80. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
  81. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
  82. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
  83. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
  84. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
  85. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
  86. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
  87. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
  88. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
  89. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
  90. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
  91. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
  92. package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
  93. package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
  94. package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
  95. package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
  96. package/services/magic-link/assets/samples/trust.json +11 -0
  97. package/services/magic-link/src/audit-log.js +24 -0
  98. package/services/magic-link/src/buyer-auth.js +251 -0
  99. package/services/magic-link/src/buyer-notifications.js +402 -0
  100. package/services/magic-link/src/buyer-users.js +129 -0
  101. package/services/magic-link/src/decision-otp.js +187 -0
  102. package/services/magic-link/src/decisions.js +92 -0
  103. package/services/magic-link/src/email-resend.js +89 -0
  104. package/services/magic-link/src/ingest-keys.js +137 -0
  105. package/services/magic-link/src/maintenance.js +95 -0
  106. package/services/magic-link/src/onboarding-email-sequence.js +331 -0
  107. package/services/magic-link/src/payment-triggers.js +733 -0
  108. package/services/magic-link/src/pdf.js +149 -0
  109. package/services/magic-link/src/policy.js +69 -0
  110. package/services/magic-link/src/redaction.js +6 -0
  111. package/services/magic-link/src/render-model.js +70 -0
  112. package/services/magic-link/src/retention-gc.js +158 -0
  113. package/services/magic-link/src/run-records.js +496 -0
  114. package/services/magic-link/src/s3.js +171 -0
  115. package/services/magic-link/src/server.js +15849 -0
  116. package/services/magic-link/src/settlement-decisions.js +84 -0
  117. package/services/magic-link/src/smtp.js +217 -0
  118. package/services/magic-link/src/storage-cli.js +88 -0
  119. package/services/magic-link/src/storage-format.js +59 -0
  120. package/services/magic-link/src/tenant-billing.js +115 -0
  121. package/services/magic-link/src/tenant-onboarding.js +467 -0
  122. package/services/magic-link/src/tenant-settings.js +1140 -0
  123. package/services/magic-link/src/usage.js +80 -0
  124. package/services/magic-link/src/verify-queue.js +179 -0
  125. package/services/magic-link/src/verify-worker.js +157 -0
  126. package/services/magic-link/src/webhook-retries.js +542 -0
  127. package/services/magic-link/src/webhooks.js +218 -0
  128. package/src/api/app.js +135 -1
@@ -0,0 +1,542 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { deliverTenantWebhooks } from "./webhooks.js";
6
+
7
+ function nowIso() {
8
+ return new Date().toISOString();
9
+ }
10
+
11
+ function isPlainObject(v) {
12
+ return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
13
+ }
14
+
15
+ function safeInt(value, fallback) {
16
+ const n = Number.parseInt(String(value ?? ""), 10);
17
+ if (!Number.isInteger(n)) return fallback;
18
+ return n;
19
+ }
20
+
21
+ function sha256Hex(value) {
22
+ return crypto.createHash("sha256").update(String(value ?? ""), "utf8").digest("hex");
23
+ }
24
+
25
+ function webhookPayloadHashHex(payload) {
26
+ return crypto.createHash("sha256").update(JSON.stringify(payload ?? {}), "utf8").digest("hex");
27
+ }
28
+
29
+ function clampRetryConfig({ maxAttempts, backoffMs } = {}) {
30
+ return {
31
+ maxAttempts: Math.max(1, Math.min(50, safeInt(maxAttempts, 3))),
32
+ backoffMs: Math.max(0, Math.min(86_400_000, safeInt(backoffMs, 250)))
33
+ };
34
+ }
35
+
36
+ function nextRetryBackoffMs({ baseMs, attempt }) {
37
+ const exp = Math.max(0, Math.min(16, Number(attempt ?? 1) - 1));
38
+ return Math.min(86_400_000, Math.max(0, Number(baseMs ?? 0)) * (2 ** exp));
39
+ }
40
+
41
+ function retryPendingDir(dataDir) {
42
+ return path.join(dataDir, "webhook_retry", "pending");
43
+ }
44
+
45
+ function retryDeadLetterDir(dataDir) {
46
+ return path.join(dataDir, "webhook_retry", "dead-letter");
47
+ }
48
+
49
+ function retryJobId({ tenantId, token, idempotencyKey }) {
50
+ const hash = sha256Hex(`${tenantId}\n${token}\n${idempotencyKey}`).slice(0, 24);
51
+ return `${tenantId}_${token}_${hash}`;
52
+ }
53
+
54
+ function retryPendingPath({ dataDir, tenantId, token, idempotencyKey }) {
55
+ return path.join(retryPendingDir(dataDir), `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
56
+ }
57
+
58
+ function retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey }) {
59
+ return path.join(retryDeadLetterDir(dataDir), `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
60
+ }
61
+
62
+ async function readJsonIfExists(fp) {
63
+ try {
64
+ const raw = await fs.readFile(fp, "utf8");
65
+ const parsed = JSON.parse(raw);
66
+ return isPlainObject(parsed) ? parsed : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ async function fileExists(fp) {
73
+ try {
74
+ await fs.access(fp);
75
+ return true;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+
81
+ function normalizeRetryJobSummary({ job, state }) {
82
+ if (!isPlainObject(job)) return null;
83
+ const attempts = Array.isArray(job.attempts) ? job.attempts : [];
84
+ const lastAttempt = attempts.length ? attempts[attempts.length - 1] : null;
85
+ const webhook = isPlainObject(job.webhook) ? job.webhook : null;
86
+ return {
87
+ schemaVersion: "MagicLinkWebhookRetrySummary.v1",
88
+ state,
89
+ tenantId: typeof job.tenantId === "string" ? job.tenantId : null,
90
+ token: typeof job.token === "string" ? job.token : null,
91
+ event: typeof job.event === "string" ? job.event : null,
92
+ idempotencyKey: typeof job.idempotencyKey === "string" ? job.idempotencyKey : null,
93
+ webhookUrl: typeof webhook?.url === "string" ? webhook.url : null,
94
+ attemptCount: Math.max(0, safeInt(job.attemptCount, 0)),
95
+ maxAttempts: Math.max(1, safeInt(job.maxAttempts, 1)),
96
+ nextAttemptAt: typeof job.nextAttemptAt === "string" ? job.nextAttemptAt : null,
97
+ enqueuedAt: typeof job.enqueuedAt === "string" ? job.enqueuedAt : null,
98
+ updatedAt: typeof job.updatedAt === "string" ? job.updatedAt : null,
99
+ deadLetteredAt: typeof job.deadLetteredAt === "string" ? job.deadLetteredAt : null,
100
+ replayCount: Math.max(0, safeInt(job.replayCount, 0)),
101
+ lastError:
102
+ typeof job.lastError === "string" && job.lastError
103
+ ? job.lastError
104
+ : typeof lastAttempt?.error === "string" && lastAttempt.error
105
+ ? lastAttempt.error
106
+ : null,
107
+ lastStatusCode:
108
+ Number.isFinite(Number(job.lastStatusCode))
109
+ ? Number(job.lastStatusCode)
110
+ : Number.isFinite(Number(lastAttempt?.statusCode))
111
+ ? Number(lastAttempt.statusCode)
112
+ : null
113
+ };
114
+ }
115
+
116
+ function resolveWebhookByResult({ webhooks, result, event }) {
117
+ const list = Array.isArray(webhooks) ? webhooks : [];
118
+ const idx = safeInt(result?.webhookIndex, -1);
119
+ if (idx >= 0 && idx < list.length && isPlainObject(list[idx])) return { webhook: list[idx], webhookIndex: idx };
120
+ const url = typeof result?.url === "string" ? result.url.trim() : "";
121
+ if (!url) return { webhook: null, webhookIndex: -1 };
122
+ for (let i = 0; i < list.length; i += 1) {
123
+ const w = list[i];
124
+ if (!isPlainObject(w)) continue;
125
+ if (!w.enabled) continue;
126
+ const events = Array.isArray(w.events) ? w.events.map((x) => String(x ?? "").trim()) : [];
127
+ const webhookUrl = typeof w.url === "string" ? w.url.trim() : "";
128
+ if (!webhookUrl || webhookUrl !== url) continue;
129
+ if (!events.includes(String(event ?? ""))) continue;
130
+ return { webhook: w, webhookIndex: i };
131
+ }
132
+ return { webhook: null, webhookIndex: -1 };
133
+ }
134
+
135
+ export function webhookRetryIdempotencyKey({ tenantId, token, event, webhookUrl, payload }) {
136
+ const payloadHash = webhookPayloadHashHex(payload);
137
+ return sha256Hex(`${tenantId}\n${token}\n${event}\n${webhookUrl}\n${payloadHash}`);
138
+ }
139
+
140
+ export async function enqueueWebhookRetryJobs({
141
+ dataDir,
142
+ tenantId,
143
+ token,
144
+ event,
145
+ payload,
146
+ webhooks,
147
+ deliveryResults,
148
+ maxAttempts = 3,
149
+ backoffMs = 250
150
+ } = {}) {
151
+ const t = String(tenantId ?? "").trim();
152
+ const tk = String(token ?? "").trim();
153
+ const ev = String(event ?? "").trim();
154
+ if (!t || !tk || !ev) {
155
+ return { ok: false, enqueued: 0, deadLettered: 0, skipped: 0, error: "INVALID_INPUT" };
156
+ }
157
+
158
+ const retry = clampRetryConfig({ maxAttempts, backoffMs });
159
+ const list = Array.isArray(deliveryResults) ? deliveryResults : [];
160
+ const out = [];
161
+ let enqueued = 0;
162
+ let deadLettered = 0;
163
+ let skipped = 0;
164
+
165
+ for (const result of list) {
166
+ if (!isPlainObject(result)) continue;
167
+ if (result.ok) continue;
168
+ const resolved = resolveWebhookByResult({ webhooks, result, event: ev });
169
+ const webhook = resolved.webhook;
170
+ if (!webhook) {
171
+ skipped += 1;
172
+ out.push({ ok: false, skipped: true, reason: "WEBHOOK_NOT_RESOLVED" });
173
+ continue;
174
+ }
175
+ const webhookUrl = typeof webhook.url === "string" ? webhook.url.trim() : "";
176
+ if (!webhookUrl) {
177
+ skipped += 1;
178
+ out.push({ ok: false, skipped: true, reason: "WEBHOOK_URL_EMPTY" });
179
+ continue;
180
+ }
181
+
182
+ const idempotencyKey = webhookRetryIdempotencyKey({ tenantId: t, token: tk, event: ev, webhookUrl, payload });
183
+ const pendingPath = retryPendingPath({ dataDir, tenantId: t, token: tk, idempotencyKey });
184
+ const deadPath = retryDeadLetterPath({ dataDir, tenantId: t, token: tk, idempotencyKey });
185
+ // Do not create duplicate retry jobs for the same delivery unit.
186
+ // eslint-disable-next-line no-await-in-loop
187
+ const pendingExists = await fileExists(pendingPath);
188
+ // eslint-disable-next-line no-await-in-loop
189
+ const deadExists = await fileExists(deadPath);
190
+ if (pendingExists || deadExists) {
191
+ skipped += 1;
192
+ out.push({ ok: false, skipped: true, reason: pendingExists ? "PENDING_EXISTS" : "DEAD_LETTER_EXISTS", idempotencyKey });
193
+ continue;
194
+ }
195
+
196
+ const initialAttemptNumber = Math.max(1, safeInt(result.attempts, 1));
197
+ const initialAttempt = {
198
+ at: nowIso(),
199
+ ok: false,
200
+ statusCode: Number.isFinite(Number(result.statusCode)) ? Number(result.statusCode) : null,
201
+ error: typeof result.error === "string" && result.error ? result.error : "WEBHOOK_DELIVERY_FAILED",
202
+ source: "inline",
203
+ attemptNumber: initialAttemptNumber
204
+ };
205
+
206
+ const job = {
207
+ schemaVersion: "MagicLinkWebhookRetryJob.v1",
208
+ tenantId: t,
209
+ token: tk,
210
+ event: ev,
211
+ idempotencyKey,
212
+ payload,
213
+ webhook: {
214
+ url: webhookUrl,
215
+ events: Array.isArray(webhook.events) ? webhook.events.map((x) => String(x ?? "").trim()).filter(Boolean) : [ev],
216
+ enabled: true,
217
+ secret: typeof webhook.secret === "string" && webhook.secret ? webhook.secret : null
218
+ },
219
+ maxAttempts: retry.maxAttempts,
220
+ backoffMs: retry.backoffMs,
221
+ attemptCount: initialAttemptNumber,
222
+ nextAttemptAt: null,
223
+ attempts: [initialAttempt],
224
+ lastError: initialAttempt.error,
225
+ lastStatusCode: initialAttempt.statusCode,
226
+ enqueuedAt: nowIso(),
227
+ updatedAt: nowIso(),
228
+ replayCount: 0
229
+ };
230
+
231
+ if (initialAttemptNumber >= retry.maxAttempts) {
232
+ const dead = { ...job, deadLetteredAt: nowIso(), updatedAt: nowIso() };
233
+ // eslint-disable-next-line no-await-in-loop
234
+ await fs.mkdir(path.dirname(deadPath), { recursive: true });
235
+ // eslint-disable-next-line no-await-in-loop
236
+ await fs.writeFile(deadPath, JSON.stringify(dead, null, 2) + "\n", "utf8");
237
+ deadLettered += 1;
238
+ out.push({
239
+ ok: false,
240
+ queued: false,
241
+ deadLettered: true,
242
+ idempotencyKey,
243
+ token: tk,
244
+ event: ev,
245
+ webhookUrl,
246
+ attemptCount: initialAttemptNumber,
247
+ maxAttempts: retry.maxAttempts,
248
+ reason: "MAX_ATTEMPTS_EXHAUSTED_INLINE"
249
+ });
250
+ continue;
251
+ }
252
+
253
+ const nextMs = Date.now() + nextRetryBackoffMs({ baseMs: retry.backoffMs, attempt: initialAttemptNumber });
254
+ job.nextAttemptAt = new Date(nextMs).toISOString();
255
+ // eslint-disable-next-line no-await-in-loop
256
+ await fs.mkdir(path.dirname(pendingPath), { recursive: true });
257
+ // eslint-disable-next-line no-await-in-loop
258
+ await fs.writeFile(pendingPath, JSON.stringify(job, null, 2) + "\n", "utf8");
259
+ enqueued += 1;
260
+ out.push({
261
+ ok: true,
262
+ queued: true,
263
+ idempotencyKey,
264
+ token: tk,
265
+ event: ev,
266
+ webhookUrl,
267
+ attemptCount: initialAttemptNumber,
268
+ maxAttempts: retry.maxAttempts,
269
+ nextAttemptAt: job.nextAttemptAt
270
+ });
271
+ }
272
+
273
+ return { ok: true, enqueued, deadLettered, skipped, rows: out };
274
+ }
275
+
276
+ export async function processWebhookRetryQueueOnce({
277
+ dataDir,
278
+ settingsKey = null,
279
+ timeoutMs = 5_000,
280
+ nowMs = Date.now(),
281
+ tenantIdFilter = null
282
+ } = {}) {
283
+ const stats = { scanned: 0, skipped: 0, retried: 0, delivered: 0, deadLettered: 0, failed: 0 };
284
+ let names = [];
285
+ try {
286
+ names = (await fs.readdir(retryPendingDir(dataDir))).filter((name) => name.endsWith(".json")).sort();
287
+ } catch {
288
+ return stats;
289
+ }
290
+
291
+ for (const name of names) {
292
+ stats.scanned += 1;
293
+ const fp = path.join(retryPendingDir(dataDir), name);
294
+ let job = null;
295
+ try {
296
+ // eslint-disable-next-line no-await-in-loop
297
+ job = JSON.parse(await fs.readFile(fp, "utf8"));
298
+ } catch {
299
+ stats.failed += 1;
300
+ continue;
301
+ }
302
+ if (!isPlainObject(job)) {
303
+ stats.failed += 1;
304
+ continue;
305
+ }
306
+
307
+ const tenantId = typeof job.tenantId === "string" ? job.tenantId : "";
308
+ if (tenantIdFilter && tenantId !== tenantIdFilter) {
309
+ stats.skipped += 1;
310
+ continue;
311
+ }
312
+
313
+ const nextAt = Date.parse(String(job.nextAttemptAt ?? ""));
314
+ if (Number.isFinite(nextAt) && nextAt > nowMs) {
315
+ stats.skipped += 1;
316
+ continue;
317
+ }
318
+
319
+ const token = typeof job.token === "string" ? job.token : "";
320
+ const event = typeof job.event === "string" ? job.event : "";
321
+ const idempotencyKey = typeof job.idempotencyKey === "string" ? job.idempotencyKey : "";
322
+ const payload = isPlainObject(job.payload) ? job.payload : null;
323
+ const webhook = isPlainObject(job.webhook) ? job.webhook : null;
324
+ if (!tenantId || !token || !event || !idempotencyKey || !payload || !webhook) {
325
+ stats.failed += 1;
326
+ continue;
327
+ }
328
+
329
+ const deliveryRows =
330
+ // eslint-disable-next-line no-await-in-loop
331
+ await deliverTenantWebhooks({
332
+ dataDir,
333
+ tenantId,
334
+ token,
335
+ event,
336
+ payload,
337
+ webhooks: [webhook],
338
+ settingsKey,
339
+ deliveryMode: "http",
340
+ timeoutMs,
341
+ maxAttempts: 1,
342
+ retryBackoffMs: 0
343
+ });
344
+ const delivered = Array.isArray(deliveryRows) && deliveryRows.length ? deliveryRows[0] : { ok: false, statusCode: null, error: "WEBHOOK_RETRY_DELIVERY_SKIPPED" };
345
+
346
+ const attemptCount = Math.max(0, safeInt(job.attemptCount, 0));
347
+ const nextAttemptNumber = attemptCount + 1;
348
+ const attempts = Array.isArray(job.attempts) ? [...job.attempts] : [];
349
+ attempts.push({
350
+ at: nowIso(),
351
+ ok: Boolean(delivered.ok),
352
+ statusCode: Number.isFinite(Number(delivered.statusCode)) ? Number(delivered.statusCode) : null,
353
+ error: typeof delivered.error === "string" && delivered.error ? delivered.error : null,
354
+ source: "retry_worker",
355
+ attemptNumber: nextAttemptNumber
356
+ });
357
+
358
+ if (delivered.ok) {
359
+ // eslint-disable-next-line no-await-in-loop
360
+ await fs.rm(fp, { force: true });
361
+ stats.delivered += 1;
362
+ continue;
363
+ }
364
+
365
+ const maxAttempts = Math.max(1, safeInt(job.maxAttempts, 3));
366
+ const backoffMs = Math.max(0, safeInt(job.backoffMs, 250));
367
+ const updated = {
368
+ ...job,
369
+ attemptCount: nextAttemptNumber,
370
+ attempts,
371
+ lastError: typeof delivered.error === "string" && delivered.error ? delivered.error : null,
372
+ lastStatusCode: Number.isFinite(Number(delivered.statusCode)) ? Number(delivered.statusCode) : null,
373
+ updatedAt: nowIso()
374
+ };
375
+
376
+ if (nextAttemptNumber >= maxAttempts) {
377
+ const deadPath = retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey });
378
+ const dead = { ...updated, deadLetteredAt: nowIso(), updatedAt: nowIso() };
379
+ // eslint-disable-next-line no-await-in-loop
380
+ await fs.mkdir(path.dirname(deadPath), { recursive: true });
381
+ // eslint-disable-next-line no-await-in-loop
382
+ await fs.writeFile(deadPath, JSON.stringify(dead, null, 2) + "\n", "utf8");
383
+ // eslint-disable-next-line no-await-in-loop
384
+ await fs.rm(fp, { force: true });
385
+ stats.deadLettered += 1;
386
+ continue;
387
+ }
388
+
389
+ const nextMs = nowMs + nextRetryBackoffMs({ baseMs: backoffMs, attempt: nextAttemptNumber });
390
+ updated.nextAttemptAt = new Date(nextMs).toISOString();
391
+ // eslint-disable-next-line no-await-in-loop
392
+ await fs.writeFile(fp, JSON.stringify(updated, null, 2) + "\n", "utf8");
393
+ stats.retried += 1;
394
+ }
395
+
396
+ return stats;
397
+ }
398
+
399
+ export function startWebhookRetryWorker({
400
+ dataDir,
401
+ settingsKey = null,
402
+ timeoutMs = 5_000,
403
+ intervalMs = 2_000,
404
+ onRetry = null,
405
+ onDeadLetter = null,
406
+ onDelivered = null,
407
+ onDepth = null
408
+ } = {}) {
409
+ const cadence = Math.max(100, safeInt(intervalMs, 2_000));
410
+ let running = false;
411
+ const timer = setInterval(async () => {
412
+ if (running) return;
413
+ running = true;
414
+ try {
415
+ const stats = await processWebhookRetryQueueOnce({ dataDir, settingsKey, timeoutMs });
416
+ if (typeof onRetry === "function" && stats.retried > 0) onRetry(stats.retried, stats);
417
+ if (typeof onDeadLetter === "function" && stats.deadLettered > 0) onDeadLetter(stats.deadLettered, stats);
418
+ if (typeof onDelivered === "function" && stats.delivered > 0) onDelivered(stats.delivered, stats);
419
+ if (typeof onDepth === "function") onDepth(await webhookRetryQueueDepth({ dataDir }), stats);
420
+ } finally {
421
+ running = false;
422
+ }
423
+ }, cadence);
424
+ if (typeof timer.unref === "function") timer.unref();
425
+ return {
426
+ stop() {
427
+ clearInterval(timer);
428
+ }
429
+ };
430
+ }
431
+
432
+ export async function webhookRetryQueueDepth({ dataDir } = {}) {
433
+ try {
434
+ const names = (await fs.readdir(retryPendingDir(dataDir))).filter((name) => name.endsWith(".json"));
435
+ return names.length;
436
+ } catch {
437
+ return 0;
438
+ }
439
+ }
440
+
441
+ export async function listWebhookRetryJobs({
442
+ dataDir,
443
+ tenantId = null,
444
+ state = "pending",
445
+ limit = 100
446
+ } = {}) {
447
+ const targetState = state === "dead-letter" ? "dead-letter" : "pending";
448
+ const dir = targetState === "dead-letter" ? retryDeadLetterDir(dataDir) : retryPendingDir(dataDir);
449
+ const capped = Math.max(1, Math.min(5_000, safeInt(limit, 100)));
450
+ let names = [];
451
+ try {
452
+ names = (await fs.readdir(dir)).filter((name) => name.endsWith(".json")).sort();
453
+ } catch {
454
+ return [];
455
+ }
456
+ const rows = [];
457
+ for (const name of names) {
458
+ const fp = path.join(dir, name);
459
+ // eslint-disable-next-line no-await-in-loop
460
+ const job = await readJsonIfExists(fp);
461
+ if (!isPlainObject(job)) continue;
462
+ if (tenantId && String(job.tenantId ?? "") !== tenantId) continue;
463
+ const row = normalizeRetryJobSummary({ job, state: targetState });
464
+ if (!row) continue;
465
+ rows.push(row);
466
+ }
467
+ rows.sort((a, b) => Date.parse(String(b.updatedAt ?? "")) - Date.parse(String(a.updatedAt ?? "")));
468
+ return rows.slice(0, capped);
469
+ }
470
+
471
+ export async function replayWebhookDeadLetterJob({
472
+ dataDir,
473
+ tenantId,
474
+ token,
475
+ idempotencyKey,
476
+ resetAttempts = false,
477
+ tenantSettings = null,
478
+ useCurrentSettings = true
479
+ } = {}) {
480
+ const deadPath = retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey });
481
+ const pendingPath = retryPendingPath({ dataDir, tenantId, token, idempotencyKey });
482
+ const deadJob = await readJsonIfExists(deadPath);
483
+ if (!deadJob) return { ok: false, code: "NOT_FOUND", message: "dead-letter job not found" };
484
+
485
+ const pendingExists = await fileExists(pendingPath);
486
+ if (pendingExists) return { ok: false, code: "PENDING_EXISTS", message: "pending retry job already exists" };
487
+
488
+ const now = nowIso();
489
+ const next = {
490
+ ...deadJob,
491
+ deadLetteredAt: null,
492
+ replayedAt: now,
493
+ replayCount: Math.max(0, safeInt(deadJob.replayCount, 0)) + 1,
494
+ nextAttemptAt: now,
495
+ updatedAt: now
496
+ };
497
+
498
+ if (resetAttempts) {
499
+ next.attemptCount = 0;
500
+ next.attempts = [];
501
+ next.lastError = null;
502
+ next.lastStatusCode = null;
503
+ }
504
+
505
+ if (useCurrentSettings && isPlainObject(tenantSettings)) {
506
+ const webhooks = Array.isArray(tenantSettings.webhooks) ? tenantSettings.webhooks : [];
507
+ const currentUrl = typeof next?.webhook?.url === "string" ? next.webhook.url : null;
508
+ const currentEvent = typeof next.event === "string" ? next.event : null;
509
+ const replacement = webhooks.find((w) => {
510
+ if (!isPlainObject(w)) return false;
511
+ if (!w.enabled) return false;
512
+ const url = typeof w.url === "string" ? w.url.trim() : "";
513
+ if (!url || (currentUrl && url !== currentUrl)) return false;
514
+ const events = Array.isArray(w.events) ? w.events.map((x) => String(x ?? "").trim()) : [];
515
+ return currentEvent ? events.includes(currentEvent) : true;
516
+ });
517
+ if (replacement) {
518
+ next.webhook = {
519
+ url: typeof replacement.url === "string" ? replacement.url.trim() : currentUrl,
520
+ events: Array.isArray(replacement.events) ? replacement.events.map((x) => String(x ?? "").trim()).filter(Boolean) : next?.webhook?.events ?? [],
521
+ enabled: true,
522
+ secret: typeof replacement.secret === "string" && replacement.secret ? replacement.secret : next?.webhook?.secret ?? null
523
+ };
524
+ }
525
+ }
526
+
527
+ await fs.mkdir(path.dirname(pendingPath), { recursive: true });
528
+ await fs.writeFile(pendingPath, JSON.stringify(next, null, 2) + "\n", "utf8");
529
+ await fs.rm(deadPath, { force: true });
530
+
531
+ return {
532
+ ok: true,
533
+ state: "pending",
534
+ tenantId,
535
+ token,
536
+ idempotencyKey,
537
+ attemptCount: Math.max(0, safeInt(next.attemptCount, 0)),
538
+ maxAttempts: Math.max(1, safeInt(next.maxAttempts, 1)),
539
+ nextAttemptAt: typeof next.nextAttemptAt === "string" ? next.nextAttemptAt : now,
540
+ replayCount: Math.max(1, safeInt(next.replayCount, 1))
541
+ };
542
+ }