@aeon-ai-pay/aigateway 0.1.5 → 0.2.1
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/CHANGELOG.md +22 -0
- package/bin/cli.mjs +20 -32
- package/docs/exit-codes.md +2 -1
- package/docs/release-process.md +9 -7
- package/package.json +1 -1
- package/skills/aigateway/SKILL.md +369 -267
- package/src/catalog.mjs +38 -0
- package/src/commands/sb-invoke.mjs +407 -0
- package/src/commands/sb-tools.mjs +37 -0
- package/src/commands/wallet-init.mjs +1 -3
- package/src/config.mjs +21 -22
- package/src/error-codes.mjs +11 -3
- package/src/funding.mjs +2 -2
- package/src/inputs-validator.mjs +125 -0
- package/src/output.mjs +1 -1
- package/src/tools-download.mjs +264 -0
- package/src/update-check.mjs +50 -47
- package/src/x402.mjs +1 -1
- package/skills/aigateway/references/check-status.md +0 -68
- package/skills/aigateway/references/create-card.md +0 -114
- package/skills/aigateway/references/store.md +0 -87
- package/src/commands/create-card-status.mjs +0 -67
- package/src/commands/create-card.mjs +0 -352
- package/src/commands/create-image.mjs +0 -428
- package/src/sanitize.mjs +0 -48
|
@@ -1,428 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* create-image: generate an AI image via Skill Boss using the x402 protocol.
|
|
3
|
-
*
|
|
4
|
-
* Server endpoint: GET {serviceUrl}/open/ai/x402/skillBoss/create?body=<urlencoded-json>&appId=<merchant>
|
|
5
|
-
* Flow: fetch payment requirements -> check balance + allowance
|
|
6
|
-
* -> (if balance is insufficient) top up via funding.mjs/fundSessionKey
|
|
7
|
-
* -> submit x402 EIP-712 signature -> download the image locally
|
|
8
|
-
*/
|
|
9
|
-
import { createX402Api, decodePaymentResponse, fetchPaymentRequirements } from "../x402.mjs";
|
|
10
|
-
import { resolve } from "../config.mjs";
|
|
11
|
-
import { getWalletBalance, getAllowance } from "../balance.mjs";
|
|
12
|
-
import axios from "axios";
|
|
13
|
-
import {
|
|
14
|
-
fundSessionKey,
|
|
15
|
-
promptTopupAmount,
|
|
16
|
-
MIN_TOPUP_USDT,
|
|
17
|
-
TOPUP_PRESETS,
|
|
18
|
-
} from "../funding.mjs";
|
|
19
|
-
import { WalletConnectError } from "../walletconnect.mjs";
|
|
20
|
-
import { emitOk, emitErr, logInfo } from "../output.mjs";
|
|
21
|
-
import { mkdirSync, createWriteStream, existsSync, unlinkSync, openSync, readSync, closeSync, statSync } from "node:fs";
|
|
22
|
-
import { join, basename, extname } from "node:path";
|
|
23
|
-
import { homedir } from "node:os";
|
|
24
|
-
import { URL } from "node:url";
|
|
25
|
-
import { get as httpsGet } from "node:https";
|
|
26
|
-
import { get as httpGet } from "node:http";
|
|
27
|
-
|
|
28
|
-
const DEFAULT_IMAGE_DIR = join(homedir(), "aigateway-images");
|
|
29
|
-
const DEFAULT_MODEL = "replicate/black-forest-labs/flux-schnell";
|
|
30
|
-
|
|
31
|
-
export async function createImage(opts) {
|
|
32
|
-
logInfo("Generating image...");
|
|
33
|
-
const serviceUrl = resolve(opts.serviceUrl, "AIGATEWAY_SERVICE_URL", "serviceUrl");
|
|
34
|
-
const privateKey = resolve(opts.privateKey, "EVM_PRIVATE_KEY", "privateKey");
|
|
35
|
-
const { prompt, appId } = opts;
|
|
36
|
-
const aspectRatio = opts.aspectRatio || "16:9";
|
|
37
|
-
const outputFormat = (opts.outputFormat || "png").toLowerCase();
|
|
38
|
-
const model = opts.model || DEFAULT_MODEL;
|
|
39
|
-
|
|
40
|
-
if (!serviceUrl) {
|
|
41
|
-
emitErr("create-image", "SERVICE_URL_MISSING", {
|
|
42
|
-
message: "Missing service URL. Set env AIGATEWAY_SERVICE_URL if you need to override the built-in default.",
|
|
43
|
-
appId,
|
|
44
|
-
});
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
if (!privateKey) {
|
|
48
|
-
emitErr("create-image", "WALLET_NOT_CONFIGURED", { appId });
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
if (!prompt || !prompt.trim()) {
|
|
52
|
-
emitErr("create-image", "MISSING_PROMPT", { appId });
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const bodyPayload = {
|
|
57
|
-
model,
|
|
58
|
-
inputs: {
|
|
59
|
-
prompt,
|
|
60
|
-
aspect_ratio: aspectRatio,
|
|
61
|
-
output_format: outputFormat,
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
const bodyParam = encodeURIComponent(JSON.stringify(bodyPayload));
|
|
65
|
-
const url = `${serviceUrl}/open/ai/x402/skillBoss/create?body=${bodyParam}&appId=${encodeURIComponent(appId)}`;
|
|
66
|
-
|
|
67
|
-
logInfo("Fetching payment requirements...");
|
|
68
|
-
let requiredUsdt;
|
|
69
|
-
let paymentReq;
|
|
70
|
-
try {
|
|
71
|
-
paymentReq = await fetchPaymentRequirements(url);
|
|
72
|
-
requiredUsdt = paymentReq.amountUsdt;
|
|
73
|
-
logInfo(`Required: ${requiredUsdt} USDT (pay to ${paymentReq.payTo})`);
|
|
74
|
-
} catch (e) {
|
|
75
|
-
emitErr("create-image", "PAYMENT_FETCH_FAILED", {
|
|
76
|
-
message: `Failed to fetch payment requirements: ${e.message}`,
|
|
77
|
-
appId,
|
|
78
|
-
});
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
logInfo("Checking wallet...");
|
|
83
|
-
let needTopup = false;
|
|
84
|
-
let needGas = false;
|
|
85
|
-
let sessionAddress;
|
|
86
|
-
let topupAmount = null;
|
|
87
|
-
let balanceInitialUsdt = null;
|
|
88
|
-
let balanceBeforeChargeUsdt = null;
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
const { address, usdt, bnb, bnbRaw } = await getWalletBalance(privateKey);
|
|
92
|
-
sessionAddress = address;
|
|
93
|
-
balanceInitialUsdt = usdt;
|
|
94
|
-
balanceBeforeChargeUsdt = usdt;
|
|
95
|
-
const usdtNum = parseFloat(usdt);
|
|
96
|
-
|
|
97
|
-
logInfo(`Wallet: ${address}`);
|
|
98
|
-
logInfo(`Balance: ${usdt} USDT, ${bnb} BNB`);
|
|
99
|
-
|
|
100
|
-
const allowance = await getAllowance(address);
|
|
101
|
-
const requiredWei = BigInt(paymentReq.amountWei);
|
|
102
|
-
if (requiredWei === 0n) {
|
|
103
|
-
emitErr("create-image", "INVALID_PAYMENT_AMOUNT", {
|
|
104
|
-
message: "Server returned invalid payment amount (0). Please retry later.",
|
|
105
|
-
appId,
|
|
106
|
-
});
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
if (allowance >= requiredWei) {
|
|
110
|
-
logInfo("Allowance sufficient, no approve needed.");
|
|
111
|
-
} else {
|
|
112
|
-
logInfo(`Allowance ${allowance} < required ${requiredWei}; approve needed.`);
|
|
113
|
-
if (bnbRaw === 0n) {
|
|
114
|
-
needGas = true;
|
|
115
|
-
logInfo("No BNB for approve gas, will request BNB transfer.");
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (usdtNum < requiredUsdt) {
|
|
120
|
-
needTopup = true;
|
|
121
|
-
const shortfall = requiredUsdt - usdtNum;
|
|
122
|
-
const minTopup = Math.max(MIN_TOPUP_USDT, Math.ceil(shortfall));
|
|
123
|
-
logInfo(`USDT insufficient: have ${usdtNum}, need ${requiredUsdt}, shortfall ${shortfall.toFixed(6)} (top-up minimum: ${minTopup} USDT)`);
|
|
124
|
-
|
|
125
|
-
if (opts.topupAmount != null && String(opts.topupAmount).trim() !== "") {
|
|
126
|
-
const amt = Number(opts.topupAmount);
|
|
127
|
-
if (!Number.isFinite(amt) || amt <= 0) {
|
|
128
|
-
emitErr("create-image", "AMOUNT_INVALID", {
|
|
129
|
-
message: `Invalid --topup-amount: ${opts.topupAmount}`,
|
|
130
|
-
appId,
|
|
131
|
-
});
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (amt < minTopup) {
|
|
135
|
-
emitErr("create-image", "TOPUP_AMOUNT_TOO_SMALL", {
|
|
136
|
-
message: `--topup-amount ${amt} USDT is below the ${minTopup} USDT minimum for this call.`,
|
|
137
|
-
minTopup,
|
|
138
|
-
appId,
|
|
139
|
-
});
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
topupAmount = String(opts.topupAmount);
|
|
143
|
-
logInfo(`Using --topup-amount: ${topupAmount} USDT`);
|
|
144
|
-
} else if (process.stdin.isTTY) {
|
|
145
|
-
topupAmount = await promptTopupAmount(minTopup);
|
|
146
|
-
logInfo(`Selected top-up amount: ${topupAmount} USDT`);
|
|
147
|
-
} else {
|
|
148
|
-
const presets = TOPUP_PRESETS.filter((v) => v >= minTopup);
|
|
149
|
-
emitErr("create-image", "TOPUP_REQUIRED", {
|
|
150
|
-
message: `USDT balance is below the ${minTopup} USDT minimum for this call. Choose a top-up amount and rerun with --topup-amount <usdt>.`,
|
|
151
|
-
minTopup,
|
|
152
|
-
required: requiredUsdt,
|
|
153
|
-
currentBalance: balanceInitialUsdt,
|
|
154
|
-
address: sessionAddress,
|
|
155
|
-
appId,
|
|
156
|
-
presets,
|
|
157
|
-
hint: `Rerun: aigateway wallet-topup --amount <usdt> --app-id ${appId}`,
|
|
158
|
-
});
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
} catch (e) {
|
|
163
|
-
emitErr("create-image", "BALANCE_CHECK_FAILED", {
|
|
164
|
-
message: `Balance check failed: ${e.message}`,
|
|
165
|
-
appId,
|
|
166
|
-
});
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
if (needTopup || needGas) {
|
|
171
|
-
logInfo("Funding flow triggered...");
|
|
172
|
-
try {
|
|
173
|
-
await fundSessionKey({
|
|
174
|
-
sessionAddress,
|
|
175
|
-
usdtAmount: needTopup ? topupAmount : null,
|
|
176
|
-
needGas,
|
|
177
|
-
});
|
|
178
|
-
} catch (e) {
|
|
179
|
-
if (e instanceof WalletConnectError) {
|
|
180
|
-
emitErr("create-image", e.code, { message: e.message, address: sessionAddress, appId });
|
|
181
|
-
} else {
|
|
182
|
-
emitErr("create-image", "FUNDING_FAILED", { message: e.message, address: sessionAddress, appId });
|
|
183
|
-
}
|
|
184
|
-
return;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
logInfo("Re-checking wallet balance...");
|
|
188
|
-
try {
|
|
189
|
-
const { usdt, bnbRaw } = await getWalletBalance(privateKey);
|
|
190
|
-
balanceBeforeChargeUsdt = usdt;
|
|
191
|
-
const usdtNum = parseFloat(usdt);
|
|
192
|
-
if (needGas && bnbRaw === 0n) {
|
|
193
|
-
emitErr("create-image", "INSUFFICIENT_BNB", {
|
|
194
|
-
message: "No BNB for approve transaction after funding. Run 'aigateway wallet-gas' to add BNB manually.",
|
|
195
|
-
address: sessionAddress,
|
|
196
|
-
appId,
|
|
197
|
-
});
|
|
198
|
-
return;
|
|
199
|
-
}
|
|
200
|
-
if (usdtNum < requiredUsdt) {
|
|
201
|
-
emitErr("create-image", "INSUFFICIENT_USDT", {
|
|
202
|
-
message: "Still insufficient USDT after funding.",
|
|
203
|
-
required: `${requiredUsdt} USDT`,
|
|
204
|
-
available: `${usdt} USDT`,
|
|
205
|
-
address: sessionAddress,
|
|
206
|
-
appId,
|
|
207
|
-
});
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
} catch (e) {
|
|
211
|
-
emitErr("create-image", "BALANCE_CHECK_FAILED", {
|
|
212
|
-
message: `Balance re-check failed: ${e.message}`,
|
|
213
|
-
appId,
|
|
214
|
-
});
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const { client } = createX402Api(privateKey);
|
|
220
|
-
logInfo(`Submitting payment & request: ${url}`);
|
|
221
|
-
|
|
222
|
-
try {
|
|
223
|
-
const { x402HTTPClient } = await import("@aeon-ai-pay/core/client");
|
|
224
|
-
const httpClient = new x402HTTPClient(client);
|
|
225
|
-
|
|
226
|
-
const raw402 = paymentReq.raw402Response;
|
|
227
|
-
const getHeader = (name) => {
|
|
228
|
-
const value = raw402.headers[name] ?? raw402.headers[name.toLowerCase()];
|
|
229
|
-
return typeof value === "string" ? value : undefined;
|
|
230
|
-
};
|
|
231
|
-
const paymentRequired = httpClient.getPaymentRequiredResponse(getHeader, raw402.data);
|
|
232
|
-
const paymentPayload = await client.createPaymentPayload(paymentRequired);
|
|
233
|
-
const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload);
|
|
234
|
-
|
|
235
|
-
const response = await axios.get(url, {
|
|
236
|
-
headers: {
|
|
237
|
-
...paymentHeaders,
|
|
238
|
-
"Access-Control-Expose-Headers": "PAYMENT-RESPONSE",
|
|
239
|
-
},
|
|
240
|
-
});
|
|
241
|
-
const paymentResponse = decodePaymentResponse(response.headers);
|
|
242
|
-
|
|
243
|
-
const transaction = response.data?.transaction || paymentResponse?.txHash || null;
|
|
244
|
-
const images = Array.isArray(response.data?.data?.images) ? response.data.data.images : [];
|
|
245
|
-
|
|
246
|
-
const outputDir = opts.output || DEFAULT_IMAGE_DIR;
|
|
247
|
-
const downloaded = [];
|
|
248
|
-
if (images.length > 0) {
|
|
249
|
-
mkdirSync(outputDir, { recursive: true });
|
|
250
|
-
for (const img of images) {
|
|
251
|
-
const imgUrl = img?.url;
|
|
252
|
-
if (!imgUrl) continue;
|
|
253
|
-
try {
|
|
254
|
-
const localPath = await downloadImage(imgUrl, outputDir);
|
|
255
|
-
const meta = readImageMeta(localPath);
|
|
256
|
-
downloaded.push({
|
|
257
|
-
url: imgUrl,
|
|
258
|
-
localPath,
|
|
259
|
-
format: meta.format,
|
|
260
|
-
width: meta.width,
|
|
261
|
-
height: meta.height,
|
|
262
|
-
sizeBytes: meta.sizeBytes,
|
|
263
|
-
sizeHuman: meta.sizeHuman,
|
|
264
|
-
});
|
|
265
|
-
logInfo(`Saved: ${localPath} (${meta.format || "?"}, ${meta.width || "?"}×${meta.height || "?"}, ${meta.sizeHuman})`);
|
|
266
|
-
} catch (e) {
|
|
267
|
-
logInfo(`Failed to download ${imgUrl}: ${e.message}`);
|
|
268
|
-
downloaded.push({ url: imgUrl, error: e.message });
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
let balanceAfterUsdt = null;
|
|
274
|
-
try {
|
|
275
|
-
const after = await getWalletBalance(privateKey);
|
|
276
|
-
balanceAfterUsdt = after.usdt;
|
|
277
|
-
} catch (e) {
|
|
278
|
-
logInfo(`Post-payment balance check failed: ${e.message}`);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const result = {
|
|
282
|
-
appId,
|
|
283
|
-
prompt,
|
|
284
|
-
aspectRatio,
|
|
285
|
-
outputFormat,
|
|
286
|
-
model,
|
|
287
|
-
transaction,
|
|
288
|
-
images: downloaded,
|
|
289
|
-
balance: {
|
|
290
|
-
initial: balanceInitialUsdt,
|
|
291
|
-
before: balanceBeforeChargeUsdt,
|
|
292
|
-
after: balanceAfterUsdt,
|
|
293
|
-
charged: requiredUsdt,
|
|
294
|
-
topup: topupAmount,
|
|
295
|
-
},
|
|
296
|
-
data: response.data,
|
|
297
|
-
paymentResponse,
|
|
298
|
-
};
|
|
299
|
-
emitOk("create-image", result, { success: true, ...result });
|
|
300
|
-
} catch (error) {
|
|
301
|
-
emitErr("create-image", "PAYMENT_FAILED", {
|
|
302
|
-
message: error.message,
|
|
303
|
-
status: error.response?.status,
|
|
304
|
-
data: error.response?.data,
|
|
305
|
-
appId,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function readImageMeta(filePath) {
|
|
311
|
-
const sizeBytes = statSync(filePath).size;
|
|
312
|
-
const sizeHuman = humanSize(sizeBytes);
|
|
313
|
-
let format = null, width = null, height = null;
|
|
314
|
-
const fd = openSync(filePath, "r");
|
|
315
|
-
try {
|
|
316
|
-
const buf = Buffer.alloc(64 * 1024);
|
|
317
|
-
const len = readSync(fd, buf, 0, buf.length, 0);
|
|
318
|
-
if (len >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) {
|
|
319
|
-
format = "png";
|
|
320
|
-
width = buf.readUInt32BE(16);
|
|
321
|
-
height = buf.readUInt32BE(20);
|
|
322
|
-
} else if (len >= 4 && buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF) {
|
|
323
|
-
format = "jpeg";
|
|
324
|
-
let i = 2;
|
|
325
|
-
while (i + 9 < len) {
|
|
326
|
-
if (buf[i] !== 0xFF) { i++; continue; }
|
|
327
|
-
while (i < len && buf[i] === 0xFF) i++;
|
|
328
|
-
const marker = buf[i];
|
|
329
|
-
i++;
|
|
330
|
-
if (marker === 0xD8 || marker === 0xD9) continue;
|
|
331
|
-
const segLen = buf.readUInt16BE(i);
|
|
332
|
-
if (marker >= 0xC0 && marker <= 0xCF && marker !== 0xC4 && marker !== 0xC8 && marker !== 0xCC) {
|
|
333
|
-
height = buf.readUInt16BE(i + 3);
|
|
334
|
-
width = buf.readUInt16BE(i + 5);
|
|
335
|
-
break;
|
|
336
|
-
}
|
|
337
|
-
i += segLen;
|
|
338
|
-
}
|
|
339
|
-
} else if (len >= 30 && buf.slice(0, 4).toString("ascii") === "RIFF" && buf.slice(8, 12).toString("ascii") === "WEBP") {
|
|
340
|
-
format = "webp";
|
|
341
|
-
const fourCC = buf.slice(12, 16).toString("ascii");
|
|
342
|
-
if (fourCC === "VP8 ") {
|
|
343
|
-
width = buf.readUInt16LE(26) & 0x3FFF;
|
|
344
|
-
height = buf.readUInt16LE(28) & 0x3FFF;
|
|
345
|
-
} else if (fourCC === "VP8L") {
|
|
346
|
-
const b0 = buf[21], b1 = buf[22], b2 = buf[23], b3 = buf[24];
|
|
347
|
-
width = ((b1 & 0x3F) << 8 | b0) + 1;
|
|
348
|
-
height = ((b3 & 0x0F) << 10 | b2 << 2 | (b1 & 0xC0) >> 6) + 1;
|
|
349
|
-
} else if (fourCC === "VP8X") {
|
|
350
|
-
width = (buf[24] | (buf[25] << 8) | (buf[26] << 16)) + 1;
|
|
351
|
-
height = (buf[27] | (buf[28] << 8) | (buf[29] << 16)) + 1;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
} catch {
|
|
355
|
-
// leave null on parse failure
|
|
356
|
-
} finally {
|
|
357
|
-
closeSync(fd);
|
|
358
|
-
}
|
|
359
|
-
return { format, width, height, sizeBytes, sizeHuman };
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
function humanSize(bytes) {
|
|
363
|
-
if (!Number.isFinite(bytes)) return "?";
|
|
364
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
365
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
366
|
-
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
367
|
-
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function downloadImage(imgUrl, outputDir, { maxRedirects = 5, timeoutMs = 60_000 } = {}) {
|
|
371
|
-
let filename;
|
|
372
|
-
try {
|
|
373
|
-
filename = basename(new URL(imgUrl).pathname) || `image-${Date.now()}.png`;
|
|
374
|
-
} catch {
|
|
375
|
-
filename = `image-${Date.now()}.png`;
|
|
376
|
-
}
|
|
377
|
-
if (!extname(filename)) filename += ".png";
|
|
378
|
-
|
|
379
|
-
let target = join(outputDir, filename);
|
|
380
|
-
if (existsSync(target)) {
|
|
381
|
-
const ext = extname(filename);
|
|
382
|
-
const stem = filename.slice(0, filename.length - ext.length);
|
|
383
|
-
let i = 1;
|
|
384
|
-
while (existsSync(join(outputDir, `${stem}-${i}${ext}`))) i++;
|
|
385
|
-
target = join(outputDir, `${stem}-${i}${ext}`);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
return new Promise((resolve, reject) => {
|
|
389
|
-
const fetchOnce = (currentUrl, redirectsLeft) => {
|
|
390
|
-
let parsed;
|
|
391
|
-
try {
|
|
392
|
-
parsed = new URL(currentUrl);
|
|
393
|
-
} catch (e) {
|
|
394
|
-
return reject(new Error(`Invalid URL: ${currentUrl}`));
|
|
395
|
-
}
|
|
396
|
-
const httpModule = parsed.protocol === "http:" ? httpGet : httpsGet;
|
|
397
|
-
const req = httpModule(currentUrl, { timeout: timeoutMs }, (res) => {
|
|
398
|
-
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
399
|
-
res.resume();
|
|
400
|
-
if (redirectsLeft <= 0) return reject(new Error("Too many redirects"));
|
|
401
|
-
const nextUrl = new URL(res.headers.location, currentUrl).toString();
|
|
402
|
-
return fetchOnce(nextUrl, redirectsLeft - 1);
|
|
403
|
-
}
|
|
404
|
-
if (res.statusCode !== 200) {
|
|
405
|
-
res.resume();
|
|
406
|
-
return reject(new Error(`HTTP ${res.statusCode} from ${currentUrl}`));
|
|
407
|
-
}
|
|
408
|
-
const file = createWriteStream(target);
|
|
409
|
-
res.pipe(file);
|
|
410
|
-
file.on("finish", () => file.close(() => resolve(target)));
|
|
411
|
-
file.on("error", (err) => {
|
|
412
|
-
try { unlinkSync(target); } catch {}
|
|
413
|
-
reject(err);
|
|
414
|
-
});
|
|
415
|
-
res.on("error", (err) => {
|
|
416
|
-
file.destroy();
|
|
417
|
-
try { unlinkSync(target); } catch {}
|
|
418
|
-
reject(err);
|
|
419
|
-
});
|
|
420
|
-
});
|
|
421
|
-
req.on("error", reject);
|
|
422
|
-
req.on("timeout", () => {
|
|
423
|
-
req.destroy(new Error(`Download timed out after ${timeoutMs}ms`));
|
|
424
|
-
});
|
|
425
|
-
};
|
|
426
|
-
fetchOnce(imgUrl, maxRedirects);
|
|
427
|
-
});
|
|
428
|
-
}
|
package/src/sanitize.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Card output sanitisation: redact sensitive card data (truncate the full PAN to its last 4 digits, drop CVV, drop expiry).
|
|
3
|
-
* The CLI emits JSON for an agent to parse; the agent then renders the product-specific template to the user.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
// Fields whose value should be replaced with the last-4 representation
|
|
7
|
-
const CARD_NUMBER_KEYS = new Set([
|
|
8
|
-
"cardnumber", "cardno",
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
// Fields that must be removed entirely
|
|
12
|
-
const REMOVE_KEYS = new Set([
|
|
13
|
-
"cvv", "cvv2", "cvc", "cvc2", "securitycode",
|
|
14
|
-
"expiry", "expirydate", "expiredate", "cardexpiry",
|
|
15
|
-
"expirationdate", "validthru",
|
|
16
|
-
]);
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Recursively sanitise an object:
|
|
20
|
-
* - cardNumber / cardNo → keep only the last four digits ("•••• 3398")
|
|
21
|
-
* - cvv / securityCode → drop
|
|
22
|
-
* - expiry / expireDate → drop
|
|
23
|
-
*/
|
|
24
|
-
export function sanitizeOutput(obj) {
|
|
25
|
-
if (obj === null || obj === undefined) return obj;
|
|
26
|
-
if (Array.isArray(obj)) return obj.map(sanitizeOutput);
|
|
27
|
-
if (typeof obj !== "object") return obj;
|
|
28
|
-
|
|
29
|
-
const result = {};
|
|
30
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
31
|
-
const normalized = key.toLowerCase().replace(/[-_]/g, "");
|
|
32
|
-
|
|
33
|
-
// Drop sensitive fields (CVV, expiry, etc.)
|
|
34
|
-
if (REMOVE_KEYS.has(normalized)) continue;
|
|
35
|
-
|
|
36
|
-
// Card number: only keep the last 4 digits
|
|
37
|
-
if (CARD_NUMBER_KEYS.has(normalized)) {
|
|
38
|
-
if (typeof value === "string" && value.length >= 4) {
|
|
39
|
-
result[key] = "•••• " + value.slice(-4);
|
|
40
|
-
}
|
|
41
|
-
// when value is null, do not emit this field
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
result[key] = sanitizeOutput(value);
|
|
46
|
-
}
|
|
47
|
-
return result;
|
|
48
|
-
}
|