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.
- package/docs/CONFIG.md +12 -0
- package/docs/README.md +3 -0
- package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
- package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
- package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
- package/package.json +3 -1
- package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
- package/scripts/setup/login.mjs +67 -1
- package/scripts/setup/onboard.mjs +159 -28
- package/scripts/setup/onboarding-failure-taxonomy.mjs +96 -0
- package/scripts/setup/onboarding-state-machine.mjs +102 -0
- package/services/magic-link/README.md +343 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/trust.json +11 -0
- package/services/magic-link/src/audit-log.js +24 -0
- package/services/magic-link/src/buyer-auth.js +220 -0
- package/services/magic-link/src/buyer-notifications.js +402 -0
- package/services/magic-link/src/buyer-users.js +129 -0
- package/services/magic-link/src/decision-otp.js +156 -0
- package/services/magic-link/src/decisions.js +92 -0
- package/services/magic-link/src/ingest-keys.js +137 -0
- package/services/magic-link/src/maintenance.js +70 -0
- package/services/magic-link/src/onboarding-email-sequence.js +331 -0
- package/services/magic-link/src/payment-triggers.js +733 -0
- package/services/magic-link/src/pdf.js +149 -0
- package/services/magic-link/src/policy.js +69 -0
- package/services/magic-link/src/redaction.js +6 -0
- package/services/magic-link/src/render-model.js +70 -0
- package/services/magic-link/src/retention-gc.js +158 -0
- package/services/magic-link/src/run-records.js +496 -0
- package/services/magic-link/src/s3.js +171 -0
- package/services/magic-link/src/server.js +15788 -0
- package/services/magic-link/src/settlement-decisions.js +84 -0
- package/services/magic-link/src/smtp.js +202 -0
- package/services/magic-link/src/storage-cli.js +88 -0
- package/services/magic-link/src/storage-format.js +59 -0
- package/services/magic-link/src/tenant-billing.js +115 -0
- package/services/magic-link/src/tenant-onboarding.js +467 -0
- package/services/magic-link/src/tenant-settings.js +1140 -0
- package/services/magic-link/src/usage.js +80 -0
- package/services/magic-link/src/verify-queue.js +179 -0
- package/services/magic-link/src/verify-worker.js +157 -0
- package/services/magic-link/src/webhook-retries.js +542 -0
- package/services/magic-link/src/webhooks.js +218 -0
- package/src/api/app.js +129 -0
|
@@ -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
|
+
}
|