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,10 @@
|
|
|
1
|
+
export { SettldClient } from "./client.js";
|
|
2
|
+
export { fetchWithSettldAutopay } from "./x402-autopay.js";
|
|
3
|
+
export {
|
|
4
|
+
verifySettldWebhookSignature,
|
|
5
|
+
SettldWebhookSignatureError,
|
|
6
|
+
SettldWebhookSignatureHeaderError,
|
|
7
|
+
SettldWebhookTimestampToleranceError,
|
|
8
|
+
SettldWebhookNoMatchingSignatureError
|
|
9
|
+
} from "./webhook-signature.js";
|
|
10
|
+
export { verifySettldWebhook } from "./express-middleware.js";
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function isNonEmptyString(value) {
|
|
4
|
+
return typeof value === "string" && value.trim() !== "";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function toBodyBuffer(rawBody) {
|
|
8
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(rawBody)) return rawBody;
|
|
9
|
+
if (typeof rawBody === "string") return Buffer.from(rawBody, "utf8");
|
|
10
|
+
if (rawBody instanceof ArrayBuffer) return Buffer.from(rawBody);
|
|
11
|
+
if (rawBody instanceof Uint8Array) return Buffer.from(rawBody.buffer, rawBody.byteOffset, rawBody.byteLength);
|
|
12
|
+
throw new TypeError("rawBody must be a string, Buffer, Uint8Array, or ArrayBuffer");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseTimestampToMs(timestamp) {
|
|
16
|
+
const raw = String(timestamp ?? "").trim();
|
|
17
|
+
if (!raw) return Number.NaN;
|
|
18
|
+
if (/^\d+$/.test(raw)) {
|
|
19
|
+
const asSeconds = Number(raw);
|
|
20
|
+
if (!Number.isSafeInteger(asSeconds) || asSeconds <= 0) return Number.NaN;
|
|
21
|
+
return asSeconds * 1000;
|
|
22
|
+
}
|
|
23
|
+
const asMs = Date.parse(raw);
|
|
24
|
+
if (!Number.isFinite(asMs)) return Number.NaN;
|
|
25
|
+
return asMs;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeHex(value) {
|
|
29
|
+
const candidate = String(value ?? "").trim().toLowerCase();
|
|
30
|
+
if (!/^[0-9a-f]{64}$/.test(candidate)) return null;
|
|
31
|
+
return candidate;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function timingSafeEqualHex(leftHex, rightHex) {
|
|
35
|
+
const left = normalizeHex(leftHex);
|
|
36
|
+
const right = normalizeHex(rightHex);
|
|
37
|
+
if (!left || !right) return false;
|
|
38
|
+
const leftBuf = Buffer.from(left, "hex");
|
|
39
|
+
const rightBuf = Buffer.from(right, "hex");
|
|
40
|
+
if (leftBuf.length !== rightBuf.length) return false;
|
|
41
|
+
return crypto.timingSafeEqual(leftBuf, rightBuf);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseSignatureHeader(signatureHeader) {
|
|
45
|
+
if (!isNonEmptyString(signatureHeader)) {
|
|
46
|
+
throw new SettldWebhookSignatureHeaderError("x-settld-signature header is required");
|
|
47
|
+
}
|
|
48
|
+
const parts = signatureHeader
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((value) => value.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
if (parts.length === 0) {
|
|
53
|
+
throw new SettldWebhookSignatureHeaderError("x-settld-signature header is empty");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let timestamp = null;
|
|
57
|
+
const signatures = [];
|
|
58
|
+
for (const part of parts) {
|
|
59
|
+
const separatorIndex = part.indexOf("=");
|
|
60
|
+
if (separatorIndex <= 0) {
|
|
61
|
+
signatures.push(part);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const key = part.slice(0, separatorIndex).trim().toLowerCase();
|
|
65
|
+
const value = part.slice(separatorIndex + 1).trim();
|
|
66
|
+
if (!value) continue;
|
|
67
|
+
if (key === "t") {
|
|
68
|
+
timestamp = value;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (key === "v1") {
|
|
72
|
+
signatures.push(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (signatures.length === 0) {
|
|
77
|
+
throw new SettldWebhookSignatureHeaderError("x-settld-signature header did not include any signatures");
|
|
78
|
+
}
|
|
79
|
+
return { timestamp, signatures };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseVerifyOptions(optionsOrTolerance) {
|
|
83
|
+
if (optionsOrTolerance === null || optionsOrTolerance === undefined) {
|
|
84
|
+
return { toleranceSeconds: 300, timestamp: null, nowMs: Date.now() };
|
|
85
|
+
}
|
|
86
|
+
if (typeof optionsOrTolerance === "number") {
|
|
87
|
+
return { toleranceSeconds: optionsOrTolerance, timestamp: null, nowMs: Date.now() };
|
|
88
|
+
}
|
|
89
|
+
if (!optionsOrTolerance || typeof optionsOrTolerance !== "object" || Array.isArray(optionsOrTolerance)) {
|
|
90
|
+
throw new TypeError("options must be a number or plain object");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
toleranceSeconds:
|
|
94
|
+
optionsOrTolerance.toleranceSeconds === undefined || optionsOrTolerance.toleranceSeconds === null
|
|
95
|
+
? 300
|
|
96
|
+
: Number(optionsOrTolerance.toleranceSeconds),
|
|
97
|
+
timestamp: optionsOrTolerance.timestamp ?? null,
|
|
98
|
+
nowMs:
|
|
99
|
+
optionsOrTolerance.nowMs === undefined || optionsOrTolerance.nowMs === null
|
|
100
|
+
? Date.now()
|
|
101
|
+
: Number(optionsOrTolerance.nowMs)
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class SettldWebhookSignatureError extends Error {
|
|
106
|
+
constructor(message, { code = "SETTLD_WEBHOOK_SIGNATURE_ERROR" } = {}) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = "SettldWebhookSignatureError";
|
|
109
|
+
this.code = code;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class SettldWebhookSignatureHeaderError extends SettldWebhookSignatureError {
|
|
114
|
+
constructor(message) {
|
|
115
|
+
super(message, { code: "SETTLD_WEBHOOK_SIGNATURE_HEADER_INVALID" });
|
|
116
|
+
this.name = "SettldWebhookSignatureHeaderError";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export class SettldWebhookTimestampToleranceError extends SettldWebhookSignatureError {
|
|
121
|
+
constructor(message, { timestamp = null, toleranceSeconds = null, nowMs = null } = {}) {
|
|
122
|
+
super(message, { code: "SETTLD_WEBHOOK_TIMESTAMP_OUTSIDE_TOLERANCE" });
|
|
123
|
+
this.name = "SettldWebhookTimestampToleranceError";
|
|
124
|
+
this.timestamp = timestamp;
|
|
125
|
+
this.toleranceSeconds = toleranceSeconds;
|
|
126
|
+
this.nowMs = nowMs;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export class SettldWebhookNoMatchingSignatureError extends SettldWebhookSignatureError {
|
|
131
|
+
constructor(message) {
|
|
132
|
+
super(message, { code: "SETTLD_WEBHOOK_SIGNATURE_NO_MATCH" });
|
|
133
|
+
this.name = "SettldWebhookNoMatchingSignatureError";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function verifySettldWebhookSignature(rawBody, signatureHeader, secret, optionsOrTolerance = 300) {
|
|
138
|
+
if (!isNonEmptyString(secret)) throw new TypeError("secret is required");
|
|
139
|
+
const bodyBuffer = toBodyBuffer(rawBody);
|
|
140
|
+
const parsed = parseSignatureHeader(signatureHeader);
|
|
141
|
+
const options = parseVerifyOptions(optionsOrTolerance);
|
|
142
|
+
|
|
143
|
+
if (!Number.isFinite(options.toleranceSeconds) || options.toleranceSeconds <= 0) {
|
|
144
|
+
throw new TypeError("toleranceSeconds must be a positive number");
|
|
145
|
+
}
|
|
146
|
+
if (!Number.isFinite(options.nowMs)) {
|
|
147
|
+
throw new TypeError("nowMs must be a finite number");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const timestamp = isNonEmptyString(parsed.timestamp)
|
|
151
|
+
? parsed.timestamp.trim()
|
|
152
|
+
: isNonEmptyString(options.timestamp)
|
|
153
|
+
? String(options.timestamp).trim()
|
|
154
|
+
: null;
|
|
155
|
+
if (!timestamp) {
|
|
156
|
+
throw new SettldWebhookSignatureHeaderError("timestamp is required (use t=... in signature header or options.timestamp)");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const timestampMs = parseTimestampToMs(timestamp);
|
|
160
|
+
if (!Number.isFinite(timestampMs)) {
|
|
161
|
+
throw new SettldWebhookSignatureHeaderError("timestamp is invalid");
|
|
162
|
+
}
|
|
163
|
+
const ageSeconds = Math.abs(options.nowMs - timestampMs) / 1000;
|
|
164
|
+
if (ageSeconds > options.toleranceSeconds) {
|
|
165
|
+
throw new SettldWebhookTimestampToleranceError("signature timestamp is outside tolerance", {
|
|
166
|
+
timestamp,
|
|
167
|
+
toleranceSeconds: options.toleranceSeconds,
|
|
168
|
+
nowMs: options.nowMs
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hmac = crypto.createHmac("sha256", String(secret));
|
|
173
|
+
hmac.update(`${timestamp}.`, "utf8");
|
|
174
|
+
hmac.update(bodyBuffer);
|
|
175
|
+
const expected = hmac.digest("hex");
|
|
176
|
+
for (const provided of parsed.signatures) {
|
|
177
|
+
if (timingSafeEqualHex(provided, expected)) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
throw new SettldWebhookNoMatchingSignatureError("no matching signature in x-settld-signature header");
|
|
182
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
function assertFetchFunction(fetchImpl) {
|
|
2
|
+
if (typeof fetchImpl !== "function") throw new TypeError("fetch must be a function");
|
|
3
|
+
return fetchImpl;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function normalizeHeaderName(name, fallback) {
|
|
7
|
+
const raw = typeof name === "string" && name.trim() !== "" ? name.trim() : fallback;
|
|
8
|
+
return raw.toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseBooleanLike(value) {
|
|
12
|
+
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
13
|
+
if (raw === "1" || raw === "true" || raw === "yes" || raw === "on") return true;
|
|
14
|
+
if (raw === "0" || raw === "false" || raw === "no" || raw === "off") return false;
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function normalizeChallengeRef(value) {
|
|
19
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
20
|
+
return raw === "" ? null : raw;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function normalizeChallengeHash(value) {
|
|
24
|
+
const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
25
|
+
return /^[0-9a-f]{64}$/.test(raw) ? raw : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function parseChallengeFields(text) {
|
|
29
|
+
const raw = typeof text === "string" ? text.trim() : "";
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
if (raw.startsWith("{") && raw.endsWith("}")) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(raw);
|
|
34
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
35
|
+
return parsed;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const part of raw.split(";")) {
|
|
42
|
+
const chunk = part.trim();
|
|
43
|
+
if (!chunk) continue;
|
|
44
|
+
const idx = chunk.indexOf("=");
|
|
45
|
+
if (idx <= 0) continue;
|
|
46
|
+
const key = chunk.slice(0, idx).trim();
|
|
47
|
+
const value = chunk.slice(idx + 1).trim();
|
|
48
|
+
if (!key) continue;
|
|
49
|
+
out[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseBase64UrlJson(value) {
|
|
55
|
+
const raw = typeof value === "string" ? value.trim() : "";
|
|
56
|
+
if (!raw) return null;
|
|
57
|
+
if (typeof Buffer === "undefined") return null;
|
|
58
|
+
try {
|
|
59
|
+
const text = Buffer.from(raw, "base64url").toString("utf8");
|
|
60
|
+
const parsed = JSON.parse(text);
|
|
61
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
62
|
+
return parsed;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sortedJsonClone(value) {
|
|
69
|
+
if (Array.isArray(value)) return value.map((entry) => sortedJsonClone(entry));
|
|
70
|
+
if (!value || typeof value !== "object") return value;
|
|
71
|
+
const out = {};
|
|
72
|
+
for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
|
|
73
|
+
out[key] = sortedJsonClone(value[key]);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeAgentPassportHeaderValue(agentPassport) {
|
|
79
|
+
if (agentPassport === null || agentPassport === undefined) return null;
|
|
80
|
+
if (!agentPassport || typeof agentPassport !== "object" || Array.isArray(agentPassport)) {
|
|
81
|
+
throw new TypeError("agentPassport must be an object when provided");
|
|
82
|
+
}
|
|
83
|
+
if (typeof Buffer === "undefined") {
|
|
84
|
+
throw new TypeError("agentPassport header encoding requires Buffer support");
|
|
85
|
+
}
|
|
86
|
+
const canonical = JSON.stringify(sortedJsonClone(agentPassport));
|
|
87
|
+
return Buffer.from(canonical, "utf8").toString("base64url");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildX402ChallengeMetadata(res, { gateHeaderName }) {
|
|
91
|
+
const gateIdRaw = res.headers.get(gateHeaderName);
|
|
92
|
+
const gateId = typeof gateIdRaw === "string" && gateIdRaw.trim() !== "" ? gateIdRaw.trim() : null;
|
|
93
|
+
const paymentRequiredRaw = res.headers.get("x-payment-required") ?? res.headers.get("payment-required");
|
|
94
|
+
const paymentRequired = typeof paymentRequiredRaw === "string" && paymentRequiredRaw.trim() !== "" ? paymentRequiredRaw.trim() : null;
|
|
95
|
+
const fields = paymentRequired ? parseChallengeFields(paymentRequired) : null;
|
|
96
|
+
const quote = parseBase64UrlJson(res.headers.get("x-settld-provider-quote"));
|
|
97
|
+
const quoteSignature = parseBase64UrlJson(res.headers.get("x-settld-provider-quote-signature"));
|
|
98
|
+
const quoteRequired = parseBooleanLike(fields?.quoteRequired);
|
|
99
|
+
return {
|
|
100
|
+
gateId,
|
|
101
|
+
paymentRequired,
|
|
102
|
+
fields,
|
|
103
|
+
policyChallenge: {
|
|
104
|
+
spendAuthorizationMode: normalizeChallengeRef(fields?.spendAuthorizationMode),
|
|
105
|
+
requestBindingMode: normalizeChallengeRef(fields?.requestBindingMode),
|
|
106
|
+
requestBindingSha256: normalizeChallengeHash(fields?.requestBindingSha256),
|
|
107
|
+
quoteRequired,
|
|
108
|
+
quoteId: normalizeChallengeRef(fields?.quoteId),
|
|
109
|
+
providerId: normalizeChallengeRef(fields?.providerId),
|
|
110
|
+
toolId: normalizeChallengeRef(fields?.toolId),
|
|
111
|
+
policyRef: normalizeChallengeRef(fields?.policyRef),
|
|
112
|
+
policyVersion: normalizeChallengeRef(fields?.policyVersion),
|
|
113
|
+
policyHash: normalizeChallengeHash(fields?.policyHash),
|
|
114
|
+
policyFingerprint: normalizeChallengeHash(fields?.policyFingerprint),
|
|
115
|
+
sponsorRef: normalizeChallengeRef(fields?.sponsorRef),
|
|
116
|
+
sponsorWalletRef: normalizeChallengeRef(fields?.sponsorWalletRef)
|
|
117
|
+
},
|
|
118
|
+
providerQuote: quote,
|
|
119
|
+
providerQuoteSignature: quoteSignature
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cloneBodyForRetry(body) {
|
|
124
|
+
if (body === null || body === undefined) return { ok: true, value: undefined };
|
|
125
|
+
if (typeof body === "string") return { ok: true, value: body };
|
|
126
|
+
if (body instanceof URLSearchParams) return { ok: true, value: new URLSearchParams(body) };
|
|
127
|
+
if (body instanceof ArrayBuffer) return { ok: true, value: body.slice(0) };
|
|
128
|
+
if (ArrayBuffer.isView(body)) {
|
|
129
|
+
const bytes = new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
|
|
130
|
+
return { ok: true, value: Uint8Array.from(bytes) };
|
|
131
|
+
}
|
|
132
|
+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(body)) return { ok: true, value: Buffer.from(body) };
|
|
133
|
+
return { ok: false, reason: "body_not_replayable" };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function normalizeInitHeaders(initHeaders) {
|
|
137
|
+
const out = new Headers();
|
|
138
|
+
if (!initHeaders) return out;
|
|
139
|
+
const input = new Headers(initHeaders);
|
|
140
|
+
for (const [k, v] of input.entries()) out.set(k, v);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildInitialInit(init, { agentPassportHeaderName, agentPassportHeaderValue }) {
|
|
145
|
+
const safeInit = init && typeof init === "object" ? init : {};
|
|
146
|
+
const headers = normalizeInitHeaders(safeInit.headers);
|
|
147
|
+
if (agentPassportHeaderValue) headers.set(agentPassportHeaderName, agentPassportHeaderValue);
|
|
148
|
+
return {
|
|
149
|
+
...safeInit,
|
|
150
|
+
headers
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildRetryInit(init, { gateHeaderName, gateId, agentPassportHeaderName, agentPassportHeaderValue }) {
|
|
155
|
+
const safeInit = init && typeof init === "object" ? init : {};
|
|
156
|
+
const bodyResult = cloneBodyForRetry(safeInit.body);
|
|
157
|
+
if (!bodyResult.ok) {
|
|
158
|
+
const err = new Error("x402 autopay cannot replay this request body");
|
|
159
|
+
err.code = "SETTLD_AUTOPAY_BODY_NOT_REPLAYABLE";
|
|
160
|
+
throw err;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const headers = normalizeInitHeaders(safeInit.headers);
|
|
164
|
+
headers.set(gateHeaderName, gateId);
|
|
165
|
+
if (agentPassportHeaderValue) headers.set(agentPassportHeaderName, agentPassportHeaderValue);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
...safeInit,
|
|
169
|
+
headers,
|
|
170
|
+
body: bodyResult.value
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function fetchWithSettldAutopay(url, init = {}, opts = {}) {
|
|
175
|
+
const fetchImpl = assertFetchFunction(opts?.fetch ?? globalThis.fetch);
|
|
176
|
+
const gateHeaderName = normalizeHeaderName(opts?.gateHeaderName, "x-settld-gate-id");
|
|
177
|
+
const agentPassportHeaderName = normalizeHeaderName(opts?.agentPassportHeaderName, "x-settld-agent-passport");
|
|
178
|
+
const agentPassportHeaderValue = normalizeAgentPassportHeaderValue(opts?.agentPassport ?? null);
|
|
179
|
+
const onChallenge = typeof opts?.onChallenge === "function" ? opts.onChallenge : null;
|
|
180
|
+
const maxAttemptsRaw = Number(opts?.maxAttempts ?? 2);
|
|
181
|
+
const maxAttempts = Number.isSafeInteger(maxAttemptsRaw) && maxAttemptsRaw >= 1 ? maxAttemptsRaw : 2;
|
|
182
|
+
|
|
183
|
+
let attempt = 0;
|
|
184
|
+
let currentInit = buildInitialInit(init, { agentPassportHeaderName, agentPassportHeaderValue });
|
|
185
|
+
let lastResponse = null;
|
|
186
|
+
while (attempt < maxAttempts) {
|
|
187
|
+
attempt += 1;
|
|
188
|
+
const res = await fetchImpl(url, currentInit);
|
|
189
|
+
lastResponse = res;
|
|
190
|
+
if (res.status !== 402) return res;
|
|
191
|
+
|
|
192
|
+
if (onChallenge) {
|
|
193
|
+
try {
|
|
194
|
+
onChallenge(buildX402ChallengeMetadata(res, { gateHeaderName }));
|
|
195
|
+
} catch {
|
|
196
|
+
// Ignore callback failures to keep autopay deterministic.
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (attempt >= maxAttempts) return res;
|
|
200
|
+
|
|
201
|
+
const gateIdRaw = res.headers.get(gateHeaderName);
|
|
202
|
+
const gateId = typeof gateIdRaw === "string" ? gateIdRaw.trim() : "";
|
|
203
|
+
if (!gateId) return res;
|
|
204
|
+
|
|
205
|
+
const nextInit = buildRetryInit(currentInit, { gateHeaderName, gateId, agentPassportHeaderName, agentPassportHeaderValue });
|
|
206
|
+
currentInit = nextInit;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return lastResponse;
|
|
210
|
+
}
|
|
@@ -42,6 +42,8 @@ async function main() {
|
|
|
42
42
|
sh("tar", ["-xzf", tarballPath, "-C", unpackDir], { env: npmEnv });
|
|
43
43
|
const packageRoot = path.join(unpackDir, "package");
|
|
44
44
|
const cliPath = path.join(packageRoot, "bin", "settld.js");
|
|
45
|
+
await fs.access(path.join(packageRoot, "scripts", "mcp", "settld-mcp-server.mjs"));
|
|
46
|
+
await fs.access(path.join(packageRoot, "packages", "api-sdk", "src", "x402-autopay.js"));
|
|
45
47
|
|
|
46
48
|
const runTarballCli = (args) => {
|
|
47
49
|
const cmd = ["npx", "--yes", "--package", tarballPath, "--", "settld", ...args].map(shellQuote).join(" ");
|
package/scripts/setup/login.mjs
CHANGED
|
@@ -290,6 +290,11 @@ export async function runLogin({
|
|
|
290
290
|
});
|
|
291
291
|
if (!otpRequest.res.ok) {
|
|
292
292
|
const code = responseCode(otpRequest);
|
|
293
|
+
if (otpRequest.res.status === 400 && code === "BUYER_AUTH_DISABLED") {
|
|
294
|
+
throw new Error(
|
|
295
|
+
"buyer OTP login is not enabled for this tenant. This tenant may be stale on the onboarding service; rerun `settld login` without --tenant-id to create a fresh tenant, or use bootstrap/manual API key mode."
|
|
296
|
+
);
|
|
297
|
+
}
|
|
293
298
|
if (otpRequest.res.status === 403 && code === "FORBIDDEN") {
|
|
294
299
|
throw new Error(
|
|
295
300
|
"OTP login is unavailable on this base URL. Use `Generate during setup` with an onboarding bootstrap API key, or use an existing tenant API key."
|
|
@@ -326,7 +331,7 @@ export async function runLogin({
|
|
|
326
331
|
otpAlreadyIssued = Boolean(signup.json?.otpIssued);
|
|
327
332
|
if (interactive) stdout.write(`Created tenant: ${tenantId}\n`);
|
|
328
333
|
}
|
|
329
|
-
if (!otpAlreadyIssued) await requestTenantOtp(tenantId);
|
|
334
|
+
if (!otpAlreadyIssued && !state.otp) await requestTenantOtp(tenantId);
|
|
330
335
|
|
|
331
336
|
if (!state.otp && interactive) {
|
|
332
337
|
state.otp = await promptLine(rl, "OTP code", { required: true });
|
|
@@ -1020,6 +1020,17 @@ async function runGuidedQuickFlow({
|
|
|
1020
1020
|
}
|
|
1021
1021
|
}
|
|
1022
1022
|
|
|
1023
|
+
const paidToolsBaseUrl = String(actionEnv.SETTLD_PAID_TOOLS_BASE_URL ?? "").trim();
|
|
1024
|
+
if (!paidToolsBaseUrl) {
|
|
1025
|
+
summary.firstPaidCall = {
|
|
1026
|
+
ok: false,
|
|
1027
|
+
skipped: true,
|
|
1028
|
+
reason: "SETTLD_PAID_TOOLS_BASE_URL not configured"
|
|
1029
|
+
};
|
|
1030
|
+
summary.warnings.push("first paid call probe skipped (SETTLD_PAID_TOOLS_BASE_URL not configured)");
|
|
1031
|
+
return summary;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1023
1034
|
const paidProbe = runMcpPaidCallProbe({ env: actionEnv });
|
|
1024
1035
|
if (paidProbe.ok) {
|
|
1025
1036
|
summary.firstPaidCall = { ok: true };
|
|
@@ -1126,10 +1137,24 @@ async function requestRuntimeBootstrapMcpEnv({
|
|
|
1126
1137
|
json = null;
|
|
1127
1138
|
}
|
|
1128
1139
|
if (!res.ok) {
|
|
1140
|
+
const responseCode =
|
|
1141
|
+
json && typeof json === "object" && (typeof json?.code === "string" || typeof json?.error === "string")
|
|
1142
|
+
? String(json?.code ?? json?.error ?? "").trim().toUpperCase()
|
|
1143
|
+
: "";
|
|
1129
1144
|
const message =
|
|
1130
1145
|
json && typeof json === "object"
|
|
1131
1146
|
? json?.message ?? json?.error ?? `HTTP ${res.status}`
|
|
1132
1147
|
: text || `HTTP ${res.status}`;
|
|
1148
|
+
if (res.status === 403 && cookie && !apiKey) {
|
|
1149
|
+
throw new Error(
|
|
1150
|
+
`runtime bootstrap request failed (403): ${String(message)}. Saved login session was rejected for this tenant; rerun \`settld login\` without --tenant-id to create a fresh tenant, or choose \`Generate during setup\`.`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
if (res.status === 400 && responseCode === "BUYER_AUTH_DISABLED") {
|
|
1154
|
+
throw new Error(
|
|
1155
|
+
"runtime bootstrap request failed (400): buyer OTP login is not enabled for this tenant. Rerun `settld login` without --tenant-id to create a fresh tenant, or choose `Generate during setup`."
|
|
1156
|
+
);
|
|
1157
|
+
}
|
|
1133
1158
|
throw new Error(`runtime bootstrap request failed (${res.status}): ${String(message)}`);
|
|
1134
1159
|
}
|
|
1135
1160
|
return extractBootstrapMcpEnv(json);
|
|
@@ -1944,7 +1969,8 @@ export async function runOnboard({
|
|
|
1944
1969
|
if (guided.ran) {
|
|
1945
1970
|
lines.push(`- wallet fund: ${guided.walletFund?.ok ? "ok" : "not completed"}`);
|
|
1946
1971
|
lines.push(`- wallet balance watch: ${guided.walletBalanceWatch?.ok ? "ok" : "not completed"}`);
|
|
1947
|
-
|
|
1972
|
+
const firstPaidCallState = guided.firstPaidCall?.skipped ? "skipped" : guided.firstPaidCall?.ok ? "ok" : "failed";
|
|
1973
|
+
lines.push(`- first paid call: ${firstPaidCallState}`);
|
|
1948
1974
|
} else {
|
|
1949
1975
|
lines.push("- skipped");
|
|
1950
1976
|
}
|
|
@@ -19,6 +19,17 @@ const FAILURE_CLASSES = Object.freeze([
|
|
|
19
19
|
patterns: [/OTP_(INVALID|EXPIRED|CONSUMED|MISSING)/i, /otp code is required/i, /invalid otp/i],
|
|
20
20
|
remediation: "Request a fresh OTP and retry `settld login`."
|
|
21
21
|
},
|
|
22
|
+
{
|
|
23
|
+
code: "ONBOARDING_AUTH_TENANT_DISABLED",
|
|
24
|
+
phase: "auth",
|
|
25
|
+
patterns: [
|
|
26
|
+
/buyer OTP login is not enabled for this tenant/i,
|
|
27
|
+
/BUYER_AUTH_DISABLED/i,
|
|
28
|
+
/Saved login session was rejected for this tenant/i
|
|
29
|
+
],
|
|
30
|
+
remediation:
|
|
31
|
+
"Rerun `settld login` without `--tenant-id` to create a fresh tenant, or choose `Generate during setup` / `Paste existing key`."
|
|
32
|
+
},
|
|
22
33
|
{
|
|
23
34
|
code: "ONBOARDING_BOOTSTRAP_FORBIDDEN",
|
|
24
35
|
phase: "bootstrap",
|
|
@@ -27,7 +27,8 @@ Example:
|
|
|
27
27
|
export MAGIC_LINK_HOST=127.0.0.1 # set 0.0.0.0 in prod
|
|
28
28
|
export MAGIC_LINK_PORT=8787
|
|
29
29
|
export MAGIC_LINK_API_KEY='dev_key'
|
|
30
|
-
export MAGIC_LINK_DATA_DIR=/tmp/settld-magic-link
|
|
30
|
+
export MAGIC_LINK_DATA_DIR=/tmp/settld-magic-link # dev only; use a persistent volume path in production
|
|
31
|
+
export MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=0 # set 1 in production to fail fast on ephemeral paths
|
|
31
32
|
export MAGIC_LINK_VERIFY_TIMEOUT_MS=60000
|
|
32
33
|
export MAGIC_LINK_RATE_LIMIT_UPLOADS_PER_HOUR=100
|
|
33
34
|
export MAGIC_LINK_VERIFY_QUEUE_WORKERS=2
|
|
@@ -82,11 +83,12 @@ Magic Link can deliver OTP codes in three modes:
|
|
|
82
83
|
- `record` (default): write OTPs to an on-disk outbox under `MAGIC_LINK_DATA_DIR` (dev/testing)
|
|
83
84
|
- `log`: log OTP codes to stdout (dev only)
|
|
84
85
|
- `smtp`: send OTP codes via SMTP (production)
|
|
86
|
+
- `resend`: send OTP codes via Resend HTTPS API (recommended on hosts where SMTP egress is restricted)
|
|
85
87
|
|
|
86
88
|
Env vars:
|
|
87
89
|
|
|
88
|
-
- `MAGIC_LINK_BUYER_OTP_DELIVERY_MODE=record|log|smtp`
|
|
89
|
-
- `MAGIC_LINK_DECISION_OTP_DELIVERY_MODE=record|log|smtp`
|
|
90
|
+
- `MAGIC_LINK_BUYER_OTP_DELIVERY_MODE=record|log|smtp|resend`
|
|
91
|
+
- `MAGIC_LINK_DECISION_OTP_DELIVERY_MODE=record|log|smtp|resend`
|
|
90
92
|
|
|
91
93
|
SMTP config (required for `smtp` mode):
|
|
92
94
|
|
|
@@ -95,7 +97,13 @@ SMTP config (required for `smtp` mode):
|
|
|
95
97
|
- `MAGIC_LINK_SMTP_SECURE=1|0` (default `0`; set `1` for SMTPS/465)
|
|
96
98
|
- `MAGIC_LINK_SMTP_STARTTLS=1|0` (default `1`; ignored when `SECURE=1`)
|
|
97
99
|
- `MAGIC_LINK_SMTP_USER`, `MAGIC_LINK_SMTP_PASS` (optional; enables `AUTH PLAIN`)
|
|
98
|
-
- `MAGIC_LINK_SMTP_FROM` (required when `MAGIC_LINK_SMTP_HOST` is set)
|
|
100
|
+
- `MAGIC_LINK_SMTP_FROM` (required when `MAGIC_LINK_SMTP_HOST` is set; use bare email for envelope sender, e.g. `ops@settld.work`)
|
|
101
|
+
|
|
102
|
+
Resend config (required for `resend` mode):
|
|
103
|
+
|
|
104
|
+
- `MAGIC_LINK_RESEND_API_KEY`
|
|
105
|
+
- `MAGIC_LINK_RESEND_FROM` (verified sender, e.g. `onboarding@settld.work`)
|
|
106
|
+
- `MAGIC_LINK_RESEND_BASE_URL` (optional, default `https://api.resend.com`)
|
|
99
107
|
|
|
100
108
|
## Data dir format + upgrades
|
|
101
109
|
|
|
@@ -106,6 +114,7 @@ Magic Link persists state under `MAGIC_LINK_DATA_DIR`. A small format marker is
|
|
|
106
114
|
On startup, Magic Link can initialize/migrate this marker (default: enabled):
|
|
107
115
|
|
|
108
116
|
- `MAGIC_LINK_MIGRATE_ON_STARTUP=1` (default)
|
|
117
|
+
- `MAGIC_LINK_REQUIRE_DURABLE_DATA_DIR=1|0` (default `0`; set `1` in production to block startup when `MAGIC_LINK_DATA_DIR` points to `/tmp`)
|
|
109
118
|
|
|
110
119
|
You can also run an explicit check/migrate command without starting the server:
|
|
111
120
|
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { sendSmtpMail } from "./smtp.js";
|
|
6
|
+
import { sendResendMail } from "./email-resend.js";
|
|
6
7
|
|
|
7
8
|
function nowIso() {
|
|
8
9
|
return new Date().toISOString();
|
|
@@ -44,7 +45,15 @@ function issueCode6() {
|
|
|
44
45
|
return String(n).padStart(6, "0");
|
|
45
46
|
}
|
|
46
47
|
|
|
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 issueBuyerOtp({ dataDir, tenantId, email, ttlSeconds, deliveryMode, smtp, resend } = {}) {
|
|
48
57
|
const emailNorm = normalizeEmailLower(email);
|
|
49
58
|
if (!emailNorm) return { ok: false, error: "INVALID_EMAIL", message: "invalid email" };
|
|
50
59
|
|
|
@@ -106,7 +115,29 @@ export async function issueBuyerOtp({ dataDir, tenantId, email, ttlSeconds, deli
|
|
|
106
115
|
text: `Your login code is: ${code}\n\nThis code expires at: ${expiresAt}\n\nIf you did not request this code, you can ignore this email.\n`
|
|
107
116
|
});
|
|
108
117
|
} catch (err) {
|
|
109
|
-
return { ok: false, error: "SMTP_SEND_FAILED", message:
|
|
118
|
+
return { ok: false, error: "SMTP_SEND_FAILED", message: errorMessageOrFallback(err, "smtp send failed") };
|
|
119
|
+
}
|
|
120
|
+
} else if (mode === "resend") {
|
|
121
|
+
const from = typeof resend?.from === "string" ? resend.from.trim() : "";
|
|
122
|
+
const apiKey = typeof resend?.apiKey === "string" ? resend.apiKey.trim() : "";
|
|
123
|
+
if (!from || !apiKey) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: "RESEND_NOT_CONFIGURED",
|
|
127
|
+
message: "resend.from and resend.apiKey are required"
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
await sendResendMail({
|
|
132
|
+
apiKey,
|
|
133
|
+
from,
|
|
134
|
+
to: emailNorm,
|
|
135
|
+
subject: "Your Settld login code",
|
|
136
|
+
text: `Your login code is: ${code}\n\nThis code expires at: ${expiresAt}\n\nIf you did not request this code, you can ignore this email.\n`,
|
|
137
|
+
baseUrl: resend?.baseUrl
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
return { ok: false, error: "RESEND_SEND_FAILED", message: errorMessageOrFallback(err, "resend send failed") };
|
|
110
141
|
}
|
|
111
142
|
} else {
|
|
112
143
|
throw new Error("invalid deliveryMode");
|
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { sendSmtpMail } from "./smtp.js";
|
|
6
|
+
import { sendResendMail } from "./email-resend.js";
|
|
6
7
|
|
|
7
8
|
function nowIso() {
|
|
8
9
|
return new Date().toISOString();
|
|
@@ -44,7 +45,15 @@ function issueCode6() {
|
|
|
44
45
|
return String(n).padStart(6, "0");
|
|
45
46
|
}
|
|
46
47
|
|
|
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 } = {}) {
|
|
48
57
|
const emailNorm = normalizeEmail(email);
|
|
49
58
|
if (!emailNorm) return { ok: false, error: "INVALID_EMAIL", message: "invalid email" };
|
|
50
59
|
|
|
@@ -103,7 +112,29 @@ export async function issueDecisionOtp({ dataDir, token, email, ttlSeconds, deli
|
|
|
103
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`
|
|
104
113
|
});
|
|
105
114
|
} catch (err) {
|
|
106
|
-
return { ok: false, error: "SMTP_SEND_FAILED", message:
|
|
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") };
|
|
107
138
|
}
|
|
108
139
|
} else {
|
|
109
140
|
throw new Error("invalid deliveryMode");
|