settld 0.2.5 → 0.2.7
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/package.json +2 -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/setup/login.mjs +6 -1
- package/scripts/setup/onboard.mjs +27 -1
- package/scripts/setup/onboarding-failure-taxonomy.mjs +11 -0
- package/services/magic-link/README.md +13 -4
- package/services/magic-link/src/buyer-auth.js +33 -2
- package/services/magic-link/src/decision-otp.js +33 -2
- package/services/magic-link/src/email-resend.js +89 -0
- package/services/magic-link/src/maintenance.js +26 -1
- package/services/magic-link/src/server.js +72 -11
- package/services/magic-link/src/smtp.js +19 -4
- package/src/api/app.js +6 -1
|
@@ -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
|
+
|
|
@@ -11,11 +11,21 @@ function nowIso() {
|
|
|
11
11
|
return new Date().toISOString();
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const
|
|
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";
|
|
15
22
|
const migrateOnStartup = String(process.env.MAGIC_LINK_MIGRATE_ON_STARTUP ?? "1").trim() !== "0";
|
|
16
23
|
const intervalSeconds = Number.parseInt(String(process.env.MAGIC_LINK_MAINTENANCE_INTERVAL_SECONDS ?? "86400"), 10);
|
|
17
24
|
|
|
18
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
|
+
}
|
|
19
29
|
|
|
20
30
|
await fs.mkdir(dataDir, { recursive: true });
|
|
21
31
|
const fmt = await checkAndMigrateDataDir({ dataDir, migrateOnStartup });
|
|
@@ -35,6 +45,21 @@ process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
|
35
45
|
|
|
36
46
|
// eslint-disable-next-line no-console
|
|
37
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
|
+
}
|
|
38
63
|
|
|
39
64
|
while (!stopped) {
|
|
40
65
|
const loopStartMs = Date.now();
|
|
@@ -713,11 +713,24 @@ async function readJsonIfExists(fp) {
|
|
|
713
713
|
}
|
|
714
714
|
}
|
|
715
715
|
|
|
716
|
+
function isLikelyEphemeralDataDir(dirPath) {
|
|
717
|
+
const resolved = path.resolve(String(dirPath ?? ""));
|
|
718
|
+
return (
|
|
719
|
+
resolved === "/tmp" ||
|
|
720
|
+
resolved.startsWith("/tmp/") ||
|
|
721
|
+
resolved === os.tmpdir() ||
|
|
722
|
+
resolved.startsWith(`${os.tmpdir()}${path.sep}`)
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
716
726
|
const port = Number(process.env.MAGIC_LINK_PORT ?? "8787");
|
|
717
727
|
const host = process.env.MAGIC_LINK_HOST ? String(process.env.MAGIC_LINK_HOST) : "127.0.0.1";
|
|
718
728
|
const socketPath = process.env.MAGIC_LINK_SOCKET_PATH ? path.resolve(String(process.env.MAGIC_LINK_SOCKET_PATH)) : null;
|
|
719
729
|
const apiKey = process.env.MAGIC_LINK_API_KEY ?? null;
|
|
720
|
-
const
|
|
730
|
+
const dataDirRaw = process.env.MAGIC_LINK_DATA_DIR ? String(process.env.MAGIC_LINK_DATA_DIR).trim() : "";
|
|
731
|
+
const dataDir = dataDirRaw ? path.resolve(dataDirRaw) : path.join(os.tmpdir(), "settld-magic-link");
|
|
732
|
+
const dataDirLikelyEphemeral = isLikelyEphemeralDataDir(dataDir);
|
|
733
|
+
const requireDurableDataDir = String(process.env.MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR ?? "0").trim() === "1";
|
|
721
734
|
const maxUploadBytes = Number(process.env.MAGIC_LINK_MAX_UPLOAD_BYTES ?? String(50 * 1024 * 1024));
|
|
722
735
|
const tokenTtlSeconds = Number(process.env.MAGIC_LINK_TOKEN_TTL_SECONDS ?? String(7 * 24 * 3600));
|
|
723
736
|
const verifyTimeoutMs = Number(process.env.MAGIC_LINK_VERIFY_TIMEOUT_MS ?? String(60_000));
|
|
@@ -897,6 +910,10 @@ const smtpUser = process.env.MAGIC_LINK_SMTP_USER ? String(process.env.MAGIC_LIN
|
|
|
897
910
|
const smtpPass = process.env.MAGIC_LINK_SMTP_PASS ? String(process.env.MAGIC_LINK_SMTP_PASS) : "";
|
|
898
911
|
const smtpFrom = process.env.MAGIC_LINK_SMTP_FROM ? String(process.env.MAGIC_LINK_SMTP_FROM).trim() : "";
|
|
899
912
|
const smtpConfig = smtpHost && smtpFrom ? { host: smtpHost, port: smtpPort, secure: smtpSecure, starttls: smtpStarttls, user: smtpUser, pass: smtpPass, from: smtpFrom } : null;
|
|
913
|
+
const resendApiKey = process.env.MAGIC_LINK_RESEND_API_KEY ? String(process.env.MAGIC_LINK_RESEND_API_KEY).trim() : "";
|
|
914
|
+
const resendFrom = process.env.MAGIC_LINK_RESEND_FROM ? String(process.env.MAGIC_LINK_RESEND_FROM).trim() : "";
|
|
915
|
+
const resendBaseUrl = process.env.MAGIC_LINK_RESEND_BASE_URL ? String(process.env.MAGIC_LINK_RESEND_BASE_URL).trim() : "https://api.resend.com";
|
|
916
|
+
const resendConfig = resendApiKey && resendFrom ? { apiKey: resendApiKey, from: resendFrom, baseUrl: resendBaseUrl } : null;
|
|
900
917
|
const onboardingEmailSequenceDeliveryMode =
|
|
901
918
|
onboardingEmailSequenceDeliveryModeRaw || (smtpConfig ? "smtp" : "record");
|
|
902
919
|
|
|
@@ -985,10 +1002,14 @@ if (billingProvider === "stripe" && stripeWebhookSecret && !stripeWebhookSecret.
|
|
|
985
1002
|
}
|
|
986
1003
|
if (!Number.isInteger(decisionOtpTtlSeconds) || decisionOtpTtlSeconds <= 0) throw new Error("MAGIC_LINK_DECISION_OTP_TTL_SECONDS must be a positive integer");
|
|
987
1004
|
if (!Number.isInteger(decisionOtpMaxAttempts) || decisionOtpMaxAttempts < 1) throw new Error("MAGIC_LINK_DECISION_OTP_MAX_ATTEMPTS must be an integer >= 1");
|
|
988
|
-
if (decisionOtpDeliveryMode !== "record" && decisionOtpDeliveryMode !== "log" && decisionOtpDeliveryMode !== "smtp"
|
|
1005
|
+
if (decisionOtpDeliveryMode !== "record" && decisionOtpDeliveryMode !== "log" && decisionOtpDeliveryMode !== "smtp" && decisionOtpDeliveryMode !== "resend") {
|
|
1006
|
+
throw new Error("MAGIC_LINK_DECISION_OTP_DELIVERY_MODE must be record|log|smtp|resend");
|
|
1007
|
+
}
|
|
989
1008
|
if (!Number.isInteger(buyerOtpTtlSeconds) || buyerOtpTtlSeconds <= 0) throw new Error("MAGIC_LINK_BUYER_OTP_TTL_SECONDS must be a positive integer");
|
|
990
1009
|
if (!Number.isInteger(buyerOtpMaxAttempts) || buyerOtpMaxAttempts < 1) throw new Error("MAGIC_LINK_BUYER_OTP_MAX_ATTEMPTS must be an integer >= 1");
|
|
991
|
-
if (buyerOtpDeliveryMode !== "record" && buyerOtpDeliveryMode !== "log" && buyerOtpDeliveryMode !== "smtp"
|
|
1010
|
+
if (buyerOtpDeliveryMode !== "record" && buyerOtpDeliveryMode !== "log" && buyerOtpDeliveryMode !== "smtp" && buyerOtpDeliveryMode !== "resend") {
|
|
1011
|
+
throw new Error("MAGIC_LINK_BUYER_OTP_DELIVERY_MODE must be record|log|smtp|resend");
|
|
1012
|
+
}
|
|
992
1013
|
if (!Number.isInteger(buyerSessionTtlSeconds) || buyerSessionTtlSeconds <= 0) throw new Error("MAGIC_LINK_BUYER_SESSION_TTL_SECONDS must be a positive integer");
|
|
993
1014
|
if (
|
|
994
1015
|
onboardingEmailSequenceDeliveryMode !== "record" &&
|
|
@@ -1001,10 +1022,18 @@ if (!Number.isInteger(paymentTriggerRetryIntervalMs) || paymentTriggerRetryInter
|
|
|
1001
1022
|
if (!Number.isInteger(paymentTriggerMaxAttempts) || paymentTriggerMaxAttempts < 1) throw new Error("MAGIC_LINK_PAYMENT_TRIGGER_MAX_ATTEMPTS must be an integer >= 1");
|
|
1002
1023
|
if (!Number.isInteger(paymentTriggerRetryBackoffMs) || paymentTriggerRetryBackoffMs < 0) throw new Error("MAGIC_LINK_PAYMENT_TRIGGER_RETRY_BACKOFF_MS must be an integer >= 0");
|
|
1003
1024
|
if (typeof migrateOnStartup !== "boolean") throw new Error("MAGIC_LINK_MIGRATE_ON_STARTUP must be 1|0");
|
|
1025
|
+
if (requireDurableDataDir && dataDirLikelyEphemeral) {
|
|
1026
|
+
throw new Error("MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 but MAGIC_LINK_DATA_DIR resolves to an ephemeral path (/tmp)");
|
|
1027
|
+
}
|
|
1004
1028
|
if (smtpHost) {
|
|
1005
1029
|
if (!Number.isInteger(smtpPort) || smtpPort < 1 || smtpPort > 65535) throw new Error("MAGIC_LINK_SMTP_PORT must be 1..65535");
|
|
1006
1030
|
if (!smtpFrom) throw new Error("MAGIC_LINK_SMTP_FROM is required when MAGIC_LINK_SMTP_HOST is set");
|
|
1007
1031
|
}
|
|
1032
|
+
if (buyerOtpDeliveryMode === "resend" || decisionOtpDeliveryMode === "resend") {
|
|
1033
|
+
if (!resendApiKey || !resendFrom) {
|
|
1034
|
+
throw new Error("MAGIC_LINK_RESEND_API_KEY and MAGIC_LINK_RESEND_FROM are required when OTP delivery mode is resend");
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1008
1037
|
if (publicBaseUrl !== null && publicBaseUrl !== "") {
|
|
1009
1038
|
// Minimal validation; URL constructor will throw on invalid.
|
|
1010
1039
|
// eslint-disable-next-line no-new
|
|
@@ -1062,6 +1091,12 @@ const verifyDurationsWindow = 500;
|
|
|
1062
1091
|
await fs.mkdir(dataDir, { recursive: true });
|
|
1063
1092
|
const dataDirFormat = await checkAndMigrateDataDir({ dataDir, migrateOnStartup });
|
|
1064
1093
|
if (!dataDirFormat.ok) throw new Error(`magic-link data dir check failed: ${dataDirFormat.code ?? "UNKNOWN"}`);
|
|
1094
|
+
if (dataDirLikelyEphemeral) {
|
|
1095
|
+
// eslint-disable-next-line no-console
|
|
1096
|
+
console.warn(
|
|
1097
|
+
`magic-link warning: MAGIC_LINK_DATA_DIR (${dataDir}) is likely ephemeral; use a persistent volume and set MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1 in production`
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1065
1100
|
|
|
1066
1101
|
function recordVerifyDurationMs(ms) {
|
|
1067
1102
|
const n = Math.max(0, Number(ms));
|
|
@@ -1093,6 +1128,8 @@ async function readinessSignals() {
|
|
|
1093
1128
|
metrics.setGauge("magic_link_data_dir_writable_gauge", null, dataDirWritable ? 1 : 0);
|
|
1094
1129
|
return {
|
|
1095
1130
|
dataDir,
|
|
1131
|
+
dataDirLikelyEphemeral,
|
|
1132
|
+
requireDurableDataDir,
|
|
1096
1133
|
dataDirWritable,
|
|
1097
1134
|
dataFormatVersion: MAGIC_LINK_DATA_FORMAT_VERSION_CURRENT,
|
|
1098
1135
|
migrateOnStartup,
|
|
@@ -10123,10 +10160,18 @@ async function handleBuyerLoginOtpRequest(req, res, tenantId) {
|
|
|
10123
10160
|
return sendJson(res, 400, { ok: false, code: "BUYER_EMAIL_DOMAIN_FORBIDDEN", message: "email domain is not allowed" });
|
|
10124
10161
|
}
|
|
10125
10162
|
|
|
10126
|
-
const issued = await issueBuyerOtp({
|
|
10163
|
+
const issued = await issueBuyerOtp({
|
|
10164
|
+
dataDir,
|
|
10165
|
+
tenantId,
|
|
10166
|
+
email,
|
|
10167
|
+
ttlSeconds: buyerOtpTtlSeconds,
|
|
10168
|
+
deliveryMode: buyerOtpDeliveryMode,
|
|
10169
|
+
smtp: smtpConfig,
|
|
10170
|
+
resend: resendConfig
|
|
10171
|
+
});
|
|
10127
10172
|
if (!issued.ok) {
|
|
10128
10173
|
metrics.incCounter("login_otp_requests_total", { tenantId, ok: "false", code: String(issued.error ?? "OTP_FAILED") }, 1);
|
|
10129
|
-
return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message
|
|
10174
|
+
return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message || "otp failed" });
|
|
10130
10175
|
}
|
|
10131
10176
|
metrics.incCounter("login_otp_requests_total", { tenantId, ok: "true" }, 1);
|
|
10132
10177
|
try {
|
|
@@ -10160,7 +10205,7 @@ async function handleBuyerLogin(req, res, tenantId) {
|
|
|
10160
10205
|
if (!isEmailAllowedByDomains({ email, allowedDomains })) return sendJson(res, 400, { ok: false, code: "BUYER_EMAIL_DOMAIN_FORBIDDEN", message: "email domain is not allowed" });
|
|
10161
10206
|
|
|
10162
10207
|
const verified = await verifyAndConsumeBuyerOtp({ dataDir, tenantId, email, code, maxAttempts: buyerOtpMaxAttempts });
|
|
10163
|
-
if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message
|
|
10208
|
+
if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message || "otp failed" });
|
|
10164
10209
|
|
|
10165
10210
|
const session = createBuyerSessionToken({ sessionKey, tenantId, email, ttlSeconds: buyerSessionTtlSeconds });
|
|
10166
10211
|
if (!session.ok) return sendJson(res, 500, { ok: false, code: session.error ?? "SESSION_FAILED", message: "failed to create buyer session" });
|
|
@@ -10336,7 +10381,15 @@ async function handlePublicSignup(req, res) {
|
|
|
10336
10381
|
status: "active"
|
|
10337
10382
|
});
|
|
10338
10383
|
|
|
10339
|
-
const issued = await issueBuyerOtp({
|
|
10384
|
+
const issued = await issueBuyerOtp({
|
|
10385
|
+
dataDir,
|
|
10386
|
+
tenantId,
|
|
10387
|
+
email,
|
|
10388
|
+
ttlSeconds: buyerOtpTtlSeconds,
|
|
10389
|
+
deliveryMode: buyerOtpDeliveryMode,
|
|
10390
|
+
smtp: smtpConfig,
|
|
10391
|
+
resend: resendConfig
|
|
10392
|
+
});
|
|
10340
10393
|
if (!issued.ok) {
|
|
10341
10394
|
return sendJson(res, 202, {
|
|
10342
10395
|
ok: true,
|
|
@@ -10344,7 +10397,7 @@ async function handlePublicSignup(req, res) {
|
|
|
10344
10397
|
email,
|
|
10345
10398
|
otpIssued: false,
|
|
10346
10399
|
warning: issued.error ?? "OTP_FAILED",
|
|
10347
|
-
message: issued.message
|
|
10400
|
+
message: issued.message || "tenant created, otp issue failed"
|
|
10348
10401
|
});
|
|
10349
10402
|
}
|
|
10350
10403
|
|
|
@@ -14515,10 +14568,18 @@ async function handleDecisionOtpRequest(req, res, token) {
|
|
|
14515
14568
|
return sendJson(res, 400, { ok: false, code: "OTP_EMAIL_DOMAIN_FORBIDDEN", message: "email domain is not allowed" });
|
|
14516
14569
|
}
|
|
14517
14570
|
|
|
14518
|
-
const issued = await issueDecisionOtp({
|
|
14571
|
+
const issued = await issueDecisionOtp({
|
|
14572
|
+
dataDir,
|
|
14573
|
+
token,
|
|
14574
|
+
email,
|
|
14575
|
+
ttlSeconds: decisionOtpTtlSeconds,
|
|
14576
|
+
deliveryMode: decisionOtpDeliveryMode,
|
|
14577
|
+
smtp: smtpConfig,
|
|
14578
|
+
resend: resendConfig
|
|
14579
|
+
});
|
|
14519
14580
|
if (!issued.ok) {
|
|
14520
14581
|
metrics.incCounter("decision_otp_requests_total", { tenantId: String(tenantId ?? "default"), ok: "false", code: String(issued.error ?? "OTP_FAILED") }, 1);
|
|
14521
|
-
return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message
|
|
14582
|
+
return sendJson(res, 400, { ok: false, code: issued.error ?? "OTP_FAILED", message: issued.message || "otp failed" });
|
|
14522
14583
|
}
|
|
14523
14584
|
metrics.incCounter("decision_otp_requests_total", { tenantId: String(tenantId ?? "default"), ok: "true" }, 1);
|
|
14524
14585
|
|
|
@@ -14724,7 +14785,7 @@ async function handleDecision(req, res, token, { internalAutoDecision = false }
|
|
|
14724
14785
|
return sendJson(res, 400, { ok: false, code: "OTP_REQUIRED", message: "otp code is required" });
|
|
14725
14786
|
}
|
|
14726
14787
|
const verified = await verifyAndConsumeDecisionOtp({ dataDir, token, email: actorEmailNorm, code: otp, maxAttempts: decisionOtpMaxAttempts });
|
|
14727
|
-
if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message
|
|
14788
|
+
if (!verified.ok) return sendJson(res, 400, { ok: false, code: verified.error ?? "OTP_FAILED", message: verified.message || "otp failed" });
|
|
14728
14789
|
}
|
|
14729
14790
|
|
|
14730
14791
|
let cliOut = null;
|
|
@@ -21,6 +21,19 @@ function clampText(v, { max }) {
|
|
|
21
21
|
return s.slice(0, Math.max(0, max - 1)) + "…";
|
|
22
22
|
}
|
|
23
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
|
+
|
|
24
37
|
export function formatSmtpMessage({ from, to, subject, text, messageIdDomain }) {
|
|
25
38
|
const subj = clampText(subject, { max: 200 });
|
|
26
39
|
const body = String(text ?? "");
|
|
@@ -133,6 +146,8 @@ export async function sendSmtpMail({
|
|
|
133
146
|
if (!h) throw new Error("smtp host required");
|
|
134
147
|
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("smtp port invalid");
|
|
135
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" });
|
|
136
151
|
|
|
137
152
|
const connect = secure
|
|
138
153
|
? () => tls.connect({ host: h, port: p, servername: h })
|
|
@@ -178,13 +193,13 @@ export async function sendSmtpMail({
|
|
|
178
193
|
}
|
|
179
194
|
}
|
|
180
195
|
|
|
181
|
-
await sendCmd(socket, reader, `MAIL FROM:<${
|
|
182
|
-
await sendCmd(socket, reader, `RCPT TO:<${
|
|
196
|
+
await sendCmd(socket, reader, `MAIL FROM:<${envelopeFrom}>`, 250);
|
|
197
|
+
await sendCmd(socket, reader, `RCPT TO:<${envelopeTo}>`, [250, 251]);
|
|
183
198
|
await sendCmd(socket, reader, "DATA", 354);
|
|
184
199
|
|
|
185
200
|
const domain = (() => {
|
|
186
|
-
const i =
|
|
187
|
-
return i !== -1 ?
|
|
201
|
+
const i = envelopeFrom.indexOf("@");
|
|
202
|
+
return i !== -1 ? envelopeFrom.slice(i + 1) : "localhost";
|
|
188
203
|
})();
|
|
189
204
|
const msg = formatSmtpMessage({ from, to, subject, text, messageIdDomain: domain });
|
|
190
205
|
|
package/src/api/app.js
CHANGED
|
@@ -7588,7 +7588,12 @@ export function createApi({
|
|
|
7588
7588
|
const out = [];
|
|
7589
7589
|
const seen = new Set();
|
|
7590
7590
|
for (const entry of parsed) {
|
|
7591
|
-
|
|
7591
|
+
if (entry === null || entry === undefined || String(entry).trim() === "") continue;
|
|
7592
|
+
if (typeof entry !== "string") throw new TypeError(`${fieldPath} must contain only strings`);
|
|
7593
|
+
const id = String(entry).trim();
|
|
7594
|
+
if (!id) continue;
|
|
7595
|
+
if (id.length > max) throw new TypeError(`${fieldPath} must be <= ${max} chars`);
|
|
7596
|
+
if (!/^[A-Za-z0-9:_.-]+$/.test(id)) throw new TypeError(`${fieldPath} must match ^[A-Za-z0-9:_.-]+$`);
|
|
7592
7597
|
if (!id) continue;
|
|
7593
7598
|
if (seen.has(id)) continue;
|
|
7594
7599
|
seen.add(id);
|