@yolk-sdk/emulators 0.1.0-canary.96

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 (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
package/src/r2.ts ADDED
@@ -0,0 +1,890 @@
1
+ /**
2
+ * R2 emulator: a fixture-driven, in-memory fake backend for the host R2 ports `R2Presigner` and
3
+ * `R2ObjectClient`.
4
+ *
5
+ * The R2 connector never talks to R2 itself: hosts implement `R2Presigner` (local SigV4 signing of
6
+ * a PUT URL) and `R2ObjectClient` (conditional get and put). This emulator stands in for such a
7
+ * host at the port level: `call(port, method, request)` takes one port call as plain JSON (the
8
+ * credential-free request; bytes as base64) and answers `{ response }`, `{ failure }`, or a
9
+ * fail-closed `{ notEmulated }`. There is no S3, SigV4 signer, socket, `node:` builtin, or SDK
10
+ * import here: it is a plain structural object, and `r2PortsLayerFromBackend` in
11
+ * `@yolk-sdk/connectors/r2-storage/conformance` turns it into both port layers.
12
+ *
13
+ * Response behaviour comes ONLY from the R2 conformance fixtures (copied as data in
14
+ * `r2-fixtures.ts`). The emulator keeps an in-memory bucket (objects with their etag and bytes)
15
+ * seeded with the object the fixtures describe; the state only selects which fixture answers (the
16
+ * first matching fixture consistent with the bucket) and is updated with what that fixture says
17
+ * happened (an object created or replaced under the etag the fixture names). It never invents an
18
+ * etag, a byte, a failure, or a response. Presigning writes nothing.
19
+ *
20
+ * Credentials: every credential field (the shared port scan's `isPortCredentialKey` names, copied
21
+ * in `r2-guard.ts`) is dropped at any depth before anything is compared or recorded. Every refusal
22
+ * (`unknown-method`, `invalid-request`, `uncheckable-body`, `credential-in-request`,
23
+ * `uncheckable-request`, `no-matching-fixture`, `state-conflict`) is ledgered with constant text
24
+ * only (request `<redacted>`; port and method `<unrecognised>` for `unknown-method` and
25
+ * `uncheckable-request`), uses no fault, and changes no state. Only a request equal to a fixture
26
+ * request (minus credential fields) is recorded, so the ledger holds fixture text, with every
27
+ * `bodyBase64` as `<redacted>` plus `bodyBytes` (the decoded length). An own `__proto__` key at any
28
+ * depth is `invalid-request`. Every `bodyBase64` must be canonical standard base64 of UTF-8 text
29
+ * (else `uncheckable-body`); its decoded text is checked with the request's own texts. A request is
30
+ * `credential-in-request` when any key, string value, number (as printed and, for an integer, as
31
+ * its digit string), or decoded body repeats a guarded value, or holds, once the exact canonical
32
+ * placeholders are blanked out, a SigV4 credential name, a credential query parameter, or a token
33
+ * the shared scan flags (a bearer token, an API-key prefix, a JSON Web Token, a PEM private key).
34
+ * Guarded values are every non-empty key, string, and number under a credential field (a numeric
35
+ * string also in its number forms), with no minimum length, unlike the shared helpers. Both checks
36
+ * run raw, in R2's own decodings (`decodedVariants`: up to three percent and three escape rounds,
37
+ * with `\uXXXX`, `\xXX`, and numeric HTML references), and through the shared fail-closed closure
38
+ * (`textClosureOutcome` of `stateful-secrets.ts`, imported, never copied; a cap refuses). A request
39
+ * the checks cannot walk (cyclic, or nested too deeply) is `uncheckable-request`: `call` never
40
+ * throws. The copied credential key, parameter, and token lists live in `r2-guard.ts`;
41
+ * `test/r2.test.ts` holds one sample per entry of each, asserts each sample count equals its list's
42
+ * length, and checks every sample against the shared scan (`isPortCredentialKey`,
43
+ * `scanPortFixtureForSecrets`) and the emulator, so an entry added here without a sample fails. A
44
+ * name or pattern the shared scan adds later is not enforced until it is copied here with a sample.
45
+ *
46
+ * Request-shape latitude (the only one): credential fields are never compared or recorded, and JSON
47
+ * key order is not compared. Everything else (the endpoint, bucket, key, content type, `maxBytes`,
48
+ * `expectedEtag`, the put `condition`, `bodyBase64`, and `maxUploadBytes`) must equal a fixture
49
+ * request exactly, so only the fixtures' `run-synthetic` run id is emulated. A `bodyBase64` must be
50
+ * canonical standard base64 of UTF-8 text (else `uncheckable-body`); it is compared as sent but
51
+ * recorded only as its decoded length. No object may have an own `__proto__` key (else
52
+ * `invalid-request`), and no key or value, the decoded body included, may carry a credential or
53
+ * repeat a dropped credential value of any length (else `credential-in-request`). Every refusal is
54
+ * ledgered with constant text only (request `<redacted>`). Anything that does not match (an unknown
55
+ * port or method, no matching fixture, or no fixture consistent with the bucket) fails closed with
56
+ * a ledgered `notEmulated` answer, the port analogue of HTTP 400.
57
+ *
58
+ * @experimental
59
+ */
60
+ import { Data, Equal, Predicate, Result } from 'effect'
61
+ import * as Schema from 'effect/Schema'
62
+ import {
63
+ r2EmulatorFixtures,
64
+ type R2EmulatorFailure,
65
+ type R2EmulatorFixture
66
+ } from './r2-fixtures.ts'
67
+ import {
68
+ bindRouteHandlers,
69
+ emulatorRouteKey,
70
+ type EmulatorEvidence,
71
+ type EmulatorRouteEvidence
72
+ } from './route-evidence.ts'
73
+ import {
74
+ guardedValues,
75
+ hasOwnProtoKey,
76
+ textCarriesCredential,
77
+ textsOf,
78
+ withoutCredentials
79
+ } from './r2-guard.ts'
80
+
81
+ export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
82
+
83
+ export { r2EmulatorFixtures, type R2EmulatorFailure, type R2EmulatorFixture }
84
+
85
+ /** The presigner port the presign fixture and manifest route name. */
86
+ export const r2EmulatorPresignerPort = 'R2Presigner'
87
+
88
+ /** The object-client port the get and put fixtures and manifest routes name. */
89
+ export const r2EmulatorObjectClientPort = 'R2ObjectClient'
90
+
91
+ /** Manifest routes of port emulators use this pseudo method; `path` is `<Port>.<method>`. */
92
+ export const r2EmulatorRouteMethod = 'PORT'
93
+
94
+ const portRoute = (
95
+ port: string,
96
+ method: string,
97
+ write: boolean,
98
+ caseIds: ReadonlyArray<string>
99
+ ): EmulatorRouteEvidence => ({
100
+ method: r2EmulatorRouteMethod,
101
+ path: `${port}.${method}`,
102
+ kind: 'connector',
103
+ write,
104
+ caseIds,
105
+ evidence: 'unverified',
106
+ observedAt: undefined
107
+ })
108
+
109
+ const presignUploadUrl = 'r2.presign.put-upload-url'
110
+
111
+ const getMaxBytes = 'r2.objects.get-max-bytes'
112
+
113
+ const getExpectedEtag = 'r2.objects.get-expected-etag'
114
+
115
+ const getMissing = 'r2.objects.get-missing-not-found'
116
+
117
+ const createIfAbsent = 'r2.objects.create-if-absent'
118
+
119
+ const updateIfMatch = 'r2.objects.update-if-match'
120
+
121
+ /**
122
+ * Route evidence manifest: every emulated port method (`PORT <Port>.<method>`) and the R2
123
+ * conformance cases whose (synthetic, unverified) fixtures it follows. `R2ObjectClient.put` is an
124
+ * unverified connector write: it needs a pending entry in the repo's evidence check until an
125
+ * owner-approved live run against a practice bucket verifies it. Presigning is local signing and
126
+ * `get` reads, so neither is a write.
127
+ */
128
+ export const r2EmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = [
129
+ portRoute(r2EmulatorPresignerPort, 'presignPutObject', false, [presignUploadUrl]),
130
+ portRoute(r2EmulatorObjectClientPort, 'get', false, [
131
+ getMaxBytes,
132
+ getExpectedEtag,
133
+ getMissing,
134
+ createIfAbsent,
135
+ updateIfMatch
136
+ ]),
137
+ portRoute(r2EmulatorObjectClientPort, 'put', true, [createIfAbsent, updateIfMatch])
138
+ ]
139
+
140
+ const NonEmpty = Schema.String.check(Schema.isNonEmpty())
141
+
142
+ /** Bytes of canonical standard base64, or `undefined` when it does not decode canonically. */
143
+ const base64Bytes = (text: string): Uint8Array | undefined => {
144
+ let binary: string
145
+
146
+ try {
147
+ binary = atob(text)
148
+ } catch {
149
+ return undefined
150
+ }
151
+
152
+ return btoa(binary) === text
153
+ ? Uint8Array.from(binary, character => character.charCodeAt(0))
154
+ : undefined
155
+ }
156
+
157
+ const Base64 = Schema.String.check(
158
+ Schema.makeFilter((text: string) => base64Bytes(text) !== undefined, {
159
+ identifier: 'R2EmulatorBase64',
160
+ description: 'canonical standard base64'
161
+ })
162
+ )
163
+
164
+ /** One object in the emulated bucket: its key, its etag (quotes kept), and its bytes as base64. */
165
+ export const R2EmulatorObject = Schema.Struct({
166
+ key: NonEmpty,
167
+ etag: NonEmpty,
168
+ bodyBase64: Base64
169
+ })
170
+
171
+ export type R2EmulatorObject = typeof R2EmulatorObject.Type
172
+
173
+ export const R2EmulatorBucket = Schema.Struct({
174
+ name: NonEmpty,
175
+ objects: Schema.Array(R2EmulatorObject)
176
+ })
177
+
178
+ export type R2EmulatorBucket = typeof R2EmulatorBucket.Type
179
+
180
+ /** The emulated buckets: plain JSON, used both as the seed and as the `state()` snapshot. */
181
+ export const R2EmulatorSeed = Schema.Struct({
182
+ buckets: Schema.Array(R2EmulatorBucket)
183
+ })
184
+
185
+ export type R2EmulatorSeed = typeof R2EmulatorSeed.Type
186
+
187
+ /**
188
+ * The synthetic practice bucket the R2 fixtures describe (the default seed): the seeded object the
189
+ * get fixtures read, with the etag and bytes they answer.
190
+ */
191
+ export const r2EmulatorDefaultSeed: R2EmulatorSeed = {
192
+ buckets: [
193
+ {
194
+ name: 'yolk-synthetic-bucket',
195
+ objects: [
196
+ {
197
+ key: 'fixtures/synthetic-object.txt',
198
+ etag: '"a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5"',
199
+ bodyBase64: 'U3ludGhldGljIGNvbmZvcm1hbmNlIG9iamVjdDogc2FmZSB0byByZWFkLgo='
200
+ }
201
+ ]
202
+ }
203
+ ]
204
+ }
205
+
206
+ const Failure = Schema.Struct({
207
+ kind: Schema.Literals(['expected', 'error']),
208
+ code: NonEmpty,
209
+ message: Schema.String,
210
+ status: Schema.optionalKey(Schema.Int)
211
+ })
212
+
213
+ /**
214
+ * A fault: answer the next `count` calls (every one when omitted) of `port` and `method` whose
215
+ * credential-free request contains `match` (a deep subset: objects by key, arrays element by
216
+ * element, other values exactly) with `failure` instead of a fixture. Faults apply only to a call a
217
+ * fixture would answer, and change no bucket state.
218
+ */
219
+ export const R2EmulatorFault = Schema.Struct({
220
+ kind: Schema.Literal('failure'),
221
+ port: NonEmpty,
222
+ method: NonEmpty,
223
+ match: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)),
224
+ count: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
225
+ failure: Failure
226
+ })
227
+
228
+ export type R2EmulatorFault = typeof R2EmulatorFault.Type
229
+
230
+ export type R2EmulatorFaultState = {
231
+ readonly id: number
232
+ readonly fault: R2EmulatorFault
233
+ /** Remaining matching calls; `undefined` for an unlimited fault. */
234
+ readonly remaining: number | undefined
235
+ readonly applied: number
236
+ }
237
+
238
+ /** Thrown by `makeR2Emulator` (invalid seed) and `faults.add` (invalid fault). */
239
+ export class R2EmulatorInputInvalid extends Data.TaggedError('R2EmulatorInputInvalid')<{
240
+ readonly input: 'seed' | 'fault'
241
+ readonly reason: string
242
+ }> {
243
+ override get message(): string {
244
+ return `Invalid R2 emulator ${this.input}: ${this.reason}`
245
+ }
246
+ }
247
+
248
+ /** Why a call failed closed. */
249
+ export type R2EmulatorNotEmulatedReason =
250
+ | 'unknown-method'
251
+ | 'invalid-request'
252
+ | 'uncheckable-body'
253
+ | 'credential-in-request'
254
+ | 'uncheckable-request'
255
+ | 'no-matching-fixture'
256
+ | 'state-conflict'
257
+
258
+ const notEmulatedText: Record<R2EmulatorNotEmulatedReason, string> = {
259
+ 'unknown-method': 'unknown-method: the port and method have no emulated route',
260
+ 'invalid-request': 'invalid-request: the request is not a JSON object',
261
+ 'uncheckable-body': 'uncheckable-body: a bodyBase64 is not canonical base64 of UTF-8 text',
262
+ 'credential-in-request':
263
+ 'credential-in-request: the request carries a credential outside its credential fields',
264
+ 'uncheckable-request': 'uncheckable-request: the request could not be checked',
265
+ 'no-matching-fixture': 'no-matching-fixture: no fixture matches this request',
266
+ 'state-conflict': 'state-conflict: no matching fixture is consistent with the emulated bucket'
267
+ }
268
+
269
+ /** The constant port, method, and request of a ledger entry that records nothing it received. */
270
+ export const r2EmulatorRedacted = '<redacted>'
271
+
272
+ export const r2EmulatorUnrecognised = '<unrecognised>'
273
+
274
+ /** One emulator answer; structurally the reply of a plain-JSON `R2Backend`. */
275
+ export type R2EmulatorReply =
276
+ | { readonly response: Schema.Json }
277
+ | { readonly failure: R2EmulatorFailure }
278
+ | { readonly notEmulated: { readonly reason: string } }
279
+
280
+ export type R2EmulatorLedgerEntry = {
281
+ readonly seq: number
282
+ /** The port, or `<unrecognised>` for an `unknown-method` or `uncheckable-request` refusal. */
283
+ readonly port: string
284
+ /** The method, or `<unrecognised>` for an `unknown-method` or `uncheckable-request` refusal. */
285
+ readonly method: string
286
+ /**
287
+ * For an answered or faulted call, the request without credential fields (a fixture request) with
288
+ * every `bodyBase64` value replaced by `<redacted>`; `<redacted>` whole for every refusal.
289
+ */
290
+ readonly request: Schema.Json
291
+ /** The decoded byte length of a recorded request's top-level `bodyBase64`. */
292
+ readonly bodyBytes?: number
293
+ readonly outcome: 'answered' | 'fault' | 'not-emulated'
294
+ /** `unknown-method` for a call outside the manifest. */
295
+ readonly evidence: EmulatorEvidence | 'unknown-method'
296
+ readonly fixtureId?: string
297
+ readonly faultId?: number
298
+ readonly reason?: R2EmulatorNotEmulatedReason
299
+ }
300
+
301
+ export type R2EmulatorRouteCoverage = EmulatorRouteEvidence & {
302
+ /** Ledger calls to this route since the last ledger clear or reset. */
303
+ readonly calls: number
304
+ }
305
+
306
+ export type R2EmulatorCoverage = {
307
+ readonly routes: ReadonlyArray<R2EmulatorRouteCoverage>
308
+ /** Ledger calls that failed closed. */
309
+ readonly notEmulatedCalls: number
310
+ /** Fixtures that have not answered a call since the emulator was made or last reset. */
311
+ readonly unusedFixtureIds: ReadonlyArray<string>
312
+ }
313
+
314
+ export type R2EmulatorOptions = {
315
+ /** The initial buckets. Defaults to `r2EmulatorDefaultSeed`. */
316
+ readonly seed?: R2EmulatorSeed
317
+ }
318
+
319
+ export type R2Emulator = {
320
+ /** Answer one R2 port call; never throws. This makes the emulator an `R2Backend`. */
321
+ readonly call: (port: string, method: string, request: Schema.Json) => R2EmulatorReply
322
+ /** The current buckets (a copy). */
323
+ readonly state: () => R2EmulatorSeed
324
+ /** The seed the emulator started from (a copy). */
325
+ readonly seed: () => R2EmulatorSeed
326
+ readonly ledger: {
327
+ readonly entries: () => ReadonlyArray<R2EmulatorLedgerEntry>
328
+ readonly clear: () => void
329
+ }
330
+ /** Clear the ledger and faults, forget fixture use, and restore the seeded buckets. */
331
+ readonly reset: () => void
332
+ readonly faults: {
333
+ readonly add: (fault: R2EmulatorFault) => R2EmulatorFaultState
334
+ readonly list: () => ReadonlyArray<R2EmulatorFaultState>
335
+ readonly clear: () => void
336
+ }
337
+ readonly coverage: () => R2EmulatorCoverage
338
+ }
339
+
340
+ // JSON helpers.
341
+
342
+ type JsonObject = Schema.JsonObject
343
+
344
+ const isJsonObject = (value: Schema.Json | undefined): value is JsonObject =>
345
+ value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
346
+
347
+ const objectField = (value: Schema.Json | undefined, key: string): JsonObject | undefined => {
348
+ const field = isJsonObject(value) ? value[key] : undefined
349
+
350
+ return isJsonObject(field) ? field : undefined
351
+ }
352
+
353
+ const stringField = (value: Schema.Json | undefined, key: string): string | undefined => {
354
+ const field = isJsonObject(value) ? value[key] : undefined
355
+
356
+ return Predicate.isString(field) ? field : undefined
357
+ }
358
+
359
+ const numberField = (value: Schema.Json | undefined, key: string): number | undefined => {
360
+ const field = isJsonObject(value) ? value[key] : undefined
361
+
362
+ return Predicate.isNumber(field) ? field : undefined
363
+ }
364
+
365
+ /** The field that carries put bytes as base64; checked and recorded only as decoded text. */
366
+ const bodyField = 'bodyBase64'
367
+
368
+ const utf8 = new TextDecoder('utf-8', { fatal: true })
369
+
370
+ /** The UTF-8 text of canonical standard base64, or `undefined` when it is anything else. */
371
+ const bodyText = (value: Schema.Json): string | undefined => {
372
+ const bytes = Predicate.isString(value) ? base64Bytes(value) : undefined
373
+
374
+ if (bytes === undefined) return undefined
375
+
376
+ try {
377
+ return utf8.decode(bytes)
378
+ } catch {
379
+ return undefined
380
+ }
381
+ }
382
+
383
+ /** Every `bodyBase64` value of `value`, at any depth. */
384
+ const bodyValues = (value: Schema.Json): ReadonlyArray<Schema.Json> => {
385
+ if (Array.isArray(value)) return value.flatMap(bodyValues)
386
+
387
+ if (!isJsonObject(value)) return []
388
+
389
+ return Object.entries(value).flatMap(([key, item]) =>
390
+ key === bodyField ? [item] : bodyValues(item)
391
+ )
392
+ }
393
+
394
+ /** A copy with every `bodyBase64` value replaced by `<redacted>`, at any depth. */
395
+ const withoutBodies = (value: Schema.Json): Schema.Json => {
396
+ if (Array.isArray(value)) return value.map(withoutBodies)
397
+
398
+ if (!isJsonObject(value)) return value
399
+
400
+ return Object.fromEntries(
401
+ Object.entries(value).map(([key, item]) => [
402
+ key,
403
+ key === bodyField ? r2EmulatorRedacted : withoutBodies(item)
404
+ ])
405
+ )
406
+ }
407
+
408
+ /** Deep subset: objects by key, arrays element by element (same length), other values exactly. */
409
+ const containsSubset = (value: Schema.Json | undefined, pattern: Schema.Json): boolean => {
410
+ if (value === undefined) return false
411
+
412
+ if (Array.isArray(pattern)) {
413
+ return (
414
+ Array.isArray(value) &&
415
+ value.length === pattern.length &&
416
+ pattern.every((item, index) => containsSubset(value[index], item))
417
+ )
418
+ }
419
+
420
+ if (isJsonObject(pattern)) {
421
+ return (
422
+ isJsonObject(value) &&
423
+ Object.entries(pattern).every(
424
+ ([key, item]) => Object.hasOwn(value, key) && containsSubset(value[key], item)
425
+ )
426
+ )
427
+ }
428
+
429
+ return Equal.equals(value, pattern)
430
+ }
431
+
432
+ // Bucket state.
433
+
434
+ type ObjectState = { readonly key: string; etag: string; bodyBase64: string }
435
+
436
+ type BucketState = { readonly name: string; objects: Array<ObjectState> }
437
+
438
+ type BucketsState = { readonly buckets: Array<BucketState> }
439
+
440
+ const bucketsFrom = (seed: R2EmulatorSeed): BucketsState => ({
441
+ buckets: seed.buckets.map(bucket => ({
442
+ name: bucket.name,
443
+ objects: bucket.objects.map(object => ({ ...object }))
444
+ }))
445
+ })
446
+
447
+ const snapshotOf = (state: BucketsState): R2EmulatorSeed => ({
448
+ buckets: state.buckets.map(bucket => ({
449
+ name: bucket.name,
450
+ objects: bucket.objects.map(object => ({ ...object }))
451
+ }))
452
+ })
453
+
454
+ const bucketOf = (state: BucketsState, request: JsonObject): BucketState | undefined => {
455
+ const name = stringField(request, 'bucket')
456
+
457
+ return state.buckets.find(bucket => bucket.name === name)
458
+ }
459
+
460
+ const objectIn = (bucket: BucketState, request: JsonObject): ObjectState | undefined => {
461
+ const key = stringField(request, 'key')
462
+
463
+ return bucket.objects.find(object => object.key === key)
464
+ }
465
+
466
+ const byteLength = (bodyBase64: string | undefined): number | undefined =>
467
+ bodyBase64 === undefined ? undefined : base64Bytes(bodyBase64)?.byteLength
468
+
469
+ /** A state model for one route: whether a fixture is consistent, and what it changes. */
470
+ type RouteModel = {
471
+ readonly consistent: (
472
+ state: BucketsState,
473
+ request: JsonObject,
474
+ fixture: R2EmulatorFixture
475
+ ) => boolean
476
+ readonly apply: (state: BucketsState, request: JsonObject, fixture: R2EmulatorFixture) => void
477
+ }
478
+
479
+ /** Presigning is local signing: every matching fixture is consistent, and nothing changes. */
480
+ const presignModel: RouteModel = {
481
+ consistent: () => true,
482
+ apply: () => undefined
483
+ }
484
+
485
+ const getModel: RouteModel = {
486
+ consistent: (state, request, fixture) => {
487
+ const bucket = bucketOf(state, request)
488
+
489
+ if (bucket === undefined) return false
490
+
491
+ const object = objectIn(bucket, request)
492
+ const expected = stringField(request, 'expectedEtag')
493
+ const maxBytes = numberField(request, 'maxBytes')
494
+ const size = byteLength(object?.bodyBase64)
495
+ const etagHolds = object !== undefined && (expected === undefined || expected === object.etag)
496
+
497
+ if (fixture.failure !== undefined) {
498
+ switch (fixture.failure.code) {
499
+ case 'not_found':
500
+ return object === undefined
501
+ case 'conflict':
502
+ return object !== undefined && expected !== undefined && expected !== object.etag
503
+ case 'response_too_large':
504
+ return etagHolds && size !== undefined && maxBytes !== undefined && size > maxBytes
505
+ default:
506
+ // No other get failure is recorded with a state it follows from.
507
+ return false
508
+ }
509
+ }
510
+
511
+ return (
512
+ object !== undefined &&
513
+ etagHolds &&
514
+ size !== undefined &&
515
+ maxBytes !== undefined &&
516
+ size <= maxBytes &&
517
+ Equal.equals(fixture.response, {
518
+ etag: object.etag,
519
+ size,
520
+ bodyBase64: object.bodyBase64
521
+ })
522
+ )
523
+ },
524
+ apply: () => undefined
525
+ }
526
+
527
+ const putModel: RouteModel = {
528
+ consistent: (state, request, fixture) => {
529
+ const bucket = bucketOf(state, request)
530
+
531
+ if (bucket === undefined) return false
532
+
533
+ const object = objectIn(bucket, request)
534
+ const condition = objectField(request, 'condition')
535
+ const kind = stringField(condition, 'kind')
536
+ const conditionEtag = stringField(condition, 'etag')
537
+
538
+ if (fixture.failure !== undefined) {
539
+ if (fixture.failure.code !== 'conflict') return false
540
+
541
+ return kind === 'absent'
542
+ ? object !== undefined
543
+ : kind === 'etag' && object !== undefined && object.etag !== conditionEtag
544
+ }
545
+
546
+ const size = byteLength(stringField(request, 'bodyBase64'))
547
+ const maxUploadBytes = numberField(request, 'maxUploadBytes')
548
+ const etag = stringField(fixture.response, 'etag')
549
+
550
+ const conditionHolds =
551
+ kind === 'absent'
552
+ ? object === undefined
553
+ : kind === 'etag' &&
554
+ object !== undefined &&
555
+ object.etag === conditionEtag &&
556
+ etag !== object.etag
557
+
558
+ return (
559
+ conditionHolds &&
560
+ etag !== undefined &&
561
+ size !== undefined &&
562
+ maxUploadBytes !== undefined &&
563
+ size <= maxUploadBytes &&
564
+ numberField(fixture.response, 'size') === size
565
+ )
566
+ },
567
+ apply: (state, request, fixture) => {
568
+ const bucket = bucketOf(state, request)
569
+ const key = stringField(request, 'key')
570
+ const bodyBase64 = stringField(request, 'bodyBase64')
571
+ const etag = stringField(fixture.response, 'etag')
572
+
573
+ if (
574
+ fixture.failure !== undefined ||
575
+ bucket === undefined ||
576
+ key === undefined ||
577
+ bodyBase64 === undefined ||
578
+ etag === undefined
579
+ ) {
580
+ return
581
+ }
582
+
583
+ const object = objectIn(bucket, request)
584
+
585
+ if (object === undefined) {
586
+ bucket.objects.push({ key, etag, bodyBase64 })
587
+ } else {
588
+ object.etag = etag
589
+ object.bodyBase64 = bodyBase64
590
+ }
591
+ }
592
+ }
593
+
594
+ const routePath = (port: string, method: string) => `${port}.${method}`
595
+
596
+ const routeModels: ReadonlyMap<string, RouteModel> = new Map([
597
+ [
598
+ emulatorRouteKey(r2EmulatorRouteMethod, routePath(r2EmulatorPresignerPort, 'presignPutObject')),
599
+ presignModel
600
+ ],
601
+ [emulatorRouteKey(r2EmulatorRouteMethod, routePath(r2EmulatorObjectClientPort, 'get')), getModel],
602
+ [emulatorRouteKey(r2EmulatorRouteMethod, routePath(r2EmulatorObjectClientPort, 'put')), putModel]
603
+ ])
604
+
605
+ const strict = { onExcessProperty: 'error' } as const
606
+
607
+ const decodeSeed = Schema.decodeUnknownResult(R2EmulatorSeed, strict)
608
+
609
+ const decodeFault = Schema.decodeUnknownResult(R2EmulatorFault, strict)
610
+
611
+ const issueText = (error: Schema.SchemaError): string => error.message
612
+
613
+ /** Bucket names, and object keys within a bucket, must be unique so fixtures address one object. */
614
+ const seedProblem = (seed: R2EmulatorSeed): string | undefined => {
615
+ const names = seed.buckets.map(bucket => bucket.name)
616
+
617
+ if (new Set(names).size !== names.length) {
618
+ return 'bucket names must be unique'
619
+ }
620
+
621
+ for (const bucket of seed.buckets) {
622
+ const keys = bucket.objects.map(object => object.key)
623
+
624
+ if (new Set(keys).size !== keys.length) {
625
+ return `object keys in ${bucket.name} must be unique`
626
+ }
627
+ }
628
+
629
+ return undefined
630
+ }
631
+
632
+ /**
633
+ * Create an R2 emulator. Each call has its own buckets, ledger, faults, and fixture use.
634
+ *
635
+ * `call(port, method, request)` answers in this order: a port and method outside the manifest
636
+ * (`unknown-method`), a non-object request or one with an own `__proto__` key (`invalid-request`),
637
+ * a `bodyBase64` that is not canonical base64 of UTF-8 text (`uncheckable-body`), or a credential
638
+ * outside the credential fields (`credential-in-request`) fails closed; otherwise the fixtures
639
+ * whose port, method, and credential-free request match are checked against the buckets, and the
640
+ * first consistent one (preferring one not used since the last reset) is chosen; no match
641
+ * (`no-matching-fixture`) or no consistent match (`state-conflict`) fails closed. Only then does
642
+ * the first active matching fault answer its failure (changing nothing); otherwise the chosen
643
+ * fixture answers and updates the buckets. Every refusal is ledgered with constant text only; a
644
+ * request the checks cannot walk is refused as `uncheckable-request`, so `call` never throws.
645
+ *
646
+ * Throws `R2EmulatorInputInvalid` for an invalid seed.
647
+ */
648
+ export const makeR2Emulator = (options: R2EmulatorOptions = {}): R2Emulator => {
649
+ const seedResult = decodeSeed(options.seed ?? r2EmulatorDefaultSeed)
650
+
651
+ if (Result.isFailure(seedResult)) {
652
+ throw new R2EmulatorInputInvalid({ input: 'seed', reason: issueText(seedResult.failure) })
653
+ }
654
+
655
+ const seed = seedResult.success
656
+ const problem = seedProblem(seed)
657
+
658
+ if (problem !== undefined) {
659
+ throw new R2EmulatorInputInvalid({ input: 'seed', reason: problem })
660
+ }
661
+
662
+ const bound = new Map(
663
+ bindRouteHandlers(r2EmulatorRoutes, routeModels).map(({ route, handler }) => [
664
+ route.path,
665
+ { route, model: handler }
666
+ ])
667
+ )
668
+
669
+ let state = bucketsFrom(seed)
670
+ let entries: Array<R2EmulatorLedgerEntry> = []
671
+
672
+ let faults: Array<{
673
+ id: number
674
+ fault: R2EmulatorFault
675
+ remaining: number | undefined
676
+ applied: number
677
+ }> = []
678
+
679
+ let nextFaultId = 1
680
+ let nextSeq = 1
681
+ const used = new Set<string>()
682
+
683
+ const record = (entry: Omit<R2EmulatorLedgerEntry, 'seq'>) => {
684
+ entries.push({ seq: nextSeq, ...entry })
685
+ nextSeq += 1
686
+ }
687
+
688
+ const refuse = (
689
+ entry: Pick<R2EmulatorLedgerEntry, 'port' | 'method' | 'request' | 'evidence'>,
690
+ reason: R2EmulatorNotEmulatedReason
691
+ ): R2EmulatorReply => {
692
+ record({ ...entry, outcome: 'not-emulated', reason })
693
+
694
+ return { notEmulated: { reason: notEmulatedText[reason] } }
695
+ }
696
+
697
+ const answer = (port: string, method: string, rawRequest: Schema.Json): R2EmulatorReply => {
698
+ const target = bound.get(routePath(port, method))
699
+
700
+ if (target === undefined) {
701
+ // Nothing from an unrecognised call is recorded: its port, method, or request may hold
702
+ // anything.
703
+ return refuse(
704
+ {
705
+ port: r2EmulatorUnrecognised,
706
+ method: r2EmulatorUnrecognised,
707
+ request: r2EmulatorRedacted,
708
+ evidence: 'unknown-method'
709
+ },
710
+ 'unknown-method'
711
+ )
712
+ }
713
+
714
+ const evidence = target.route.evidence
715
+
716
+ // Every refusal below records constant text only: no request text reaches the ledger unless
717
+ // the request equals a fixture request (minus credential fields).
718
+ const constant = { port, method, request: r2EmulatorRedacted, evidence }
719
+
720
+ if (!isJsonObject(rawRequest) || hasOwnProtoKey(rawRequest)) {
721
+ return refuse(constant, 'invalid-request')
722
+ }
723
+
724
+ const request = withoutCredentials(rawRequest)
725
+
726
+ // Bytes are checked as the text they decode to; anything else cannot be checked.
727
+ const bodies = bodyValues(request).map(bodyText)
728
+ const decodedBodies = bodies.flatMap(body => (body === undefined ? [] : [body]))
729
+
730
+ if (decodedBodies.length !== bodies.length) {
731
+ return refuse(constant, 'uncheckable-body')
732
+ }
733
+
734
+ const guarded = guardedValues(rawRequest)
735
+
736
+ if (
737
+ [...textsOf(request), ...decodedBodies].some(text => textCarriesCredential(text, guarded))
738
+ ) {
739
+ return refuse(constant, 'credential-in-request')
740
+ }
741
+
742
+ const matching = r2EmulatorFixtures.filter(
743
+ fixture =>
744
+ fixture.port === port &&
745
+ fixture.method === method &&
746
+ isJsonObject(fixture.request) &&
747
+ Equal.equals(withoutCredentials(fixture.request), request)
748
+ )
749
+
750
+ if (matching.length === 0) {
751
+ return refuse(constant, 'no-matching-fixture')
752
+ }
753
+
754
+ const consistent = matching.filter(fixture => target.model.consistent(state, request, fixture))
755
+ const chosen = consistent.find(fixture => !used.has(fixture.id)) ?? consistent[0]
756
+
757
+ if (chosen === undefined) {
758
+ return refuse(constant, 'state-conflict')
759
+ }
760
+
761
+ // From here on the request equals a fixture request: what is recorded is fixture text, with
762
+ // the body only as its decoded length.
763
+ const bodyBytes = byteLength(stringField(request, bodyField))
764
+ const ledgerRequest = withoutBodies(request)
765
+
766
+ const recorded = (entry: Pick<R2EmulatorLedgerEntry, 'outcome' | 'fixtureId' | 'faultId'>) => {
767
+ const base = { port, method, request: ledgerRequest, evidence, ...entry }
768
+
769
+ record(bodyBytes === undefined ? base : { ...base, bodyBytes })
770
+ }
771
+
772
+ // Faults apply only to a call a fixture would answer: a refused call above leaves every fault
773
+ // untouched. A fault changes no bucket state.
774
+ const fault = faults.find(
775
+ candidate =>
776
+ candidate.fault.port === port &&
777
+ candidate.fault.method === method &&
778
+ (candidate.remaining === undefined || candidate.remaining > 0) &&
779
+ (candidate.fault.match === undefined || containsSubset(request, candidate.fault.match))
780
+ )
781
+
782
+ if (fault !== undefined) {
783
+ fault.applied += 1
784
+
785
+ if (fault.remaining !== undefined) {
786
+ fault.remaining -= 1
787
+ }
788
+
789
+ recorded({ outcome: 'fault', faultId: fault.id })
790
+
791
+ return { failure: fault.fault.failure }
792
+ }
793
+
794
+ target.model.apply(state, request, chosen)
795
+ used.add(chosen.id)
796
+ recorded({ outcome: 'answered', fixtureId: chosen.id })
797
+
798
+ return chosen.failure === undefined
799
+ ? { response: chosen.response }
800
+ : { failure: chosen.failure }
801
+ }
802
+
803
+ /**
804
+ * `answer`, never throwing: a request the checks cannot walk (a cyclic or too deeply nested
805
+ * value) is ledgered with constant text and answered not-emulated. Every check runs before any
806
+ * fault is used or any state changes, so a throw leaves both untouched.
807
+ */
808
+ const call = (port: string, method: string, rawRequest: Schema.Json): R2EmulatorReply => {
809
+ try {
810
+ return answer(port, method, rawRequest)
811
+ } catch {
812
+ return refuse(
813
+ {
814
+ port: r2EmulatorUnrecognised,
815
+ method: r2EmulatorUnrecognised,
816
+ request: r2EmulatorRedacted,
817
+ evidence: 'unknown-method'
818
+ },
819
+ 'uncheckable-request'
820
+ )
821
+ }
822
+ }
823
+
824
+ const faultState = (fault: (typeof faults)[number]): R2EmulatorFaultState => ({
825
+ id: fault.id,
826
+ fault: fault.fault,
827
+ remaining: fault.remaining,
828
+ applied: fault.applied
829
+ })
830
+
831
+ return {
832
+ call,
833
+ state: () => snapshotOf(state),
834
+ seed: () => snapshotOf(bucketsFrom(seed)),
835
+ ledger: {
836
+ entries: () => entries.map(entry => ({ ...entry })),
837
+ clear: () => {
838
+ entries = []
839
+ }
840
+ },
841
+ reset: () => {
842
+ state = bucketsFrom(seed)
843
+ entries = []
844
+ faults = []
845
+ used.clear()
846
+ },
847
+ faults: {
848
+ add: input => {
849
+ const decoded = decodeFault(input)
850
+
851
+ if (Result.isFailure(decoded)) {
852
+ throw new R2EmulatorInputInvalid({ input: 'fault', reason: issueText(decoded.failure) })
853
+ }
854
+
855
+ if (!bound.has(routePath(decoded.success.port, decoded.success.method))) {
856
+ throw new R2EmulatorInputInvalid({
857
+ input: 'fault',
858
+ reason: 'the port and method have no emulated route'
859
+ })
860
+ }
861
+
862
+ const added = {
863
+ id: nextFaultId,
864
+ fault: decoded.success,
865
+ remaining: decoded.success.count,
866
+ applied: 0
867
+ }
868
+
869
+ nextFaultId += 1
870
+ faults.push(added)
871
+
872
+ return faultState(added)
873
+ },
874
+ list: () => faults.map(faultState),
875
+ clear: () => {
876
+ faults = []
877
+ }
878
+ },
879
+ coverage: () => ({
880
+ routes: r2EmulatorRoutes.map(route => ({
881
+ ...route,
882
+ calls: entries.filter(entry => routePath(entry.port, entry.method) === route.path).length
883
+ })),
884
+ notEmulatedCalls: entries.filter(entry => entry.outcome === 'not-emulated').length,
885
+ unusedFixtureIds: r2EmulatorFixtures.flatMap(fixture =>
886
+ used.has(fixture.id) ? [] : [fixture.id]
887
+ )
888
+ })
889
+ }
890
+ }