@copilotkit/aimock 1.42.1 → 1.43.0

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.
Files changed (338) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +50 -0
  4. package/README.md +102 -16
  5. package/dist/agui-mock.cjs +1 -1
  6. package/dist/agui-mock.cjs.map +1 -1
  7. package/dist/agui-mock.d.cts.map +1 -1
  8. package/dist/agui-mock.d.ts.map +1 -1
  9. package/dist/agui-mock.js +1 -1
  10. package/dist/agui-mock.js.map +1 -1
  11. package/dist/agui-recorder.cjs +33 -11
  12. package/dist/agui-recorder.cjs.map +1 -1
  13. package/dist/agui-recorder.d.cts +10 -1
  14. package/dist/agui-recorder.d.cts.map +1 -1
  15. package/dist/agui-recorder.d.ts +10 -1
  16. package/dist/agui-recorder.d.ts.map +1 -1
  17. package/dist/agui-recorder.js +33 -11
  18. package/dist/agui-recorder.js.map +1 -1
  19. package/dist/agui-types.d.cts +1 -0
  20. package/dist/agui-types.d.cts.map +1 -1
  21. package/dist/agui-types.d.ts +1 -0
  22. package/dist/agui-types.d.ts.map +1 -1
  23. package/dist/aimock-cli.cjs +38 -10
  24. package/dist/aimock-cli.cjs.map +1 -1
  25. package/dist/aimock-cli.d.cts +5 -0
  26. package/dist/aimock-cli.d.cts.map +1 -1
  27. package/dist/aimock-cli.d.ts +5 -0
  28. package/dist/aimock-cli.d.ts.map +1 -1
  29. package/dist/aimock-cli.js +38 -10
  30. package/dist/aimock-cli.js.map +1 -1
  31. package/dist/batches.cjs +458 -0
  32. package/dist/batches.cjs.map +1 -0
  33. package/dist/batches.d.cts +57 -0
  34. package/dist/batches.d.cts.map +1 -0
  35. package/dist/batches.d.ts +57 -0
  36. package/dist/batches.d.ts.map +1 -0
  37. package/dist/batches.js +453 -0
  38. package/dist/batches.js.map +1 -0
  39. package/dist/bedrock-converse.cjs +12 -8
  40. package/dist/bedrock-converse.cjs.map +1 -1
  41. package/dist/bedrock-converse.d.cts.map +1 -1
  42. package/dist/bedrock-converse.d.ts.map +1 -1
  43. package/dist/bedrock-converse.js +14 -10
  44. package/dist/bedrock-converse.js.map +1 -1
  45. package/dist/bedrock.cjs +10 -8
  46. package/dist/bedrock.cjs.map +1 -1
  47. package/dist/bedrock.d.cts.map +1 -1
  48. package/dist/bedrock.d.ts.map +1 -1
  49. package/dist/bedrock.js +12 -10
  50. package/dist/bedrock.js.map +1 -1
  51. package/dist/byteplus-video.cjs +3 -3
  52. package/dist/byteplus-video.cjs.map +1 -1
  53. package/dist/byteplus-video.js +4 -4
  54. package/dist/byteplus-video.js.map +1 -1
  55. package/dist/chaos.cjs +622 -72
  56. package/dist/chaos.cjs.map +1 -1
  57. package/dist/chaos.d.cts +164 -7
  58. package/dist/chaos.d.cts.map +1 -1
  59. package/dist/chaos.d.ts +164 -7
  60. package/dist/chaos.d.ts.map +1 -1
  61. package/dist/chaos.js +613 -74
  62. package/dist/chaos.js.map +1 -1
  63. package/dist/cli.cjs +42 -23
  64. package/dist/cli.cjs.map +1 -1
  65. package/dist/cli.js +42 -23
  66. package/dist/cli.js.map +1 -1
  67. package/dist/cohere.cjs +10 -8
  68. package/dist/cohere.cjs.map +1 -1
  69. package/dist/cohere.d.cts.map +1 -1
  70. package/dist/cohere.d.ts.map +1 -1
  71. package/dist/cohere.js +12 -10
  72. package/dist/cohere.js.map +1 -1
  73. package/dist/config-loader.d.ts.map +1 -1
  74. package/dist/convert.cjs +1 -1
  75. package/dist/convert.cjs.map +1 -1
  76. package/dist/convert.js +1 -1
  77. package/dist/convert.js.map +1 -1
  78. package/dist/elevenlabs-audio.cjs +3 -3
  79. package/dist/elevenlabs-audio.cjs.map +1 -1
  80. package/dist/elevenlabs-audio.js +4 -4
  81. package/dist/elevenlabs-audio.js.map +1 -1
  82. package/dist/elevenlabs-voice.cjs +1053 -0
  83. package/dist/elevenlabs-voice.cjs.map +1 -0
  84. package/dist/elevenlabs-voice.d.cts +72 -0
  85. package/dist/elevenlabs-voice.d.cts.map +1 -0
  86. package/dist/elevenlabs-voice.d.ts +72 -0
  87. package/dist/elevenlabs-voice.d.ts.map +1 -0
  88. package/dist/elevenlabs-voice.js +1048 -0
  89. package/dist/elevenlabs-voice.js.map +1 -0
  90. package/dist/embeddings.cjs +8 -4
  91. package/dist/embeddings.cjs.map +1 -1
  92. package/dist/embeddings.d.cts.map +1 -1
  93. package/dist/embeddings.d.ts.map +1 -1
  94. package/dist/embeddings.js +9 -5
  95. package/dist/embeddings.js.map +1 -1
  96. package/dist/fal-audio.cjs +3 -3
  97. package/dist/fal-audio.cjs.map +1 -1
  98. package/dist/fal-audio.js +4 -4
  99. package/dist/fal-audio.js.map +1 -1
  100. package/dist/fal.cjs +28 -4
  101. package/dist/fal.cjs.map +1 -1
  102. package/dist/fal.d.cts +16 -1
  103. package/dist/fal.d.cts.map +1 -1
  104. package/dist/fal.d.ts +16 -1
  105. package/dist/fal.d.ts.map +1 -1
  106. package/dist/fal.js +28 -5
  107. package/dist/fal.js.map +1 -1
  108. package/dist/files.cjs +1071 -0
  109. package/dist/files.cjs.map +1 -0
  110. package/dist/files.d.cts +68 -0
  111. package/dist/files.d.cts.map +1 -0
  112. package/dist/files.d.ts +68 -0
  113. package/dist/files.d.ts.map +1 -0
  114. package/dist/files.js +1059 -0
  115. package/dist/files.js.map +1 -0
  116. package/dist/fine-tuning.cjs +1051 -0
  117. package/dist/fine-tuning.cjs.map +1 -0
  118. package/dist/fine-tuning.d.cts +96 -0
  119. package/dist/fine-tuning.d.cts.map +1 -0
  120. package/dist/fine-tuning.d.ts +96 -0
  121. package/dist/fine-tuning.d.ts.map +1 -0
  122. package/dist/fine-tuning.js +1044 -0
  123. package/dist/fine-tuning.js.map +1 -0
  124. package/dist/fixture-loader.cjs +0 -0
  125. package/dist/fixture-loader.cjs.map +1 -1
  126. package/dist/fixture-loader.d.cts +37 -5
  127. package/dist/fixture-loader.d.cts.map +1 -1
  128. package/dist/fixture-loader.d.ts +37 -5
  129. package/dist/fixture-loader.d.ts.map +1 -1
  130. package/dist/fixture-loader.js +0 -0
  131. package/dist/fixture-loader.js.map +1 -1
  132. package/dist/gemini-embeddings.cjs +2 -2
  133. package/dist/gemini-embeddings.cjs.map +1 -1
  134. package/dist/gemini-embeddings.js +3 -3
  135. package/dist/gemini-embeddings.js.map +1 -1
  136. package/dist/gemini-interactions.cjs +13 -7
  137. package/dist/gemini-interactions.cjs.map +1 -1
  138. package/dist/gemini-interactions.d.cts.map +1 -1
  139. package/dist/gemini-interactions.d.ts.map +1 -1
  140. package/dist/gemini-interactions.js +15 -9
  141. package/dist/gemini-interactions.js.map +1 -1
  142. package/dist/gemini.cjs +11 -7
  143. package/dist/gemini.cjs.map +1 -1
  144. package/dist/gemini.d.cts.map +1 -1
  145. package/dist/gemini.d.ts.map +1 -1
  146. package/dist/gemini.js +13 -9
  147. package/dist/gemini.js.map +1 -1
  148. package/dist/grok-video.cjs +14 -14
  149. package/dist/grok-video.cjs.map +1 -1
  150. package/dist/grok-video.js +15 -15
  151. package/dist/grok-video.js.map +1 -1
  152. package/dist/helpers.cjs +221 -5
  153. package/dist/helpers.cjs.map +1 -1
  154. package/dist/helpers.d.cts +38 -2
  155. package/dist/helpers.d.cts.map +1 -1
  156. package/dist/helpers.d.ts +38 -2
  157. package/dist/helpers.d.ts.map +1 -1
  158. package/dist/helpers.js +213 -6
  159. package/dist/helpers.js.map +1 -1
  160. package/dist/images.cjs +54 -123
  161. package/dist/images.cjs.map +1 -1
  162. package/dist/images.d.cts.map +1 -1
  163. package/dist/images.d.ts.map +1 -1
  164. package/dist/images.js +55 -124
  165. package/dist/images.js.map +1 -1
  166. package/dist/index.cjs +41 -8
  167. package/dist/index.d.cts +12 -7
  168. package/dist/index.d.ts +12 -7
  169. package/dist/index.js +14 -10
  170. package/dist/jest.cjs +14 -7
  171. package/dist/jest.cjs.map +1 -1
  172. package/dist/jest.d.cts +3 -2
  173. package/dist/jest.d.cts.map +1 -1
  174. package/dist/jest.d.ts +3 -2
  175. package/dist/jest.d.ts.map +1 -1
  176. package/dist/jest.js +14 -7
  177. package/dist/jest.js.map +1 -1
  178. package/dist/journal.cjs +41 -4
  179. package/dist/journal.cjs.map +1 -1
  180. package/dist/journal.d.cts +44 -3
  181. package/dist/journal.d.cts.map +1 -1
  182. package/dist/journal.d.ts +44 -3
  183. package/dist/journal.d.ts.map +1 -1
  184. package/dist/journal.js +41 -5
  185. package/dist/journal.js.map +1 -1
  186. package/dist/live-fixture.cjs +1192 -0
  187. package/dist/live-fixture.cjs.map +1 -0
  188. package/dist/live-fixture.js +1187 -0
  189. package/dist/live-fixture.js.map +1 -0
  190. package/dist/live-recorder.cjs +256 -0
  191. package/dist/live-recorder.cjs.map +1 -0
  192. package/dist/live-recorder.js +254 -0
  193. package/dist/live-recorder.js.map +1 -0
  194. package/dist/live-replay.cjs +286 -0
  195. package/dist/live-replay.cjs.map +1 -0
  196. package/dist/live-replay.js +285 -0
  197. package/dist/live-replay.js.map +1 -0
  198. package/dist/live-sanitize.cjs +196 -0
  199. package/dist/live-sanitize.cjs.map +1 -0
  200. package/dist/live-sanitize.js +196 -0
  201. package/dist/live-sanitize.js.map +1 -0
  202. package/dist/live-types.d.cts +71 -0
  203. package/dist/live-types.d.cts.map +1 -0
  204. package/dist/live-types.d.ts +71 -0
  205. package/dist/live-types.d.ts.map +1 -0
  206. package/dist/llmock.cjs +52 -41
  207. package/dist/llmock.cjs.map +1 -1
  208. package/dist/llmock.d.cts +16 -5
  209. package/dist/llmock.d.cts.map +1 -1
  210. package/dist/llmock.d.ts +16 -5
  211. package/dist/llmock.d.ts.map +1 -1
  212. package/dist/llmock.js +53 -42
  213. package/dist/llmock.js.map +1 -1
  214. package/dist/messages.cjs +11 -7
  215. package/dist/messages.cjs.map +1 -1
  216. package/dist/messages.d.cts.map +1 -1
  217. package/dist/messages.d.ts.map +1 -1
  218. package/dist/messages.js +13 -9
  219. package/dist/messages.js.map +1 -1
  220. package/dist/metrics.cjs +258 -5
  221. package/dist/metrics.cjs.map +1 -1
  222. package/dist/metrics.d.cts +3 -1
  223. package/dist/metrics.d.cts.map +1 -1
  224. package/dist/metrics.d.ts +3 -1
  225. package/dist/metrics.d.ts.map +1 -1
  226. package/dist/metrics.js +255 -6
  227. package/dist/metrics.js.map +1 -1
  228. package/dist/moderation.cjs +10 -1
  229. package/dist/moderation.cjs.map +1 -1
  230. package/dist/moderation.d.cts.map +1 -1
  231. package/dist/moderation.d.ts.map +1 -1
  232. package/dist/moderation.js +10 -1
  233. package/dist/moderation.js.map +1 -1
  234. package/dist/ollama.cjs +5 -5
  235. package/dist/ollama.cjs.map +1 -1
  236. package/dist/ollama.js +6 -6
  237. package/dist/ollama.js.map +1 -1
  238. package/dist/openrouter-video.cjs +5 -5
  239. package/dist/openrouter-video.cjs.map +1 -1
  240. package/dist/openrouter-video.js +6 -6
  241. package/dist/openrouter-video.js.map +1 -1
  242. package/dist/recorder.cjs +45 -9
  243. package/dist/recorder.cjs.map +1 -1
  244. package/dist/recorder.d.cts.map +1 -1
  245. package/dist/recorder.d.ts.map +1 -1
  246. package/dist/recorder.js +43 -11
  247. package/dist/recorder.js.map +1 -1
  248. package/dist/responses.cjs +4 -3
  249. package/dist/responses.cjs.map +1 -1
  250. package/dist/responses.js +5 -5
  251. package/dist/responses.js.map +1 -1
  252. package/dist/router.cjs +20 -33
  253. package/dist/router.cjs.map +1 -1
  254. package/dist/router.d.cts +4 -1
  255. package/dist/router.d.cts.map +1 -1
  256. package/dist/router.d.ts +4 -1
  257. package/dist/router.d.ts.map +1 -1
  258. package/dist/router.js +20 -34
  259. package/dist/router.js.map +1 -1
  260. package/dist/server.cjs +701 -625
  261. package/dist/server.cjs.map +1 -1
  262. package/dist/server.d.cts +1 -0
  263. package/dist/server.d.cts.map +1 -1
  264. package/dist/server.d.ts +1 -0
  265. package/dist/server.d.ts.map +1 -1
  266. package/dist/server.js +704 -628
  267. package/dist/server.js.map +1 -1
  268. package/dist/speech.cjs +2 -2
  269. package/dist/speech.cjs.map +1 -1
  270. package/dist/speech.js +3 -3
  271. package/dist/speech.js.map +1 -1
  272. package/dist/transcription.cjs +4 -4
  273. package/dist/transcription.cjs.map +1 -1
  274. package/dist/transcription.js +5 -5
  275. package/dist/transcription.js.map +1 -1
  276. package/dist/types.d.cts +119 -16
  277. package/dist/types.d.cts.map +1 -1
  278. package/dist/types.d.ts +119 -16
  279. package/dist/types.d.ts.map +1 -1
  280. package/dist/validate-cli.cjs +925 -0
  281. package/dist/validate-cli.cjs.map +1 -0
  282. package/dist/validate-cli.js +924 -0
  283. package/dist/validate-cli.js.map +1 -0
  284. package/dist/veo-video.cjs +13 -13
  285. package/dist/veo-video.cjs.map +1 -1
  286. package/dist/veo-video.js +14 -14
  287. package/dist/veo-video.js.map +1 -1
  288. package/dist/video.cjs +4 -4
  289. package/dist/video.cjs.map +1 -1
  290. package/dist/video.d.cts +1 -1
  291. package/dist/video.d.cts.map +1 -1
  292. package/dist/video.d.ts +1 -1
  293. package/dist/video.d.ts.map +1 -1
  294. package/dist/video.js +5 -5
  295. package/dist/video.js.map +1 -1
  296. package/dist/vitest.cjs +13 -6
  297. package/dist/vitest.cjs.map +1 -1
  298. package/dist/vitest.d.cts +2 -1
  299. package/dist/vitest.d.cts.map +1 -1
  300. package/dist/vitest.d.ts +2 -1
  301. package/dist/vitest.d.ts.map +1 -1
  302. package/dist/vitest.js +14 -7
  303. package/dist/vitest.js.map +1 -1
  304. package/dist/watcher.cjs +2 -1
  305. package/dist/watcher.cjs.map +1 -1
  306. package/dist/watcher.js +2 -1
  307. package/dist/watcher.js.map +1 -1
  308. package/dist/ws-framing.cjs +150 -35
  309. package/dist/ws-framing.cjs.map +1 -1
  310. package/dist/ws-framing.d.cts +18 -3
  311. package/dist/ws-framing.d.cts.map +1 -1
  312. package/dist/ws-framing.d.ts +18 -3
  313. package/dist/ws-framing.d.ts.map +1 -1
  314. package/dist/ws-framing.js +151 -36
  315. package/dist/ws-framing.js.map +1 -1
  316. package/dist/ws-gemini-live.cjs +2 -2
  317. package/dist/ws-gemini-live.d.ts +2 -2
  318. package/dist/ws-gemini-live.js +2 -2
  319. package/dist/ws-live.cjs +401 -0
  320. package/dist/ws-live.cjs.map +1 -0
  321. package/dist/ws-live.js +400 -0
  322. package/dist/ws-live.js.map +1 -0
  323. package/dist/ws-realtime.cjs +46 -2
  324. package/dist/ws-realtime.cjs.map +1 -1
  325. package/dist/ws-realtime.d.cts.map +1 -1
  326. package/dist/ws-realtime.d.ts +2 -2
  327. package/dist/ws-realtime.d.ts.map +1 -1
  328. package/dist/ws-realtime.js +46 -2
  329. package/dist/ws-realtime.js.map +1 -1
  330. package/dist/ws-responses.cjs +2 -2
  331. package/dist/ws-responses.js +2 -2
  332. package/dist/ws-upstream.cjs +118 -0
  333. package/dist/ws-upstream.cjs.map +1 -0
  334. package/dist/ws-upstream.js +115 -0
  335. package/dist/ws-upstream.js.map +1 -0
  336. package/fixtures/openai-live-client.json +390 -0
  337. package/fixtures/openai-live-managed.json +709 -0
  338. package/package.json +1 -1
package/dist/files.js ADDED
@@ -0,0 +1,1059 @@
1
+ import { flattenHeaders, generateId, isJsonObject } from "./helpers.js";
2
+ import { applyChaosAsync } from "./chaos.js";
3
+ import { createHash } from "node:crypto";
4
+
5
+ //#region src/files.ts
6
+ /**
7
+ * OpenAI Files API mock for aimock.
8
+ *
9
+ * Implements the subset of `POST /v1/files`, `GET /v1/files`,
10
+ * `GET /v1/files/{id}`, `GET /v1/files/{id}/content` and
11
+ * `DELETE /v1/files/{id}` that test harnesses need to exercise
12
+ * file-upload flows without touching the real API.
13
+ *
14
+ * Storage is in-memory and per-process (like the video job maps):
15
+ * - metadata lives in `fileStore`
16
+ * - raw bytes live in `fileContents` as `Buffer`s. Bytes are never
17
+ * round-tripped through a utf8 string, so binary uploads come back
18
+ * byte-for-byte identical.
19
+ * - `clearFileStore()` wipes both and is wired into the full reset path
20
+ * (`POST /__aimock/reset` + `LLMock.reset()`)
21
+ *
22
+ * The only memory bound here is per file: {@link FILES_MAX_BYTES} caps one
23
+ * upload. The file COUNT is deliberately unbounded, so total residency grows
24
+ * with the number of uploads until something calls `clearFileStore()` (or the
25
+ * process exits) — a suite that uploads in a loop without resetting will grow
26
+ * this map without limit. That is a real, documented bound, not an OOM defense.
27
+ *
28
+ * The job maps this module otherwise mirrors (fal-audio.ts,
29
+ * openrouter-video.ts, grok-video.ts, byteplus-video.ts) DO cap entry count and
30
+ * FIFO-evict the oldest, and that convention deliberately does NOT apply here.
31
+ * Those hold transient jobs that the real API also expires, so eviction
32
+ * reproduces real behaviour. A file is a durable object: the real API keeps it
33
+ * until the caller deletes it. Silently evicting one would make the mock answer
34
+ * `404 No such file` for a file the caller successfully uploaded and never
35
+ * deleted — an outcome the real API cannot produce, surfacing as an
36
+ * unexplainable flake in the suite under test. Unbounded growth is the honest
37
+ * failure mode; `POST /__aimock/reset` is the lever that reclaims it.
38
+ *
39
+ * Body handling accepts both shapes real SDKs send:
40
+ * - JSON `{ filename, purpose, content }` (test-friendly; `bytes` is an alias
41
+ * of `content`, and exactly one of the two must be present)
42
+ * - `multipart/form-data` with `purpose` + exactly one payload part named
43
+ * `file` (or its `content` alias), framed per RFC 2046 §5.1.1 so a payload
44
+ * containing the boundary token is stored whole rather than truncated at it
45
+ *
46
+ * Both parsers are strict: a malformed body is an OpenAI-shaped
47
+ * `400 invalid_request_error` naming the offending field or part. Nothing is
48
+ * `String()`-coerced and no missing field falls through to a fabricated
49
+ * default, because a mock that quietly stores `"[object Object]"` or a 0-byte
50
+ * file teaches the suite under test that a broken upload succeeded.
51
+ *
52
+ * Every branch journals with `service: "files"` so
53
+ * `GET /__aimock/journal?service=files` selects exactly this traffic, and with
54
+ * `source: "internal"` because — as with fine-tuning — no fixture or proxy
55
+ * ever serves this store. A successful upload journals a SYNTHETIC body
56
+ * describing it (`{ purpose, filename, bytes, content_type, sha256 }`) — never
57
+ * the uploaded octets — exactly as the transcription route journals its
58
+ * multipart audio; a `400` journals the error envelope it answered with, so
59
+ * the reason is readable from the journal; see {@link journalFiles}. Files also
60
+ * honors the inbound API-key boundary via the server dispatch (no bypass),
61
+ * and runs through the chaos gate so retry/backoff suites can inject
62
+ * 500s at the files surface too.
63
+ */
64
+ /**
65
+ * Purposes accepted on upload — `CreateFileRequest.purpose` in the official
66
+ * `openai/openai-openapi` spec (and the `FilePurpose` type in the SDK).
67
+ * `user_data` is what Responses-API file inputs use, so rejecting it broke the
68
+ * most current flow.
69
+ */
70
+ const CREATE_PURPOSES = new Set([
71
+ "assistants",
72
+ "batch",
73
+ "fine-tune",
74
+ "vision",
75
+ "user_data",
76
+ "evals"
77
+ ]);
78
+ /**
79
+ * Exposed for tests and for callers that want to check a value themselves:
80
+ * the enum this mock enforces on upload.
81
+ */
82
+ const FILE_CREATE_PURPOSES = [...CREATE_PURPOSES];
83
+ function invalidPurpose(purpose) {
84
+ return `Invalid purpose '${purpose}'. Expected one of: ${[...CREATE_PURPOSES].join(", ")}`;
85
+ }
86
+ /**
87
+ * `Content-Transfer-Encoding` values that are identity transforms (RFC 2045
88
+ * §6.2): the part body already *is* the file's octets, so storing it verbatim
89
+ * is correct. Everything else (`base64`, `quoted-printable`, …) would have to
90
+ * be decoded before storage, and is rejected instead — see
91
+ * {@link unsupportedPartEncoding}.
92
+ */
93
+ const IDENTITY_TRANSFER_ENCODINGS = new Set([
94
+ "7bit",
95
+ "8bit",
96
+ "binary",
97
+ "identity"
98
+ ]);
99
+ /**
100
+ * Reject, rather than silently mis-store, a file part that asks for a content
101
+ * encoding this mock does not apply.
102
+ *
103
+ * We deliberately do NOT implement a decoder. RFC 7578 §4.7 deprecates
104
+ * `Content-Transfer-Encoding` in `multipart/form-data` ("Senders SHOULD NOT
105
+ * generate any parts with a Content-Transfer-Encoding header field. Currently,
106
+ * no deployed implementations that send such bodies have been discovered"), and
107
+ * a capture of the clients that actually reach this endpoint — curl `-F`,
108
+ * python `requests`, python `httpx` (the OpenAI Python SDK's transport) and
109
+ * undici `FormData` (the OpenAI Node SDK's transport) — confirms none of them
110
+ * emits one. Storing an encoded body as-is is the harmful outcome: `bytes` and
111
+ * `GET /content` would report the *encoded* form and a hash comparison in a
112
+ * test would fail for a reason the caller cannot see. A loud 400 naming the
113
+ * header is the honest answer for a mock.
114
+ *
115
+ * `identity` counts as a no-op alongside the RFC 2045 §6.2 values: it is the
116
+ * registered `Content-Encoding` for "no transformation", and a client that
117
+ * spells it on `Content-Transfer-Encoding` means the same thing. Rejecting it
118
+ * named a problem that did not exist.
119
+ *
120
+ * Every part is checked, not only the payload part, and the message names the
121
+ * part it came from. While only the payload part was checked, a `base64`
122
+ * `purpose` part slid through to the enum check and 400'd with
123
+ * `Invalid purpose 'YXNzaXN0YW50cw=='`, pointing the caller at the wrong thing.
124
+ */
125
+ function unsupportedPartEncoding(header, value, partName) {
126
+ return `Unsupported ${header}: '${value}' on the multipart '${partName}' part. aimock stores part bodies verbatim and does not decode them.${header === "Content-Transfer-Encoding" ? ` RFC 7578 section 4.7 deprecates ${header} in multipart/form-data.` : ""} Send the raw bytes instead.`;
127
+ }
128
+ /**
129
+ * Content types for the handful of extensions test harnesses actually upload.
130
+ * Anything unrecognised falls back to `application/octet-stream` — the safe
131
+ * default for opaque bytes, and what stops a PNG being served (and re-decoded)
132
+ * as UTF-8 text.
133
+ *
134
+ * This is an allowlist of INERT types on purpose. `text/html` is deliberately
135
+ * absent: the mock serves uploaded bytes back from its own origin, so any
136
+ * active type here would let a stored upload script that origin in a browser.
137
+ * HTML uploads round-trip byte-for-byte, they are just served as opaque bytes.
138
+ */
139
+ const CONTENT_TYPE_BY_EXT = {
140
+ json: "application/json",
141
+ jsonl: "application/jsonl",
142
+ txt: "text/plain; charset=utf-8",
143
+ md: "text/markdown; charset=utf-8",
144
+ csv: "text/csv; charset=utf-8",
145
+ pdf: "application/pdf",
146
+ png: "image/png",
147
+ jpg: "image/jpeg",
148
+ jpeg: "image/jpeg",
149
+ gif: "image/gif",
150
+ webp: "image/webp",
151
+ wav: "audio/wav",
152
+ mp3: "audio/mpeg",
153
+ zip: "application/zip"
154
+ };
155
+ function contentTypeForFilename(filename) {
156
+ const dot = filename.lastIndexOf(".");
157
+ if (dot === -1) return "application/octet-stream";
158
+ return CONTENT_TYPE_BY_EXT[filename.slice(dot + 1).toLowerCase()] ?? "application/octet-stream";
159
+ }
160
+ /**
161
+ * RFC 5987 §3.2.1 `ext-value` percent-encoding for the `filename*` parameter.
162
+ *
163
+ * `encodeURIComponent` leaves `!'()*-._~` unescaped, but `'`, `(`, `)` and `*`
164
+ * are NOT in RFC 5987's `attr-char` set, so they have to be escaped by hand.
165
+ */
166
+ function encodeExtValue(value) {
167
+ return encodeURIComponent(value).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
168
+ }
169
+ /**
170
+ * `Content-Disposition` for the content route (RFC 6266 §4.1).
171
+ *
172
+ * Always `attachment`. {@link CONTENT_TYPE_BY_EXT} is an allowlist of INERT
173
+ * types precisely so a stored upload cannot execute against the mock's own
174
+ * origin, but `application/pdf` was still being served with no disposition at
175
+ * all, which in every mainstream browser means "render in the built-in PDF
176
+ * viewer" — an active surface on this origin, exactly what the allowlist's
177
+ * comment claims is excluded. `attachment` closes that without touching the
178
+ * allowlist, and it also stops a `.json`/`.txt`/`.csv` body being rendered
179
+ * inline if someone opens a content URL in a tab.
180
+ *
181
+ * What the REAL API sends here is UNVERIFIED: probing
182
+ * `api.openai.com/v1/files/{id}/content` needs a key, and neither
183
+ * `openai/openai-openapi` nor the SDK documents the response headers. So this
184
+ * is chosen for safety, not fidelity — and it is inert for SDK callers: the
185
+ * vendored SDK's `files.content()` (openai@4.104.0,
186
+ * `resources/files.js:82-88`) issues the GET with `Accept: application/binary`
187
+ * and `__binaryResponse: true`, handing back the raw `Response` without ever
188
+ * reading `Content-Disposition`.
189
+ *
190
+ * Both parameters are built from the STORED filename, which is attacker-shaped
191
+ * input: a multipart `filename="..."` can carry CR/LF and would otherwise
192
+ * inject response headers. The ASCII fallback replaces every non-printable and
193
+ * non-ASCII byte with `_` and backslash-escapes `"` and `\` per the
194
+ * quoted-string rule; `filename*` percent-encodes them. Neither can emit a bare
195
+ * CR or LF.
196
+ */
197
+ function contentDispositionFor(filename) {
198
+ const base = `attachment; filename="${filename.replace(/[^\x20-\x7e]/g, "_").replace(/(["\\])/g, "\\$1")}"`;
199
+ if (/^[\x20-\x7e]*$/.test(filename)) return base;
200
+ return `${base}; filename*=UTF-8''${encodeExtValue(filename)}`;
201
+ }
202
+ /**
203
+ * Per-file content cap. An upload whose decoded content is above this gets a
204
+ * `400` with the usual OpenAI-style error envelope, at every wire size a body
205
+ * can have — see {@link FILES_BODY_MAX_BYTES} for how that is kept true
206
+ * without buffering an unbounded body.
207
+ */
208
+ const FILES_MAX_BYTES = 10 * 1024 * 1024;
209
+ /**
210
+ * Worst-case wire-to-content expansion of a JSON upload.
211
+ *
212
+ * `content` is a JSON string, so one byte of decoded payload can arrive as as
213
+ * many as six wire bytes: a `\uXXXX` escape is 6 bytes and the cheapest code
214
+ * point one can carry (U+0000–U+007F) is a single UTF-8 byte. Every other
215
+ * code point does better — U+0800–U+FFFF is 3 UTF-8 bytes per 6 wire bytes,
216
+ * a surrogate pair 4 per 12 — and a multipart part is 1:1 plus its envelope.
217
+ * So six is an upper bound on wire → content expansion for both parsers.
218
+ */
219
+ const FILES_MAX_WIRE_EXPANSION = 6;
220
+ /** Slack for the JSON envelope, or for multipart boundaries and part headers. */
221
+ const FILES_ENVELOPE_HEADROOM_BYTES = 64 * 1024;
222
+ /**
223
+ * How much of a Files upload body the route will BUFFER — derived, not picked.
224
+ *
225
+ * `readBodyBuffer` enforces a limit by destroying the socket: no status line,
226
+ * no body, no CORS headers, just an `ECONNRESET`. That made the `400 File
227
+ * content exceeds ... byte cap` branches below unreachable when the two caps
228
+ * were equal (both the 10 MB `DEFAULT_MAX_BODY_BYTES`), and a fixed slice of
229
+ * headroom above the cap only moved the cliff: a `\u0001`-heavy JSON body or a
230
+ * multipart body far enough over the cap still died on the socket.
231
+ *
232
+ * So the bound is computed from the widest a within-cap payload can be on the
233
+ * wire ({@link FILES_MAX_WIRE_EXPANSION} × the cap, plus envelope headroom).
234
+ * Two consequences, and they are the contract:
235
+ *
236
+ * - No body that could decode to a payload within the cap is ever refused for
237
+ * its size — it fits here by construction, and is answered on content.
238
+ * - A body larger than this is not buffered. The route stops retaining bytes
239
+ * (peak memory stays at this bound), drains the rest, and answers a real
240
+ * `400` with CORS. It is not a socket drop, and it cannot be a false
241
+ * rejection either, by the point above.
242
+ *
243
+ * The global `readBodyBuffer` default is deliberately left alone — every other
244
+ * route depends on it.
245
+ */
246
+ const FILES_BODY_MAX_BYTES = FILES_MAX_BYTES * FILES_MAX_WIRE_EXPANSION + FILES_ENVELOPE_HEADROOM_BYTES;
247
+ /**
248
+ * The one documented socket drop on this route: a DoS backstop.
249
+ *
250
+ * Draining an over-size body is what buys the caller a real `400`, but the
251
+ * draining itself is work an attacker can ask for without limit. Past this
252
+ * the request is simply not worth finishing and the socket is destroyed, the
253
+ * same way every other route treats its own limit. It is deliberately far
254
+ * above {@link FILES_BODY_MAX_BYTES}: a client that lands here is not sending
255
+ * a file that was ever going to be accepted.
256
+ */
257
+ const FILES_BODY_DRAIN_MAX_BYTES = FILES_BODY_MAX_BYTES * 2;
258
+ /**
259
+ * Stands in for an upload body that went past {@link FILES_BODY_MAX_BYTES} and
260
+ * was therefore drained instead of buffered. The route hands this to
261
+ * {@link handleFilesCreate} in place of the bytes, so the over-size answer is
262
+ * the same `400` envelope — journalled, CORS-headed, chaos-gated — as every
263
+ * other rejection here, rather than a second error path in the server.
264
+ */
265
+ const FILES_BODY_OVERSIZED = Symbol("files-body-oversized");
266
+ /** The `400` for a body too large to be a within-cap upload. */
267
+ function oversizedBody() {
268
+ return `Request body exceeds the ${FILES_BODY_MAX_BYTES} byte upload limit for this route (the ${FILES_MAX_BYTES} byte content cap plus worst-case wire encoding overhead)`;
269
+ }
270
+ const fileStore = /* @__PURE__ */ new Map();
271
+ const fileContents = /* @__PURE__ */ new Map();
272
+ function clearFileStore() {
273
+ fileStore.clear();
274
+ fileContents.clear();
275
+ }
276
+ /**
277
+ * The stored bytes for one file, as the very `Buffer` this module holds — so a
278
+ * test can assert on what it RETAINS (`buffer.byteLength`), not just on what it
279
+ * serves back. `GET /v1/files/{id}/content` copies onto the wire, so it cannot
280
+ * tell an owned buffer from a `subarray` pinning a 60 MB request body.
281
+ */
282
+ function getStoredFileBytes(id) {
283
+ return fileContents.get(id)?.bytes;
284
+ }
285
+ /**
286
+ * Register one file in the store and return its wire object. This is the one
287
+ * insert path: `POST /v1/files` uses it, and so do server-side producers of
288
+ * files (the Batches mock minting `output_file_id` / `error_file_id`), so a
289
+ * file minted internally is retrievable through `GET /v1/files/{id}` and
290
+ * `/content` exactly like an upload. `content` must be an OWNED buffer (see
291
+ * {@link ParsedUpload.content}) — it is stored as-is, and it must already be
292
+ * within {@link FILES_MAX_BYTES}: the cap the upload route enforces holds for
293
+ * every file in the store, so a producer that cannot fit under it fails its
294
+ * own operation instead of storing the file.
295
+ */
296
+ function storeFile(file) {
297
+ if (file.content.length > FILES_MAX_BYTES) throw new RangeError(`File content exceeds ${FILES_MAX_BYTES} byte cap`);
298
+ const id = generateId("file");
299
+ const obj = {
300
+ id,
301
+ object: "file",
302
+ bytes: file.content.length,
303
+ created_at: Math.floor(Date.now() / 1e3),
304
+ filename: file.filename,
305
+ purpose: file.purpose,
306
+ status: "processed"
307
+ };
308
+ fileStore.set(id, obj);
309
+ fileContents.set(id, { bytes: file.content });
310
+ return obj;
311
+ }
312
+ /**
313
+ * Synthetic journal body for an upload — the SAME convention
314
+ * {@link handleTranscription} uses for `multipart/form-data` audio: the entry
315
+ * carries a small object describing the upload that was parsed out of the
316
+ * body, never the body itself. Raw file octets are NEVER journalled, in any
317
+ * encoding: they can be megabytes of binary, they would blow the journal's
318
+ * 64 KB body cap on anything real, and a base64 round-trip of them is not
319
+ * something a test wants to diff. `sha256` is the byte identity a caller
320
+ * asserts on instead.
321
+ *
322
+ * `content_type` is derived from the stored filename via
323
+ * {@link contentTypeForFilename} — the same decision `GET /v1/files/{id}/content`
324
+ * serves the bytes back with — rather than from the client-declared part
325
+ * header, which this mock deliberately ignores everywhere else.
326
+ *
327
+ * `model`/`messages` are the inert `ChatCompletionRequest` floor the journal's
328
+ * body type requires (the same `{ model: "", messages: [] }` the server uses
329
+ * for its own bodyless synthetic entries); `_endpointType` names the surface.
330
+ */
331
+ function filesUploadBody(upload) {
332
+ return {
333
+ model: "",
334
+ messages: [],
335
+ _endpointType: "files",
336
+ purpose: upload.purpose,
337
+ filename: upload.filename,
338
+ bytes: upload.content.length,
339
+ content_type: contentTypeForFilename(upload.filename),
340
+ sha256: createHash("sha256").update(upload.content).digest("hex")
341
+ };
342
+ }
343
+ /**
344
+ * Journal one files-route request.
345
+ *
346
+ * The rule for `body`: an entry carries the {@link filesUploadBody} descriptor
347
+ * exactly when a request had an upload payload that parsed — i.e. a successful
348
+ * `POST /v1/files` — and the {@link invalidRequest} envelope exactly when the
349
+ * request was rejected with a `400` (see {@link rejectFiles}), so the reason
350
+ * is readable from the journal without the wire response. Every other files
351
+ * entry (GET one, GET content, list, DELETE, a chaos-faulted request, a 404)
352
+ * has no payload to describe and stays `null`, matching the bodyless routes in
353
+ * {@link handleTranscription}. Raw uploaded bytes never appear.
354
+ *
355
+ * `service: "files"` is set on every branch so
356
+ * `GET /__aimock/journal?service=files` selects exactly this traffic, and
357
+ * `source: "internal"` because this store is aimock's own — the same tag every
358
+ * fine-tuning entry carries, success and error alike.
359
+ *
360
+ * Call it AFTER the response has been written: a `writeHead` that throws must
361
+ * not leave behind an entry for a response the client never received.
362
+ */
363
+ function journalFiles(journal, method, path, headers, status, body = null) {
364
+ journal.add({
365
+ method,
366
+ path,
367
+ headers,
368
+ body,
369
+ service: "files",
370
+ response: {
371
+ status,
372
+ fixture: null,
373
+ source: "internal"
374
+ }
375
+ });
376
+ }
377
+ /** Keep the entire warning bounded and on one physical line, including the URL. */
378
+ function filesRejectionWarning(message) {
379
+ const maxLength = 1024;
380
+ let safe = "";
381
+ for (const character of message) {
382
+ const code = character.charCodeAt(0);
383
+ const escaped = code < 32 || code >= 127 && code <= 159 || code === 8232 || code === 8233 ? `\\u${code.toString(16).padStart(4, "0")}` : character;
384
+ if (safe.length + escaped.length > maxLength - 3) return `${safe}...`;
385
+ safe += escaped;
386
+ }
387
+ return safe;
388
+ }
389
+ /**
390
+ * Answer a files-route `400`: log it at `warn` (the level the server uses for
391
+ * a request it refused), journal the {@link invalidRequest} envelope as the
392
+ * entry's body so the reason survives in the journal, and write that same
393
+ * envelope to the wire. One helper so the three can never disagree.
394
+ */
395
+ function rejectFiles(res, journal, logger, method, path, headers, message, setCorsHeaders) {
396
+ const envelope = invalidRequest(message);
397
+ logger.warn(filesRejectionWarning(`Files mock: rejected ${method} ${path} with 400: ${message}`));
398
+ writeJson(res, 400, envelope, setCorsHeaders);
399
+ journalFiles(journal, method, path, headers, 400, envelope);
400
+ }
401
+ function writeJson(res, status, payload, setCorsHeaders) {
402
+ setCorsHeaders(res);
403
+ res.writeHead(status, { "Content-Type": "application/json" });
404
+ res.end(JSON.stringify(payload));
405
+ }
406
+ function invalidRequest(message) {
407
+ return { error: {
408
+ message,
409
+ type: "invalid_request_error"
410
+ } };
411
+ }
412
+ /**
413
+ * Name a rejected value's JSON type for the 400 message. The point is that the
414
+ * caller sees *what* they sent instead of silently getting `String(value)`
415
+ * stored as the file's content.
416
+ *
417
+ * The listed cases are exhaustive for the one caller. `JSON.parse` only ever
418
+ * yields null, boolean, number, string, array or plain object — never
419
+ * `undefined`, `function`, `symbol` or `bigint` — and the caller has already
420
+ * excluded `string` (it is the accepted type) and `undefined` (that is the
421
+ * separate "required" 400). So everything reaching the last line is an object.
422
+ */
423
+ function jsonTypeOf(value) {
424
+ if (value === null) return "null";
425
+ if (Array.isArray(value)) return "an array";
426
+ if (typeof value === "number") return "a number";
427
+ if (typeof value === "boolean") return "a boolean";
428
+ return "an object";
429
+ }
430
+ /**
431
+ * A high surrogate not followed by a low one, or a low surrogate not preceded
432
+ * by a high one — i.e. a JS string that is not well-formed UTF-16 and therefore
433
+ * has no UTF-8 encoding.
434
+ *
435
+ * `Buffer.from(s, "utf8")` does not fail on these: it substitutes U+FFFD (EF BF
436
+ * BD), silently and permanently. That breaks the one guarantee an upload mock
437
+ * owes its caller — `GET /v1/files/{id}/content` returns what was uploaded — and
438
+ * it breaks it invisibly, three bytes at a time, in a test whose hash
439
+ * comparison then fails for no visible reason. The byte-for-byte promise this
440
+ * module documents is made for the multipart path (which is scanned as latin1
441
+ * and never decoded); the JSON path carries *text*, so text that cannot be
442
+ * encoded is a 400, not a lossy store.
443
+ */
444
+ const LONE_SURROGATE_RE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
445
+ /**
446
+ * Longest `filename` this mock will store, in UTF-8 bytes.
447
+ *
448
+ * NOT the real API's limit: `openai/openai-openapi` gives `OpenAIFile.filename`
449
+ * as a bare `type: string` with no `maxLength`, and `CreateFileRequest` has no
450
+ * `filename` property at all. So the bound comes from the one thing here a
451
+ * filename can break: `GET /v1/files/{id}/content` echoes the stored name into
452
+ * `Content-Disposition` twice — quoted ASCII (where `"` and `\` double) and
453
+ * percent-encoded per RFC 5987 (where a byte can triple) — so ~5 bytes of
454
+ * header per byte of name. 1024 bytes of name is ~5 KiB of header, well inside
455
+ * Node's 16 KiB default `--max-http-header-size`.
456
+ *
457
+ * Unbounded, a 20 KB filename uploaded `200` and then made the content route
458
+ * unreadable by undici: `UND_ERR_HEADERS_OVERFLOW`, no status, no envelope, no
459
+ * CORS — the OpenAI SDK could not fetch a file it had just uploaded.
460
+ */
461
+ const FILES_MAX_FILENAME_BYTES = 1024;
462
+ /**
463
+ * The filename rules, in one place because both parsers must answer the same.
464
+ *
465
+ * They used to disagree: a name carrying NUL, CR or LF was a `400` as a
466
+ * multipart parameter and a `200` as a JSON field, stored and echoed back
467
+ * verbatim. Returns an error message, or `undefined` if the name is fine.
468
+ */
469
+ function filenameError(filename) {
470
+ if (CONTROL_CHARS.test(filename)) return "Invalid parameter: 'filename' must not contain control characters";
471
+ if (LONE_SURROGATE_RE.test(filename)) return "Invalid parameter: 'filename' must not contain an unpaired UTF-16 surrogate";
472
+ const byteLength = Buffer.byteLength(filename, "utf8");
473
+ if (byteLength > FILES_MAX_FILENAME_BYTES) return `Invalid parameter: 'filename' is ${byteLength} UTF-8 bytes, above the ${FILES_MAX_FILENAME_BYTES} byte cap (a longer name makes GET /v1/files/{id}/content unfetchable: its Content-Disposition header would exceed the HTTP header limit)`;
474
+ }
475
+ /** The deterministic name given to an upload that arrived without one. */
476
+ function synthesizedFilename(content) {
477
+ return `upload-${createHash("sha256").update(content).digest("hex").slice(0, 24)}.bin`;
478
+ }
479
+ function parseJsonUpload(raw) {
480
+ let text;
481
+ try {
482
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
483
+ } catch {
484
+ return { error: "Invalid request: body is not valid UTF-8" };
485
+ }
486
+ let body;
487
+ try {
488
+ body = JSON.parse(text);
489
+ } catch (err) {
490
+ return { error: `Malformed JSON: ${err instanceof Error ? err.message : "unknown"}` };
491
+ }
492
+ if (!isJsonObject(body)) return { error: "Request body must be a JSON object" };
493
+ const rawFilename = body["filename"];
494
+ const purpose = body["purpose"];
495
+ if (typeof rawFilename !== "string") return { error: "Invalid parameter: 'filename' must be a string" };
496
+ const filenameProblem = filenameError(rawFilename);
497
+ if (filenameProblem !== void 0) return { error: filenameProblem };
498
+ const filename = rawFilename.trim() === "" ? void 0 : rawFilename;
499
+ if (typeof purpose !== "string" || purpose.length === 0) return { error: "Invalid parameter: 'purpose' must be a non-empty string" };
500
+ if (!CREATE_PURPOSES.has(purpose)) return { error: invalidPurpose(purpose) };
501
+ const hasContent = body["content"] !== void 0;
502
+ const hasBytesAlias = body["bytes"] !== void 0;
503
+ if (hasContent && hasBytesAlias) return { error: "Invalid parameter: 'content' and its alias 'bytes' are mutually exclusive; send exactly one" };
504
+ const field = hasContent ? "content" : "bytes";
505
+ const content = hasContent ? body["content"] : body["bytes"];
506
+ if (content === void 0) return { error: "Invalid parameter: 'content' is required (or its alias 'bytes')" };
507
+ if (typeof content !== "string") return { error: `Invalid parameter: '${field}' must be a string, got ${jsonTypeOf(content)}` };
508
+ if (LONE_SURROGATE_RE.test(content)) return { error: `Invalid parameter: '${field}' contains an unpaired UTF-16 surrogate and is not valid text. aimock would have to store it as U+FFFD, so the bytes served back by GET /v1/files/{id}/content would not be the bytes you sent. Send binary as a multipart/form-data upload, which is stored verbatim.` };
509
+ const bytes = Buffer.from(content, "utf8");
510
+ if (bytes.length > FILES_MAX_BYTES) return { error: `File content exceeds ${FILES_MAX_BYTES} byte cap` };
511
+ return {
512
+ filename: filename ?? synthesizedFilename(bytes),
513
+ purpose,
514
+ content: bytes
515
+ };
516
+ }
517
+ const HEADER_TERMINATOR = Buffer.from("\r\n\r\n");
518
+ /** RFC 9110 `token` characters — the legal set for a header field name. */
519
+ const TOKEN_CHAR = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
520
+ /** C0 controls plus DEL — never legitimate in a filename we echo back. */
521
+ const CONTROL_CHARS = /[-]/;
522
+ /**
523
+ * Is this request body `multipart/form-data`?
524
+ *
525
+ * Compares the media type's *essence* (RFC 9110 §8.3.1: everything before the
526
+ * first `;`), not a substring of the whole header. A substring test routed
527
+ * `application/json; note="multipart/form-data"` — and anything else that
528
+ * merely mentions the token in a parameter — into the multipart parser, which
529
+ * then 400'd about a missing `boundary` directive on a perfectly good JSON
530
+ * body.
531
+ */
532
+ function isMultipartFormData(contentType) {
533
+ if (contentType === void 0) return false;
534
+ const semi = contentType.indexOf(";");
535
+ return (semi === -1 ? contentType : contentType.slice(0, semi)).trim().toLowerCase() === "multipart/form-data";
536
+ }
537
+ /**
538
+ * Locate the next *boundary delimiter line* at or after `from`.
539
+ *
540
+ * RFC 2046 §5.1.1 defines the delimiter as `CRLF "--" boundary` followed by
541
+ * either transport-padding + CRLF (another part follows) or `--` (the close
542
+ * delimiter). Both halves matter:
543
+ *
544
+ * - The **leading CRLF is part of the delimiter**, not of the preceding body.
545
+ * The previous implementation split on a bare `--boundary`, so a payload
546
+ * whose bytes happened to contain that sequence was truncated at it and
547
+ * stored short — with a 200, silently, which is the worst possible answer
548
+ * from a module whose contract is byte-for-byte storage.
549
+ * - An occurrence **not followed by** CRLF or `--` is not a delimiter line at
550
+ * all, so it is ordinary body content and the scan continues past it. That
551
+ * is what keeps a payload containing `CRLF--boundary` plus arbitrary bytes
552
+ * byte-exact. (undici's `Response.formData()` rejects such a body outright;
553
+ * see the note on {@link parseMultipartUpload}.)
554
+ */
555
+ function nextDelimiter(buf, delimiter, from) {
556
+ let at = buf.indexOf(delimiter, from);
557
+ while (at !== -1) {
558
+ const tail = delimiterTail(buf, at + delimiter.length);
559
+ if (tail) return {
560
+ at,
561
+ ...tail
562
+ };
563
+ at = buf.indexOf(delimiter, at + 1);
564
+ }
565
+ }
566
+ /**
567
+ * Classify what follows a `CRLF "--" boundary` match: transport-padding + CRLF
568
+ * (another part follows), `--` (the close delimiter), or neither — in which
569
+ * case the match is not a delimiter line at all and the caller keeps scanning.
570
+ * Split out of {@link nextDelimiter} so {@link parseMultipartUpload} can apply
571
+ * the same rule to an opening delimiter whose leading CRLF is not present.
572
+ */
573
+ function delimiterTail(buf, after) {
574
+ if (buf[after] === 45 && buf[after + 1] === 45) return {
575
+ next: after + 2,
576
+ closing: true
577
+ };
578
+ let j = after;
579
+ while (buf[j] === 32 || buf[j] === 9) j += 1;
580
+ if (buf[j] === 13 && buf[j + 1] === 10) return {
581
+ next: j + 2,
582
+ closing: false
583
+ };
584
+ }
585
+ function parseHeaderParams(value) {
586
+ const params = /* @__PURE__ */ new Map();
587
+ const semi = value.indexOf(";");
588
+ const head = (semi === -1 ? value : value.slice(0, semi)).trim();
589
+ let i = semi === -1 ? value.length : semi;
590
+ while (i < value.length) {
591
+ i += 1;
592
+ while (value[i] === " " || value[i] === " ") i += 1;
593
+ if (i >= value.length) break;
594
+ const eq = value.indexOf("=", i);
595
+ if (eq === -1) return MALFORMED_HEADER;
596
+ const attr = value.slice(i, eq).trim().toLowerCase();
597
+ if (!TOKEN_CHAR.test(attr)) return MALFORMED_HEADER;
598
+ if (params.has(attr)) return {
599
+ error: "duplicate",
600
+ param: attr
601
+ };
602
+ i = eq + 1;
603
+ if (value[i] === "\"") {
604
+ i += 1;
605
+ let out = "";
606
+ for (;;) {
607
+ if (i >= value.length) return MALFORMED_HEADER;
608
+ const ch = value[i];
609
+ if (ch === "\\" && i + 1 < value.length) {
610
+ out += value[i + 1];
611
+ i += 2;
612
+ continue;
613
+ }
614
+ if (ch === "\"") {
615
+ i += 1;
616
+ break;
617
+ }
618
+ out += ch;
619
+ i += 1;
620
+ }
621
+ params.set(attr, out);
622
+ while (value[i] === " " || value[i] === " ") i += 1;
623
+ if (i < value.length && value[i] !== ";") return MALFORMED_HEADER;
624
+ } else {
625
+ const end = value.indexOf(";", i);
626
+ params.set(attr, (end === -1 ? value.slice(i) : value.slice(i, end)).trim());
627
+ i = end === -1 ? value.length : end;
628
+ }
629
+ }
630
+ return {
631
+ value: head,
632
+ params
633
+ };
634
+ }
635
+ const MALFORMED_HEADER = { error: "malformed" };
636
+ /** Render a {@link parseHeaderParams} failure as a 400 message naming the header. */
637
+ function headerParamError(err, header, where) {
638
+ return err.error === "duplicate" ? `Invalid request: duplicate '${err.param}' parameter on the ${header} header in ${where}` : `Invalid request: malformed ${header} header in ${where}`;
639
+ }
640
+ /**
641
+ * Decode an RFC 5987 `ext-value` (`charset'language'pct-encoded`), the form a
642
+ * `filename*` parameter takes. RFC 6266 §4.3 says a recipient that understands
643
+ * it SHOULD prefer it over the plain `filename`, so {@link parsePart} does.
644
+ * A client that sends only `filename*=` therefore keeps the name it sent
645
+ * instead of receiving a synthesized one.
646
+ */
647
+ function decodeExtValue(raw) {
648
+ const first = raw.indexOf("'");
649
+ const second = raw.indexOf("'", first + 1);
650
+ if (first === -1 || second === -1) return void 0;
651
+ const charset = raw.slice(0, first).toLowerCase();
652
+ if (charset !== "utf-8" && charset !== "iso-8859-1") return void 0;
653
+ const octets = [];
654
+ const pct = raw.slice(second + 1);
655
+ for (let i = 0; i < pct.length; i += 1) if (pct[i] === "%") {
656
+ const hex = pct.slice(i + 1, i + 3);
657
+ if (!/^[0-9A-Fa-f]{2}$/.test(hex)) return void 0;
658
+ octets.push(parseInt(hex, 16));
659
+ i += 2;
660
+ } else {
661
+ const code = pct.charCodeAt(i);
662
+ if (code > 127) return void 0;
663
+ octets.push(code);
664
+ }
665
+ const bytes = Buffer.from(octets);
666
+ if (charset === "iso-8859-1") return bytes.toString("latin1");
667
+ try {
668
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
669
+ } catch {
670
+ return;
671
+ }
672
+ }
673
+ /** Turn one part's raw octets into headers + body, or name why it is malformed. */
674
+ function parsePart(partBytes, index) {
675
+ const where = `multipart part #${index + 1}`;
676
+ let headerText;
677
+ let bodyStart;
678
+ if (partBytes[0] === 13 && partBytes[1] === 10) {
679
+ headerText = "";
680
+ bodyStart = 2;
681
+ } else {
682
+ const end = partBytes.indexOf(HEADER_TERMINATOR);
683
+ if (end === -1) return { error: `Invalid request: malformed ${where} — its headers are not terminated by a blank line` };
684
+ headerText = partBytes.subarray(0, end).toString("utf8");
685
+ bodyStart = end + 4;
686
+ }
687
+ const headers = /* @__PURE__ */ new Map();
688
+ if (headerText !== "") for (const line of headerText.split("\r\n")) {
689
+ const colon = line.indexOf(":");
690
+ const fieldName = colon === -1 ? "" : line.slice(0, colon);
691
+ if (!TOKEN_CHAR.test(fieldName)) return { error: `Invalid request: malformed header line in ${where}` };
692
+ const field = fieldName.toLowerCase();
693
+ if (headers.has(field)) return { error: `Invalid request: duplicate '${fieldName.trim()}' header in ${where}` };
694
+ headers.set(field, line.slice(colon + 1).trim());
695
+ }
696
+ const disposition = headers.get("content-disposition");
697
+ if (disposition === void 0) return { error: `Invalid request: ${where} has no Content-Disposition header` };
698
+ const parsed = parseHeaderParams(disposition);
699
+ if ("error" in parsed) return { error: headerParamError(parsed, "Content-Disposition", where) };
700
+ if (parsed.value.toLowerCase() !== "form-data") return { error: `Invalid request: ${where} has Content-Disposition type '${parsed.value}'; RFC 7578 section 4.2 requires 'form-data'` };
701
+ const name = parsed.params.get("name");
702
+ if (name === void 0) return { error: `Invalid request: ${where} has no 'name' parameter on its Content-Disposition header` };
703
+ const star = parsed.params.get("filename*");
704
+ const filename = star !== void 0 ? decodeExtValue(star) : parsed.params.get("filename");
705
+ if (star !== void 0 && filename === void 0) return { error: `Invalid request: malformed 'filename*' parameter in ${where}` };
706
+ return {
707
+ name,
708
+ filename,
709
+ headers,
710
+ body: partBytes.subarray(bodyStart)
711
+ };
712
+ }
713
+ /**
714
+ * Read a part's content encoding header. An empty value reads as absent: the
715
+ * old regex used `\s*` after the colon, which matches a CRLF, so an empty
716
+ * `Content-Transfer-Encoding:` captured the *next* header line and 400'd
717
+ * naming `content-type: image/png` as the unsupported encoding.
718
+ */
719
+ function partEncoding(part, header) {
720
+ const value = part.headers.get(header)?.trim().toLowerCase();
721
+ return value === void 0 || value === "" ? void 0 : value;
722
+ }
723
+ /**
724
+ * Parse a `multipart/form-data` upload body.
725
+ *
726
+ * ## Why this is not `Response.formData()`
727
+ *
728
+ * Node's built-in (undici) `Response.formData()` is a spec-compliant multipart
729
+ * parser and was evaluated first, per the "use the platform, do not hand-roll
730
+ * a codec" rule. It fails three of this module's requirements:
731
+ *
732
+ * 1. **Byte fidelity.** It throws `expected CRLF` on any body whose payload
733
+ * contains the boundary token, including the RFC-legal bare `--boundary`
734
+ * with no preceding CRLF. Those bytes are storable and this module promises
735
+ * to store them.
736
+ * 2. **Filename-less payload parts.** A part with no `filename` parameter comes
737
+ * back as a *string*, decoded as UTF-8, so binary octets are destroyed
738
+ * (`00 ff fe ...` becomes `00 efbfbd efbfbd ...`). Such a part is legal
739
+ * input here and already round-tripped byte-exact.
740
+ * 3. **Part headers.** `File` exposes only `name`, `type` and `size`. There is
741
+ * no way to see a part's `Content-Transfer-Encoding`, so the "reject an
742
+ * encoding we do not apply" contract above could not be implemented at all
743
+ * and a `base64` part would be stored in its encoded form — the exact bug
744
+ * that contract exists to prevent.
745
+ *
746
+ * So the framing stays local, but it is written to RFC 2046 §5.1.1 and
747
+ * RFC 7578 rather than to a `split()`: see {@link nextDelimiter} for the
748
+ * delimiter rule and {@link parsePart} for the header rule.
749
+ *
750
+ * Bytes never round-trip through a string. Part bodies are `subarray`s of the
751
+ * request buffer, so what is parsed is literally what arrived on the wire — but
752
+ * the payload part is COPIED out of that view before it is returned, because a
753
+ * `subarray` retains its whole backing buffer (see the copy below). The scan
754
+ * itself never copies the body either, so peak residency during a parse is one
755
+ * request body, not two.
756
+ */
757
+ function parseMultipartUpload(rawBytes, contentType) {
758
+ const parsedType = parseHeaderParams(contentType ?? "");
759
+ if ("error" in parsedType) return { error: headerParamError(parsedType, "Content-Type", "the request") };
760
+ const boundary = parsedType.params.get("boundary");
761
+ if (!boundary) return { error: "Invalid request: multipart/form-data Content-Type is missing a 'boundary' directive" };
762
+ const buf = rawBytes;
763
+ const delimiter = Buffer.from(`\r\n--${boundary}`, "latin1");
764
+ const dashBoundary = Buffer.from(`--${boundary}`, "latin1");
765
+ const opening = buf.subarray(0, dashBoundary.length).equals(dashBoundary) ? delimiterTail(buf, dashBoundary.length) : void 0;
766
+ let delim = opening ? {
767
+ at: 0,
768
+ ...opening
769
+ } : nextDelimiter(buf, delimiter, 0);
770
+ if (!delim) return { error: "Invalid request: multipart body contains no boundary delimiter" };
771
+ const parts = [];
772
+ while (!delim.closing) {
773
+ const start = delim.next;
774
+ const following = nextDelimiter(buf, delimiter, start);
775
+ if (!following) return { error: "Invalid request: multipart body ended before its closing boundary delimiter" };
776
+ const part = parsePart(buf.subarray(start, following.at), parts.length);
777
+ if ("error" in part) return part;
778
+ parts.push(part);
779
+ delim = following;
780
+ }
781
+ for (const part of parts) {
782
+ const transfer = partEncoding(part, "content-transfer-encoding");
783
+ if (transfer !== void 0 && !IDENTITY_TRANSFER_ENCODINGS.has(transfer)) return { error: unsupportedPartEncoding("Content-Transfer-Encoding", transfer, part.name) };
784
+ const encoding = partEncoding(part, "content-encoding");
785
+ if (encoding !== void 0 && encoding !== "identity") return { error: unsupportedPartEncoding("Content-Encoding", encoding, part.name) };
786
+ }
787
+ const purposeParts = parts.filter((p) => p.name === "purpose");
788
+ if (purposeParts.length > 1) return { error: "Invalid request: multipart body has more than one 'purpose' part; send exactly one" };
789
+ const payloadParts = parts.filter((p) => p.name === "file" || p.name === "content");
790
+ if (payloadParts.length > 1) {
791
+ const [first, second] = payloadParts;
792
+ return { error: `Invalid request: multipart body has more than one payload part ('${first.name}' and '${second.name}'); send exactly one 'file' or 'content' part` };
793
+ }
794
+ const purpose = purposeParts[0]?.body.toString("utf8");
795
+ if (!purpose) return { error: "Invalid parameter: multipart 'purpose' field is required" };
796
+ if (!CREATE_PURPOSES.has(purpose)) return { error: invalidPurpose(purpose) };
797
+ const payload = payloadParts[0];
798
+ if (payload === void 0) return { error: "Invalid request: multipart body must include a 'file' part (or its 'content' alias)" };
799
+ if (payload.body.length > FILES_MAX_BYTES) return { error: `File content exceeds ${FILES_MAX_BYTES} byte cap` };
800
+ const content = Buffer.alloc(payload.body.length);
801
+ payload.body.copy(content);
802
+ let filename = payload.filename;
803
+ if (filename !== void 0) {
804
+ const problem = filenameError(filename);
805
+ if (problem !== void 0) return { error: problem };
806
+ }
807
+ if (filename !== void 0 && filename.trim() === "") filename = void 0;
808
+ if (filename === void 0) filename = synthesizedFilename(content);
809
+ return {
810
+ filename,
811
+ purpose,
812
+ content
813
+ };
814
+ }
815
+ async function handleFilesCreate(req, res, raw, journal, defaults, setCorsHeaders) {
816
+ const path = req.url ?? "/v1/files";
817
+ const method = req.method ?? "POST";
818
+ setCorsHeaders(res);
819
+ if (await applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
820
+ method,
821
+ path,
822
+ headers: flattenHeaders(req.headers),
823
+ body: null,
824
+ service: "files"
825
+ }, "internal", defaults.registry, defaults.logger)) return;
826
+ if (raw === FILES_BODY_OVERSIZED) {
827
+ rejectFiles(res, journal, defaults.logger, method, path, flattenHeaders(req.headers), oversizedBody(), setCorsHeaders);
828
+ return;
829
+ }
830
+ const contentType = Array.isArray(req.headers["content-type"]) ? req.headers["content-type"][0] : req.headers["content-type"];
831
+ const parsed = isMultipartFormData(contentType) ? parseMultipartUpload(raw, contentType) : parseJsonUpload(raw);
832
+ if ("error" in parsed) {
833
+ rejectFiles(res, journal, defaults.logger, method, path, flattenHeaders(req.headers), parsed.error, setCorsHeaders);
834
+ return;
835
+ }
836
+ const obj = storeFile(parsed);
837
+ const { id, bytes } = obj;
838
+ defaults.logger.debug(`Files mock: stored ${id} (${parsed.filename}, ${bytes} bytes)`);
839
+ writeJson(res, 200, obj, setCorsHeaders);
840
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 200, filesUploadBody(parsed));
841
+ }
842
+ /**
843
+ * `GET /v1/files` paging bounds, taken from the `openai/openai-openapi` spec
844
+ * (`operationId: listFiles`): "Limit can range between 1 and 10,000, and the
845
+ * default is 10,000." Files is the odd one out among the list surfaces — most
846
+ * default to 20 — so the default is spelled out here rather than shared.
847
+ */
848
+ const FILES_LIST_MAX_LIMIT = 1e4;
849
+ const FILES_LIST_DEFAULT_LIMIT = FILES_LIST_MAX_LIMIT;
850
+ /**
851
+ * An unsigned decimal integer literal and nothing else — no sign, no exponent,
852
+ * no radix prefix, no surrounding whitespace. See {@link parseListQuery} for
853
+ * what each of those would otherwise be silently accepted as.
854
+ *
855
+ * A long digit run is still safe to hand to `Number()`: it stays finite and
856
+ * integral, so it fails the range check below rather than the type check.
857
+ */
858
+ const DECIMAL_INTEGER_RE = /^[0-9]+$/;
859
+ /**
860
+ * The single non-empty value of a list query parameter, or the 400 it earns.
861
+ *
862
+ * ONE rule for all four of `limit`, `order`, `after` and `purpose`, because the
863
+ * three ways of reading them used to disagree on the same URL:
864
+ *
865
+ * - REPEATED is a 400. `URLSearchParams.get` answers with the FIRST of a
866
+ * repeated parameter and launders the rest, so `?limit=1&limit=abc` paged
867
+ * at 1 and never looked at `abc` — a caller that built its URL twice over
868
+ * got a silent success for a value it never meant. `listFiles` declares
869
+ * each of the four as a single scalar and gives no combining rule for a
870
+ * repeat, so a repeat is a caller error. Matches the multipart body, where
871
+ * two `purpose` parts are already a 400 rather than a last-one-wins.
872
+ * - PRESENT-BUT-EMPTY is a 400. `?order=` was already one (no member of the
873
+ * enum is ""), but `?after=` meant "no cursor" and `?purpose=` meant "no
874
+ * filter" and handed back the WHOLE store — the exact opposite of the
875
+ * narrowing the caller asked for. An empty string is not an object id and
876
+ * not a purpose any stored file can carry, and `?x=` is what building a
877
+ * URL from a partly-undefined params object emits, which is a bug worth
878
+ * showing. Omitting the parameter is how you say "no filter".
879
+ * - ABSENT is `null`, and each caller applies its own default.
880
+ *
881
+ * Both 400s are OURS, not the vendor's: the spec states neither. They are the
882
+ * mock's standing bias towards failing loudly over answering a question the
883
+ * caller did not ask.
884
+ */
885
+ function singleParam(params, name) {
886
+ const all = params.getAll(name);
887
+ if (all.length > 1) return {
888
+ ok: false,
889
+ error: `Invalid parameter: '${name}' was given ${all.length} times; it takes a single value`
890
+ };
891
+ if (all.length === 0) return {
892
+ ok: true,
893
+ value: null
894
+ };
895
+ if (all[0] === "") return {
896
+ ok: false,
897
+ error: `Invalid parameter: '${name}' was given an empty value; omit it instead`
898
+ };
899
+ return {
900
+ ok: true,
901
+ value: all[0]
902
+ };
903
+ }
904
+ /**
905
+ * Parse the `limit` / `order` / `after` / `purpose` parameters off a list URL.
906
+ *
907
+ * Shapes come from `operationId: listFiles` in `openai/openai-openapi` and from
908
+ * `FileListParams extends CursorPageParams` in the vendored SDK (openai
909
+ * 4.104.0 — `resources/files.d.ts` gives `order?: 'asc' | 'desc'` and
910
+ * `purpose?: string`; `pagination.d.ts` gives `after?: string` and
911
+ * `limit?: number`). `order` defaults to `desc`, so the newest file comes back
912
+ * first.
913
+ *
914
+ * All four are read here, through {@link singleParam}, so there is exactly one
915
+ * place that decides what a repeated or empty list parameter means. `purpose`
916
+ * used to be read separately in `src/server.ts` and passed in already parsed,
917
+ * which is how it ended up with neither rule.
918
+ */
919
+ function parseListQuery(rawUrl) {
920
+ const queryStart = rawUrl.indexOf("?");
921
+ const params = new URLSearchParams(queryStart === -1 ? "" : rawUrl.slice(queryStart + 1));
922
+ const rawOrder = singleParam(params, "order");
923
+ if (!rawOrder.ok) return { error: rawOrder.error };
924
+ const orderRaw = rawOrder.value;
925
+ if (orderRaw !== null && orderRaw !== "asc" && orderRaw !== "desc") return { error: "Invalid parameter: 'order' must be one of: asc, desc" };
926
+ const rawLimit = singleParam(params, "limit");
927
+ if (!rawLimit.ok) return { error: rawLimit.error };
928
+ let limit = FILES_LIST_DEFAULT_LIMIT;
929
+ if (rawLimit.value !== null) {
930
+ const parsed = DECIMAL_INTEGER_RE.test(rawLimit.value) ? Number(rawLimit.value) : NaN;
931
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > FILES_LIST_MAX_LIMIT) return { error: `Invalid parameter: 'limit' must be an integer between 1 and ${FILES_LIST_MAX_LIMIT}` };
932
+ limit = parsed;
933
+ }
934
+ const rawAfter = singleParam(params, "after");
935
+ if (!rawAfter.ok) return { error: rawAfter.error };
936
+ const rawPurpose = singleParam(params, "purpose");
937
+ if (!rawPurpose.ok) return { error: rawPurpose.error };
938
+ return {
939
+ limit,
940
+ order: orderRaw ?? "desc",
941
+ after: rawAfter.value ?? void 0,
942
+ purpose: rawPurpose.value ?? void 0
943
+ };
944
+ }
945
+ async function handleFilesList(req, res, journal, defaults, setCorsHeaders) {
946
+ const path = req.url ?? "/v1/files";
947
+ const method = req.method ?? "GET";
948
+ setCorsHeaders(res);
949
+ if (await applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
950
+ method,
951
+ path,
952
+ headers: flattenHeaders(req.headers),
953
+ body: null,
954
+ service: "files"
955
+ }, "internal", defaults.registry, defaults.logger)) return;
956
+ const query = parseListQuery(path);
957
+ if ("error" in query) {
958
+ rejectFiles(res, journal, defaults.logger, method, path, flattenHeaders(req.headers), query.error, setCorsHeaders);
959
+ return;
960
+ }
961
+ let data = [...fileStore.values()];
962
+ if (query.purpose !== void 0) data = data.filter((f) => f.purpose === query.purpose);
963
+ data.sort((a, b) => a.created_at - b.created_at);
964
+ if (query.order === "desc") data.reverse();
965
+ let start = 0;
966
+ if (query.after !== void 0) {
967
+ const idx = data.findIndex((f) => f.id === query.after);
968
+ if (idx === -1) {
969
+ rejectFiles(res, journal, defaults.logger, method, path, flattenHeaders(req.headers), `Invalid parameter: 'after' cursor '${query.after}' does not match any file in this listing`, setCorsHeaders);
970
+ return;
971
+ }
972
+ start = idx + 1;
973
+ }
974
+ const page = data.slice(start, start + query.limit);
975
+ writeJson(res, 200, {
976
+ object: "list",
977
+ data: page,
978
+ first_id: page[0]?.id ?? null,
979
+ last_id: page[page.length - 1]?.id ?? null,
980
+ has_more: start + page.length < data.length
981
+ }, setCorsHeaders);
982
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 200);
983
+ }
984
+ async function handleFilesRetrieve(req, res, fileId, journal, defaults, setCorsHeaders) {
985
+ const path = req.url ?? `/v1/files/${fileId}`;
986
+ const method = req.method ?? "GET";
987
+ setCorsHeaders(res);
988
+ if (await applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
989
+ method,
990
+ path,
991
+ headers: flattenHeaders(req.headers),
992
+ body: null,
993
+ service: "files"
994
+ }, "internal", defaults.registry, defaults.logger)) return;
995
+ const found = fileStore.get(fileId);
996
+ if (!found) {
997
+ writeJson(res, 404, invalidRequest(`No such file: ${fileId}`), setCorsHeaders);
998
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 404);
999
+ return;
1000
+ }
1001
+ writeJson(res, 200, found, setCorsHeaders);
1002
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 200);
1003
+ }
1004
+ async function handleFilesContent(req, res, fileId, journal, defaults, setCorsHeaders) {
1005
+ const path = req.url ?? `/v1/files/${fileId}/content`;
1006
+ const method = req.method ?? "GET";
1007
+ setCorsHeaders(res);
1008
+ if (await applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
1009
+ method,
1010
+ path,
1011
+ headers: flattenHeaders(req.headers),
1012
+ body: null,
1013
+ service: "files"
1014
+ }, "internal", defaults.registry, defaults.logger)) return;
1015
+ const found = fileStore.get(fileId);
1016
+ const content = fileContents.get(fileId);
1017
+ if (!found || content === void 0) {
1018
+ writeJson(res, 404, invalidRequest(`No such file: ${fileId}`), setCorsHeaders);
1019
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 404);
1020
+ return;
1021
+ }
1022
+ res.writeHead(200, {
1023
+ "Content-Type": contentTypeForFilename(found.filename),
1024
+ "Content-Length": String(content.bytes.length),
1025
+ "X-Content-Type-Options": "nosniff",
1026
+ "Content-Disposition": contentDispositionFor(found.filename)
1027
+ });
1028
+ res.end(content.bytes);
1029
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 200);
1030
+ }
1031
+ async function handleFilesDelete(req, res, fileId, journal, defaults, setCorsHeaders) {
1032
+ const path = req.url ?? `/v1/files/${fileId}`;
1033
+ const method = req.method ?? "DELETE";
1034
+ setCorsHeaders(res);
1035
+ if (await applyChaosAsync(res, null, defaults.chaos, req.headers, req.url, journal, {
1036
+ method,
1037
+ path,
1038
+ headers: flattenHeaders(req.headers),
1039
+ body: null,
1040
+ service: "files"
1041
+ }, "internal", defaults.registry, defaults.logger)) return;
1042
+ if (!fileStore.get(fileId)) {
1043
+ writeJson(res, 404, invalidRequest(`No such file: ${fileId}`), setCorsHeaders);
1044
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 404);
1045
+ return;
1046
+ }
1047
+ fileStore.delete(fileId);
1048
+ fileContents.delete(fileId);
1049
+ writeJson(res, 200, {
1050
+ id: fileId,
1051
+ object: "file",
1052
+ deleted: true
1053
+ }, setCorsHeaders);
1054
+ journalFiles(journal, method, path, flattenHeaders(req.headers), 200);
1055
+ }
1056
+
1057
+ //#endregion
1058
+ export { FILES_BODY_DRAIN_MAX_BYTES, FILES_BODY_MAX_BYTES, FILES_BODY_OVERSIZED, FILES_MAX_BYTES, clearFileStore, getStoredFileBytes, handleFilesContent, handleFilesCreate, handleFilesDelete, handleFilesList, handleFilesRetrieve, storeFile };
1059
+ //# sourceMappingURL=files.js.map