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,467 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+
5
+ function nowIso() {
6
+ return new Date().toISOString();
7
+ }
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 normalizeEmailLower(value) {
14
+ const raw = String(value ?? "").trim().toLowerCase();
15
+ if (!raw || raw.length > 320) return null;
16
+ if (/\s/.test(raw)) return null;
17
+ const parts = raw.split("@");
18
+ if (parts.length !== 2) return null;
19
+ if (!parts[0] || !parts[1]) return null;
20
+ return raw;
21
+ }
22
+
23
+ function normalizeTenantName(value) {
24
+ const v = String(value ?? "").trim();
25
+ if (!v) return null;
26
+ if (v.length > 200) return null;
27
+ if (v.includes("\n") || v.includes("\r")) return null;
28
+ return v;
29
+ }
30
+
31
+ function slugifyTenantName(name) {
32
+ const raw = String(name ?? "")
33
+ .trim()
34
+ .toLowerCase()
35
+ .replaceAll(/[^a-z0-9]+/g, "_")
36
+ .replaceAll(/^_+|_+$/g, "");
37
+ return raw || "tenant";
38
+ }
39
+
40
+ function randomSuffixHex(len = 8) {
41
+ return crypto.randomBytes(Math.max(2, Math.ceil(len / 2))).toString("hex").slice(0, len);
42
+ }
43
+
44
+ function defaultTenantProfile({ tenantId }) {
45
+ return {
46
+ schemaVersion: "MagicLinkTenantProfile.v1",
47
+ tenantId,
48
+ name: null,
49
+ contactEmail: null,
50
+ billingEmail: null,
51
+ status: "pending",
52
+ createdAt: nowIso(),
53
+ activatedAt: null,
54
+ firstUploadAt: null,
55
+ firstVerifiedAt: null,
56
+ firstWizardViewedAt: null,
57
+ firstTemplateSelectedAt: null,
58
+ firstTemplateRenderedAt: null,
59
+ firstSampleUploadAt: null,
60
+ firstSampleVerifiedAt: null,
61
+ firstBuyerLinkSharedAt: null,
62
+ firstBuyerLinkOpenedAt: null,
63
+ firstReferralLinkSharedAt: null,
64
+ firstReferralSignupAt: null,
65
+ onboardingEvents: []
66
+ };
67
+ }
68
+
69
+ function profilePath({ dataDir, tenantId }) {
70
+ return path.join(dataDir, "tenants", tenantId, "profile.json");
71
+ }
72
+
73
+ export async function loadTenantProfileBestEffort({ dataDir, tenantId }) {
74
+ const fp = profilePath({ dataDir, tenantId });
75
+ try {
76
+ const raw = JSON.parse(await fs.readFile(fp, "utf8"));
77
+ if (!isPlainObject(raw)) return null;
78
+ const merged = { ...defaultTenantProfile({ tenantId }), ...raw, tenantId };
79
+ return merged;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ async function saveTenantProfile({ dataDir, tenantId, profile }) {
86
+ const fp = profilePath({ dataDir, tenantId });
87
+ await fs.mkdir(path.dirname(fp), { recursive: true });
88
+ await fs.writeFile(fp, JSON.stringify(profile, null, 2) + "\n", "utf8");
89
+ }
90
+
91
+ const ONBOARDING_EVENT_TYPE = Object.freeze({
92
+ TENANT_CREATED: "tenant_created",
93
+ WIZARD_VIEWED: "wizard_viewed",
94
+ TEMPLATE_SELECTED: "template_selected",
95
+ TEMPLATE_RENDERED: "template_rendered",
96
+ DEMO_TRUST_ENABLED: "demo_trust_enabled",
97
+ SAMPLE_UPLOAD_GENERATED: "sample_upload_generated",
98
+ SAMPLE_UPLOAD_VERIFIED: "sample_upload_verified",
99
+ SAMPLE_UPLOAD_FAILED: "sample_upload_failed",
100
+ REAL_UPLOAD_GENERATED: "real_upload_generated",
101
+ REAL_UPLOAD_VERIFIED: "real_upload_verified",
102
+ REAL_UPLOAD_FAILED: "real_upload_failed",
103
+ BUYER_LINK_SHARED: "buyer_link_shared",
104
+ BUYER_LINK_OPENED: "buyer_link_opened",
105
+ REFERRAL_LINK_SHARED: "referral_link_shared",
106
+ REFERRAL_SIGNUP: "referral_signup"
107
+ });
108
+
109
+ const ONBOARDING_EVENT_TYPE_SET = new Set(Object.values(ONBOARDING_EVENT_TYPE));
110
+
111
+ function normalizeOnboardingEventType(value, { allowNull = false, fieldName = "eventType" } = {}) {
112
+ if (value === undefined || value === null || String(value).trim() === "") {
113
+ if (allowNull) return null;
114
+ throw new TypeError(`${fieldName} is required`);
115
+ }
116
+ const normalized = String(value).trim().toLowerCase();
117
+ if (!ONBOARDING_EVENT_TYPE_SET.has(normalized)) {
118
+ throw new TypeError(`${fieldName} must be one of: ${[...ONBOARDING_EVENT_TYPE_SET].join("|")}`);
119
+ }
120
+ return normalized;
121
+ }
122
+
123
+ function normalizeOnboardingEventMetadata(input) {
124
+ if (!isPlainObject(input)) return null;
125
+ const out = {};
126
+ const entries = Object.entries(input).slice(0, 20);
127
+ for (const [keyRaw, value] of entries) {
128
+ const key = String(keyRaw ?? "").trim();
129
+ if (!key || key.length > 64) continue;
130
+ if (value === null || value === undefined) {
131
+ out[key] = null;
132
+ continue;
133
+ }
134
+ if (typeof value === "string") {
135
+ out[key] = value.length > 200 ? value.slice(0, 200) : value;
136
+ continue;
137
+ }
138
+ if (typeof value === "number") {
139
+ out[key] = Number.isFinite(value) ? value : null;
140
+ continue;
141
+ }
142
+ if (typeof value === "boolean") {
143
+ out[key] = value;
144
+ continue;
145
+ }
146
+ }
147
+ return Object.keys(out).length ? out : null;
148
+ }
149
+
150
+ function appendOnboardingEvent(profile, { eventType, at, source = null, metadata = null }) {
151
+ const eventAt = typeof at === "string" && at.trim() !== "" ? at.trim() : nowIso();
152
+ const nextEvents = Array.isArray(profile?.onboardingEvents) ? [...profile.onboardingEvents] : [];
153
+ nextEvents.push({
154
+ eventType,
155
+ at: eventAt,
156
+ source: typeof source === "string" && source.trim() !== "" ? source.trim() : null,
157
+ metadata: normalizeOnboardingEventMetadata(metadata)
158
+ });
159
+ if (nextEvents.length > 200) nextEvents.splice(0, nextEvents.length - 200);
160
+
161
+ const next = { ...profile, onboardingEvents: nextEvents };
162
+ if (eventType === ONBOARDING_EVENT_TYPE.WIZARD_VIEWED && !next.firstWizardViewedAt) next.firstWizardViewedAt = eventAt;
163
+ if (eventType === ONBOARDING_EVENT_TYPE.TEMPLATE_SELECTED && !next.firstTemplateSelectedAt) next.firstTemplateSelectedAt = eventAt;
164
+ if (eventType === ONBOARDING_EVENT_TYPE.TEMPLATE_RENDERED && !next.firstTemplateRenderedAt) next.firstTemplateRenderedAt = eventAt;
165
+ if (eventType === ONBOARDING_EVENT_TYPE.SAMPLE_UPLOAD_GENERATED && !next.firstSampleUploadAt) next.firstSampleUploadAt = eventAt;
166
+ if (eventType === ONBOARDING_EVENT_TYPE.SAMPLE_UPLOAD_VERIFIED && !next.firstSampleVerifiedAt) next.firstSampleVerifiedAt = eventAt;
167
+ if (eventType === ONBOARDING_EVENT_TYPE.BUYER_LINK_SHARED && !next.firstBuyerLinkSharedAt) next.firstBuyerLinkSharedAt = eventAt;
168
+ if (eventType === ONBOARDING_EVENT_TYPE.BUYER_LINK_OPENED && !next.firstBuyerLinkOpenedAt) next.firstBuyerLinkOpenedAt = eventAt;
169
+ if (eventType === ONBOARDING_EVENT_TYPE.REFERRAL_LINK_SHARED && !next.firstReferralLinkSharedAt) next.firstReferralLinkSharedAt = eventAt;
170
+ if (eventType === ONBOARDING_EVENT_TYPE.REFERRAL_SIGNUP && !next.firstReferralSignupAt) next.firstReferralSignupAt = eventAt;
171
+ return next;
172
+ }
173
+
174
+ function monthKeyFromIso(value) {
175
+ const ms = Date.parse(String(value ?? ""));
176
+ if (!Number.isFinite(ms)) return null;
177
+ const d = new Date(ms);
178
+ return `${String(d.getUTCFullYear()).padStart(4, "0")}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
179
+ }
180
+
181
+ function pct(part, total) {
182
+ const p = Number(part);
183
+ const t = Number(total);
184
+ if (!Number.isFinite(p) || !Number.isFinite(t) || t <= 0) return 0;
185
+ return Math.round((p / t) * 10000) / 100;
186
+ }
187
+
188
+ function medianMs(values) {
189
+ const nums = (Array.isArray(values) ? values : [])
190
+ .map((v) => Number(v))
191
+ .filter((v) => Number.isFinite(v))
192
+ .sort((a, b) => a - b);
193
+ if (!nums.length) return null;
194
+ const mid = Math.floor(nums.length / 2);
195
+ if (nums.length % 2 === 1) return nums[mid];
196
+ return Math.round((nums[mid - 1] + nums[mid]) / 2);
197
+ }
198
+
199
+ export async function createTenantProfile({ dataDir, tenantId, name, contactEmail, billingEmail } = {}) {
200
+ const t = String(tenantId ?? "").trim();
201
+ if (!t || !/^[a-zA-Z0-9_-]{1,64}$/.test(t)) return { ok: false, error: "tenantId invalid (allowed: [A-Za-z0-9_-]{1,64})" };
202
+
203
+ const profileName = normalizeTenantName(name);
204
+ if (!profileName) return { ok: false, error: "name is required" };
205
+ const contact = normalizeEmailLower(contactEmail);
206
+ if (!contact) return { ok: false, error: "contactEmail is required and must be valid" };
207
+ const billing = normalizeEmailLower(billingEmail);
208
+ if (!billing) return { ok: false, error: "billingEmail is required and must be valid" };
209
+
210
+ const existing = await loadTenantProfileBestEffort({ dataDir, tenantId: t });
211
+ if (existing) return { ok: false, error: "tenant already exists", code: "TENANT_EXISTS" };
212
+
213
+ const profile = {
214
+ ...defaultTenantProfile({ tenantId: t }),
215
+ name: profileName,
216
+ contactEmail: contact,
217
+ billingEmail: billing
218
+ };
219
+ const withCreatedEvent = appendOnboardingEvent(profile, {
220
+ eventType: ONBOARDING_EVENT_TYPE.TENANT_CREATED,
221
+ at: profile.createdAt,
222
+ source: "tenant_create",
223
+ metadata: null
224
+ });
225
+ await saveTenantProfile({ dataDir, tenantId: t, profile: withCreatedEvent });
226
+ return { ok: true, profile: withCreatedEvent };
227
+ }
228
+
229
+ export function generateTenantIdFromName(name) {
230
+ const base = slugifyTenantName(name);
231
+ const capped = base.slice(0, 52).replaceAll(/^_+|_+$/g, "") || "tenant";
232
+ return `${capped}_${randomSuffixHex(8)}`;
233
+ }
234
+
235
+ export async function markTenantOnboardingProgress({ dataDir, tenantId, isSample = false, verificationOk = false, at = null } = {}) {
236
+ const t = String(tenantId ?? "").trim();
237
+ if (!t) return { ok: false, error: "tenantId required" };
238
+
239
+ const now = typeof at === "string" && at.trim() ? at.trim() : nowIso();
240
+ const existing = (await loadTenantProfileBestEffort({ dataDir, tenantId: t })) ?? defaultTenantProfile({ tenantId: t });
241
+ let next = { ...existing };
242
+
243
+ if (isSample) {
244
+ next = appendOnboardingEvent(next, {
245
+ eventType: ONBOARDING_EVENT_TYPE.SAMPLE_UPLOAD_GENERATED,
246
+ at: now,
247
+ source: "sample_upload",
248
+ metadata: { verificationOk: Boolean(verificationOk) }
249
+ });
250
+ next = appendOnboardingEvent(next, {
251
+ eventType: verificationOk ? ONBOARDING_EVENT_TYPE.SAMPLE_UPLOAD_VERIFIED : ONBOARDING_EVENT_TYPE.SAMPLE_UPLOAD_FAILED,
252
+ at: now,
253
+ source: "sample_upload",
254
+ metadata: null
255
+ });
256
+ } else {
257
+ next = appendOnboardingEvent(next, {
258
+ eventType: ONBOARDING_EVENT_TYPE.REAL_UPLOAD_GENERATED,
259
+ at: now,
260
+ source: "real_upload",
261
+ metadata: { verificationOk: Boolean(verificationOk) }
262
+ });
263
+ if (!next.firstUploadAt) next.firstUploadAt = now;
264
+ if (!next.activatedAt) next.activatedAt = now;
265
+ next.status = "active";
266
+ if (verificationOk && !next.firstVerifiedAt) next.firstVerifiedAt = now;
267
+ next = appendOnboardingEvent(next, {
268
+ eventType: verificationOk ? ONBOARDING_EVENT_TYPE.REAL_UPLOAD_VERIFIED : ONBOARDING_EVENT_TYPE.REAL_UPLOAD_FAILED,
269
+ at: now,
270
+ source: "real_upload",
271
+ metadata: null
272
+ });
273
+ }
274
+
275
+ await saveTenantProfile({ dataDir, tenantId: t, profile: next });
276
+ return { ok: true, profile: next };
277
+ }
278
+
279
+ export async function recordTenantOnboardingEvent({
280
+ dataDir,
281
+ tenantId,
282
+ eventType,
283
+ at = null,
284
+ source = null,
285
+ metadata = null
286
+ } = {}) {
287
+ const t = String(tenantId ?? "").trim();
288
+ if (!t) return { ok: false, error: "tenantId required" };
289
+ let normalizedEventType;
290
+ try {
291
+ normalizedEventType = normalizeOnboardingEventType(eventType, { allowNull: false, fieldName: "eventType" });
292
+ } catch (err) {
293
+ return { ok: false, error: err?.message ?? "invalid eventType" };
294
+ }
295
+ const eventAt = typeof at === "string" && at.trim() !== "" ? at.trim() : nowIso();
296
+ let next = (await loadTenantProfileBestEffort({ dataDir, tenantId: t })) ?? defaultTenantProfile({ tenantId: t });
297
+ next = appendOnboardingEvent(next, {
298
+ eventType: normalizedEventType,
299
+ at: eventAt,
300
+ source,
301
+ metadata
302
+ });
303
+ await saveTenantProfile({ dataDir, tenantId: t, profile: next });
304
+ return { ok: true, profile: next };
305
+ }
306
+
307
+ export function onboardingMetricsFromProfile(profile) {
308
+ if (!isPlainObject(profile)) return null;
309
+ const onboardingEvents = Array.isArray(profile.onboardingEvents) ? profile.onboardingEvents : [];
310
+ const referralSharedCount = onboardingEvents.filter((row) => row?.eventType === ONBOARDING_EVENT_TYPE.REFERRAL_LINK_SHARED).length;
311
+ const referralSignupCount = onboardingEvents.filter((row) => row?.eventType === ONBOARDING_EVENT_TYPE.REFERRAL_SIGNUP).length;
312
+ const firstVerifiedMs = profile.firstVerifiedAt ? Date.parse(String(profile.firstVerifiedAt)) : NaN;
313
+ const createdMs = profile.createdAt ? Date.parse(String(profile.createdAt)) : NaN;
314
+ const timeToFirstVerifiedMs = Number.isFinite(firstVerifiedMs) && Number.isFinite(createdMs) ? Math.max(0, firstVerifiedMs - createdMs) : null;
315
+ const firstArtifactGeneratedAt = (() => {
316
+ const sampleMs = profile.firstSampleUploadAt ? Date.parse(String(profile.firstSampleUploadAt)) : NaN;
317
+ const realMs = profile.firstUploadAt ? Date.parse(String(profile.firstUploadAt)) : NaN;
318
+ if (Number.isFinite(sampleMs) && Number.isFinite(realMs)) return new Date(Math.min(sampleMs, realMs)).toISOString();
319
+ if (Number.isFinite(sampleMs)) return String(profile.firstSampleUploadAt);
320
+ if (Number.isFinite(realMs)) return String(profile.firstUploadAt);
321
+ return null;
322
+ })();
323
+ const stages = [
324
+ { stageKey: "tenant_created", label: "Tenant created", at: typeof profile.createdAt === "string" ? profile.createdAt : null },
325
+ { stageKey: "wizard_viewed", label: "Wizard viewed", at: typeof profile.firstWizardViewedAt === "string" ? profile.firstWizardViewedAt : null },
326
+ { stageKey: "template_selected", label: "Template selected", at: typeof profile.firstTemplateSelectedAt === "string" ? profile.firstTemplateSelectedAt : null },
327
+ { stageKey: "template_validated", label: "Template validated", at: typeof profile.firstTemplateRenderedAt === "string" ? profile.firstTemplateRenderedAt : null },
328
+ { stageKey: "artifact_generated", label: "Artifact generated", at: firstArtifactGeneratedAt },
329
+ { stageKey: "real_upload_generated", label: "Real upload generated", at: typeof profile.firstUploadAt === "string" ? profile.firstUploadAt : null },
330
+ { stageKey: "first_verified", label: "First verified", at: typeof profile.firstVerifiedAt === "string" ? profile.firstVerifiedAt : null },
331
+ { stageKey: "buyer_link_shared", label: "Buyer link shared", at: typeof profile.firstBuyerLinkSharedAt === "string" ? profile.firstBuyerLinkSharedAt : null },
332
+ { stageKey: "referral_signup", label: "Referral signup", at: typeof profile.firstReferralSignupAt === "string" ? profile.firstReferralSignupAt : null }
333
+ ].map((row) => ({ ...row, reached: Boolean(row.at) }));
334
+ const reachedCount = stages.filter((row) => row.reached).length;
335
+ const totalCount = stages.length;
336
+ const nextStage = stages.find((row) => !row.reached) ?? null;
337
+ const latestEvent =
338
+ onboardingEvents.length
339
+ ? onboardingEvents[onboardingEvents.length - 1]
340
+ : null;
341
+ return {
342
+ schemaVersion: "MagicLinkTenantOnboardingMetrics.v1",
343
+ tenantId: typeof profile.tenantId === "string" ? profile.tenantId : null,
344
+ status: typeof profile.status === "string" ? profile.status : "pending",
345
+ createdAt: typeof profile.createdAt === "string" ? profile.createdAt : null,
346
+ activatedAt: typeof profile.activatedAt === "string" ? profile.activatedAt : null,
347
+ firstUploadAt: typeof profile.firstUploadAt === "string" ? profile.firstUploadAt : null,
348
+ firstVerifiedAt: typeof profile.firstVerifiedAt === "string" ? profile.firstVerifiedAt : null,
349
+ firstSampleUploadAt: typeof profile.firstSampleUploadAt === "string" ? profile.firstSampleUploadAt : null,
350
+ firstSampleVerifiedAt: typeof profile.firstSampleVerifiedAt === "string" ? profile.firstSampleVerifiedAt : null,
351
+ firstBuyerLinkSharedAt: typeof profile.firstBuyerLinkSharedAt === "string" ? profile.firstBuyerLinkSharedAt : null,
352
+ firstBuyerLinkOpenedAt: typeof profile.firstBuyerLinkOpenedAt === "string" ? profile.firstBuyerLinkOpenedAt : null,
353
+ firstReferralLinkSharedAt: typeof profile.firstReferralLinkSharedAt === "string" ? profile.firstReferralLinkSharedAt : null,
354
+ firstReferralSignupAt: typeof profile.firstReferralSignupAt === "string" ? profile.firstReferralSignupAt : null,
355
+ timeToFirstVerifiedMs,
356
+ referral: {
357
+ linkSharedCount: referralSharedCount,
358
+ signupCount: referralSignupCount,
359
+ conversionRatePct: pct(referralSignupCount, referralSharedCount)
360
+ },
361
+ funnel: {
362
+ reachedStages: reachedCount,
363
+ totalStages: totalCount,
364
+ completionPct: pct(reachedCount, totalCount),
365
+ nextStageKey: nextStage ? nextStage.stageKey : null,
366
+ droppedOffStageKey: nextStage ? nextStage.stageKey : null,
367
+ stages
368
+ },
369
+ events: {
370
+ count: onboardingEvents.length,
371
+ latestEvent:
372
+ latestEvent && isPlainObject(latestEvent)
373
+ ? {
374
+ eventType: typeof latestEvent.eventType === "string" ? latestEvent.eventType : null,
375
+ at: typeof latestEvent.at === "string" ? latestEvent.at : null,
376
+ source: typeof latestEvent.source === "string" ? latestEvent.source : null
377
+ }
378
+ : null
379
+ }
380
+ };
381
+ }
382
+
383
+ export async function listTenantProfilesBestEffort({ dataDir, limit = 5000 } = {}) {
384
+ const out = [];
385
+ const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(10_000, limit) : 5000;
386
+ const root = path.join(dataDir, "tenants");
387
+ try {
388
+ const entries = await fs.readdir(root, { withFileTypes: true });
389
+ for (const entry of entries) {
390
+ if (!entry.isDirectory()) continue;
391
+ if (out.length >= safeLimit) break;
392
+ const tenantId = String(entry.name ?? "").trim();
393
+ if (!tenantId) continue;
394
+ // eslint-disable-next-line no-await-in-loop
395
+ const profile = await loadTenantProfileBestEffort({ dataDir, tenantId });
396
+ if (profile) out.push(profile);
397
+ }
398
+ } catch {
399
+ return [];
400
+ }
401
+ out.sort((a, b) => String(a?.tenantId ?? "").localeCompare(String(b?.tenantId ?? "")));
402
+ return out;
403
+ }
404
+
405
+ export function onboardingCohortMetricsFromProfiles(profiles, { limit = 24 } = {}) {
406
+ const rows = Array.isArray(profiles) ? profiles : [];
407
+ const byMonth = new Map();
408
+ for (const profile of rows) {
409
+ if (!isPlainObject(profile)) continue;
410
+ const cohortMonth = monthKeyFromIso(profile.createdAt);
411
+ if (!cohortMonth) continue;
412
+ if (!byMonth.has(cohortMonth)) {
413
+ byMonth.set(cohortMonth, {
414
+ cohortMonth,
415
+ tenants: 0,
416
+ wizardViewed: 0,
417
+ templateValidated: 0,
418
+ artifactGenerated: 0,
419
+ realUpload: 0,
420
+ verified: 0,
421
+ buyerLinkShared: 0,
422
+ referralLinkShared: 0,
423
+ referralSignup: 0,
424
+ timeToFirstVerifiedMs: []
425
+ });
426
+ }
427
+ const row = byMonth.get(cohortMonth);
428
+ row.tenants += 1;
429
+ if (profile.firstWizardViewedAt) row.wizardViewed += 1;
430
+ if (profile.firstTemplateRenderedAt) row.templateValidated += 1;
431
+ if (profile.firstSampleUploadAt || profile.firstUploadAt) row.artifactGenerated += 1;
432
+ if (profile.firstUploadAt) row.realUpload += 1;
433
+ if (profile.firstVerifiedAt) row.verified += 1;
434
+ if (profile.firstBuyerLinkSharedAt) row.buyerLinkShared += 1;
435
+ if (profile.firstReferralLinkSharedAt) row.referralLinkShared += 1;
436
+ if (profile.firstReferralSignupAt) row.referralSignup += 1;
437
+ const metrics = onboardingMetricsFromProfile(profile);
438
+ if (Number.isFinite(Number(metrics?.timeToFirstVerifiedMs))) {
439
+ row.timeToFirstVerifiedMs.push(Number(metrics.timeToFirstVerifiedMs));
440
+ }
441
+ }
442
+ const cohortRows = [...byMonth.values()]
443
+ .map((row) => ({
444
+ cohortMonth: row.cohortMonth,
445
+ tenants: row.tenants,
446
+ wizardViewed: row.wizardViewed,
447
+ templateValidated: row.templateValidated,
448
+ artifactGenerated: row.artifactGenerated,
449
+ realUpload: row.realUpload,
450
+ verified: row.verified,
451
+ buyerLinkShared: row.buyerLinkShared,
452
+ referralLinkShared: row.referralLinkShared,
453
+ referralSignup: row.referralSignup,
454
+ wizardViewedRatePct: pct(row.wizardViewed, row.tenants),
455
+ templateValidatedRatePct: pct(row.templateValidated, row.tenants),
456
+ artifactGeneratedRatePct: pct(row.artifactGenerated, row.tenants),
457
+ realUploadRatePct: pct(row.realUpload, row.tenants),
458
+ verifiedRatePct: pct(row.verified, row.tenants),
459
+ buyerLinkSharedRatePct: pct(row.buyerLinkShared, row.tenants),
460
+ referralLinkSharedRatePct: pct(row.referralLinkShared, row.tenants),
461
+ referralSignupRatePct: pct(row.referralSignup, row.tenants),
462
+ medianTimeToFirstVerifiedMs: medianMs(row.timeToFirstVerifiedMs)
463
+ }))
464
+ .sort((a, b) => String(b.cohortMonth).localeCompare(String(a.cohortMonth)));
465
+ const safeLimit = Number.isInteger(limit) && limit > 0 ? Math.min(120, limit) : 24;
466
+ return cohortRows.slice(0, safeLimit);
467
+ }