settld 0.2.4 → 0.2.5

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 (118) hide show
  1. package/docs/CONFIG.md +12 -0
  2. package/docs/README.md +3 -0
  3. package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
  4. package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
  5. package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
  6. package/package.json +3 -1
  7. package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
  8. package/scripts/setup/login.mjs +67 -1
  9. package/scripts/setup/onboard.mjs +159 -28
  10. package/scripts/setup/onboarding-failure-taxonomy.mjs +96 -0
  11. package/scripts/setup/onboarding-state-machine.mjs +102 -0
  12. package/services/magic-link/README.md +343 -0
  13. package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
  14. package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
  15. package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
  16. package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
  17. package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
  18. package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
  19. package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
  20. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
  21. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
  22. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
  23. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
  24. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
  25. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
  26. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
  27. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
  28. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
  29. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
  30. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
  31. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
  32. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
  33. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
  34. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
  35. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
  36. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
  37. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
  38. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
  39. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
  40. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
  41. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
  42. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
  43. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
  44. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
  45. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
  46. package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
  47. package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
  48. package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
  49. package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
  50. package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
  51. package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
  52. package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
  53. package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
  54. package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
  55. package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
  56. package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
  57. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
  58. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
  59. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
  60. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
  61. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
  62. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
  63. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
  64. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
  65. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
  66. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
  67. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
  68. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
  69. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
  70. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
  71. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
  72. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
  73. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
  74. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
  75. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
  76. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
  77. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
  78. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
  79. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
  80. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
  81. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
  82. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
  83. package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
  84. package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
  85. package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
  86. package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
  87. package/services/magic-link/assets/samples/trust.json +11 -0
  88. package/services/magic-link/src/audit-log.js +24 -0
  89. package/services/magic-link/src/buyer-auth.js +220 -0
  90. package/services/magic-link/src/buyer-notifications.js +402 -0
  91. package/services/magic-link/src/buyer-users.js +129 -0
  92. package/services/magic-link/src/decision-otp.js +156 -0
  93. package/services/magic-link/src/decisions.js +92 -0
  94. package/services/magic-link/src/ingest-keys.js +137 -0
  95. package/services/magic-link/src/maintenance.js +70 -0
  96. package/services/magic-link/src/onboarding-email-sequence.js +331 -0
  97. package/services/magic-link/src/payment-triggers.js +733 -0
  98. package/services/magic-link/src/pdf.js +149 -0
  99. package/services/magic-link/src/policy.js +69 -0
  100. package/services/magic-link/src/redaction.js +6 -0
  101. package/services/magic-link/src/render-model.js +70 -0
  102. package/services/magic-link/src/retention-gc.js +158 -0
  103. package/services/magic-link/src/run-records.js +496 -0
  104. package/services/magic-link/src/s3.js +171 -0
  105. package/services/magic-link/src/server.js +15788 -0
  106. package/services/magic-link/src/settlement-decisions.js +84 -0
  107. package/services/magic-link/src/smtp.js +202 -0
  108. package/services/magic-link/src/storage-cli.js +88 -0
  109. package/services/magic-link/src/storage-format.js +59 -0
  110. package/services/magic-link/src/tenant-billing.js +115 -0
  111. package/services/magic-link/src/tenant-onboarding.js +467 -0
  112. package/services/magic-link/src/tenant-settings.js +1140 -0
  113. package/services/magic-link/src/usage.js +80 -0
  114. package/services/magic-link/src/verify-queue.js +179 -0
  115. package/services/magic-link/src/verify-worker.js +157 -0
  116. package/services/magic-link/src/webhook-retries.js +542 -0
  117. package/services/magic-link/src/webhooks.js +218 -0
  118. package/src/api/app.js +129 -0
@@ -0,0 +1,733 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import http from "node:http";
5
+ import https from "node:https";
6
+
7
+ import { decryptStoredSecret } from "./tenant-settings.js";
8
+
9
+ function nowIso() {
10
+ return new Date().toISOString();
11
+ }
12
+
13
+ function sha256Hex(value) {
14
+ return crypto.createHash("sha256").update(String(value ?? ""), "utf8").digest("hex");
15
+ }
16
+
17
+ function hmacSha256Hex(secret, message) {
18
+ return crypto.createHmac("sha256", String(secret ?? "")).update(String(message ?? ""), "utf8").digest("hex");
19
+ }
20
+
21
+ function isPlainObject(v) {
22
+ return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
23
+ }
24
+
25
+ function safeInt(value, fallback) {
26
+ const n = Number.parseInt(String(value ?? ""), 10);
27
+ if (!Number.isInteger(n)) return fallback;
28
+ return n;
29
+ }
30
+
31
+ function clampRetryConfig({ retryMaxAttempts, retryBackoffMs } = {}) {
32
+ const maxAttempts = Math.max(1, Math.min(50, safeInt(retryMaxAttempts, 5)));
33
+ const backoffMs = Math.max(0, Math.min(86_400_000, safeInt(retryBackoffMs, 5_000)));
34
+ return { maxAttempts, backoffMs };
35
+ }
36
+
37
+ function statusFromPublicSummary(publicSummary) {
38
+ const ok = Boolean(publicSummary?.verification?.ok);
39
+ const verificationOk = Boolean(publicSummary?.verification?.verificationOk);
40
+ const warnings = Array.isArray(publicSummary?.verification?.warningCodes) ? publicSummary.verification.warningCodes : [];
41
+ if (!ok || !verificationOk) return "red";
42
+ if (warnings.length) return "amber";
43
+ return "green";
44
+ }
45
+
46
+ function triggerStatePath({ dataDir, tenantId, token }) {
47
+ return path.join(dataDir, "payment_triggers", tenantId, `${token}.json`);
48
+ }
49
+
50
+ function retryPendingDir(dataDir) {
51
+ return path.join(dataDir, "payment_trigger_retry", "pending");
52
+ }
53
+
54
+ function retryDeadLetterDir(dataDir) {
55
+ return path.join(dataDir, "payment_trigger_retry", "dead-letter");
56
+ }
57
+
58
+ function retryAttemptsDir(dataDir) {
59
+ return path.join(dataDir, "payment_trigger_retry", "attempts");
60
+ }
61
+
62
+ function retryJobId({ tenantId, token, idempotencyKey }) {
63
+ const hash = sha256Hex(`${tenantId}\n${token}\n${idempotencyKey}`).slice(0, 24);
64
+ return `${tenantId}_${token}_${hash}`;
65
+ }
66
+
67
+ function retryPendingPath({ dataDir, tenantId, token, idempotencyKey }) {
68
+ return path.join(retryPendingDir(dataDir), `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
69
+ }
70
+
71
+ function retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey }) {
72
+ return path.join(retryDeadLetterDir(dataDir), `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
73
+ }
74
+
75
+ function triggerOutboxPath({ dataDir, tenantId, token, idempotencyKey }) {
76
+ return path.join(dataDir, "payment-trigger-outbox", `${retryJobId({ tenantId, token, idempotencyKey })}.json`);
77
+ }
78
+
79
+ async function loadStateBestEffort({ dataDir, tenantId, token }) {
80
+ const fp = triggerStatePath({ dataDir, tenantId, token });
81
+ try {
82
+ return JSON.parse(await fs.readFile(fp, "utf8"));
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ async function writeState({ dataDir, tenantId, token, state }) {
89
+ const fp = triggerStatePath({ dataDir, tenantId, token });
90
+ await fs.mkdir(path.dirname(fp), { recursive: true });
91
+ await fs.writeFile(fp, JSON.stringify(state, null, 2) + "\n", "utf8");
92
+ }
93
+
94
+ async function loadRetryJobBestEffort({ dataDir, tenantId, token, idempotencyKey }) {
95
+ const fp = retryPendingPath({ dataDir, tenantId, token, idempotencyKey });
96
+ try {
97
+ const raw = await fs.readFile(fp, "utf8");
98
+ const parsed = JSON.parse(raw);
99
+ return isPlainObject(parsed) ? parsed : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ async function readJsonIfExists(fp) {
106
+ try {
107
+ const raw = await fs.readFile(fp, "utf8");
108
+ const parsed = JSON.parse(raw);
109
+ return isPlainObject(parsed) ? parsed : null;
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ async function fileExists(fp) {
116
+ try {
117
+ await fs.access(fp);
118
+ return true;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function absoluteUrl(baseUrl, relPath) {
125
+ const rel = String(relPath ?? "");
126
+ const base = typeof baseUrl === "string" && baseUrl.trim() ? baseUrl.trim().replace(/\/+$/, "") : "";
127
+ if (!base) return rel;
128
+ if (!rel.startsWith("/")) return rel;
129
+ return `${base}${rel}`;
130
+ }
131
+
132
+ async function request({ url, method, headers, body, timeoutMs }) {
133
+ const u = new URL(url);
134
+ const lib = u.protocol === "https:" ? https : http;
135
+ return await new Promise((resolve) => {
136
+ const req = lib.request(
137
+ {
138
+ protocol: u.protocol,
139
+ hostname: u.hostname,
140
+ port: u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80,
141
+ path: u.pathname + u.search,
142
+ method,
143
+ headers,
144
+ timeout: timeoutMs
145
+ },
146
+ (res) => {
147
+ const chunks = [];
148
+ res.on("data", (d) => chunks.push(d));
149
+ res.on("end", () => resolve({ ok: true, statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") }));
150
+ }
151
+ );
152
+ req.on("timeout", () => {
153
+ try {
154
+ req.destroy(new Error("timeout"));
155
+ } catch {
156
+ // ignore
157
+ }
158
+ });
159
+ req.on("error", (err) => resolve({ ok: false, error: err?.message ?? String(err ?? "request failed") }));
160
+ req.end(body);
161
+ });
162
+ }
163
+
164
+ function buildPayload({ tenantId, token, decisionReport, publicSummary, closePackZipUrl, publicBaseUrl, idempotencyKey }) {
165
+ const decision = isPlainObject(decisionReport) ? decisionReport : {};
166
+ const invoice = isPlainObject(publicSummary?.invoiceClaim) ? publicSummary.invoiceClaim : null;
167
+ const magicLinkPath = `/r/${token}`;
168
+ const decisionReportPath = `${magicLinkPath}/settlement_decision_report.json`;
169
+ const status = statusFromPublicSummary(publicSummary);
170
+
171
+ return {
172
+ schemaVersion: "MagicLinkPaymentTrigger.v1",
173
+ event: "payment.approval_ready",
174
+ triggeredAt: nowIso(),
175
+ tenantId,
176
+ token,
177
+ idempotencyKey,
178
+ decision: {
179
+ decision: typeof decision.decision === "string" ? decision.decision : null,
180
+ decidedAt: typeof decision.decidedAt === "string" ? decision.decidedAt : null,
181
+ reportHash: typeof decision.reportHash === "string" ? decision.reportHash : null,
182
+ signerKeyId: typeof decision.signerKeyId === "string" ? decision.signerKeyId : null,
183
+ actorEmail: typeof decision?.actor?.email === "string" ? decision.actor.email : null
184
+ },
185
+ verification: {
186
+ status,
187
+ ok: Boolean(publicSummary?.verification?.ok),
188
+ verificationOk: Boolean(publicSummary?.verification?.verificationOk)
189
+ },
190
+ invoice: invoice
191
+ ? {
192
+ invoiceId: typeof invoice.invoiceId === "string" ? invoice.invoiceId : null,
193
+ currency: typeof invoice.currency === "string" ? invoice.currency : null,
194
+ totalCents: typeof invoice.totalCents === "string" ? invoice.totalCents : null
195
+ }
196
+ : null,
197
+ artifacts: {
198
+ magicLinkUrl: absoluteUrl(publicBaseUrl, magicLinkPath),
199
+ decisionReportUrl: absoluteUrl(publicBaseUrl, decisionReportPath),
200
+ closePackZipUrl:
201
+ typeof closePackZipUrl === "string" && closePackZipUrl
202
+ ? absoluteUrl(publicBaseUrl, closePackZipUrl)
203
+ : absoluteUrl(publicBaseUrl, `${magicLinkPath}/closepack.zip`)
204
+ }
205
+ };
206
+ }
207
+
208
+ function nextRetryBackoffMs({ baseMs, attempt }) {
209
+ const exp = Math.max(0, Math.min(16, Number(attempt ?? 1) - 1));
210
+ return Math.min(86_400_000, Math.max(0, Number(baseMs ?? 0)) * (2 ** exp));
211
+ }
212
+
213
+ function buildAttemptResult({ statusCode = null, error = null } = {}) {
214
+ const ok = Number.isInteger(statusCode) && statusCode >= 200 && statusCode < 300;
215
+ return {
216
+ at: nowIso(),
217
+ ok,
218
+ statusCode: Number.isInteger(statusCode) ? statusCode : null,
219
+ error: typeof error === "string" && error ? error : null
220
+ };
221
+ }
222
+
223
+ function buildWebhookHeaders({ body, idempotencyKey, webhookSecret }) {
224
+ const headers = {
225
+ "content-type": "application/json; charset=utf-8",
226
+ "content-length": String(Buffer.byteLength(body, "utf8")),
227
+ "x-settld-event": "payment.approval_ready",
228
+ "x-settld-idempotency-key": idempotencyKey
229
+ };
230
+ if (webhookSecret) {
231
+ const ts = nowIso();
232
+ const sig = hmacSha256Hex(webhookSecret, `${ts}.${body}`);
233
+ headers["x-settld-timestamp"] = ts;
234
+ headers["x-settld-signature"] = `v1=${sig}`;
235
+ }
236
+ return headers;
237
+ }
238
+
239
+ async function deliverWebhookAttempt({ webhookUrl, webhookSecret, payload, idempotencyKey, timeoutMs }) {
240
+ const body = JSON.stringify(payload);
241
+ const headers = buildWebhookHeaders({ body, idempotencyKey, webhookSecret });
242
+ const httpRes = await request({ url: webhookUrl, method: "POST", headers, body, timeoutMs });
243
+ if (!httpRes.ok) return buildAttemptResult({ error: httpRes.error ?? "PAYMENT_TRIGGER_WEBHOOK_FAILED" });
244
+ if (httpRes.statusCode < 200 || httpRes.statusCode >= 300) return buildAttemptResult({ statusCode: httpRes.statusCode, error: "PAYMENT_TRIGGER_WEBHOOK_NON_2XX" });
245
+ return buildAttemptResult({ statusCode: httpRes.statusCode });
246
+ }
247
+
248
+ async function appendAttemptLog({ dataDir, job, attempt }) {
249
+ const id = retryJobId({ tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
250
+ const fp = path.join(retryAttemptsDir(dataDir), `${id}.jsonl`);
251
+ await fs.mkdir(path.dirname(fp), { recursive: true });
252
+ await fs.appendFile(fp, JSON.stringify({ schemaVersion: "MagicLinkPaymentTriggerAttempt.v1", ...attempt }) + "\n", "utf8");
253
+ }
254
+
255
+ async function persistRetryJob({ dataDir, job }) {
256
+ const fp = retryPendingPath({ dataDir, tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
257
+ await fs.mkdir(path.dirname(fp), { recursive: true });
258
+ await fs.writeFile(fp, JSON.stringify(job, null, 2) + "\n", "utf8");
259
+ return fp;
260
+ }
261
+
262
+ async function enqueueRetryJob({
263
+ dataDir,
264
+ tenantId,
265
+ token,
266
+ payload,
267
+ idempotencyKey,
268
+ webhookUrl,
269
+ webhookSecretStored,
270
+ maxAttempts,
271
+ backoffMs,
272
+ firstAttempt
273
+ }) {
274
+ const attemptCount = 1;
275
+ const nextAttemptAtMs = Date.now() + nextRetryBackoffMs({ baseMs: backoffMs, attempt: 1 });
276
+ const job = {
277
+ schemaVersion: "MagicLinkPaymentTriggerRetryJob.v1",
278
+ tenantId,
279
+ token,
280
+ idempotencyKey,
281
+ payload,
282
+ webhookUrl,
283
+ webhookSecretStored: typeof webhookSecretStored === "string" && webhookSecretStored ? webhookSecretStored : null,
284
+ maxAttempts,
285
+ backoffMs,
286
+ attemptCount,
287
+ nextAttemptAt: new Date(nextAttemptAtMs).toISOString(),
288
+ attempts: [firstAttempt],
289
+ lastError: firstAttempt?.error ?? null,
290
+ enqueuedAt: nowIso(),
291
+ updatedAt: nowIso()
292
+ };
293
+ const fp = await persistRetryJob({ dataDir, job });
294
+ return { job, path: fp };
295
+ }
296
+
297
+ async function moveToDeadLetter({ dataDir, job }) {
298
+ const src = retryPendingPath({ dataDir, tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
299
+ const dst = retryDeadLetterPath({ dataDir, tenantId: job.tenantId, token: job.token, idempotencyKey: job.idempotencyKey });
300
+ await fs.mkdir(path.dirname(dst), { recursive: true });
301
+ const dead = { ...job, deadLetteredAt: nowIso(), updatedAt: nowIso() };
302
+ await fs.writeFile(dst, JSON.stringify(dead, null, 2) + "\n", "utf8");
303
+ await fs.rm(src, { force: true });
304
+ return dst;
305
+ }
306
+
307
+ export async function sendPaymentTriggerOnApproval({
308
+ dataDir,
309
+ tenantId,
310
+ token,
311
+ tenantSettings,
312
+ decisionReport,
313
+ publicSummary,
314
+ closePackZipUrl = null,
315
+ publicBaseUrl = null,
316
+ settingsKey = null,
317
+ timeoutMs = 5_000,
318
+ retryMaxAttempts = 5,
319
+ retryBackoffMs = 5_000
320
+ } = {}) {
321
+ const cfg = isPlainObject(tenantSettings?.paymentTriggers) ? tenantSettings.paymentTriggers : null;
322
+ if (!cfg || !cfg.enabled) return { ok: true, skipped: true, reason: "PAYMENT_TRIGGER_DISABLED" };
323
+
324
+ const decision = typeof decisionReport?.decision === "string" ? decisionReport.decision : null;
325
+ if (decision !== "approve") return { ok: true, skipped: true, reason: "PAYMENT_TRIGGER_NOT_APPROVED" };
326
+
327
+ const deliveryMode = String(cfg.deliveryMode ?? "record").trim().toLowerCase();
328
+ if (deliveryMode !== "record" && deliveryMode !== "webhook") {
329
+ return { ok: false, skipped: true, reason: "PAYMENT_TRIGGER_INVALID_DELIVERY_MODE" };
330
+ }
331
+
332
+ const { maxAttempts, backoffMs } = clampRetryConfig({ retryMaxAttempts, retryBackoffMs });
333
+ const idempotencyKey = typeof decisionReport?.reportHash === "string" ? decisionReport.reportHash : sha256Hex(JSON.stringify(decisionReport ?? {}));
334
+ const previous = await loadStateBestEffort({ dataDir, tenantId, token });
335
+ if (previous && previous.ok === true && previous.idempotencyKey === idempotencyKey && typeof previous.deliveredAt === "string" && previous.deliveredAt) {
336
+ return {
337
+ ok: true,
338
+ skipped: true,
339
+ reason: "PAYMENT_TRIGGER_ALREADY_DELIVERED",
340
+ deliveredAt: previous.deliveredAt,
341
+ idempotencyKey
342
+ };
343
+ }
344
+
345
+ const payload = buildPayload({ tenantId, token, decisionReport, publicSummary, closePackZipUrl, publicBaseUrl, idempotencyKey });
346
+ let result = null;
347
+
348
+ if (deliveryMode === "record") {
349
+ const fp = triggerOutboxPath({ dataDir, tenantId, token, idempotencyKey });
350
+ await fs.mkdir(path.dirname(fp), { recursive: true });
351
+ await fs.writeFile(fp, JSON.stringify(payload, null, 2) + "\n", "utf8");
352
+ result = { ok: true, mode: "record", recorded: true, outboxPath: fp };
353
+ } else {
354
+ const webhookUrl = typeof cfg.webhookUrl === "string" ? cfg.webhookUrl.trim() : "";
355
+ if (!webhookUrl) {
356
+ result = { ok: false, mode: "webhook", error: "PAYMENT_TRIGGER_WEBHOOK_URL_MISSING" };
357
+ } else {
358
+ const existingJob = await loadRetryJobBestEffort({ dataDir, tenantId, token, idempotencyKey });
359
+ if (existingJob) {
360
+ result = {
361
+ ok: false,
362
+ mode: "webhook",
363
+ queued: true,
364
+ reason: "PAYMENT_TRIGGER_RETRY_ALREADY_ENQUEUED",
365
+ idempotencyKey,
366
+ attemptCount: safeInt(existingJob.attemptCount, 1),
367
+ maxAttempts: safeInt(existingJob.maxAttempts, maxAttempts),
368
+ nextAttemptAt: typeof existingJob.nextAttemptAt === "string" ? existingJob.nextAttemptAt : null
369
+ };
370
+ } else {
371
+ const webhookSecret = decryptStoredSecret({ settingsKey, storedSecret: cfg.webhookSecret });
372
+ const firstAttempt = await deliverWebhookAttempt({ webhookUrl, webhookSecret, payload, idempotencyKey, timeoutMs });
373
+ await appendAttemptLog({
374
+ dataDir,
375
+ job: { tenantId, token, idempotencyKey },
376
+ attempt: { ...firstAttempt, attemptNumber: 1, source: "inline" }
377
+ });
378
+ if (firstAttempt.ok) {
379
+ result = { ok: true, mode: "webhook", statusCode: firstAttempt.statusCode };
380
+ } else if (maxAttempts > 1) {
381
+ const enq = await enqueueRetryJob({
382
+ dataDir,
383
+ tenantId,
384
+ token,
385
+ payload,
386
+ idempotencyKey,
387
+ webhookUrl,
388
+ webhookSecretStored: cfg.webhookSecret,
389
+ maxAttempts,
390
+ backoffMs,
391
+ firstAttempt
392
+ });
393
+ result = {
394
+ ok: false,
395
+ mode: "webhook",
396
+ queued: true,
397
+ reason: "PAYMENT_TRIGGER_RETRY_ENQUEUED",
398
+ error: firstAttempt.error ?? null,
399
+ attemptCount: 1,
400
+ maxAttempts,
401
+ nextAttemptAt: enq.job.nextAttemptAt
402
+ };
403
+ } else {
404
+ result = { ok: false, mode: "webhook", error: firstAttempt.error ?? "PAYMENT_TRIGGER_WEBHOOK_FAILED", statusCode: firstAttempt.statusCode ?? null };
405
+ }
406
+ }
407
+ }
408
+ }
409
+
410
+ const state = {
411
+ schemaVersion: "MagicLinkPaymentTriggerState.v1",
412
+ attemptedAt: nowIso(),
413
+ deliveredAt: result && result.ok ? nowIso() : null,
414
+ ok: Boolean(result && result.ok),
415
+ tenantId,
416
+ token,
417
+ idempotencyKey,
418
+ deliveryMode,
419
+ result
420
+ };
421
+ await writeState({ dataDir, tenantId, token, state });
422
+ if (result && result.ok) return { ok: true, skipped: false, idempotencyKey, ...result };
423
+ return { ok: false, skipped: false, idempotencyKey, ...(result ?? { mode: deliveryMode, error: "PAYMENT_TRIGGER_FAILED" }) };
424
+ }
425
+
426
+ export async function processPaymentTriggerRetryQueueOnce({
427
+ dataDir,
428
+ settingsKey = null,
429
+ timeoutMs = 5_000,
430
+ nowMs = Date.now(),
431
+ tenantIdFilter = null
432
+ } = {}) {
433
+ const stats = { scanned: 0, skipped: 0, retried: 0, delivered: 0, deadLettered: 0, failed: 0 };
434
+ let names = [];
435
+ try {
436
+ names = (await fs.readdir(retryPendingDir(dataDir))).filter((name) => name.endsWith(".json")).sort();
437
+ } catch {
438
+ return stats;
439
+ }
440
+
441
+ for (const name of names) {
442
+ stats.scanned += 1;
443
+ const fp = path.join(retryPendingDir(dataDir), name);
444
+ let job = null;
445
+ try {
446
+ // eslint-disable-next-line no-await-in-loop
447
+ job = JSON.parse(await fs.readFile(fp, "utf8"));
448
+ } catch {
449
+ stats.failed += 1;
450
+ continue;
451
+ }
452
+ if (!isPlainObject(job)) {
453
+ stats.failed += 1;
454
+ continue;
455
+ }
456
+
457
+ const nextAt = Date.parse(String(job.nextAttemptAt ?? ""));
458
+ if (Number.isFinite(nextAt) && nextAt > nowMs) {
459
+ stats.skipped += 1;
460
+ continue;
461
+ }
462
+
463
+ const tenantId = typeof job.tenantId === "string" ? job.tenantId : null;
464
+ const token = typeof job.token === "string" ? job.token : null;
465
+ const idempotencyKey = typeof job.idempotencyKey === "string" ? job.idempotencyKey : null;
466
+ const webhookUrl = typeof job.webhookUrl === "string" ? job.webhookUrl : null;
467
+ const payload = isPlainObject(job.payload) ? job.payload : null;
468
+ if (!tenantId || !token || !idempotencyKey || !webhookUrl || !payload) {
469
+ stats.failed += 1;
470
+ continue;
471
+ }
472
+ if (tenantIdFilter && tenantId !== tenantIdFilter) {
473
+ stats.skipped += 1;
474
+ continue;
475
+ }
476
+
477
+ const maxAttempts = Math.max(1, safeInt(job.maxAttempts, 5));
478
+ const backoffMs = Math.max(0, safeInt(job.backoffMs, 5_000));
479
+ const attemptCount = Math.max(0, safeInt(job.attemptCount, 0));
480
+ const nextAttemptNumber = attemptCount + 1;
481
+
482
+ const webhookSecret = decryptStoredSecret({ settingsKey, storedSecret: job.webhookSecretStored });
483
+ // eslint-disable-next-line no-await-in-loop
484
+ const attempt = await deliverWebhookAttempt({ webhookUrl, webhookSecret, payload, idempotencyKey, timeoutMs });
485
+ // eslint-disable-next-line no-await-in-loop
486
+ await appendAttemptLog({
487
+ dataDir,
488
+ job: { tenantId, token, idempotencyKey },
489
+ attempt: { ...attempt, attemptNumber: nextAttemptNumber, source: "retry_worker" }
490
+ });
491
+
492
+ if (attempt.ok) {
493
+ // eslint-disable-next-line no-await-in-loop
494
+ await fs.rm(fp, { force: true });
495
+ // eslint-disable-next-line no-await-in-loop
496
+ await writeState({
497
+ dataDir,
498
+ tenantId,
499
+ token,
500
+ state: {
501
+ schemaVersion: "MagicLinkPaymentTriggerState.v1",
502
+ attemptedAt: attempt.at,
503
+ deliveredAt: attempt.at,
504
+ ok: true,
505
+ tenantId,
506
+ token,
507
+ idempotencyKey,
508
+ deliveryMode: "webhook",
509
+ result: {
510
+ ok: true,
511
+ mode: "webhook",
512
+ retried: true,
513
+ statusCode: attempt.statusCode,
514
+ attemptCount: nextAttemptNumber
515
+ }
516
+ }
517
+ });
518
+ stats.delivered += 1;
519
+ continue;
520
+ }
521
+
522
+ const attempts = Array.isArray(job.attempts) ? [...job.attempts] : [];
523
+ attempts.push(attempt);
524
+ if (nextAttemptNumber >= maxAttempts) {
525
+ const deadJob = {
526
+ ...job,
527
+ attemptCount: nextAttemptNumber,
528
+ attempts,
529
+ lastError: attempt.error ?? null,
530
+ updatedAt: nowIso()
531
+ };
532
+ // eslint-disable-next-line no-await-in-loop
533
+ await fs.writeFile(fp, JSON.stringify(deadJob, null, 2) + "\n", "utf8");
534
+ // eslint-disable-next-line no-await-in-loop
535
+ await moveToDeadLetter({ dataDir, job: deadJob });
536
+ // eslint-disable-next-line no-await-in-loop
537
+ await writeState({
538
+ dataDir,
539
+ tenantId,
540
+ token,
541
+ state: {
542
+ schemaVersion: "MagicLinkPaymentTriggerState.v1",
543
+ attemptedAt: attempt.at,
544
+ deliveredAt: null,
545
+ ok: false,
546
+ tenantId,
547
+ token,
548
+ idempotencyKey,
549
+ deliveryMode: "webhook",
550
+ result: {
551
+ ok: false,
552
+ mode: "webhook",
553
+ deadLetter: true,
554
+ error: attempt.error ?? null,
555
+ statusCode: attempt.statusCode ?? null,
556
+ attemptCount: nextAttemptNumber,
557
+ maxAttempts
558
+ }
559
+ }
560
+ });
561
+ stats.deadLettered += 1;
562
+ continue;
563
+ }
564
+
565
+ const nextMs = nowMs + nextRetryBackoffMs({ baseMs: backoffMs, attempt: nextAttemptNumber });
566
+ const updated = {
567
+ ...job,
568
+ attemptCount: nextAttemptNumber,
569
+ nextAttemptAt: new Date(nextMs).toISOString(),
570
+ attempts,
571
+ lastError: attempt.error ?? null,
572
+ updatedAt: nowIso()
573
+ };
574
+ // eslint-disable-next-line no-await-in-loop
575
+ await fs.writeFile(fp, JSON.stringify(updated, null, 2) + "\n", "utf8");
576
+ stats.retried += 1;
577
+ }
578
+ return stats;
579
+ }
580
+
581
+ export function startPaymentTriggerRetryWorker({
582
+ dataDir,
583
+ settingsKey = null,
584
+ timeoutMs = 5_000,
585
+ intervalMs = 2_000,
586
+ onRetry = null,
587
+ onDeadLetter = null,
588
+ onDelivered = null
589
+ } = {}) {
590
+ const cadence = Math.max(100, safeInt(intervalMs, 2_000));
591
+ let running = false;
592
+ const timer = setInterval(async () => {
593
+ if (running) return;
594
+ running = true;
595
+ try {
596
+ const stats = await processPaymentTriggerRetryQueueOnce({ dataDir, settingsKey, timeoutMs });
597
+ if (typeof onRetry === "function" && stats.retried > 0) onRetry(stats.retried, stats);
598
+ if (typeof onDeadLetter === "function" && stats.deadLettered > 0) onDeadLetter(stats.deadLettered, stats);
599
+ if (typeof onDelivered === "function" && stats.delivered > 0) onDelivered(stats.delivered, stats);
600
+ } finally {
601
+ running = false;
602
+ }
603
+ }, cadence);
604
+ if (typeof timer.unref === "function") timer.unref();
605
+ return {
606
+ stop() {
607
+ clearInterval(timer);
608
+ }
609
+ };
610
+ }
611
+
612
+ export async function paymentTriggerRetryQueueDepth({ dataDir } = {}) {
613
+ try {
614
+ const names = (await fs.readdir(retryPendingDir(dataDir))).filter((name) => name.endsWith(".json"));
615
+ return names.length;
616
+ } catch {
617
+ return 0;
618
+ }
619
+ }
620
+
621
+ export async function paymentTriggerDeadLetterExists({ dataDir, tenantId, token, idempotencyKey } = {}) {
622
+ const fp = retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey });
623
+ return await fileExists(fp);
624
+ }
625
+
626
+ function normalizeRetryJobSummary({ job, state }) {
627
+ if (!isPlainObject(job)) return null;
628
+ const attempts = Array.isArray(job.attempts) ? job.attempts : [];
629
+ const lastAttempt = attempts.length ? attempts[attempts.length - 1] : null;
630
+ return {
631
+ schemaVersion: "MagicLinkPaymentTriggerRetrySummary.v1",
632
+ state,
633
+ tenantId: typeof job.tenantId === "string" ? job.tenantId : null,
634
+ token: typeof job.token === "string" ? job.token : null,
635
+ idempotencyKey: typeof job.idempotencyKey === "string" ? job.idempotencyKey : null,
636
+ attemptCount: safeInt(job.attemptCount, 0),
637
+ maxAttempts: safeInt(job.maxAttempts, 0),
638
+ nextAttemptAt: typeof job.nextAttemptAt === "string" ? job.nextAttemptAt : null,
639
+ enqueuedAt: typeof job.enqueuedAt === "string" ? job.enqueuedAt : null,
640
+ updatedAt: typeof job.updatedAt === "string" ? job.updatedAt : null,
641
+ deadLetteredAt: typeof job.deadLetteredAt === "string" ? job.deadLetteredAt : null,
642
+ lastError: typeof job.lastError === "string" && job.lastError ? job.lastError : typeof lastAttempt?.error === "string" ? lastAttempt.error : null
643
+ };
644
+ }
645
+
646
+ export async function listPaymentTriggerRetryJobs({
647
+ dataDir,
648
+ tenantId = null,
649
+ state = "pending",
650
+ limit = 100
651
+ } = {}) {
652
+ const targetState = state === "dead-letter" ? "dead-letter" : "pending";
653
+ const dir = targetState === "dead-letter" ? retryDeadLetterDir(dataDir) : retryPendingDir(dataDir);
654
+ const capped = Math.max(1, Math.min(500, safeInt(limit, 100)));
655
+ let names = [];
656
+ try {
657
+ names = (await fs.readdir(dir)).filter((name) => name.endsWith(".json")).sort();
658
+ } catch {
659
+ return [];
660
+ }
661
+ const rows = [];
662
+ for (const name of names) {
663
+ const fp = path.join(dir, name);
664
+ // eslint-disable-next-line no-await-in-loop
665
+ const job = await readJsonIfExists(fp);
666
+ if (!isPlainObject(job)) continue;
667
+ if (tenantId && String(job.tenantId ?? "") !== tenantId) continue;
668
+ const row = normalizeRetryJobSummary({ job, state: targetState });
669
+ if (!row) continue;
670
+ rows.push(row);
671
+ }
672
+ rows.sort((a, b) => Date.parse(String(b.updatedAt ?? "")) - Date.parse(String(a.updatedAt ?? "")));
673
+ return rows.slice(0, capped);
674
+ }
675
+
676
+ export async function replayPaymentTriggerDeadLetterJob({
677
+ dataDir,
678
+ tenantId,
679
+ token,
680
+ idempotencyKey,
681
+ resetAttempts = false,
682
+ tenantSettings = null,
683
+ useCurrentSettings = true
684
+ } = {}) {
685
+ const deadPath = retryDeadLetterPath({ dataDir, tenantId, token, idempotencyKey });
686
+ const pendingPath = retryPendingPath({ dataDir, tenantId, token, idempotencyKey });
687
+ const deadJob = await readJsonIfExists(deadPath);
688
+ if (!deadJob) return { ok: false, code: "NOT_FOUND", message: "dead-letter job not found" };
689
+
690
+ const pendingExists = await fileExists(pendingPath);
691
+ if (pendingExists) return { ok: false, code: "PENDING_EXISTS", message: "pending retry job already exists" };
692
+
693
+ const now = nowIso();
694
+ const next = {
695
+ ...deadJob,
696
+ deadLetteredAt: null,
697
+ replayedAt: now,
698
+ replayCount: safeInt(deadJob.replayCount, 0) + 1,
699
+ nextAttemptAt: now,
700
+ updatedAt: now
701
+ };
702
+
703
+ if (resetAttempts) {
704
+ next.attemptCount = 0;
705
+ next.attempts = [];
706
+ next.lastError = null;
707
+ }
708
+
709
+ if (useCurrentSettings && isPlainObject(tenantSettings?.paymentTriggers)) {
710
+ const cfg = tenantSettings.paymentTriggers;
711
+ const webhookUrl = typeof cfg.webhookUrl === "string" ? cfg.webhookUrl.trim() : "";
712
+ if (webhookUrl) next.webhookUrl = webhookUrl;
713
+ if (cfg.webhookSecret === null || cfg.webhookSecret === undefined || typeof cfg.webhookSecret === "string") {
714
+ next.webhookSecretStored = cfg.webhookSecret ?? null;
715
+ }
716
+ }
717
+
718
+ await fs.mkdir(path.dirname(pendingPath), { recursive: true });
719
+ await fs.writeFile(pendingPath, JSON.stringify(next, null, 2) + "\n", "utf8");
720
+ await fs.rm(deadPath, { force: true });
721
+
722
+ return {
723
+ ok: true,
724
+ state: "pending",
725
+ tenantId,
726
+ token,
727
+ idempotencyKey,
728
+ attemptCount: safeInt(next.attemptCount, 0),
729
+ maxAttempts: safeInt(next.maxAttempts, 0),
730
+ nextAttemptAt: typeof next.nextAttemptAt === "string" ? next.nextAttemptAt : now,
731
+ replayCount: safeInt(next.replayCount, 1)
732
+ };
733
+ }