@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
@@ -0,0 +1,2571 @@
1
+ /**
2
+ * Shared wrapper of the fixture-only stateful connector emulators (internal; used by `/dropbox`,
3
+ * `/notion`, `/todoist`, `/telegram`, `/github`, `/google`, `/linkedin-search`, and `/mcp`, not by
4
+ * the earlier `/fortnox` and `/microsoft` emulators, which keep their own).
5
+ *
6
+ * The emulator state lives in an `@emulators/core` custom runtime that the Node-only subpath
7
+ * creates and hands in (`src/stateful-core.ts`; this module imports no Node builtin and never
8
+ * imports the core); the request ledger, status faults, credential handling, and the
9
+ * `/_emulate/*` control plane live here, because the core reserves `/_emulate`.
10
+ *
11
+ * The owner rule: response behaviour comes only from the committed conformance fixtures. Every
12
+ * request is answered by its route or with one ledgered 400 not-emulated
13
+ * (`{ error: { type: 'not_emulated', message } }`, `notEmulated` in the ledger). Precedence per
14
+ * request:
15
+ *
16
+ * 1. route match on the raw path (path parameters decoded once; unknown routes and methods, and
17
+ * invalid percent-encoding, are not emulated), and the origin the route is recorded on;
18
+ * 2. a non-empty `Authorization: Bearer` credential (never compared against anything, stored,
19
+ * forwarded, or ledgered) and the emulator's header rules;
20
+ * 3. the body the route takes (none, JSON with an `application/json` media type, or raw bytes);
21
+ * 4. the route's request-shape check, which reads no state;
22
+ * 5. in the core runtime, the route's plan: a pure, state-reading eligibility check that refuses
23
+ * what the state cannot answer the way a fixture does, and otherwise returns a commit;
24
+ * 6. the first matching status fault, decided only for an eligible request (nothing is written);
25
+ * 7. the commit, the only step that writes.
26
+ *
27
+ * A request that is not emulated (steps 1 to 5) never uses up a fault. The plan, the fault
28
+ * decision, and the commit run synchronously together, so no other request interleaves. A route
29
+ * that throws answers an evidence-tagged 500 emulator error
30
+ * (`responseError` in the ledger); a closed emulator answers 503. No recovery answer reads the
31
+ * injectable clock, so a failing clock cannot change it. Every response of a matched route
32
+ * carries `x-emulator-evidence: unverified` when the route is unverified. Ledgered bodies and
33
+ * query parameters have credential-named keys redacted.
34
+ *
35
+ * Fail-closed mode (opt-in, `failClosed`; used by `/github`, `/google`, and `/linkedin-search`):
36
+ * every route parameter has a raw pattern (matched in full), and a request is recognised only when
37
+ * its raw path is exactly an emulated route shape under that route's method and any `Authorization`
38
+ * header is exactly `Bearer <at least 8 non-space characters>` (a recognisable bearer, below).
39
+ * Every other request is ledgered and answered with constant text only (`/<unrecognised>`, a
40
+ * standard method or `<other>`, an empty query, no body, a constant reason). A recognised bearer
41
+ * must match the RFC 6750 `b64token` syntax exactly (`^[A-Za-z0-9\-._~+/]+=*$`, at least 8
42
+ * characters), start with a character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, and hold
43
+ * at least one character outside the JSON-number alphabet `[0-9.eE+-]` (every GitHub and Google
44
+ * token form does: `ghp_…`, `github_pat_…`, `gho_…`, `ya29.…`). So no number's text can contain it;
45
+ * it holds no escape introducer (`%`, `\`, `"`), so no escape starts inside it; and its first
46
+ * character is no hex digit and no JSON escape letter, so no stray `%`, `\`, or partial escape to
47
+ * its left can complete with it, and its characters always decode in place. An `Authorization`
48
+ * header with any other value is unrecognisable. A recognised request that repeats the bearer value
49
+ * in its raw path, any path segment, the raw query or any query key or value, any recorded header,
50
+ * or its body is refused and ledgered with constant text only: a standard method, the path
51
+ * `/<unrecognised>`, its route template, an empty query, no headers or body, and a constant reason
52
+ * (`the query repeats the credential`, for example). Each part is checked through the closure of
53
+ * two total, lexical transforms that cannot fail: a tolerant percent-decode (every `%XX` below
54
+ * `%80` becomes its ASCII character; any other `%` sequence is left as it is) and a tolerant
55
+ * JSON-unescape (in any text, whether or not it parses as JSON, `\uXXXX` below `\u0080` and `\"`,
56
+ * `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t` become their characters). Starting from each part's raw
57
+ * text, either transform is applied to every text of the previous step, deduplicated, until no new
58
+ * text appears (a fixpoint), and every text is checked for the bearer as a substring; both
59
+ * transforms never lengthen a text and shorten it whenever they change it. So any depth of
60
+ * percent-encoding or JSON escaping, in any order, is seen through in every part: the raw path and
61
+ * each raw path segment, the raw query and each query key and value (already decoded once by
62
+ * `URLSearchParams`), each recorded header, and the raw body. The work is capped at 64 rounds, 1024
63
+ * distinct texts, or 8 Mi characters read by the transforms, whichever comes first; a part whose
64
+ * closure hits a cap before its fixpoint counts as repeating the credential and is refused with the
65
+ * same constant entry (uncertainty refuses, it never admits; so any part over 4 Mi characters is
66
+ * always refused). Any other recognised request has the bearer value scrubbed from its ledgered
67
+ * fields and every not-emulated reason (plan-time reasons included); its recorded query is keyed by
68
+ * recorded key; a key recorded more than once lists its values in order (as a JSON array); and
69
+ * recorded headers and query keys and values that start like JSON (`{`, `[`, `"`) are recorded
70
+ * parsed with credential-named keys redacted at any depth, or as `<redacted>` when they do not
71
+ * parse, whatever the header's declared format. Empty query components (a bare `?`, a stray `&`)
72
+ * are refused. Routes check their own query and body keys with `exactQuery` and `exactBodyKeys`,
73
+ * whose reasons never echo a request's own key (`exactQuery`'s opt-in `rawNames`, used by
74
+ * `/linkedin-search`, also refuses a parameter name in any but its plain form, comparing the raw
75
+ * names the wrapper hands routes as `rawQuery`). A template parameter written `{name+}` spans one
76
+ * or more path segments (each decoded once, none may decode to a `/`). The credential helpers live
77
+ * in `src/stateful-secrets.ts`. A route may also name decoded views of its raw body
78
+ * (`decodedViews`, opt-in; `/google` gives the base64url-decoded MIME of a Gmail draft's
79
+ * `message.raw`, which the provider's own wire format wraps): in fail-closed mode each view goes
80
+ * through the same fixpoint check as the raw body, before anything is recorded, a fault is decided,
81
+ * or anything is committed, and a hit is the same constant credential-repeat entry. A view may
82
+ * throw to refuse a body it cannot check completely: that request is ledgered as the same constant
83
+ * entry, as a repeat when the `DecodedViewRefusal` it threw carries cleanly decoded text holding
84
+ * the bearer, else with the refusal's reason when the route declares it in `viewRefusalReasons` (a
85
+ * constant the route owns, scrubbed defensively), else with
86
+ * `the request body cannot be checked for the credential`. A route without `decodedViews` is
87
+ * checked exactly as before. An emulator may also opt in to a per-origin bearer digest
88
+ * (`bearerDigest`, fail-closed mode only; `/linkedin-search` uses it): routes then see a one-way
89
+ * digest of the bearer for the origin the request arrived on (`EmulatedRequest.bearerDigest`),
90
+ * never the bearer, so a seed can mark a key as rejected on one origin by its digest, and the
91
+ * bearer still never reaches the state, the ledger, or `/_emulate/*`. A digest that throws or
92
+ * repeats the bearer answers the 500 emulator error (`responseError`). Without it, routes see no
93
+ * digest, as before.
94
+ *
95
+ * More opt-ins serve `/mcp`, whose JSON-RPC methods share one HTTP endpoint; a route or emulator
96
+ * that does not take them behaves exactly as before. A route may answer several manifest rows
97
+ * (`variants`, for example `RPC <origin><path>#<method>`): its admission names the row, which
98
+ * becomes the request's ledger route, its coverage row, and what a fault's `match.route` compares
99
+ * (a request refused before admission keeps the route template). A fault's `match.route` must name
100
+ * a manifest row (a variant, or the template of a route without variants); any other value is
101
+ * rejected when the fault is added, and `match.method` is always the HTTP method. A plan may return
102
+ * a `StreamedCommit`: the answer it prepares, and the `commit` that writes. Faults are decided
103
+ * against the prepared answer, and a faulted request never runs `commit`; an emulator built with
104
+ * `makeChunkedStatefulEmulator` also takes `truncate-after-chunks` faults, which send the prepared
105
+ * answer cut short and write nothing (one that cannot take effect answers 500 and is not used up).
106
+ * In fail-closed mode: `constantRefusals` ledgers every refusal with constant text only
107
+ * (`/<unrecognised>`, no query, headers, or body), so request text reaches the ledger only once a
108
+ * route admitted the request; `guardAllHeaders` checks every request header name and value but
109
+ * `Authorization` for a credential repeat, not only the recorded ones; and `guardOutput` checks a
110
+ * streamed commit's prepared answer (every header and chunk) and its `persisted` texts (minted ids,
111
+ * for example) for the bearer before any fault is decided or anything is committed, refusing a hit
112
+ * with the constant credential-repeat entry, so no answer or stored value repeats the bearer, while
113
+ * routes still see only its digest. `uniqueJsonKeys` refuses a JSON body in which an object repeats
114
+ * a key (after unescaping). Routes also see every request header name
115
+ * (`EmulatedRequest.headerNames`), never a credential value.
116
+ *
117
+ * Resolved mode (opt-in, `resolveRequest`; not with `failClosed`; used by `/todoist` and
118
+ * `/telegram`) serves an emulator whose credential the wrapper cannot find by itself (a Telegram
119
+ * bot token in a path segment) or that has its own credential rules (a Todoist bearer of any 8
120
+ * non-space characters): the emulator resolves every request itself, failing closed
121
+ * (`StatefulResolution`): an unrecognised request is ledgered with constant text only (the method
122
+ * as sent when standard, else `<other>`), a request it refuses before its body is read keeps its
123
+ * scrubbed fields, and a recognised one names its route, credential-free parameters, ledger path,
124
+ * guarded path, and the credential values to guard. Those values are scrubbed from everything the
125
+ * ledger keeps or a refusal answers (plan-time reasons included), and a query, guarded path, or
126
+ * body that repeats one (raw or percent-decoded once, and in any parsed JSON key, string, or
127
+ * number) is refused with a constant reason before anything else is checked. The ledger records
128
+ * the resolution's path and the query as sent (credential-named keys redacted), faults'
129
+ * `match.path` compares that path, and routes and the core see it too (routes see no header
130
+ * names and read no header but `content-type`), so the credential never reaches a route or the
131
+ * core. The request body is read once, after the query and path checks (so a query or path refusal
132
+ * leaves it unread, and any later refusal finds it read); a body that is already consumed or
133
+ * locked is refused as unreadable; a refusal reason that cannot be percent-encoded (an unpaired
134
+ * surrogate) is a handler failure (500). Fault answers and refusals are decided in the core with
135
+ * the commit, but returned by the wrapper itself, so a reset or a close before they are read never
136
+ * cancels them (only a commit's answer is the core's own); a core that answers without running
137
+ * the dispatch (closed meanwhile) gives the 500 handler failure (`responseError`
138
+ * `the route handler answered no eligibility verdict`). Resolved mode records no request
139
+ * header (`recordHeaders` must be empty). More opt-ins, off by default: a `json-or-empty` route
140
+ * body (JSON of any media type, or none), `makeHeaderlessStatefulEmulator` (ledger entries without
141
+ * a `headers` field), and `errorTexts` (the texts of the recovery answers).
142
+ *
143
+ * @experimental
144
+ */
145
+ import { Data, Predicate, Result } from 'effect'
146
+ import * as Schema from 'effect/Schema'
147
+ import {
148
+ EmulatorHeaderRecord,
149
+ answeredOutsideCore,
150
+ emulatorJobHeader,
151
+ handlerFailedHeader,
152
+ handlerFailedResponse,
153
+ isCredentialHeaderName,
154
+ isCredentialQueryKey,
155
+ redactCredentialFields,
156
+ redactCredentialQuery,
157
+ redactedCredentialValue
158
+ } from './emulator-http.ts'
159
+ import {
160
+ emulatorEvidenceHeader,
161
+ emulatorRouteKey,
162
+ type EmulatorEvidence,
163
+ type EmulatorRouteEvidence
164
+ } from './route-evidence.ts'
165
+ import {
166
+ isRecognisableBearerValue,
167
+ jsonRepeatsSecret,
168
+ repeatsSecret,
169
+ scrubSecrets,
170
+ textRepeatsSecret,
171
+ unrecognisedLedgerPath,
172
+ unrecognisedMethod
173
+ } from './stateful-secrets.ts'
174
+
175
+ /** A request the emulator does not emulate, with the reason (answered 400 not-emulated). */
176
+ export class NotEmulated extends Data.TaggedClass('NotEmulated')<{ readonly reason: string }> {}
177
+
178
+ /**
179
+ * What a route's decoded view throws to refuse a body with a reason of its own (fail-closed mode;
180
+ * see `StatefulRouteBinding.decodedViews`). `reason` must be one of the route's declared
181
+ * `viewRefusalReasons` (a constant the route owns, never derived from the request); `decoded` holds
182
+ * any text the view did decode cleanly, which is still checked for the bearer first.
183
+ */
184
+ export class DecodedViewRefusal extends Data.TaggedError('DecodedViewRefusal')<{
185
+ readonly reason: string
186
+ readonly decoded: ReadonlyArray<string>
187
+ }> {}
188
+
189
+ export const notEmulated = (reason: string): NotEmulated => new NotEmulated({ reason })
190
+
191
+ export const isNotEmulated = (value: unknown): value is NotEmulated => value instanceof NotEmulated
192
+
193
+ export const jsonResponse = (
194
+ status: number,
195
+ body: unknown,
196
+ headers: HeadersInit = {}
197
+ ): Response => {
198
+ const responseHeaders = new Headers(headers)
199
+
200
+ if (!responseHeaders.has('content-type')) {
201
+ responseHeaders.set('content-type', 'application/json')
202
+ }
203
+
204
+ return new Response(JSON.stringify(body), { status, headers: responseHeaders })
205
+ }
206
+
207
+ /** The one answer for everything no fixture records. */
208
+ export const notEmulatedResponse = (reason: string): Response =>
209
+ jsonResponse(400, { error: { type: 'not_emulated', message: `Not emulated: ${reason}` } })
210
+
211
+ const emulatorError = (status: number, message: string, headers: HeadersInit = {}): Response =>
212
+ jsonResponse(status, { error: { message, type: 'emulator_error' } }, headers)
213
+
214
+ // Fault statuses are errors only (400-599), so no control answers a success no fixture records.
215
+ // One range check, so a rejection names the range actually accepted (400-599 holds no 204, 205,
216
+ // or 3xx, which the shared response-status schema otherwise excludes).
217
+ const FaultStatus = Schema.Int.check(Schema.isBetween({ minimum: 400, maximum: 599 }))
218
+
219
+ const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
220
+
221
+ const ChunkCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))
222
+
223
+ /**
224
+ * Optional fault filter; an omitted field matches every request. `method` is the HTTP method
225
+ * (never a manifest row's `RPC`). `path` is the raw request path (in resolved mode, the
226
+ * resolution's ledger path); ending in `*`, a prefix. `route` is a manifest row path, compared
227
+ * with the request's ledger route: the template of its matched route, or the manifest variant its
228
+ * route admitted it as (see `StatefulRouteBinding.variants`).
229
+ * A `route` that names no manifest row of the emulator could never match, so adding the fault
230
+ * rejects it (a route with variants is matched by its variant rows, never its template).
231
+ */
232
+ export const StatefulFaultMatch = Schema.Struct({
233
+ method: Schema.optionalKey(Schema.String),
234
+ path: Schema.optionalKey(Schema.String),
235
+ route: Schema.optionalKey(Schema.String)
236
+ })
237
+
238
+ export type StatefulFaultMatch = typeof StatefulFaultMatch.Type
239
+
240
+ /**
241
+ * A status fault: answer matching requests with this status (400-599), headers, and body before
242
+ * the route runs, so nothing is written. The body defaults to an emulator-fault body
243
+ * (`{ error: { type: 'emulator_fault', message } }`), never a guessed provider envelope. `count`
244
+ * limits how many requests it answers (omitted: all). `match.path` is the raw request path.
245
+ * Invalid header names or values, `location`, and framing headers are rejected.
246
+ */
247
+ export const StatefulFault = Schema.Struct({
248
+ kind: Schema.Literal('status'),
249
+ status: FaultStatus,
250
+ headers: Schema.optionalKey(EmulatorHeaderRecord),
251
+ body: Schema.optionalKey(Schema.Json),
252
+ match: Schema.optionalKey(StatefulFaultMatch),
253
+ count: Schema.optionalKey(FaultCount)
254
+ })
255
+
256
+ export type StatefulFault = typeof StatefulFault.Type
257
+
258
+ /**
259
+ * Opt-in (`makeChunkedStatefulEmulator`): send the first `chunks` body chunks of the answer a
260
+ * streamed commit prepared (`StreamedCommit`), then close the body cleanly (a truncated answer).
261
+ * It is decided where a status fault is, after the plan, and a truncated request writes nothing:
262
+ * its `commit` never runs (no state, counter, or runtime change). One that cannot take effect (the
263
+ * answer is not streamed, or has no more than `chunks` chunks) answers the 500 emulator error and
264
+ * is not used up, never a silent no-op.
265
+ */
266
+ export const StatefulTruncateFault = Schema.Struct({
267
+ kind: Schema.Literal('truncate-after-chunks'),
268
+ chunks: ChunkCount,
269
+ match: Schema.optionalKey(StatefulFaultMatch),
270
+ count: Schema.optionalKey(FaultCount)
271
+ })
272
+
273
+ export type StatefulTruncateFault = typeof StatefulTruncateFault.Type
274
+
275
+ /** A status fault, or (`makeChunkedStatefulEmulator`) a truncation fault. */
276
+ export const StatefulStreamFault = Schema.Union([StatefulFault, StatefulTruncateFault])
277
+
278
+ export type StatefulStreamFault = typeof StatefulStreamFault.Type
279
+
280
+ export type StatefulFaultState<Fault = StatefulFault> = {
281
+ readonly id: number
282
+ readonly fault: Fault
283
+ /** Remaining matching requests; `undefined` for an unlimited fault. */
284
+ readonly remaining: number | undefined
285
+ readonly applied: number
286
+ }
287
+
288
+ export type StatefulLedgerEntry = {
289
+ /** 1-based arrival order since the last ledger clear or reset. */
290
+ readonly seq: number
291
+ readonly method: string
292
+ /**
293
+ * Raw request path; in fail-closed mode with guarded secrets scrubbed, in resolved mode the
294
+ * resolution's ledger path (scrubbed), and `/<unrecognised>` for an unrecognised request.
295
+ */
296
+ readonly path: string
297
+ /** Path template of the matched route. */
298
+ readonly route?: string
299
+ /** Query parameters; the values of credential-named keys are `<redacted>`. */
300
+ readonly query: Readonly<Record<string, string>>
301
+ /** Parsed JSON request body, with credential-named keys redacted at any depth. */
302
+ readonly body?: Schema.Json
303
+ /** Length of a raw (non-JSON) request body. */
304
+ readonly bodyBytes?: number
305
+ /**
306
+ * The non-credential request headers the emulator records (lower-case names); a JSON header is
307
+ * recorded with credential-named keys redacted at any depth, or as `<redacted>` when unparseable.
308
+ */
309
+ readonly headers: Readonly<Record<string, string>>
310
+ readonly status: number
311
+ /** Evidence of the matched route; `unknown-route` for requests on no route. */
312
+ readonly evidence: EmulatorEvidence | 'unknown-route'
313
+ /** Why the request was answered 400 not-emulated. */
314
+ readonly notEmulated?: string
315
+ /** Set when a fault answered the request (`truncate-after-chunks` cut its streamed body). */
316
+ readonly fault?: 'status' | 'truncate-after-chunks'
317
+ /** Set when the emulator could not build the response, or a route threw (answered 500). */
318
+ readonly responseError?: string
319
+ }
320
+
321
+ export type StatefulRouteCoverage = EmulatorRouteEvidence & {
322
+ /** Ledger requests on this route since the last ledger clear or reset. */
323
+ readonly requests: number
324
+ }
325
+
326
+ export type StatefulCoverage = {
327
+ readonly routes: ReadonlyArray<StatefulRouteCoverage>
328
+ /** Ledger requests on no route. */
329
+ readonly unknownRouteRequests: number
330
+ /** Ledger requests answered 400 not-emulated (on a route or not). */
331
+ readonly notEmulatedRequests: number
332
+ }
333
+
334
+ /**
335
+ * A ledger entry of an emulator built with `makeHeaderlessStatefulEmulator`, which records no
336
+ * request header and takes status faults only: the entry carries no `headers` field, no
337
+ * `bodyBytes` (it has no `bytes` routes), and only a `status` fault.
338
+ */
339
+ export type StatefulHeaderlessLedgerEntry = Omit<
340
+ StatefulLedgerEntry,
341
+ 'headers' | 'bodyBytes' | 'fault'
342
+ > & {
343
+ /** Set when a fault answered the request. */
344
+ readonly fault?: 'status'
345
+ }
346
+
347
+ /** One routed API request, as a route sees it. */
348
+ export type EmulatedRequest = {
349
+ readonly method: string
350
+ /** Raw (still percent-encoded) path; in resolved mode, the resolution's ledger path. */
351
+ readonly path: string
352
+ /**
353
+ * Path parameters, percent-decoded once; in resolved mode (`resolveRequest`), the parameters the
354
+ * resolution supplies (never a credential).
355
+ */
356
+ readonly params: Readonly<Record<string, string>>
357
+ readonly query: URLSearchParams
358
+ /**
359
+ * The raw query (after `?`, before `#`, never decoded; `''` without one), as the wrapper saw it.
360
+ * Absent on a request built elsewhere (then `exactQuery`'s `rawNames` refuses any parameter).
361
+ */
362
+ readonly rawQuery?: string | undefined
363
+ /**
364
+ * A non-credential request header (credential headers always read as `undefined`; in resolved
365
+ * mode, every header but `content-type` does).
366
+ */
367
+ readonly header: (name: string) => string | undefined
368
+ /**
369
+ * The lower-case names of every request header, credential headers included (names only, never
370
+ * a credential value); none in resolved mode. Absent on a request built elsewhere.
371
+ */
372
+ readonly headerNames?: ReadonlyArray<string> | undefined
373
+ /** Parsed JSON body (`json` routes, and `json-or-empty` routes with a body). */
374
+ readonly json: Schema.Json | undefined
375
+ /** Raw body (`bytes` routes). */
376
+ readonly bytes: Uint8Array | undefined
377
+ /**
378
+ * Fail-closed mode with `bearerDigest` only: the digest of the recognised bearer for the origin
379
+ * the request arrived on (never the bearer itself); `undefined` otherwise.
380
+ */
381
+ readonly bearerDigest?: string | undefined
382
+ }
383
+
384
+ export type RunContext<Env> = {
385
+ readonly env: Env
386
+ /** Ledger sequence number of the request (for synthetic request ids). */
387
+ readonly seq: number
388
+ }
389
+
390
+ /** The writing part of an eligible request: applies its change and answers. */
391
+ export type Commit = () => Response
392
+
393
+ /** A streamed answer: its status, headers, and body chunks (sent one per pull). */
394
+ export type StreamedAnswer = {
395
+ readonly status: number
396
+ readonly headers: Readonly<Record<string, string>>
397
+ /** The body chunks; none answers no body at all. */
398
+ readonly chunks: ReadonlyArray<string>
399
+ }
400
+
401
+ /**
402
+ * A commit whose answer the plan prepares: `answer` is sent (one chunk per pull) only after
403
+ * `commit` has written the request's change. Because the answer exists before anything is
404
+ * written, the wrapper decides faults against it: a status fault or a `truncate-after-chunks`
405
+ * fault (`makeChunkedStatefulEmulator`) answers without running `commit`, so a faulted request
406
+ * writes nothing. `persisted` lists the texts `commit` will store (minted ids, for example), which
407
+ * the opt-in output guard (`guardOutput`) checks together with the answer.
408
+ */
409
+ export type StreamedCommit = {
410
+ readonly answer: StreamedAnswer
411
+ readonly commit: () => void
412
+ readonly persisted: ReadonlyArray<string>
413
+ }
414
+
415
+ /** A streamed commit: the prepared `answer`, and the write (none by default) it commits. */
416
+ export const streamedCommit = (
417
+ answer: StreamedAnswer,
418
+ commit: () => void = () => undefined,
419
+ persisted: ReadonlyArray<string> = []
420
+ ): StreamedCommit => ({ answer, commit, persisted })
421
+
422
+ /** What a plan returns: a commit, a streamed commit, or not emulated. */
423
+ export type Planned = Commit | StreamedCommit | NotEmulated
424
+
425
+ /** An admitted request: its pure, state-reading eligibility check, returning the commit. */
426
+ export type Admission<State, Env> = {
427
+ readonly plan: (state: State, context: RunContext<Env>) => Planned
428
+ /**
429
+ * The path of the manifest variant the request was admitted as (`StatefulRouteBinding.variants`):
430
+ * required when the route has variants, absent otherwise.
431
+ */
432
+ readonly variant?: string
433
+ }
434
+
435
+ /**
436
+ * The body a route takes: none; JSON with an `application/json` media type; JSON of any media type
437
+ * or none (`json-or-empty`: an empty body is no body, any other must be valid JSON, and the route
438
+ * checks the media type itself); or raw bytes.
439
+ */
440
+ export type RouteBody = 'none' | 'json' | 'json-or-empty' | 'bytes'
441
+
442
+ /** Route parts that are not evidence: the recorded origin and the raw parameter patterns. */
443
+ export type StatefulRouteBinding = {
444
+ /** The origin a fixture records the route on; another origin is not emulated. Omitted: any. */
445
+ readonly origin?: string
446
+ /**
447
+ * Raw (still percent-encoded) patterns of the path parameters; a parameter that does not match
448
+ * its pattern makes the path match no route. Fail-closed emulators give every parameter one.
449
+ */
450
+ readonly params?: Readonly<Record<string, RegExp>>
451
+ /**
452
+ * Fail-closed mode only: texts the route derives from its raw body that the provider's wire
453
+ * format encodes in a way the percent and JSON closure cannot see through (for example base64url
454
+ * content). Each view is checked for the bearer like the raw body. A body that holds no such
455
+ * content yields no view. A view may throw to refuse a body whose encoded content it cannot
456
+ * check completely (for example content the route would refuse anyway); the request is then
457
+ * ledgered as the constant credential-repeat entry shape, before anything is recorded, a fault is
458
+ * decided, or anything is committed, with this reason:
459
+ *
460
+ * - `the request body repeats the credential` when the throw is a `DecodedViewRefusal` whose
461
+ * cleanly `decoded` texts hold the bearer (a repeat, not a refusal);
462
+ * - otherwise its `reason` when that is one of `viewRefusalReasons` (scrubbed of the bearer,
463
+ * defensively);
464
+ * - otherwise (an undeclared reason, or any other throw)
465
+ * `the request body cannot be checked for the credential`.
466
+ *
467
+ * Omitted: none.
468
+ */
469
+ readonly decodedViews?: (body: string) => ReadonlyArray<string>
470
+ /**
471
+ * The constant reasons a decoded view may refuse a body with (`DecodedViewRefusal`); any other
472
+ * reason is replaced by the uncheckable reason. Owned by the route, never derived from a request.
473
+ */
474
+ readonly viewRefusalReasons?: ReadonlyArray<string>
475
+ /**
476
+ * Opt-in: the manifest rows this route answers INSTEAD of its own (for example one row per
477
+ * JSON-RPC method on one HTTP endpoint, `{ method: 'RPC', path: '<origin><path>#<method>' }`).
478
+ * Each admission names its row (`Admission.variant`), which becomes the request's ledger route,
479
+ * its coverage row, and what a fault's `match.route` compares; a request refused before it is
480
+ * admitted keeps the route's own template. Every variant carries the route's evidence, and no
481
+ * variant path repeats another manifest row or a route template (checked at build). Omitted:
482
+ * the route is its own manifest row, as before.
483
+ */
484
+ readonly variants?: ReadonlyArray<EmulatorRouteEvidence>
485
+ }
486
+
487
+ export type StatefulRoute<State, Env> = EmulatorRouteEvidence &
488
+ StatefulRouteBinding & {
489
+ readonly body: RouteBody
490
+ /** The request-shape check (reads no state): an admission, or not emulated. */
491
+ readonly admit: (request: EmulatedRequest, env: Env) => Admission<State, Env> | NotEmulated
492
+ }
493
+
494
+ /**
495
+ * A route of the table: its evidence (and recorded origin), the body it takes, its request-shape
496
+ * check `admit` (no state), and `plan`, which only ever sees an admitted input: it reads the state
497
+ * without writing and answers not emulated or the commit that writes.
498
+ */
499
+ export const statefulRoute = <State, Env, Input>(
500
+ evidence: EmulatorRouteEvidence & StatefulRouteBinding,
501
+ body: RouteBody,
502
+ admit: (request: EmulatedRequest, env: Env) => Input | NotEmulated,
503
+ plan: (state: State, input: Input, context: RunContext<Env>) => Planned
504
+ ): StatefulRoute<State, Env> => ({
505
+ ...evidence,
506
+ body,
507
+ admit: (request, env) => {
508
+ const input = admit(request, env)
509
+
510
+ return isNotEmulated(input) ? input : { plan: (state, context) => plan(state, input, context) }
511
+ }
512
+ })
513
+
514
+ /** Route evidence without the handler parts. */
515
+ export const routeEvidence = <State, Env>({
516
+ body: _body,
517
+ admit: _admit,
518
+ origin: _origin,
519
+ params: _params,
520
+ decodedViews: _decodedViews,
521
+ viewRefusalReasons: _viewRefusalReasons,
522
+ variants: _variants,
523
+ ...evidence
524
+ }: StatefulRoute<State, Env>): EmulatorRouteEvidence => evidence
525
+
526
+ /** The manifest rows of a route: its variants, or the route itself (`routeEvidence`). */
527
+ export const routeManifest = <State, Env>(
528
+ route: StatefulRoute<State, Env>
529
+ ): ReadonlyArray<EmulatorRouteEvidence> => route.variants ?? [routeEvidence(route)]
530
+
531
+ /** A percent-decoded path segment, or `undefined` for invalid percent-encoding. */
532
+ export const decodeSegment = (segment: string): string | undefined => {
533
+ try {
534
+ return decodeURIComponent(segment)
535
+ } catch {
536
+ return undefined
537
+ }
538
+ }
539
+
540
+ // `{name}` is one path segment; `{name+}` is one or more segments.
541
+ const templatePattern = (template: string): RegExp =>
542
+ new RegExp(
543
+ `^${template
544
+ .split(/(\{[A-Za-z]+\+?\})/)
545
+ .map(part =>
546
+ /^\{[A-Za-z]+\}$/.test(part)
547
+ ? '([^/]+)'
548
+ : /^\{[A-Za-z]+\+\}$/.test(part)
549
+ ? '(.+)'
550
+ : part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
551
+ )
552
+ .join('')}$`
553
+ )
554
+
555
+ const templateNames = (template: string): ReadonlyArray<string> =>
556
+ [...template.matchAll(/\{([A-Za-z]+)\+?\}/g)].map(match => match[1] ?? '')
557
+
558
+ /**
559
+ * A multi-segment parameter decoded segment by segment (each once), or `undefined` for an empty
560
+ * segment, invalid percent-encoding, or a segment that decodes to a `/`.
561
+ */
562
+ const decodeSegments = (raw: string): string | undefined => {
563
+ const segments = raw.split('/').map(segment => {
564
+ const decoded = segment === '' ? undefined : decodeSegment(segment)
565
+
566
+ return decoded === undefined || decoded.includes('/') ? undefined : decoded
567
+ })
568
+
569
+ return segments.some(segment => segment === undefined) ? undefined : segments.join('/')
570
+ }
571
+
572
+ export type MatchedRoute<State, Env> = {
573
+ readonly route: StatefulRoute<State, Env>
574
+ readonly params: Readonly<Record<string, string>>
575
+ }
576
+
577
+ /**
578
+ * `pattern` required to match a whole raw parameter: `^(?:source)$` with its flags, so alternation
579
+ * and lazy quantifiers are tried against the whole value, never against a prefix of it.
580
+ */
581
+ const wholePattern = (pattern: RegExp): RegExp =>
582
+ new RegExp(`^(?:${pattern.source})$`, pattern.flags)
583
+
584
+ /**
585
+ * A matcher over a route table: the route answering `method` + raw `path`, with its parameters
586
+ * decoded once, or `undefined` (also for a parameter that is not valid percent-encoding). A raw
587
+ * parameter pattern must match the whole raw parameter; a pattern with the `g` or `y` flag (whose
588
+ * matches depend on earlier ones) is refused when the matcher is built.
589
+ */
590
+ export const routeMatcher = <State, Env>(routes: ReadonlyArray<StatefulRoute<State, Env>>) => {
591
+ const keys = routes.map(route => emulatorRouteKey(route.method, route.path))
592
+ const duplicate = keys.find((key, index) => keys.indexOf(key) !== index)
593
+
594
+ if (duplicate !== undefined) {
595
+ throw new Error(`duplicate emulator route ${duplicate}`)
596
+ }
597
+
598
+ for (const route of routes) {
599
+ for (const [name, pattern] of Object.entries(route.params ?? {})) {
600
+ if (pattern.global || pattern.sticky) {
601
+ const where = `${route.method} ${route.path} parameter ${name}`
602
+
603
+ throw new Error(`raw patterns take no g or y flag (${where})`)
604
+ }
605
+ }
606
+ }
607
+
608
+ const compiled = routes.map(route => ({
609
+ route,
610
+ pattern: templatePattern(route.path),
611
+ names: templateNames(route.path),
612
+ // Compiled when the matcher is built, so no request compiles a RegExp.
613
+ whole: new Map(
614
+ Object.entries(route.params ?? {}).map(([name, raw]) => [name, wholePattern(raw)] as const)
615
+ ),
616
+ multi: new Set([...route.path.matchAll(/\{([A-Za-z]+)\+\}/g)].map(match => match[1] ?? ''))
617
+ }))
618
+
619
+ return (method: string, path: string): MatchedRoute<State, Env> | undefined => {
620
+ for (const candidate of compiled) {
621
+ if (candidate.route.method !== method.toUpperCase()) continue
622
+
623
+ const match = candidate.pattern.exec(path)
624
+
625
+ if (match === null) continue
626
+
627
+ const raws = candidate.names.map((name, index) => [name, match[index + 1] ?? ''] as const)
628
+
629
+ // A raw parameter outside its pattern (matched in full) is no shape of this route.
630
+ if (
631
+ raws.some(([name, raw]) => {
632
+ const pattern = candidate.whole.get(name)
633
+
634
+ return pattern !== undefined && !pattern.test(raw)
635
+ })
636
+ ) {
637
+ continue
638
+ }
639
+
640
+ const params: Record<string, string> = {}
641
+
642
+ for (const [name, raw] of raws) {
643
+ const value = candidate.multi.has(name) ? decodeSegments(raw) : decodeSegment(raw)
644
+
645
+ if (value === undefined) return undefined
646
+
647
+ params[name] = value
648
+ }
649
+
650
+ return { route: candidate.route, params }
651
+ }
652
+
653
+ return undefined
654
+ }
655
+ }
656
+
657
+ /** The template parameters of a route that have no raw pattern (fail-closed emulators need one). */
658
+ export const unpatternedParams = <State, Env>(
659
+ route: StatefulRoute<State, Env>
660
+ ): ReadonlyArray<string> => templateNames(route.path).filter(name => !route.params?.[name])
661
+
662
+ const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json))
663
+
664
+ /** Parsed JSON, or `undefined` for invalid JSON. */
665
+ export const parseJsonText = (text: string): Schema.Json | undefined => {
666
+ const result = decodeJsonText(text)
667
+
668
+ return Result.isSuccess(result) ? result.success : undefined
669
+ }
670
+
671
+ export const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
672
+ value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
673
+
674
+ /**
675
+ * True when any object of a valid JSON text repeats a key, compared after JSON unescaping (so
676
+ * `"id"` and `"\u0069d"` are one key). `JSON.parse` would keep the last value silently.
677
+ */
678
+ export const repeatsJsonKey = (text: string): boolean => {
679
+ // One entry per open object or array: an object's keys so far, and whether a key comes next.
680
+ const open: Array<{ readonly keys: Set<string> | undefined; expectKey: boolean }> = []
681
+ let index = 0
682
+
683
+ while (index < text.length) {
684
+ const character = text[index]
685
+
686
+ if (character === '"') {
687
+ let end = index + 1
688
+
689
+ while (end < text.length && text[end] !== '"') end += text[end] === '\\' ? 2 : 1
690
+
691
+ const top = open.at(-1)
692
+
693
+ if (top?.keys !== undefined && top.expectKey) {
694
+ const key = parseJsonText(text.slice(index, end + 1))
695
+
696
+ if (!Predicate.isString(key) || top.keys.has(key)) return true
697
+
698
+ top.keys.add(key)
699
+ top.expectKey = false
700
+ }
701
+
702
+ index = end + 1
703
+ continue
704
+ }
705
+
706
+ if (character === '{') open.push({ keys: new Set(), expectKey: true })
707
+ else if (character === '[') open.push({ keys: undefined, expectKey: false })
708
+ else if (character === '}' || character === ']') open.pop()
709
+ else if (character === ',') {
710
+ const top = open.at(-1)
711
+
712
+ if (top?.keys !== undefined) top.expectKey = true
713
+ }
714
+
715
+ index += 1
716
+ }
717
+
718
+ return false
719
+ }
720
+
721
+ /** The media type of a `content-type` value (lower-case, without parameters). */
722
+ export const mediaType = (value: string | undefined): string =>
723
+ (value ?? '').split(';')[0]?.trim().toLowerCase() ?? ''
724
+
725
+ /**
726
+ * A JSON object with exactly the `required` keys plus any of the `optional` ones, or not
727
+ * emulated (naming the first missing or unknown key).
728
+ */
729
+ export const exactObject = (
730
+ value: Schema.Json | undefined,
731
+ label: string,
732
+ required: ReadonlyArray<string>,
733
+ optional: ReadonlyArray<string> = []
734
+ ): Schema.JsonObject | NotEmulated => {
735
+ if (!isJsonObject(value)) return notEmulated(`${label} must be a JSON object`)
736
+
737
+ const unknown = Object.keys(value).find(key => !required.includes(key) && !optional.includes(key))
738
+
739
+ if (unknown !== undefined) return notEmulated(`${label} key '${unknown}' is not emulated`)
740
+
741
+ const missing = required.find(key => !(key in value))
742
+
743
+ return missing === undefined
744
+ ? value
745
+ : notEmulated(`${label} without '${missing}' is not emulated`)
746
+ }
747
+
748
+ /**
749
+ * Constant-text shape check (for fail-closed emulators): a JSON object with exactly the `required`
750
+ * keys plus any of the `optional` ones, or not emulated. Unlike `exactObject`, a reason never
751
+ * echoes a request's own key; it names only `label` and a missing key from the route's own list.
752
+ */
753
+ export const exactBodyKeys = (
754
+ value: Schema.Json | undefined,
755
+ label: string,
756
+ required: ReadonlyArray<string>,
757
+ optional: ReadonlyArray<string> = []
758
+ ): Schema.JsonObject | NotEmulated => {
759
+ if (!isJsonObject(value)) return notEmulated(`${label} must be a JSON object`)
760
+
761
+ const keys = Object.keys(value)
762
+
763
+ if (keys.some(key => !required.includes(key) && !optional.includes(key))) {
764
+ return notEmulated(`${label} has a key this route does not take`)
765
+ }
766
+
767
+ const missing = required.find(key => !keys.includes(key))
768
+
769
+ return missing === undefined
770
+ ? value
771
+ : notEmulated(`${label} without '${missing}' is not emulated`)
772
+ }
773
+
774
+ /** Options of `exactQuery`. */
775
+ export type ExactQueryOptions = {
776
+ /**
777
+ * Opt-in: every raw parameter name (before `URLSearchParams` decodes it) must be written exactly
778
+ * as the route names it, so a percent-encoded or `+`-spaced spelling of an accepted name is not
779
+ * emulated. Omitted: names are compared decoded, as before.
780
+ */
781
+ readonly rawNames?: boolean
782
+ }
783
+
784
+ /** The raw parameter names of a raw query, in order (`[]` for an empty query). */
785
+ const rawQueryNames = (raw: string): ReadonlyArray<string> =>
786
+ raw === '' ? [] : raw.split('&').map(component => component.split('=', 1)[0] ?? '')
787
+
788
+ /**
789
+ * Constant-text query check (for fail-closed emulators): exactly the `required` query keys plus
790
+ * any of the `optional` ones, each once, as a record of their values; or not emulated. A reason
791
+ * never echoes a request's own key; it names only a missing key from the route's own list. With
792
+ * `rawNames`, every raw parameter name must also be the plain name it decodes to.
793
+ */
794
+ export const exactQuery = (
795
+ request: EmulatedRequest,
796
+ required: ReadonlyArray<string>,
797
+ optional: ReadonlyArray<string> = [],
798
+ options: ExactQueryOptions = {}
799
+ ): Readonly<Record<string, string>> | NotEmulated => {
800
+ const keys = [...request.query.keys()]
801
+
802
+ if (keys.length !== new Set(keys).size) {
803
+ return notEmulated('repeated query parameters are not emulated')
804
+ }
805
+
806
+ if (keys.some(key => !required.includes(key) && !optional.includes(key))) {
807
+ return notEmulated('a query parameter this route does not take is not emulated')
808
+ }
809
+
810
+ if (options.rawNames === true) {
811
+ const raw = rawQueryNames(request.rawQuery ?? '')
812
+
813
+ if (raw.length !== keys.length || raw.some((name, index) => name !== keys[index])) {
814
+ return notEmulated('a query parameter name in any but its plain form is not emulated')
815
+ }
816
+ }
817
+
818
+ const missing = required.find(key => !keys.includes(key))
819
+
820
+ return missing === undefined
821
+ ? Object.fromEntries(request.query)
822
+ : notEmulated(`requests without query parameter ${missing} are not emulated on this route`)
823
+ }
824
+
825
+ /** An integer in `[minimum, maximum]`, or not emulated. */
826
+ export const integerIn = (
827
+ value: Schema.Json | undefined,
828
+ label: string,
829
+ minimum: number,
830
+ maximum: number
831
+ ): number | NotEmulated =>
832
+ Predicate.isNumber(value) && Number.isSafeInteger(value) && value >= minimum && value <= maximum
833
+ ? value
834
+ : notEmulated(`${label} must be an integer from ${minimum} to ${maximum}`)
835
+
836
+ /** Error-input kinds of the JS API. */
837
+ export type StatefulInputKind = 'seed' | 'fault' | 'option'
838
+
839
+ /** The core runtime the Node subpath creates (state, snapshot, restore), as the wrapper uses it. */
840
+ export type StatefulCore<State> = {
841
+ readonly fetch: (request: Request) => Promise<Response>
842
+ readonly baseUrl: string
843
+ readonly snapshot: () => State
844
+ readonly restore: (state: State) => Promise<void>
845
+ readonly close: () => Promise<void>
846
+ }
847
+
848
+ /**
849
+ * What the core runs for a request the wrapper forwarded: `(state, request)` to a response. The
850
+ * Node subpath registers it as the core app's only route.
851
+ */
852
+ export type CoreDispatch<State> = (state: State, request: Request) => Response
853
+
854
+ /** A request header the ledger records. */
855
+ export type RecordedHeader = { readonly name: string; readonly json: boolean }
856
+
857
+ /**
858
+ * Fail-closed mode: the constant reasons ledgered and answered for an unrecognised request (no
859
+ * emulated route shape) and for an unrecognisable `Authorization` header. Nothing the request
860
+ * carries is ledgered or answered for either.
861
+ */
862
+ export type StatefulFailClosed = {
863
+ readonly unrecognised: string
864
+ readonly unrecognisedAuthorization: string
865
+ }
866
+
867
+ /**
868
+ * Resolved mode: what the emulator's `resolveRequest` decides for one API request, failing closed.
869
+ *
870
+ * - `unrecognised`: the raw request matches no emulated route shape exactly (or its credential
871
+ * cannot be extracted). It is ledgered and answered with constant text only
872
+ * (`/<unrecognised>`, the method as sent when it is a standard one, else `<other>`, an empty
873
+ * query, no headers or body, the constant `reason`).
874
+ * - `refused`: a recognised route refuses the request before its body is read (a missing
875
+ * credential, a query key it does not take); the entry keeps the request's scrubbed fields.
876
+ * - `route`: a recognised request, handed to `route` with the credential-free `params`.
877
+ *
878
+ * For `refused` and `route`, `ledgerPath` is the path the ledger records (and a fault's
879
+ * `match.path` compares), with the credential already replaced by the emulator if the path carries
880
+ * it; `secrets` are the credential values the emulator took from the recognised shape (a path
881
+ * segment, a header), which the wrapper scrubs from everything it ledgers or answers and refuses
882
+ * to see repeated. `guardedPath` is the request text besides the query that must not repeat a
883
+ * secret (the path without the segment that carries the credential, for example).
884
+ */
885
+ export type StatefulResolution<State, Env> =
886
+ | { readonly kind: 'unrecognised'; readonly reason: string }
887
+ | {
888
+ readonly kind: 'refused'
889
+ readonly route: StatefulRoute<State, Env>
890
+ readonly ledgerPath: string
891
+ readonly reason: string
892
+ readonly secrets: ReadonlyArray<string>
893
+ }
894
+ | {
895
+ readonly kind: 'route'
896
+ readonly route: StatefulRoute<State, Env>
897
+ readonly params: Readonly<Record<string, string>>
898
+ readonly ledgerPath: string
899
+ readonly guardedPath: string
900
+ readonly secrets: ReadonlyArray<string>
901
+ }
902
+
903
+ /**
904
+ * The emulator-owned texts of its recovery answers
905
+ * (`{ error: { type: 'emulator_error', message } }`): `failed` when a response cannot be built or a
906
+ * route throws (500), `closed` after `close` (503), and `unhandled` when handling the request
907
+ * itself fails (500).
908
+ */
909
+ export type StatefulErrorTexts = {
910
+ readonly failed: string
911
+ readonly closed: string
912
+ readonly unhandled: string
913
+ }
914
+
915
+ const defaultErrorTexts: StatefulErrorTexts = {
916
+ failed: 'the emulator could not build the response',
917
+ closed: 'the emulator is closed',
918
+ unhandled: 'the emulator failed to handle the request'
919
+ }
920
+
921
+ export type StatefulEmulatorConfig<State, Env> = {
922
+ readonly routes: ReadonlyArray<StatefulRoute<State, Env>>
923
+ readonly env: Env
924
+ readonly initial: State
925
+ /** Decode and build a seed; a string is why it is invalid. */
926
+ readonly buildSeed: (input: unknown) => State | string
927
+ /** Header rules every request must meet (after the credential); a string is not emulated. */
928
+ readonly requestProblem?: (header: (name: string) => string | undefined) => string | undefined
929
+ /**
930
+ * Non-credential request headers the ledger records (lower-case names). A `json` header is
931
+ * parsed and its credential-named keys redacted at any depth; an unparseable value is recorded
932
+ * as `<redacted>`. A credential header name is refused when the emulator is built.
933
+ */
934
+ readonly recordHeaders: ReadonlyArray<RecordedHeader>
935
+ /**
936
+ * Opt-in fail-closed mode (see the module header): constant-text ledger entries for unrecognised
937
+ * requests and Authorization headers, and the bearer value guarded as a secret. Every route
938
+ * parameter must have a raw pattern (checked when the emulator is built).
939
+ */
940
+ readonly failClosed?: StatefulFailClosed
941
+ /**
942
+ * Opt-in resolved mode (not with `failClosed`; checked when the emulator is built), for an
943
+ * emulator whose credential the wrapper cannot find by itself (a token in a path segment) or
944
+ * that has its own credential rules: the emulator resolves every API request itself
945
+ * (`StatefulResolution`) instead of the wrapper's route match and `Authorization` handling, and
946
+ * names the credential values to guard. The wrapper then records the resolution's ledger path
947
+ * and the query as sent (credential-named keys redacted), both scrubbed of the secrets, matches a
948
+ * fault's `match.path` against that ledger path, and refuses, before the request headers are
949
+ * checked or the body is parsed, a recognised request that repeats a secret: the raw query, the
950
+ * `guardedPath`, or the body, each raw or percent-decoded once (`repeatsSecret`), and a JSON body
951
+ * also in any key, string, or number once parsed and as it would be recorded
952
+ * (`jsonRepeatsSecret`). Those refusals keep the request's scrubbed fields and never record the
953
+ * body. Routes see the ledger path and no header names; fault answers and refusals are returned
954
+ * outside the core; `recordHeaders` must be empty (checked at build). See the module header.
955
+ * Omitted: the wrapper matches routes itself, as before.
956
+ */
957
+ readonly resolveRequest?: (request: Request, url: URL) => StatefulResolution<State, Env>
958
+ /**
959
+ * Opt-in: the texts of the emulator's recovery answers (`StatefulErrorTexts`). Omitted: the
960
+ * default texts, as before.
961
+ */
962
+ readonly errorTexts?: StatefulErrorTexts
963
+ /**
964
+ * Opt-in, fail-closed mode only (checked when the emulator is built): a one-way digest of a
965
+ * recognised bearer for the origin the request arrived on, handed to routes as
966
+ * `EmulatedRequest.bearerDigest`, never the bearer itself. A route compares it with digests its
967
+ * state holds (for example of the keys a seed marks as rejected on one origin), so a credential
968
+ * can change an answer without the bearer reaching the state, the ledger, or `/_emulate/*`.
969
+ * Taking the origin makes the digest per origin: one key gives different digests on two origins.
970
+ * A digest that throws or repeats the bearer (through the closure) answers the 500 emulator
971
+ * error before the route's shape check (no fault used, nothing written). Omitted: routes see no
972
+ * digest, as before.
973
+ */
974
+ readonly bearerDigest?: (bearer: string, origin: string) => string
975
+ /**
976
+ * Opt-in, fail-closed mode only (checked at build): every refusal is ledgered with constant text
977
+ * only, like an unrecognised request (`/<unrecognised>`, a standard method or `<other>`, an
978
+ * empty query, no headers or body), keeping only its constant route (template or variant) and
979
+ * reason, so a ledger entry holds request text only once the route admitted the request. Route
980
+ * reasons must then be constants. Omitted: refusals keep the request's recorded fields, as
981
+ * before.
982
+ */
983
+ readonly constantRefusals?: boolean
984
+ /**
985
+ * Opt-in, fail-closed mode only (checked at build): every request header other than
986
+ * `Authorization`, its name and its value, is checked for a credential repeat through the
987
+ * closure (not only the recorded headers), independently of what the ledger records; a repeat is
988
+ * refused with the constant credential-repeat entry (`a request header repeats the credential`).
989
+ * Omitted: only the recorded headers are checked, as before.
990
+ */
991
+ readonly guardAllHeaders?: boolean
992
+ /**
993
+ * Opt-in, fail-closed mode only (checked at build): a recognised request's prepared output must
994
+ * not repeat its bearer. Every plan answers a `StreamedCommit` (a plain `Commit` answers the 500
995
+ * emulator error), and before any fault is decided or anything is committed, every answer header
996
+ * name and value, every answer chunk, and every `persisted` text are checked for the bearer
997
+ * through the closure; a hit is refused with the constant credential-repeat entry
998
+ * (`the answer would repeat the credential`), no fault used and nothing written. Routes still see
999
+ * only the bearer's digest. Omitted: answers are not checked, as before.
1000
+ */
1001
+ readonly guardOutput?: boolean
1002
+ /**
1003
+ * Opt-in: a JSON body in which any object repeats a key (after JSON unescaping, so `"id"` and
1004
+ * `"\u0069d"` are one key) is not emulated (`a JSON body with a repeated key is not emulated`),
1005
+ * instead of `JSON.parse` keeping the last value. Omitted: the last value wins, as before.
1006
+ */
1007
+ readonly uniqueJsonKeys?: boolean
1008
+ /** Clear runtime data (cursors) on reset and seed. */
1009
+ readonly clearRuntime: () => void
1010
+ /** Extra `/_emulate/state` fields (runtime data). */
1011
+ readonly runtimeState: () => Schema.JsonObject
1012
+ /** The `/_emulate/seed` answer for a new state. */
1013
+ readonly seedSummary: (state: State) => Schema.JsonObject
1014
+ readonly inputInvalid: (input: StatefulInputKind, reason: string) => Error
1015
+ }
1016
+
1017
+ export type StatefulEmulatorApi<State, Seed, Fault = StatefulFault, Entry = StatefulLedgerEntry> = {
1018
+ /**
1019
+ * The fetch handler (API routes and `/_emulate/*`); a request arrives on the origin of its URL.
1020
+ * Never rejects.
1021
+ */
1022
+ readonly fetch: (request: Request) => Promise<Response>
1023
+ /**
1024
+ * The fetch handler for requests that arrive on `origin` whatever their URL says (serve it on
1025
+ * a loopback server behind `EmulatedHttpClient`, which rewrites the origin). Never rejects.
1026
+ */
1027
+ readonly fetchOn: (origin: string) => (request: Request) => Promise<Response>
1028
+ readonly ledger: {
1029
+ readonly entries: () => ReadonlyArray<Entry>
1030
+ readonly clear: () => void
1031
+ }
1032
+ readonly faults: {
1033
+ /** Add a fault; throws the emulator's input-invalid error for an invalid fault. */
1034
+ readonly add: (fault: Fault) => StatefulFaultState<Fault>
1035
+ readonly list: () => ReadonlyArray<StatefulFaultState<Fault>>
1036
+ readonly clear: () => void
1037
+ }
1038
+ /** Restore the current seed and clear the ledger, faults, and runtime data (cursors). */
1039
+ readonly reset: () => Promise<void>
1040
+ /**
1041
+ * Replace the state with a new seed, which becomes what `reset` restores (runtime data is
1042
+ * cleared). Rejects with the emulator's input-invalid error for an invalid seed.
1043
+ */
1044
+ readonly seed: (seed: Seed) => Promise<void>
1045
+ /** A deep copy of the current state. */
1046
+ readonly snapshot: () => State
1047
+ readonly coverage: () => StatefulCoverage
1048
+ /** Close the core runtime. Later requests answer 503. Idempotent. */
1049
+ readonly close: () => Promise<void>
1050
+ }
1051
+
1052
+ const strict = { onExcessProperty: 'error' } as const
1053
+
1054
+ const decodeFault = Schema.decodeUnknownResult(StatefulFault, strict)
1055
+
1056
+ const decodeFaultList = Schema.decodeUnknownResult(
1057
+ Schema.Union([StatefulFault, Schema.Struct({ faults: Schema.Array(StatefulFault) })]),
1058
+ strict
1059
+ )
1060
+
1061
+ const decodeStreamFault = Schema.decodeUnknownResult(StatefulStreamFault, strict)
1062
+
1063
+ const decodeStreamFaultList = Schema.decodeUnknownResult(
1064
+ Schema.Union([StatefulStreamFault, Schema.Struct({ faults: Schema.Array(StatefulStreamFault) })]),
1065
+ strict
1066
+ )
1067
+
1068
+ const issueMessage = (issue: Schema.SchemaError['issue']): string =>
1069
+ new Schema.SchemaError(issue).message
1070
+
1071
+ // A non-empty bearer credential. The value is never checked, stored, forwarded, or ledgered.
1072
+ const bearerPattern = /^bearer\s+\S+/i
1073
+
1074
+ /**
1075
+ * Fail-closed mode: exactly `Bearer `, one space, and one token of at least 8 non-space characters
1076
+ * (no other scheme spelling, no extra words, so combined duplicate headers never match). The token
1077
+ * must also match the RFC 6750 `b64token` syntax, start with a character that completes no escape
1078
+ * (not a hex digit, not `n`, `r`, `t`, `u`), and hold a character outside the JSON-number alphabet
1079
+ * (`isRecognisableBearerValue`, which says why). The value is guarded, never compared against
1080
+ * anything, stored, or ledgered.
1081
+ */
1082
+ const recognisableBearerPattern = /^Bearer ([^\s]{8,})$/
1083
+
1084
+ /** The fail-closed bearer of an `Authorization` header, or `undefined` when unrecognisable. */
1085
+ const recognisableBearer = (authorization: string | null): string | undefined => {
1086
+ const bearer = recognisableBearerPattern.exec(authorization ?? '')?.[1]
1087
+
1088
+ return bearer !== undefined && isRecognisableBearerValue(bearer) ? bearer : undefined
1089
+ }
1090
+
1091
+ /** The raw query of a request URL (after `?`, before `#`), or `undefined` when it has none. */
1092
+ const rawQuery = (requestUrl: string): string | undefined => {
1093
+ const withoutFragment = requestUrl.split('#', 1)[0] ?? ''
1094
+ const start = withoutFragment.indexOf('?')
1095
+
1096
+ return start === -1 ? undefined : withoutFragment.slice(start + 1)
1097
+ }
1098
+
1099
+ /** True for a bare `?` or an empty `&`-separated component (`URLSearchParams` drops both). */
1100
+ const hasEmptyQueryComponent = (requestUrl: string): boolean => {
1101
+ const query = rawQuery(requestUrl)
1102
+
1103
+ return query !== undefined && query.split('&').some(component => component === '')
1104
+ }
1105
+
1106
+ /**
1107
+ * Fail-closed mode: a query key or value or a header value that starts like a JSON object, array,
1108
+ * or string (`{`, `[`, `"`) is recorded parsed with credential-named keys redacted at any depth, or
1109
+ * whole as `<redacted>` when it does not parse; any other text (numbers included) is recorded
1110
+ * unchanged. A request whose text repeats a guarded secret never gets here: it is ledgered with
1111
+ * constant text only.
1112
+ */
1113
+ const recordedJsonLooking = (text: string): string => {
1114
+ const trimmed = text.trimStart()
1115
+
1116
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[') && !trimmed.startsWith('"')) {
1117
+ return text
1118
+ }
1119
+
1120
+ const parsed = parseJsonText(text)
1121
+
1122
+ return parsed === undefined
1123
+ ? redactedCredentialValue
1124
+ : JSON.stringify(redactCredentialFields(parsed))
1125
+ }
1126
+
1127
+ /**
1128
+ * Fail-closed mode: the recorded query, built from every original pair (never one value per key)
1129
+ * and keyed by recorded key: credential-named keys have their values redacted, keys and values are
1130
+ * recorded as `recordedJsonLooking` does, and a key recorded more than once (two original keys may
1131
+ * record alike, for example two unparseable JSON-looking keys as `<redacted>`) lists its values in
1132
+ * order, as a JSON array.
1133
+ */
1134
+ const recordedQueryPairs = (
1135
+ query: URLSearchParams,
1136
+ scrub: (text: string) => string
1137
+ ): Readonly<Record<string, string>> => {
1138
+ const values = new Map<string, Array<string>>()
1139
+
1140
+ for (const [key, value] of query) {
1141
+ const recordedKey = scrub(recordedJsonLooking(key))
1142
+
1143
+ const recordedValue = scrub(
1144
+ isCredentialQueryKey(key) ? redactedCredentialValue : recordedJsonLooking(value)
1145
+ )
1146
+
1147
+ values.set(recordedKey, [...(values.get(recordedKey) ?? []), recordedValue])
1148
+ }
1149
+
1150
+ return Object.fromEntries(
1151
+ [...values].map(([key, list]) => [
1152
+ key,
1153
+ list.length === 1 ? (list[0] ?? '') : JSON.stringify(list)
1154
+ ])
1155
+ )
1156
+ }
1157
+
1158
+ /** The constant reason of a body a route's decoded view refuses to check (the view threw). */
1159
+ const uncheckableBodyReason = 'the request body cannot be checked for the credential'
1160
+
1161
+ const missingBearerReason =
1162
+ 'requests without Authorization: Bearer <token of at least 8 characters> are not emulated'
1163
+
1164
+ const pathMatches = (pattern: string, path: string): boolean =>
1165
+ pattern.endsWith('*') ? path.startsWith(pattern.slice(0, -1)) : pattern === path
1166
+
1167
+ const faultMatches = (
1168
+ fault: StatefulStreamFault,
1169
+ method: string,
1170
+ path: string,
1171
+ route: string | undefined
1172
+ ): boolean =>
1173
+ (fault.match?.method === undefined || fault.match.method.toUpperCase() === method) &&
1174
+ (fault.match?.path === undefined || pathMatches(fault.match.path, path)) &&
1175
+ (fault.match?.route === undefined || fault.match.route === route)
1176
+
1177
+ const faultBody = (status: number): Schema.Json => ({
1178
+ error: { type: 'emulator_fault', message: `Emulator fault: status ${status}.` }
1179
+ })
1180
+
1181
+ const readText = (request: Request): Promise<string | undefined> =>
1182
+ request.text().then(
1183
+ text => text,
1184
+ () => undefined
1185
+ )
1186
+
1187
+ const readBytes = (request: Request): Promise<Uint8Array | undefined> =>
1188
+ request.arrayBuffer().then(
1189
+ buffer => new Uint8Array(buffer),
1190
+ () => undefined
1191
+ )
1192
+
1193
+ const isControlPath = (path: string): boolean =>
1194
+ path === '/_emulate' || path.startsWith('/_emulate/')
1195
+
1196
+ type MutableLedgerEntry = {
1197
+ seq: number
1198
+ method: string
1199
+ path: string
1200
+ route?: string
1201
+ query: Readonly<Record<string, string>>
1202
+ body?: Schema.Json
1203
+ bodyBytes?: number
1204
+ headers: Record<string, string>
1205
+ status: number
1206
+ evidence: EmulatorEvidence | 'unknown-route'
1207
+ notEmulated?: string
1208
+ fault?: 'status' | 'truncate-after-chunks'
1209
+ responseError?: string
1210
+ }
1211
+
1212
+ type MutableFaultState = {
1213
+ readonly id: number
1214
+ readonly fault: StatefulStreamFault
1215
+ remaining: number | undefined
1216
+ applied: number
1217
+ }
1218
+
1219
+ /** How the first matching fault shapes an eligible request's answer. */
1220
+ type FaultDecision =
1221
+ | { readonly kind: 'none' }
1222
+ | { readonly kind: 'answer'; readonly response: Response }
1223
+ | { readonly kind: 'truncate'; readonly after: number }
1224
+
1225
+ type Job<State, Env> = {
1226
+ readonly admission: Admission<State, Env>
1227
+ /** Ledger sequence number of the request (for synthetic request ids). */
1228
+ readonly seq: number
1229
+ /**
1230
+ * Decides the first matching fault for an answer of `chunks` streamed chunks (`undefined` for an
1231
+ * answer that is not streamed), using it up when it applies.
1232
+ */
1233
+ readonly decideFault: (chunks: number | undefined) => FaultDecision
1234
+ /** Guarded credential values, scrubbed from a plan's not-emulated reason. */
1235
+ readonly secrets: ReadonlyArray<string>
1236
+ notEmulated?: string
1237
+ /** Set when `guardOutput` refused the prepared output (a constant credential-repeat entry). */
1238
+ credentialRepeat?: boolean
1239
+ /** Resolved mode: the fault's answer, decided in the core but returned outside it. */
1240
+ faultAnswer?: Response
1241
+ /** Set when the core ran the dispatch for this job (it may answer without it, when closed). */
1242
+ dispatched?: boolean
1243
+ }
1244
+
1245
+ const withEvidence = (response: Response, evidence: EmulatorEvidence): Response => {
1246
+ const headers = new Headers(response.headers)
1247
+
1248
+ if (evidence === 'unverified') {
1249
+ headers.set(emulatorEvidenceHeader, 'unverified')
1250
+ }
1251
+
1252
+ return new Response(response.body, {
1253
+ status: response.status,
1254
+ statusText: response.statusText,
1255
+ headers
1256
+ })
1257
+ }
1258
+
1259
+ /**
1260
+ * The recorded query: credential-named keys redacted, and any value that parses as a JSON object or
1261
+ * array (Dropbox's browser-style `arg` parameter, for example) recorded with its credential-named
1262
+ * keys redacted at any depth, and a JSON-looking value that does not parse recorded as `<redacted>`,
1263
+ * as a recorded JSON header is.
1264
+ */
1265
+ const recordedQuery = (query: URLSearchParams): Readonly<Record<string, string>> =>
1266
+ Object.fromEntries(
1267
+ Object.entries(redactCredentialQuery(query)).map(([key, value]) => {
1268
+ if (!value.trimStart().startsWith('{') && !value.trimStart().startsWith('[')) {
1269
+ return [key, value]
1270
+ }
1271
+
1272
+ // A JSON-looking value that does not parse cannot be redacted field by field: record it
1273
+ // whole as redacted, as a recorded JSON header is.
1274
+ const parsed = parseJsonText(value)
1275
+
1276
+ return [
1277
+ key,
1278
+ parsed === undefined
1279
+ ? redactedCredentialValue
1280
+ : JSON.stringify(redactCredentialFields(parsed))
1281
+ ]
1282
+ })
1283
+ )
1284
+
1285
+ /** A recorded header value: JSON with credential-named keys redacted, or the plain value. */
1286
+ const recordedHeaderValue = (header: RecordedHeader, value: string): string => {
1287
+ if (!header.json) return value
1288
+
1289
+ const parsed = parseJsonText(value)
1290
+
1291
+ return parsed === undefined
1292
+ ? redactedCredentialValue
1293
+ : JSON.stringify(redactCredentialFields(parsed))
1294
+ }
1295
+
1296
+ /** The request text a ledger entry records (constant in a `constantRefusals` refusal). */
1297
+ type RecordedFields = Pick<MutableLedgerEntry, 'method' | 'path' | 'query' | 'headers'> & {
1298
+ body?: Schema.Json
1299
+ bodyBytes?: number
1300
+ }
1301
+
1302
+ /** `constantRefusals`: a refused request's entry keeps only constant text. */
1303
+ const blankEntry = (entry: MutableLedgerEntry) => {
1304
+ entry.method = unrecognisedMethod(entry.method.toUpperCase())
1305
+ entry.path = unrecognisedLedgerPath
1306
+ entry.query = {}
1307
+ entry.headers = {}
1308
+ delete entry.body
1309
+ delete entry.bodyBytes
1310
+ }
1311
+
1312
+ const snapshotEntry = (entry: MutableLedgerEntry): StatefulLedgerEntry => ({
1313
+ ...entry,
1314
+ query: { ...entry.query },
1315
+ headers: { ...entry.headers }
1316
+ })
1317
+
1318
+ /** Whether an entry records no truncation fault (only a status fault, if any). */
1319
+ const hasStatusFaultOnly = <Entry extends { readonly fault?: MutableLedgerEntry['fault'] }>(
1320
+ entry: Entry
1321
+ ): entry is Entry & { readonly fault?: 'status' } => entry.fault !== 'truncate-after-chunks'
1322
+
1323
+ /** `makeHeaderlessStatefulEmulator`: the entry without its (always empty) `headers` field. */
1324
+ const snapshotHeaderlessEntry = ({
1325
+ headers: _headers,
1326
+ bodyBytes: _bodyBytes,
1327
+ ...entry
1328
+ }: MutableLedgerEntry): StatefulHeaderlessLedgerEntry => {
1329
+ const copy = { ...entry, query: { ...entry.query } }
1330
+
1331
+ if (hasStatusFaultOnly(copy)) return copy
1332
+
1333
+ // Unreachable: a headerless emulator decodes status faults only.
1334
+ throw new Error('a headerless stateful emulator records status faults only')
1335
+ }
1336
+
1337
+ /**
1338
+ * Resolved mode: the constant reason when the raw query or the resolution's `guardedPath` repeats a
1339
+ * guarded secret (`repeatsSecret`: raw, and percent-decoded once), or `undefined`. Checked before
1340
+ * the body is read, so such a refusal leaves the request body unread.
1341
+ */
1342
+ const partsCredentialRepeat = (
1343
+ url: URL,
1344
+ guardedPath: string,
1345
+ secrets: ReadonlyArray<string>
1346
+ ): string | undefined => {
1347
+ if (repeatsSecret(url.search, secrets)) return 'the query repeats the credential'
1348
+
1349
+ return repeatsSecret(guardedPath, secrets) ? 'the request path repeats the credential' : undefined
1350
+ }
1351
+
1352
+ /**
1353
+ * Resolved mode: the constant reason when the body text repeats a guarded secret, or `undefined`:
1354
+ * `repeatsSecret` on the raw text, and for a body that parses as JSON also `jsonRepeatsSecret`
1355
+ * (every key, string, and number) and as it would be recorded. A body that cannot be read or parsed
1356
+ * is left to the route's body check.
1357
+ */
1358
+ const bodyCredentialRepeat = (
1359
+ text: string | undefined,
1360
+ secrets: ReadonlyArray<string>
1361
+ ): string | undefined => {
1362
+ if (text === undefined || text === '') return undefined
1363
+
1364
+ const repeat = 'the request body repeats the credential'
1365
+
1366
+ if (repeatsSecret(text, secrets)) return repeat
1367
+
1368
+ const json = parseJsonText(text)
1369
+
1370
+ // Normalised forms (`\u0051` escapes, `1.2345678e7` numbers) only show once parsed.
1371
+ return json !== undefined &&
1372
+ (jsonRepeatsSecret(json, secrets) || repeatsSecret(JSON.stringify(json), secrets))
1373
+ ? repeat
1374
+ : undefined
1375
+ }
1376
+
1377
+ /** The body as `Request.text()` reads it: UTF-8, a leading byte order mark dropped. */
1378
+ const bodyText = (bytes: Uint8Array): string => new TextDecoder().decode(bytes)
1379
+
1380
+ const snapshotFault = (state: MutableFaultState): StatefulFaultState<StatefulStreamFault> => ({
1381
+ ...state
1382
+ })
1383
+
1384
+ const isStreamedCommit = (planned: Commit | StreamedCommit): planned is StreamedCommit =>
1385
+ !Predicate.isFunction(planned)
1386
+
1387
+ /** The constant reason of a prepared output that would repeat the bearer (`guardOutput`). */
1388
+ const outputRepeatReason = 'the answer would repeat the credential'
1389
+
1390
+ /**
1391
+ * `guardOutput`: whether a streamed commit's answer (every header name and value, every chunk, and
1392
+ * the whole body) or any text it would persist repeats a guarded secret, through the closure.
1393
+ */
1394
+ const outputRepeatsSecret = (planned: StreamedCommit, secrets: ReadonlyArray<string>): boolean => {
1395
+ const { answer } = planned
1396
+
1397
+ return [
1398
+ ...Object.entries(answer.headers).flat(),
1399
+ ...answer.chunks,
1400
+ answer.chunks.join(''),
1401
+ ...planned.persisted
1402
+ ].some(text => textRepeatsSecret(text, secrets))
1403
+ }
1404
+
1405
+ /**
1406
+ * A streamed answer's body, strictly pull-driven (one chunk per pull), closed cleanly after
1407
+ * `after` chunks when a truncation fault applies; no body at all for an answer without chunks.
1408
+ */
1409
+ const streamedResponse = (answer: StreamedAnswer, after: number | undefined): Response => {
1410
+ if (answer.chunks.length === 0) {
1411
+ return new Response(null, { status: answer.status, headers: answer.headers })
1412
+ }
1413
+
1414
+ const encoder = new TextEncoder()
1415
+ const sent = after === undefined ? answer.chunks : answer.chunks.slice(0, after)
1416
+ let next = 0
1417
+
1418
+ const body = new ReadableStream<Uint8Array>(
1419
+ {
1420
+ pull: controller => {
1421
+ const chunk = sent[next]
1422
+
1423
+ if (chunk === undefined) {
1424
+ controller.close()
1425
+
1426
+ return
1427
+ }
1428
+
1429
+ next += 1
1430
+ controller.enqueue(encoder.encode(chunk))
1431
+ }
1432
+ },
1433
+ { highWaterMark: 0 }
1434
+ )
1435
+
1436
+ return new Response(body, { status: answer.status, headers: answer.headers })
1437
+ }
1438
+
1439
+ /**
1440
+ * Build the wrapper over a core runtime (`makeStatefulEmulator`, with truncation faults
1441
+ * `makeChunkedStatefulEmulator`, or without ledgered headers `makeHeaderlessStatefulEmulator`).
1442
+ * `createCore` receives the dispatch the core must run for every request (its only route) and
1443
+ * returns the runtime adapter; `snapshotLedgerEntry` copies a ledger entry for readers.
1444
+ */
1445
+ const buildStatefulEmulator = async <State, Env, Seed, Entry>(
1446
+ config: StatefulEmulatorConfig<State, Env>,
1447
+ createCore: (dispatch: CoreDispatch<State>) => Promise<StatefulCore<State>>,
1448
+ chunkFaults: boolean,
1449
+ snapshotLedgerEntry: (entry: MutableLedgerEntry) => Entry
1450
+ ): Promise<StatefulEmulatorApi<State, Seed, StatefulStreamFault, Entry>> => {
1451
+ const credentialHeader = config.recordHeaders.find(header => isCredentialHeaderName(header.name))
1452
+
1453
+ if (credentialHeader !== undefined) {
1454
+ throw new Error(`the ledger never records the credential header ${credentialHeader.name}`)
1455
+ }
1456
+
1457
+ if (config.resolveRequest !== undefined && config.failClosed !== undefined) {
1458
+ throw new Error('resolveRequest (resolved mode) and failClosed exclude each other')
1459
+ }
1460
+
1461
+ // Resolved mode checks no request header for a credential repeat, so it records none.
1462
+ if (config.resolveRequest !== undefined && config.recordHeaders.length > 0) {
1463
+ throw new Error('resolveRequest (resolved mode) records no request header (recordHeaders)')
1464
+ }
1465
+
1466
+ const resolveRequest = config.resolveRequest
1467
+ const errorTexts = config.errorTexts ?? defaultErrorTexts
1468
+
1469
+ if (config.bearerDigest !== undefined && config.failClosed === undefined) {
1470
+ throw new Error('bearerDigest needs fail-closed mode (failClosed)')
1471
+ }
1472
+
1473
+ for (const option of ['constantRefusals', 'guardAllHeaders', 'guardOutput'] as const) {
1474
+ if (config[option] === true && config.failClosed === undefined) {
1475
+ throw new Error(`${option} needs fail-closed mode (failClosed)`)
1476
+ }
1477
+ }
1478
+
1479
+ const constantRefusals = config.constantRefusals === true
1480
+ const templates = new Set(config.routes.map(route => route.path))
1481
+ const variantRows = new Set<string>()
1482
+
1483
+ for (const route of config.routes) {
1484
+ if (route.variants === undefined) continue
1485
+
1486
+ if (route.variants.length === 0) {
1487
+ throw new Error(`route ${route.method} ${route.path} has an empty variants list`)
1488
+ }
1489
+
1490
+ for (const variant of route.variants) {
1491
+ const where = `variant ${variant.method} ${variant.path} of ${route.method} ${route.path}`
1492
+
1493
+ if (variant.evidence !== route.evidence) {
1494
+ throw new Error(`${where} must carry its route's evidence`)
1495
+ }
1496
+
1497
+ if (templates.has(variant.path) || variantRows.has(variant.path)) {
1498
+ throw new Error(`${where} repeats a route template or another variant path`)
1499
+ }
1500
+
1501
+ variantRows.add(variant.path)
1502
+ }
1503
+ }
1504
+
1505
+ if (config.failClosed !== undefined) {
1506
+ for (const route of config.routes) {
1507
+ const missing = unpatternedParams(route)
1508
+
1509
+ if (missing.length > 0) {
1510
+ const where = `fail-closed route ${route.method} ${route.path}`
1511
+
1512
+ throw new Error(`${where} needs raw patterns for ${missing.join(', ')}`)
1513
+ }
1514
+ }
1515
+ }
1516
+
1517
+ const match = routeMatcher(config.routes)
1518
+ const manifest = config.routes.flatMap(routeManifest)
1519
+ const manifestPaths = new Set(manifest.map(row => row.path))
1520
+ const jobs = new Map<number, Job<State, Env>>()
1521
+ let nextJobId = 1
1522
+
1523
+ // Plan, fault decision, and commit run synchronously together: no request interleaves.
1524
+ const dispatch: CoreDispatch<State> = (state, request) => {
1525
+ const job = jobs.get(Number(request.headers.get(emulatorJobHeader) ?? 'NaN'))
1526
+
1527
+ if (job === undefined) return handlerFailedResponse()
1528
+
1529
+ job.dispatched = true
1530
+
1531
+ try {
1532
+ const planned = job.admission.plan(state, { env: config.env, seq: job.seq })
1533
+
1534
+ if (isNotEmulated(planned)) {
1535
+ // Resolved mode keeps every refusal reason percent-encodable: one that is not (an unpaired
1536
+ // surrogate) is a handler failure.
1537
+ if (
1538
+ resolveRequest !== undefined &&
1539
+ Result.isFailure(Result.try(() => encodeURIComponent(planned.reason)))
1540
+ ) {
1541
+ return handlerFailedResponse()
1542
+ }
1543
+
1544
+ const reason = scrubSecrets(planned.reason, job.secrets)
1545
+
1546
+ job.notEmulated = reason
1547
+
1548
+ // Resolved mode: the wrapper answers the refusal itself, outside the core.
1549
+ return resolveRequest === undefined ? notEmulatedResponse(reason) : answeredOutsideCore()
1550
+ }
1551
+
1552
+ const streamed = isStreamedCommit(planned)
1553
+
1554
+ // `guardOutput`: the prepared answer and the texts the commit would store must not repeat
1555
+ // the bearer; checked before any fault is decided or anything is written.
1556
+ if (config.guardOutput === true) {
1557
+ if (!streamed) return handlerFailedResponse()
1558
+
1559
+ if (outputRepeatsSecret(planned, job.secrets)) {
1560
+ job.notEmulated = outputRepeatReason
1561
+ job.credentialRepeat = true
1562
+
1563
+ return notEmulatedResponse(outputRepeatReason)
1564
+ }
1565
+ }
1566
+
1567
+ const decision = job.decideFault(streamed ? planned.answer.chunks.length : undefined)
1568
+
1569
+ if (decision.kind === 'answer') {
1570
+ if (resolveRequest === undefined) return decision.response
1571
+
1572
+ // Resolved mode: the fault's answer is returned outside the core, so a reset or a close
1573
+ // before it is read never cancels it.
1574
+ job.faultAnswer = decision.response
1575
+
1576
+ return answeredOutsideCore()
1577
+ }
1578
+
1579
+ if (!streamed) return planned()
1580
+
1581
+ // A truncated answer is the prepared one cut short; its commit never runs.
1582
+ if (decision.kind === 'truncate') return streamedResponse(planned.answer, decision.after)
1583
+
1584
+ planned.commit()
1585
+
1586
+ return streamedResponse(planned.answer, undefined)
1587
+ } catch {
1588
+ return handlerFailedResponse()
1589
+ }
1590
+ }
1591
+
1592
+ const core = await createCore(dispatch)
1593
+
1594
+ let baseline: State = config.initial
1595
+ let entries: Array<MutableLedgerEntry> = []
1596
+ let faultStates: Array<MutableFaultState> = []
1597
+ let nextSeq = 1
1598
+ let nextFaultId = 1
1599
+ let closed = false
1600
+
1601
+ const clearLedger = () => {
1602
+ entries = []
1603
+ nextSeq = 1
1604
+ }
1605
+
1606
+ /** Why a fault's `match.route` could never match (it names no manifest row), or `undefined`. */
1607
+ const matchRouteProblem = (fault: StatefulStreamFault): string | undefined => {
1608
+ const route = fault.match?.route
1609
+
1610
+ return route === undefined || manifestPaths.has(route)
1611
+ ? undefined
1612
+ : 'match.route must name a manifest row of this emulator'
1613
+ }
1614
+
1615
+ const addFault = (input: unknown): StatefulFaultState<StatefulStreamFault> | string => {
1616
+ const decoded = chunkFaults ? decodeStreamFault(input) : decodeFault(input)
1617
+
1618
+ if (Result.isFailure(decoded)) {
1619
+ return issueMessage(decoded.failure.issue)
1620
+ }
1621
+
1622
+ const problem = matchRouteProblem(decoded.success)
1623
+
1624
+ if (problem !== undefined) return problem
1625
+
1626
+ const state: MutableFaultState = {
1627
+ id: nextFaultId++,
1628
+ fault: decoded.success,
1629
+ remaining: decoded.success.count,
1630
+ applied: 0
1631
+ }
1632
+
1633
+ faultStates.push(state)
1634
+
1635
+ return snapshotFault(state)
1636
+ }
1637
+
1638
+ const takeFault = (
1639
+ method: string,
1640
+ path: string,
1641
+ route: string | undefined
1642
+ ): MutableFaultState | undefined =>
1643
+ faultStates.find(
1644
+ state =>
1645
+ (state.remaining === undefined || state.remaining > 0) &&
1646
+ faultMatches(state.fault, method, path, route)
1647
+ )
1648
+
1649
+ const spend = (state: MutableFaultState) => {
1650
+ state.applied += 1
1651
+
1652
+ if (state.remaining !== undefined) {
1653
+ state.remaining -= 1
1654
+ }
1655
+ }
1656
+
1657
+ /** Build the fault's response first: a response that cannot be built must not consume it. */
1658
+ const applyFault = (state: MutableFaultState, fault: StatefulFault): Response => {
1659
+ const headers = new Headers(fault.headers ?? {})
1660
+ const body = fault.body ?? faultBody(fault.status)
1661
+
1662
+ if (!headers.has('content-type')) {
1663
+ headers.set('content-type', Predicate.isString(body) ? 'text/plain' : 'application/json')
1664
+ }
1665
+
1666
+ const response = new Response(Predicate.isString(body) ? body : JSON.stringify(body), {
1667
+ status: fault.status,
1668
+ headers
1669
+ })
1670
+
1671
+ spend(state)
1672
+
1673
+ return response
1674
+ }
1675
+
1676
+ const reset = async () => {
1677
+ clearLedger()
1678
+ faultStates = []
1679
+ config.clearRuntime()
1680
+ await core.restore(baseline)
1681
+ }
1682
+
1683
+ const reseed = async (input: unknown): Promise<State | string> => {
1684
+ const next = config.buildSeed(input)
1685
+
1686
+ if (Predicate.isString(next)) {
1687
+ return next
1688
+ }
1689
+
1690
+ await core.restore(next)
1691
+ config.clearRuntime()
1692
+ baseline = next
1693
+
1694
+ return next
1695
+ }
1696
+
1697
+ const coverage = (): StatefulCoverage => ({
1698
+ routes: manifest.map(route => ({
1699
+ ...route,
1700
+ requests: entries.filter(
1701
+ entry =>
1702
+ entry.route === route.path &&
1703
+ (variantRows.has(route.path) || entry.method.toUpperCase() === route.method)
1704
+ ).length
1705
+ })),
1706
+ unknownRouteRequests: entries.filter(entry => entry.evidence === 'unknown-route').length,
1707
+ notEmulatedRequests: entries.filter(entry => entry.notEmulated !== undefined).length
1708
+ })
1709
+
1710
+ /** The ledgered 400; the reason is scrubbed of the request's guarded secrets first. */
1711
+ const refuse = (
1712
+ entry: MutableLedgerEntry,
1713
+ reason: string,
1714
+ secrets: ReadonlyArray<string> = []
1715
+ ): Response => {
1716
+ const safe = scrubSecrets(reason, secrets)
1717
+
1718
+ entry.notEmulated = safe
1719
+
1720
+ return notEmulatedResponse(safe)
1721
+ }
1722
+
1723
+ /**
1724
+ * Fail-closed mode: the constant reason when any part of a recognised request repeats a guarded
1725
+ * secret, or `undefined`. Checked, each through the fixpoint closure of `textRepeatsSecret` (a
1726
+ * capped closure counts as a repeat): the raw path and every path segment, the raw query and
1727
+ * every decoded query key and value, every recorded header, and the body.
1728
+ */
1729
+ const credentialRepeat = (
1730
+ request: Request,
1731
+ url: URL,
1732
+ body: string | undefined,
1733
+ secrets: ReadonlyArray<string>,
1734
+ binding: StatefulRouteBinding
1735
+ ): string | undefined => {
1736
+ if (
1737
+ textRepeatsSecret(url.pathname, secrets) ||
1738
+ url.pathname.split('/').some(segment => textRepeatsSecret(segment, secrets))
1739
+ ) {
1740
+ return 'the request path repeats the credential'
1741
+ }
1742
+
1743
+ if (
1744
+ textRepeatsSecret(url.search, secrets) ||
1745
+ [...url.searchParams].some(
1746
+ ([key, value]) => textRepeatsSecret(key, secrets) || textRepeatsSecret(value, secrets)
1747
+ )
1748
+ ) {
1749
+ return 'the query repeats the credential'
1750
+ }
1751
+
1752
+ if (
1753
+ config.recordHeaders.some(recorded =>
1754
+ textRepeatsSecret(request.headers.get(recorded.name) ?? '', secrets)
1755
+ )
1756
+ ) {
1757
+ return 'a recorded request header repeats the credential'
1758
+ }
1759
+
1760
+ if (config.guardAllHeaders === true) {
1761
+ const headers: Array<string> = []
1762
+
1763
+ request.headers.forEach((value, name) => {
1764
+ if (name !== 'authorization') headers.push(name, value)
1765
+ })
1766
+
1767
+ if (headers.some(text => textRepeatsSecret(text, secrets))) {
1768
+ return 'a request header repeats the credential'
1769
+ }
1770
+ }
1771
+
1772
+ if (body === undefined) return undefined
1773
+
1774
+ const repeat = 'the request body repeats the credential'
1775
+
1776
+ if (textRepeatsSecret(body, secrets)) return repeat
1777
+
1778
+ // The route's decoded views of its body (base64url content, for example) are checked too. A
1779
+ // view that throws refuses the body (uncertainty refuses, it never admits): a repeat when what
1780
+ // it decoded cleanly holds the bearer, else its declared constant reason, else the uncheckable
1781
+ // reason, so a request without the bearer is never told it repeats it.
1782
+ const { decodedViews, viewRefusalReasons = [] } = binding
1783
+ const views = Result.try(() => (decodedViews === undefined ? [] : decodedViews(body)))
1784
+
1785
+ if (Result.isFailure(views)) {
1786
+ const refusal = views.failure
1787
+
1788
+ if (!(refusal instanceof DecodedViewRefusal)) return uncheckableBodyReason
1789
+
1790
+ if (refusal.decoded.some(view => textRepeatsSecret(view, secrets))) return repeat
1791
+
1792
+ return viewRefusalReasons.includes(refusal.reason)
1793
+ ? scrubSecrets(refusal.reason, secrets)
1794
+ : uncheckableBodyReason
1795
+ }
1796
+
1797
+ return views.success.some(view => textRepeatsSecret(view, secrets)) ? repeat : undefined
1798
+ }
1799
+
1800
+ /** Everything after the route match: credential, headers, body, shape, then the core. */
1801
+ const routed = async (
1802
+ request: Request,
1803
+ url: URL,
1804
+ entry: MutableLedgerEntry,
1805
+ matched: MatchedRoute<State, Env>,
1806
+ secrets: ReadonlyArray<string>,
1807
+ arrivedOn: string,
1808
+ recorded: RecordedFields,
1809
+ /** Resolved mode: the request text besides the query that must not repeat a secret. */
1810
+ guardedPath: string | undefined
1811
+ ): Promise<Response> => {
1812
+ // Resolved mode: routes read the content type only, never a header that may carry a secret.
1813
+ const header = (name: string): string | undefined =>
1814
+ isCredentialHeaderName(name) ||
1815
+ (guardedPath !== undefined && name.toLowerCase() !== 'content-type')
1816
+ ? undefined
1817
+ : (request.headers.get(name) ?? undefined)
1818
+
1819
+ const refused = (reason: string) => refuse(entry, reason, secrets)
1820
+
1821
+ // Resolved mode reads the request body once, after the query and path checks, and every later
1822
+ // step reuses that read (`undefined`: the body is unreadable, consumed or locked included).
1823
+ let resolvedBody: { readonly bytes: Uint8Array | undefined } | undefined
1824
+
1825
+ if (guardedPath !== undefined) {
1826
+ // Resolved mode: the emulator resolved the credential; nothing may repeat it.
1827
+ const partsRepeat = partsCredentialRepeat(url, guardedPath, secrets)
1828
+
1829
+ if (partsRepeat !== undefined) return refused(partsRepeat)
1830
+
1831
+ resolvedBody = { bytes: await readBytes(request) }
1832
+
1833
+ const bodyRepeat = bodyCredentialRepeat(
1834
+ resolvedBody.bytes === undefined ? undefined : bodyText(resolvedBody.bytes),
1835
+ secrets
1836
+ )
1837
+
1838
+ if (bodyRepeat !== undefined) return refused(bodyRepeat)
1839
+ } else if (config.failClosed === undefined) {
1840
+ if (!bearerPattern.test(request.headers.get('authorization') ?? '')) {
1841
+ return refused('a non-empty Authorization: Bearer credential is required')
1842
+ }
1843
+ } else {
1844
+ // The header is absent here (an unrecognisable one never reaches a route).
1845
+ if (secrets.length === 0) return refused(missingBearerReason)
1846
+
1847
+ // A request repeating the credential never gets here (see `credentialRepeat`).
1848
+ if (hasEmptyQueryComponent(request.url)) {
1849
+ return refused('empty query components (a bare ? or a stray &) are not emulated')
1850
+ }
1851
+ }
1852
+
1853
+ const problem = config.requestProblem?.(header)
1854
+
1855
+ if (problem !== undefined) return refused(problem)
1856
+
1857
+ // Resolved mode reuses the one body read it already made. Every other emulator awaits
1858
+ // `readText(request)` directly, so its request scheduling is exactly what it was.
1859
+ const resolvedText = (): string | undefined =>
1860
+ resolvedBody?.bytes === undefined ? undefined : bodyText(resolvedBody.bytes)
1861
+
1862
+ let json: Schema.Json | undefined
1863
+ let bytes: Uint8Array | undefined
1864
+
1865
+ switch (matched.route.body) {
1866
+ case 'none': {
1867
+ const text = resolvedBody === undefined ? await readText(request) : resolvedText()
1868
+
1869
+ if (text === undefined || text !== '') {
1870
+ return refused('this route takes no request body')
1871
+ }
1872
+
1873
+ break
1874
+ }
1875
+
1876
+ case 'json': {
1877
+ const text = resolvedBody === undefined ? await readText(request) : resolvedText()
1878
+
1879
+ if (mediaType(header('content-type')) !== 'application/json') {
1880
+ return refused('this route takes a content-type: application/json body')
1881
+ }
1882
+
1883
+ const parsed = text === undefined ? undefined : parseJsonText(text)
1884
+
1885
+ if (parsed === undefined) return refused('the request body is not valid JSON')
1886
+
1887
+ if (config.uniqueJsonKeys === true && text !== undefined && repeatsJsonKey(text)) {
1888
+ return refused('a JSON body with a repeated key is not emulated')
1889
+ }
1890
+
1891
+ // Redacted before it is stored: a refused request's body stays in the ledger too (with
1892
+ // `constantRefusals`, only once the route admits the request).
1893
+ recorded.body = redactCredentialFields(parsed)
1894
+
1895
+ if (!constantRefusals) entry.body = recorded.body
1896
+
1897
+ json = parsed
1898
+
1899
+ break
1900
+ }
1901
+
1902
+ case 'json-or-empty': {
1903
+ const text = resolvedBody === undefined ? await readText(request) : resolvedText()
1904
+
1905
+ if (text === undefined) return refused('the request body is unreadable')
1906
+
1907
+ if (text === '') break
1908
+
1909
+ const parsed = parseJsonText(text)
1910
+
1911
+ if (parsed === undefined) return refused('the request body is not JSON')
1912
+
1913
+ if (config.uniqueJsonKeys === true && repeatsJsonKey(text)) {
1914
+ return refused('a JSON body with a repeated key is not emulated')
1915
+ }
1916
+
1917
+ recorded.body = redactCredentialFields(parsed)
1918
+
1919
+ if (!constantRefusals) entry.body = recorded.body
1920
+
1921
+ json = parsed
1922
+
1923
+ break
1924
+ }
1925
+
1926
+ case 'bytes': {
1927
+ bytes = resolvedBody === undefined ? await readBytes(request) : resolvedBody.bytes
1928
+
1929
+ if (bytes === undefined) return refused('the request body is unreadable')
1930
+
1931
+ recorded.bodyBytes = bytes.byteLength
1932
+
1933
+ if (!constantRefusals) entry.bodyBytes = recorded.bodyBytes
1934
+
1935
+ break
1936
+ }
1937
+ }
1938
+
1939
+ // Fail-closed mode only (checked at build): the per-origin digest of the bearer, never the
1940
+ // bearer. A digest that cannot be made, or that repeats the bearer, is a handler failure.
1941
+ let bearerDigest: string | undefined
1942
+ const [bearer] = secrets
1943
+
1944
+ if (config.bearerDigest !== undefined && bearer !== undefined) {
1945
+ const digest = Result.try(() => config.bearerDigest?.(bearer, arrivedOn))
1946
+
1947
+ if (
1948
+ Result.isFailure(digest) ||
1949
+ !Predicate.isString(digest.success) ||
1950
+ textRepeatsSecret(digest.success, secrets)
1951
+ ) {
1952
+ entry.responseError = 'the bearer digest failed'
1953
+
1954
+ return emulatorError(500, errorTexts.failed)
1955
+ }
1956
+
1957
+ bearerDigest = digest.success
1958
+ }
1959
+
1960
+ // Resolved mode: routes see the ledgered path and no header names, never the credential a
1961
+ // path segment or a header name may carry.
1962
+ const admitted = matched.route.admit(
1963
+ {
1964
+ method: request.method.toUpperCase(),
1965
+ path: guardedPath === undefined ? url.pathname : entry.path,
1966
+ params: matched.params,
1967
+ query: url.searchParams,
1968
+ rawQuery: rawQuery(request.url) ?? '',
1969
+ header,
1970
+ headerNames: guardedPath === undefined ? [...request.headers.keys()] : [],
1971
+ json,
1972
+ bytes,
1973
+ bearerDigest
1974
+ },
1975
+ config.env
1976
+ )
1977
+
1978
+ if (isNotEmulated(admitted)) return refused(admitted.reason)
1979
+
1980
+ // A route with variants admits a request as exactly one of them; a route without variants
1981
+ // admits it as none of them.
1982
+ const variants = matched.route.variants
1983
+ const variant = admitted.variant
1984
+
1985
+ if (
1986
+ variants === undefined ? variant !== undefined : !variants.some(row => row.path === variant)
1987
+ ) {
1988
+ entry.responseError = 'the route admitted the request as no row of its manifest'
1989
+
1990
+ return emulatorError(500, errorTexts.failed)
1991
+ }
1992
+
1993
+ if (variant !== undefined) entry.route = variant
1994
+
1995
+ // `constantRefusals`: the request text is recorded only now that the route admitted it.
1996
+ if (constantRefusals) {
1997
+ entry.method = recorded.method
1998
+ entry.path = recorded.path
1999
+ entry.query = recorded.query
2000
+ entry.headers = recorded.headers
2001
+
2002
+ if (recorded.body !== undefined) entry.body = recorded.body
2003
+
2004
+ if (recorded.bodyBytes !== undefined) entry.bodyBytes = recorded.bodyBytes
2005
+ }
2006
+
2007
+ const method = request.method.toUpperCase()
2008
+ const jobId = nextJobId++
2009
+ // Resolved mode: faults match, and the core sees, the ledgered path (never the credential).
2010
+ const requestPath = guardedPath === undefined ? url.pathname : entry.path
2011
+
2012
+ const job: Job<State, Env> = {
2013
+ admission: admitted,
2014
+ seq: entry.seq,
2015
+ secrets,
2016
+ decideFault: chunks => {
2017
+ const state = takeFault(method, requestPath, entry.route)
2018
+
2019
+ if (state === undefined) return { kind: 'none' }
2020
+
2021
+ const fault = state.fault
2022
+
2023
+ if (fault.kind === 'status') {
2024
+ const response = applyFault(state, fault)
2025
+
2026
+ entry.fault = 'status'
2027
+
2028
+ return { kind: 'answer', response }
2029
+ }
2030
+
2031
+ // A truncation that cannot take effect answers 500 and is not used up (never a no-op).
2032
+ if (chunks === undefined || fault.chunks >= chunks) {
2033
+ entry.responseError =
2034
+ `emulator fault cannot apply: truncate-after-chunks after ${fault.chunks} chunk(s) ` +
2035
+ 'needs a streamed answer of more chunks'
2036
+
2037
+ return { kind: 'answer', response: emulatorError(500, 'emulator fault cannot apply') }
2038
+ }
2039
+
2040
+ spend(state)
2041
+ entry.fault = 'truncate-after-chunks'
2042
+
2043
+ return { kind: 'truncate', after: fault.chunks }
2044
+ }
2045
+ }
2046
+
2047
+ jobs.set(jobId, job)
2048
+
2049
+ try {
2050
+ const response = await core.fetch(
2051
+ new Request(new URL(requestPath, core.baseUrl), {
2052
+ method: 'POST',
2053
+ headers: { [emulatorJobHeader]: String(jobId) }
2054
+ })
2055
+ )
2056
+
2057
+ if (response.headers.has(handlerFailedHeader)) {
2058
+ entry.responseError = 'the route handler failed'
2059
+
2060
+ return emulatorError(500, errorTexts.failed)
2061
+ }
2062
+
2063
+ // Resolved mode: a core that answered without running the dispatch (closed meanwhile)
2064
+ // decided nothing about the request.
2065
+ if (guardedPath !== undefined && job.dispatched !== true) {
2066
+ entry.responseError = 'the route handler answered no eligibility verdict'
2067
+
2068
+ return emulatorError(500, errorTexts.failed)
2069
+ }
2070
+
2071
+ if (job.notEmulated !== undefined) {
2072
+ entry.notEmulated = job.notEmulated
2073
+
2074
+ if (constantRefusals || job.credentialRepeat === true) blankEntry(entry)
2075
+
2076
+ // The constant credential-repeat entry keeps the route template, as a request repeat does.
2077
+ if (job.credentialRepeat === true) entry.route = matched.route.path
2078
+
2079
+ // Resolved mode: the wrapper answers the refusal itself, outside the core.
2080
+ if (guardedPath !== undefined) return notEmulatedResponse(job.notEmulated)
2081
+ }
2082
+
2083
+ return job.faultAnswer ?? response
2084
+ } finally {
2085
+ jobs.delete(jobId)
2086
+ }
2087
+ }
2088
+
2089
+ /**
2090
+ * Fail closed: an unrecognised request is ledgered and answered with constant text only. `method`
2091
+ * is the request method upper-cased (fail-closed mode) or as sent (resolved mode).
2092
+ */
2093
+ const unrecognised = (method: string, reason: string): Response => {
2094
+ entries.push({
2095
+ seq: nextSeq++,
2096
+ method: unrecognisedMethod(method),
2097
+ path: unrecognisedLedgerPath,
2098
+ query: {},
2099
+ headers: {},
2100
+ status: 400,
2101
+ evidence: 'unknown-route',
2102
+ notEmulated: reason
2103
+ })
2104
+
2105
+ return notEmulatedResponse(reason)
2106
+ }
2107
+
2108
+ const emulatedApi = async (request: Request, url: URL, arrivedOn: string): Promise<Response> => {
2109
+ const resolution = resolveRequest?.(request, url)
2110
+
2111
+ // Resolved mode: the emulator's own resolution fails closed with constant text only.
2112
+ if (resolution?.kind === 'unrecognised') return unrecognised(request.method, resolution.reason)
2113
+
2114
+ const matched: MatchedRoute<State, Env> | undefined =
2115
+ resolution === undefined
2116
+ ? match(request.method, url.pathname)
2117
+ : {
2118
+ route: resolution.route,
2119
+ params: resolution.kind === 'route' ? resolution.params : {}
2120
+ }
2121
+
2122
+ const failClosed = config.failClosed
2123
+ let secrets: ReadonlyArray<string> = resolution?.secrets ?? []
2124
+
2125
+ if (failClosed !== undefined) {
2126
+ const authorization = request.headers.get('authorization')
2127
+ const bearer = recognisableBearer(authorization)
2128
+
2129
+ // Its credential cannot be extracted and scrubbed: nothing of the request is kept.
2130
+ if (authorization !== null && bearer === undefined) {
2131
+ return unrecognised(request.method.toUpperCase(), failClosed.unrecognisedAuthorization)
2132
+ }
2133
+
2134
+ if (matched === undefined) {
2135
+ return unrecognised(request.method.toUpperCase(), failClosed.unrecognised)
2136
+ }
2137
+
2138
+ secrets = bearer === undefined ? [] : [bearer]
2139
+
2140
+ if (secrets.length > 0) {
2141
+ // Read from a copy: the route reads the body again.
2142
+ const body = request.body === null ? undefined : await readText(request.clone())
2143
+ const reason = credentialRepeat(request, url, body, secrets, matched.route)
2144
+
2145
+ // Nothing of a request that repeats the credential is kept, in any part: it is ledgered
2146
+ // and answered with constant text only (its route template is constant too).
2147
+ if (reason !== undefined) {
2148
+ entries.push({
2149
+ seq: nextSeq++,
2150
+ method: unrecognisedMethod(request.method.toUpperCase()),
2151
+ path: unrecognisedLedgerPath,
2152
+ route: matched.route.path,
2153
+ query: {},
2154
+ headers: {},
2155
+ status: 400,
2156
+ evidence: matched.route.evidence,
2157
+ notEmulated: reason
2158
+ })
2159
+
2160
+ return withEvidence(notEmulatedResponse(reason), matched.route.evidence)
2161
+ }
2162
+ }
2163
+ }
2164
+
2165
+ const scrub = (text: string): string => scrubSecrets(text, secrets)
2166
+
2167
+ // Query keys and values are scrubbed of the request's secrets before they are recorded. In
2168
+ // fail-closed mode every original pair is recorded (no pair repeats a secret by now): a key
2169
+ // that occurs more than once is recorded as a JSON array of its values, in order. Resolved
2170
+ // mode records the values as sent (credential-named keys redacted).
2171
+ const query =
2172
+ failClosed === undefined
2173
+ ? Object.fromEntries(
2174
+ Object.entries(
2175
+ resolution === undefined
2176
+ ? recordedQuery(url.searchParams)
2177
+ : redactCredentialQuery(url.searchParams)
2178
+ ).map(([key, value]) => [scrub(key), scrub(value)])
2179
+ )
2180
+ : recordedQueryPairs(url.searchParams, scrub)
2181
+
2182
+ const recorded: RecordedFields = {
2183
+ method: scrub(request.method),
2184
+ path: scrub(resolution?.ledgerPath ?? url.pathname),
2185
+ query,
2186
+ headers: {}
2187
+ }
2188
+
2189
+ for (const header of config.recordHeaders) {
2190
+ const value = request.headers.get(header.name)
2191
+
2192
+ if (value === null) continue
2193
+
2194
+ const text = recordedHeaderValue(header, value)
2195
+
2196
+ // Fail-closed mode recognises a JSON-looking value whatever the header's declared format.
2197
+ recorded.headers[header.name] = scrub(
2198
+ failClosed === undefined ? text : recordedJsonLooking(text)
2199
+ )
2200
+ }
2201
+
2202
+ const evidence = matched?.route.evidence ?? 'unknown-route'
2203
+
2204
+ // `constantRefusals`: constant text until the route admits the request.
2205
+ const entry: MutableLedgerEntry = constantRefusals
2206
+ ? {
2207
+ seq: nextSeq++,
2208
+ method: unrecognisedMethod(request.method.toUpperCase()),
2209
+ path: unrecognisedLedgerPath,
2210
+ query: {},
2211
+ headers: {},
2212
+ status: 0,
2213
+ evidence
2214
+ }
2215
+ : resolution === undefined
2216
+ ? {
2217
+ seq: nextSeq++,
2218
+ method: recorded.method,
2219
+ path: recorded.path,
2220
+ query: recorded.query,
2221
+ headers: { ...recorded.headers },
2222
+ status: 0,
2223
+ evidence
2224
+ }
2225
+ : // Resolved mode: the route is known from the start (it keeps this position).
2226
+ {
2227
+ seq: nextSeq++,
2228
+ method: recorded.method,
2229
+ path: recorded.path,
2230
+ route: resolution.route.path,
2231
+ query: recorded.query,
2232
+ headers: { ...recorded.headers },
2233
+ status: 0,
2234
+ evidence
2235
+ }
2236
+
2237
+ entries.push(entry)
2238
+
2239
+ if (matched === undefined) {
2240
+ entry.status = 400
2241
+
2242
+ return refuse(entry, 'no emulated route for this method and path')
2243
+ }
2244
+
2245
+ entry.route = matched.route.path
2246
+
2247
+ if (matched.route.origin !== undefined && matched.route.origin !== arrivedOn) {
2248
+ entry.status = 400
2249
+
2250
+ return withEvidence(
2251
+ refuse(entry, `this route is recorded on ${matched.route.origin} only`, secrets),
2252
+ matched.route.evidence
2253
+ )
2254
+ }
2255
+
2256
+ // Resolved mode: the emulator refused the request before its body is read.
2257
+ if (resolution?.kind === 'refused') {
2258
+ const tagged = withEvidence(
2259
+ refuse(entry, resolution.reason, secrets),
2260
+ resolution.route.evidence
2261
+ )
2262
+
2263
+ entry.status = tagged.status
2264
+
2265
+ return tagged
2266
+ }
2267
+
2268
+ const guardedPath = resolution?.guardedPath
2269
+
2270
+ // Error recovery still answers through the route: the fallback 500 is evidence-tagged and
2271
+ // the ledger records the status actually sent.
2272
+ const response = await routed(
2273
+ request,
2274
+ url,
2275
+ entry,
2276
+ matched,
2277
+ secrets,
2278
+ arrivedOn,
2279
+ recorded,
2280
+ guardedPath
2281
+ ).catch(() => {
2282
+ entry.responseError = 'the emulator could not build or produce the response'
2283
+
2284
+ return emulatorError(500, errorTexts.failed)
2285
+ })
2286
+
2287
+ const tagged = withEvidence(response, matched.route.evidence)
2288
+
2289
+ entry.status = tagged.status
2290
+
2291
+ return tagged
2292
+ }
2293
+
2294
+ const controlPlane = async (request: Request, path: string): Promise<Response> => {
2295
+ const method = request.method
2296
+ const allow = (methods: string) => emulatorError(405, 'method not allowed', { allow: methods })
2297
+
2298
+ const jsonBody = async (): Promise<Schema.Json | undefined> => {
2299
+ const text = await readText(request)
2300
+
2301
+ return text === undefined ? undefined : parseJsonText(text)
2302
+ }
2303
+
2304
+ switch (path) {
2305
+ case '/_emulate/ledger':
2306
+ if (method === 'GET') {
2307
+ return jsonResponse(200, { entries: entries.map(snapshotLedgerEntry) })
2308
+ }
2309
+
2310
+ if (method === 'DELETE') {
2311
+ const cleared = entries.length
2312
+
2313
+ clearLedger()
2314
+
2315
+ return jsonResponse(200, { cleared })
2316
+ }
2317
+
2318
+ return allow('GET, DELETE')
2319
+
2320
+ case '/_emulate/faults': {
2321
+ if (method === 'GET') {
2322
+ return jsonResponse(200, { faults: faultStates.map(snapshotFault) })
2323
+ }
2324
+
2325
+ if (method === 'DELETE') {
2326
+ const cleared = faultStates.length
2327
+
2328
+ faultStates = []
2329
+
2330
+ return jsonResponse(200, { cleared })
2331
+ }
2332
+
2333
+ if (method !== 'POST') {
2334
+ return allow('GET, POST, DELETE')
2335
+ }
2336
+
2337
+ const input = await jsonBody()
2338
+
2339
+ const decoded = chunkFaults ? decodeStreamFaultList(input) : decodeFaultList(input)
2340
+
2341
+ if (Result.isFailure(decoded)) {
2342
+ return emulatorError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`)
2343
+ }
2344
+
2345
+ const faults = 'faults' in decoded.success ? decoded.success.faults : [decoded.success]
2346
+ const problem = faults.map(matchRouteProblem).find(Predicate.isString)
2347
+
2348
+ // Checked before any fault is added: a list is added whole or not at all.
2349
+ if (problem !== undefined) return emulatorError(400, `invalid fault: ${problem}`)
2350
+
2351
+ return jsonResponse(201, { faults: faults.map(fault => addFault(fault)) })
2352
+ }
2353
+
2354
+ case '/_emulate/reset':
2355
+ if (method !== 'POST') {
2356
+ return allow('POST')
2357
+ }
2358
+
2359
+ await reset()
2360
+
2361
+ return jsonResponse(200, { reset: true })
2362
+
2363
+ case '/_emulate/state':
2364
+ if (method !== 'GET') {
2365
+ return allow('GET')
2366
+ }
2367
+
2368
+ return jsonResponse(200, {
2369
+ state: core.snapshot(),
2370
+ ...config.runtimeState(),
2371
+ faults: faultStates.map(snapshotFault),
2372
+ ledgerEntries: entries.length
2373
+ })
2374
+
2375
+ case '/_emulate/seed': {
2376
+ if (method !== 'POST') {
2377
+ return allow('POST')
2378
+ }
2379
+
2380
+ const next = await reseed((await jsonBody()) ?? null)
2381
+
2382
+ if (Predicate.isString(next)) {
2383
+ return emulatorError(400, `invalid seed: ${next}`)
2384
+ }
2385
+
2386
+ return jsonResponse(200, { seeded: true, ...config.seedSummary(next) })
2387
+ }
2388
+
2389
+ case '/_emulate/coverage':
2390
+ if (method !== 'GET') {
2391
+ return allow('GET')
2392
+ }
2393
+
2394
+ return jsonResponse(200, coverage())
2395
+
2396
+ default:
2397
+ return emulatorError(404, 'unknown control-plane route')
2398
+ }
2399
+ }
2400
+
2401
+ /**
2402
+ * Last resort when handling itself fails (for example an unparseable request URL): a 500
2403
+ * emulator error, tagged with the matched route's evidence when there is one (in resolved mode,
2404
+ * whose own resolution may be what failed, never).
2405
+ */
2406
+ const lastResort = (request: Request): Response => {
2407
+ const path = URL.canParse(request.url) ? new URL(request.url).pathname : undefined
2408
+ const failed = emulatorError(500, errorTexts.unhandled)
2409
+
2410
+ if (path === undefined || isControlPath(path) || resolveRequest !== undefined) return failed
2411
+
2412
+ const matched = match(request.method, path)
2413
+
2414
+ return matched === undefined ? failed : withEvidence(failed, matched.route.evidence)
2415
+ }
2416
+
2417
+ const handle = async (request: Request, origin: string | undefined): Promise<Response> => {
2418
+ if (closed) {
2419
+ return emulatorError(503, errorTexts.closed)
2420
+ }
2421
+
2422
+ const url = new URL(request.url)
2423
+
2424
+ return isControlPath(url.pathname)
2425
+ ? controlPlane(request, url.pathname)
2426
+ : emulatedApi(request, url, origin ?? url.origin)
2427
+ }
2428
+
2429
+ const fetchOn =
2430
+ (origin: string | undefined) =>
2431
+ (request: Request): Promise<Response> =>
2432
+ handle(request, origin).catch(() => lastResort(request))
2433
+
2434
+ return {
2435
+ fetch: fetchOn(undefined),
2436
+ fetchOn: origin => fetchOn(origin),
2437
+ ledger: {
2438
+ entries: () => entries.map(snapshotLedgerEntry),
2439
+ clear: clearLedger
2440
+ },
2441
+ faults: {
2442
+ add: fault => {
2443
+ const added = addFault(fault)
2444
+
2445
+ if (Predicate.isString(added)) {
2446
+ throw config.inputInvalid('fault', added)
2447
+ }
2448
+
2449
+ return added
2450
+ },
2451
+ list: () => faultStates.map(snapshotFault),
2452
+ clear: () => {
2453
+ faultStates = []
2454
+ }
2455
+ },
2456
+ reset,
2457
+ seed: async input => {
2458
+ const next = await reseed(input)
2459
+
2460
+ if (Predicate.isString(next)) {
2461
+ throw config.inputInvalid('seed', next)
2462
+ }
2463
+ },
2464
+ snapshot: () => core.snapshot(),
2465
+ coverage,
2466
+ close: () => {
2467
+ closed = true
2468
+
2469
+ return core.close()
2470
+ }
2471
+ }
2472
+ }
2473
+
2474
+ /** The fault states of an emulator without truncation faults (its decoder takes status faults). */
2475
+ const statusFaultStates = (
2476
+ states: ReadonlyArray<StatefulFaultState<StatefulStreamFault>>
2477
+ ): ReadonlyArray<StatefulFaultState> =>
2478
+ states.flatMap(state => {
2479
+ const fault = state.fault
2480
+
2481
+ return fault.kind === 'status' ? [{ ...state, fault }] : []
2482
+ })
2483
+
2484
+ /** The API of an emulator without truncation faults: its fault methods take status faults only. */
2485
+ const withStatusFaults = <State, Seed, Entry>(
2486
+ api: StatefulEmulatorApi<State, Seed, StatefulStreamFault, Entry>,
2487
+ inputInvalid: (input: StatefulInputKind, reason: string) => Error
2488
+ ): StatefulEmulatorApi<State, Seed, StatefulFault, Entry> => ({
2489
+ ...api,
2490
+ faults: {
2491
+ add: fault => {
2492
+ const [added] = statusFaultStates([api.faults.add(fault)])
2493
+
2494
+ if (added === undefined) throw inputInvalid('fault', 'a status fault is required')
2495
+
2496
+ return added
2497
+ },
2498
+ list: () => statusFaultStates(api.faults.list()),
2499
+ clear: api.faults.clear
2500
+ }
2501
+ })
2502
+
2503
+ /**
2504
+ * Build the wrapper over a core runtime. `createCore` receives the dispatch the core must run for
2505
+ * every request (its only route) and returns the runtime adapter. Faults are status faults only.
2506
+ */
2507
+ export const makeStatefulEmulator = async <State, Env, Seed>(
2508
+ config: StatefulEmulatorConfig<State, Env>,
2509
+ createCore: (dispatch: CoreDispatch<State>) => Promise<StatefulCore<State>>
2510
+ ): Promise<StatefulEmulatorApi<State, Seed>> =>
2511
+ withStatusFaults(
2512
+ await buildStatefulEmulator<State, Env, Seed, StatefulLedgerEntry>(
2513
+ config,
2514
+ createCore,
2515
+ false,
2516
+ snapshotEntry
2517
+ ),
2518
+ config.inputInvalid
2519
+ )
2520
+
2521
+ /**
2522
+ * `makeStatefulEmulator` for an emulator that records no request header (opt-in): it takes no
2523
+ * `recordHeaders`, and its ledger entries carry no `headers` field
2524
+ * (`StatefulHeaderlessLedgerEntry`). Faults are status faults only.
2525
+ */
2526
+ export const makeHeaderlessStatefulEmulator = async <State, Env, Seed>(
2527
+ config: Omit<StatefulEmulatorConfig<State, Env>, 'recordHeaders'>,
2528
+ createCore: (dispatch: CoreDispatch<State>) => Promise<StatefulCore<State>>
2529
+ ): Promise<StatefulEmulatorApi<State, Seed, StatefulFault, StatefulHeaderlessLedgerEntry>> =>
2530
+ withStatusFaults(
2531
+ await buildStatefulEmulator<State, Env, Seed, StatefulHeaderlessLedgerEntry>(
2532
+ { ...config, recordHeaders: [] },
2533
+ createCore,
2534
+ false,
2535
+ snapshotHeaderlessEntry
2536
+ ),
2537
+ config.inputInvalid
2538
+ )
2539
+
2540
+ /**
2541
+ * `makeStatefulEmulator` that also takes `truncate-after-chunks` faults
2542
+ * (`StatefulTruncateFault`), which cut a streamed answer (`StreamedCommit`). Opt-in: the other
2543
+ * emulators keep status faults only.
2544
+ */
2545
+ export const makeChunkedStatefulEmulator = <State, Env, Seed>(
2546
+ config: StatefulEmulatorConfig<State, Env>,
2547
+ createCore: (dispatch: CoreDispatch<State>) => Promise<StatefulCore<State>>
2548
+ ): Promise<StatefulEmulatorApi<State, Seed, StatefulStreamFault>> =>
2549
+ buildStatefulEmulator<State, Env, Seed, StatefulLedgerEntry>(
2550
+ config,
2551
+ createCore,
2552
+ true,
2553
+ snapshotEntry
2554
+ )
2555
+
2556
+ /** Throws `inputInvalid('option', ...)` unless every drill knob is a known boolean (or absent). */
2557
+ export const checkBooleanDrills = (
2558
+ drills: object | undefined,
2559
+ knobs: ReadonlyArray<string>,
2560
+ inputInvalid: (input: StatefulInputKind, reason: string) => Error
2561
+ ): void => {
2562
+ for (const [key, value] of Object.entries(drills ?? {})) {
2563
+ if (!knobs.includes(key)) {
2564
+ throw inputInvalid('option', `unknown drill knob ${key}`)
2565
+ }
2566
+
2567
+ if (value !== undefined && !Predicate.isBoolean(value)) {
2568
+ throw inputInvalid('option', `drills.${key} must be a boolean`)
2569
+ }
2570
+ }
2571
+ }