@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,1228 @@
1
+ import { EmulatorHeaderRecord, answeredOutsideCore, emulatorJobHeader, handlerFailedResponse, isCredentialHeaderName, isCredentialQueryKey, redactCredentialFields, redactCredentialQuery, redactedCredentialValue } from "./emulator-http.mjs";
2
+ import { emulatorEvidenceHeader, emulatorRouteKey } from "./route-evidence.mjs";
3
+ import { isRecognisableBearerValue, jsonRepeatsSecret, repeatsSecret, scrubSecrets, textRepeatsSecret, unrecognisedLedgerPath, unrecognisedMethod } from "./stateful-secrets.mjs";
4
+ import { Data, Predicate, Result } from "effect";
5
+ import * as Schema from "effect/Schema";
6
+ //#region src/stateful-emulator.ts
7
+ /**
8
+ * Shared wrapper of the fixture-only stateful connector emulators (internal; used by `/dropbox`,
9
+ * `/notion`, `/todoist`, `/telegram`, `/github`, `/google`, `/linkedin-search`, and `/mcp`, not by
10
+ * the earlier `/fortnox` and `/microsoft` emulators, which keep their own).
11
+ *
12
+ * The emulator state lives in an `@emulators/core` custom runtime that the Node-only subpath
13
+ * creates and hands in (`src/stateful-core.ts`; this module imports no Node builtin and never
14
+ * imports the core); the request ledger, status faults, credential handling, and the
15
+ * `/_emulate/*` control plane live here, because the core reserves `/_emulate`.
16
+ *
17
+ * The owner rule: response behaviour comes only from the committed conformance fixtures. Every
18
+ * request is answered by its route or with one ledgered 400 not-emulated
19
+ * (`{ error: { type: 'not_emulated', message } }`, `notEmulated` in the ledger). Precedence per
20
+ * request:
21
+ *
22
+ * 1. route match on the raw path (path parameters decoded once; unknown routes and methods, and
23
+ * invalid percent-encoding, are not emulated), and the origin the route is recorded on;
24
+ * 2. a non-empty `Authorization: Bearer` credential (never compared against anything, stored,
25
+ * forwarded, or ledgered) and the emulator's header rules;
26
+ * 3. the body the route takes (none, JSON with an `application/json` media type, or raw bytes);
27
+ * 4. the route's request-shape check, which reads no state;
28
+ * 5. in the core runtime, the route's plan: a pure, state-reading eligibility check that refuses
29
+ * what the state cannot answer the way a fixture does, and otherwise returns a commit;
30
+ * 6. the first matching status fault, decided only for an eligible request (nothing is written);
31
+ * 7. the commit, the only step that writes.
32
+ *
33
+ * A request that is not emulated (steps 1 to 5) never uses up a fault. The plan, the fault
34
+ * decision, and the commit run synchronously together, so no other request interleaves. A route
35
+ * that throws answers an evidence-tagged 500 emulator error
36
+ * (`responseError` in the ledger); a closed emulator answers 503. No recovery answer reads the
37
+ * injectable clock, so a failing clock cannot change it. Every response of a matched route
38
+ * carries `x-emulator-evidence: unverified` when the route is unverified. Ledgered bodies and
39
+ * query parameters have credential-named keys redacted.
40
+ *
41
+ * Fail-closed mode (opt-in, `failClosed`; used by `/github`, `/google`, and `/linkedin-search`):
42
+ * every route parameter has a raw pattern (matched in full), and a request is recognised only when
43
+ * its raw path is exactly an emulated route shape under that route's method and any `Authorization`
44
+ * header is exactly `Bearer <at least 8 non-space characters>` (a recognisable bearer, below).
45
+ * Every other request is ledgered and answered with constant text only (`/<unrecognised>`, a
46
+ * standard method or `<other>`, an empty query, no body, a constant reason). A recognised bearer
47
+ * must match the RFC 6750 `b64token` syntax exactly (`^[A-Za-z0-9\-._~+/]+=*$`, at least 8
48
+ * characters), start with a character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, and hold
49
+ * at least one character outside the JSON-number alphabet `[0-9.eE+-]` (every GitHub and Google
50
+ * token form does: `ghp_…`, `github_pat_…`, `gho_…`, `ya29.…`). So no number's text can contain it;
51
+ * it holds no escape introducer (`%`, `\`, `"`), so no escape starts inside it; and its first
52
+ * character is no hex digit and no JSON escape letter, so no stray `%`, `\`, or partial escape to
53
+ * its left can complete with it, and its characters always decode in place. An `Authorization`
54
+ * header with any other value is unrecognisable. A recognised request that repeats the bearer value
55
+ * in its raw path, any path segment, the raw query or any query key or value, any recorded header,
56
+ * or its body is refused and ledgered with constant text only: a standard method, the path
57
+ * `/<unrecognised>`, its route template, an empty query, no headers or body, and a constant reason
58
+ * (`the query repeats the credential`, for example). Each part is checked through the closure of
59
+ * two total, lexical transforms that cannot fail: a tolerant percent-decode (every `%XX` below
60
+ * `%80` becomes its ASCII character; any other `%` sequence is left as it is) and a tolerant
61
+ * JSON-unescape (in any text, whether or not it parses as JSON, `\uXXXX` below `\u0080` and `\"`,
62
+ * `\\`, `\/`, `\b`, `\f`, `\n`, `\r`, `\t` become their characters). Starting from each part's raw
63
+ * text, either transform is applied to every text of the previous step, deduplicated, until no new
64
+ * text appears (a fixpoint), and every text is checked for the bearer as a substring; both
65
+ * transforms never lengthen a text and shorten it whenever they change it. So any depth of
66
+ * percent-encoding or JSON escaping, in any order, is seen through in every part: the raw path and
67
+ * each raw path segment, the raw query and each query key and value (already decoded once by
68
+ * `URLSearchParams`), each recorded header, and the raw body. The work is capped at 64 rounds, 1024
69
+ * distinct texts, or 8 Mi characters read by the transforms, whichever comes first; a part whose
70
+ * closure hits a cap before its fixpoint counts as repeating the credential and is refused with the
71
+ * same constant entry (uncertainty refuses, it never admits; so any part over 4 Mi characters is
72
+ * always refused). Any other recognised request has the bearer value scrubbed from its ledgered
73
+ * fields and every not-emulated reason (plan-time reasons included); its recorded query is keyed by
74
+ * recorded key; a key recorded more than once lists its values in order (as a JSON array); and
75
+ * recorded headers and query keys and values that start like JSON (`{`, `[`, `"`) are recorded
76
+ * parsed with credential-named keys redacted at any depth, or as `<redacted>` when they do not
77
+ * parse, whatever the header's declared format. Empty query components (a bare `?`, a stray `&`)
78
+ * are refused. Routes check their own query and body keys with `exactQuery` and `exactBodyKeys`,
79
+ * whose reasons never echo a request's own key (`exactQuery`'s opt-in `rawNames`, used by
80
+ * `/linkedin-search`, also refuses a parameter name in any but its plain form, comparing the raw
81
+ * names the wrapper hands routes as `rawQuery`). A template parameter written `{name+}` spans one
82
+ * or more path segments (each decoded once, none may decode to a `/`). The credential helpers live
83
+ * in `src/stateful-secrets.ts`. A route may also name decoded views of its raw body
84
+ * (`decodedViews`, opt-in; `/google` gives the base64url-decoded MIME of a Gmail draft's
85
+ * `message.raw`, which the provider's own wire format wraps): in fail-closed mode each view goes
86
+ * through the same fixpoint check as the raw body, before anything is recorded, a fault is decided,
87
+ * or anything is committed, and a hit is the same constant credential-repeat entry. A view may
88
+ * throw to refuse a body it cannot check completely: that request is ledgered as the same constant
89
+ * entry, as a repeat when the `DecodedViewRefusal` it threw carries cleanly decoded text holding
90
+ * the bearer, else with the refusal's reason when the route declares it in `viewRefusalReasons` (a
91
+ * constant the route owns, scrubbed defensively), else with
92
+ * `the request body cannot be checked for the credential`. A route without `decodedViews` is
93
+ * checked exactly as before. An emulator may also opt in to a per-origin bearer digest
94
+ * (`bearerDigest`, fail-closed mode only; `/linkedin-search` uses it): routes then see a one-way
95
+ * digest of the bearer for the origin the request arrived on (`EmulatedRequest.bearerDigest`),
96
+ * never the bearer, so a seed can mark a key as rejected on one origin by its digest, and the
97
+ * bearer still never reaches the state, the ledger, or `/_emulate/*`. A digest that throws or
98
+ * repeats the bearer answers the 500 emulator error (`responseError`). Without it, routes see no
99
+ * digest, as before.
100
+ *
101
+ * More opt-ins serve `/mcp`, whose JSON-RPC methods share one HTTP endpoint; a route or emulator
102
+ * that does not take them behaves exactly as before. A route may answer several manifest rows
103
+ * (`variants`, for example `RPC <origin><path>#<method>`): its admission names the row, which
104
+ * becomes the request's ledger route, its coverage row, and what a fault's `match.route` compares
105
+ * (a request refused before admission keeps the route template). A fault's `match.route` must name
106
+ * a manifest row (a variant, or the template of a route without variants); any other value is
107
+ * rejected when the fault is added, and `match.method` is always the HTTP method. A plan may return
108
+ * a `StreamedCommit`: the answer it prepares, and the `commit` that writes. Faults are decided
109
+ * against the prepared answer, and a faulted request never runs `commit`; an emulator built with
110
+ * `makeChunkedStatefulEmulator` also takes `truncate-after-chunks` faults, which send the prepared
111
+ * answer cut short and write nothing (one that cannot take effect answers 500 and is not used up).
112
+ * In fail-closed mode: `constantRefusals` ledgers every refusal with constant text only
113
+ * (`/<unrecognised>`, no query, headers, or body), so request text reaches the ledger only once a
114
+ * route admitted the request; `guardAllHeaders` checks every request header name and value but
115
+ * `Authorization` for a credential repeat, not only the recorded ones; and `guardOutput` checks a
116
+ * streamed commit's prepared answer (every header and chunk) and its `persisted` texts (minted ids,
117
+ * for example) for the bearer before any fault is decided or anything is committed, refusing a hit
118
+ * with the constant credential-repeat entry, so no answer or stored value repeats the bearer, while
119
+ * routes still see only its digest. `uniqueJsonKeys` refuses a JSON body in which an object repeats
120
+ * a key (after unescaping). Routes also see every request header name
121
+ * (`EmulatedRequest.headerNames`), never a credential value.
122
+ *
123
+ * Resolved mode (opt-in, `resolveRequest`; not with `failClosed`; used by `/todoist` and
124
+ * `/telegram`) serves an emulator whose credential the wrapper cannot find by itself (a Telegram
125
+ * bot token in a path segment) or that has its own credential rules (a Todoist bearer of any 8
126
+ * non-space characters): the emulator resolves every request itself, failing closed
127
+ * (`StatefulResolution`): an unrecognised request is ledgered with constant text only (the method
128
+ * as sent when standard, else `<other>`), a request it refuses before its body is read keeps its
129
+ * scrubbed fields, and a recognised one names its route, credential-free parameters, ledger path,
130
+ * guarded path, and the credential values to guard. Those values are scrubbed from everything the
131
+ * ledger keeps or a refusal answers (plan-time reasons included), and a query, guarded path, or
132
+ * body that repeats one (raw or percent-decoded once, and in any parsed JSON key, string, or
133
+ * number) is refused with a constant reason before anything else is checked. The ledger records
134
+ * the resolution's path and the query as sent (credential-named keys redacted), faults'
135
+ * `match.path` compares that path, and routes and the core see it too (routes see no header
136
+ * names and read no header but `content-type`), so the credential never reaches a route or the
137
+ * core. The request body is read once, after the query and path checks (so a query or path refusal
138
+ * leaves it unread, and any later refusal finds it read); a body that is already consumed or
139
+ * locked is refused as unreadable; a refusal reason that cannot be percent-encoded (an unpaired
140
+ * surrogate) is a handler failure (500). Fault answers and refusals are decided in the core with
141
+ * the commit, but returned by the wrapper itself, so a reset or a close before they are read never
142
+ * cancels them (only a commit's answer is the core's own); a core that answers without running
143
+ * the dispatch (closed meanwhile) gives the 500 handler failure (`responseError`
144
+ * `the route handler answered no eligibility verdict`). Resolved mode records no request
145
+ * header (`recordHeaders` must be empty). More opt-ins, off by default: a `json-or-empty` route
146
+ * body (JSON of any media type, or none), `makeHeaderlessStatefulEmulator` (ledger entries without
147
+ * a `headers` field), and `errorTexts` (the texts of the recovery answers).
148
+ *
149
+ * @experimental
150
+ */
151
+ /** A request the emulator does not emulate, with the reason (answered 400 not-emulated). */
152
+ var NotEmulated = class extends Data.TaggedClass("NotEmulated") {};
153
+ /**
154
+ * What a route's decoded view throws to refuse a body with a reason of its own (fail-closed mode;
155
+ * see `StatefulRouteBinding.decodedViews`). `reason` must be one of the route's declared
156
+ * `viewRefusalReasons` (a constant the route owns, never derived from the request); `decoded` holds
157
+ * any text the view did decode cleanly, which is still checked for the bearer first.
158
+ */
159
+ var DecodedViewRefusal = class extends Data.TaggedError("DecodedViewRefusal") {};
160
+ const notEmulated = (reason) => new NotEmulated({ reason });
161
+ const isNotEmulated = (value) => value instanceof NotEmulated;
162
+ const jsonResponse = (status, body, headers = {}) => {
163
+ const responseHeaders = new Headers(headers);
164
+ if (!responseHeaders.has("content-type")) responseHeaders.set("content-type", "application/json");
165
+ return new Response(JSON.stringify(body), {
166
+ status,
167
+ headers: responseHeaders
168
+ });
169
+ };
170
+ /** The one answer for everything no fixture records. */
171
+ const notEmulatedResponse = (reason) => jsonResponse(400, { error: {
172
+ type: "not_emulated",
173
+ message: `Not emulated: ${reason}`
174
+ } });
175
+ const emulatorError = (status, message, headers = {}) => jsonResponse(status, { error: {
176
+ message,
177
+ type: "emulator_error"
178
+ } }, headers);
179
+ const FaultStatus = Schema.Int.check(Schema.isBetween({
180
+ minimum: 400,
181
+ maximum: 599
182
+ }));
183
+ const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
184
+ const ChunkCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
185
+ /**
186
+ * Optional fault filter; an omitted field matches every request. `method` is the HTTP method
187
+ * (never a manifest row's `RPC`). `path` is the raw request path (in resolved mode, the
188
+ * resolution's ledger path); ending in `*`, a prefix. `route` is a manifest row path, compared
189
+ * with the request's ledger route: the template of its matched route, or the manifest variant its
190
+ * route admitted it as (see `StatefulRouteBinding.variants`).
191
+ * A `route` that names no manifest row of the emulator could never match, so adding the fault
192
+ * rejects it (a route with variants is matched by its variant rows, never its template).
193
+ */
194
+ const StatefulFaultMatch = Schema.Struct({
195
+ method: Schema.optionalKey(Schema.String),
196
+ path: Schema.optionalKey(Schema.String),
197
+ route: Schema.optionalKey(Schema.String)
198
+ });
199
+ /**
200
+ * A status fault: answer matching requests with this status (400-599), headers, and body before
201
+ * the route runs, so nothing is written. The body defaults to an emulator-fault body
202
+ * (`{ error: { type: 'emulator_fault', message } }`), never a guessed provider envelope. `count`
203
+ * limits how many requests it answers (omitted: all). `match.path` is the raw request path.
204
+ * Invalid header names or values, `location`, and framing headers are rejected.
205
+ */
206
+ const StatefulFault = Schema.Struct({
207
+ kind: Schema.Literal("status"),
208
+ status: FaultStatus,
209
+ headers: Schema.optionalKey(EmulatorHeaderRecord),
210
+ body: Schema.optionalKey(Schema.Json),
211
+ match: Schema.optionalKey(StatefulFaultMatch),
212
+ count: Schema.optionalKey(FaultCount)
213
+ });
214
+ /**
215
+ * Opt-in (`makeChunkedStatefulEmulator`): send the first `chunks` body chunks of the answer a
216
+ * streamed commit prepared (`StreamedCommit`), then close the body cleanly (a truncated answer).
217
+ * It is decided where a status fault is, after the plan, and a truncated request writes nothing:
218
+ * its `commit` never runs (no state, counter, or runtime change). One that cannot take effect (the
219
+ * answer is not streamed, or has no more than `chunks` chunks) answers the 500 emulator error and
220
+ * is not used up, never a silent no-op.
221
+ */
222
+ const StatefulTruncateFault = Schema.Struct({
223
+ kind: Schema.Literal("truncate-after-chunks"),
224
+ chunks: ChunkCount,
225
+ match: Schema.optionalKey(StatefulFaultMatch),
226
+ count: Schema.optionalKey(FaultCount)
227
+ });
228
+ /** A status fault, or (`makeChunkedStatefulEmulator`) a truncation fault. */
229
+ const StatefulStreamFault = Schema.Union([StatefulFault, StatefulTruncateFault]);
230
+ /** A streamed commit: the prepared `answer`, and the write (none by default) it commits. */
231
+ const streamedCommit = (answer, commit = () => void 0, persisted = []) => ({
232
+ answer,
233
+ commit,
234
+ persisted
235
+ });
236
+ /**
237
+ * A route of the table: its evidence (and recorded origin), the body it takes, its request-shape
238
+ * check `admit` (no state), and `plan`, which only ever sees an admitted input: it reads the state
239
+ * without writing and answers not emulated or the commit that writes.
240
+ */
241
+ const statefulRoute = (evidence, body, admit, plan) => ({
242
+ ...evidence,
243
+ body,
244
+ admit: (request, env) => {
245
+ const input = admit(request, env);
246
+ return isNotEmulated(input) ? input : { plan: (state, context) => plan(state, input, context) };
247
+ }
248
+ });
249
+ /** Route evidence without the handler parts. */
250
+ const routeEvidence = ({ body: _body, admit: _admit, origin: _origin, params: _params, decodedViews: _decodedViews, viewRefusalReasons: _viewRefusalReasons, variants: _variants, ...evidence }) => evidence;
251
+ /** The manifest rows of a route: its variants, or the route itself (`routeEvidence`). */
252
+ const routeManifest = (route) => route.variants ?? [routeEvidence(route)];
253
+ /** A percent-decoded path segment, or `undefined` for invalid percent-encoding. */
254
+ const decodeSegment = (segment) => {
255
+ try {
256
+ return decodeURIComponent(segment);
257
+ } catch {
258
+ return;
259
+ }
260
+ };
261
+ const templatePattern = (template) => new RegExp(`^${template.split(/(\{[A-Za-z]+\+?\})/).map((part) => /^\{[A-Za-z]+\}$/.test(part) ? "([^/]+)" : /^\{[A-Za-z]+\+\}$/.test(part) ? "(.+)" : part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("")}$`);
262
+ const templateNames = (template) => [...template.matchAll(/\{([A-Za-z]+)\+?\}/g)].map((match) => match[1] ?? "");
263
+ /**
264
+ * A multi-segment parameter decoded segment by segment (each once), or `undefined` for an empty
265
+ * segment, invalid percent-encoding, or a segment that decodes to a `/`.
266
+ */
267
+ const decodeSegments = (raw) => {
268
+ const segments = raw.split("/").map((segment) => {
269
+ const decoded = segment === "" ? void 0 : decodeSegment(segment);
270
+ return decoded === void 0 || decoded.includes("/") ? void 0 : decoded;
271
+ });
272
+ return segments.some((segment) => segment === void 0) ? void 0 : segments.join("/");
273
+ };
274
+ /**
275
+ * `pattern` required to match a whole raw parameter: `^(?:source)$` with its flags, so alternation
276
+ * and lazy quantifiers are tried against the whole value, never against a prefix of it.
277
+ */
278
+ const wholePattern = (pattern) => new RegExp(`^(?:${pattern.source})$`, pattern.flags);
279
+ /**
280
+ * A matcher over a route table: the route answering `method` + raw `path`, with its parameters
281
+ * decoded once, or `undefined` (also for a parameter that is not valid percent-encoding). A raw
282
+ * parameter pattern must match the whole raw parameter; a pattern with the `g` or `y` flag (whose
283
+ * matches depend on earlier ones) is refused when the matcher is built.
284
+ */
285
+ const routeMatcher = (routes) => {
286
+ const keys = routes.map((route) => emulatorRouteKey(route.method, route.path));
287
+ const duplicate = keys.find((key, index) => keys.indexOf(key) !== index);
288
+ if (duplicate !== void 0) throw new Error(`duplicate emulator route ${duplicate}`);
289
+ for (const route of routes) for (const [name, pattern] of Object.entries(route.params ?? {})) if (pattern.global || pattern.sticky) {
290
+ const where = `${route.method} ${route.path} parameter ${name}`;
291
+ throw new Error(`raw patterns take no g or y flag (${where})`);
292
+ }
293
+ const compiled = routes.map((route) => ({
294
+ route,
295
+ pattern: templatePattern(route.path),
296
+ names: templateNames(route.path),
297
+ whole: new Map(Object.entries(route.params ?? {}).map(([name, raw]) => [name, wholePattern(raw)])),
298
+ multi: new Set([...route.path.matchAll(/\{([A-Za-z]+)\+\}/g)].map((match) => match[1] ?? ""))
299
+ }));
300
+ return (method, path) => {
301
+ for (const candidate of compiled) {
302
+ if (candidate.route.method !== method.toUpperCase()) continue;
303
+ const match = candidate.pattern.exec(path);
304
+ if (match === null) continue;
305
+ const raws = candidate.names.map((name, index) => [name, match[index + 1] ?? ""]);
306
+ if (raws.some(([name, raw]) => {
307
+ const pattern = candidate.whole.get(name);
308
+ return pattern !== void 0 && !pattern.test(raw);
309
+ })) continue;
310
+ const params = {};
311
+ for (const [name, raw] of raws) {
312
+ const value = candidate.multi.has(name) ? decodeSegments(raw) : decodeSegment(raw);
313
+ if (value === void 0) return void 0;
314
+ params[name] = value;
315
+ }
316
+ return {
317
+ route: candidate.route,
318
+ params
319
+ };
320
+ }
321
+ };
322
+ };
323
+ /** The template parameters of a route that have no raw pattern (fail-closed emulators need one). */
324
+ const unpatternedParams = (route) => templateNames(route.path).filter((name) => !route.params?.[name]);
325
+ const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json));
326
+ /** Parsed JSON, or `undefined` for invalid JSON. */
327
+ const parseJsonText = (text) => {
328
+ const result = decodeJsonText(text);
329
+ return Result.isSuccess(result) ? result.success : void 0;
330
+ };
331
+ const isJsonObject = (value) => value !== void 0 && value !== null && Predicate.isObject(value) && !Array.isArray(value);
332
+ /**
333
+ * True when any object of a valid JSON text repeats a key, compared after JSON unescaping (so
334
+ * `"id"` and `"\u0069d"` are one key). `JSON.parse` would keep the last value silently.
335
+ */
336
+ const repeatsJsonKey = (text) => {
337
+ const open = [];
338
+ let index = 0;
339
+ while (index < text.length) {
340
+ const character = text[index];
341
+ if (character === "\"") {
342
+ let end = index + 1;
343
+ while (end < text.length && text[end] !== "\"") end += text[end] === "\\" ? 2 : 1;
344
+ const top = open.at(-1);
345
+ if (top?.keys !== void 0 && top.expectKey) {
346
+ const key = parseJsonText(text.slice(index, end + 1));
347
+ if (!Predicate.isString(key) || top.keys.has(key)) return true;
348
+ top.keys.add(key);
349
+ top.expectKey = false;
350
+ }
351
+ index = end + 1;
352
+ continue;
353
+ }
354
+ if (character === "{") open.push({
355
+ keys: /* @__PURE__ */ new Set(),
356
+ expectKey: true
357
+ });
358
+ else if (character === "[") open.push({
359
+ keys: void 0,
360
+ expectKey: false
361
+ });
362
+ else if (character === "}" || character === "]") open.pop();
363
+ else if (character === ",") {
364
+ const top = open.at(-1);
365
+ if (top?.keys !== void 0) top.expectKey = true;
366
+ }
367
+ index += 1;
368
+ }
369
+ return false;
370
+ };
371
+ /** The media type of a `content-type` value (lower-case, without parameters). */
372
+ const mediaType = (value) => (value ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
373
+ /**
374
+ * A JSON object with exactly the `required` keys plus any of the `optional` ones, or not
375
+ * emulated (naming the first missing or unknown key).
376
+ */
377
+ const exactObject = (value, label, required, optional = []) => {
378
+ if (!isJsonObject(value)) return notEmulated(`${label} must be a JSON object`);
379
+ const unknown = Object.keys(value).find((key) => !required.includes(key) && !optional.includes(key));
380
+ if (unknown !== void 0) return notEmulated(`${label} key '${unknown}' is not emulated`);
381
+ const missing = required.find((key) => !(key in value));
382
+ return missing === void 0 ? value : notEmulated(`${label} without '${missing}' is not emulated`);
383
+ };
384
+ /**
385
+ * Constant-text shape check (for fail-closed emulators): a JSON object with exactly the `required`
386
+ * keys plus any of the `optional` ones, or not emulated. Unlike `exactObject`, a reason never
387
+ * echoes a request's own key; it names only `label` and a missing key from the route's own list.
388
+ */
389
+ const exactBodyKeys = (value, label, required, optional = []) => {
390
+ if (!isJsonObject(value)) return notEmulated(`${label} must be a JSON object`);
391
+ const keys = Object.keys(value);
392
+ if (keys.some((key) => !required.includes(key) && !optional.includes(key))) return notEmulated(`${label} has a key this route does not take`);
393
+ const missing = required.find((key) => !keys.includes(key));
394
+ return missing === void 0 ? value : notEmulated(`${label} without '${missing}' is not emulated`);
395
+ };
396
+ /** The raw parameter names of a raw query, in order (`[]` for an empty query). */
397
+ const rawQueryNames = (raw) => raw === "" ? [] : raw.split("&").map((component) => component.split("=", 1)[0] ?? "");
398
+ /**
399
+ * Constant-text query check (for fail-closed emulators): exactly the `required` query keys plus
400
+ * any of the `optional` ones, each once, as a record of their values; or not emulated. A reason
401
+ * never echoes a request's own key; it names only a missing key from the route's own list. With
402
+ * `rawNames`, every raw parameter name must also be the plain name it decodes to.
403
+ */
404
+ const exactQuery = (request, required, optional = [], options = {}) => {
405
+ const keys = [...request.query.keys()];
406
+ if (keys.length !== new Set(keys).size) return notEmulated("repeated query parameters are not emulated");
407
+ if (keys.some((key) => !required.includes(key) && !optional.includes(key))) return notEmulated("a query parameter this route does not take is not emulated");
408
+ if (options.rawNames === true) {
409
+ const raw = rawQueryNames(request.rawQuery ?? "");
410
+ if (raw.length !== keys.length || raw.some((name, index) => name !== keys[index])) return notEmulated("a query parameter name in any but its plain form is not emulated");
411
+ }
412
+ const missing = required.find((key) => !keys.includes(key));
413
+ return missing === void 0 ? Object.fromEntries(request.query) : notEmulated(`requests without query parameter ${missing} are not emulated on this route`);
414
+ };
415
+ /** An integer in `[minimum, maximum]`, or not emulated. */
416
+ const integerIn = (value, label, minimum, maximum) => Predicate.isNumber(value) && Number.isSafeInteger(value) && value >= minimum && value <= maximum ? value : notEmulated(`${label} must be an integer from ${minimum} to ${maximum}`);
417
+ const defaultErrorTexts = {
418
+ failed: "the emulator could not build the response",
419
+ closed: "the emulator is closed",
420
+ unhandled: "the emulator failed to handle the request"
421
+ };
422
+ const strict = { onExcessProperty: "error" };
423
+ const decodeFault = Schema.decodeUnknownResult(StatefulFault, strict);
424
+ const decodeFaultList = Schema.decodeUnknownResult(Schema.Union([StatefulFault, Schema.Struct({ faults: Schema.Array(StatefulFault) })]), strict);
425
+ const decodeStreamFault = Schema.decodeUnknownResult(StatefulStreamFault, strict);
426
+ const decodeStreamFaultList = Schema.decodeUnknownResult(Schema.Union([StatefulStreamFault, Schema.Struct({ faults: Schema.Array(StatefulStreamFault) })]), strict);
427
+ const issueMessage = (issue) => new Schema.SchemaError(issue).message;
428
+ const bearerPattern = /^bearer\s+\S+/i;
429
+ /**
430
+ * Fail-closed mode: exactly `Bearer `, one space, and one token of at least 8 non-space characters
431
+ * (no other scheme spelling, no extra words, so combined duplicate headers never match). The token
432
+ * must also match the RFC 6750 `b64token` syntax, start with a character that completes no escape
433
+ * (not a hex digit, not `n`, `r`, `t`, `u`), and hold a character outside the JSON-number alphabet
434
+ * (`isRecognisableBearerValue`, which says why). The value is guarded, never compared against
435
+ * anything, stored, or ledgered.
436
+ */
437
+ const recognisableBearerPattern = /^Bearer ([^\s]{8,})$/;
438
+ /** The fail-closed bearer of an `Authorization` header, or `undefined` when unrecognisable. */
439
+ const recognisableBearer = (authorization) => {
440
+ const bearer = recognisableBearerPattern.exec(authorization ?? "")?.[1];
441
+ return bearer !== void 0 && isRecognisableBearerValue(bearer) ? bearer : void 0;
442
+ };
443
+ /** The raw query of a request URL (after `?`, before `#`), or `undefined` when it has none. */
444
+ const rawQuery = (requestUrl) => {
445
+ const withoutFragment = requestUrl.split("#", 1)[0] ?? "";
446
+ const start = withoutFragment.indexOf("?");
447
+ return start === -1 ? void 0 : withoutFragment.slice(start + 1);
448
+ };
449
+ /** True for a bare `?` or an empty `&`-separated component (`URLSearchParams` drops both). */
450
+ const hasEmptyQueryComponent = (requestUrl) => {
451
+ const query = rawQuery(requestUrl);
452
+ return query !== void 0 && query.split("&").some((component) => component === "");
453
+ };
454
+ /**
455
+ * Fail-closed mode: a query key or value or a header value that starts like a JSON object, array,
456
+ * or string (`{`, `[`, `"`) is recorded parsed with credential-named keys redacted at any depth, or
457
+ * whole as `<redacted>` when it does not parse; any other text (numbers included) is recorded
458
+ * unchanged. A request whose text repeats a guarded secret never gets here: it is ledgered with
459
+ * constant text only.
460
+ */
461
+ const recordedJsonLooking = (text) => {
462
+ const trimmed = text.trimStart();
463
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith("\"")) return text;
464
+ const parsed = parseJsonText(text);
465
+ return parsed === void 0 ? redactedCredentialValue : JSON.stringify(redactCredentialFields(parsed));
466
+ };
467
+ /**
468
+ * Fail-closed mode: the recorded query, built from every original pair (never one value per key)
469
+ * and keyed by recorded key: credential-named keys have their values redacted, keys and values are
470
+ * recorded as `recordedJsonLooking` does, and a key recorded more than once (two original keys may
471
+ * record alike, for example two unparseable JSON-looking keys as `<redacted>`) lists its values in
472
+ * order, as a JSON array.
473
+ */
474
+ const recordedQueryPairs = (query, scrub) => {
475
+ const values = /* @__PURE__ */ new Map();
476
+ for (const [key, value] of query) {
477
+ const recordedKey = scrub(recordedJsonLooking(key));
478
+ const recordedValue = scrub(isCredentialQueryKey(key) ? redactedCredentialValue : recordedJsonLooking(value));
479
+ values.set(recordedKey, [...values.get(recordedKey) ?? [], recordedValue]);
480
+ }
481
+ return Object.fromEntries([...values].map(([key, list]) => [key, list.length === 1 ? list[0] ?? "" : JSON.stringify(list)]));
482
+ };
483
+ /** The constant reason of a body a route's decoded view refuses to check (the view threw). */
484
+ const uncheckableBodyReason = "the request body cannot be checked for the credential";
485
+ const missingBearerReason = "requests without Authorization: Bearer <token of at least 8 characters> are not emulated";
486
+ const pathMatches = (pattern, path) => pattern.endsWith("*") ? path.startsWith(pattern.slice(0, -1)) : pattern === path;
487
+ const faultMatches = (fault, method, path, route) => (fault.match?.method === void 0 || fault.match.method.toUpperCase() === method) && (fault.match?.path === void 0 || pathMatches(fault.match.path, path)) && (fault.match?.route === void 0 || fault.match.route === route);
488
+ const faultBody = (status) => ({ error: {
489
+ type: "emulator_fault",
490
+ message: `Emulator fault: status ${status}.`
491
+ } });
492
+ const readText = (request) => request.text().then((text) => text, () => void 0);
493
+ const readBytes = (request) => request.arrayBuffer().then((buffer) => new Uint8Array(buffer), () => void 0);
494
+ const isControlPath = (path) => path === "/_emulate" || path.startsWith("/_emulate/");
495
+ const withEvidence = (response, evidence) => {
496
+ const headers = new Headers(response.headers);
497
+ if (evidence === "unverified") headers.set(emulatorEvidenceHeader, "unverified");
498
+ return new Response(response.body, {
499
+ status: response.status,
500
+ statusText: response.statusText,
501
+ headers
502
+ });
503
+ };
504
+ /**
505
+ * The recorded query: credential-named keys redacted, and any value that parses as a JSON object or
506
+ * array (Dropbox's browser-style `arg` parameter, for example) recorded with its credential-named
507
+ * keys redacted at any depth, and a JSON-looking value that does not parse recorded as `<redacted>`,
508
+ * as a recorded JSON header is.
509
+ */
510
+ const recordedQuery = (query) => Object.fromEntries(Object.entries(redactCredentialQuery(query)).map(([key, value]) => {
511
+ if (!value.trimStart().startsWith("{") && !value.trimStart().startsWith("[")) return [key, value];
512
+ const parsed = parseJsonText(value);
513
+ return [key, parsed === void 0 ? redactedCredentialValue : JSON.stringify(redactCredentialFields(parsed))];
514
+ }));
515
+ /** A recorded header value: JSON with credential-named keys redacted, or the plain value. */
516
+ const recordedHeaderValue = (header, value) => {
517
+ if (!header.json) return value;
518
+ const parsed = parseJsonText(value);
519
+ return parsed === void 0 ? redactedCredentialValue : JSON.stringify(redactCredentialFields(parsed));
520
+ };
521
+ /** `constantRefusals`: a refused request's entry keeps only constant text. */
522
+ const blankEntry = (entry) => {
523
+ entry.method = unrecognisedMethod(entry.method.toUpperCase());
524
+ entry.path = unrecognisedLedgerPath;
525
+ entry.query = {};
526
+ entry.headers = {};
527
+ delete entry.body;
528
+ delete entry.bodyBytes;
529
+ };
530
+ const snapshotEntry = (entry) => ({
531
+ ...entry,
532
+ query: { ...entry.query },
533
+ headers: { ...entry.headers }
534
+ });
535
+ /** Whether an entry records no truncation fault (only a status fault, if any). */
536
+ const hasStatusFaultOnly = (entry) => entry.fault !== "truncate-after-chunks";
537
+ /** `makeHeaderlessStatefulEmulator`: the entry without its (always empty) `headers` field. */
538
+ const snapshotHeaderlessEntry = ({ headers: _headers, bodyBytes: _bodyBytes, ...entry }) => {
539
+ const copy = {
540
+ ...entry,
541
+ query: { ...entry.query }
542
+ };
543
+ if (hasStatusFaultOnly(copy)) return copy;
544
+ throw new Error("a headerless stateful emulator records status faults only");
545
+ };
546
+ /**
547
+ * Resolved mode: the constant reason when the raw query or the resolution's `guardedPath` repeats a
548
+ * guarded secret (`repeatsSecret`: raw, and percent-decoded once), or `undefined`. Checked before
549
+ * the body is read, so such a refusal leaves the request body unread.
550
+ */
551
+ const partsCredentialRepeat = (url, guardedPath, secrets) => {
552
+ if (repeatsSecret(url.search, secrets)) return "the query repeats the credential";
553
+ return repeatsSecret(guardedPath, secrets) ? "the request path repeats the credential" : void 0;
554
+ };
555
+ /**
556
+ * Resolved mode: the constant reason when the body text repeats a guarded secret, or `undefined`:
557
+ * `repeatsSecret` on the raw text, and for a body that parses as JSON also `jsonRepeatsSecret`
558
+ * (every key, string, and number) and as it would be recorded. A body that cannot be read or parsed
559
+ * is left to the route's body check.
560
+ */
561
+ const bodyCredentialRepeat = (text, secrets) => {
562
+ if (text === void 0 || text === "") return void 0;
563
+ const repeat = "the request body repeats the credential";
564
+ if (repeatsSecret(text, secrets)) return repeat;
565
+ const json = parseJsonText(text);
566
+ return json !== void 0 && (jsonRepeatsSecret(json, secrets) || repeatsSecret(JSON.stringify(json), secrets)) ? repeat : void 0;
567
+ };
568
+ /** The body as `Request.text()` reads it: UTF-8, a leading byte order mark dropped. */
569
+ const bodyText = (bytes) => new TextDecoder().decode(bytes);
570
+ const snapshotFault = (state) => ({ ...state });
571
+ const isStreamedCommit = (planned) => !Predicate.isFunction(planned);
572
+ /** The constant reason of a prepared output that would repeat the bearer (`guardOutput`). */
573
+ const outputRepeatReason = "the answer would repeat the credential";
574
+ /**
575
+ * `guardOutput`: whether a streamed commit's answer (every header name and value, every chunk, and
576
+ * the whole body) or any text it would persist repeats a guarded secret, through the closure.
577
+ */
578
+ const outputRepeatsSecret = (planned, secrets) => {
579
+ const { answer } = planned;
580
+ return [
581
+ ...Object.entries(answer.headers).flat(),
582
+ ...answer.chunks,
583
+ answer.chunks.join(""),
584
+ ...planned.persisted
585
+ ].some((text) => textRepeatsSecret(text, secrets));
586
+ };
587
+ /**
588
+ * A streamed answer's body, strictly pull-driven (one chunk per pull), closed cleanly after
589
+ * `after` chunks when a truncation fault applies; no body at all for an answer without chunks.
590
+ */
591
+ const streamedResponse = (answer, after) => {
592
+ if (answer.chunks.length === 0) return new Response(null, {
593
+ status: answer.status,
594
+ headers: answer.headers
595
+ });
596
+ const encoder = new TextEncoder();
597
+ const sent = after === void 0 ? answer.chunks : answer.chunks.slice(0, after);
598
+ let next = 0;
599
+ const body = new ReadableStream({ pull: (controller) => {
600
+ const chunk = sent[next];
601
+ if (chunk === void 0) {
602
+ controller.close();
603
+ return;
604
+ }
605
+ next += 1;
606
+ controller.enqueue(encoder.encode(chunk));
607
+ } }, { highWaterMark: 0 });
608
+ return new Response(body, {
609
+ status: answer.status,
610
+ headers: answer.headers
611
+ });
612
+ };
613
+ /**
614
+ * Build the wrapper over a core runtime (`makeStatefulEmulator`, with truncation faults
615
+ * `makeChunkedStatefulEmulator`, or without ledgered headers `makeHeaderlessStatefulEmulator`).
616
+ * `createCore` receives the dispatch the core must run for every request (its only route) and
617
+ * returns the runtime adapter; `snapshotLedgerEntry` copies a ledger entry for readers.
618
+ */
619
+ const buildStatefulEmulator = async (config, createCore, chunkFaults, snapshotLedgerEntry) => {
620
+ const credentialHeader = config.recordHeaders.find((header) => isCredentialHeaderName(header.name));
621
+ if (credentialHeader !== void 0) throw new Error(`the ledger never records the credential header ${credentialHeader.name}`);
622
+ if (config.resolveRequest !== void 0 && config.failClosed !== void 0) throw new Error("resolveRequest (resolved mode) and failClosed exclude each other");
623
+ if (config.resolveRequest !== void 0 && config.recordHeaders.length > 0) throw new Error("resolveRequest (resolved mode) records no request header (recordHeaders)");
624
+ const resolveRequest = config.resolveRequest;
625
+ const errorTexts = config.errorTexts ?? defaultErrorTexts;
626
+ if (config.bearerDigest !== void 0 && config.failClosed === void 0) throw new Error("bearerDigest needs fail-closed mode (failClosed)");
627
+ for (const option of [
628
+ "constantRefusals",
629
+ "guardAllHeaders",
630
+ "guardOutput"
631
+ ]) if (config[option] === true && config.failClosed === void 0) throw new Error(`${option} needs fail-closed mode (failClosed)`);
632
+ const constantRefusals = config.constantRefusals === true;
633
+ const templates = new Set(config.routes.map((route) => route.path));
634
+ const variantRows = /* @__PURE__ */ new Set();
635
+ for (const route of config.routes) {
636
+ if (route.variants === void 0) continue;
637
+ if (route.variants.length === 0) throw new Error(`route ${route.method} ${route.path} has an empty variants list`);
638
+ for (const variant of route.variants) {
639
+ const where = `variant ${variant.method} ${variant.path} of ${route.method} ${route.path}`;
640
+ if (variant.evidence !== route.evidence) throw new Error(`${where} must carry its route's evidence`);
641
+ if (templates.has(variant.path) || variantRows.has(variant.path)) throw new Error(`${where} repeats a route template or another variant path`);
642
+ variantRows.add(variant.path);
643
+ }
644
+ }
645
+ if (config.failClosed !== void 0) for (const route of config.routes) {
646
+ const missing = unpatternedParams(route);
647
+ if (missing.length > 0) {
648
+ const where = `fail-closed route ${route.method} ${route.path}`;
649
+ throw new Error(`${where} needs raw patterns for ${missing.join(", ")}`);
650
+ }
651
+ }
652
+ const match = routeMatcher(config.routes);
653
+ const manifest = config.routes.flatMap(routeManifest);
654
+ const manifestPaths = new Set(manifest.map((row) => row.path));
655
+ const jobs = /* @__PURE__ */ new Map();
656
+ let nextJobId = 1;
657
+ const dispatch = (state, request) => {
658
+ const job = jobs.get(Number(request.headers.get("x-emulator-job-id") ?? "NaN"));
659
+ if (job === void 0) return handlerFailedResponse();
660
+ job.dispatched = true;
661
+ try {
662
+ const planned = job.admission.plan(state, {
663
+ env: config.env,
664
+ seq: job.seq
665
+ });
666
+ if (isNotEmulated(planned)) {
667
+ if (resolveRequest !== void 0 && Result.isFailure(Result.try(() => encodeURIComponent(planned.reason)))) return handlerFailedResponse();
668
+ const reason = scrubSecrets(planned.reason, job.secrets);
669
+ job.notEmulated = reason;
670
+ return resolveRequest === void 0 ? notEmulatedResponse(reason) : answeredOutsideCore();
671
+ }
672
+ const streamed = isStreamedCommit(planned);
673
+ if (config.guardOutput === true) {
674
+ if (!streamed) return handlerFailedResponse();
675
+ if (outputRepeatsSecret(planned, job.secrets)) {
676
+ job.notEmulated = outputRepeatReason;
677
+ job.credentialRepeat = true;
678
+ return notEmulatedResponse(outputRepeatReason);
679
+ }
680
+ }
681
+ const decision = job.decideFault(streamed ? planned.answer.chunks.length : void 0);
682
+ if (decision.kind === "answer") {
683
+ if (resolveRequest === void 0) return decision.response;
684
+ job.faultAnswer = decision.response;
685
+ return answeredOutsideCore();
686
+ }
687
+ if (!streamed) return planned();
688
+ if (decision.kind === "truncate") return streamedResponse(planned.answer, decision.after);
689
+ planned.commit();
690
+ return streamedResponse(planned.answer, void 0);
691
+ } catch {
692
+ return handlerFailedResponse();
693
+ }
694
+ };
695
+ const core = await createCore(dispatch);
696
+ let baseline = config.initial;
697
+ let entries = [];
698
+ let faultStates = [];
699
+ let nextSeq = 1;
700
+ let nextFaultId = 1;
701
+ let closed = false;
702
+ const clearLedger = () => {
703
+ entries = [];
704
+ nextSeq = 1;
705
+ };
706
+ /** Why a fault's `match.route` could never match (it names no manifest row), or `undefined`. */
707
+ const matchRouteProblem = (fault) => {
708
+ const route = fault.match?.route;
709
+ return route === void 0 || manifestPaths.has(route) ? void 0 : "match.route must name a manifest row of this emulator";
710
+ };
711
+ const addFault = (input) => {
712
+ const decoded = chunkFaults ? decodeStreamFault(input) : decodeFault(input);
713
+ if (Result.isFailure(decoded)) return issueMessage(decoded.failure.issue);
714
+ const problem = matchRouteProblem(decoded.success);
715
+ if (problem !== void 0) return problem;
716
+ const state = {
717
+ id: nextFaultId++,
718
+ fault: decoded.success,
719
+ remaining: decoded.success.count,
720
+ applied: 0
721
+ };
722
+ faultStates.push(state);
723
+ return snapshotFault(state);
724
+ };
725
+ const takeFault = (method, path, route) => faultStates.find((state) => (state.remaining === void 0 || state.remaining > 0) && faultMatches(state.fault, method, path, route));
726
+ const spend = (state) => {
727
+ state.applied += 1;
728
+ if (state.remaining !== void 0) state.remaining -= 1;
729
+ };
730
+ /** Build the fault's response first: a response that cannot be built must not consume it. */
731
+ const applyFault = (state, fault) => {
732
+ const headers = new Headers(fault.headers ?? {});
733
+ const body = fault.body ?? faultBody(fault.status);
734
+ if (!headers.has("content-type")) headers.set("content-type", Predicate.isString(body) ? "text/plain" : "application/json");
735
+ const response = new Response(Predicate.isString(body) ? body : JSON.stringify(body), {
736
+ status: fault.status,
737
+ headers
738
+ });
739
+ spend(state);
740
+ return response;
741
+ };
742
+ const reset = async () => {
743
+ clearLedger();
744
+ faultStates = [];
745
+ config.clearRuntime();
746
+ await core.restore(baseline);
747
+ };
748
+ const reseed = async (input) => {
749
+ const next = config.buildSeed(input);
750
+ if (Predicate.isString(next)) return next;
751
+ await core.restore(next);
752
+ config.clearRuntime();
753
+ baseline = next;
754
+ return next;
755
+ };
756
+ const coverage = () => ({
757
+ routes: manifest.map((route) => ({
758
+ ...route,
759
+ requests: entries.filter((entry) => entry.route === route.path && (variantRows.has(route.path) || entry.method.toUpperCase() === route.method)).length
760
+ })),
761
+ unknownRouteRequests: entries.filter((entry) => entry.evidence === "unknown-route").length,
762
+ notEmulatedRequests: entries.filter((entry) => entry.notEmulated !== void 0).length
763
+ });
764
+ /** The ledgered 400; the reason is scrubbed of the request's guarded secrets first. */
765
+ const refuse = (entry, reason, secrets = []) => {
766
+ const safe = scrubSecrets(reason, secrets);
767
+ entry.notEmulated = safe;
768
+ return notEmulatedResponse(safe);
769
+ };
770
+ /**
771
+ * Fail-closed mode: the constant reason when any part of a recognised request repeats a guarded
772
+ * secret, or `undefined`. Checked, each through the fixpoint closure of `textRepeatsSecret` (a
773
+ * capped closure counts as a repeat): the raw path and every path segment, the raw query and
774
+ * every decoded query key and value, every recorded header, and the body.
775
+ */
776
+ const credentialRepeat = (request, url, body, secrets, binding) => {
777
+ if (textRepeatsSecret(url.pathname, secrets) || url.pathname.split("/").some((segment) => textRepeatsSecret(segment, secrets))) return "the request path repeats the credential";
778
+ if (textRepeatsSecret(url.search, secrets) || [...url.searchParams].some(([key, value]) => textRepeatsSecret(key, secrets) || textRepeatsSecret(value, secrets))) return "the query repeats the credential";
779
+ if (config.recordHeaders.some((recorded) => textRepeatsSecret(request.headers.get(recorded.name) ?? "", secrets))) return "a recorded request header repeats the credential";
780
+ if (config.guardAllHeaders === true) {
781
+ const headers = [];
782
+ request.headers.forEach((value, name) => {
783
+ if (name !== "authorization") headers.push(name, value);
784
+ });
785
+ if (headers.some((text) => textRepeatsSecret(text, secrets))) return "a request header repeats the credential";
786
+ }
787
+ if (body === void 0) return void 0;
788
+ const repeat = "the request body repeats the credential";
789
+ if (textRepeatsSecret(body, secrets)) return repeat;
790
+ const { decodedViews, viewRefusalReasons = [] } = binding;
791
+ const views = Result.try(() => decodedViews === void 0 ? [] : decodedViews(body));
792
+ if (Result.isFailure(views)) {
793
+ const refusal = views.failure;
794
+ if (!(refusal instanceof DecodedViewRefusal)) return uncheckableBodyReason;
795
+ if (refusal.decoded.some((view) => textRepeatsSecret(view, secrets))) return repeat;
796
+ return viewRefusalReasons.includes(refusal.reason) ? scrubSecrets(refusal.reason, secrets) : uncheckableBodyReason;
797
+ }
798
+ return views.success.some((view) => textRepeatsSecret(view, secrets)) ? repeat : void 0;
799
+ };
800
+ /** Everything after the route match: credential, headers, body, shape, then the core. */
801
+ const routed = async (request, url, entry, matched, secrets, arrivedOn, recorded, guardedPath) => {
802
+ const header = (name) => isCredentialHeaderName(name) || guardedPath !== void 0 && name.toLowerCase() !== "content-type" ? void 0 : request.headers.get(name) ?? void 0;
803
+ const refused = (reason) => refuse(entry, reason, secrets);
804
+ let resolvedBody;
805
+ if (guardedPath !== void 0) {
806
+ const partsRepeat = partsCredentialRepeat(url, guardedPath, secrets);
807
+ if (partsRepeat !== void 0) return refused(partsRepeat);
808
+ resolvedBody = { bytes: await readBytes(request) };
809
+ const bodyRepeat = bodyCredentialRepeat(resolvedBody.bytes === void 0 ? void 0 : bodyText(resolvedBody.bytes), secrets);
810
+ if (bodyRepeat !== void 0) return refused(bodyRepeat);
811
+ } else if (config.failClosed === void 0) {
812
+ if (!bearerPattern.test(request.headers.get("authorization") ?? "")) return refused("a non-empty Authorization: Bearer credential is required");
813
+ } else {
814
+ if (secrets.length === 0) return refused(missingBearerReason);
815
+ if (hasEmptyQueryComponent(request.url)) return refused("empty query components (a bare ? or a stray &) are not emulated");
816
+ }
817
+ const problem = config.requestProblem?.(header);
818
+ if (problem !== void 0) return refused(problem);
819
+ const resolvedText = () => resolvedBody?.bytes === void 0 ? void 0 : bodyText(resolvedBody.bytes);
820
+ let json;
821
+ let bytes;
822
+ switch (matched.route.body) {
823
+ case "none": {
824
+ const text = resolvedBody === void 0 ? await readText(request) : resolvedText();
825
+ if (text === void 0 || text !== "") return refused("this route takes no request body");
826
+ break;
827
+ }
828
+ case "json": {
829
+ const text = resolvedBody === void 0 ? await readText(request) : resolvedText();
830
+ if (mediaType(header("content-type")) !== "application/json") return refused("this route takes a content-type: application/json body");
831
+ const parsed = text === void 0 ? void 0 : parseJsonText(text);
832
+ if (parsed === void 0) return refused("the request body is not valid JSON");
833
+ if (config.uniqueJsonKeys === true && text !== void 0 && repeatsJsonKey(text)) return refused("a JSON body with a repeated key is not emulated");
834
+ recorded.body = redactCredentialFields(parsed);
835
+ if (!constantRefusals) entry.body = recorded.body;
836
+ json = parsed;
837
+ break;
838
+ }
839
+ case "json-or-empty": {
840
+ const text = resolvedBody === void 0 ? await readText(request) : resolvedText();
841
+ if (text === void 0) return refused("the request body is unreadable");
842
+ if (text === "") break;
843
+ const parsed = parseJsonText(text);
844
+ if (parsed === void 0) return refused("the request body is not JSON");
845
+ if (config.uniqueJsonKeys === true && repeatsJsonKey(text)) return refused("a JSON body with a repeated key is not emulated");
846
+ recorded.body = redactCredentialFields(parsed);
847
+ if (!constantRefusals) entry.body = recorded.body;
848
+ json = parsed;
849
+ break;
850
+ }
851
+ case "bytes":
852
+ bytes = resolvedBody === void 0 ? await readBytes(request) : resolvedBody.bytes;
853
+ if (bytes === void 0) return refused("the request body is unreadable");
854
+ recorded.bodyBytes = bytes.byteLength;
855
+ if (!constantRefusals) entry.bodyBytes = recorded.bodyBytes;
856
+ break;
857
+ }
858
+ let bearerDigest;
859
+ const [bearer] = secrets;
860
+ if (config.bearerDigest !== void 0 && bearer !== void 0) {
861
+ const digest = Result.try(() => config.bearerDigest?.(bearer, arrivedOn));
862
+ if (Result.isFailure(digest) || !Predicate.isString(digest.success) || textRepeatsSecret(digest.success, secrets)) {
863
+ entry.responseError = "the bearer digest failed";
864
+ return emulatorError(500, errorTexts.failed);
865
+ }
866
+ bearerDigest = digest.success;
867
+ }
868
+ const admitted = matched.route.admit({
869
+ method: request.method.toUpperCase(),
870
+ path: guardedPath === void 0 ? url.pathname : entry.path,
871
+ params: matched.params,
872
+ query: url.searchParams,
873
+ rawQuery: rawQuery(request.url) ?? "",
874
+ header,
875
+ headerNames: guardedPath === void 0 ? [...request.headers.keys()] : [],
876
+ json,
877
+ bytes,
878
+ bearerDigest
879
+ }, config.env);
880
+ if (isNotEmulated(admitted)) return refused(admitted.reason);
881
+ const variants = matched.route.variants;
882
+ const variant = admitted.variant;
883
+ if (variants === void 0 ? variant !== void 0 : !variants.some((row) => row.path === variant)) {
884
+ entry.responseError = "the route admitted the request as no row of its manifest";
885
+ return emulatorError(500, errorTexts.failed);
886
+ }
887
+ if (variant !== void 0) entry.route = variant;
888
+ if (constantRefusals) {
889
+ entry.method = recorded.method;
890
+ entry.path = recorded.path;
891
+ entry.query = recorded.query;
892
+ entry.headers = recorded.headers;
893
+ if (recorded.body !== void 0) entry.body = recorded.body;
894
+ if (recorded.bodyBytes !== void 0) entry.bodyBytes = recorded.bodyBytes;
895
+ }
896
+ const method = request.method.toUpperCase();
897
+ const jobId = nextJobId++;
898
+ const requestPath = guardedPath === void 0 ? url.pathname : entry.path;
899
+ const job = {
900
+ admission: admitted,
901
+ seq: entry.seq,
902
+ secrets,
903
+ decideFault: (chunks) => {
904
+ const state = takeFault(method, requestPath, entry.route);
905
+ if (state === void 0) return { kind: "none" };
906
+ const fault = state.fault;
907
+ if (fault.kind === "status") {
908
+ const response = applyFault(state, fault);
909
+ entry.fault = "status";
910
+ return {
911
+ kind: "answer",
912
+ response
913
+ };
914
+ }
915
+ if (chunks === void 0 || fault.chunks >= chunks) {
916
+ entry.responseError = `emulator fault cannot apply: truncate-after-chunks after ${fault.chunks} chunk(s) needs a streamed answer of more chunks`;
917
+ return {
918
+ kind: "answer",
919
+ response: emulatorError(500, "emulator fault cannot apply")
920
+ };
921
+ }
922
+ spend(state);
923
+ entry.fault = "truncate-after-chunks";
924
+ return {
925
+ kind: "truncate",
926
+ after: fault.chunks
927
+ };
928
+ }
929
+ };
930
+ jobs.set(jobId, job);
931
+ try {
932
+ const response = await core.fetch(new Request(new URL(requestPath, core.baseUrl), {
933
+ method: "POST",
934
+ headers: { [emulatorJobHeader]: String(jobId) }
935
+ }));
936
+ if (response.headers.has("x-emulator-handler-failed")) {
937
+ entry.responseError = "the route handler failed";
938
+ return emulatorError(500, errorTexts.failed);
939
+ }
940
+ if (guardedPath !== void 0 && job.dispatched !== true) {
941
+ entry.responseError = "the route handler answered no eligibility verdict";
942
+ return emulatorError(500, errorTexts.failed);
943
+ }
944
+ if (job.notEmulated !== void 0) {
945
+ entry.notEmulated = job.notEmulated;
946
+ if (constantRefusals || job.credentialRepeat === true) blankEntry(entry);
947
+ if (job.credentialRepeat === true) entry.route = matched.route.path;
948
+ if (guardedPath !== void 0) return notEmulatedResponse(job.notEmulated);
949
+ }
950
+ return job.faultAnswer ?? response;
951
+ } finally {
952
+ jobs.delete(jobId);
953
+ }
954
+ };
955
+ /**
956
+ * Fail closed: an unrecognised request is ledgered and answered with constant text only. `method`
957
+ * is the request method upper-cased (fail-closed mode) or as sent (resolved mode).
958
+ */
959
+ const unrecognised = (method, reason) => {
960
+ entries.push({
961
+ seq: nextSeq++,
962
+ method: unrecognisedMethod(method),
963
+ path: unrecognisedLedgerPath,
964
+ query: {},
965
+ headers: {},
966
+ status: 400,
967
+ evidence: "unknown-route",
968
+ notEmulated: reason
969
+ });
970
+ return notEmulatedResponse(reason);
971
+ };
972
+ const emulatedApi = async (request, url, arrivedOn) => {
973
+ const resolution = resolveRequest?.(request, url);
974
+ if (resolution?.kind === "unrecognised") return unrecognised(request.method, resolution.reason);
975
+ const matched = resolution === void 0 ? match(request.method, url.pathname) : {
976
+ route: resolution.route,
977
+ params: resolution.kind === "route" ? resolution.params : {}
978
+ };
979
+ const failClosed = config.failClosed;
980
+ let secrets = resolution?.secrets ?? [];
981
+ if (failClosed !== void 0) {
982
+ const authorization = request.headers.get("authorization");
983
+ const bearer = recognisableBearer(authorization);
984
+ if (authorization !== null && bearer === void 0) return unrecognised(request.method.toUpperCase(), failClosed.unrecognisedAuthorization);
985
+ if (matched === void 0) return unrecognised(request.method.toUpperCase(), failClosed.unrecognised);
986
+ secrets = bearer === void 0 ? [] : [bearer];
987
+ if (secrets.length > 0) {
988
+ const reason = credentialRepeat(request, url, request.body === null ? void 0 : await readText(request.clone()), secrets, matched.route);
989
+ if (reason !== void 0) {
990
+ entries.push({
991
+ seq: nextSeq++,
992
+ method: unrecognisedMethod(request.method.toUpperCase()),
993
+ path: unrecognisedLedgerPath,
994
+ route: matched.route.path,
995
+ query: {},
996
+ headers: {},
997
+ status: 400,
998
+ evidence: matched.route.evidence,
999
+ notEmulated: reason
1000
+ });
1001
+ return withEvidence(notEmulatedResponse(reason), matched.route.evidence);
1002
+ }
1003
+ }
1004
+ }
1005
+ const scrub = (text) => scrubSecrets(text, secrets);
1006
+ const query = failClosed === void 0 ? Object.fromEntries(Object.entries(resolution === void 0 ? recordedQuery(url.searchParams) : redactCredentialQuery(url.searchParams)).map(([key, value]) => [scrub(key), scrub(value)])) : recordedQueryPairs(url.searchParams, scrub);
1007
+ const recorded = {
1008
+ method: scrub(request.method),
1009
+ path: scrub(resolution?.ledgerPath ?? url.pathname),
1010
+ query,
1011
+ headers: {}
1012
+ };
1013
+ for (const header of config.recordHeaders) {
1014
+ const value = request.headers.get(header.name);
1015
+ if (value === null) continue;
1016
+ const text = recordedHeaderValue(header, value);
1017
+ recorded.headers[header.name] = scrub(failClosed === void 0 ? text : recordedJsonLooking(text));
1018
+ }
1019
+ const evidence = matched?.route.evidence ?? "unknown-route";
1020
+ const entry = constantRefusals ? {
1021
+ seq: nextSeq++,
1022
+ method: unrecognisedMethod(request.method.toUpperCase()),
1023
+ path: unrecognisedLedgerPath,
1024
+ query: {},
1025
+ headers: {},
1026
+ status: 0,
1027
+ evidence
1028
+ } : resolution === void 0 ? {
1029
+ seq: nextSeq++,
1030
+ method: recorded.method,
1031
+ path: recorded.path,
1032
+ query: recorded.query,
1033
+ headers: { ...recorded.headers },
1034
+ status: 0,
1035
+ evidence
1036
+ } : {
1037
+ seq: nextSeq++,
1038
+ method: recorded.method,
1039
+ path: recorded.path,
1040
+ route: resolution.route.path,
1041
+ query: recorded.query,
1042
+ headers: { ...recorded.headers },
1043
+ status: 0,
1044
+ evidence
1045
+ };
1046
+ entries.push(entry);
1047
+ if (matched === void 0) {
1048
+ entry.status = 400;
1049
+ return refuse(entry, "no emulated route for this method and path");
1050
+ }
1051
+ entry.route = matched.route.path;
1052
+ if (matched.route.origin !== void 0 && matched.route.origin !== arrivedOn) {
1053
+ entry.status = 400;
1054
+ return withEvidence(refuse(entry, `this route is recorded on ${matched.route.origin} only`, secrets), matched.route.evidence);
1055
+ }
1056
+ if (resolution?.kind === "refused") {
1057
+ const tagged = withEvidence(refuse(entry, resolution.reason, secrets), resolution.route.evidence);
1058
+ entry.status = tagged.status;
1059
+ return tagged;
1060
+ }
1061
+ const guardedPath = resolution?.guardedPath;
1062
+ const tagged = withEvidence(await routed(request, url, entry, matched, secrets, arrivedOn, recorded, guardedPath).catch(() => {
1063
+ entry.responseError = "the emulator could not build or produce the response";
1064
+ return emulatorError(500, errorTexts.failed);
1065
+ }), matched.route.evidence);
1066
+ entry.status = tagged.status;
1067
+ return tagged;
1068
+ };
1069
+ const controlPlane = async (request, path) => {
1070
+ const method = request.method;
1071
+ const allow = (methods) => emulatorError(405, "method not allowed", { allow: methods });
1072
+ const jsonBody = async () => {
1073
+ const text = await readText(request);
1074
+ return text === void 0 ? void 0 : parseJsonText(text);
1075
+ };
1076
+ switch (path) {
1077
+ case "/_emulate/ledger":
1078
+ if (method === "GET") return jsonResponse(200, { entries: entries.map(snapshotLedgerEntry) });
1079
+ if (method === "DELETE") {
1080
+ const cleared = entries.length;
1081
+ clearLedger();
1082
+ return jsonResponse(200, { cleared });
1083
+ }
1084
+ return allow("GET, DELETE");
1085
+ case "/_emulate/faults": {
1086
+ if (method === "GET") return jsonResponse(200, { faults: faultStates.map(snapshotFault) });
1087
+ if (method === "DELETE") {
1088
+ const cleared = faultStates.length;
1089
+ faultStates = [];
1090
+ return jsonResponse(200, { cleared });
1091
+ }
1092
+ if (method !== "POST") return allow("GET, POST, DELETE");
1093
+ const input = await jsonBody();
1094
+ const decoded = chunkFaults ? decodeStreamFaultList(input) : decodeFaultList(input);
1095
+ if (Result.isFailure(decoded)) return emulatorError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`);
1096
+ const faults = "faults" in decoded.success ? decoded.success.faults : [decoded.success];
1097
+ const problem = faults.map(matchRouteProblem).find(Predicate.isString);
1098
+ if (problem !== void 0) return emulatorError(400, `invalid fault: ${problem}`);
1099
+ return jsonResponse(201, { faults: faults.map((fault) => addFault(fault)) });
1100
+ }
1101
+ case "/_emulate/reset":
1102
+ if (method !== "POST") return allow("POST");
1103
+ await reset();
1104
+ return jsonResponse(200, { reset: true });
1105
+ case "/_emulate/state":
1106
+ if (method !== "GET") return allow("GET");
1107
+ return jsonResponse(200, {
1108
+ state: core.snapshot(),
1109
+ ...config.runtimeState(),
1110
+ faults: faultStates.map(snapshotFault),
1111
+ ledgerEntries: entries.length
1112
+ });
1113
+ case "/_emulate/seed": {
1114
+ if (method !== "POST") return allow("POST");
1115
+ const next = await reseed(await jsonBody() ?? null);
1116
+ if (Predicate.isString(next)) return emulatorError(400, `invalid seed: ${next}`);
1117
+ return jsonResponse(200, {
1118
+ seeded: true,
1119
+ ...config.seedSummary(next)
1120
+ });
1121
+ }
1122
+ case "/_emulate/coverage":
1123
+ if (method !== "GET") return allow("GET");
1124
+ return jsonResponse(200, coverage());
1125
+ default: return emulatorError(404, "unknown control-plane route");
1126
+ }
1127
+ };
1128
+ /**
1129
+ * Last resort when handling itself fails (for example an unparseable request URL): a 500
1130
+ * emulator error, tagged with the matched route's evidence when there is one (in resolved mode,
1131
+ * whose own resolution may be what failed, never).
1132
+ */
1133
+ const lastResort = (request) => {
1134
+ const path = URL.canParse(request.url) ? new URL(request.url).pathname : void 0;
1135
+ const failed = emulatorError(500, errorTexts.unhandled);
1136
+ if (path === void 0 || isControlPath(path) || resolveRequest !== void 0) return failed;
1137
+ const matched = match(request.method, path);
1138
+ return matched === void 0 ? failed : withEvidence(failed, matched.route.evidence);
1139
+ };
1140
+ const handle = async (request, origin) => {
1141
+ if (closed) return emulatorError(503, errorTexts.closed);
1142
+ const url = new URL(request.url);
1143
+ return isControlPath(url.pathname) ? controlPlane(request, url.pathname) : emulatedApi(request, url, origin ?? url.origin);
1144
+ };
1145
+ const fetchOn = (origin) => (request) => handle(request, origin).catch(() => lastResort(request));
1146
+ return {
1147
+ fetch: fetchOn(void 0),
1148
+ fetchOn: (origin) => fetchOn(origin),
1149
+ ledger: {
1150
+ entries: () => entries.map(snapshotLedgerEntry),
1151
+ clear: clearLedger
1152
+ },
1153
+ faults: {
1154
+ add: (fault) => {
1155
+ const added = addFault(fault);
1156
+ if (Predicate.isString(added)) throw config.inputInvalid("fault", added);
1157
+ return added;
1158
+ },
1159
+ list: () => faultStates.map(snapshotFault),
1160
+ clear: () => {
1161
+ faultStates = [];
1162
+ }
1163
+ },
1164
+ reset,
1165
+ seed: async (input) => {
1166
+ const next = await reseed(input);
1167
+ if (Predicate.isString(next)) throw config.inputInvalid("seed", next);
1168
+ },
1169
+ snapshot: () => core.snapshot(),
1170
+ coverage,
1171
+ close: () => {
1172
+ closed = true;
1173
+ return core.close();
1174
+ }
1175
+ };
1176
+ };
1177
+ /** The fault states of an emulator without truncation faults (its decoder takes status faults). */
1178
+ const statusFaultStates = (states) => states.flatMap((state) => {
1179
+ const fault = state.fault;
1180
+ return fault.kind === "status" ? [{
1181
+ ...state,
1182
+ fault
1183
+ }] : [];
1184
+ });
1185
+ /** The API of an emulator without truncation faults: its fault methods take status faults only. */
1186
+ const withStatusFaults = (api, inputInvalid) => ({
1187
+ ...api,
1188
+ faults: {
1189
+ add: (fault) => {
1190
+ const [added] = statusFaultStates([api.faults.add(fault)]);
1191
+ if (added === void 0) throw inputInvalid("fault", "a status fault is required");
1192
+ return added;
1193
+ },
1194
+ list: () => statusFaultStates(api.faults.list()),
1195
+ clear: api.faults.clear
1196
+ }
1197
+ });
1198
+ /**
1199
+ * Build the wrapper over a core runtime. `createCore` receives the dispatch the core must run for
1200
+ * every request (its only route) and returns the runtime adapter. Faults are status faults only.
1201
+ */
1202
+ const makeStatefulEmulator = async (config, createCore) => withStatusFaults(await buildStatefulEmulator(config, createCore, false, snapshotEntry), config.inputInvalid);
1203
+ /**
1204
+ * `makeStatefulEmulator` for an emulator that records no request header (opt-in): it takes no
1205
+ * `recordHeaders`, and its ledger entries carry no `headers` field
1206
+ * (`StatefulHeaderlessLedgerEntry`). Faults are status faults only.
1207
+ */
1208
+ const makeHeaderlessStatefulEmulator = async (config, createCore) => withStatusFaults(await buildStatefulEmulator({
1209
+ ...config,
1210
+ recordHeaders: []
1211
+ }, createCore, false, snapshotHeaderlessEntry), config.inputInvalid);
1212
+ /**
1213
+ * `makeStatefulEmulator` that also takes `truncate-after-chunks` faults
1214
+ * (`StatefulTruncateFault`), which cut a streamed answer (`StreamedCommit`). Opt-in: the other
1215
+ * emulators keep status faults only.
1216
+ */
1217
+ const makeChunkedStatefulEmulator = (config, createCore) => buildStatefulEmulator(config, createCore, true, snapshotEntry);
1218
+ /** Throws `inputInvalid('option', ...)` unless every drill knob is a known boolean (or absent). */
1219
+ const checkBooleanDrills = (drills, knobs, inputInvalid) => {
1220
+ for (const [key, value] of Object.entries(drills ?? {})) {
1221
+ if (!knobs.includes(key)) throw inputInvalid("option", `unknown drill knob ${key}`);
1222
+ if (value !== void 0 && !Predicate.isBoolean(value)) throw inputInvalid("option", `drills.${key} must be a boolean`);
1223
+ }
1224
+ };
1225
+ //#endregion
1226
+ export { DecodedViewRefusal, NotEmulated, StatefulFault, StatefulFaultMatch, StatefulStreamFault, StatefulTruncateFault, checkBooleanDrills, decodeSegment, exactBodyKeys, exactObject, exactQuery, integerIn, isJsonObject, isNotEmulated, jsonResponse, makeChunkedStatefulEmulator, makeHeaderlessStatefulEmulator, makeStatefulEmulator, mediaType, notEmulated, notEmulatedResponse, parseJsonText, repeatsJsonKey, routeEvidence, routeManifest, routeMatcher, statefulRoute, streamedCommit, unpatternedParams };
1227
+
1228
+ //# sourceMappingURL=stateful-emulator.mjs.map