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.
- package/Dockerfile +2 -2
- 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 +4 -1
- package/packages/api-sdk/README.md +71 -0
- package/packages/api-sdk/src/client.js +1021 -0
- package/packages/api-sdk/src/express-middleware.js +163 -0
- package/packages/api-sdk/src/index.d.ts +1662 -0
- package/packages/api-sdk/src/index.js +10 -0
- package/packages/api-sdk/src/webhook-signature.js +182 -0
- package/packages/api-sdk/src/x402-autopay.js +210 -0
- package/scripts/ci/cli-pack-smoke.mjs +2 -0
- package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
- package/scripts/setup/login.mjs +73 -2
- package/scripts/setup/onboard.mjs +173 -28
- package/scripts/setup/onboarding-failure-taxonomy.mjs +107 -0
- package/scripts/setup/onboarding-state-machine.mjs +102 -0
- package/services/magic-link/README.md +352 -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 +251 -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 +187 -0
- package/services/magic-link/src/decisions.js +92 -0
- package/services/magic-link/src/email-resend.js +89 -0
- package/services/magic-link/src/ingest-keys.js +137 -0
- package/services/magic-link/src/maintenance.js +95 -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 +15849 -0
- package/services/magic-link/src/settlement-decisions.js +84 -0
- package/services/magic-link/src/smtp.js +217 -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 +135 -1
|
@@ -0,0 +1,402 @@
|
|
|
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 { sendSmtpMail } from "./smtp.js";
|
|
8
|
+
import { decryptStoredSecret } from "./tenant-settings.js";
|
|
9
|
+
|
|
10
|
+
function nowIso() {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function sha256Hex(value) {
|
|
15
|
+
return crypto.createHash("sha256").update(String(value ?? ""), "utf8").digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeEmailLower(value) {
|
|
19
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
20
|
+
if (!raw || raw.length > 320) return null;
|
|
21
|
+
if (/\s/.test(raw)) return null;
|
|
22
|
+
const parts = raw.split("@");
|
|
23
|
+
if (parts.length !== 2) return null;
|
|
24
|
+
if (!parts[0] || !parts[1]) return null;
|
|
25
|
+
return raw;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeRunId(value) {
|
|
29
|
+
const raw = String(value ?? "").trim();
|
|
30
|
+
if (!raw || raw.length > 128) return null;
|
|
31
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(raw)) return null;
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function statusFromCliOut(cliOut) {
|
|
36
|
+
const ok = Boolean(cliOut?.ok);
|
|
37
|
+
const verificationOk = Boolean(cliOut?.verificationOk);
|
|
38
|
+
const warnings = Array.isArray(cliOut?.warnings) ? cliOut.warnings : [];
|
|
39
|
+
if (!ok || !verificationOk) return "red";
|
|
40
|
+
if (warnings.length) return "amber";
|
|
41
|
+
return "green";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function statusLabel(status) {
|
|
45
|
+
if (status === "green") return "Verified - Payable";
|
|
46
|
+
if (status === "amber") return "Review Required";
|
|
47
|
+
return "Failed - See Details";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatMoney({ currency, totalCents }) {
|
|
51
|
+
const cur = String(currency ?? "").trim() || "USD";
|
|
52
|
+
const cents = String(totalCents ?? "").trim();
|
|
53
|
+
if (!/^[0-9]+$/.test(cents)) return `${cur} ${cents || "0"}`;
|
|
54
|
+
if (cur === "USD") {
|
|
55
|
+
const padded = cents.padStart(3, "0");
|
|
56
|
+
const dollars = padded.slice(0, -2);
|
|
57
|
+
const tail = padded.slice(-2);
|
|
58
|
+
return `$${dollars}.${tail}`;
|
|
59
|
+
}
|
|
60
|
+
return `${cur} ${cents} cents`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function evidenceCountFromSummary(publicSummary) {
|
|
64
|
+
const closePackCount = publicSummary?.closePackSummaryV1?.evidenceIndex?.itemCount;
|
|
65
|
+
if (Number.isInteger(closePackCount) && closePackCount >= 0) return closePackCount;
|
|
66
|
+
const meteringCount = publicSummary?.metering?.evidenceRefsCount;
|
|
67
|
+
if (Number.isInteger(meteringCount) && meteringCount >= 0) return meteringCount;
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function notificationStatePath({ dataDir, tenantId, token }) {
|
|
72
|
+
return path.join(dataDir, "notifications", "verification", tenantId, `${token}.json`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function runNotificationStatePath({ dataDir, tenantId, runId }) {
|
|
76
|
+
const key = sha256Hex(`${tenantId}\n${runId}`);
|
|
77
|
+
return path.join(dataDir, "notifications", "verification-run", tenantId, `${key}.json`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function outboxPath({ dataDir, tenantId, token, recipient }) {
|
|
81
|
+
const hash = sha256Hex(`${tenantId}\n${token}\n${recipient}`).slice(0, 24);
|
|
82
|
+
return path.join(dataDir, "buyer-notification-outbox", `${tenantId}_${token}_${hash}.json`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function request({ url, method, headers, body, timeoutMs }) {
|
|
86
|
+
const u = new URL(url);
|
|
87
|
+
const lib = u.protocol === "https:" ? https : http;
|
|
88
|
+
return await new Promise((resolve) => {
|
|
89
|
+
const req = lib.request(
|
|
90
|
+
{
|
|
91
|
+
protocol: u.protocol,
|
|
92
|
+
hostname: u.hostname,
|
|
93
|
+
port: u.port ? Number(u.port) : u.protocol === "https:" ? 443 : 80,
|
|
94
|
+
path: u.pathname + u.search,
|
|
95
|
+
method,
|
|
96
|
+
headers,
|
|
97
|
+
timeout: timeoutMs
|
|
98
|
+
},
|
|
99
|
+
(res) => {
|
|
100
|
+
const chunks = [];
|
|
101
|
+
res.on("data", (d) => chunks.push(d));
|
|
102
|
+
res.on("end", () => {
|
|
103
|
+
resolve({ ok: true, statusCode: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf8") });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
req.on("timeout", () => {
|
|
108
|
+
try {
|
|
109
|
+
req.destroy(new Error("timeout"));
|
|
110
|
+
} catch {
|
|
111
|
+
// ignore
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
req.on("error", (err) => resolve({ ok: false, error: err?.message ?? String(err ?? "error") }));
|
|
115
|
+
req.end(body);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function loadStateBestEffort({ dataDir, tenantId, token }) {
|
|
120
|
+
const fp = notificationStatePath({ dataDir, tenantId, token });
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(await fs.readFile(fp, "utf8"));
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function writeState({ dataDir, tenantId, token, state }) {
|
|
129
|
+
const fp = notificationStatePath({ dataDir, tenantId, token });
|
|
130
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
131
|
+
await fs.writeFile(fp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function loadRunStateBestEffort({ dataDir, tenantId, runId }) {
|
|
135
|
+
const fp = runNotificationStatePath({ dataDir, tenantId, runId });
|
|
136
|
+
try {
|
|
137
|
+
return JSON.parse(await fs.readFile(fp, "utf8"));
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function writeRunState({ dataDir, tenantId, runId, state }) {
|
|
144
|
+
const fp = runNotificationStatePath({ dataDir, tenantId, runId });
|
|
145
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
146
|
+
await fs.writeFile(fp, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildEmailText({ recipient, summary }) {
|
|
150
|
+
const lines = [];
|
|
151
|
+
lines.push(`Verification update for ${summary.vendorName}: ${summary.statusLabel}`);
|
|
152
|
+
lines.push("");
|
|
153
|
+
lines.push(`Artifact ID: ${summary.token}`);
|
|
154
|
+
if (summary.runId) lines.push(`Run ID: ${summary.runId}`);
|
|
155
|
+
if (summary.invoiceId) lines.push(`Invoice ID: ${summary.invoiceId}`);
|
|
156
|
+
if (summary.evidenceCount !== null) lines.push(`Evidence count: ${summary.evidenceCount}`);
|
|
157
|
+
if (summary.netPayable) lines.push(`Net payable: ${summary.netPayable}`);
|
|
158
|
+
lines.push(`Magic Link: ${summary.magicLinkUrl}`);
|
|
159
|
+
lines.push("");
|
|
160
|
+
lines.push(`Recipient: ${recipient}`);
|
|
161
|
+
lines.push("This message is generated from artifact-derived verification data.");
|
|
162
|
+
return lines.join("\n");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createNotificationSummary({ tenantId, token, runId, publicSummary, cliOut, magicLinkUrl }) {
|
|
166
|
+
const status = statusFromCliOut(cliOut);
|
|
167
|
+
const vendorName =
|
|
168
|
+
typeof publicSummary?.vendorName === "string" && publicSummary.vendorName.trim()
|
|
169
|
+
? publicSummary.vendorName.trim()
|
|
170
|
+
: typeof publicSummary?.vendorId === "string" && publicSummary.vendorId.trim()
|
|
171
|
+
? publicSummary.vendorId.trim()
|
|
172
|
+
: "Vendor";
|
|
173
|
+
const invoiceId = typeof publicSummary?.invoiceClaim?.invoiceId === "string" ? publicSummary.invoiceClaim.invoiceId : null;
|
|
174
|
+
const currency = typeof publicSummary?.invoiceClaim?.currency === "string" ? publicSummary.invoiceClaim.currency : "USD";
|
|
175
|
+
const totalCents = typeof publicSummary?.invoiceClaim?.totalCents === "string" ? publicSummary.invoiceClaim.totalCents : null;
|
|
176
|
+
const evidenceCount = evidenceCountFromSummary(publicSummary);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
tenantId,
|
|
180
|
+
token,
|
|
181
|
+
runId: runId ?? null,
|
|
182
|
+
status,
|
|
183
|
+
statusLabel: statusLabel(status),
|
|
184
|
+
vendorName,
|
|
185
|
+
invoiceId,
|
|
186
|
+
evidenceCount,
|
|
187
|
+
netPayable: totalCents ? formatMoney({ currency, totalCents }) : null,
|
|
188
|
+
magicLinkUrl
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function notificationRecipients(tenantSettings) {
|
|
193
|
+
const rows = Array.isArray(tenantSettings?.buyerNotifications?.emails) ? tenantSettings.buyerNotifications.emails : [];
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const raw of rows) {
|
|
196
|
+
const email = normalizeEmailLower(raw);
|
|
197
|
+
if (!email) continue;
|
|
198
|
+
out.push(email);
|
|
199
|
+
}
|
|
200
|
+
return [...new Set(out)].sort();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function sendBuyerVerificationNotifications({
|
|
204
|
+
dataDir,
|
|
205
|
+
tenantId,
|
|
206
|
+
token,
|
|
207
|
+
runId = null,
|
|
208
|
+
tenantSettings,
|
|
209
|
+
publicSummary,
|
|
210
|
+
cliOut,
|
|
211
|
+
magicLinkUrl,
|
|
212
|
+
smtpConfig,
|
|
213
|
+
settingsKey,
|
|
214
|
+
timeoutMs = 5_000
|
|
215
|
+
} = {}) {
|
|
216
|
+
const recipients = notificationRecipients(tenantSettings);
|
|
217
|
+
if (!recipients.length) return { ok: true, skipped: true, reason: "NO_RECIPIENTS", recipients: [] };
|
|
218
|
+
|
|
219
|
+
const runIdNorm = normalizeRunId(runId);
|
|
220
|
+
|
|
221
|
+
const previous = await loadStateBestEffort({ dataDir, tenantId, token });
|
|
222
|
+
if (previous && previous.ok && typeof previous.sentAt === "string" && previous.sentAt) {
|
|
223
|
+
return { ok: true, skipped: true, reason: "ALREADY_SENT", sentAt: previous.sentAt, recipients };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (runIdNorm) {
|
|
227
|
+
const priorRun = await loadRunStateBestEffort({ dataDir, tenantId, runId: runIdNorm });
|
|
228
|
+
if (priorRun && typeof priorRun.sentAt === "string" && priorRun.sentAt) {
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
skipped: true,
|
|
232
|
+
reason: "ALREADY_SENT_RUN",
|
|
233
|
+
sentAt: priorRun.sentAt,
|
|
234
|
+
token: typeof priorRun.token === "string" ? priorRun.token : null,
|
|
235
|
+
runId: runIdNorm,
|
|
236
|
+
recipients
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const modeRaw = tenantSettings?.buyerNotifications?.deliveryMode;
|
|
242
|
+
const deliveryMode = String(modeRaw ?? "smtp").trim().toLowerCase();
|
|
243
|
+
if (deliveryMode !== "smtp" && deliveryMode !== "webhook" && deliveryMode !== "record") {
|
|
244
|
+
return { ok: false, skipped: true, reason: "INVALID_DELIVERY_MODE", deliveryMode };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const summary = createNotificationSummary({ tenantId, token, runId: runIdNorm, publicSummary, cliOut, magicLinkUrl });
|
|
248
|
+
const subject = `Settld verification ready: ${summary.statusLabel}`;
|
|
249
|
+
const webhookUrl = typeof tenantSettings?.buyerNotifications?.webhookUrl === "string" ? tenantSettings.buyerNotifications.webhookUrl.trim() : "";
|
|
250
|
+
const webhookSecret = decryptStoredSecret({ settingsKey, storedSecret: tenantSettings?.buyerNotifications?.webhookSecret });
|
|
251
|
+
const results = [];
|
|
252
|
+
|
|
253
|
+
for (const recipient of recipients) {
|
|
254
|
+
if (deliveryMode === "record") {
|
|
255
|
+
const out = {
|
|
256
|
+
schemaVersion: "MagicLinkBuyerNotificationOutbox.v1",
|
|
257
|
+
createdAt: nowIso(),
|
|
258
|
+
tenantId,
|
|
259
|
+
token,
|
|
260
|
+
recipient,
|
|
261
|
+
subject,
|
|
262
|
+
summary,
|
|
263
|
+
text: buildEmailText({ recipient, summary })
|
|
264
|
+
};
|
|
265
|
+
const fp = outboxPath({ dataDir, tenantId, token, recipient });
|
|
266
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
267
|
+
await fs.writeFile(fp, JSON.stringify(out, null, 2) + "\n", "utf8");
|
|
268
|
+
results.push({ ok: true, recipient, mode: deliveryMode, recorded: true });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (deliveryMode === "smtp") {
|
|
273
|
+
try {
|
|
274
|
+
const from = typeof smtpConfig?.from === "string" ? smtpConfig.from.trim() : "";
|
|
275
|
+
if (!from) throw new Error("SMTP_NOT_CONFIGURED");
|
|
276
|
+
await sendSmtpMail({
|
|
277
|
+
host: smtpConfig?.host,
|
|
278
|
+
port: smtpConfig?.port,
|
|
279
|
+
secure: Boolean(smtpConfig?.secure),
|
|
280
|
+
starttls: smtpConfig?.starttls === undefined ? true : Boolean(smtpConfig?.starttls),
|
|
281
|
+
auth: smtpConfig?.user && smtpConfig?.pass ? { user: smtpConfig.user, pass: smtpConfig.pass } : null,
|
|
282
|
+
from,
|
|
283
|
+
to: recipient,
|
|
284
|
+
subject,
|
|
285
|
+
text: buildEmailText({ recipient, summary }),
|
|
286
|
+
timeoutMs
|
|
287
|
+
});
|
|
288
|
+
results.push({ ok: true, recipient, mode: deliveryMode });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
results.push({ ok: false, recipient, mode: deliveryMode, error: err?.message ?? String(err ?? "smtp failed") });
|
|
291
|
+
}
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!webhookUrl) {
|
|
296
|
+
results.push({ ok: false, recipient, mode: deliveryMode, error: "WEBHOOK_URL_MISSING" });
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const payload = {
|
|
300
|
+
schemaVersion: "MagicLinkBuyerNotificationWebhook.v1",
|
|
301
|
+
sentAt: nowIso(),
|
|
302
|
+
tenantId,
|
|
303
|
+
token,
|
|
304
|
+
recipient,
|
|
305
|
+
subject,
|
|
306
|
+
summary,
|
|
307
|
+
text: buildEmailText({ recipient, summary })
|
|
308
|
+
};
|
|
309
|
+
const body = JSON.stringify(payload);
|
|
310
|
+
const headers = {
|
|
311
|
+
"content-type": "application/json; charset=utf-8",
|
|
312
|
+
"content-length": String(Buffer.byteLength(body, "utf8")),
|
|
313
|
+
"x-settld-notification-event": "verification.email"
|
|
314
|
+
};
|
|
315
|
+
if (webhookSecret) {
|
|
316
|
+
const ts = new Date().toISOString();
|
|
317
|
+
const sig = crypto.createHmac("sha256", webhookSecret).update(`${ts}.${body}`, "utf8").digest("hex");
|
|
318
|
+
headers["x-settld-timestamp"] = ts;
|
|
319
|
+
headers["x-settld-signature"] = `v1=${sig}`;
|
|
320
|
+
}
|
|
321
|
+
const res = await request({ url: webhookUrl, method: "POST", headers, body, timeoutMs });
|
|
322
|
+
if (res.ok && res.statusCode >= 200 && res.statusCode < 300) {
|
|
323
|
+
results.push({ ok: true, recipient, mode: deliveryMode, statusCode: res.statusCode });
|
|
324
|
+
} else if (res.ok) {
|
|
325
|
+
results.push({ ok: false, recipient, mode: deliveryMode, statusCode: res.statusCode, error: "WEBHOOK_NON_2XX" });
|
|
326
|
+
} else {
|
|
327
|
+
results.push({ ok: false, recipient, mode: deliveryMode, error: res.error ?? "WEBHOOK_FAILED" });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const state = {
|
|
332
|
+
schemaVersion: "MagicLinkBuyerNotificationState.v1",
|
|
333
|
+
tenantId,
|
|
334
|
+
token,
|
|
335
|
+
runId: runIdNorm,
|
|
336
|
+
attemptedAt: nowIso(),
|
|
337
|
+
sentAt: results.every((r) => r.ok) ? nowIso() : null,
|
|
338
|
+
ok: results.every((r) => r.ok),
|
|
339
|
+
deliveryMode,
|
|
340
|
+
recipients,
|
|
341
|
+
summary,
|
|
342
|
+
results
|
|
343
|
+
};
|
|
344
|
+
await writeState({ dataDir, tenantId, token, state });
|
|
345
|
+
if (state.ok && state.sentAt && runIdNorm) {
|
|
346
|
+
await writeRunState({
|
|
347
|
+
dataDir,
|
|
348
|
+
tenantId,
|
|
349
|
+
runId: runIdNorm,
|
|
350
|
+
state: {
|
|
351
|
+
schemaVersion: "MagicLinkBuyerNotificationRunState.v1",
|
|
352
|
+
tenantId,
|
|
353
|
+
runId: runIdNorm,
|
|
354
|
+
token,
|
|
355
|
+
ok: true,
|
|
356
|
+
sentAt: state.sentAt,
|
|
357
|
+
deliveryMode,
|
|
358
|
+
recipients
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
return state;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export async function loadLatestBuyerNotificationStatusBestEffort({ dataDir, tenantId } = {}) {
|
|
366
|
+
const dir = path.join(dataDir, "notifications", "verification", tenantId);
|
|
367
|
+
let names = [];
|
|
368
|
+
try {
|
|
369
|
+
names = (await fs.readdir(dir)).filter((n) => n.endsWith(".json"));
|
|
370
|
+
} catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
let latest = null;
|
|
374
|
+
let latestAt = 0;
|
|
375
|
+
for (const name of names) {
|
|
376
|
+
const fp = path.join(dir, name);
|
|
377
|
+
let row = null;
|
|
378
|
+
try {
|
|
379
|
+
// eslint-disable-next-line no-await-in-loop
|
|
380
|
+
row = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
381
|
+
} catch {
|
|
382
|
+
row = null;
|
|
383
|
+
}
|
|
384
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
385
|
+
const atRaw = typeof row.sentAt === "string" && row.sentAt ? row.sentAt : typeof row.attemptedAt === "string" ? row.attemptedAt : null;
|
|
386
|
+
const at = atRaw ? Date.parse(atRaw) : NaN;
|
|
387
|
+
const ts = Number.isFinite(at) ? at : 0;
|
|
388
|
+
if (ts >= latestAt) {
|
|
389
|
+
latestAt = ts;
|
|
390
|
+
latest = {
|
|
391
|
+
token: typeof row.token === "string" ? row.token : null,
|
|
392
|
+
ok: Boolean(row.ok),
|
|
393
|
+
attemptedAt: typeof row.attemptedAt === "string" ? row.attemptedAt : null,
|
|
394
|
+
sentAt: typeof row.sentAt === "string" ? row.sentAt : null,
|
|
395
|
+
deliveryMode: typeof row.deliveryMode === "string" ? row.deliveryMode : null,
|
|
396
|
+
recipients: Array.isArray(row.recipients) ? row.recipients : [],
|
|
397
|
+
failures: Array.isArray(row.results) ? row.results.filter((x) => !x?.ok).map((x) => ({ recipient: x?.recipient ?? null, error: x?.error ?? null })) : []
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return latest;
|
|
402
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function nowIso() {
|
|
5
|
+
return new Date().toISOString();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function normalizeEmailLower(v) {
|
|
9
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
10
|
+
if (!s || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) return null;
|
|
11
|
+
return s;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeRole(v) {
|
|
15
|
+
const role = String(v ?? "viewer").trim().toLowerCase();
|
|
16
|
+
if (role === "admin" || role === "approver" || role === "viewer") return role;
|
|
17
|
+
return "viewer";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function ensureDir(filePath) {
|
|
21
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function usersPath(dataDir, tenantId) {
|
|
25
|
+
return path.join(String(dataDir ?? "."), "buyer-users", `${String(tenantId ?? "").trim()}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeDoc(raw, tenantId) {
|
|
29
|
+
const out = {
|
|
30
|
+
schemaVersion: "BuyerUsers.v1",
|
|
31
|
+
tenantId: String(tenantId ?? "").trim(),
|
|
32
|
+
updatedAt: nowIso(),
|
|
33
|
+
users: {}
|
|
34
|
+
};
|
|
35
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
|
|
36
|
+
const users = raw.users && typeof raw.users === "object" && !Array.isArray(raw.users) ? raw.users : {};
|
|
37
|
+
for (const [rawEmail, rawUser] of Object.entries(users)) {
|
|
38
|
+
const email = normalizeEmailLower(rawEmail) ?? normalizeEmailLower(rawUser?.email);
|
|
39
|
+
if (!email) continue;
|
|
40
|
+
const role = normalizeRole(rawUser?.role);
|
|
41
|
+
const createdAt = typeof rawUser?.createdAt === "string" && rawUser.createdAt.trim() ? rawUser.createdAt : nowIso();
|
|
42
|
+
const updatedAt = typeof rawUser?.updatedAt === "string" && rawUser.updatedAt.trim() ? rawUser.updatedAt : createdAt;
|
|
43
|
+
out.users[email] = {
|
|
44
|
+
email,
|
|
45
|
+
role,
|
|
46
|
+
fullName: typeof rawUser?.fullName === "string" ? rawUser.fullName.trim() : "",
|
|
47
|
+
company: typeof rawUser?.company === "string" ? rawUser.company.trim() : "",
|
|
48
|
+
status: typeof rawUser?.status === "string" && rawUser.status.trim() ? String(rawUser.status).trim() : "active",
|
|
49
|
+
createdAt,
|
|
50
|
+
updatedAt,
|
|
51
|
+
lastLoginAt: typeof rawUser?.lastLoginAt === "string" && rawUser.lastLoginAt.trim() ? rawUser.lastLoginAt : null
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadDoc({ dataDir, tenantId }) {
|
|
58
|
+
const p = usersPath(dataDir, tenantId);
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(await fs.readFile(p, "utf8"));
|
|
61
|
+
return normalizeDoc(raw, tenantId);
|
|
62
|
+
} catch {
|
|
63
|
+
return normalizeDoc(null, tenantId);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function saveDoc({ dataDir, tenantId, doc }) {
|
|
68
|
+
const p = usersPath(dataDir, tenantId);
|
|
69
|
+
await ensureDir(p);
|
|
70
|
+
const tmp = `${p}.tmp-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
71
|
+
await fs.writeFile(tmp, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
72
|
+
await fs.rename(tmp, p);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toList(doc) {
|
|
76
|
+
return Object.values(doc.users)
|
|
77
|
+
.sort((a, b) => {
|
|
78
|
+
if (a.email < b.email) return -1;
|
|
79
|
+
if (a.email > b.email) return 1;
|
|
80
|
+
return 0;
|
|
81
|
+
})
|
|
82
|
+
.map((row) => ({
|
|
83
|
+
email: row.email,
|
|
84
|
+
role: row.role,
|
|
85
|
+
fullName: row.fullName,
|
|
86
|
+
company: row.company,
|
|
87
|
+
status: row.status,
|
|
88
|
+
createdAt: row.createdAt,
|
|
89
|
+
updatedAt: row.updatedAt,
|
|
90
|
+
lastLoginAt: row.lastLoginAt
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function listBuyerUsers({ dataDir, tenantId }) {
|
|
95
|
+
const doc = await loadDoc({ dataDir, tenantId });
|
|
96
|
+
return toList(doc);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function upsertBuyerUser({
|
|
100
|
+
dataDir,
|
|
101
|
+
tenantId,
|
|
102
|
+
email,
|
|
103
|
+
role = "viewer",
|
|
104
|
+
fullName = "",
|
|
105
|
+
company = "",
|
|
106
|
+
status = "active",
|
|
107
|
+
lastLoginAt = null
|
|
108
|
+
} = {}) {
|
|
109
|
+
const emailNorm = normalizeEmailLower(email);
|
|
110
|
+
if (!emailNorm) throw new TypeError("email is required");
|
|
111
|
+
const roleNorm = normalizeRole(role);
|
|
112
|
+
const nowAt = nowIso();
|
|
113
|
+
const doc = await loadDoc({ dataDir, tenantId });
|
|
114
|
+
const prev = doc.users[emailNorm] ?? null;
|
|
115
|
+
const next = {
|
|
116
|
+
email: emailNorm,
|
|
117
|
+
role: roleNorm,
|
|
118
|
+
fullName: typeof fullName === "string" ? fullName.trim() : prev?.fullName ?? "",
|
|
119
|
+
company: typeof company === "string" ? company.trim() : prev?.company ?? "",
|
|
120
|
+
status: typeof status === "string" && status.trim() ? status.trim() : prev?.status ?? "active",
|
|
121
|
+
createdAt: prev?.createdAt ?? nowAt,
|
|
122
|
+
updatedAt: nowAt,
|
|
123
|
+
lastLoginAt: typeof lastLoginAt === "string" && lastLoginAt.trim() ? lastLoginAt : prev?.lastLoginAt ?? null
|
|
124
|
+
};
|
|
125
|
+
doc.users[emailNorm] = next;
|
|
126
|
+
doc.updatedAt = nowAt;
|
|
127
|
+
await saveDoc({ dataDir, tenantId, doc });
|
|
128
|
+
return next;
|
|
129
|
+
}
|