settld 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +2 -2
- package/docs/CONFIG.md +12 -0
- package/docs/README.md +3 -0
- package/docs/ops/HOSTED_BASELINE_R2.md +4 -2
- package/docs/ops/MINIMUM_PRODUCTION_TOPOLOGY.md +19 -7
- package/docs/ops/PRODUCTION_DEPLOYMENT_CHECKLIST.md +8 -3
- package/package.json +4 -1
- package/packages/api-sdk/README.md +71 -0
- package/packages/api-sdk/src/client.js +1021 -0
- package/packages/api-sdk/src/express-middleware.js +163 -0
- package/packages/api-sdk/src/index.d.ts +1662 -0
- package/packages/api-sdk/src/index.js +10 -0
- package/packages/api-sdk/src/webhook-signature.js +182 -0
- package/packages/api-sdk/src/x402-autopay.js +210 -0
- package/scripts/ci/cli-pack-smoke.mjs +2 -0
- package/scripts/ci/run-public-onboarding-gate.mjs +136 -0
- package/scripts/setup/login.mjs +73 -2
- package/scripts/setup/onboard.mjs +173 -28
- package/scripts/setup/onboarding-failure-taxonomy.mjs +107 -0
- package/scripts/setup/onboarding-state-machine.mjs +102 -0
- package/services/magic-link/README.md +352 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-bad/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_criteria.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/acceptance/acceptance_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/evidence/evidence_index.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/invoice/invoice_claim.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/metering/metering_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/attestation/bundle_head_attestation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/events.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/events/payload_material.jsonl +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/global/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/policy.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/revocations.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/events.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/events/payload_material.jsonl +0 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/governance/tenant/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/job/snapshot.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/keys/public_keys.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/manifest.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/payload/job_proof_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/pricing/pricing_matrix_signatures.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/payload/invoice_bundle/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/settld.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_definition.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/sla/sla_evaluation.json +1 -0
- package/services/magic-link/assets/samples/closepack/known-good/verify/verification_report.json +1 -0
- package/services/magic-link/assets/samples/trust.json +11 -0
- package/services/magic-link/src/audit-log.js +24 -0
- package/services/magic-link/src/buyer-auth.js +251 -0
- package/services/magic-link/src/buyer-notifications.js +402 -0
- package/services/magic-link/src/buyer-users.js +129 -0
- package/services/magic-link/src/decision-otp.js +187 -0
- package/services/magic-link/src/decisions.js +92 -0
- package/services/magic-link/src/email-resend.js +89 -0
- package/services/magic-link/src/ingest-keys.js +137 -0
- package/services/magic-link/src/maintenance.js +95 -0
- package/services/magic-link/src/onboarding-email-sequence.js +331 -0
- package/services/magic-link/src/payment-triggers.js +733 -0
- package/services/magic-link/src/pdf.js +149 -0
- package/services/magic-link/src/policy.js +69 -0
- package/services/magic-link/src/redaction.js +6 -0
- package/services/magic-link/src/render-model.js +70 -0
- package/services/magic-link/src/retention-gc.js +158 -0
- package/services/magic-link/src/run-records.js +496 -0
- package/services/magic-link/src/s3.js +171 -0
- package/services/magic-link/src/server.js +15849 -0
- package/services/magic-link/src/settlement-decisions.js +84 -0
- package/services/magic-link/src/smtp.js +217 -0
- package/services/magic-link/src/storage-cli.js +88 -0
- package/services/magic-link/src/storage-format.js +59 -0
- package/services/magic-link/src/tenant-billing.js +115 -0
- package/services/magic-link/src/tenant-onboarding.js +467 -0
- package/services/magic-link/src/tenant-settings.js +1140 -0
- package/services/magic-link/src/usage.js +80 -0
- package/services/magic-link/src/verify-queue.js +179 -0
- package/services/magic-link/src/verify-worker.js +157 -0
- package/services/magic-link/src/webhook-retries.js +542 -0
- package/services/magic-link/src/webhooks.js +218 -0
- package/src/api/app.js +135 -1
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function isPlainObject(v) {
|
|
5
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function dirFor({ dataDir, token }) {
|
|
9
|
+
return path.join(dataDir, "settlement_decisions", String(token));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function parseSeqFromName(name) {
|
|
13
|
+
const m = /^([0-9]{4})_(approve|hold)\.json$/.exec(String(name ?? ""));
|
|
14
|
+
if (!m) return null;
|
|
15
|
+
const n = Number.parseInt(m[1], 10);
|
|
16
|
+
if (!Number.isInteger(n) || n < 0) return null;
|
|
17
|
+
return n;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function listSettlementDecisionReportFiles({ dataDir, token }) {
|
|
21
|
+
const dir = dirFor({ dataDir, token });
|
|
22
|
+
let names = [];
|
|
23
|
+
try {
|
|
24
|
+
names = (await fs.readdir(dir)).filter((n) => n.endsWith(".json"));
|
|
25
|
+
} catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
const parsed = [];
|
|
29
|
+
for (const name of names) {
|
|
30
|
+
const seq = parseSeqFromName(name);
|
|
31
|
+
if (seq === null) continue;
|
|
32
|
+
parsed.push({ seq, name, path: path.join(dir, name) });
|
|
33
|
+
}
|
|
34
|
+
parsed.sort((a, b) => a.seq - b.seq || String(a.name).localeCompare(String(b.name)));
|
|
35
|
+
return parsed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadLatestSettlementDecisionReport({ dataDir, token }) {
|
|
39
|
+
const files = await listSettlementDecisionReportFiles({ dataDir, token });
|
|
40
|
+
if (!files.length) return null;
|
|
41
|
+
const last = files[files.length - 1];
|
|
42
|
+
try {
|
|
43
|
+
return JSON.parse(await fs.readFile(last.path, "utf8"));
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nextSeq(files) {
|
|
50
|
+
let max = 0;
|
|
51
|
+
for (const f of Array.isArray(files) ? files : []) {
|
|
52
|
+
const n = Number(f?.seq);
|
|
53
|
+
if (Number.isInteger(n) && n >= max) max = n + 1;
|
|
54
|
+
}
|
|
55
|
+
return max;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeDecision(v) {
|
|
59
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
60
|
+
if (s === "approve" || s === "hold") return s;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function appendSettlementDecisionReport({ dataDir, token, report }) {
|
|
65
|
+
if (!isPlainObject(report) || String(report.schemaVersion ?? "") !== "SettlementDecisionReport.v1") {
|
|
66
|
+
return { ok: false, error: "INVALID_REPORT", message: "report must be SettlementDecisionReport.v1" };
|
|
67
|
+
}
|
|
68
|
+
const decision = normalizeDecision(report.decision);
|
|
69
|
+
if (!decision) return { ok: false, error: "INVALID_DECISION", message: "decision must be approve|hold" };
|
|
70
|
+
|
|
71
|
+
const dir = dirFor({ dataDir, token });
|
|
72
|
+
await fs.mkdir(dir, { recursive: true });
|
|
73
|
+
|
|
74
|
+
const existing = await listSettlementDecisionReportFiles({ dataDir, token });
|
|
75
|
+
const seq = nextSeq(existing);
|
|
76
|
+
if (seq > 9999) return { ok: false, error: "TOO_MANY_DECISIONS", message: "too many settlement decisions recorded" };
|
|
77
|
+
|
|
78
|
+
const name = `${String(seq).padStart(4, "0")}_${decision}.json`;
|
|
79
|
+
const fp = path.join(dir, name);
|
|
80
|
+
const raw = JSON.stringify(report, null, 2) + "\n";
|
|
81
|
+
await fs.writeFile(fp, raw, "utf8");
|
|
82
|
+
return { ok: true, path: fp, name, seq };
|
|
83
|
+
}
|
|
84
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
|
|
5
|
+
function b64(text) {
|
|
6
|
+
return Buffer.from(String(text ?? ""), "utf8").toString("base64");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function nowRfc2822() {
|
|
10
|
+
return new Date().toUTCString();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function makeMessageId(domain = "localhost") {
|
|
14
|
+
const id = crypto.randomBytes(16).toString("hex");
|
|
15
|
+
return `<${id}@${domain}>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function clampText(v, { max }) {
|
|
19
|
+
const s = String(v ?? "");
|
|
20
|
+
if (s.length <= max) return s;
|
|
21
|
+
return s.slice(0, Math.max(0, max - 1)) + "…";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SIMPLE_EMAIL_RE = /^[^\s@<>]+@[^\s@<>]+$/;
|
|
25
|
+
|
|
26
|
+
export function extractSmtpEnvelopeAddress(value, { fieldName = "address" } = {}) {
|
|
27
|
+
const raw = String(value ?? "").trim();
|
|
28
|
+
if (!raw) throw new Error(`smtp ${fieldName} required`);
|
|
29
|
+
const bracketed = /<\s*([^<>\s@]+@[^\s@<>]+)\s*>/.exec(raw);
|
|
30
|
+
const candidate = (bracketed ? bracketed[1] : raw).trim();
|
|
31
|
+
if (!SIMPLE_EMAIL_RE.test(candidate)) {
|
|
32
|
+
throw new Error(`smtp ${fieldName} must be an email address`);
|
|
33
|
+
}
|
|
34
|
+
return candidate;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatSmtpMessage({ from, to, subject, text, messageIdDomain }) {
|
|
38
|
+
const subj = clampText(subject, { max: 200 });
|
|
39
|
+
const body = String(text ?? "");
|
|
40
|
+
const msgId = makeMessageId(messageIdDomain ?? "localhost");
|
|
41
|
+
const headers = [
|
|
42
|
+
`From: ${from}`,
|
|
43
|
+
`To: ${to}`,
|
|
44
|
+
`Subject: ${subj}`,
|
|
45
|
+
`Date: ${nowRfc2822()}`,
|
|
46
|
+
`Message-ID: ${msgId}`,
|
|
47
|
+
"MIME-Version: 1.0",
|
|
48
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
49
|
+
"Content-Transfer-Encoding: 8bit"
|
|
50
|
+
];
|
|
51
|
+
// Ensure CRLF line endings for SMTP DATA.
|
|
52
|
+
const normalizedBody = body.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n").join("\r\n");
|
|
53
|
+
return headers.join("\r\n") + "\r\n\r\n" + normalizedBody + "\r\n";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function dotStuffSmtpData(data) {
|
|
57
|
+
return String(data ?? "")
|
|
58
|
+
.split("\r\n")
|
|
59
|
+
.map((l) => (l.startsWith(".") ? "." + l : l))
|
|
60
|
+
.join("\r\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createLineReader(socket, { timeoutMs }) {
|
|
64
|
+
let buf = "";
|
|
65
|
+
const queue = [];
|
|
66
|
+
const waiters = [];
|
|
67
|
+
|
|
68
|
+
function pushLine(line) {
|
|
69
|
+
if (waiters.length) {
|
|
70
|
+
const w = waiters.shift();
|
|
71
|
+
w.resolve(line);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
queue.push(line);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
socket.on("data", (d) => {
|
|
78
|
+
buf += d.toString("utf8");
|
|
79
|
+
while (true) {
|
|
80
|
+
const idx = buf.indexOf("\n");
|
|
81
|
+
if (idx === -1) break;
|
|
82
|
+
const raw = buf.slice(0, idx + 1);
|
|
83
|
+
buf = buf.slice(idx + 1);
|
|
84
|
+
const line = raw.replace(/\r?\n$/, "");
|
|
85
|
+
pushLine(line);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function readLine() {
|
|
90
|
+
if (queue.length) return Promise.resolve(queue.shift());
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const t = setTimeout(() => reject(new Error("smtp timeout")), timeoutMs);
|
|
93
|
+
waiters.push({
|
|
94
|
+
resolve: (v) => {
|
|
95
|
+
clearTimeout(t);
|
|
96
|
+
resolve(v);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { readLine };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function readReply(reader) {
|
|
106
|
+
const lines = [];
|
|
107
|
+
let code = null;
|
|
108
|
+
while (true) {
|
|
109
|
+
// eslint-disable-next-line no-await-in-loop
|
|
110
|
+
const line = await reader.readLine();
|
|
111
|
+
lines.push(line);
|
|
112
|
+
const m = /^(\d{3})([ -])/.exec(line);
|
|
113
|
+
if (!m) continue;
|
|
114
|
+
code = Number.parseInt(m[1], 10);
|
|
115
|
+
const sep = m[2];
|
|
116
|
+
if (sep === " ") break;
|
|
117
|
+
}
|
|
118
|
+
return { code: code ?? 0, lines };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function sendCmd(socket, reader, cmd, expectedCodes) {
|
|
122
|
+
socket.write(String(cmd ?? "") + "\r\n");
|
|
123
|
+
const rep = await readReply(reader);
|
|
124
|
+
const allowed = Array.isArray(expectedCodes) ? expectedCodes : [expectedCodes];
|
|
125
|
+
if (!allowed.includes(rep.code)) {
|
|
126
|
+
const detail = rep.lines.join("\n");
|
|
127
|
+
throw new Error(`smtp unexpected reply for ${String(cmd).split(" ")[0]}: ${rep.code}\n${detail}`);
|
|
128
|
+
}
|
|
129
|
+
return rep;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function sendSmtpMail({
|
|
133
|
+
host,
|
|
134
|
+
port,
|
|
135
|
+
secure = false,
|
|
136
|
+
starttls = true,
|
|
137
|
+
auth = null,
|
|
138
|
+
from,
|
|
139
|
+
to,
|
|
140
|
+
subject,
|
|
141
|
+
text,
|
|
142
|
+
timeoutMs = 10_000
|
|
143
|
+
} = {}) {
|
|
144
|
+
const h = String(host ?? "").trim();
|
|
145
|
+
const p = Number.parseInt(String(port ?? ""), 10);
|
|
146
|
+
if (!h) throw new Error("smtp host required");
|
|
147
|
+
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("smtp port invalid");
|
|
148
|
+
if (!from || !to) throw new Error("smtp from/to required");
|
|
149
|
+
const envelopeFrom = extractSmtpEnvelopeAddress(from, { fieldName: "from" });
|
|
150
|
+
const envelopeTo = extractSmtpEnvelopeAddress(to, { fieldName: "to" });
|
|
151
|
+
|
|
152
|
+
const connect = secure
|
|
153
|
+
? () => tls.connect({ host: h, port: p, servername: h })
|
|
154
|
+
: () => net.connect({ host: h, port: p });
|
|
155
|
+
|
|
156
|
+
let socket = connect();
|
|
157
|
+
socket.setTimeout(timeoutMs);
|
|
158
|
+
socket.on("timeout", () => {
|
|
159
|
+
try {
|
|
160
|
+
socket.destroy(new Error("smtp timeout"));
|
|
161
|
+
} catch {
|
|
162
|
+
// ignore
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await new Promise((resolve, reject) => {
|
|
167
|
+
socket.once("error", reject);
|
|
168
|
+
socket.once("connect", resolve);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
let reader = createLineReader(socket, { timeoutMs });
|
|
172
|
+
const greet = await readReply(reader);
|
|
173
|
+
if (greet.code !== 220) throw new Error(`smtp bad greeting: ${greet.code}`);
|
|
174
|
+
|
|
175
|
+
const ehlo = async () => await sendCmd(socket, reader, `EHLO settld`, 250);
|
|
176
|
+
let ehloReply = await ehlo();
|
|
177
|
+
|
|
178
|
+
const supportsStarttls = ehloReply.lines.some((l) => /STARTTLS/i.test(l));
|
|
179
|
+
if (!secure && starttls && supportsStarttls) {
|
|
180
|
+
await sendCmd(socket, reader, "STARTTLS", 220);
|
|
181
|
+
socket = tls.connect({ socket, servername: h });
|
|
182
|
+
socket.setTimeout(timeoutMs);
|
|
183
|
+
reader = createLineReader(socket, { timeoutMs });
|
|
184
|
+
ehloReply = await ehlo();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (auth && typeof auth === "object") {
|
|
188
|
+
const user = typeof auth.user === "string" ? auth.user : "";
|
|
189
|
+
const pass = typeof auth.pass === "string" ? auth.pass : "";
|
|
190
|
+
if (user && pass) {
|
|
191
|
+
const token = b64(`\u0000${user}\u0000${pass}`);
|
|
192
|
+
await sendCmd(socket, reader, `AUTH PLAIN ${token}`, [235, 250]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
await sendCmd(socket, reader, `MAIL FROM:<${envelopeFrom}>`, 250);
|
|
197
|
+
await sendCmd(socket, reader, `RCPT TO:<${envelopeTo}>`, [250, 251]);
|
|
198
|
+
await sendCmd(socket, reader, "DATA", 354);
|
|
199
|
+
|
|
200
|
+
const domain = (() => {
|
|
201
|
+
const i = envelopeFrom.indexOf("@");
|
|
202
|
+
return i !== -1 ? envelopeFrom.slice(i + 1) : "localhost";
|
|
203
|
+
})();
|
|
204
|
+
const msg = formatSmtpMessage({ from, to, subject, text, messageIdDomain: domain });
|
|
205
|
+
|
|
206
|
+
const stuffed = dotStuffSmtpData(msg);
|
|
207
|
+
|
|
208
|
+
socket.write(stuffed + "\r\n.\r\n");
|
|
209
|
+
const dataReply = await readReply(reader);
|
|
210
|
+
if (dataReply.code !== 250) throw new Error(`smtp DATA failed: ${dataReply.code}`);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
await sendCmd(socket, reader, "QUIT", 221);
|
|
214
|
+
} catch {
|
|
215
|
+
// ignore
|
|
216
|
+
}
|
|
217
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
import { checkAndMigrateDataDir, readFormatInfo, MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT } from "./storage-format.js";
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
// eslint-disable-next-line no-console
|
|
8
|
+
console.error(
|
|
9
|
+
[
|
|
10
|
+
"usage:",
|
|
11
|
+
" node services/magic-link/src/storage-cli.js check --data-dir <path>",
|
|
12
|
+
" node services/magic-link/src/storage-cli.js migrate --data-dir <path>",
|
|
13
|
+
"",
|
|
14
|
+
"notes:",
|
|
15
|
+
` current format version: ${MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT}`,
|
|
16
|
+
" check does not write; migrate initializes/upgrades the data dir format marker."
|
|
17
|
+
].join("\n")
|
|
18
|
+
);
|
|
19
|
+
process.exit(2);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const cmd = argv[0];
|
|
24
|
+
if (cmd !== "check" && cmd !== "migrate") usage();
|
|
25
|
+
|
|
26
|
+
let dataDir = null;
|
|
27
|
+
for (let i = 1; i < argv.length; i += 1) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a === "--data-dir") {
|
|
30
|
+
dataDir = String(argv[i + 1] ?? "");
|
|
31
|
+
i += 1;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (a === "--help" || a === "-h") usage();
|
|
35
|
+
usage();
|
|
36
|
+
}
|
|
37
|
+
if (!dataDir) usage();
|
|
38
|
+
return { cmd, dataDir };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
const { cmd, dataDir } = parseArgs(process.argv.slice(2));
|
|
43
|
+
|
|
44
|
+
if (cmd === "check") {
|
|
45
|
+
const info = await readFormatInfo({ dataDir });
|
|
46
|
+
if (!info) {
|
|
47
|
+
// eslint-disable-next-line no-console
|
|
48
|
+
console.error(JSON.stringify({ ok: false, code: "DATA_DIR_UNINITIALIZED", currentVersion: MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT }, null, 2));
|
|
49
|
+
process.exit(3);
|
|
50
|
+
}
|
|
51
|
+
const v = Number.parseInt(String(info.version ?? ""), 10);
|
|
52
|
+
if (!Number.isInteger(v)) {
|
|
53
|
+
// eslint-disable-next-line no-console
|
|
54
|
+
console.error(JSON.stringify({ ok: false, code: "DATA_DIR_FORMAT_INVALID", currentVersion: MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT, format: info }, null, 2));
|
|
55
|
+
process.exit(4);
|
|
56
|
+
}
|
|
57
|
+
if (v > MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT) {
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.error(
|
|
60
|
+
JSON.stringify(
|
|
61
|
+
{ ok: false, code: "DATA_DIR_TOO_NEW", currentVersion: MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT, foundVersion: v, format: info },
|
|
62
|
+
null,
|
|
63
|
+
2
|
|
64
|
+
)
|
|
65
|
+
);
|
|
66
|
+
process.exit(5);
|
|
67
|
+
}
|
|
68
|
+
// eslint-disable-next-line no-console
|
|
69
|
+
console.log(JSON.stringify({ ok: true, currentVersion: MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT, foundVersion: v, format: info }, null, 2));
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const migrated = await checkAndMigrateDataDir({ dataDir, migrateOnStartup: true });
|
|
74
|
+
if (!migrated.ok) {
|
|
75
|
+
// eslint-disable-next-line no-console
|
|
76
|
+
console.error(JSON.stringify(migrated, null, 2));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.log(JSON.stringify(migrated, null, 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
main().catch((err) => {
|
|
84
|
+
// eslint-disable-next-line no-console
|
|
85
|
+
console.error(err?.stack ?? String(err ?? ""));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
});
|
|
88
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function nowIso() {
|
|
5
|
+
return new Date().toISOString();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isPlainObject(v) {
|
|
9
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT = 1;
|
|
13
|
+
|
|
14
|
+
export function formatInfoPath({ dataDir }) {
|
|
15
|
+
return path.join(String(dataDir ?? ""), "format.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readFormatInfo({ dataDir }) {
|
|
19
|
+
const fp = formatInfoPath({ dataDir });
|
|
20
|
+
try {
|
|
21
|
+
const raw = await fs.readFile(fp, "utf8");
|
|
22
|
+
const j = JSON.parse(raw);
|
|
23
|
+
if (!isPlainObject(j)) return null;
|
|
24
|
+
if (String(j.schemaVersion ?? "") !== "MagicLinkDataFormat.v1") return null;
|
|
25
|
+
return j;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function writeFormatInfo({ dataDir, version }) {
|
|
32
|
+
const fp = formatInfoPath({ dataDir });
|
|
33
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
34
|
+
const record = { schemaVersion: "MagicLinkDataFormat.v1", version, writtenAt: nowIso() };
|
|
35
|
+
await fs.writeFile(fp, JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
36
|
+
return record;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function checkAndMigrateDataDir({ dataDir, migrateOnStartup = true } = {}) {
|
|
40
|
+
const cur = MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT;
|
|
41
|
+
const existing = await readFormatInfo({ dataDir });
|
|
42
|
+
if (!existing) {
|
|
43
|
+
if (!migrateOnStartup) return { ok: false, code: "DATA_DIR_UNINITIALIZED", currentVersion: cur };
|
|
44
|
+
const written = await writeFormatInfo({ dataDir, version: cur });
|
|
45
|
+
return { ok: true, currentVersion: cur, previousVersion: null, initialized: true, migrated: false, format: written };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const v = Number.parseInt(String(existing.version ?? ""), 10);
|
|
49
|
+
if (!Number.isInteger(v) || v < 1) return { ok: false, code: "DATA_DIR_FORMAT_INVALID", currentVersion: cur, format: existing };
|
|
50
|
+
if (v === cur) return { ok: true, currentVersion: cur, previousVersion: v, initialized: false, migrated: false, format: existing };
|
|
51
|
+
if (v > cur) return { ok: false, code: "DATA_DIR_TOO_NEW", currentVersion: cur, foundVersion: v, format: existing };
|
|
52
|
+
|
|
53
|
+
if (!migrateOnStartup) return { ok: false, code: "MIGRATIONS_DISABLED", currentVersion: cur, foundVersion: v, format: existing };
|
|
54
|
+
|
|
55
|
+
// v1 -> current (no-op today). Future versions should apply explicit migrations here.
|
|
56
|
+
const written = await writeFormatInfo({ dataDir, version: cur });
|
|
57
|
+
return { ok: true, currentVersion: cur, previousVersion: v, initialized: false, migrated: true, format: written };
|
|
58
|
+
}
|
|
59
|
+
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function nowIso() {
|
|
5
|
+
return new Date().toISOString();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function isPlainObject(v) {
|
|
9
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function defaultTenantBillingState({ tenantId }) {
|
|
13
|
+
return {
|
|
14
|
+
schemaVersion: "MagicLinkTenantBilling.v1",
|
|
15
|
+
tenantId,
|
|
16
|
+
provider: "stripe",
|
|
17
|
+
currentPlan: "free",
|
|
18
|
+
status: "inactive",
|
|
19
|
+
customerId: null,
|
|
20
|
+
subscriptionId: null,
|
|
21
|
+
lastCheckoutSessionId: null,
|
|
22
|
+
paymentDelinquent: false,
|
|
23
|
+
suspended: false,
|
|
24
|
+
updatedAt: nowIso(),
|
|
25
|
+
lastEvent: null
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function billingStatePath({ dataDir, tenantId }) {
|
|
30
|
+
return path.join(dataDir, "tenants", tenantId, "billing.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stripeEventPath({ dataDir, eventId }) {
|
|
34
|
+
return path.join(dataDir, "billing", "stripe", "events", `${eventId}.json`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function stripeCustomerPath({ dataDir, customerId }) {
|
|
38
|
+
return path.join(dataDir, "billing", "stripe", "customers", `${customerId}.json`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function loadTenantBillingStateBestEffort({ dataDir, tenantId }) {
|
|
42
|
+
const fp = billingStatePath({ dataDir, tenantId });
|
|
43
|
+
try {
|
|
44
|
+
const raw = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
45
|
+
if (!isPlainObject(raw)) return defaultTenantBillingState({ tenantId });
|
|
46
|
+
return { ...defaultTenantBillingState({ tenantId }), ...raw, tenantId };
|
|
47
|
+
} catch {
|
|
48
|
+
return defaultTenantBillingState({ tenantId });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function saveTenantBillingState({ dataDir, tenantId, state }) {
|
|
53
|
+
const fp = billingStatePath({ dataDir, tenantId });
|
|
54
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
55
|
+
const next = { ...defaultTenantBillingState({ tenantId }), ...(isPlainObject(state) ? state : {}), tenantId, updatedAt: nowIso() };
|
|
56
|
+
await fs.writeFile(fp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
57
|
+
return next;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function patchTenantBillingState({ dataDir, tenantId, patch }) {
|
|
61
|
+
const cur = await loadTenantBillingStateBestEffort({ dataDir, tenantId });
|
|
62
|
+
return await saveTenantBillingState({ dataDir, tenantId, state: { ...cur, ...(isPlainObject(patch) ? patch : {}) } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function isStripeEventProcessed({ dataDir, eventId }) {
|
|
66
|
+
if (!eventId || typeof eventId !== "string") return false;
|
|
67
|
+
const fp = stripeEventPath({ dataDir, eventId });
|
|
68
|
+
try {
|
|
69
|
+
await fs.access(fp);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function markStripeEventProcessed({ dataDir, eventId, payload }) {
|
|
77
|
+
if (!eventId || typeof eventId !== "string") return null;
|
|
78
|
+
const fp = stripeEventPath({ dataDir, eventId });
|
|
79
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
80
|
+
const row = {
|
|
81
|
+
schemaVersion: "MagicLinkStripeEventReceipt.v1",
|
|
82
|
+
eventId,
|
|
83
|
+
receivedAt: nowIso(),
|
|
84
|
+
payload
|
|
85
|
+
};
|
|
86
|
+
await fs.writeFile(fp, JSON.stringify(row, null, 2) + "\n", "utf8");
|
|
87
|
+
return row;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function setStripeCustomerTenantMap({ dataDir, customerId, tenantId }) {
|
|
91
|
+
if (!customerId || !tenantId) return null;
|
|
92
|
+
const fp = stripeCustomerPath({ dataDir, customerId });
|
|
93
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
94
|
+
const row = {
|
|
95
|
+
schemaVersion: "MagicLinkStripeCustomerTenantMap.v1",
|
|
96
|
+
customerId,
|
|
97
|
+
tenantId,
|
|
98
|
+
updatedAt: nowIso()
|
|
99
|
+
};
|
|
100
|
+
await fs.writeFile(fp, JSON.stringify(row, null, 2) + "\n", "utf8");
|
|
101
|
+
return row;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function getTenantIdByStripeCustomerId({ dataDir, customerId }) {
|
|
105
|
+
if (!customerId || typeof customerId !== "string") return null;
|
|
106
|
+
const fp = stripeCustomerPath({ dataDir, customerId });
|
|
107
|
+
try {
|
|
108
|
+
const raw = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
109
|
+
if (!isPlainObject(raw)) return null;
|
|
110
|
+
const tenantId = typeof raw.tenantId === "string" ? raw.tenantId.trim() : "";
|
|
111
|
+
return tenantId || null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|