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,187 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { sendSmtpMail } from "./smtp.js";
|
|
6
|
+
import { sendResendMail } from "./email-resend.js";
|
|
7
|
+
|
|
8
|
+
function nowIso() {
|
|
9
|
+
return new Date().toISOString();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function sha256Hex(text) {
|
|
13
|
+
return crypto.createHash("sha256").update(String(text ?? ""), "utf8").digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clampText(v, { max }) {
|
|
17
|
+
const s = String(v ?? "").trim();
|
|
18
|
+
if (!s) return null;
|
|
19
|
+
return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeEmail(value) {
|
|
23
|
+
const raw = clampText(value, { max: 320 });
|
|
24
|
+
if (!raw) return null;
|
|
25
|
+
const email = raw.toLowerCase();
|
|
26
|
+
if (!email.includes("@")) return null;
|
|
27
|
+
const [local, domain, ...rest] = email.split("@");
|
|
28
|
+
if (!local || !domain || rest.length) return null;
|
|
29
|
+
if (/\s/.test(email)) return null;
|
|
30
|
+
return email;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function otpRecordPath({ dataDir, token, email }) {
|
|
34
|
+
const key = sha256Hex(`${token}\n${email}`);
|
|
35
|
+
return path.join(dataDir, "decision-otp", token, `${key}.json`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function otpOutboxPath({ dataDir, token, email }) {
|
|
39
|
+
const key = sha256Hex(`${token}\n${email}`);
|
|
40
|
+
return path.join(dataDir, "decision-otp-outbox", `${token}_${key}.json`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function issueCode6() {
|
|
44
|
+
const n = crypto.randomInt(0, 1_000_000);
|
|
45
|
+
return String(n).padStart(6, "0");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function errorMessageOrFallback(err, fallback = "smtp failed") {
|
|
49
|
+
const direct = typeof err?.message === "string" ? err.message.trim() : "";
|
|
50
|
+
if (direct) return direct;
|
|
51
|
+
const asText = String(err ?? "").trim();
|
|
52
|
+
if (asText && asText !== "[object Object]") return asText;
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function issueDecisionOtp({ dataDir, token, email, ttlSeconds, deliveryMode, smtp, resend } = {}) {
|
|
57
|
+
const emailNorm = normalizeEmail(email);
|
|
58
|
+
if (!emailNorm) return { ok: false, error: "INVALID_EMAIL", message: "invalid email" };
|
|
59
|
+
|
|
60
|
+
const ttl = Number.parseInt(String(ttlSeconds ?? ""), 10);
|
|
61
|
+
if (!Number.isInteger(ttl) || ttl <= 0) throw new TypeError("ttlSeconds must be a positive integer");
|
|
62
|
+
|
|
63
|
+
const code = issueCode6();
|
|
64
|
+
const createdAt = nowIso();
|
|
65
|
+
const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
|
|
66
|
+
const codeSha256 = sha256Hex(`${token}\n${emailNorm}\n${code}`);
|
|
67
|
+
|
|
68
|
+
const record = {
|
|
69
|
+
schemaVersion: "DecisionOtpRecord.v1",
|
|
70
|
+
token,
|
|
71
|
+
email: emailNorm,
|
|
72
|
+
codeSha256,
|
|
73
|
+
createdAt,
|
|
74
|
+
expiresAt,
|
|
75
|
+
consumedAt: null,
|
|
76
|
+
attempts: 0
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const fp = otpRecordPath({ dataDir, token, email: emailNorm });
|
|
80
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
81
|
+
await fs.writeFile(fp, JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
82
|
+
|
|
83
|
+
const mode = String(deliveryMode ?? "record").trim().toLowerCase();
|
|
84
|
+
if (mode === "record") {
|
|
85
|
+
const outFp = otpOutboxPath({ dataDir, token, email: emailNorm });
|
|
86
|
+
await fs.mkdir(path.dirname(outFp), { recursive: true });
|
|
87
|
+
await fs.writeFile(
|
|
88
|
+
outFp,
|
|
89
|
+
JSON.stringify(
|
|
90
|
+
{ schemaVersion: "DecisionOtpOutboxRecord.v1", token, email: emailNorm, code, createdAt, expiresAt },
|
|
91
|
+
null,
|
|
92
|
+
2
|
|
93
|
+
) + "\n",
|
|
94
|
+
"utf8"
|
|
95
|
+
);
|
|
96
|
+
} else if (mode === "log") {
|
|
97
|
+
// eslint-disable-next-line no-console
|
|
98
|
+
console.log(`decision otp token=${token} email=${emailNorm} code=${code} expiresAt=${expiresAt}`);
|
|
99
|
+
} else if (mode === "smtp") {
|
|
100
|
+
const from = typeof smtp?.from === "string" ? smtp.from.trim() : "";
|
|
101
|
+
if (!from) return { ok: false, error: "SMTP_NOT_CONFIGURED", message: "smtp.from is required" };
|
|
102
|
+
try {
|
|
103
|
+
await sendSmtpMail({
|
|
104
|
+
host: smtp?.host,
|
|
105
|
+
port: smtp?.port,
|
|
106
|
+
secure: Boolean(smtp?.secure),
|
|
107
|
+
starttls: smtp?.starttls === undefined ? true : Boolean(smtp?.starttls),
|
|
108
|
+
auth: smtp?.user && smtp?.pass ? { user: smtp.user, pass: smtp.pass } : null,
|
|
109
|
+
from,
|
|
110
|
+
to: emailNorm,
|
|
111
|
+
subject: "Your Settld decision code",
|
|
112
|
+
text: `Your decision code is: ${code}\n\nThis code expires at: ${expiresAt}\n\nIf you did not request this code, you can ignore this email.\n`
|
|
113
|
+
});
|
|
114
|
+
} catch (err) {
|
|
115
|
+
return { ok: false, error: "SMTP_SEND_FAILED", message: errorMessageOrFallback(err, "smtp send failed") };
|
|
116
|
+
}
|
|
117
|
+
} else if (mode === "resend") {
|
|
118
|
+
const from = typeof resend?.from === "string" ? resend.from.trim() : "";
|
|
119
|
+
const apiKey = typeof resend?.apiKey === "string" ? resend.apiKey.trim() : "";
|
|
120
|
+
if (!from || !apiKey) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error: "RESEND_NOT_CONFIGURED",
|
|
124
|
+
message: "resend.from and resend.apiKey are required"
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
await sendResendMail({
|
|
129
|
+
apiKey,
|
|
130
|
+
from,
|
|
131
|
+
to: emailNorm,
|
|
132
|
+
subject: "Your Settld decision code",
|
|
133
|
+
text: `Your decision code is: ${code}\n\nThis code expires at: ${expiresAt}\n\nIf you did not request this code, you can ignore this email.\n`,
|
|
134
|
+
baseUrl: resend?.baseUrl
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
return { ok: false, error: "RESEND_SEND_FAILED", message: errorMessageOrFallback(err, "resend send failed") };
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
throw new Error("invalid deliveryMode");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { ok: true, email: emailNorm, expiresAt };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function verifyAndConsumeDecisionOtp({ dataDir, token, email, code, maxAttempts }) {
|
|
147
|
+
const emailNorm = normalizeEmail(email);
|
|
148
|
+
const codeNorm = clampText(code, { max: 32 });
|
|
149
|
+
if (!emailNorm || !codeNorm) return { ok: false, error: "OTP_INVALID", message: "email and code are required" };
|
|
150
|
+
|
|
151
|
+
const max = Number.parseInt(String(maxAttempts ?? ""), 10);
|
|
152
|
+
if (!Number.isInteger(max) || max < 1) throw new TypeError("maxAttempts must be an integer >= 1");
|
|
153
|
+
|
|
154
|
+
const fp = otpRecordPath({ dataDir, token, email: emailNorm });
|
|
155
|
+
let rec = null;
|
|
156
|
+
try {
|
|
157
|
+
rec = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
158
|
+
} catch {
|
|
159
|
+
rec = null;
|
|
160
|
+
}
|
|
161
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec) || rec.schemaVersion !== "DecisionOtpRecord.v1") {
|
|
162
|
+
return { ok: false, error: "OTP_MISSING", message: "no active otp" };
|
|
163
|
+
}
|
|
164
|
+
if (String(rec.token ?? "") !== token || String(rec.email ?? "") !== emailNorm) {
|
|
165
|
+
return { ok: false, error: "OTP_MISSING", message: "no active otp" };
|
|
166
|
+
}
|
|
167
|
+
if (typeof rec.consumedAt === "string" && rec.consumedAt) return { ok: false, error: "OTP_CONSUMED", message: "otp already used" };
|
|
168
|
+
|
|
169
|
+
const expiresMs = Date.parse(String(rec.expiresAt ?? ""));
|
|
170
|
+
if (!Number.isFinite(expiresMs) || Date.now() > expiresMs) return { ok: false, error: "OTP_EXPIRED", message: "otp expired" };
|
|
171
|
+
|
|
172
|
+
const attempts = Number.parseInt(String(rec.attempts ?? "0"), 10);
|
|
173
|
+
if (Number.isInteger(attempts) && attempts >= max) return { ok: false, error: "OTP_LOCKED", message: "too many attempts" };
|
|
174
|
+
|
|
175
|
+
const expected = String(rec.codeSha256 ?? "");
|
|
176
|
+
const actual = sha256Hex(`${token}\n${emailNorm}\n${codeNorm}`);
|
|
177
|
+
if (expected !== actual) {
|
|
178
|
+
rec.attempts = (Number.isInteger(attempts) ? attempts : 0) + 1;
|
|
179
|
+
await fs.writeFile(fp, JSON.stringify(rec, null, 2) + "\n", "utf8");
|
|
180
|
+
return { ok: false, error: "OTP_INVALID", message: "invalid code" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
rec.consumedAt = nowIso();
|
|
184
|
+
rec.attempts = Number.isInteger(attempts) ? attempts : 0;
|
|
185
|
+
await fs.writeFile(fp, JSON.stringify(rec, null, 2) + "\n", "utf8");
|
|
186
|
+
return { ok: true };
|
|
187
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
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 decisionPath({ dataDir, token }) {
|
|
9
|
+
return path.join(dataDir, "decisions", `${token}.json`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function loadDecisionRecord({ dataDir, token }) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = await fs.readFile(decisionPath({ dataDir, token }), "utf8");
|
|
15
|
+
const j = JSON.parse(raw);
|
|
16
|
+
if (!isPlainObject(j) || j.schemaVersion !== "DecisionRecord.v0") return null;
|
|
17
|
+
return j;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function clampText(v, { max }) {
|
|
24
|
+
const s = String(v ?? "").trim();
|
|
25
|
+
if (!s) return null;
|
|
26
|
+
return s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeDecision(v) {
|
|
30
|
+
const s = String(v ?? "").trim().toLowerCase();
|
|
31
|
+
if (s === "approve" || s === "hold") return s;
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function appendDecision({
|
|
36
|
+
dataDir,
|
|
37
|
+
token,
|
|
38
|
+
tenantId,
|
|
39
|
+
zipSha256,
|
|
40
|
+
verifyJsonSha256,
|
|
41
|
+
decision,
|
|
42
|
+
actorName,
|
|
43
|
+
actorEmail,
|
|
44
|
+
authMethod,
|
|
45
|
+
actorIp,
|
|
46
|
+
actorUserAgent,
|
|
47
|
+
note
|
|
48
|
+
}) {
|
|
49
|
+
const d = normalizeDecision(decision);
|
|
50
|
+
if (!d) return { ok: false, error: "INVALID_DECISION", message: "decision must be approve|hold" };
|
|
51
|
+
const name = clampText(actorName, { max: 200 });
|
|
52
|
+
const email = clampText(actorEmail, { max: 320 });
|
|
53
|
+
if (!email) return { ok: false, error: "INVALID_ACTOR", message: "email is required" };
|
|
54
|
+
const auth = clampText(authMethod, { max: 32 }) ?? "none";
|
|
55
|
+
const ip = clampText(actorIp, { max: 128 });
|
|
56
|
+
const userAgent = clampText(actorUserAgent, { max: 400 });
|
|
57
|
+
const noteValue = clampText(note, { max: 2000 });
|
|
58
|
+
|
|
59
|
+
const fp = decisionPath({ dataDir, token });
|
|
60
|
+
await fs.mkdir(path.dirname(fp), { recursive: true });
|
|
61
|
+
|
|
62
|
+
let record = null;
|
|
63
|
+
try {
|
|
64
|
+
record = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
65
|
+
} catch {
|
|
66
|
+
record = null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const decidedAt = new Date().toISOString();
|
|
70
|
+
const entry = {
|
|
71
|
+
decision: d,
|
|
72
|
+
decidedAt,
|
|
73
|
+
actor: { name, email },
|
|
74
|
+
auth: { method: auth },
|
|
75
|
+
client: { ip, userAgent },
|
|
76
|
+
note: noteValue
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const next = isPlainObject(record) && record.schemaVersion === "DecisionRecord.v0"
|
|
80
|
+
? { ...record }
|
|
81
|
+
: { schemaVersion: "DecisionRecord.v0", token, tenantId, zipSha256, verifyJsonSha256, decisions: [] };
|
|
82
|
+
|
|
83
|
+
next.tenantId = tenantId;
|
|
84
|
+
next.zipSha256 = zipSha256;
|
|
85
|
+
next.verifyJsonSha256 = verifyJsonSha256;
|
|
86
|
+
next.decisions = Array.isArray(next.decisions) ? next.decisions : [];
|
|
87
|
+
next.decisions.push(entry);
|
|
88
|
+
next.updatedAt = decidedAt;
|
|
89
|
+
|
|
90
|
+
await fs.writeFile(fp, JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
91
|
+
return { ok: true, record: next };
|
|
92
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
function normalizeBaseUrl(raw) {
|
|
2
|
+
const text = String(raw ?? "").trim();
|
|
3
|
+
if (!text) return null;
|
|
4
|
+
try {
|
|
5
|
+
const parsed = new URL(text);
|
|
6
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
7
|
+
return parsed.toString().replace(/\/+$/, "");
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function errorMessageOrFallback(err, fallback = "resend request failed") {
|
|
14
|
+
const direct = typeof err?.message === "string" ? err.message.trim() : "";
|
|
15
|
+
if (direct) return direct;
|
|
16
|
+
const asText = String(err ?? "").trim();
|
|
17
|
+
if (asText && asText !== "[object Object]") return asText;
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function sendResendMail({
|
|
22
|
+
apiKey,
|
|
23
|
+
from,
|
|
24
|
+
to,
|
|
25
|
+
subject,
|
|
26
|
+
text,
|
|
27
|
+
baseUrl = "https://api.resend.com",
|
|
28
|
+
timeoutMs = 10_000,
|
|
29
|
+
fetchImpl = fetch
|
|
30
|
+
} = {}) {
|
|
31
|
+
const key = String(apiKey ?? "").trim();
|
|
32
|
+
if (!key) throw new Error("resend api key required");
|
|
33
|
+
const sender = String(from ?? "").trim();
|
|
34
|
+
if (!sender) throw new Error("resend from is required");
|
|
35
|
+
const recipient = String(to ?? "").trim();
|
|
36
|
+
if (!recipient) throw new Error("resend to is required");
|
|
37
|
+
const sub = String(subject ?? "").trim();
|
|
38
|
+
if (!sub) throw new Error("resend subject is required");
|
|
39
|
+
const bodyText = String(text ?? "");
|
|
40
|
+
const urlBase = normalizeBaseUrl(baseUrl);
|
|
41
|
+
if (!urlBase) throw new Error("resend base URL must be a valid http(s) URL");
|
|
42
|
+
if (typeof fetchImpl !== "function") throw new Error("resend fetch implementation unavailable");
|
|
43
|
+
|
|
44
|
+
const controller = new AbortController();
|
|
45
|
+
const t = setTimeout(() => controller.abort(), Math.max(100, Number(timeoutMs) || 10_000));
|
|
46
|
+
t.unref?.();
|
|
47
|
+
|
|
48
|
+
let res;
|
|
49
|
+
try {
|
|
50
|
+
res = await fetchImpl(`${urlBase}/emails`, {
|
|
51
|
+
method: "POST",
|
|
52
|
+
headers: {
|
|
53
|
+
authorization: `Bearer ${key}`,
|
|
54
|
+
"content-type": "application/json"
|
|
55
|
+
},
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
from: sender,
|
|
58
|
+
to: [recipient],
|
|
59
|
+
subject: sub,
|
|
60
|
+
text: bodyText
|
|
61
|
+
}),
|
|
62
|
+
signal: controller.signal
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(errorMessageOrFallback(err, "resend transport failed"));
|
|
66
|
+
} finally {
|
|
67
|
+
clearTimeout(t);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const raw = await res.text();
|
|
71
|
+
let json = null;
|
|
72
|
+
try {
|
|
73
|
+
json = raw ? JSON.parse(raw) : null;
|
|
74
|
+
} catch {
|
|
75
|
+
json = null;
|
|
76
|
+
}
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
const message =
|
|
79
|
+
(json && typeof json === "object" && (json?.message || json?.error?.message || json?.error)) ||
|
|
80
|
+
raw ||
|
|
81
|
+
`HTTP ${res.status}`;
|
|
82
|
+
throw new Error(`resend send failed (${res.status}): ${String(message)}`);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
id: json && typeof json === "object" && typeof json.id === "string" ? json.id : null
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
function isPlainObject(v) {
|
|
6
|
+
return Boolean(v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function assertId(value, { name }) {
|
|
10
|
+
const v = String(value ?? "").trim();
|
|
11
|
+
if (!v) throw new TypeError(`${name} is required`);
|
|
12
|
+
if (!/^[a-zA-Z0-9_-]{1,64}$/.test(v)) throw new TypeError(`${name} invalid (allowed: [A-Za-z0-9_-]{1,64})`);
|
|
13
|
+
return v;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function nowIso() {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sha256Hex(s) {
|
|
21
|
+
return crypto.createHash("sha256").update(String(s ?? ""), "utf8").digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ingestKeyPath({ dataDir, tenantId, keyHash }) {
|
|
25
|
+
return path.join(dataDir, "ingest-keys", tenantId, `${keyHash}.json`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function generateIngestKey() {
|
|
29
|
+
return "igk_" + crypto.randomBytes(32).toString("hex");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function createIngestKey({ dataDir, tenantId, vendorId, vendorName = null, expiresAt = null } = {}) {
|
|
33
|
+
const t = assertId(tenantId, { name: "tenantId" });
|
|
34
|
+
const v = assertId(vendorId, { name: "vendorId" });
|
|
35
|
+
const name = vendorName === null || vendorName === undefined ? null : String(vendorName).trim() || null;
|
|
36
|
+
|
|
37
|
+
let exp = null;
|
|
38
|
+
if (expiresAt !== null && expiresAt !== undefined) {
|
|
39
|
+
const raw = String(expiresAt ?? "").trim();
|
|
40
|
+
if (!raw) exp = null;
|
|
41
|
+
else {
|
|
42
|
+
const ms = Date.parse(raw);
|
|
43
|
+
if (!Number.isFinite(ms)) throw new TypeError("expiresAt must be an ISO date string or null");
|
|
44
|
+
exp = new Date(ms).toISOString();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await fs.mkdir(path.join(dataDir, "ingest-keys", t), { recursive: true });
|
|
49
|
+
|
|
50
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
51
|
+
const ingestKey = generateIngestKey();
|
|
52
|
+
const keyHash = sha256Hex(ingestKey);
|
|
53
|
+
const fp = ingestKeyPath({ dataDir, tenantId: t, keyHash });
|
|
54
|
+
try {
|
|
55
|
+
await fs.writeFile(
|
|
56
|
+
fp,
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
schemaVersion: "MagicLinkIngestKey.v1",
|
|
60
|
+
tenantId: t,
|
|
61
|
+
vendorId: v,
|
|
62
|
+
vendorName: name,
|
|
63
|
+
keyHash,
|
|
64
|
+
createdAt: nowIso(),
|
|
65
|
+
expiresAt: exp,
|
|
66
|
+
revokedAt: null,
|
|
67
|
+
revokedReason: null,
|
|
68
|
+
permissions: ["upload_only"]
|
|
69
|
+
},
|
|
70
|
+
null,
|
|
71
|
+
2
|
|
72
|
+
) + "\n",
|
|
73
|
+
{ encoding: "utf8", flag: "wx" }
|
|
74
|
+
);
|
|
75
|
+
return { ok: true, ingestKey, keyHash };
|
|
76
|
+
} catch (err) {
|
|
77
|
+
if (err?.code === "EEXIST") continue;
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { ok: false, error: "KEYGEN_FAILED", message: "failed to generate unique ingest key" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function loadIngestKeyRecordByHash({ dataDir, tenantId, keyHash } = {}) {
|
|
85
|
+
const t = assertId(tenantId, { name: "tenantId" });
|
|
86
|
+
const h = String(keyHash ?? "").trim().toLowerCase();
|
|
87
|
+
if (!/^[0-9a-f]{64}$/.test(h)) return null;
|
|
88
|
+
const fp = ingestKeyPath({ dataDir, tenantId: t, keyHash: h });
|
|
89
|
+
try {
|
|
90
|
+
const raw = await fs.readFile(fp, "utf8");
|
|
91
|
+
const j = JSON.parse(raw);
|
|
92
|
+
if (!isPlainObject(j) || j.schemaVersion !== "MagicLinkIngestKey.v1") return null;
|
|
93
|
+
if (j.tenantId !== t) return null;
|
|
94
|
+
if (typeof j.vendorId !== "string" || !j.vendorId.trim()) return null;
|
|
95
|
+
if (j.keyHash !== h) return null;
|
|
96
|
+
return j;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function authenticateIngestKey({ dataDir, tenantId, ingestKey } = {}) {
|
|
103
|
+
const key = typeof ingestKey === "string" ? ingestKey.trim() : "";
|
|
104
|
+
if (!key) return { ok: false, error: "MISSING" };
|
|
105
|
+
if (!key.startsWith("igk_")) return { ok: false, error: "INVALID" };
|
|
106
|
+
const keyHash = sha256Hex(key);
|
|
107
|
+
const rec = await loadIngestKeyRecordByHash({ dataDir, tenantId, keyHash });
|
|
108
|
+
if (!rec) return { ok: false, error: "NOT_FOUND" };
|
|
109
|
+
if (rec.revokedAt) return { ok: false, error: "REVOKED" };
|
|
110
|
+
if (rec.expiresAt) {
|
|
111
|
+
const ms = Date.parse(String(rec.expiresAt));
|
|
112
|
+
if (Number.isFinite(ms) && Date.now() > ms) return { ok: false, error: "EXPIRED" };
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, record: rec };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function revokeIngestKey({ dataDir, tenantId, keyHash, reason = null } = {}) {
|
|
118
|
+
const t = assertId(tenantId, { name: "tenantId" });
|
|
119
|
+
const h = String(keyHash ?? "").trim().toLowerCase();
|
|
120
|
+
if (!/^[0-9a-f]{64}$/.test(h)) return { ok: false, error: "INVALID_KEY_HASH" };
|
|
121
|
+
const fp = ingestKeyPath({ dataDir, tenantId: t, keyHash: h });
|
|
122
|
+
|
|
123
|
+
let rec = null;
|
|
124
|
+
try {
|
|
125
|
+
rec = JSON.parse(await fs.readFile(fp, "utf8"));
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false, error: "NOT_FOUND" };
|
|
128
|
+
}
|
|
129
|
+
if (!isPlainObject(rec) || rec.schemaVersion !== "MagicLinkIngestKey.v1") return { ok: false, error: "NOT_FOUND" };
|
|
130
|
+
if (rec.revokedAt) return { ok: true, alreadyRevoked: true, revokedAt: rec.revokedAt };
|
|
131
|
+
|
|
132
|
+
rec.revokedAt = nowIso();
|
|
133
|
+
rec.revokedReason = typeof reason === "string" && reason.trim() ? reason.trim() : null;
|
|
134
|
+
await fs.writeFile(fp, JSON.stringify(rec, null, 2) + "\n", "utf8");
|
|
135
|
+
return { ok: true, revokedAt: rec.revokedAt };
|
|
136
|
+
}
|
|
137
|
+
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
|
|
6
|
+
import { checkAndMigrateDataDir } from "./storage-format.js";
|
|
7
|
+
import { loadTenantSettings } from "./tenant-settings.js";
|
|
8
|
+
import { garbageCollectTenantByRetention, listTenantIdsWithIndex } from "./retention-gc.js";
|
|
9
|
+
|
|
10
|
+
function nowIso() {
|
|
11
|
+
return new Date().toISOString();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const dataDirRaw = process.env.MAGIC_LINK_DATA_DIR ? String(process.env.MAGIC_LINK_DATA_DIR).trim() : "";
|
|
15
|
+
const dataDir = dataDirRaw ? path.resolve(dataDirRaw) : path.join(os.tmpdir(), "settld-magic-link");
|
|
16
|
+
const dataDirLikelyEphemeral =
|
|
17
|
+
dataDir === "/tmp" ||
|
|
18
|
+
dataDir.startsWith("/tmp/") ||
|
|
19
|
+
dataDir === os.tmpdir() ||
|
|
20
|
+
dataDir.startsWith(`${os.tmpdir()}${path.sep}`);
|
|
21
|
+
const requireDurableDataDir = String(process.env.MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR ?? "0").trim() === "1";
|
|
22
|
+
const migrateOnStartup = String(process.env.MAGIC_LINK_MIGRATE_ON_STARTUP ?? "1").trim() !== "0";
|
|
23
|
+
const intervalSeconds = Number.parseInt(String(process.env.MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS ?? "86400"), 10);
|
|
24
|
+
|
|
25
|
+
if (!Number.isInteger(intervalSeconds) || intervalSeconds < 5) throw new Error("MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS must be an integer >= 5");
|
|
26
|
+
if (requireDurableDataDir && dataDirLikelyEphemeral) {
|
|
27
|
+
throw new Error("MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 but MAGIC_LINK_DATA_DIR resolves to an ephemeral path (/tmp)");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await fs.mkdir(dataDir, { recursive: true });
|
|
31
|
+
const fmt = await checkAndMigrateDataDir({ dataDir, migrateOnStartup });
|
|
32
|
+
if (!fmt.ok) throw new Error(`magic-link data dir check failed: ${fmt.code ?? "UNKNOWN"}`);
|
|
33
|
+
|
|
34
|
+
let stopped = false;
|
|
35
|
+
async function shutdown(signal) {
|
|
36
|
+
if (stopped) return;
|
|
37
|
+
stopped = true;
|
|
38
|
+
// eslint-disable-next-line no-console
|
|
39
|
+
console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.shutdown", signal }, null, 2));
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
44
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
45
|
+
|
|
46
|
+
// eslint-disable-next-line no-console
|
|
47
|
+
console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.start", dataDir, intervalSeconds }, null, 2));
|
|
48
|
+
if (dataDirLikelyEphemeral) {
|
|
49
|
+
// eslint-disable-next-line no-console
|
|
50
|
+
console.warn(
|
|
51
|
+
JSON.stringify(
|
|
52
|
+
{
|
|
53
|
+
at: nowIso(),
|
|
54
|
+
event: "magic_link_maintenance.ephemeral_data_dir_warning",
|
|
55
|
+
dataDir,
|
|
56
|
+
message: "data dir looks ephemeral; use persistent volume + MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 in production"
|
|
57
|
+
},
|
|
58
|
+
null,
|
|
59
|
+
2
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
while (!stopped) {
|
|
65
|
+
const loopStartMs = Date.now();
|
|
66
|
+
let tenants = [];
|
|
67
|
+
try {
|
|
68
|
+
tenants = await listTenantIdsWithIndex({ dataDir });
|
|
69
|
+
} catch {
|
|
70
|
+
tenants = [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let deleted = 0;
|
|
74
|
+
let kept = 0;
|
|
75
|
+
for (const tenantId of tenants) {
|
|
76
|
+
try {
|
|
77
|
+
// eslint-disable-next-line no-await-in-loop
|
|
78
|
+
const tenantSettings = await loadTenantSettings({ dataDir, tenantId });
|
|
79
|
+
// eslint-disable-next-line no-await-in-loop
|
|
80
|
+
const res = await garbageCollectTenantByRetention({ dataDir, tenantId, tenantSettings });
|
|
81
|
+
deleted += Number(res?.deleted ?? 0);
|
|
82
|
+
kept += Number(res?.kept ?? 0);
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore per-tenant failures
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.log(JSON.stringify({ at: nowIso(), event: "magic_link_maintenance.retention_sweep", tenants: tenants.length, deleted, kept }, null, 2));
|
|
90
|
+
|
|
91
|
+
const elapsedMs = Date.now() - loopStartMs;
|
|
92
|
+
const sleepMs = Math.max(0, intervalSeconds * 1000 - elapsedMs);
|
|
93
|
+
// eslint-disable-next-line no-await-in-loop
|
|
94
|
+
await new Promise((r) => setTimeout(r, sleepMs));
|
|
95
|
+
}
|