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,496 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import pg from "pg";
4
+
5
+ import { safeTruncate } from "./redaction.js";
6
+
7
+ const { Client } = pg;
8
+
9
+ function isPlainObject(v) {
10
+ return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
11
+ }
12
+
13
+ function cmpString(a, b) {
14
+ const aa = String(a ?? "");
15
+ const bb = String(b ?? "");
16
+ if (aa < bb) return -1;
17
+ if (aa > bb) return 1;
18
+ return 0;
19
+ }
20
+
21
+ function isRunToken(value) {
22
+ return typeof value === "string" && /^ml_[0-9a-f]{48}$/.test(value);
23
+ }
24
+
25
+ function normalizeRunRecordForList(record, tokenHint = null) {
26
+ if (!isPlainObject(record)) return null;
27
+ const token = isRunToken(record.token) ? record.token : isRunToken(tokenHint) ? tokenHint : null;
28
+ if (!token) return null;
29
+ return { ...record, token };
30
+ }
31
+
32
+ export function runRecordPath({ dataDir, tenantId, token }) {
33
+ return path.join(String(dataDir ?? ""), "runs", String(tenantId ?? "default"), `${String(token ?? "")}.json`);
34
+ }
35
+
36
+ const runStoreModeRaw = String(process.env.MAGIC_LINK_RUN_STORE_MODE ?? "fs").trim().toLowerCase();
37
+ const runStoreMode = runStoreModeRaw === "db" || runStoreModeRaw === "dual" ? runStoreModeRaw : "fs";
38
+ const runStoreDatabaseUrl = String(process.env.MAGIC_LINK_RUN_STORE_DATABASE_URL ?? process.env.DATABASE_URL ?? "").trim();
39
+ const runStoreDbEnabled = runStoreMode !== "fs" && Boolean(runStoreDatabaseUrl);
40
+
41
+ let pgClientPromise = null;
42
+
43
+ function hasDbWriteMode() {
44
+ return runStoreDbEnabled && (runStoreMode === "db" || runStoreMode === "dual");
45
+ }
46
+
47
+ function hasFsWriteMode() {
48
+ return runStoreMode === "fs" || runStoreMode === "dual" || !runStoreDbEnabled;
49
+ }
50
+
51
+ async function getPgClient() {
52
+ if (!runStoreDbEnabled) return null;
53
+ if (!pgClientPromise) {
54
+ pgClientPromise = (async () => {
55
+ const client = new Client({ connectionString: runStoreDatabaseUrl });
56
+ await client.connect();
57
+ await client.query(`
58
+ create table if not exists magic_link_run_records_v1 (
59
+ tenant_id text not null,
60
+ token text not null,
61
+ created_at timestamptz null,
62
+ updated_at timestamptz not null default now(),
63
+ verification_status text null,
64
+ evidence_count integer null,
65
+ active_evidence_count integer null,
66
+ sla_compliance_pct integer null,
67
+ template_id text null,
68
+ template_config_hash text null,
69
+ decision text null,
70
+ decision_decided_at timestamptz null,
71
+ decision_decided_by_email text null,
72
+ record_json jsonb not null,
73
+ primary key (tenant_id, token)
74
+ )
75
+ `);
76
+ await client.query(`create index if not exists magic_link_run_records_v1_tenant_created_idx on magic_link_run_records_v1 (tenant_id, created_at desc nulls last)`);
77
+ await client.query(`create index if not exists magic_link_run_records_v1_tenant_status_idx on magic_link_run_records_v1 (tenant_id, verification_status)`);
78
+ return client;
79
+ })();
80
+ }
81
+ try {
82
+ return await pgClientPromise;
83
+ } catch {
84
+ pgClientPromise = null;
85
+ return null;
86
+ }
87
+ }
88
+
89
+ function dbProjectionFromRecord(record) {
90
+ const verification = isPlainObject(record?.verification) ? record.verification : null;
91
+ const closePack = isPlainObject(record?.closePackSummaryV1) ? record.closePackSummaryV1 : null;
92
+ const evidenceIndex = isPlainObject(closePack?.evidenceIndex) ? closePack.evidenceIndex : null;
93
+ const sla = isPlainObject(closePack?.sla) ? closePack.sla : null;
94
+ const decision = isPlainObject(record?.decision) ? record.decision : null;
95
+
96
+ const verificationStatus = verification?.ok ? (Array.isArray(verification.warningCodes) && verification.warningCodes.length ? "amber" : "green") : "red";
97
+ const evidenceCount = Number.isInteger(evidenceIndex?.itemCount) ? evidenceIndex.itemCount : null;
98
+ const activeEvidenceCount = Number.isInteger(record?.metering?.evidenceRefsCount) ? record.metering.evidenceRefsCount : evidenceCount;
99
+ const slaCompliancePct = Number.isInteger(sla?.failingClausesCount) ? Math.max(0, 100 - sla.failingClausesCount) : null;
100
+
101
+ return {
102
+ verificationStatus,
103
+ evidenceCount,
104
+ activeEvidenceCount,
105
+ slaCompliancePct,
106
+ templateId: typeof record?.templateId === "string" ? record.templateId : null,
107
+ templateConfigHash: typeof record?.templateConfigHash === "string" ? record.templateConfigHash : null,
108
+ decision: typeof decision?.decision === "string" ? decision.decision : null,
109
+ decisionDecidedAt: typeof decision?.decidedAt === "string" ? decision.decidedAt : null,
110
+ decisionDecidedByEmail: typeof decision?.decidedByEmail === "string" ? decision.decidedByEmail : null,
111
+ createdAt: typeof record?.createdAt === "string" ? record.createdAt : null
112
+ };
113
+ }
114
+
115
+ async function upsertRunRecordDbBestEffort({ tenantId, token, record }) {
116
+ const client = await getPgClient();
117
+ if (!client) return { ok: false, skipped: true };
118
+ const proj = dbProjectionFromRecord(record);
119
+ await client.query(
120
+ `
121
+ insert into magic_link_run_records_v1 (
122
+ tenant_id,
123
+ token,
124
+ created_at,
125
+ updated_at,
126
+ verification_status,
127
+ evidence_count,
128
+ active_evidence_count,
129
+ sla_compliance_pct,
130
+ template_id,
131
+ template_config_hash,
132
+ decision,
133
+ decision_decided_at,
134
+ decision_decided_by_email,
135
+ record_json
136
+ ) values ($1,$2,$3,now(),$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb)
137
+ on conflict (tenant_id, token)
138
+ do update set
139
+ created_at = excluded.created_at,
140
+ updated_at = now(),
141
+ verification_status = excluded.verification_status,
142
+ evidence_count = excluded.evidence_count,
143
+ active_evidence_count = excluded.active_evidence_count,
144
+ sla_compliance_pct = excluded.sla_compliance_pct,
145
+ template_id = excluded.template_id,
146
+ template_config_hash = excluded.template_config_hash,
147
+ decision = excluded.decision,
148
+ decision_decided_at = excluded.decision_decided_at,
149
+ decision_decided_by_email = excluded.decision_decided_by_email,
150
+ record_json = excluded.record_json
151
+ `,
152
+ [
153
+ tenantId,
154
+ token,
155
+ proj.createdAt,
156
+ proj.verificationStatus,
157
+ proj.evidenceCount,
158
+ proj.activeEvidenceCount,
159
+ proj.slaCompliancePct,
160
+ proj.templateId,
161
+ proj.templateConfigHash,
162
+ proj.decision,
163
+ proj.decisionDecidedAt,
164
+ proj.decisionDecidedByEmail,
165
+ JSON.stringify(record ?? {})
166
+ ]
167
+ );
168
+ return { ok: true };
169
+ }
170
+
171
+ async function readRunRecordDbBestEffort({ tenantId, token }) {
172
+ const client = await getPgClient();
173
+ if (!client) return null;
174
+ try {
175
+ const out = await client.query(`select record_json from magic_link_run_records_v1 where tenant_id = $1 and token = $2 limit 1`, [tenantId, token]);
176
+ if (!out.rows.length) return null;
177
+ const row = out.rows[0];
178
+ return row?.record_json && typeof row.record_json === "object" && !Array.isArray(row.record_json) ? row.record_json : null;
179
+ } catch {
180
+ return null;
181
+ }
182
+ }
183
+
184
+ async function listTenantRunRecordsDbBestEffort({ tenantId, max }) {
185
+ const client = await getPgClient();
186
+ if (!client) return [];
187
+ try {
188
+ const out = await client.query(
189
+ `select token from magic_link_run_records_v1 where tenant_id = $1 order by created_at asc nulls last, token asc limit $2`,
190
+ [tenantId, Math.max(1, Math.min(200_000, Number.parseInt(String(max ?? "50000"), 10) || 50_000))]
191
+ );
192
+ return out.rows.map((r) => String(r?.token ?? "")).filter((t) => /^ml_[0-9a-f]{48}$/.test(t));
193
+ } catch {
194
+ return [];
195
+ }
196
+ }
197
+
198
+ async function listTenantRunRecordRowsDbBestEffort({ tenantId, max }) {
199
+ const client = await getPgClient();
200
+ if (!client) return [];
201
+ try {
202
+ const out = await client.query(
203
+ `
204
+ select token, record_json
205
+ from magic_link_run_records_v1
206
+ where tenant_id = $1
207
+ order by created_at desc nulls last, token desc
208
+ limit $2
209
+ `,
210
+ [tenantId, Math.max(1, Math.min(200_000, Number.parseInt(String(max ?? "50000"), 10) || 50_000))]
211
+ );
212
+ const rows = [];
213
+ for (const row of out.rows) {
214
+ const rec = normalizeRunRecordForList(row?.record_json, row?.token);
215
+ if (rec) rows.push(rec);
216
+ }
217
+ return rows;
218
+ } catch {
219
+ return [];
220
+ }
221
+ }
222
+
223
+ async function listTenantRunRecordRowsFsBestEffort({ dataDir, tenantId, max }) {
224
+ const dir = path.join(String(dataDir ?? ""), "runs", String(tenantId ?? "default"));
225
+ let names = [];
226
+ try {
227
+ names = (await fs.readdir(dir)).filter((n) => n.endsWith(".json")).slice(0, max);
228
+ } catch {
229
+ names = [];
230
+ }
231
+ const rows = [];
232
+ for (const name of names) {
233
+ const token = name.endsWith(".json") ? name.slice(0, -".json".length) : name;
234
+ if (!isRunToken(token)) continue;
235
+ const fp = path.join(dir, name);
236
+ let row = null;
237
+ try {
238
+ // eslint-disable-next-line no-await-in-loop
239
+ row = JSON.parse(await fs.readFile(fp, "utf8"));
240
+ } catch {
241
+ row = null;
242
+ }
243
+ const rec = normalizeRunRecordForList(row, token);
244
+ if (rec) rows.push(rec);
245
+ }
246
+ rows.sort((a, b) => cmpString(b?.createdAt, a?.createdAt) || cmpString(b?.token, a?.token));
247
+ return rows.slice(0, max);
248
+ }
249
+
250
+ export async function writeRunRecordV1({ dataDir, tenantId, token, meta, publicSummary, cliOut, retentionDaysEffective }) {
251
+ const fp = runRecordPath({ dataDir, tenantId, token });
252
+ const existing = await readRunRecordBestEffort({ dataDir, tenantId, token });
253
+ const record = buildRunRecordV1({ token, tenantId, meta, publicSummary, cliOut, retentionDaysEffective, existing });
254
+
255
+ if (hasFsWriteMode()) {
256
+ await fs.mkdir(path.dirname(fp), { recursive: true });
257
+ await fs.writeFile(fp, JSON.stringify(record, null, 2) + "\n", "utf8");
258
+ }
259
+ if (hasDbWriteMode()) {
260
+ try {
261
+ await upsertRunRecordDbBestEffort({ tenantId, token, record });
262
+ } catch {
263
+ // ignore db write failures in best-effort mode
264
+ }
265
+ }
266
+ return record;
267
+ }
268
+
269
+ export async function readRunRecordBestEffort({ dataDir, tenantId, token }) {
270
+ if (runStoreMode === "db" && runStoreDbEnabled) {
271
+ const db = await readRunRecordDbBestEffort({ tenantId, token });
272
+ if (db) return db;
273
+ return null;
274
+ }
275
+ if (runStoreMode === "dual" && runStoreDbEnabled) {
276
+ const db = await readRunRecordDbBestEffort({ tenantId, token });
277
+ if (db) return db;
278
+ }
279
+
280
+ const fp = runRecordPath({ dataDir, tenantId, token });
281
+ try {
282
+ return JSON.parse(await fs.readFile(fp, "utf8"));
283
+ } catch {
284
+ if (runStoreMode !== "dual" || !runStoreDbEnabled) return null;
285
+ return await readRunRecordDbBestEffort({ tenantId, token });
286
+ }
287
+ }
288
+
289
+ export async function listTenantRunRecordsBestEffort({ dataDir, tenantId, max = 50_000 } = {}) {
290
+ const limit = Math.max(1, Math.min(200_000, Number.parseInt(String(max ?? "50000"), 10) || 50_000));
291
+
292
+ let dbTokens = [];
293
+ if (runStoreMode === "db" || runStoreMode === "dual") dbTokens = await listTenantRunRecordsDbBestEffort({ tenantId, max: limit });
294
+
295
+ let fsTokens = [];
296
+ if (runStoreMode === "fs" || runStoreMode === "dual" || !runStoreDbEnabled) {
297
+ const dir = path.join(String(dataDir ?? ""), "runs", String(tenantId ?? "default"));
298
+ let names = [];
299
+ try {
300
+ names = (await fs.readdir(dir)).filter((n) => n.endsWith(".json")).slice(0, limit);
301
+ } catch {
302
+ names = [];
303
+ }
304
+ fsTokens = names
305
+ .map((n) => (n.endsWith(".json") ? n.slice(0, -".json".length) : n))
306
+ .filter((t) => /^ml_[0-9a-f]{48}$/.test(t));
307
+ }
308
+
309
+ if (runStoreMode === "db" && runStoreDbEnabled) return dbTokens.slice(0, limit);
310
+ if (runStoreMode === "fs" || !runStoreDbEnabled) return fsTokens.sort().slice(0, limit);
311
+
312
+ const merged = [...new Set([...dbTokens, ...fsTokens])].sort();
313
+ return merged.slice(0, limit);
314
+ }
315
+
316
+ export async function listTenantRunRecordRowsBestEffort({ dataDir, tenantId, max = 50_000 } = {}) {
317
+ const limit = Math.max(1, Math.min(200_000, Number.parseInt(String(max ?? "50000"), 10) || 50_000));
318
+
319
+ let dbRows = [];
320
+ if (runStoreMode === "db" || runStoreMode === "dual") dbRows = await listTenantRunRecordRowsDbBestEffort({ tenantId, max: limit });
321
+
322
+ let fsRows = [];
323
+ if (runStoreMode === "fs" || runStoreMode === "dual" || !runStoreDbEnabled) {
324
+ fsRows = await listTenantRunRecordRowsFsBestEffort({ dataDir, tenantId, max: limit });
325
+ }
326
+
327
+ if (runStoreMode === "db" && runStoreDbEnabled) return dbRows.slice(0, limit);
328
+ if (runStoreMode === "fs" || !runStoreDbEnabled) return fsRows.slice(0, limit);
329
+
330
+ const out = new Map();
331
+ for (const row of dbRows) out.set(row.token, row);
332
+ for (const row of fsRows) {
333
+ if (!out.has(row.token)) out.set(row.token, row);
334
+ }
335
+ const merged = [...out.values()];
336
+ merged.sort((a, b) => cmpString(b?.createdAt, a?.createdAt) || cmpString(b?.token, a?.token));
337
+ return merged.slice(0, limit);
338
+ }
339
+
340
+ export async function updateRunRecordDecisionBestEffort({ dataDir, tenantId, token, decisionReport }) {
341
+ if (!isPlainObject(decisionReport)) return { ok: false, skipped: true };
342
+ const cur = await readRunRecordBestEffort({ dataDir, tenantId, token });
343
+ if (!isPlainObject(cur)) return { ok: false, skipped: true };
344
+
345
+ const updated = { ...cur };
346
+ updated.decision = {
347
+ schemaVersion: "MagicLinkDecisionSummary.v1",
348
+ decision: typeof decisionReport.decision === "string" ? safeTruncate(decisionReport.decision, { max: 64 }) : null,
349
+ decidedAt: typeof decisionReport.decidedAt === "string" ? safeTruncate(decisionReport.decidedAt, { max: 500 }) : null,
350
+ decidedByEmail: typeof decisionReport?.actor?.email === "string" ? safeTruncate(decisionReport.actor.email, { max: 320 }) : null
351
+ };
352
+
353
+ const fp = runRecordPath({ dataDir, tenantId, token });
354
+ if (hasFsWriteMode()) {
355
+ await fs.mkdir(path.dirname(fp), { recursive: true });
356
+ await fs.writeFile(fp, JSON.stringify(updated, null, 2) + "\n", "utf8");
357
+ }
358
+ if (hasDbWriteMode()) {
359
+ try {
360
+ await upsertRunRecordDbBestEffort({ tenantId, token, record: updated });
361
+ } catch {
362
+ // ignore
363
+ }
364
+ }
365
+ return { ok: true };
366
+ }
367
+
368
+ export async function deleteRunRecordBestEffort({ dataDir, tenantId, token, deleteFs = false }) {
369
+ const fp = runRecordPath({ dataDir, tenantId, token });
370
+ if (deleteFs && hasFsWriteMode()) {
371
+ try {
372
+ await fs.rm(fp, { force: true });
373
+ } catch {
374
+ // ignore
375
+ }
376
+ }
377
+ if (hasDbWriteMode()) {
378
+ try {
379
+ const client = await getPgClient();
380
+ if (client) await client.query(`delete from magic_link_run_records_v1 where tenant_id = $1 and token = $2`, [tenantId, token]);
381
+ } catch {
382
+ // ignore
383
+ }
384
+ }
385
+ return { ok: true };
386
+ }
387
+
388
+ export async function migrateRunRecordsFromFsToDbBestEffort({ dataDir, tenantIds = null, max = 500_000 } = {}) {
389
+ if (!hasDbWriteMode()) return { ok: false, skipped: true, reason: "DB_DISABLED" };
390
+ const tenants = Array.isArray(tenantIds) && tenantIds.length
391
+ ? tenantIds.map((x) => String(x ?? "").trim()).filter(Boolean)
392
+ : await (async () => {
393
+ try {
394
+ const dir = path.join(String(dataDir ?? ""), "runs");
395
+ const names = await fs.readdir(dir, { withFileTypes: true });
396
+ return names.filter((e) => e.isDirectory()).map((e) => e.name);
397
+ } catch {
398
+ return [];
399
+ }
400
+ })();
401
+ let migrated = 0;
402
+ let skipped = 0;
403
+ for (const tenantId of tenants) {
404
+ const dir = path.join(String(dataDir ?? ""), "runs", tenantId);
405
+ let names = [];
406
+ try {
407
+ // eslint-disable-next-line no-await-in-loop
408
+ names = (await fs.readdir(dir)).filter((n) => n.endsWith(".json")).sort();
409
+ } catch {
410
+ names = [];
411
+ }
412
+ const tokens = names.map((n) => n.slice(0, -".json".length)).filter((t) => /^ml_[0-9a-f]{48}$/.test(t));
413
+ for (const token of tokens) {
414
+ if (migrated + skipped >= max) break;
415
+ // eslint-disable-next-line no-await-in-loop
416
+ const fp = runRecordPath({ dataDir, tenantId, token });
417
+ let row = null;
418
+ try {
419
+ // eslint-disable-next-line no-await-in-loop
420
+ row = JSON.parse(await fs.readFile(fp, "utf8"));
421
+ } catch {
422
+ row = null;
423
+ }
424
+ if (!isPlainObject(row)) {
425
+ skipped += 1;
426
+ continue;
427
+ }
428
+ try {
429
+ // eslint-disable-next-line no-await-in-loop
430
+ await upsertRunRecordDbBestEffort({ tenantId, token, record: row });
431
+ migrated += 1;
432
+ } catch {
433
+ skipped += 1;
434
+ }
435
+ }
436
+ }
437
+ return { ok: true, migrated, skipped };
438
+ }
439
+
440
+ export function runStoreModeInfo() {
441
+ return { mode: runStoreMode, dbEnabled: runStoreDbEnabled, databaseConfigured: Boolean(runStoreDatabaseUrl) };
442
+ }
443
+
444
+ function buildRunRecordV1({ token, tenantId, meta, publicSummary, cliOut, retentionDaysEffective, existing }) {
445
+ const pub = isPlainObject(publicSummary) ? publicSummary : {};
446
+ const m = isPlainObject(meta) ? meta : {};
447
+ const ex = isPlainObject(existing) ? existing : null;
448
+
449
+ const errorCodes = Array.isArray(pub?.verification?.errorCodes) ? pub.verification.errorCodes.map(String).filter(Boolean) : [];
450
+ const warningCodes = Array.isArray(pub?.verification?.warningCodes) ? pub.verification.warningCodes.map(String).filter(Boolean) : [];
451
+
452
+ const out = {
453
+ schemaVersion: "MagicLinkRunRecord.v1",
454
+ token: typeof token === "string" ? token : null,
455
+ tenantId: typeof tenantId === "string" ? tenantId : null,
456
+ createdAt: typeof m.createdAt === "string" ? m.createdAt : typeof pub.createdAt === "string" ? pub.createdAt : null,
457
+ startedAt: typeof m.startedAt === "string" ? m.startedAt : null,
458
+ finishedAt: typeof m.finishedAt === "string" ? m.finishedAt : null,
459
+ durationMs: Number.isFinite(Number(m.durationMs)) ? Number(m.durationMs) : null,
460
+ retentionDaysEffective: Number.isInteger(retentionDaysEffective) ? retentionDaysEffective : null,
461
+ vendorId: typeof m.vendorId === "string" ? safeTruncate(m.vendorId, { max: 128 }) : typeof pub.vendorId === "string" ? safeTruncate(pub.vendorId, { max: 128 }) : null,
462
+ vendorName: typeof m.vendorName === "string" ? safeTruncate(m.vendorName, { max: 500 }) : typeof pub.vendorName === "string" ? safeTruncate(pub.vendorName, { max: 500 }) : null,
463
+ runId: typeof m.runId === "string" ? safeTruncate(m.runId, { max: 128 }) : typeof pub.runId === "string" ? safeTruncate(pub.runId, { max: 128 }) : null,
464
+ contractId: typeof m.contractId === "string" ? safeTruncate(m.contractId, { max: 128 }) : typeof pub.contractId === "string" ? safeTruncate(pub.contractId, { max: 128 }) : null,
465
+ templateId: typeof m.templateId === "string" ? safeTruncate(m.templateId, { max: 128 }) : typeof pub.templateId === "string" ? safeTruncate(pub.templateId, { max: 128 }) : null,
466
+ zipSha256: typeof m.zipSha256 === "string" ? m.zipSha256 : typeof pub.zipSha256 === "string" ? pub.zipSha256 : null,
467
+ zipBytes: Number.isFinite(Number(m.zipBytes)) ? Number(m.zipBytes) : Number.isFinite(Number(pub.zipBytes)) ? Number(pub.zipBytes) : null,
468
+ modeRequested: typeof m.modeRequested === "string" ? safeTruncate(m.modeRequested, { max: 16 }) : typeof pub.modeRequested === "string" ? safeTruncate(pub.modeRequested, { max: 16 }) : null,
469
+ modeResolved: typeof m.modeResolved === "string" ? safeTruncate(m.modeResolved, { max: 16 }) : typeof pub.modeResolved === "string" ? safeTruncate(pub.modeResolved, { max: 16 }) : null,
470
+ policySource: typeof m.policySource === "string" ? safeTruncate(m.policySource, { max: 64 }) : typeof pub.policySource === "string" ? safeTruncate(pub.policySource, { max: 64 }) : null,
471
+ policySetHash: typeof m.policySetHash === "string" ? safeTruncate(m.policySetHash, { max: 128 }) : typeof pub.policySetHash === "string" ? safeTruncate(pub.policySetHash, { max: 128 }) : null,
472
+ trustSetHash: typeof m.trustSetHash === "string" ? safeTruncate(m.trustSetHash, { max: 128 }) : null,
473
+ pricingTrustSetHash: typeof m.pricingTrustSetHash === "string" ? safeTruncate(m.pricingTrustSetHash, { max: 128 }) : null,
474
+ verification: {
475
+ ok: Boolean(pub?.verification?.ok),
476
+ verificationOk: Boolean(pub?.verification?.verificationOk),
477
+ errorCodes: errorCodes.map((c) => safeTruncate(c, { max: 200 })).slice(0, 200),
478
+ warningCodes: warningCodes.map((c) => safeTruncate(c, { max: 200 })).slice(0, 200),
479
+ tool: isPlainObject(cliOut?.tool)
480
+ ? {
481
+ name: typeof cliOut.tool.name === "string" ? safeTruncate(cliOut.tool.name, { max: 64 }) : null,
482
+ version: typeof cliOut.tool.version === "string" ? safeTruncate(cliOut.tool.version, { max: 64 }) : null,
483
+ commit: typeof cliOut.tool.commit === "string" ? safeTruncate(cliOut.tool.commit, { max: 64 }) : null
484
+ }
485
+ : null
486
+ },
487
+ bundle: isPlainObject(pub?.bundle) ? pub.bundle : null,
488
+ pricingMatrixSignatures: isPlainObject(pub?.pricingMatrixSignatures) ? pub.pricingMatrixSignatures : null,
489
+ closePackSummaryV1: isPlainObject(pub?.closePackSummaryV1) ? pub.closePackSummaryV1 : null,
490
+ invoiceClaim: isPlainObject(pub?.invoiceClaim) ? pub.invoiceClaim : null,
491
+ metering: isPlainObject(pub?.metering) ? pub.metering : null,
492
+ receiptPresent: Boolean(pub?.receiptPresent)
493
+ };
494
+ if (ex && isPlainObject(ex.decision)) out.decision = ex.decision;
495
+ return out;
496
+ }
@@ -0,0 +1,171 @@
1
+ import crypto from "node:crypto";
2
+ import http from "node:http";
3
+ import https from "node:https";
4
+
5
+ function sha256Hex(data) {
6
+ const h = crypto.createHash("sha256");
7
+ if (typeof data === "string") h.update(data, "utf8");
8
+ else h.update(data);
9
+ return h.digest("hex");
10
+ }
11
+
12
+ function hmac(key, msg, encoding = null) {
13
+ const h = crypto.createHmac("sha256", key);
14
+ h.update(msg, "utf8");
15
+ return encoding ? h.digest(encoding) : h.digest();
16
+ }
17
+
18
+ function amzDateUtcNow() {
19
+ const d = new Date();
20
+ const y = String(d.getUTCFullYear()).padStart(4, "0");
21
+ const m = String(d.getUTCMonth() + 1).padStart(2, "0");
22
+ const day = String(d.getUTCDate()).padStart(2, "0");
23
+ const hh = String(d.getUTCHours()).padStart(2, "0");
24
+ const mm = String(d.getUTCMinutes()).padStart(2, "0");
25
+ const ss = String(d.getUTCSeconds()).padStart(2, "0");
26
+ return { dateStamp: `${y}${m}${day}`, amzDate: `${y}${m}${day}T${hh}${mm}${ss}Z` };
27
+ }
28
+
29
+ function awsEncodeUriPath(pathname) {
30
+ // Encode each segment but preserve `/` separators.
31
+ return String(pathname ?? "")
32
+ .split("/")
33
+ .map((seg) => encodeURIComponent(seg).replaceAll("%2F", "/"))
34
+ .join("/");
35
+ }
36
+
37
+ function canonicalizeHeaders(headers) {
38
+ const entries = [];
39
+ for (const [k, v] of Object.entries(headers ?? {})) {
40
+ const name = String(k ?? "").trim().toLowerCase();
41
+ if (!name) continue;
42
+ const value = Array.isArray(v) ? v.map(String).join(",") : String(v ?? "");
43
+ entries.push([name, value.replace(/\s+/g, " ").trim()]);
44
+ }
45
+ entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
46
+ const canonical = entries.map(([k, v]) => `${k}:${v}\n`).join("");
47
+ const signedHeaders = entries.map(([k]) => k).join(";");
48
+ return { canonicalHeaders: canonical, signedHeaders };
49
+ }
50
+
51
+ function buildSigV4Authorization({ method, url, headers, bodySha256, accessKeyId, secretAccessKey, sessionToken, region, service }) {
52
+ const { dateStamp, amzDate } = amzDateUtcNow();
53
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`;
54
+
55
+ const u = new URL(url);
56
+ const canonicalUri = awsEncodeUriPath(u.pathname);
57
+ const canonicalQuery = ""; // no query for our usage
58
+
59
+ const baseHeaders = { ...headers, host: u.host, "x-amz-date": amzDate, "x-amz-content-sha256": bodySha256 };
60
+ if (sessionToken) baseHeaders["x-amz-security-token"] = sessionToken;
61
+ const { canonicalHeaders, signedHeaders } = canonicalizeHeaders(baseHeaders);
62
+
63
+ const canonicalRequest = [method.toUpperCase(), canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, bodySha256].join("\n");
64
+ const stringToSign = ["AWS4-HMAC-SHA256", amzDate, scope, sha256Hex(canonicalRequest)].join("\n");
65
+
66
+ const kDate = hmac(Buffer.from(`AWS4${secretAccessKey}`, "utf8"), dateStamp);
67
+ const kRegion = hmac(kDate, region);
68
+ const kService = hmac(kRegion, service);
69
+ const kSigning = hmac(kService, "aws4_request");
70
+ const signature = hmac(kSigning, stringToSign, "hex");
71
+
72
+ const authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
73
+ return { authorization, amzDate, signedHeaders, headers: baseHeaders };
74
+ }
75
+
76
+ export function buildS3ObjectUrl({ endpoint, region, bucket, key, pathStyle = false } = {}) {
77
+ if (!bucket || typeof bucket !== "string") throw new TypeError("bucket is required");
78
+ if (!key || typeof key !== "string") throw new TypeError("key is required");
79
+ const safeKey = key.replace(/^\/+/, "");
80
+
81
+ if (endpoint) {
82
+ const base = new URL(endpoint);
83
+ if (base.protocol !== "http:" && base.protocol !== "https:") throw new Error("endpoint must be http(s)");
84
+ if (pathStyle) {
85
+ base.pathname = `/${bucket}/${safeKey}`.replaceAll("//", "/");
86
+ return base.toString();
87
+ }
88
+ // virtual-host style: bucket as subdomain (best-effort)
89
+ return `${base.protocol}//${bucket}.${base.host}/${safeKey}`;
90
+ }
91
+
92
+ if (!region) throw new TypeError("region is required when endpoint is not set");
93
+ if (pathStyle) return `https://s3.${region}.amazonaws.com/${bucket}/${safeKey}`;
94
+ return `https://${bucket}.s3.${region}.amazonaws.com/${safeKey}`;
95
+ }
96
+
97
+ export async function s3PutObject({
98
+ url,
99
+ region,
100
+ accessKeyId,
101
+ secretAccessKey,
102
+ sessionToken = null,
103
+ body,
104
+ contentType = "application/octet-stream",
105
+ sse = "none",
106
+ kmsKeyId = null,
107
+ extraHeaders = null,
108
+ timeoutMs = 30_000
109
+ } = {}) {
110
+ if (!url) throw new TypeError("url is required");
111
+ if (!region || typeof region !== "string") throw new TypeError("region is required");
112
+ if (!accessKeyId || typeof accessKeyId !== "string") throw new TypeError("accessKeyId is required");
113
+ if (!secretAccessKey || typeof secretAccessKey !== "string") throw new TypeError("secretAccessKey is required");
114
+ if (!(body instanceof Uint8Array) && !Buffer.isBuffer(body)) throw new TypeError("body must be bytes");
115
+
116
+ const payloadHash = sha256Hex(body);
117
+ const headers = {
118
+ "content-type": contentType,
119
+ "content-length": String(body.length),
120
+ ...((extraHeaders && typeof extraHeaders === "object") ? extraHeaders : {})
121
+ };
122
+ if (sse === "aes256") headers["x-amz-server-side-encryption"] = "AES256";
123
+ if (sse === "aws:kms") {
124
+ headers["x-amz-server-side-encryption"] = "aws:kms";
125
+ if (kmsKeyId) headers["x-amz-server-side-encryption-aws-kms-key-id"] = kmsKeyId;
126
+ }
127
+
128
+ const auth = buildSigV4Authorization({
129
+ method: "PUT",
130
+ url,
131
+ headers,
132
+ bodySha256: payloadHash,
133
+ accessKeyId,
134
+ secretAccessKey,
135
+ sessionToken,
136
+ region,
137
+ service: "s3"
138
+ });
139
+
140
+ const u = new URL(url);
141
+ const isHttps = u.protocol === "https:";
142
+ const reqHeaders = { ...auth.headers, authorization: auth.authorization };
143
+ const transport = isHttps ? https : http;
144
+
145
+ return await new Promise((resolve) => {
146
+ const req = transport.request(
147
+ {
148
+ method: "PUT",
149
+ protocol: u.protocol,
150
+ hostname: u.hostname,
151
+ port: u.port ? Number(u.port) : isHttps ? 443 : 80,
152
+ path: u.pathname + u.search,
153
+ headers: reqHeaders,
154
+ timeout: timeoutMs
155
+ },
156
+ (res) => {
157
+ const chunks = [];
158
+ res.on("data", (c) => chunks.push(c));
159
+ res.on("end", () => {
160
+ const bodyText = Buffer.concat(chunks).toString("utf8");
161
+ const ok = res.statusCode && res.statusCode >= 200 && res.statusCode < 300;
162
+ resolve({ ok, statusCode: res.statusCode ?? null, headers: res.headers ?? {}, bodyText: bodyText || "" });
163
+ });
164
+ }
165
+ );
166
+ req.on("timeout", () => req.destroy(Object.assign(new Error("timeout"), { code: "TIMEOUT" })));
167
+ req.on("error", (err) => resolve({ ok: false, statusCode: null, error: err?.code ?? "REQUEST_FAILED", message: err?.message ?? String(err ?? "error") }));
168
+ req.end(Buffer.from(body));
169
+ });
170
+ }
171
+