@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,982 @@
1
+ /**
2
+ * Stateful Microsoft Graph emulator (`/v1.0` on `https://graph.microsoft.com`, plus the OneDrive
3
+ * copy monitor URL on the SharePoint origin), built on the upstream `@emulators/core` custom
4
+ * runtime, with a request ledger, wire faults, and an `/_emulate/*` control plane.
5
+ *
6
+ * It never imports SDK code: its wire shapes and default seed are copied as data from the
7
+ * synthetic Microsoft conformance fixtures, and every route names the conformance cases it
8
+ * follows in `microsoftEmulatorRoutes`. Only the routes those eleven cases need are emulated;
9
+ * everything else fails closed with the Graph error envelope.
10
+ *
11
+ * Node-only: `@emulators/core` imports Node builtins, so the core is loaded lazily by
12
+ * `makeMicrosoftEmulator` (importing this module has no side effects).
13
+ *
14
+ * @experimental
15
+ */
16
+ import type { EmulatorSnapshot } from '@emulators/core'
17
+ import { Data, Predicate, Result } from 'effect'
18
+ import * as Schema from 'effect/Schema'
19
+ import {
20
+ EmulatorHeaderRecord,
21
+ EmulatorResponseStatus,
22
+ emulatorJobHeader,
23
+ handlerFailedHeader,
24
+ redactCredentialFields,
25
+ redactCredentialQuery
26
+ } from './emulator-http.ts'
27
+ import {
28
+ matchMicrosoftRoute,
29
+ microsoftApiRoutes,
30
+ registerMicrosoftApi,
31
+ requestSeqHeader
32
+ } from './microsoft/api.ts'
33
+ import {
34
+ codes,
35
+ errorContext,
36
+ graphError,
37
+ parseJsonText,
38
+ type CopyMonitor,
39
+ type ErrorContext,
40
+ type MicrosoftApiEnv,
41
+ type MicrosoftEmulatorDrills
42
+ } from './microsoft/graph.ts'
43
+ import {
44
+ buildSeedState,
45
+ decodeState,
46
+ type MicrosoftEmulatorSeed,
47
+ type MicrosoftEmulatorState
48
+ } from './microsoft/state.ts'
49
+ import {
50
+ emulatorEvidenceHeader,
51
+ type EmulatorEvidence,
52
+ type EmulatorRouteEvidence
53
+ } from './route-evidence.ts'
54
+
55
+ export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
56
+
57
+ export { emulatorEvidenceHeader } from './route-evidence.ts'
58
+
59
+ export { microsoftEmulatorBasePath } from './microsoft/api.ts'
60
+
61
+ export { microsoftEmulatorErrorCodes, type MicrosoftEmulatorDrills } from './microsoft/graph.ts'
62
+
63
+ export {
64
+ MicrosoftEmulatorAttachment,
65
+ MicrosoftEmulatorAttachmentSeed,
66
+ MicrosoftEmulatorCalendar,
67
+ MicrosoftEmulatorDrive,
68
+ MicrosoftEmulatorDriveItem,
69
+ MicrosoftEmulatorDriveItemSeed,
70
+ MicrosoftEmulatorEvent,
71
+ MicrosoftEmulatorEventSeed,
72
+ MicrosoftEmulatorMailFolder,
73
+ MicrosoftEmulatorMessage,
74
+ MicrosoftEmulatorMessageSeed,
75
+ MicrosoftEmulatorProfile,
76
+ MicrosoftEmulatorRecipient,
77
+ MicrosoftEmulatorSeed,
78
+ MicrosoftEmulatorStateSchema,
79
+ MicrosoftEmulatorUser,
80
+ type MicrosoftEmulatorState
81
+ } from './microsoft/state.ts'
82
+
83
+ /** Origin the connector calls; also the origin of `@odata.nextLink` values. */
84
+ export const microsoftEmulatorDefaultOrigin = 'https://graph.microsoft.com'
85
+
86
+ /**
87
+ * Origin of OneDrive `webUrl` values and copy monitor URLs (the fixtures' synthetic SharePoint
88
+ * host). Route it to the same emulator as the Graph origin.
89
+ */
90
+ export const microsoftEmulatorDefaultSharePointOrigin = 'https://synthetic-my.sharepoint.com'
91
+
92
+ /**
93
+ * Route evidence manifest: every emulated route, whether it writes, and the conformance cases
94
+ * whose (currently synthetic, unverified) wire claims it follows. Kept in sync with the handlers
95
+ * by construction (both come from one route table). The monitor route lives on the SharePoint
96
+ * origin; every other route on the Graph origin.
97
+ */
98
+ export const microsoftEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> = microsoftApiRoutes.map(
99
+ ({ handler: _handler, queryKeys: _queryKeys, auth: _auth, ...evidence }) => evidence
100
+ )
101
+
102
+ const FaultCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))
103
+
104
+ /** Optional fault filter; an omitted field matches every request. `path` ending in `*` is a prefix. */
105
+ export const MicrosoftFaultMatch = Schema.Struct({
106
+ method: Schema.optionalKey(Schema.String),
107
+ path: Schema.optionalKey(Schema.String)
108
+ })
109
+
110
+ export type MicrosoftFaultMatch = typeof MicrosoftFaultMatch.Type
111
+
112
+ /**
113
+ * A wire fault: answer matching requests with this status, headers, and body (for example 429
114
+ * with `retry-after`) instead of the route's successful answer, so nothing is written. A request
115
+ * the route refuses is answered by the route and uses up no fault. The body defaults to a Graph
116
+ * error envelope. `count` limits how many requests it answers (omitted: all). `match.path` is
117
+ * the raw request path (`/v1.0/users/ada%40example.test/...`).
118
+ *
119
+ * The shared emulator rules apply: statuses that cannot carry a body (1xx, 204, 205) and
120
+ * redirects (3xx) are rejected, as are invalid header names or values, a `location` header, and
121
+ * framing headers (`content-length`, `transfer-encoding`, `connection`, `keep-alive`, `upgrade`).
122
+ */
123
+ export const MicrosoftFault = Schema.Struct({
124
+ kind: Schema.Literal('status'),
125
+ status: EmulatorResponseStatus,
126
+ headers: Schema.optionalKey(EmulatorHeaderRecord),
127
+ body: Schema.optionalKey(Schema.Json),
128
+ match: Schema.optionalKey(MicrosoftFaultMatch),
129
+ count: Schema.optionalKey(FaultCount)
130
+ })
131
+
132
+ export type MicrosoftFault = typeof MicrosoftFault.Type
133
+
134
+ /** Invalid emulator input from the JS API: a seed, a fault, or an option. A programmer error. */
135
+ export class MicrosoftEmulatorInputInvalid extends Data.TaggedError(
136
+ 'MicrosoftEmulatorInputInvalid'
137
+ )<{
138
+ readonly input: 'seed' | 'fault' | 'option'
139
+ readonly reason: string
140
+ }> {
141
+ override get message(): string {
142
+ return `Invalid Microsoft emulator ${this.input}: ${this.reason}`
143
+ }
144
+ }
145
+
146
+ export type MicrosoftLedgerEntry = {
147
+ /** 1-based arrival order since the last ledger clear or reset. */
148
+ readonly seq: number
149
+ readonly method: string
150
+ /** Raw request path, for example `/v1.0/users/ada%40example.test/messages`. */
151
+ readonly path: string
152
+ /** Path template of the matched route, for example `/v1.0/users/{userId}/messages`. */
153
+ readonly route?: string
154
+ /** Query parameters; the values of credential-named keys (`access_token`) are `<redacted>`. */
155
+ readonly query: Readonly<Record<string, string>>
156
+ /**
157
+ * Parsed JSON request body, when there was one, with every credential-named key's value
158
+ * (for example a `$batch` subrequest's `Authorization` header) replaced by `<redacted>`.
159
+ */
160
+ readonly body?: Schema.Json
161
+ /** The `Prefer` header, when sent (never a credential). */
162
+ readonly prefer?: string
163
+ readonly status: number
164
+ /** Evidence of the matched route; `unknown-route` for requests that failed closed. */
165
+ readonly evidence: EmulatorEvidence | 'unknown-route'
166
+ /** Set when a fault answered the request. */
167
+ readonly fault?: 'status'
168
+ /**
169
+ * Set when the emulator could not build or produce the response, or a route handler threw; the
170
+ * request was answered with a 500 Graph error envelope (still evidence-tagged) and no fault was
171
+ * used up.
172
+ */
173
+ readonly responseError?: string
174
+ }
175
+
176
+ export type MicrosoftFaultState = {
177
+ readonly id: number
178
+ readonly fault: MicrosoftFault
179
+ /** Remaining matching requests; `undefined` for an unlimited fault. */
180
+ readonly remaining: number | undefined
181
+ readonly applied: number
182
+ }
183
+
184
+ export type MicrosoftRouteCoverage = EmulatorRouteEvidence & {
185
+ /** Ledger requests answered by this route since the last ledger clear or reset. */
186
+ readonly requests: number
187
+ }
188
+
189
+ export type MicrosoftCoverage = {
190
+ readonly routes: ReadonlyArray<MicrosoftRouteCoverage>
191
+ /** Ledger requests to unknown routes (failed closed). */
192
+ readonly unknownRouteRequests: number
193
+ }
194
+
195
+ /** A copy monitor as reported by `/_emulate/state` and `monitors()`. */
196
+ export type MicrosoftCopyMonitorState = {
197
+ readonly id: string
198
+ readonly sourceId: string
199
+ readonly destinationParentId: string
200
+ readonly pollsLeft: number
201
+ readonly status: 'inProgress' | 'completed'
202
+ readonly resourceId?: string
203
+ }
204
+
205
+ export type MicrosoftEmulatorOptions = {
206
+ /** Typed seed; defaults to the fixture entities (`profile: 'default'`). */
207
+ readonly seed?: MicrosoftEmulatorSeed
208
+ /** Clock in epoch milliseconds. Defaults to `Date.now`. */
209
+ readonly now?: () => number
210
+ /** Origin of `@odata.nextLink` values. Defaults to `https://graph.microsoft.com`. */
211
+ readonly baseUrl?: string
212
+ /** Origin of `webUrl` values and monitor URLs. Defaults to `https://synthetic-my.sharepoint.com`. */
213
+ readonly sharePointOrigin?: string
214
+ /**
215
+ * In-progress monitor answers before a copy completes (integer 0-100, default 0: the first poll
216
+ * completes, as the copy fixture records).
217
+ */
218
+ readonly copyInProgressPolls?: number
219
+ /**
220
+ * How long (ms, integer 0-10000, default 25) the first committed message write holds its
221
+ * message (a refused or faulted write holds nothing); an overlapping write to that message gets
222
+ * 409. Non-overlapping writes both apply (an emulator extrapolation). `0` only rejects writes
223
+ * that overlap the handler itself.
224
+ */
225
+ readonly conflictWindowMs?: number
226
+ /** Drill knobs (tests only): make the emulator disagree with one conformance claim. */
227
+ readonly drills?: MicrosoftEmulatorDrills
228
+ }
229
+
230
+ export type MicrosoftEmulator = {
231
+ /** The fetch handler (Graph routes, the copy monitor, and `/_emulate/*`). Never rejects. */
232
+ readonly fetch: (request: Request) => Promise<Response>
233
+ /** Origin of `@odata.nextLink` values. */
234
+ readonly baseUrl: string
235
+ /** Origin of `webUrl` values and monitor URLs; route it to this emulator too. */
236
+ readonly sharePointOrigin: string
237
+ readonly ledger: {
238
+ readonly entries: () => ReadonlyArray<MicrosoftLedgerEntry>
239
+ readonly clear: () => void
240
+ }
241
+ readonly faults: {
242
+ /** Add a fault; throws `MicrosoftEmulatorInputInvalid` for an invalid fault. */
243
+ readonly add: (fault: MicrosoftFault) => MicrosoftFaultState
244
+ readonly list: () => ReadonlyArray<MicrosoftFaultState>
245
+ readonly clear: () => void
246
+ }
247
+ /** The copy monitors created since the last reset or seed. */
248
+ readonly monitors: () => ReadonlyArray<MicrosoftCopyMonitorState>
249
+ /** Restore the current seed and clear the ledger, faults, and copy monitors. */
250
+ readonly reset: () => Promise<void>
251
+ /**
252
+ * Replace the state with a new seed, which becomes what `reset` restores (monitors are
253
+ * cleared). Rejects with `MicrosoftEmulatorInputInvalid` for an invalid seed.
254
+ */
255
+ readonly seed: (seed: MicrosoftEmulatorSeed) => Promise<void>
256
+ /** A deep copy of the current state (entities and counters). */
257
+ readonly snapshot: () => MicrosoftEmulatorState
258
+ readonly coverage: () => MicrosoftCoverage
259
+ /** Close the core runtime. Later requests answer 503. Idempotent. */
260
+ readonly close: () => Promise<void>
261
+ }
262
+
263
+ const strict = { onExcessProperty: 'error' } as const
264
+
265
+ const decodeFault = Schema.decodeUnknownResult(MicrosoftFault, strict)
266
+
267
+ const decodeFaultList = Schema.decodeUnknownResult(
268
+ Schema.Union([MicrosoftFault, Schema.Struct({ faults: Schema.Array(MicrosoftFault) })]),
269
+ strict
270
+ )
271
+
272
+ const issueMessage = (issue: Schema.SchemaError['issue']): string =>
273
+ new Schema.SchemaError(issue).message
274
+
275
+ const jsonResponse = (status: number, body: unknown, headers: HeadersInit = {}): Response => {
276
+ const responseHeaders = new Headers(headers)
277
+
278
+ responseHeaders.set('content-type', 'application/json')
279
+
280
+ return new Response(JSON.stringify(body), { status, headers: responseHeaders })
281
+ }
282
+
283
+ const controlError = (status: number, message: string, headers: HeadersInit = {}): Response =>
284
+ jsonResponse(status, { error: { message, type: 'emulator_error' } }, headers)
285
+
286
+ const defaultFaultBody = (context: ErrorContext, status: number): Schema.Json => ({
287
+ error: {
288
+ code: status === 429 ? codes.rateLimited : codes.upstreamError,
289
+ message:
290
+ status === 429 ? 'Synthetic: too many requests.' : `Synthetic upstream error (${status}).`,
291
+ innerError: {
292
+ date: context.date,
293
+ 'request-id': context.requestId,
294
+ 'client-request-id': context.clientRequestId
295
+ }
296
+ }
297
+ })
298
+
299
+ // A non-empty bearer credential. The value is never checked, stored, forwarded, or ledgered.
300
+ const bearerPattern = /^bearer\s+\S+/i
301
+
302
+ const pathMatches = (pattern: string, path: string): boolean =>
303
+ pattern.endsWith('*') ? path.startsWith(pattern.slice(0, -1)) : pattern === path
304
+
305
+ const faultMatches = (fault: MicrosoftFault, method: string, path: string): boolean =>
306
+ (fault.match?.method === undefined || fault.match.method.toUpperCase() === method) &&
307
+ (fault.match?.path === undefined || pathMatches(fault.match.path, path))
308
+
309
+ const readText = (request: Request): Promise<string | undefined> =>
310
+ request.text().then(
311
+ text => text,
312
+ () => undefined
313
+ )
314
+
315
+ const validOrigin = (input: string): string | undefined => {
316
+ if (!URL.canParse(input)) {
317
+ return undefined
318
+ }
319
+
320
+ const url = new URL(input)
321
+
322
+ const bare =
323
+ (url.pathname === '/' || url.pathname === '') &&
324
+ url.search === '' &&
325
+ url.hash === '' &&
326
+ url.username === '' &&
327
+ url.password === ''
328
+
329
+ return (url.protocol === 'http:' || url.protocol === 'https:') && bare ? url.origin : undefined
330
+ }
331
+
332
+ const integerOption = (
333
+ name: string,
334
+ value: number | undefined,
335
+ fallback: number,
336
+ maximum: number
337
+ ): number => {
338
+ const resolved = value ?? fallback
339
+
340
+ if (!Number.isSafeInteger(resolved) || resolved < 0 || resolved > maximum) {
341
+ throw new MicrosoftEmulatorInputInvalid({
342
+ input: 'option',
343
+ reason: `${name} must be an integer from 0 to ${maximum}`
344
+ })
345
+ }
346
+
347
+ return resolved
348
+ }
349
+
350
+ type MutableLedgerEntry = {
351
+ seq: number
352
+ method: string
353
+ path: string
354
+ route?: string
355
+ query: Readonly<Record<string, string>>
356
+ body?: Schema.Json
357
+ prefer?: string
358
+ status: number
359
+ evidence: EmulatorEvidence | 'unknown-route'
360
+ fault?: 'status'
361
+ responseError?: string
362
+ }
363
+
364
+ /** One core request's fault outcome: the fault's answer, set when a fault answered it. */
365
+ type FaultJob = { faultAnswer?: Response }
366
+
367
+ type MutableFaultState = {
368
+ readonly id: number
369
+ readonly fault: MicrosoftFault
370
+ remaining: number | undefined
371
+ applied: number
372
+ }
373
+
374
+ const withEvidence = (response: Response, evidence: EmulatorEvidence): Response => {
375
+ const headers = new Headers(response.headers)
376
+
377
+ if (evidence === 'unverified') {
378
+ headers.set(emulatorEvidenceHeader, 'unverified')
379
+ }
380
+
381
+ return new Response(response.body, {
382
+ status: response.status,
383
+ statusText: response.statusText,
384
+ headers
385
+ })
386
+ }
387
+
388
+ const isControlPath = (path: string): boolean =>
389
+ path === '/_emulate' || path.startsWith('/_emulate/')
390
+
391
+ /** The largest instant a JS `Date` holds (ms from the epoch, either way). */
392
+ const maxDateMs = 8_640_000_000_000_000
393
+
394
+ /** Fixed synthetic `innerError.date` for error recovery when the clock fails (the epoch). */
395
+ const recoveryDateMs = 0
396
+
397
+ const monitorState = (monitor: CopyMonitor): MicrosoftCopyMonitorState => {
398
+ const base: MicrosoftCopyMonitorState = {
399
+ id: monitor.id,
400
+ sourceId: monitor.sourceId,
401
+ destinationParentId: monitor.destinationParentId,
402
+ pollsLeft: monitor.pollsLeft,
403
+ status: monitor.resourceId === undefined ? 'inProgress' : 'completed'
404
+ }
405
+
406
+ return monitor.resourceId === undefined ? base : { ...base, resourceId: monitor.resourceId }
407
+ }
408
+
409
+ /**
410
+ * Create a stateful Microsoft Graph emulator on the `@emulators/core` custom runtime. Each call
411
+ * has its own state, ledger, faults, and copy monitors. Rejects with
412
+ * `MicrosoftEmulatorInputInvalid` for an invalid seed or option.
413
+ *
414
+ * Graph routes need `Authorization: Bearer <non-empty>` (missing: 401 Graph error envelope); the
415
+ * token is never checked, stored, forwarded to the core, or ledgered. The copy monitor needs no
416
+ * credential. Precedence per request: route match (unknown routes fail closed with a 404 Graph
417
+ * error envelope), authorization, JSON body parsing, then the stateful route's plan (its query
418
+ * allowlist and `If-Match` refusal, then its handler on drafts of the state and copy monitors),
419
+ * then the first matching fault, then the commit. A request the route refuses (any answer that is
420
+ * not 2xx, such as a 404 for an unknown id, a 400 not emulated, or a 409 conflict) uses up no
421
+ * fault; a faulted request writes nothing and holds no message. A handler that throws answers a 500
422
+ * Graph error envelope with `responseError` in the ledger, even when the clock throws; unknown
423
+ * routes (404) and a closed emulator (503) keep their status when the clock throws too. Every
424
+ * response from an unverified route carries `x-emulator-evidence: unverified`. Ledgered bodies and
425
+ * query parameters have credential-named keys redacted.
426
+ */
427
+ export const makeMicrosoftEmulator = async (
428
+ options: MicrosoftEmulatorOptions = {}
429
+ ): Promise<MicrosoftEmulator> => {
430
+ const initial = buildSeedState(options.seed ?? {})
431
+
432
+ if (Predicate.isString(initial)) {
433
+ throw new MicrosoftEmulatorInputInvalid({ input: 'seed', reason: initial })
434
+ }
435
+
436
+ const graphOrigin = validOrigin(options.baseUrl ?? microsoftEmulatorDefaultOrigin)
437
+
438
+ const sharePointOrigin = validOrigin(
439
+ options.sharePointOrigin ?? microsoftEmulatorDefaultSharePointOrigin
440
+ )
441
+
442
+ if (graphOrigin === undefined || sharePointOrigin === undefined) {
443
+ throw new MicrosoftEmulatorInputInvalid({
444
+ input: 'option',
445
+ reason:
446
+ 'baseUrl and sharePointOrigin must be http(s) origins without path, query, hash, or credentials'
447
+ })
448
+ }
449
+
450
+ const now = options.now ?? (() => Date.now())
451
+
452
+ const env: MicrosoftApiEnv = {
453
+ now,
454
+ graphOrigin,
455
+ sharePointOrigin,
456
+ drills: {
457
+ calendarRangeEmpty: options.drills?.calendarRangeEmpty ?? false,
458
+ createOmitsId: options.drills?.createOmitsId ?? false,
459
+ timestampPrecisionDigits: integerOption(
460
+ 'drills.timestampPrecisionDigits',
461
+ options.drills?.timestampPrecisionDigits,
462
+ 7,
463
+ 7
464
+ ),
465
+ omitNextLink: options.drills?.omitNextLink ?? false
466
+ },
467
+ copyInProgressPolls: integerOption('copyInProgressPolls', options.copyInProgressPolls, 0, 100),
468
+ conflictWindowMs: integerOption('conflictWindowMs', options.conflictWindowMs, 25, 10_000),
469
+ messageLocks: new Set(),
470
+ monitors: new Map(),
471
+ monitorCounter: { next: 1 }
472
+ }
473
+
474
+ // Loaded lazily: the core imports Node builtins and reads files at import time.
475
+ const core = await import('@emulators/core')
476
+
477
+ /**
478
+ * Fault decisions of the core requests in flight, by job id (asked by the route once it would
479
+ * answer successfully). Job ids never reset, so a ledger clear never makes two jobs share one.
480
+ */
481
+ const jobs = new Map<string, () => boolean>()
482
+ let nextJobId = 1
483
+
484
+ const definition = core.defineEmulator<MicrosoftEmulatorState>({
485
+ name: 'microsoft',
486
+ cors: false,
487
+ state: () => initial,
488
+ validateSeed: value => {
489
+ const decoded = decodeState(value)
490
+
491
+ if (Predicate.isString(decoded)) {
492
+ throw new MicrosoftEmulatorInputInvalid({ input: 'seed', reason: decoded })
493
+ }
494
+
495
+ return decoded
496
+ },
497
+ setup: ({ app, state }) =>
498
+ registerMicrosoftApi(app, state, env, jobId =>
499
+ jobId === null ? false : (jobs.get(jobId)?.() ?? false)
500
+ )
501
+ })
502
+
503
+ const runtime = await core.createCustomRuntime(definition, { seed: initial })
504
+
505
+ let baseline: MicrosoftEmulatorState = initial
506
+ let entries: Array<MutableLedgerEntry> = []
507
+ let faultStates: Array<MutableFaultState> = []
508
+ let nextSeq = 1
509
+ let nextFaultId = 1
510
+ let closed = false
511
+
512
+ const snapshotEntry = (entry: MutableLedgerEntry): MicrosoftLedgerEntry => ({
513
+ ...entry,
514
+ query: { ...entry.query }
515
+ })
516
+
517
+ const snapshotFault = (state: MutableFaultState): MicrosoftFaultState => ({ ...state })
518
+
519
+ const clearLedger = () => {
520
+ entries = []
521
+ nextSeq = 1
522
+ }
523
+
524
+ const clearMonitors = () => {
525
+ env.monitors.clear()
526
+ env.monitorCounter.next = 1
527
+ }
528
+
529
+ const addFault = (input: unknown): MicrosoftFaultState | string => {
530
+ const decoded = decodeFault(input)
531
+
532
+ if (Result.isFailure(decoded)) {
533
+ return issueMessage(decoded.failure.issue)
534
+ }
535
+
536
+ const state: MutableFaultState = {
537
+ id: nextFaultId++,
538
+ fault: decoded.success,
539
+ remaining: decoded.success.count,
540
+ applied: 0
541
+ }
542
+
543
+ faultStates.push(state)
544
+
545
+ return snapshotFault(state)
546
+ }
547
+
548
+ const takeFault = (method: string, path: string): MutableFaultState | undefined =>
549
+ faultStates.find(
550
+ state =>
551
+ (state.remaining === undefined || state.remaining > 0) &&
552
+ faultMatches(state.fault, method, path)
553
+ )
554
+
555
+ /** Build the fault's response first: a response that cannot be built must not consume it. */
556
+ const applyFault = (state: MutableFaultState, context: ErrorContext): Response => {
557
+ const headers = new Headers(state.fault.headers ?? {})
558
+ const body = state.fault.body ?? defaultFaultBody(context, state.fault.status)
559
+
560
+ if (!headers.has('content-type')) {
561
+ headers.set('content-type', Predicate.isString(body) ? 'text/plain' : 'application/json')
562
+ }
563
+
564
+ const response = new Response(Predicate.isString(body) ? body : JSON.stringify(body), {
565
+ status: state.fault.status,
566
+ headers
567
+ })
568
+
569
+ state.applied += 1
570
+
571
+ if (state.remaining !== undefined) {
572
+ state.remaining -= 1
573
+ }
574
+
575
+ return response
576
+ }
577
+
578
+ const snapshot = (): MicrosoftEmulatorState => runtime.snapshot().state
579
+
580
+ const restore = (state: MicrosoftEmulatorState): Promise<void> => {
581
+ const current: EmulatorSnapshot<MicrosoftEmulatorState> = runtime.snapshot()
582
+
583
+ return runtime.restore({ ...current, state })
584
+ }
585
+
586
+ const reset = async () => {
587
+ clearLedger()
588
+ faultStates = []
589
+ clearMonitors()
590
+ await restore(baseline)
591
+ }
592
+
593
+ const reseed = async (input: unknown): Promise<MicrosoftEmulatorState | string> => {
594
+ const next = buildSeedState(input)
595
+
596
+ if (Predicate.isString(next)) {
597
+ return next
598
+ }
599
+
600
+ await restore(next)
601
+ clearMonitors()
602
+ baseline = next
603
+
604
+ return next
605
+ }
606
+
607
+ const coverage = (): MicrosoftCoverage => ({
608
+ routes: microsoftEmulatorRoutes.map(route => ({
609
+ ...route,
610
+ requests: entries.filter(
611
+ entry => entry.route === route.path && entry.method.toUpperCase() === route.method
612
+ ).length
613
+ })),
614
+ unknownRouteRequests: entries.filter(entry => entry.evidence === 'unknown-route').length
615
+ })
616
+
617
+ const contextOf = (request: Request, seq: number): ErrorContext =>
618
+ errorContext(now(), seq, request.headers.get('client-request-id'))
619
+
620
+ /**
621
+ * The clock for error recovery: read once, and a clock that throws (or answers a value that is
622
+ * not a finite date) falls back to a fixed synthetic date, so recovery never fails on it.
623
+ */
624
+ const recoveryNow = (): number => {
625
+ try {
626
+ const value = now()
627
+
628
+ return Number.isFinite(value) && Math.abs(value) <= maxDateMs ? value : recoveryDateMs
629
+ } catch {
630
+ return recoveryDateMs
631
+ }
632
+ }
633
+
634
+ /**
635
+ * The evidence-tagged fallback when a matched route cannot build or produce its response.
636
+ * Clock-independent: see `recoveryNow`.
637
+ */
638
+ const responseFailed = (request: Request, seq: number): Response =>
639
+ graphError(
640
+ errorContext(recoveryNow(), seq, request.headers.get('client-request-id')),
641
+ 500,
642
+ codes.upstreamError,
643
+ 'Synthetic: the emulator could not build the response.'
644
+ )
645
+
646
+ /**
647
+ * Authorization, body parsing, then the core route (without the credential), which asks for the
648
+ * fault only once it would answer successfully.
649
+ */
650
+ const routed = async (
651
+ request: Request,
652
+ url: URL,
653
+ entry: MutableLedgerEntry,
654
+ auth: boolean
655
+ ): Promise<Response> => {
656
+ // Built on demand, so the clock is read only when the wrapper answers an error itself.
657
+ const context = () => contextOf(request, entry.seq)
658
+
659
+ if (auth && !bearerPattern.test(request.headers.get('authorization') ?? '')) {
660
+ return graphError(context(), 401, codes.unauthenticated, 'Access token is empty.')
661
+ }
662
+
663
+ const text = await readText(request)
664
+
665
+ if (text === undefined) {
666
+ return graphError(
667
+ context(),
668
+ 400,
669
+ codes.invalidBody,
670
+ 'Synthetic: the request body is unreadable.'
671
+ )
672
+ }
673
+
674
+ if (text !== '') {
675
+ const json = parseJsonText(text)
676
+
677
+ if (json === undefined) {
678
+ return graphError(
679
+ context(),
680
+ 400,
681
+ codes.invalidBody,
682
+ 'Synthetic: the request body is not valid JSON.'
683
+ )
684
+ }
685
+
686
+ // Redacted before it is stored: a rejected request's body stays in the ledger too.
687
+ entry.body = redactCredentialFields(json)
688
+ }
689
+
690
+ const method = request.method.toUpperCase()
691
+ const hasBody = text !== '' && method !== 'GET' && method !== 'HEAD'
692
+ const jobId = String(nextJobId++)
693
+
694
+ const headers = new Headers({
695
+ accept: 'application/json',
696
+ [requestSeqHeader]: String(entry.seq),
697
+ [emulatorJobHeader]: jobId
698
+ })
699
+
700
+ // Only non-credential headers the routes read are forwarded.
701
+ for (const name of ['prefer', 'if-match', 'client-request-id']) {
702
+ const value = request.headers.get(name)
703
+
704
+ if (value !== null) headers.set(name, value)
705
+ }
706
+
707
+ if (hasBody) headers.set('content-type', 'application/json')
708
+
709
+ const init: RequestInit = { method, headers }
710
+
711
+ if (hasBody) {
712
+ init.body = text
713
+ }
714
+
715
+ const job: FaultJob = {}
716
+
717
+ // Asked by the route only for an answer it would send successfully, before it commits.
718
+ jobs.set(jobId, () => {
719
+ const fault = takeFault(method, url.pathname)
720
+
721
+ if (fault === undefined) return false
722
+
723
+ try {
724
+ job.faultAnswer = applyFault(fault, context())
725
+ } catch (error) {
726
+ // The fault's answer could not be built (the route did not fail): the core answers its
727
+ // handler failure, and the fault is not used up.
728
+ entry.responseError = 'the emulator could not build or produce the response'
729
+
730
+ throw error
731
+ }
732
+
733
+ entry.fault = 'status'
734
+
735
+ return true
736
+ })
737
+
738
+ const response = await runtime
739
+ .fetch(new Request(new URL(`${url.pathname}${url.search}`, runtime.baseUrl), init))
740
+ .finally(() => jobs.delete(jobId))
741
+
742
+ // The fault's answer is sent from here, never through the core.
743
+ if (job.faultAnswer !== undefined) return job.faultAnswer
744
+
745
+ if (response.headers.has(handlerFailedHeader)) {
746
+ entry.responseError ??= 'the route handler failed'
747
+
748
+ return responseFailed(request, entry.seq)
749
+ }
750
+
751
+ return response
752
+ }
753
+
754
+ const emulatedApi = async (request: Request, url: URL): Promise<Response> => {
755
+ const matched = matchMicrosoftRoute(request.method, url.pathname)
756
+ const prefer = request.headers.get('prefer')
757
+
758
+ const entry: MutableLedgerEntry = {
759
+ seq: nextSeq++,
760
+ method: request.method,
761
+ path: url.pathname,
762
+ query: redactCredentialQuery(url.searchParams),
763
+ status: 0,
764
+ evidence: matched?.route.evidence ?? 'unknown-route'
765
+ }
766
+
767
+ if (prefer !== null) entry.prefer = prefer
768
+
769
+ entries.push(entry)
770
+
771
+ if (matched === undefined) {
772
+ entry.status = 404
773
+
774
+ // Clock-independent (see `recoveryNow`), so the answer is the 404 the ledger records.
775
+ return graphError(
776
+ errorContext(recoveryNow(), entry.seq, request.headers.get('client-request-id')),
777
+ 404,
778
+ codes.unknownRoute,
779
+ 'Synthetic: no emulated Microsoft Graph route.'
780
+ )
781
+ }
782
+
783
+ entry.route = matched.route.path
784
+
785
+ // Error recovery still answers through the route: the fallback 500 is evidence-tagged and
786
+ // the ledger records the status actually sent.
787
+ const response = await routed(request, url, entry, matched.route.auth).catch(() => {
788
+ entry.responseError = 'the emulator could not build or produce the response'
789
+
790
+ return responseFailed(request, entry.seq)
791
+ })
792
+
793
+ const tagged = withEvidence(response, matched.route.evidence)
794
+
795
+ entry.status = tagged.status
796
+
797
+ return tagged
798
+ }
799
+
800
+ const controlPlane = async (request: Request, path: string): Promise<Response> => {
801
+ const method = request.method
802
+ const allow = (methods: string) => controlError(405, 'method not allowed', { allow: methods })
803
+
804
+ const jsonBody = async (): Promise<Schema.Json | undefined> => {
805
+ const text = await readText(request)
806
+
807
+ return text === undefined ? undefined : parseJsonText(text)
808
+ }
809
+
810
+ switch (path) {
811
+ case '/_emulate/ledger':
812
+ if (method === 'GET') {
813
+ return jsonResponse(200, { entries: entries.map(snapshotEntry) })
814
+ }
815
+
816
+ if (method === 'DELETE') {
817
+ const cleared = entries.length
818
+
819
+ clearLedger()
820
+
821
+ return jsonResponse(200, { cleared })
822
+ }
823
+
824
+ return allow('GET, DELETE')
825
+
826
+ case '/_emulate/faults': {
827
+ if (method === 'GET') {
828
+ return jsonResponse(200, { faults: faultStates.map(snapshotFault) })
829
+ }
830
+
831
+ if (method === 'DELETE') {
832
+ const cleared = faultStates.length
833
+
834
+ faultStates = []
835
+
836
+ return jsonResponse(200, { cleared })
837
+ }
838
+
839
+ if (method !== 'POST') {
840
+ return allow('GET, POST, DELETE')
841
+ }
842
+
843
+ const decoded = decodeFaultList(await jsonBody())
844
+
845
+ if (Result.isFailure(decoded)) {
846
+ return controlError(400, `invalid fault: ${issueMessage(decoded.failure.issue)}`)
847
+ }
848
+
849
+ const faults = 'faults' in decoded.success ? decoded.success.faults : [decoded.success]
850
+
851
+ return jsonResponse(201, { faults: faults.map(fault => addFault(fault)) })
852
+ }
853
+
854
+ case '/_emulate/reset':
855
+ if (method !== 'POST') {
856
+ return allow('POST')
857
+ }
858
+
859
+ await reset()
860
+
861
+ return jsonResponse(200, { reset: true })
862
+
863
+ case '/_emulate/state':
864
+ if (method !== 'GET') {
865
+ return allow('GET')
866
+ }
867
+
868
+ return jsonResponse(200, {
869
+ state: snapshot(),
870
+ monitors: [...env.monitors.values()].map(monitorState),
871
+ faults: faultStates.map(snapshotFault),
872
+ ledgerEntries: entries.length
873
+ })
874
+
875
+ case '/_emulate/seed': {
876
+ if (method !== 'POST') {
877
+ return allow('POST')
878
+ }
879
+
880
+ const next = await reseed((await jsonBody()) ?? null)
881
+
882
+ if (Predicate.isString(next)) {
883
+ return controlError(400, `invalid seed: ${next}`)
884
+ }
885
+
886
+ return jsonResponse(200, {
887
+ seeded: true,
888
+ messages: next.messages.length,
889
+ events: next.events.length,
890
+ driveItems: next.driveItems.length
891
+ })
892
+ }
893
+
894
+ case '/_emulate/coverage':
895
+ if (method !== 'GET') {
896
+ return allow('GET')
897
+ }
898
+
899
+ return jsonResponse(200, coverage())
900
+
901
+ default:
902
+ return controlError(404, 'unknown control-plane route')
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Last resort when handling itself fails (for example an unparseable request URL): API
908
+ * requests answer a Graph error 500, tagged with the matched route's evidence; control-plane
909
+ * requests answer an emulator error.
910
+ */
911
+ const lastResort = (request: Request): Response => {
912
+ const path = URL.canParse(request.url) ? new URL(request.url).pathname : undefined
913
+
914
+ if (path !== undefined && isControlPath(path)) {
915
+ return controlError(500, 'emulator failed to handle the request')
916
+ }
917
+
918
+ const matched = path === undefined ? undefined : matchMicrosoftRoute(request.method, path)
919
+ const failed = responseFailed(request, 0)
920
+
921
+ return matched === undefined ? failed : withEvidence(failed, matched.route.evidence)
922
+ }
923
+
924
+ const handle = async (request: Request): Promise<Response> => {
925
+ if (closed) {
926
+ // Clock-independent (see `recoveryNow`): a closed emulator always answers 503.
927
+ return graphError(
928
+ errorContext(recoveryNow(), 0, request.headers.get('client-request-id')),
929
+ 503,
930
+ codes.upstreamError,
931
+ 'Synthetic: the emulator is closed.'
932
+ )
933
+ }
934
+
935
+ const url = new URL(request.url)
936
+
937
+ return isControlPath(url.pathname)
938
+ ? controlPlane(request, url.pathname)
939
+ : emulatedApi(request, url)
940
+ }
941
+
942
+ return {
943
+ fetch: request => handle(request).catch(() => lastResort(request)),
944
+ baseUrl: graphOrigin,
945
+ sharePointOrigin,
946
+ ledger: {
947
+ entries: () => entries.map(snapshotEntry),
948
+ clear: clearLedger
949
+ },
950
+ faults: {
951
+ add: fault => {
952
+ const added = addFault(fault)
953
+
954
+ if (Predicate.isString(added)) {
955
+ throw new MicrosoftEmulatorInputInvalid({ input: 'fault', reason: added })
956
+ }
957
+
958
+ return added
959
+ },
960
+ list: () => faultStates.map(snapshotFault),
961
+ clear: () => {
962
+ faultStates = []
963
+ }
964
+ },
965
+ monitors: () => [...env.monitors.values()].map(monitorState),
966
+ reset,
967
+ seed: async input => {
968
+ const next = await reseed(input)
969
+
970
+ if (Predicate.isString(next)) {
971
+ throw new MicrosoftEmulatorInputInvalid({ input: 'seed', reason: next })
972
+ }
973
+ },
974
+ snapshot,
975
+ coverage,
976
+ close: () => {
977
+ closed = true
978
+
979
+ return runtime.close()
980
+ }
981
+ }
982
+ }