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,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
+ }
@@ -0,0 +1,156 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { sendSmtpMail } from "./smtp.js";
6
+
7
+ function nowIso() {
8
+ return new Date().toISOString();
9
+ }
10
+
11
+ function sha256Hex(text) {
12
+ return crypto.createHash("sha256").update(String(text ?? ""), "utf8").digest("hex");
13
+ }
14
+
15
+ function clampText(v, { max }) {
16
+ const s = String(v ?? "").trim();
17
+ if (!s) return null;
18
+ return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
19
+ }
20
+
21
+ function normalizeEmail(value) {
22
+ const raw = clampText(value, { max: 320 });
23
+ if (!raw) return null;
24
+ const email = raw.toLowerCase();
25
+ if (!email.includes("@")) return null;
26
+ const [local, domain, ...rest] = email.split("@");
27
+ if (!local || !domain || rest.length) return null;
28
+ if (/\s/.test(email)) return null;
29
+ return email;
30
+ }
31
+
32
+ function otpRecordPath({ dataDir, token, email }) {
33
+ const key = sha256Hex(`${token}\n${email}`);
34
+ return path.join(dataDir, "decision-otp", token, `${key}.json`);
35
+ }
36
+
37
+ function otpOutboxPath({ dataDir, token, email }) {
38
+ const key = sha256Hex(`${token}\n${email}`);
39
+ return path.join(dataDir, "decision-otp-outbox", `${token}_${key}.json`);
40
+ }
41
+
42
+ function issueCode6() {
43
+ const n = crypto.randomInt(0, 1_000_000);
44
+ return String(n).padStart(6, "0");
45
+ }
46
+
47
+ export async function issueDecisionOtp({ dataDir, token, email, ttlSeconds, deliveryMode, smtp } = {}) {
48
+ const emailNorm = normalizeEmail(email);
49
+ if (!emailNorm) return { ok: false, error: "INVALID_EMAIL", message: "invalid email" };
50
+
51
+ const ttl = Number.parseInt(String(ttlSeconds ?? ""), 10);
52
+ if (!Number.isInteger(ttl) || ttl <= 0) throw new TypeError("ttlSeconds must be a positive integer");
53
+
54
+ const code = issueCode6();
55
+ const createdAt = nowIso();
56
+ const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
57
+ const codeSha256 = sha256Hex(`${token}\n${emailNorm}\n${code}`);
58
+
59
+ const record = {
60
+ schemaVersion: "DecisionOtpRecord.v1",
61
+ token,
62
+ email: emailNorm,
63
+ codeSha256,
64
+ createdAt,
65
+ expiresAt,
66
+ consumedAt: null,
67
+ attempts: 0
68
+ };
69
+
70
+ const fp = otpRecordPath({ dataDir, token, email: emailNorm });
71
+ await fs.mkdir(path.dirname(fp), { recursive: true });
72
+ await fs.writeFile(fp, JSON.stringify(record, null, 2) + "\n", "utf8");
73
+
74
+ const mode = String(deliveryMode ?? "record").trim().toLowerCase();
75
+ if (mode === "record") {
76
+ const outFp = otpOutboxPath({ dataDir, token, email: emailNorm });
77
+ await fs.mkdir(path.dirname(outFp), { recursive: true });
78
+ await fs.writeFile(
79
+ outFp,
80
+ JSON.stringify(
81
+ { schemaVersion: "DecisionOtpOutboxRecord.v1", token, email: emailNorm, code, createdAt, expiresAt },
82
+ null,
83
+ 2
84
+ ) + "\n",
85
+ "utf8"
86
+ );
87
+ } else if (mode === "log") {
88
+ // eslint-disable-next-line no-console
89
+ console.log(`decision otp token=${token} email=${emailNorm} code=${code} expiresAt=${expiresAt}`);
90
+ } else if (mode === "smtp") {
91
+ const from = typeof smtp?.from === "string" ? smtp.from.trim() : "";
92
+ if (!from) return { ok: false, error: "SMTP_NOT_CONFIGURED", message: "smtp.from is required" };
93
+ try {
94
+ await sendSmtpMail({
95
+ host: smtp?.host,
96
+ port: smtp?.port,
97
+ secure: Boolean(smtp?.secure),
98
+ starttls: smtp?.starttls === undefined ? true : Boolean(smtp?.starttls),
99
+ auth: smtp?.user && smtp?.pass ? { user: smtp.user, pass: smtp.pass } : null,
100
+ from,
101
+ to: emailNorm,
102
+ subject: "Your Settld decision code",
103
+ text: `Your decision code is: ${code}\n\nThis code expires at: ${expiresAt}\n\nIf you did not request this code, you can ignore this email.\n`
104
+ });
105
+ } catch (err) {
106
+ return { ok: false, error: "SMTP_SEND_FAILED", message: err?.message ?? String(err ?? "smtp failed") };
107
+ }
108
+ } else {
109
+ throw new Error("invalid deliveryMode");
110
+ }
111
+
112
+ return { ok: true, email: emailNorm, expiresAt };
113
+ }
114
+
115
+ export async function verifyAndConsumeDecisionOtp({ dataDir, token, email, code, maxAttempts }) {
116
+ const emailNorm = normalizeEmail(email);
117
+ const codeNorm = clampText(code, { max: 32 });
118
+ if (!emailNorm || !codeNorm) return { ok: false, error: "OTP_INVALID", message: "email and code are required" };
119
+
120
+ const max = Number.parseInt(String(maxAttempts ?? ""), 10);
121
+ if (!Number.isInteger(max) || max < 1) throw new TypeError("maxAttempts must be an integer >= 1");
122
+
123
+ const fp = otpRecordPath({ dataDir, token, email: emailNorm });
124
+ let rec = null;
125
+ try {
126
+ rec = JSON.parse(await fs.readFile(fp, "utf8"));
127
+ } catch {
128
+ rec = null;
129
+ }
130
+ if (!rec || typeof rec !== "object" || Array.isArray(rec) || rec.schemaVersion !== "DecisionOtpRecord.v1") {
131
+ return { ok: false, error: "OTP_MISSING", message: "no active otp" };
132
+ }
133
+ if (String(rec.token ?? "") !== token || String(rec.email ?? "") !== emailNorm) {
134
+ return { ok: false, error: "OTP_MISSING", message: "no active otp" };
135
+ }
136
+ if (typeof rec.consumedAt === "string" && rec.consumedAt) return { ok: false, error: "OTP_CONSUMED", message: "otp already used" };
137
+
138
+ const expiresMs = Date.parse(String(rec.expiresAt ?? ""));
139
+ if (!Number.isFinite(expiresMs) || Date.now() > expiresMs) return { ok: false, error: "OTP_EXPIRED", message: "otp expired" };
140
+
141
+ const attempts = Number.parseInt(String(rec.attempts ?? "0"), 10);
142
+ if (Number.isInteger(attempts) && attempts >= max) return { ok: false, error: "OTP_LOCKED", message: "too many attempts" };
143
+
144
+ const expected = String(rec.codeSha256 ?? "");
145
+ const actual = sha256Hex(`${token}\n${emailNorm}\n${codeNorm}`);
146
+ if (expected !== actual) {
147
+ rec.attempts = (Number.isInteger(attempts) ? attempts : 0) + 1;
148
+ await fs.writeFile(fp, JSON.stringify(rec, null, 2) + "\n", "utf8");
149
+ return { ok: false, error: "OTP_INVALID", message: "invalid code" };
150
+ }
151
+
152
+ rec.consumedAt = nowIso();
153
+ rec.attempts = Number.isInteger(attempts) ? attempts : 0;
154
+ await fs.writeFile(fp, JSON.stringify(rec, null, 2) + "\n", "utf8");
155
+ return { ok: true };
156
+ }