settld 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/Dockerfile +2 -2
  2. package/docs/CONFIG.md +12 -0
  3. package/docs/README.md +3 -0
  4. package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
  5. package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
  6. package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
  7. package/package.json +4 -1
  8. package/packages/api-sdk/README.md +71 -0
  9. package/packages/api-sdk/src/client.js +1021 -0
  10. package/packages/api-sdk/src/express-middleware.js +163 -0
  11. package/packages/api-sdk/src/index.d.ts +1662 -0
  12. package/packages/api-sdk/src/index.js +10 -0
  13. package/packages/api-sdk/src/webhook-signature.js +182 -0
  14. package/packages/api-sdk/src/x402-autopay.js +210 -0
  15. package/scripts/ci/cli-pack-smoke.mjs +2 -0
  16. package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
  17. package/scripts/setup/login.mjs +73 -2
  18. package/scripts/setup/onboard.mjs +173 -28
  19. package/scripts/setup/onboarding-failure-taxonomy.mjs +107 -0
  20. package/scripts/setup/onboarding-state-machine.mjs +102 -0
  21. package/services/magic-link/README.md +352 -0
  22. package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
  23. package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
  24. package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
  25. package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
  26. package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
  27. package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
  28. package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
  29. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
  30. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
  31. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
  32. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
  33. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
  34. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
  35. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
  36. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
  37. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
  38. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
  39. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
  40. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
  41. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
  42. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
  43. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
  44. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
  45. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
  46. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
  47. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
  48. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
  49. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
  50. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
  51. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
  52. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
  53. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
  54. package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
  55. package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
  56. package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
  57. package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
  58. package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
  59. package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
  60. package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
  61. package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
  62. package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
  63. package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
  64. package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
  65. package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
  66. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
  67. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
  68. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
  69. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
  70. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
  71. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
  72. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
  73. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
  74. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
  75. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
  76. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
  77. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
  78. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
  79. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
  80. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
  81. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
  82. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
  83. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
  84. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
  85. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
  86. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
  87. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
  88. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
  89. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
  90. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
  91. package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
  92. package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
  93. package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
  94. package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
  95. package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
  96. package/services/magic-link/assets/samples/trust.json +11 -0
  97. package/services/magic-link/src/audit-log.js +24 -0
  98. package/services/magic-link/src/buyer-auth.js +251 -0
  99. package/services/magic-link/src/buyer-notifications.js +402 -0
  100. package/services/magic-link/src/buyer-users.js +129 -0
  101. package/services/magic-link/src/decision-otp.js +187 -0
  102. package/services/magic-link/src/decisions.js +92 -0
  103. package/services/magic-link/src/email-resend.js +89 -0
  104. package/services/magic-link/src/ingest-keys.js +137 -0
  105. package/services/magic-link/src/maintenance.js +95 -0
  106. package/services/magic-link/src/onboarding-email-sequence.js +331 -0
  107. package/services/magic-link/src/payment-triggers.js +733 -0
  108. package/services/magic-link/src/pdf.js +149 -0
  109. package/services/magic-link/src/policy.js +69 -0
  110. package/services/magic-link/src/redaction.js +6 -0
  111. package/services/magic-link/src/render-model.js +70 -0
  112. package/services/magic-link/src/retention-gc.js +158 -0
  113. package/services/magic-link/src/run-records.js +496 -0
  114. package/services/magic-link/src/s3.js +171 -0
  115. package/services/magic-link/src/server.js +15849 -0
  116. package/services/magic-link/src/settlement-decisions.js +84 -0
  117. package/services/magic-link/src/smtp.js +217 -0
  118. package/services/magic-link/src/storage-cli.js +88 -0
  119. package/services/magic-link/src/storage-format.js +59 -0
  120. package/services/magic-link/src/tenant-billing.js +115 -0
  121. package/services/magic-link/src/tenant-onboarding.js +467 -0
  122. package/services/magic-link/src/tenant-settings.js +1140 -0
  123. package/services/magic-link/src/usage.js +80 -0
  124. package/services/magic-link/src/verify-queue.js +179 -0
  125. package/services/magic-link/src/verify-worker.js +157 -0
  126. package/services/magic-link/src/webhook-retries.js +542 -0
  127. package/services/magic-link/src/webhooks.js +218 -0
  128. package/src/api/app.js +135 -1
@@ -0,0 +1,1140 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ function isPlainObject(v) {
6
+ return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
7
+ }
8
+
9
+ function trustRootSetHashHex(keyIds) {
10
+ const list = Array.isArray(keyIds) ? keyIds.map(String).filter(Boolean).sort() : [];
11
+ const data = JSON.stringify(list);
12
+ return crypto.createHash("sha256").update(data, "utf8").digest("hex");
13
+ }
14
+
15
+ function parseSettingsKeyHex(raw) {
16
+ const s = String(raw ?? "").trim();
17
+ if (!s) return null;
18
+ if (!/^[0-9a-fA-F]{64}$/.test(s)) throw new Error("MAGIC_LINK_SETTINGS_KEY_HEX must be 64 hex chars (32 bytes)");
19
+ return Buffer.from(s, "hex");
20
+ }
21
+
22
+ function encryptStringAes256Gcm({ key, plaintext }) {
23
+ const iv = crypto.randomBytes(12);
24
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
25
+ const ciphertext = Buffer.concat([cipher.update(String(plaintext ?? ""), "utf8"), cipher.final()]);
26
+ const tag = cipher.getAuthTag();
27
+ const packed = Buffer.concat([iv, tag, ciphertext]);
28
+ return `enc:v1:${packed.toString("base64")}`;
29
+ }
30
+
31
+ function decryptStringAes256Gcm({ key, value }) {
32
+ const v = String(value ?? "");
33
+ if (!v.startsWith("enc:v1:")) return v;
34
+ const b64 = v.slice("enc:v1:".length);
35
+ const packed = Buffer.from(b64, "base64");
36
+ if (packed.length < 12 + 16) throw new Error("invalid ciphertext");
37
+ const iv = packed.subarray(0, 12);
38
+ const tag = packed.subarray(12, 28);
39
+ const ciphertext = packed.subarray(28);
40
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
41
+ decipher.setAuthTag(tag);
42
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
43
+ return plaintext;
44
+ }
45
+
46
+ export const TENANT_PLAN_CATALOG = Object.freeze({
47
+ free: Object.freeze({
48
+ plan: "free",
49
+ displayName: "Free",
50
+ limits: Object.freeze({
51
+ maxVerificationsPerMonth: 100,
52
+ maxStoredBundles: 100,
53
+ maxPolicyVersions: 10,
54
+ maxIntegrations: 5,
55
+ retentionDays: 30,
56
+ }),
57
+ billing: Object.freeze({
58
+ subscriptionCents: 0,
59
+ pricePerVerificationCents: 0,
60
+ }),
61
+ }),
62
+ builder: Object.freeze({
63
+ plan: "builder",
64
+ displayName: "Builder",
65
+ limits: Object.freeze({
66
+ maxVerificationsPerMonth: 10000,
67
+ maxStoredBundles: 1000,
68
+ maxPolicyVersions: 20,
69
+ maxIntegrations: 10,
70
+ retentionDays: 30,
71
+ }),
72
+ billing: Object.freeze({
73
+ subscriptionCents: 9900,
74
+ pricePerVerificationCents: 1,
75
+ pricePerSettledVolumeBps: 75,
76
+ pricePerArbitrationCaseCents: 200,
77
+ }),
78
+ }),
79
+ growth: Object.freeze({
80
+ plan: "growth",
81
+ displayName: "Growth",
82
+ limits: Object.freeze({
83
+ maxVerificationsPerMonth: 100000,
84
+ maxStoredBundles: 10000,
85
+ maxPolicyVersions: 50,
86
+ maxIntegrations: 25,
87
+ retentionDays: 180,
88
+ }),
89
+ billing: Object.freeze({
90
+ subscriptionCents: 59900,
91
+ pricePerVerificationCents: 0.7,
92
+ pricePerSettledVolumeBps: 45,
93
+ pricePerArbitrationCaseCents: 100,
94
+ }),
95
+ }),
96
+ enterprise: Object.freeze({
97
+ plan: "enterprise",
98
+ displayName: "Enterprise",
99
+ limits: Object.freeze({
100
+ maxVerificationsPerMonth: null,
101
+ maxStoredBundles: null,
102
+ maxPolicyVersions: null,
103
+ maxIntegrations: null,
104
+ retentionDays: 365,
105
+ }),
106
+ billing: Object.freeze({
107
+ // Enterprise is contract-priced; defaults are zero and set via negotiated terms.
108
+ subscriptionCents: 0,
109
+ pricePerVerificationCents: 0,
110
+ }),
111
+ }),
112
+ });
113
+
114
+ export function normalizeTenantPlan(value, { allowNull = false } = {}) {
115
+ if (value === null || value === undefined || value === "") {
116
+ if (allowNull) return null;
117
+ return "free";
118
+ }
119
+ const rawPlan = String(value).trim().toLowerCase();
120
+ // Backward compatibility for older persisted settings.
121
+ const plan = rawPlan === "scale" ? "enterprise" : rawPlan;
122
+ if (!Object.prototype.hasOwnProperty.call(TENANT_PLAN_CATALOG, plan)) {
123
+ throw new TypeError("plan must be free|builder|growth|enterprise");
124
+ }
125
+ return plan;
126
+ }
127
+
128
+ export function defaultTenantSettings() {
129
+ return {
130
+ schemaVersion: "TenantSettings.v2",
131
+ plan: "free",
132
+ defaultMode: "auto",
133
+ governanceTrustRootsJson: null,
134
+ pricingSignerKeysJson: null,
135
+ trustedPricingSignerKeyIds: null,
136
+ retentionDays: 30,
137
+ maxUploadBytesOverride: null,
138
+ maxVerificationsPerMonth: null,
139
+ maxStoredBundles: null,
140
+ vendorPolicies: null,
141
+ contractPolicies: null,
142
+ buyerAuthEmailDomains: [],
143
+ buyerUserRoles: null,
144
+ buyerNotifications: { emails: [], deliveryMode: "smtp", webhookUrl: null, webhookSecret: null },
145
+ autoDecision: {
146
+ enabled: false,
147
+ approveOnGreen: false,
148
+ approveOnAmber: false,
149
+ holdOnRed: false,
150
+ templateIds: null,
151
+ actorName: "Settld AutoDecision",
152
+ actorEmail: "automation@settld.local"
153
+ },
154
+ paymentTriggers: { enabled: false, deliveryMode: "record", webhookUrl: null, webhookSecret: null },
155
+ decisionAuthEmailDomains: [],
156
+ settlementDecisionSigner: null,
157
+ rateLimits: {
158
+ uploadsPerHour: 100,
159
+ verificationViewsPerHour: 1000,
160
+ decisionsPerHour: 300,
161
+ otpRequestsPerHour: 300,
162
+ conformanceRunsPerHour: 12
163
+ },
164
+ webhooks: [],
165
+ artifactStorage: { storeBundleZip: true, storePdf: true, precomputeMonthlyAuditPackets: false },
166
+ archiveExportSink: null
167
+ };
168
+ }
169
+
170
+ export function resolveTenantEntitlements({ settings, defaultBilling = null } = {}) {
171
+ const source = isPlainObject(settings) ? settings : defaultTenantSettings();
172
+ const plan = normalizeTenantPlan(source.plan, { allowNull: false });
173
+ const base = TENANT_PLAN_CATALOG[plan] ?? TENANT_PLAN_CATALOG.free;
174
+ const fallbackBilling = isPlainObject(defaultBilling) ? defaultBilling : null;
175
+ const fallbackSubscriptionCents = Number(String(fallbackBilling?.subscriptionCents ?? ""));
176
+ const fallbackPerVerificationCents = Number(String(fallbackBilling?.pricePerVerificationCents ?? ""));
177
+
178
+ const maxVerificationsPerMonth = Number.isInteger(source.maxVerificationsPerMonth) ? source.maxVerificationsPerMonth : base.limits.maxVerificationsPerMonth;
179
+ const maxStoredBundles = Number.isInteger(source.maxStoredBundles) ? source.maxStoredBundles : base.limits.maxStoredBundles;
180
+ const retentionDays = Number.isInteger(source.retentionDays) ? source.retentionDays : base.limits.retentionDays;
181
+ const uploadsPerHour = Number.isInteger(source?.rateLimits?.uploadsPerHour) ? source.rateLimits.uploadsPerHour : defaultTenantSettings().rateLimits.uploadsPerHour;
182
+ const verificationViewsPerHour = Number.isInteger(source?.rateLimits?.verificationViewsPerHour)
183
+ ? source.rateLimits.verificationViewsPerHour
184
+ : defaultTenantSettings().rateLimits.verificationViewsPerHour;
185
+ const decisionsPerHour = Number.isInteger(source?.rateLimits?.decisionsPerHour)
186
+ ? source.rateLimits.decisionsPerHour
187
+ : defaultTenantSettings().rateLimits.decisionsPerHour;
188
+ const conformanceRunsPerHour = Number.isInteger(source?.rateLimits?.conformanceRunsPerHour)
189
+ ? source.rateLimits.conformanceRunsPerHour
190
+ : defaultTenantSettings().rateLimits.conformanceRunsPerHour;
191
+
192
+ const baseSubscriptionCents = Number(String(base?.billing?.subscriptionCents ?? ""));
193
+ const basePerVerificationCents = Number(String(base?.billing?.pricePerVerificationCents ?? ""));
194
+ const subscriptionCents = Number.isFinite(baseSubscriptionCents) && baseSubscriptionCents >= 0
195
+ ? baseSubscriptionCents
196
+ : Number.isFinite(fallbackSubscriptionCents) && fallbackSubscriptionCents >= 0
197
+ ? fallbackSubscriptionCents
198
+ : 0;
199
+ const pricePerVerificationCents = Number.isFinite(basePerVerificationCents) && basePerVerificationCents >= 0
200
+ ? basePerVerificationCents
201
+ : Number.isFinite(fallbackPerVerificationCents) && fallbackPerVerificationCents >= 0
202
+ ? fallbackPerVerificationCents
203
+ : 0;
204
+
205
+ return {
206
+ schemaVersion: "TenantEntitlements.v1",
207
+ plan,
208
+ displayName: base.displayName,
209
+ limits: {
210
+ maxVerificationsPerMonth,
211
+ maxStoredBundles,
212
+ maxPolicyVersions: base.limits.maxPolicyVersions,
213
+ maxIntegrations: base.limits.maxIntegrations,
214
+ retentionDays
215
+ },
216
+ rateLimits: {
217
+ uploadsPerHour,
218
+ verificationViewsPerHour,
219
+ decisionsPerHour,
220
+ conformanceRunsPerHour
221
+ },
222
+ billing: {
223
+ subscriptionCents,
224
+ pricePerVerificationCents
225
+ }
226
+ };
227
+ }
228
+
229
+ function normalizeEmailLower(value) {
230
+ const raw = String(value ?? "").trim();
231
+ if (!raw) return null;
232
+ if (raw.length > 320) return null;
233
+ const email = raw.toLowerCase();
234
+ if (/\s/.test(email)) return null;
235
+ const parts = email.split("@");
236
+ if (parts.length !== 2) return null;
237
+ const [local, domain] = parts;
238
+ if (!local || !domain) return null;
239
+ return email;
240
+ }
241
+
242
+ function normalizeSafeId(raw, { fieldName, maxLen = 128 } = {}) {
243
+ const id = String(raw ?? "").trim();
244
+ if (!id) return { ok: false, error: `${fieldName} must be a non-empty string` };
245
+ if (id.length > maxLen) return { ok: false, error: `${fieldName} must be <= ${maxLen} chars` };
246
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return { ok: false, error: `${fieldName} must match [A-Za-z0-9_-]+` };
247
+ return { ok: true, id };
248
+ }
249
+
250
+ function normalizeTrustRootsJson(value) {
251
+ if (value === undefined) return { ok: true, roots: undefined };
252
+ if (value === null) return { ok: true, roots: null };
253
+ if (!isPlainObject(value)) return { ok: false, error: "governanceTrustRootsJson must be an object or null" };
254
+ const out = {};
255
+ for (const [k, v] of Object.entries(value)) {
256
+ const keyId = String(k ?? "").trim();
257
+ if (!keyId) continue;
258
+ if (typeof v !== "string" || !v.trim()) return { ok: false, error: "governanceTrustRootsJson values must be non-empty strings" };
259
+ out[keyId] = v;
260
+ }
261
+ return { ok: true, roots: out };
262
+ }
263
+
264
+ function normalizePricingSignerKeysJson(value) {
265
+ if (value === undefined) return { ok: true, keys: undefined };
266
+ if (value === null) return { ok: true, keys: null };
267
+ if (!isPlainObject(value)) return { ok: false, error: "pricingSignerKeysJson must be an object or null" };
268
+ const out = {};
269
+ for (const [k, v] of Object.entries(value)) {
270
+ const keyId = String(k ?? "").trim();
271
+ if (!keyId) continue;
272
+ if (typeof v !== "string" || !v.trim()) return { ok: false, error: "pricingSignerKeysJson values must be non-empty strings" };
273
+ out[keyId] = v;
274
+ }
275
+ return { ok: true, keys: out };
276
+ }
277
+
278
+ function normalizeWebhookConfigList(value, { current = [] } = {}) {
279
+ if (value === undefined) return { ok: true, webhooks: undefined };
280
+ if (!Array.isArray(value)) return { ok: false, error: "webhooks must be an array" };
281
+ const cur = Array.isArray(current) ? current : [];
282
+
283
+ const out = [];
284
+ for (const w of value) {
285
+ if (!isPlainObject(w)) return { ok: false, error: "webhook must be an object" };
286
+ const url = typeof w.url === "string" ? w.url.trim() : "";
287
+ if (!url) return { ok: false, error: "webhook.url is required" };
288
+ const enabled = Boolean(w.enabled);
289
+ const eventsRaw = Array.isArray(w.events) ? w.events.map((e) => String(e ?? "").trim()).filter(Boolean) : [];
290
+ const eventsSet = new Set(eventsRaw);
291
+ const events = [...eventsSet].sort();
292
+ const allowed = new Set(["verification.completed", "verification.failed", "decision.approved", "decision.held"]);
293
+ for (const e of events) if (!allowed.has(e)) return { ok: false, error: "invalid webhook event", event: e };
294
+ if (!events.length) return { ok: false, error: "webhook.events must be non-empty" };
295
+
296
+ let secret = w.secret;
297
+ if (secret === undefined) {
298
+ // Carry forward existing secret if url+events match.
299
+ const prior = cur.find((p) => isPlainObject(p) && p.url === url && Array.isArray(p.events) && JSON.stringify([...new Set(p.events)].sort()) === JSON.stringify(events));
300
+ secret = prior ? prior.secret : null;
301
+ }
302
+ if (secret !== null && secret !== undefined && typeof secret !== "string") return { ok: false, error: "webhook.secret must be string|null" };
303
+ const secretNorm = typeof secret === "string" ? secret : secret === null ? null : null;
304
+
305
+ out.push({ url, events, enabled, secret: secretNorm });
306
+ }
307
+ return { ok: true, webhooks: out };
308
+ }
309
+
310
+ function normalizeArtifactStorageConfig(value, { current } = {}) {
311
+ if (value === undefined) return { ok: true, artifactStorage: undefined };
312
+ if (value === null) return { ok: true, artifactStorage: null };
313
+ if (!isPlainObject(value)) return { ok: false, error: "artifactStorage must be an object or null" };
314
+ const base = { ...defaultTenantSettings().artifactStorage, ...(isPlainObject(current) ? current : {}) };
315
+ const out = {};
316
+ for (const k of ["storeBundleZip", "storePdf", "precomputeMonthlyAuditPackets"]) {
317
+ if (value[k] === undefined) out[k] = Boolean(base[k]);
318
+ else out[k] = Boolean(value[k]);
319
+ }
320
+ return { ok: true, artifactStorage: out };
321
+ }
322
+
323
+ function normalizeArchiveExportSink(value, { current } = {}) {
324
+ if (value === undefined) return { ok: true, archiveExportSink: undefined };
325
+ if (value === null) return { ok: true, archiveExportSink: null };
326
+ if (!isPlainObject(value)) return { ok: false, error: "archiveExportSink must be an object or null" };
327
+
328
+ const cur = isPlainObject(current) ? current : null;
329
+
330
+ const type = typeof value.type === "string" ? value.type.trim() : "";
331
+ if (type !== "s3") return { ok: false, error: "archiveExportSink.type must be s3" };
332
+
333
+ const enabled = Boolean(value.enabled);
334
+ const endpointRaw = value.endpoint === null || value.endpoint === undefined ? null : String(value.endpoint);
335
+ const endpoint = endpointRaw && endpointRaw.trim() ? endpointRaw.trim() : null;
336
+ if (endpoint !== null) {
337
+ try {
338
+ const u = new URL(endpoint);
339
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, error: "archiveExportSink.endpoint must be http(s)" };
340
+ } catch {
341
+ return { ok: false, error: "archiveExportSink.endpoint invalid URL" };
342
+ }
343
+ }
344
+
345
+ const regionRaw = value.region === null || value.region === undefined ? null : String(value.region);
346
+ const region = regionRaw && regionRaw.trim() ? regionRaw.trim() : null;
347
+
348
+ const bucketRaw = value.bucket === null || value.bucket === undefined ? null : String(value.bucket);
349
+ const bucket = bucketRaw && bucketRaw.trim() ? bucketRaw.trim() : null;
350
+ const prefixRaw = value.prefix === null || value.prefix === undefined ? null : String(value.prefix);
351
+ const prefix = prefixRaw && prefixRaw.trim() ? prefixRaw.trim().replaceAll("\\", "/").replace(/^\/+/, "") : "";
352
+
353
+ const accessKeyIdRaw = value.accessKeyId === null || value.accessKeyId === undefined ? null : String(value.accessKeyId);
354
+ const accessKeyId = accessKeyIdRaw && accessKeyIdRaw.trim() ? accessKeyIdRaw.trim() : null;
355
+
356
+ let secretAccessKey = value.secretAccessKey;
357
+ if (secretAccessKey === undefined && cur && typeof cur.secretAccessKey === "string") secretAccessKey = cur.secretAccessKey;
358
+ if (secretAccessKey !== null && secretAccessKey !== undefined && typeof secretAccessKey !== "string") return { ok: false, error: "archiveExportSink.secretAccessKey must be string|null" };
359
+ const secretAccessKeyNorm = typeof secretAccessKey === "string" && secretAccessKey.trim() ? secretAccessKey : null;
360
+
361
+ let sessionToken = value.sessionToken;
362
+ if (sessionToken === undefined && cur && typeof cur.sessionToken === "string") sessionToken = cur.sessionToken;
363
+ if (sessionToken !== null && sessionToken !== undefined && typeof sessionToken !== "string") return { ok: false, error: "archiveExportSink.sessionToken must be string|null" };
364
+ const sessionTokenNorm = typeof sessionToken === "string" && sessionToken.trim() ? sessionToken : null;
365
+
366
+ const sseRaw = typeof value.sse === "string" ? value.sse.trim() : "";
367
+ const sse = sseRaw === "aes256" || sseRaw === "aws:kms" ? sseRaw : "none";
368
+ const kmsKeyIdRaw = value.kmsKeyId === null || value.kmsKeyId === undefined ? null : String(value.kmsKeyId);
369
+ const kmsKeyId = kmsKeyIdRaw && kmsKeyIdRaw.trim() ? kmsKeyIdRaw.trim() : null;
370
+ if (sse === "aws:kms" && !kmsKeyId) return { ok: false, error: "archiveExportSink.kmsKeyId required when sse=aws:kms" };
371
+
372
+ const pathStyle = value.pathStyle === null || value.pathStyle === undefined ? null : Boolean(value.pathStyle);
373
+
374
+ if (enabled) {
375
+ if (!bucket) return { ok: false, error: "archiveExportSink.bucket is required when enabled" };
376
+ if (!accessKeyId || !secretAccessKeyNorm) return { ok: false, error: "archiveExportSink accessKeyId/secretAccessKey required when enabled" };
377
+ if (!region && !endpoint) return { ok: false, error: "archiveExportSink.region is required when enabled (unless endpoint is set)" };
378
+ }
379
+
380
+ return {
381
+ ok: true,
382
+ archiveExportSink: {
383
+ type,
384
+ enabled,
385
+ endpoint,
386
+ region,
387
+ bucket,
388
+ prefix,
389
+ pathStyle,
390
+ accessKeyId,
391
+ secretAccessKey: secretAccessKeyNorm,
392
+ sessionToken: sessionTokenNorm,
393
+ sse,
394
+ kmsKeyId
395
+ }
396
+ };
397
+ }
398
+
399
+ function normalizeBuyerNotifications(value, { current } = {}) {
400
+ if (value === undefined) return { ok: true, buyerNotifications: undefined };
401
+ if (value === null) return { ok: true, buyerNotifications: { ...defaultTenantSettings().buyerNotifications } };
402
+ if (!isPlainObject(value)) return { ok: false, error: "buyerNotifications must be an object or null" };
403
+
404
+ const cur = isPlainObject(current) ? current : defaultTenantSettings().buyerNotifications;
405
+
406
+ let emails = value.emails;
407
+ if (emails === undefined) emails = cur?.emails;
408
+ if (emails === null) emails = [];
409
+ if (!Array.isArray(emails)) return { ok: false, error: "buyerNotifications.emails must be an array or null" };
410
+ const normalizedEmails = [];
411
+ for (const raw of emails) {
412
+ const email = normalizeEmailLower(raw);
413
+ if (!email) return { ok: false, error: "buyerNotifications.emails contains invalid email", email: raw };
414
+ normalizedEmails.push(email);
415
+ }
416
+
417
+ const deliveryModeRaw = value.deliveryMode === undefined ? cur?.deliveryMode : value.deliveryMode;
418
+ const deliveryMode = String(deliveryModeRaw ?? "smtp").trim().toLowerCase();
419
+ if (deliveryMode !== "smtp" && deliveryMode !== "webhook" && deliveryMode !== "record") {
420
+ return { ok: false, error: "buyerNotifications.deliveryMode must be smtp|webhook|record" };
421
+ }
422
+
423
+ const webhookUrlRaw = value.webhookUrl === undefined ? cur?.webhookUrl : value.webhookUrl;
424
+ const webhookUrl = webhookUrlRaw === null || webhookUrlRaw === undefined ? null : String(webhookUrlRaw).trim();
425
+ if (deliveryMode === "webhook") {
426
+ if (!webhookUrl) return { ok: false, error: "buyerNotifications.webhookUrl is required when deliveryMode=webhook" };
427
+ try {
428
+ const u = new URL(webhookUrl);
429
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, error: "buyerNotifications.webhookUrl must be http(s)" };
430
+ } catch {
431
+ return { ok: false, error: "buyerNotifications.webhookUrl invalid URL" };
432
+ }
433
+ }
434
+
435
+ let webhookSecret = value.webhookSecret;
436
+ if (webhookSecret === undefined && cur && typeof cur.webhookSecret === "string") webhookSecret = cur.webhookSecret;
437
+ if (webhookSecret !== null && webhookSecret !== undefined && typeof webhookSecret !== "string") {
438
+ return { ok: false, error: "buyerNotifications.webhookSecret must be string|null" };
439
+ }
440
+ const webhookSecretNorm = typeof webhookSecret === "string" && webhookSecret.trim() ? webhookSecret : null;
441
+
442
+ return {
443
+ ok: true,
444
+ buyerNotifications: {
445
+ emails: [...new Set(normalizedEmails)].sort(),
446
+ deliveryMode,
447
+ webhookUrl: webhookUrl || null,
448
+ webhookSecret: webhookSecretNorm
449
+ }
450
+ };
451
+ }
452
+
453
+ function normalizeAutoDecision(value, { current } = {}) {
454
+ if (value === undefined) return { ok: true, autoDecision: undefined };
455
+ if (value === null) return { ok: true, autoDecision: { ...defaultTenantSettings().autoDecision } };
456
+ if (!isPlainObject(value)) return { ok: false, error: "autoDecision must be an object or null" };
457
+
458
+ const cur = isPlainObject(current) ? current : defaultTenantSettings().autoDecision;
459
+ const out = {};
460
+
461
+ const enabledRaw = value.enabled === undefined ? cur?.enabled : value.enabled;
462
+ out.enabled = Boolean(enabledRaw);
463
+ const approveOnGreenRaw = value.approveOnGreen === undefined ? cur?.approveOnGreen : value.approveOnGreen;
464
+ out.approveOnGreen = Boolean(approveOnGreenRaw);
465
+ const approveOnAmberRaw = value.approveOnAmber === undefined ? cur?.approveOnAmber : value.approveOnAmber;
466
+ out.approveOnAmber = Boolean(approveOnAmberRaw);
467
+ const holdOnRedRaw = value.holdOnRed === undefined ? cur?.holdOnRed : value.holdOnRed;
468
+ out.holdOnRed = Boolean(holdOnRedRaw);
469
+
470
+ let templateIdsRaw = value.templateIds;
471
+ if (templateIdsRaw === undefined) templateIdsRaw = cur?.templateIds;
472
+ if (templateIdsRaw === null) out.templateIds = null;
473
+ else if (templateIdsRaw === undefined) out.templateIds = null;
474
+ else if (!Array.isArray(templateIdsRaw)) return { ok: false, error: "autoDecision.templateIds must be an array or null" };
475
+ else {
476
+ const ids = [];
477
+ for (const row of templateIdsRaw) {
478
+ const parsed = normalizeSafeId(row, { fieldName: "autoDecision.templateIds[]" });
479
+ if (!parsed.ok) return parsed;
480
+ ids.push(parsed.id);
481
+ }
482
+ out.templateIds = [...new Set(ids)].sort();
483
+ }
484
+
485
+ let actorNameRaw = value.actorName;
486
+ if (actorNameRaw === undefined) actorNameRaw = cur?.actorName;
487
+ const actorName = String(actorNameRaw ?? "").trim();
488
+ if (!actorName) return { ok: false, error: "autoDecision.actorName is required" };
489
+ if (actorName.length > 200) return { ok: false, error: "autoDecision.actorName must be <= 200 chars" };
490
+ out.actorName = actorName;
491
+
492
+ let actorEmailRaw = value.actorEmail;
493
+ if (actorEmailRaw === undefined) actorEmailRaw = cur?.actorEmail;
494
+ const actorEmail = normalizeEmailLower(actorEmailRaw);
495
+ if (!actorEmail) return { ok: false, error: "autoDecision.actorEmail must be a valid email" };
496
+ out.actorEmail = actorEmail;
497
+
498
+ if (out.enabled && !out.approveOnGreen && !out.approveOnAmber && !out.holdOnRed) {
499
+ return { ok: false, error: "autoDecision enabled requires at least one action: approveOnGreen, approveOnAmber, or holdOnRed" };
500
+ }
501
+
502
+ return { ok: true, autoDecision: out };
503
+ }
504
+
505
+ function normalizePaymentTriggers(value, { current } = {}) {
506
+ if (value === undefined) return { ok: true, paymentTriggers: undefined };
507
+ if (value === null) return { ok: true, paymentTriggers: { ...defaultTenantSettings().paymentTriggers } };
508
+ if (!isPlainObject(value)) return { ok: false, error: "paymentTriggers must be an object or null" };
509
+
510
+ const cur = isPlainObject(current) ? current : defaultTenantSettings().paymentTriggers;
511
+ const out = {};
512
+
513
+ const enabledRaw = value.enabled === undefined ? cur?.enabled : value.enabled;
514
+ out.enabled = Boolean(enabledRaw);
515
+
516
+ const deliveryModeRaw = value.deliveryMode === undefined ? cur?.deliveryMode : value.deliveryMode;
517
+ const deliveryMode = String(deliveryModeRaw ?? "record").trim().toLowerCase();
518
+ if (deliveryMode !== "record" && deliveryMode !== "webhook") return { ok: false, error: "paymentTriggers.deliveryMode must be record|webhook" };
519
+ out.deliveryMode = deliveryMode;
520
+
521
+ const webhookUrlRaw = value.webhookUrl === undefined ? cur?.webhookUrl : value.webhookUrl;
522
+ const webhookUrl = webhookUrlRaw === null || webhookUrlRaw === undefined ? null : String(webhookUrlRaw).trim();
523
+ if (deliveryMode === "webhook" && out.enabled) {
524
+ if (!webhookUrl) return { ok: false, error: "paymentTriggers.webhookUrl is required when enabled and deliveryMode=webhook" };
525
+ try {
526
+ const u = new URL(webhookUrl);
527
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, error: "paymentTriggers.webhookUrl must be http(s)" };
528
+ } catch {
529
+ return { ok: false, error: "paymentTriggers.webhookUrl invalid URL" };
530
+ }
531
+ }
532
+ out.webhookUrl = webhookUrl || null;
533
+
534
+ let webhookSecret = value.webhookSecret;
535
+ if (webhookSecret === undefined && cur && typeof cur.webhookSecret === "string") webhookSecret = cur.webhookSecret;
536
+ if (webhookSecret !== null && webhookSecret !== undefined && typeof webhookSecret !== "string") return { ok: false, error: "paymentTriggers.webhookSecret must be string|null" };
537
+ out.webhookSecret = typeof webhookSecret === "string" && webhookSecret.trim() ? webhookSecret : null;
538
+
539
+ return { ok: true, paymentTriggers: out };
540
+ }
541
+
542
+ function normalizeRateLimits(value, { current } = {}) {
543
+ if (value === undefined) return { ok: true, rateLimits: undefined };
544
+ if (value === null) return { ok: true, rateLimits: { ...defaultTenantSettings().rateLimits } };
545
+ if (!isPlainObject(value)) return { ok: false, error: "rateLimits must be an object or null" };
546
+
547
+ const base = { ...defaultTenantSettings().rateLimits, ...(isPlainObject(current) ? current : {}) };
548
+ const out = {};
549
+ for (const field of ["uploadsPerHour", "verificationViewsPerHour", "decisionsPerHour", "otpRequestsPerHour", "conformanceRunsPerHour"]) {
550
+ const raw = value[field] === undefined ? base[field] : value[field];
551
+ const n = Number.parseInt(String(raw ?? ""), 10);
552
+ if (!Number.isInteger(n) || n < 0 || n > 1_000_000) return { ok: false, error: `rateLimits.${field} must be an integer 0..1000000` };
553
+ out[field] = n;
554
+ }
555
+ return { ok: true, rateLimits: out };
556
+ }
557
+
558
+ function normalizePolicyProfile(profile) {
559
+ if (!isPlainObject(profile)) return { ok: false, error: "policy profile must be an object" };
560
+ const allowed = new Set([
561
+ "requiredMode",
562
+ "failOnWarnings",
563
+ "allowAmberApprovals",
564
+ "requiredPricingMatrixSignerKeyIds",
565
+ "requireProducerReceiptPresent",
566
+ "retentionDays"
567
+ ]);
568
+ for (const k of Object.keys(profile)) {
569
+ if (!allowed.has(k)) return { ok: false, error: `unknown policy field: ${k}` };
570
+ }
571
+
572
+ const out = {};
573
+
574
+ if (profile.requiredMode !== undefined) {
575
+ const v = String(profile.requiredMode ?? "").trim().toLowerCase();
576
+ if (v !== "auto" && v !== "strict" && v !== "compat") return { ok: false, error: "policy.requiredMode must be auto|strict|compat" };
577
+ out.requiredMode = v;
578
+ }
579
+ if (profile.failOnWarnings !== undefined) out.failOnWarnings = Boolean(profile.failOnWarnings);
580
+ if (profile.allowAmberApprovals !== undefined) out.allowAmberApprovals = Boolean(profile.allowAmberApprovals);
581
+ if (profile.requireProducerReceiptPresent !== undefined) out.requireProducerReceiptPresent = Boolean(profile.requireProducerReceiptPresent);
582
+
583
+ if (profile.requiredPricingMatrixSignerKeyIds !== undefined) {
584
+ if (profile.requiredPricingMatrixSignerKeyIds === null) out.requiredPricingMatrixSignerKeyIds = null;
585
+ else if (!Array.isArray(profile.requiredPricingMatrixSignerKeyIds)) return { ok: false, error: "policy.requiredPricingMatrixSignerKeyIds must be an array or null" };
586
+ else {
587
+ const list = profile.requiredPricingMatrixSignerKeyIds.map((x) => String(x ?? "").trim()).filter(Boolean);
588
+ const uniq = [...new Set(list)].sort();
589
+ out.requiredPricingMatrixSignerKeyIds = uniq;
590
+ }
591
+ }
592
+
593
+ if (profile.retentionDays !== undefined) {
594
+ if (profile.retentionDays === null) out.retentionDays = null;
595
+ else {
596
+ const n = Number.parseInt(String(profile.retentionDays ?? ""), 10);
597
+ if (!Number.isInteger(n) || n < 1 || n > 3650) return { ok: false, error: "policy.retentionDays must be null or an integer 1..3650" };
598
+ out.retentionDays = n;
599
+ }
600
+ }
601
+
602
+ return { ok: true, policy: out };
603
+ }
604
+
605
+ function normalizePoliciesMap(value, { current, idName }) {
606
+ if (value === undefined) return { ok: true, map: undefined };
607
+ if (value === null) return { ok: true, map: null };
608
+ if (!isPlainObject(value)) return { ok: false, error: `${idName}Policies must be an object or null` };
609
+
610
+ const cur = isPlainObject(current) ? current : null;
611
+ const merged = cur ? { ...cur } : {};
612
+
613
+ for (const [rawId, row] of Object.entries(value)) {
614
+ const id = String(rawId ?? "").trim();
615
+ if (!id) continue;
616
+ if (!/^[a-zA-Z0-9_-]{1,128}$/.test(id)) return { ok: false, error: `${idName} policy id invalid`, id };
617
+ if (row === null) {
618
+ delete merged[id];
619
+ continue;
620
+ }
621
+ const parsed = normalizePolicyProfile(row);
622
+ if (!parsed.ok) return parsed;
623
+ merged[id] = parsed.policy;
624
+ }
625
+
626
+ return { ok: true, map: merged };
627
+ }
628
+
629
+ function normalizeSettingsPatch(patch, { currentSettings }) {
630
+ if (!isPlainObject(patch)) return { ok: false, error: "settings body must be an object" };
631
+ const out = {};
632
+
633
+ if (patch.plan !== undefined) {
634
+ try {
635
+ out.plan = normalizeTenantPlan(patch.plan, { allowNull: false });
636
+ } catch (err) {
637
+ return { ok: false, error: err?.message ?? "plan must be free|builder|growth|enterprise" };
638
+ }
639
+ }
640
+
641
+ if (patch.defaultMode !== undefined) {
642
+ const v = String(patch.defaultMode ?? "").trim().toLowerCase();
643
+ if (v !== "auto" && v !== "strict" && v !== "compat") return { ok: false, error: "defaultMode must be auto|strict|compat" };
644
+ out.defaultMode = v;
645
+ }
646
+
647
+ if (patch.retentionDays !== undefined) {
648
+ const n = Number.parseInt(String(patch.retentionDays ?? ""), 10);
649
+ if (!Number.isInteger(n) || n < 1 || n > 3650) return { ok: false, error: "retentionDays must be an integer 1..3650" };
650
+ out.retentionDays = n;
651
+ }
652
+
653
+ if (patch.maxUploadBytesOverride !== undefined) {
654
+ if (patch.maxUploadBytesOverride === null) out.maxUploadBytesOverride = null;
655
+ else {
656
+ const n = Number.parseInt(String(patch.maxUploadBytesOverride ?? ""), 10);
657
+ if (!Number.isInteger(n) || n < 1) return { ok: false, error: "maxUploadBytesOverride must be null or a positive integer" };
658
+ out.maxUploadBytesOverride = n;
659
+ }
660
+ }
661
+
662
+ if (patch.trustedPricingSignerKeyIds !== undefined) {
663
+ if (patch.trustedPricingSignerKeyIds === null) out.trustedPricingSignerKeyIds = null;
664
+ else if (!Array.isArray(patch.trustedPricingSignerKeyIds)) return { ok: false, error: "trustedPricingSignerKeyIds must be an array or null" };
665
+ else {
666
+ const list = patch.trustedPricingSignerKeyIds.map((x) => String(x ?? "").trim()).filter(Boolean);
667
+ for (const kid of list) {
668
+ if (kid.length > 128 || /\s/.test(kid)) return { ok: false, error: "trustedPricingSignerKeyIds entries must be non-empty keyId strings", keyId: kid };
669
+ }
670
+ out.trustedPricingSignerKeyIds = [...new Set(list)].sort();
671
+ }
672
+ }
673
+
674
+ if (patch.maxVerificationsPerMonth !== undefined) {
675
+ if (patch.maxVerificationsPerMonth === null) out.maxVerificationsPerMonth = null;
676
+ else {
677
+ const n = Number.parseInt(String(patch.maxVerificationsPerMonth ?? ""), 10);
678
+ if (!Number.isInteger(n) || n < 0) return { ok: false, error: "maxVerificationsPerMonth must be null or an integer >= 0" };
679
+ out.maxVerificationsPerMonth = n;
680
+ }
681
+ }
682
+
683
+ if (patch.maxStoredBundles !== undefined) {
684
+ if (patch.maxStoredBundles === null) out.maxStoredBundles = null;
685
+ else {
686
+ const n = Number.parseInt(String(patch.maxStoredBundles ?? ""), 10);
687
+ if (!Number.isInteger(n) || n < 0) return { ok: false, error: "maxStoredBundles must be null or an integer >= 0" };
688
+ out.maxStoredBundles = n;
689
+ }
690
+ }
691
+
692
+ const trust = normalizeTrustRootsJson(patch.governanceTrustRootsJson);
693
+ if (!trust.ok) return trust;
694
+ if (trust.roots !== undefined) out.governanceTrustRootsJson = trust.roots;
695
+
696
+ const pricingSigners = normalizePricingSignerKeysJson(patch.pricingSignerKeysJson);
697
+ if (!pricingSigners.ok) return pricingSigners;
698
+ if (pricingSigners.keys !== undefined) out.pricingSignerKeysJson = pricingSigners.keys;
699
+
700
+ const webhooks = normalizeWebhookConfigList(patch.webhooks, { current: currentSettings?.webhooks ?? [] });
701
+ if (!webhooks.ok) return webhooks;
702
+ if (webhooks.webhooks !== undefined) out.webhooks = webhooks.webhooks;
703
+
704
+ const artifactStorage = normalizeArtifactStorageConfig(patch.artifactStorage, { current: currentSettings?.artifactStorage ?? null });
705
+ if (!artifactStorage.ok) return artifactStorage;
706
+ if (artifactStorage.artifactStorage !== undefined) out.artifactStorage = artifactStorage.artifactStorage;
707
+
708
+ const archiveExportSink = normalizeArchiveExportSink(patch.archiveExportSink, { current: currentSettings?.archiveExportSink ?? null });
709
+ if (!archiveExportSink.ok) return archiveExportSink;
710
+ if (archiveExportSink.archiveExportSink !== undefined) out.archiveExportSink = archiveExportSink.archiveExportSink;
711
+
712
+ const buyerNotifications = normalizeBuyerNotifications(patch.buyerNotifications, { current: currentSettings?.buyerNotifications ?? null });
713
+ if (!buyerNotifications.ok) return buyerNotifications;
714
+ if (buyerNotifications.buyerNotifications !== undefined) out.buyerNotifications = buyerNotifications.buyerNotifications;
715
+
716
+ const autoDecision = normalizeAutoDecision(patch.autoDecision, { current: currentSettings?.autoDecision ?? null });
717
+ if (!autoDecision.ok) return autoDecision;
718
+ if (autoDecision.autoDecision !== undefined) out.autoDecision = autoDecision.autoDecision;
719
+
720
+ const paymentTriggers = normalizePaymentTriggers(patch.paymentTriggers, { current: currentSettings?.paymentTriggers ?? null });
721
+ if (!paymentTriggers.ok) return paymentTriggers;
722
+ if (paymentTriggers.paymentTriggers !== undefined) out.paymentTriggers = paymentTriggers.paymentTriggers;
723
+
724
+ const rateLimits = normalizeRateLimits(patch.rateLimits, { current: currentSettings?.rateLimits ?? null });
725
+ if (!rateLimits.ok) return rateLimits;
726
+ if (rateLimits.rateLimits !== undefined) out.rateLimits = rateLimits.rateLimits;
727
+
728
+ const vendorPolicies = normalizePoliciesMap(patch.vendorPolicies, { current: currentSettings?.vendorPolicies ?? null, idName: "vendor" });
729
+ if (!vendorPolicies.ok) return vendorPolicies;
730
+ if (vendorPolicies.map !== undefined) out.vendorPolicies = vendorPolicies.map;
731
+
732
+ const contractPolicies = normalizePoliciesMap(patch.contractPolicies, { current: currentSettings?.contractPolicies ?? null, idName: "contract" });
733
+ if (!contractPolicies.ok) return contractPolicies;
734
+ if (contractPolicies.map !== undefined) out.contractPolicies = contractPolicies.map;
735
+
736
+ if (patch.buyerAuthEmailDomains !== undefined) {
737
+ if (patch.buyerAuthEmailDomains === null) out.buyerAuthEmailDomains = [];
738
+ else if (!Array.isArray(patch.buyerAuthEmailDomains)) return { ok: false, error: "buyerAuthEmailDomains must be an array or null" };
739
+ else {
740
+ const raw = patch.buyerAuthEmailDomains.map((x) => String(x ?? "").trim().toLowerCase()).filter(Boolean);
741
+ const domains = [];
742
+ for (const d of raw) {
743
+ if (d.includes("@")) return { ok: false, error: "buyerAuthEmailDomains must not include @", domain: d };
744
+ if (!/^[a-z0-9.-]{1,255}$/.test(d) || d.startsWith(".") || d.endsWith(".") || d.includes("..")) {
745
+ return { ok: false, error: "buyerAuthEmailDomains contains invalid domain", domain: d };
746
+ }
747
+ domains.push(d);
748
+ }
749
+ out.buyerAuthEmailDomains = [...new Set(domains)].sort();
750
+ }
751
+ }
752
+
753
+ if (patch.buyerUserRoles !== undefined) {
754
+ if (patch.buyerUserRoles === null) out.buyerUserRoles = null;
755
+ else if (!isPlainObject(patch.buyerUserRoles)) return { ok: false, error: "buyerUserRoles must be an object or null" };
756
+ else {
757
+ const outRoles = {};
758
+ const allowed = new Set(["admin", "approver", "viewer"]);
759
+ for (const [rawEmail, rawRole] of Object.entries(patch.buyerUserRoles)) {
760
+ const email = normalizeEmailLower(rawEmail);
761
+ if (!email) return { ok: false, error: "buyerUserRoles contains invalid email", email: rawEmail };
762
+ const role = String(rawRole ?? "").trim().toLowerCase();
763
+ if (!allowed.has(role)) return { ok: false, error: "buyerUserRoles contains invalid role", email, role: rawRole };
764
+ outRoles[email] = role;
765
+ }
766
+ out.buyerUserRoles = outRoles;
767
+ }
768
+ }
769
+
770
+ if (patch.decisionAuthEmailDomains !== undefined) {
771
+ if (patch.decisionAuthEmailDomains === null) out.decisionAuthEmailDomains = [];
772
+ else if (!Array.isArray(patch.decisionAuthEmailDomains)) return { ok: false, error: "decisionAuthEmailDomains must be an array or null" };
773
+ else {
774
+ const raw = patch.decisionAuthEmailDomains.map((x) => String(x ?? "").trim().toLowerCase()).filter(Boolean);
775
+ const domains = [];
776
+ for (const d of raw) {
777
+ if (d.includes("@")) return { ok: false, error: "decisionAuthEmailDomains must not include @", domain: d };
778
+ if (!/^[a-z0-9.-]{1,255}$/.test(d) || d.startsWith(".") || d.endsWith(".") || d.includes("..")) {
779
+ return { ok: false, error: "decisionAuthEmailDomains contains invalid domain", domain: d };
780
+ }
781
+ domains.push(d);
782
+ }
783
+ out.decisionAuthEmailDomains = [...new Set(domains)].sort();
784
+ }
785
+ }
786
+
787
+ if (patch.settlementDecisionSigner !== undefined) {
788
+ if (patch.settlementDecisionSigner === null) out.settlementDecisionSigner = null;
789
+ else if (!isPlainObject(patch.settlementDecisionSigner)) return { ok: false, error: "settlementDecisionSigner must be an object or null" };
790
+ else {
791
+ const cur = isPlainObject(currentSettings?.settlementDecisionSigner) ? currentSettings.settlementDecisionSigner : null;
792
+ const s = patch.settlementDecisionSigner;
793
+
794
+ const signerKeyId = typeof s.signerKeyId === "string" ? s.signerKeyId.trim() : "";
795
+ if (!signerKeyId) return { ok: false, error: "settlementDecisionSigner.signerKeyId is required" };
796
+
797
+ let privateKeyPem = s.privateKeyPem;
798
+ if (privateKeyPem === undefined && cur && typeof cur.privateKeyPem === "string") privateKeyPem = cur.privateKeyPem;
799
+ if (privateKeyPem !== null && privateKeyPem !== undefined && typeof privateKeyPem !== "string") return { ok: false, error: "settlementDecisionSigner.privateKeyPem must be string|null" };
800
+ const privateKeyPemNorm = typeof privateKeyPem === "string" && privateKeyPem.trim() ? privateKeyPem : null;
801
+
802
+ const remoteSignerUrl = s.remoteSignerUrl;
803
+ if (remoteSignerUrl !== null && remoteSignerUrl !== undefined && typeof remoteSignerUrl !== "string") return { ok: false, error: "settlementDecisionSigner.remoteSignerUrl must be string|null" };
804
+ const remoteSignerUrlNorm = typeof remoteSignerUrl === "string" && remoteSignerUrl.trim() ? remoteSignerUrl.trim() : null;
805
+ if (remoteSignerUrlNorm !== null) {
806
+ try {
807
+ const u = new URL(remoteSignerUrlNorm);
808
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, error: "settlementDecisionSigner.remoteSignerUrl must be http(s)" };
809
+ } catch {
810
+ return { ok: false, error: "settlementDecisionSigner.remoteSignerUrl invalid" };
811
+ }
812
+ }
813
+
814
+ let remoteSignerBearerToken = s.remoteSignerBearerToken;
815
+ if (remoteSignerBearerToken === undefined && cur && typeof cur.remoteSignerBearerToken === "string") remoteSignerBearerToken = cur.remoteSignerBearerToken;
816
+ if (remoteSignerBearerToken !== null && remoteSignerBearerToken !== undefined && typeof remoteSignerBearerToken !== "string") {
817
+ return { ok: false, error: "settlementDecisionSigner.remoteSignerBearerToken must be string|null" };
818
+ }
819
+ const remoteSignerBearerTokenNorm = typeof remoteSignerBearerToken === "string" && remoteSignerBearerToken.trim() ? remoteSignerBearerToken : null;
820
+
821
+ const modeCount = (privateKeyPemNorm ? 1 : 0) + (remoteSignerUrlNorm ? 1 : 0);
822
+ if (modeCount !== 1) return { ok: false, error: "settlementDecisionSigner must set exactly one of privateKeyPem or remoteSignerUrl" };
823
+ if (privateKeyPemNorm && !privateKeyPemNorm.includes("BEGIN PRIVATE KEY")) return { ok: false, error: "settlementDecisionSigner.privateKeyPem must be a PEM private key" };
824
+
825
+ out.settlementDecisionSigner = {
826
+ signerKeyId,
827
+ privateKeyPem: privateKeyPemNorm,
828
+ remoteSignerUrl: remoteSignerUrlNorm,
829
+ remoteSignerBearerToken: remoteSignerUrlNorm ? remoteSignerBearerTokenNorm : null
830
+ };
831
+ }
832
+ }
833
+
834
+ return { ok: true, patch: out };
835
+ }
836
+
837
+ export function governanceTrustInfo({ tenantSettings, envValue }) {
838
+ // Tenant settings win when non-null; otherwise fall back to process env.
839
+ const fromTenant = tenantSettings && tenantSettings.governanceTrustRootsJson !== null && tenantSettings.governanceTrustRootsJson !== undefined;
840
+ let roots = fromTenant ? tenantSettings.governanceTrustRootsJson : null;
841
+ if (!fromTenant) {
842
+ const raw = String(envValue ?? "").trim();
843
+ if (!raw) roots = null;
844
+ else {
845
+ try {
846
+ roots = JSON.parse(raw);
847
+ } catch (err) {
848
+ return { configured: false, reason: "invalid", keyIds: [], detail: err?.message ?? String(err ?? "") };
849
+ }
850
+ }
851
+ }
852
+
853
+ const parsed = normalizeTrustRootsJson(roots);
854
+ if (!parsed.ok) return { configured: false, reason: "invalid", keyIds: [], detail: parsed.error };
855
+ const obj = parsed.roots;
856
+ if (obj === null || obj === undefined) return { configured: false, reason: "missing", keyIds: [], json: "" };
857
+ const keyIds = Object.keys(obj).filter(Boolean).sort();
858
+ const setHash = trustRootSetHashHex(keyIds);
859
+ const json = JSON.stringify(obj);
860
+ return { configured: keyIds.length > 0, reason: keyIds.length > 0 ? null : "empty", keyIds, setHash, json, source: fromTenant ? "tenant" : "env" };
861
+ }
862
+
863
+ export function pricingSignerTrustInfo({ tenantSettings, envValue }) {
864
+ const fromTenant = tenantSettings && tenantSettings.pricingSignerKeysJson !== null && tenantSettings.pricingSignerKeysJson !== undefined;
865
+ let keys = fromTenant ? tenantSettings.pricingSignerKeysJson : null;
866
+ if (!fromTenant) {
867
+ const raw = String(envValue ?? "").trim();
868
+ if (!raw) keys = null;
869
+ else {
870
+ try {
871
+ keys = JSON.parse(raw);
872
+ } catch (err) {
873
+ return { configured: false, reason: "invalid", keyIds: [], detail: err?.message ?? String(err ?? "") };
874
+ }
875
+ }
876
+ }
877
+
878
+ const parsed = normalizePricingSignerKeysJson(keys);
879
+ if (!parsed.ok) return { configured: false, reason: "invalid", keyIds: [], detail: parsed.error };
880
+ let obj = parsed.keys;
881
+ if (obj === null || obj === undefined) return { configured: false, reason: "missing", keyIds: [], json: "" };
882
+
883
+ const allow = Array.isArray(tenantSettings?.trustedPricingSignerKeyIds) ? tenantSettings.trustedPricingSignerKeyIds.map(String).filter(Boolean) : [];
884
+ if (allow.length) {
885
+ const allowed = new Set(allow);
886
+ const next = {};
887
+ for (const [k, v] of Object.entries(obj)) {
888
+ if (allowed.has(k)) next[k] = v;
889
+ }
890
+ obj = next;
891
+ }
892
+
893
+ const keyIds = Object.keys(obj).filter(Boolean).sort();
894
+ const setHash = trustRootSetHashHex(keyIds);
895
+ const json = JSON.stringify(obj);
896
+ return { configured: keyIds.length > 0, reason: keyIds.length > 0 ? null : "empty", keyIds, setHash, json, source: fromTenant ? "tenant" : "env" };
897
+ }
898
+
899
+ export function sanitizeTenantSettingsForApi(settings) {
900
+ const s = isPlainObject(settings) ? settings : defaultTenantSettings();
901
+ const out = { ...defaultTenantSettings(), ...s };
902
+ try {
903
+ out.plan = normalizeTenantPlan(out.plan, { allowNull: false });
904
+ } catch {
905
+ out.plan = "free";
906
+ }
907
+ out.webhooks = Array.isArray(out.webhooks)
908
+ ? out.webhooks.map((w) => ({
909
+ url: typeof w?.url === "string" ? w.url : null,
910
+ events: Array.isArray(w?.events) ? w.events : [],
911
+ enabled: Boolean(w?.enabled),
912
+ secret: null
913
+ }))
914
+ : [];
915
+ out.vendorPolicies = isPlainObject(out.vendorPolicies) ? out.vendorPolicies : out.vendorPolicies === null ? null : null;
916
+ out.contractPolicies = isPlainObject(out.contractPolicies) ? out.contractPolicies : out.contractPolicies === null ? null : null;
917
+ out.buyerAuthEmailDomains = Array.isArray(out.buyerAuthEmailDomains) ? out.buyerAuthEmailDomains.map((d) => String(d ?? "").trim().toLowerCase()).filter(Boolean) : [];
918
+ out.buyerUserRoles = isPlainObject(out.buyerUserRoles) ? out.buyerUserRoles : out.buyerUserRoles === null ? null : null;
919
+ out.buyerNotifications = isPlainObject(out.buyerNotifications)
920
+ ? {
921
+ emails: Array.isArray(out.buyerNotifications.emails) ? out.buyerNotifications.emails.map((x) => normalizeEmailLower(x)).filter(Boolean) : [],
922
+ deliveryMode: typeof out.buyerNotifications.deliveryMode === "string" ? out.buyerNotifications.deliveryMode : "smtp",
923
+ webhookUrl: typeof out.buyerNotifications.webhookUrl === "string" ? out.buyerNotifications.webhookUrl : null,
924
+ webhookSecret: null
925
+ }
926
+ : { ...defaultTenantSettings().buyerNotifications, webhookSecret: null };
927
+ out.autoDecision = isPlainObject(out.autoDecision)
928
+ ? {
929
+ enabled: Boolean(out.autoDecision.enabled),
930
+ approveOnGreen: Boolean(out.autoDecision.approveOnGreen),
931
+ approveOnAmber: Boolean(out.autoDecision.approveOnAmber),
932
+ holdOnRed: Boolean(out.autoDecision.holdOnRed),
933
+ templateIds: Array.isArray(out.autoDecision.templateIds) ? out.autoDecision.templateIds.map((x) => String(x ?? "").trim()).filter(Boolean) : null,
934
+ actorName: typeof out.autoDecision.actorName === "string" ? out.autoDecision.actorName : defaultTenantSettings().autoDecision.actorName,
935
+ actorEmail: normalizeEmailLower(out.autoDecision.actorEmail) ?? defaultTenantSettings().autoDecision.actorEmail
936
+ }
937
+ : { ...defaultTenantSettings().autoDecision };
938
+ out.paymentTriggers = isPlainObject(out.paymentTriggers)
939
+ ? {
940
+ enabled: Boolean(out.paymentTriggers.enabled),
941
+ deliveryMode: typeof out.paymentTriggers.deliveryMode === "string" ? out.paymentTriggers.deliveryMode : "record",
942
+ webhookUrl: typeof out.paymentTriggers.webhookUrl === "string" ? out.paymentTriggers.webhookUrl : null,
943
+ webhookSecret: null
944
+ }
945
+ : { ...defaultTenantSettings().paymentTriggers, webhookSecret: null };
946
+ out.rateLimits = isPlainObject(out.rateLimits) ? { ...defaultTenantSettings().rateLimits, ...out.rateLimits } : { ...defaultTenantSettings().rateLimits };
947
+ out.decisionAuthEmailDomains = Array.isArray(out.decisionAuthEmailDomains) ? out.decisionAuthEmailDomains.map((d) => String(d ?? "").trim().toLowerCase()).filter(Boolean) : [];
948
+ out.trustedPricingSignerKeyIds = Array.isArray(out.trustedPricingSignerKeyIds) ? out.trustedPricingSignerKeyIds.map((k) => String(k ?? "").trim()).filter(Boolean) : out.trustedPricingSignerKeyIds === null ? null : null;
949
+ out.settlementDecisionSigner =
950
+ isPlainObject(out.settlementDecisionSigner) && typeof out.settlementDecisionSigner.signerKeyId === "string"
951
+ ? { signerKeyId: out.settlementDecisionSigner.signerKeyId, privateKeyPem: null, remoteSignerUrl: out.settlementDecisionSigner.remoteSignerUrl ?? null, remoteSignerBearerToken: null }
952
+ : null;
953
+ out.artifactStorage = isPlainObject(out.artifactStorage) ? { ...defaultTenantSettings().artifactStorage, ...out.artifactStorage } : defaultTenantSettings().artifactStorage;
954
+ out.archiveExportSink = isPlainObject(out.archiveExportSink)
955
+ ? {
956
+ type: out.archiveExportSink.type ?? null,
957
+ enabled: Boolean(out.archiveExportSink.enabled),
958
+ endpoint: out.archiveExportSink.endpoint ?? null,
959
+ region: out.archiveExportSink.region ?? null,
960
+ bucket: out.archiveExportSink.bucket ?? null,
961
+ prefix: out.archiveExportSink.prefix ?? null,
962
+ pathStyle: out.archiveExportSink.pathStyle ?? null,
963
+ accessKeyId: out.archiveExportSink.accessKeyId ?? null,
964
+ secretAccessKey: null,
965
+ sessionToken: null,
966
+ sse: out.archiveExportSink.sse ?? "none",
967
+ kmsKeyId: out.archiveExportSink.kmsKeyId ?? null
968
+ }
969
+ : null;
970
+ return out;
971
+ }
972
+
973
+ export async function loadTenantSettings({ dataDir, tenantId }) {
974
+ const fp = path.join(dataDir, "tenants", tenantId, "settings.json");
975
+ try {
976
+ const raw = await fs.readFile(fp, "utf8");
977
+ const j = JSON.parse(raw);
978
+ if (!isPlainObject(j)) return defaultTenantSettings();
979
+ if (j.schemaVersion === "TenantSettings.v2") return { ...defaultTenantSettings(), ...j };
980
+ if (j.schemaVersion === "TenantSettings.v1") {
981
+ const migrated = { ...defaultTenantSettings(), ...j, schemaVersion: "TenantSettings.v2" };
982
+ if (!isPlainObject(migrated.artifactStorage)) migrated.artifactStorage = { ...defaultTenantSettings().artifactStorage };
983
+ if (migrated.archiveExportSink === undefined) migrated.archiveExportSink = null;
984
+ return migrated;
985
+ }
986
+ return defaultTenantSettings();
987
+ } catch {
988
+ return defaultTenantSettings();
989
+ }
990
+ }
991
+
992
+ export async function saveTenantSettings({ dataDir, tenantId, settings, settingsKey }) {
993
+ const fp = path.join(dataDir, "tenants", tenantId, "settings.json");
994
+ await fs.mkdir(path.dirname(fp), { recursive: true });
995
+
996
+ const s = { ...defaultTenantSettings(), ...(isPlainObject(settings) ? settings : {}) };
997
+ s.schemaVersion = "TenantSettings.v2";
998
+ const webhooks = Array.isArray(s.webhooks) ? s.webhooks : [];
999
+ s.webhooks = webhooks.map((w) => {
1000
+ if (!isPlainObject(w)) return w;
1001
+ if (typeof w.secret !== "string" || !w.secret) return { ...w, secret: null };
1002
+ if (w.secret.startsWith("enc:v1:")) return w;
1003
+ if (!settingsKey) return w;
1004
+ return { ...w, secret: encryptStringAes256Gcm({ key: settingsKey, plaintext: w.secret }) };
1005
+ });
1006
+
1007
+ if (isPlainObject(s.settlementDecisionSigner)) {
1008
+ const next = { ...s.settlementDecisionSigner };
1009
+ for (const field of ["privateKeyPem", "remoteSignerBearerToken"]) {
1010
+ const v = next[field];
1011
+ if (typeof v !== "string" || !v) {
1012
+ next[field] = null;
1013
+ continue;
1014
+ }
1015
+ if (v.startsWith("enc:v1:")) continue;
1016
+ if (!settingsKey) continue;
1017
+ next[field] = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1018
+ }
1019
+ s.settlementDecisionSigner = next;
1020
+ }
1021
+
1022
+ if (isPlainObject(s.buyerNotifications)) {
1023
+ const next = { ...defaultTenantSettings().buyerNotifications, ...s.buyerNotifications };
1024
+ const v = next.webhookSecret;
1025
+ if (typeof v !== "string" || !v) next.webhookSecret = null;
1026
+ else if (v.startsWith("enc:v1:")) next.webhookSecret = v;
1027
+ else if (settingsKey) next.webhookSecret = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1028
+ s.buyerNotifications = next;
1029
+ }
1030
+
1031
+ if (isPlainObject(s.paymentTriggers)) {
1032
+ const next = { ...defaultTenantSettings().paymentTriggers, ...s.paymentTriggers };
1033
+ const v = next.webhookSecret;
1034
+ if (typeof v !== "string" || !v) next.webhookSecret = null;
1035
+ else if (v.startsWith("enc:v1:")) next.webhookSecret = v;
1036
+ else if (settingsKey) next.webhookSecret = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1037
+ s.paymentTriggers = next;
1038
+ }
1039
+
1040
+ if (isPlainObject(s.archiveExportSink)) {
1041
+ const next = { ...s.archiveExportSink };
1042
+ for (const field of ["secretAccessKey", "sessionToken"]) {
1043
+ const v = next[field];
1044
+ if (typeof v !== "string" || !v) {
1045
+ next[field] = null;
1046
+ continue;
1047
+ }
1048
+ if (v.startsWith("enc:v1:")) continue;
1049
+ if (!settingsKey) continue;
1050
+ next[field] = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1051
+ }
1052
+ s.archiveExportSink = next;
1053
+ }
1054
+
1055
+ await fs.writeFile(fp, JSON.stringify(s, null, 2) + "\n", "utf8");
1056
+ }
1057
+
1058
+ export function getSettingsKeyFromEnv() {
1059
+ return parseSettingsKeyHex(process.env.MAGIC_LINK_SETTINGS_KEY_HEX ?? "");
1060
+ }
1061
+
1062
+ export function applyTenantSettingsPatch({ currentSettings, patch, settingsKey }) {
1063
+ const normalized = normalizeSettingsPatch(patch, { currentSettings });
1064
+ if (!normalized.ok) return normalized;
1065
+
1066
+ const next = { ...defaultTenantSettings(), ...(isPlainObject(currentSettings) ? currentSettings : {}), ...normalized.patch };
1067
+ next.schemaVersion = "TenantSettings.v2";
1068
+ if (Array.isArray(next.webhooks)) {
1069
+ next.webhooks = next.webhooks.map((w) => {
1070
+ if (!isPlainObject(w)) return w;
1071
+ // Normalize + encrypt secret before storing.
1072
+ if (typeof w.secret !== "string" || !w.secret) return { ...w, secret: null };
1073
+ if (w.secret.startsWith("enc:v1:")) return w;
1074
+ if (!settingsKey) return { ...w, secret: w.secret };
1075
+ return { ...w, secret: encryptStringAes256Gcm({ key: settingsKey, plaintext: w.secret }) };
1076
+ });
1077
+ }
1078
+ if (isPlainObject(next.settlementDecisionSigner)) {
1079
+ const row = { ...next.settlementDecisionSigner };
1080
+ for (const field of ["privateKeyPem", "remoteSignerBearerToken"]) {
1081
+ const v = row[field];
1082
+ if (typeof v !== "string" || !v) {
1083
+ row[field] = null;
1084
+ continue;
1085
+ }
1086
+ if (v.startsWith("enc:v1:")) continue;
1087
+ if (!settingsKey) continue;
1088
+ row[field] = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1089
+ }
1090
+ next.settlementDecisionSigner = row;
1091
+ }
1092
+ if (isPlainObject(next.buyerNotifications)) {
1093
+ const row = { ...defaultTenantSettings().buyerNotifications, ...next.buyerNotifications };
1094
+ const v = row.webhookSecret;
1095
+ if (typeof v !== "string" || !v) row.webhookSecret = null;
1096
+ else if (v.startsWith("enc:v1:")) row.webhookSecret = v;
1097
+ else if (settingsKey) row.webhookSecret = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1098
+ next.buyerNotifications = row;
1099
+ }
1100
+ if (isPlainObject(next.paymentTriggers)) {
1101
+ const row = { ...defaultTenantSettings().paymentTriggers, ...next.paymentTriggers };
1102
+ const v = row.webhookSecret;
1103
+ if (typeof v !== "string" || !v) row.webhookSecret = null;
1104
+ else if (v.startsWith("enc:v1:")) row.webhookSecret = v;
1105
+ else if (settingsKey) row.webhookSecret = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1106
+ next.paymentTriggers = row;
1107
+ }
1108
+ if (isPlainObject(next.archiveExportSink)) {
1109
+ const row = { ...next.archiveExportSink };
1110
+ for (const field of ["secretAccessKey", "sessionToken"]) {
1111
+ const v = row[field];
1112
+ if (typeof v !== "string" || !v) {
1113
+ row[field] = null;
1114
+ continue;
1115
+ }
1116
+ if (v.startsWith("enc:v1:")) continue;
1117
+ if (!settingsKey) continue;
1118
+ row[field] = encryptStringAes256Gcm({ key: settingsKey, plaintext: v });
1119
+ }
1120
+ next.archiveExportSink = row;
1121
+ }
1122
+ return { ok: true, settings: next };
1123
+ }
1124
+
1125
+ export function decryptStoredSecret({ settingsKey, storedSecret }) {
1126
+ if (storedSecret === null || storedSecret === undefined) return null;
1127
+ if (typeof storedSecret !== "string") return null;
1128
+ const v = storedSecret;
1129
+ if (!v.startsWith("enc:v1:")) return v;
1130
+ if (!settingsKey) return null;
1131
+ try {
1132
+ return decryptStringAes256Gcm({ key: settingsKey, value: v });
1133
+ } catch {
1134
+ return null;
1135
+ }
1136
+ }
1137
+
1138
+ export function decryptWebhookSecret({ settingsKey, storedSecret }) {
1139
+ return decryptStoredSecret({ settingsKey, storedSecret });
1140
+ }