poe-code 4.0.39 → 4.0.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poe-code",
3
- "version": "4.0.39",
3
+ "version": "4.0.40",
4
4
  "description": "CLI tool to configure Poe API for developer workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -550,7 +550,12 @@ function collectRequestBodyParams(document, operation, operationId, method) {
550
550
  ? Object.entries(schema.properties)
551
551
  .filter(([, property]) => {
552
552
  const resolved = resolveBodySchema(document, property, operationId, "requestBody multipart field");
553
- return resolved.type === "string" && resolved.format === "binary";
553
+ if (resolved.type === "string" && resolved.format === "binary")
554
+ return true;
555
+ if (resolved.type !== "array" || resolved.items === undefined)
556
+ return false;
557
+ const items = resolveBodySchema(document, resolved.items, operationId, "requestBody multipart array item");
558
+ return items.type === "string" && items.format === "binary";
554
559
  })
555
560
  .map(([name]) => name)
556
561
  : undefined;
@@ -579,10 +584,18 @@ function collectRequestBodyParams(document, operation, operationId, method) {
579
584
  const assemblies = [];
580
585
  const declaredPropertyCount = Object.keys(schema.properties).length;
581
586
  for (const [name, property] of Object.entries(schema.properties)) {
582
- const propertySchema = resolveBodySchema(document, property, operationId, `requestBody.properties.${name}`);
583
- if (propertySchema.readOnly === true) {
587
+ const resolvedPropertySchema = resolveBodySchema(document, property, operationId, `requestBody.properties.${name}`);
588
+ if (resolvedPropertySchema.readOnly === true) {
584
589
  continue;
585
590
  }
591
+ const propertySchema = multipartBinaryFields?.includes(name) === true
592
+ ? {
593
+ ...resolvedPropertySchema,
594
+ description: resolvedPropertySchema.description === undefined
595
+ ? "Local path, HTTP(S) URL, or base64 value."
596
+ : `${resolvedPropertySchema.description} Local path, HTTP(S) URL, or base64 value.`
597
+ }
598
+ : resolvedPropertySchema;
586
599
  const generated = createBodyField(document, name, propertySchema, bodyOptional || !required.has(name), operationId);
587
600
  assemblies.push(generated);
588
601
  }
@@ -1564,6 +1577,7 @@ function createCommandFile(options) {
1564
1577
  lines.push(` multipartBinaryFields: ${JSON.stringify(options.multipartBinaryFields)},`);
1565
1578
  lines.push(" fs,");
1566
1579
  lines.push(" env,");
1580
+ lines.push(" fetch,");
1567
1581
  lines.push(" });");
1568
1582
  }
1569
1583
  else {
@@ -1627,6 +1641,9 @@ function createCommandFile(options) {
1627
1641
  if (usesBinaryOutput) {
1628
1642
  lines.push(" return writeBinaryResponseOutput(result, params.output, { fs, env });");
1629
1643
  }
1644
+ else if (usesRequestShapeVariable) {
1645
+ lines.push(" return result;");
1646
+ }
1630
1647
  lines.push(" },");
1631
1648
  lines.push("});");
1632
1649
  lines.push("");
@@ -56,6 +56,8 @@ export declare function prepareMultipartFileInputs(requestShape: RequestShape, o
56
56
  multipartBinaryFields?: readonly string[];
57
57
  fs?: RuntimeFileSystem;
58
58
  env?: RuntimeEnvironment;
59
+ fetch?: typeof globalThis.fetch;
60
+ signal?: AbortSignal;
59
61
  }): Promise<RequestShape>;
60
62
  export declare function writeBinaryResponseOutput(result: unknown, outputPath: unknown, runtime: {
61
63
  fs?: RuntimeFileSystem;
@@ -1,4 +1,5 @@
1
1
  import path from "node:path";
2
+ import { isIP } from "node:net";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { text as designText } from "toolcraft-design";
4
5
  import { HttpError, UserError, createHttpError, shouldEmitDiagnostic } from "toolcraft";
@@ -6,6 +7,10 @@ import { classifyNetworkError } from "./network-error.js";
6
7
  import { redactHeaders, redactHeaderValue, redactSensitiveQueryValues } from "./redaction.js";
7
8
  export { HttpError };
8
9
  const TRANSCRIPT_BODY_BYTE_LIMIT = 4 * 1024;
10
+ const MULTIPART_FILE_BYTE_LIMIT = 100 * 1024 * 1024;
11
+ const MULTIPART_REQUEST_BYTE_LIMIT = 250 * 1024 * 1024;
12
+ const MULTIPART_DOWNLOAD_TIMEOUT_MS = 30_000;
13
+ const MULTIPART_REDIRECT_LIMIT = 5;
9
14
  export async function requestJson(options) {
10
15
  const token = options.auth === "none" ? undefined : await options.tokenSource.getToken();
11
16
  const method = options.method.toUpperCase();
@@ -214,25 +219,174 @@ export async function prepareMultipartFileInputs(requestShape, options) {
214
219
  return requestShape;
215
220
  }
216
221
  const body = { ...requestShape.body };
222
+ const runtime = {
223
+ field: "",
224
+ fs: options.fs,
225
+ env: options.env,
226
+ fetch: options.fetch ?? globalThis.fetch,
227
+ signal: options.signal,
228
+ totalBytes: 0
229
+ };
217
230
  for (const field of options.multipartBinaryFields) {
218
231
  const value = body[field];
219
- if (typeof value !== "string") {
220
- continue;
221
- }
222
- const filePath = resolveUserPath(value, options.env);
223
- if (!(await options.fs.exists(filePath))) {
232
+ const sources = Array.isArray(value) ? value : [value];
233
+ if (sources.some((source) => typeof source !== "string")) {
224
234
  continue;
225
235
  }
226
- body[field] = {
227
- data: await options.fs.readFile(filePath, "base64"),
228
- filename: path.basename(filePath) || field
229
- };
236
+ runtime.field = field;
237
+ const resolved = await Promise.all(sources.map((source) => resolveMultipartSource(source, runtime)));
238
+ body[field] = Array.isArray(value) ? resolved : resolved[0];
230
239
  }
231
240
  return {
232
241
  ...requestShape,
233
242
  body
234
243
  };
235
244
  }
245
+ async function resolveMultipartSource(source, runtime) {
246
+ if (URL.canParse(source) && source.includes(":")) {
247
+ const sourceUrl = new URL(source);
248
+ validateMultipartUrl(sourceUrl, runtime.field);
249
+ return downloadMultipartSource(sourceUrl, runtime);
250
+ }
251
+ const filePath = resolveUserPath(source, runtime.env);
252
+ if (await runtime.fs.exists(filePath)) {
253
+ const data = await runtime.fs.readFile(filePath, "base64");
254
+ accountMultipartBytes(Buffer.byteLength(data, "base64"), source, runtime);
255
+ return {
256
+ data,
257
+ filename: path.basename(filePath) || runtime.field,
258
+ contentType: inferContentType(filePath)
259
+ };
260
+ }
261
+ if (isValidBase64(source))
262
+ return source;
263
+ throw new UserError(`Multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(source)} does not exist.`);
264
+ }
265
+ async function downloadMultipartSource(initialUrl, runtime) {
266
+ let url = initialUrl;
267
+ const timeoutSignal = AbortSignal.timeout(MULTIPART_DOWNLOAD_TIMEOUT_MS);
268
+ const signal = runtime.signal === undefined
269
+ ? timeoutSignal
270
+ : AbortSignal.any([runtime.signal, timeoutSignal]);
271
+ for (let redirects = 0; redirects <= MULTIPART_REDIRECT_LIMIT; redirects += 1) {
272
+ validateMultipartUrl(url, runtime.field);
273
+ let response;
274
+ try {
275
+ response = await runtime.fetch(url, { redirect: "manual", signal });
276
+ }
277
+ catch {
278
+ throw new UserError(`Could not download multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(redactMultipartUrl(url))}.`);
279
+ }
280
+ if (response.status >= 300 && response.status < 400) {
281
+ const location = response.headers.get("location");
282
+ if (location === null || redirects === MULTIPART_REDIRECT_LIMIT) {
283
+ throw new UserError(`Multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(redactMultipartUrl(initialUrl))} exceeded the redirect limit.`);
284
+ }
285
+ url = new URL(location, url);
286
+ continue;
287
+ }
288
+ if (!response.ok) {
289
+ throw new UserError(`Multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(redactMultipartUrl(url))} returned HTTP ${response.status}.`);
290
+ }
291
+ const declaredLength = Number(response.headers.get("content-length"));
292
+ if (Number.isFinite(declaredLength) && declaredLength > MULTIPART_FILE_BYTE_LIMIT) {
293
+ throw new UserError(`Multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(redactMultipartUrl(url))} exceeds the 100 MiB file limit.`);
294
+ }
295
+ const bytes = await readMultipartResponse(response, url, runtime);
296
+ accountMultipartBytes(bytes.byteLength, redactMultipartUrl(url), runtime);
297
+ return {
298
+ data: Buffer.from(bytes).toString("base64"),
299
+ filename: selectRemoteFilename(response, url, runtime.field),
300
+ contentType: normalizeContentType(response.headers.get("content-type"))
301
+ };
302
+ }
303
+ throw new UserError("Unexpected multipart redirect state.");
304
+ }
305
+ async function readMultipartResponse(response, url, runtime) {
306
+ if (response.body === null)
307
+ return new Uint8Array();
308
+ const reader = response.body.getReader();
309
+ const chunks = [];
310
+ let byteLength = 0;
311
+ while (true) {
312
+ const { done, value } = await reader.read();
313
+ if (done)
314
+ break;
315
+ byteLength += value.byteLength;
316
+ if (byteLength > MULTIPART_FILE_BYTE_LIMIT) {
317
+ await reader.cancel();
318
+ throw new UserError(`Multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(redactMultipartUrl(url))} exceeds the 100 MiB file limit.`);
319
+ }
320
+ chunks.push(value);
321
+ }
322
+ const result = new Uint8Array(byteLength);
323
+ let offset = 0;
324
+ for (const chunk of chunks) {
325
+ result.set(chunk, offset);
326
+ offset += chunk.byteLength;
327
+ }
328
+ return result;
329
+ }
330
+ function validateMultipartUrl(url, field) {
331
+ if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) {
332
+ throw new UserError(`Multipart field ${JSON.stringify(field)} uses a disallowed URL.`);
333
+ }
334
+ const hostname = url.hostname.toLowerCase();
335
+ if (hostname === "localhost" || hostname.endsWith(".localhost") || isPrivateIpLiteral(hostname)) {
336
+ throw new UserError(`Multipart field ${JSON.stringify(field)} uses a private network URL.`);
337
+ }
338
+ }
339
+ function isPrivateIpLiteral(hostname) {
340
+ const normalized = hostname.startsWith("[") && hostname.endsWith("]")
341
+ ? hostname.slice(1, -1)
342
+ : hostname;
343
+ const version = isIP(normalized);
344
+ if (version === 4) {
345
+ const octets = normalized.split(".").map(Number);
346
+ return octets[0] === 10 || octets[0] === 127 || octets[0] === 0 ||
347
+ (octets[0] === 169 && octets[1] === 254) ||
348
+ (octets[0] === 172 && (octets[1] ?? 0) >= 16 && (octets[1] ?? 0) <= 31) ||
349
+ (octets[0] === 192 && octets[1] === 168);
350
+ }
351
+ return version === 6 && (normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd"));
352
+ }
353
+ function accountMultipartBytes(bytes, source, runtime) {
354
+ if (bytes > MULTIPART_FILE_BYTE_LIMIT) {
355
+ throw new UserError(`Multipart field ${JSON.stringify(runtime.field)} source ${JSON.stringify(source)} exceeds the 100 MiB file limit.`);
356
+ }
357
+ runtime.totalBytes += bytes;
358
+ if (runtime.totalBytes > MULTIPART_REQUEST_BYTE_LIMIT) {
359
+ throw new UserError("Multipart file inputs exceed the 250 MiB request limit.");
360
+ }
361
+ }
362
+ function selectRemoteFilename(response, url, fallback) {
363
+ const disposition = response.headers.get("content-disposition");
364
+ const candidate = disposition?.split(";").map((part) => part.trim()).find((part) => part.toLowerCase().startsWith("filename="))?.slice("filename=".length).replaceAll('"', "");
365
+ return sanitizeFilename(candidate ?? path.posix.basename(url.pathname) ?? fallback, fallback);
366
+ }
367
+ function sanitizeFilename(value, fallback) {
368
+ const basename = path.basename(value.replaceAll("\\", "/"));
369
+ const cleaned = Array.from(basename).filter((character) => character >= " " && character !== "\u007f").join("").trim();
370
+ return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
371
+ }
372
+ function normalizeContentType(value) {
373
+ const mediaType = value?.split(";", 1)[0]?.trim().toLowerCase();
374
+ return mediaType?.includes("/") === true ? mediaType : "application/octet-stream";
375
+ }
376
+ function inferContentType(filePath) {
377
+ const extension = path.extname(filePath).toLowerCase();
378
+ return {
379
+ ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".gif": "image/gif",
380
+ ".webp": "image/webp", ".pdf": "application/pdf", ".txt": "text/plain", ".json": "application/json",
381
+ ".wav": "audio/wav", ".mp3": "audio/mpeg", ".mp4": "video/mp4"
382
+ }[extension] ?? "application/octet-stream";
383
+ }
384
+ function redactMultipartUrl(url) {
385
+ const redacted = new URL(url);
386
+ redacted.username = "";
387
+ redacted.password = "";
388
+ return redactSensitiveQueryValues(redacted.toString());
389
+ }
236
390
  export async function writeBinaryResponseOutput(result, outputPath, runtime) {
237
391
  if (outputPath === undefined) {
238
392
  return result;
@@ -375,14 +529,16 @@ function decodeMultipartBinaryValue(value, fallbackFilename) {
375
529
  if (typeof value === "string") {
376
530
  return {
377
531
  data: value,
378
- filename: fallbackFilename
532
+ filename: fallbackFilename,
533
+ contentType: "application/octet-stream"
379
534
  };
380
535
  }
381
536
  if (value !== null &&
382
537
  typeof value === "object" &&
383
538
  !Array.isArray(value) &&
384
539
  typeof value.data === "string" &&
385
- typeof value.filename === "string") {
540
+ typeof value.filename === "string" &&
541
+ typeof value.contentType === "string") {
386
542
  return value;
387
543
  }
388
544
  throw new UserError("Multipart binary request fields must be base64 strings or resolved file inputs.");
@@ -397,8 +553,11 @@ function serializeMultipartBody(body, binaryFields = []) {
397
553
  if (value === undefined)
398
554
  continue;
399
555
  if (binary.has(key)) {
400
- const file = decodeMultipartBinaryValue(value, key);
401
- form.append(key, new Blob([decodeBase64Body(file.data)]), file.filename);
556
+ const values = Array.isArray(value) ? value : [value];
557
+ for (const item of values) {
558
+ const file = decodeMultipartBinaryValue(item, key);
559
+ form.append(key, new Blob([decodeBase64Body(file.data)], { type: file.contentType }), file.filename);
560
+ }
402
561
  }
403
562
  else if (Array.isArray(value)) {
404
563
  for (const item of value)
@@ -134,7 +134,8 @@ function createRuntimeHandler(command) {
134
134
  bodyMode: command.bodyMode,
135
135
  multipartBinaryFields: command.multipartBinaryFields,
136
136
  fs,
137
- env
137
+ env,
138
+ fetch
138
139
  });
139
140
  const result = await requestJson({
140
141
  baseUrl: command.baseUrl ?? baseUrl,