@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,550 @@
1
+ /**
2
+ * Shared Microsoft Graph emulator wire helpers (internal): the error envelope and codes, JSON
3
+ * responses, `@odata.context`, `$select` projection, `$top`/`$skip` paging with
4
+ * `@odata.nextLink` (folder messages only), single-page `$top` collections (`$top` always required
5
+ * and capped at the largest value a fixture sends to the route), the `Prefer` header, strict nested
6
+ * body objects, and the route handler contract.
7
+ *
8
+ * @experimental
9
+ */
10
+ import { Predicate, Result } from 'effect'
11
+ import * as Schema from 'effect/Schema'
12
+ import type { MicrosoftEmulatorState, MicrosoftEmulatorUser } from './state.ts'
13
+
14
+ /**
15
+ * Error codes the emulator answers with. `ErrorItemNotFound`, `itemNotFound`, and
16
+ * `ErrorIrresolvableConflict` are copied from the fixtures; `InvalidAuthenticationToken` (missing
17
+ * bearer), `ErrorInvalidUser` (a user other than the seeded mailbox), and `TooManyRequests` (the
18
+ * default 429 fault body) are documented Graph codes no fixture records; the `Synthetic*` codes
19
+ * are emulator codes for requests it refuses to emulate (fail closed) or cannot answer.
20
+ */
21
+ export const microsoftEmulatorErrorCodes = {
22
+ mailItemNotFound: 'ErrorItemNotFound',
23
+ driveItemNotFound: 'itemNotFound',
24
+ conflict: 'ErrorIrresolvableConflict',
25
+ unauthenticated: 'InvalidAuthenticationToken',
26
+ invalidUser: 'ErrorInvalidUser',
27
+ rateLimited: 'TooManyRequests',
28
+ unknownRoute: 'SyntheticRouteNotEmulated',
29
+ unsupportedQuery: 'SyntheticQueryNotEmulated',
30
+ unsupportedValue: 'SyntheticValueNotEmulated',
31
+ invalidRequest: 'SyntheticInvalidRequest',
32
+ invalidBody: 'SyntheticInvalidBody',
33
+ upstreamError: 'SyntheticUpstreamError'
34
+ } as const
35
+
36
+ export const codes = microsoftEmulatorErrorCodes
37
+
38
+ /** Graph JSON content type, as the fixtures record it. */
39
+ export const graphContentType =
40
+ 'application/json; odata.metadata=minimal; odata.streaming=true; IEEE754Compatible=false; charset=utf-8'
41
+
42
+ export const jsonResponse = (
43
+ status: number,
44
+ body: unknown,
45
+ headers: HeadersInit = {}
46
+ ): Response => {
47
+ const responseHeaders = new Headers(headers)
48
+
49
+ if (!responseHeaders.has('content-type')) responseHeaders.set('content-type', graphContentType)
50
+
51
+ return new Response(JSON.stringify(body), { status, headers: responseHeaders })
52
+ }
53
+
54
+ /** Graph API version prefix of every Graph route. */
55
+ export const microsoftEmulatorBasePath = '/v1.0'
56
+
57
+ /** A percent-decoded path segment, or `undefined` for invalid percent-encoding. */
58
+ export const decodeSegment = (segment: string): string | undefined => {
59
+ try {
60
+ return decodeURIComponent(segment)
61
+ } catch {
62
+ return undefined
63
+ }
64
+ }
65
+
66
+ /**
67
+ * An OData key segment as the fixtures write it in `@odata.context`: `('ada%40example.test')`,
68
+ * `('b%21synthetic-drive-0001')` (percent-encoded, including `!'()*`).
69
+ */
70
+ export const odataKey = (value: string): string =>
71
+ `('${encodeURIComponent(value).replace(
72
+ /[!'()*]/g,
73
+ character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`
74
+ )}')`
75
+
76
+ /** `(a,b,c)` for a `$select`, empty without one, as the fixture contexts write it. */
77
+ export const selectSuffix = (fields: ReadonlyArray<string> | undefined): string =>
78
+ fields === undefined ? '' : `(${fields.join(',')})`
79
+
80
+ /** The fixture `@odata.context` URL: `{graphOrigin}/v1.0/$metadata#{path}`. */
81
+ export const metadataContext = (env: MicrosoftApiEnv, path: string): string =>
82
+ `${env.graphOrigin}${microsoftEmulatorBasePath}/$metadata#${path}`
83
+
84
+ /** An entity body with its `@odata.context` first, as the fixtures record it. */
85
+ export const entity = (context: string, body: Schema.JsonObject): Schema.JsonObject => ({
86
+ '@odata.context': context,
87
+ ...body
88
+ })
89
+
90
+ /** A bodiless success (204 delete, 202 cancel/copy), as the fixtures record them. */
91
+ export const emptyResponse = (status: number, headers: HeadersInit = {}): Response =>
92
+ new Response(null, { status, headers })
93
+
94
+ /** Where a Graph error envelope's `innerError` values come from. */
95
+ export type ErrorContext = {
96
+ /** `innerError.date` (`YYYY-MM-DDTHH:MM:SS`, UTC). */
97
+ readonly date: string
98
+ readonly requestId: string
99
+ readonly clientRequestId: string
100
+ }
101
+
102
+ /** The Graph error envelope `{ error: { code, message, innerError } }`. */
103
+ export const graphError = (
104
+ context: ErrorContext,
105
+ status: number,
106
+ code: string,
107
+ message: string
108
+ ): Response =>
109
+ jsonResponse(status, {
110
+ error: {
111
+ code,
112
+ message,
113
+ innerError: {
114
+ date: context.date,
115
+ 'request-id': context.requestId,
116
+ 'client-request-id': context.clientRequestId
117
+ }
118
+ }
119
+ })
120
+
121
+ export const padded = (value: number, width: number): string => String(value).padStart(width, '0')
122
+
123
+ /** A synthetic request id for ledger sequence `seq`. */
124
+ export const syntheticRequestId = (seq: number): string =>
125
+ `00000000-0000-4000-8000-${padded(seq % 1_000_000_000_000, 12)}`
126
+
127
+ const clientRequestIdPattern = /^[A-Za-z0-9-]{1,64}$/
128
+
129
+ /** The error context of one request; `client-request-id` is echoed only when it is plain. */
130
+ export const errorContext = (
131
+ now: number,
132
+ seq: number,
133
+ clientRequestId: string | null
134
+ ): ErrorContext => {
135
+ const requestId = syntheticRequestId(seq)
136
+
137
+ return {
138
+ date: new Date(Math.floor(now / 1000) * 1000).toISOString().slice(0, 19),
139
+ requestId,
140
+ clientRequestId:
141
+ clientRequestId !== null && clientRequestIdPattern.test(clientRequestId)
142
+ ? clientRequestId
143
+ : requestId
144
+ }
145
+ }
146
+
147
+ /** The parsed `Prefer` preferences the emulator reads (others are ignored, as Graph does). */
148
+ export type Preferences = {
149
+ /** `IdType="ImmutableId"`: answer message ids in immutable form. */
150
+ readonly immutableId: boolean
151
+ /** `outlook.timezone`, when sent. */
152
+ readonly timezone: string | undefined
153
+ }
154
+
155
+ const unquote = (value: string): string =>
156
+ value.length >= 2 && value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value
157
+
158
+ /** Parse `Prefer`: comma-separated `name=value` preferences. */
159
+ export const parsePreferences = (header: string | null): Preferences => {
160
+ let immutableId = false
161
+ let timezone: string | undefined
162
+
163
+ for (const part of (header ?? '').split(',')) {
164
+ const separator = part.indexOf('=')
165
+ const name = (separator === -1 ? part : part.slice(0, separator)).trim().toLowerCase()
166
+ const value = separator === -1 ? '' : unquote(part.slice(separator + 1).trim())
167
+
168
+ if (name === 'idtype' && value.toLowerCase() === 'immutableid') immutableId = true
169
+
170
+ if (name === 'outlook.timezone') timezone = value
171
+ }
172
+
173
+ return { immutableId, timezone }
174
+ }
175
+
176
+ /** One routed API request, as the handlers see it. */
177
+ export type RouteRequest = {
178
+ /** Decoded path parameters. */
179
+ readonly params: Readonly<Record<string, string>>
180
+ /** Raw (still percent-encoded) path, for example `/v1.0/users/ada%40example.test/...`. */
181
+ readonly path: string
182
+ readonly query: URLSearchParams
183
+ /** Parsed JSON body; `undefined` when absent. */
184
+ readonly body: Schema.Json | undefined
185
+ readonly prefer: Preferences
186
+ /** A Graph error envelope response for this request. */
187
+ readonly error: (status: number, code: string, message: string) => Response
188
+ }
189
+
190
+ /** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
191
+ export type MicrosoftEmulatorDrills = {
192
+ /** `true`: `calendarView` answers an empty `value` for every range. */
193
+ readonly calendarRangeEmpty?: boolean
194
+ /** `true`: event create answers 201 without the new event's `id` (the event is still created). */
195
+ readonly createOmitsId?: boolean
196
+ /** Fractional digits of event `dateTime` values (Graph uses 7); `3` drills the precision case. */
197
+ readonly timestampPrecisionDigits?: number
198
+ /** `true`: folder message pages never carry `@odata.nextLink`. */
199
+ readonly omitNextLink?: boolean
200
+ }
201
+
202
+ /** A OneDrive copy the monitor reports on (runtime-only, like the ledger; not in the state). */
203
+ export type CopyMonitor = {
204
+ readonly id: string
205
+ readonly sourceId: string
206
+ readonly destinationParentId: string
207
+ /** In-progress answers left before the copy runs and completes. */
208
+ pollsLeft: number
209
+ /** Set once the copy ran and completed. */
210
+ resourceId: string | undefined
211
+ }
212
+
213
+ export type MicrosoftApiEnv = {
214
+ /** Clock in epoch milliseconds (timestamps of created items). */
215
+ readonly now: () => number
216
+ /** Origin of `@odata.nextLink` values, for example `https://graph.microsoft.com`. */
217
+ readonly graphOrigin: string
218
+ /** Origin of OneDrive `webUrl` values and copy monitor URLs. */
219
+ readonly sharePointOrigin: string
220
+ readonly drills: Required<MicrosoftEmulatorDrills>
221
+ /** In-progress monitor answers before a copy completes (default 0: the first poll completes). */
222
+ readonly copyInProgressPolls: number
223
+ /** How long a message write holds its message; a concurrent write in that time loses (409). */
224
+ readonly conflictWindowMs: number
225
+ /** Immutable ids of messages with a write in flight (runtime-only). */
226
+ readonly messageLocks: Set<string>
227
+ /** Copy monitors by id (runtime-only; cleared by reset and seed). */
228
+ readonly monitors: Map<string, CopyMonitor>
229
+ readonly monitorCounter: { next: number }
230
+ }
231
+
232
+ /**
233
+ * A message write's answer and the message it holds once committed: the write holds it for
234
+ * `conflictWindowMs` before answering, so an overlapping write to it gets 409.
235
+ */
236
+ export type HeldAnswer = {
237
+ readonly response: Response
238
+ /** Immutable id of the message held. */
239
+ readonly holds: string
240
+ }
241
+
242
+ /**
243
+ * A route handler. It runs on a draft of the state and of the copy monitors (see
244
+ * `registerMicrosoftApi`) and decides the whole answer synchronously; it replaces whole state lists
245
+ * and counters, never editing them in place. Any answer that is not 2xx is a refusal.
246
+ */
247
+ export type RouteHandler = (
248
+ state: MicrosoftEmulatorState,
249
+ request: RouteRequest,
250
+ env: MicrosoftApiEnv
251
+ ) => Response | HeldAnswer
252
+
253
+ export const isJsonObject = (value: Schema.Json | undefined): value is Schema.JsonObject =>
254
+ value !== undefined && value !== null && Predicate.isObject(value) && !Array.isArray(value)
255
+
256
+ const decodeJsonText = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Json))
257
+
258
+ /** Parsed JSON, or `undefined` for invalid JSON. */
259
+ export const parseJsonText = (text: string): Schema.Json | undefined => {
260
+ const result = decodeJsonText(text)
261
+
262
+ return Result.isSuccess(result) ? result.success : undefined
263
+ }
264
+
265
+ /** The user a `/users/{userId}` segment names (id, `mail`, or UPN, case-insensitive). */
266
+ export const resolveUser = (
267
+ state: MicrosoftEmulatorState,
268
+ request: RouteRequest
269
+ ): MicrosoftEmulatorUser | Response => {
270
+ const userId = (request.params.userId ?? '').toLowerCase()
271
+ const user = state.user
272
+
273
+ return [user.id, user.mail, user.userPrincipalName].some(
274
+ candidate => candidate.toLowerCase() === userId
275
+ )
276
+ ? user
277
+ : request.error(404, codes.invalidUser, 'Synthetic: the requested user is not emulated.')
278
+ }
279
+
280
+ /** The `$select` fields, `undefined` without `$select`, or a 400 for a field not emulated. */
281
+ export const selectedFields = (
282
+ request: RouteRequest,
283
+ allowed: ReadonlyArray<string>
284
+ ): ReadonlyArray<string> | undefined | Response => {
285
+ const raw = request.query.get('$select')
286
+
287
+ if (raw === null) return undefined
288
+
289
+ const fields = raw.split(',').map(field => field.trim())
290
+ const unknown = fields.find(field => !allowed.includes(field))
291
+
292
+ return unknown === undefined
293
+ ? fields
294
+ : request.error(
295
+ 400,
296
+ codes.unsupportedQuery,
297
+ `Synthetic: $select field '${unknown}' is not emulated on this resource.`
298
+ )
299
+ }
300
+
301
+ /** Annotations and the id are always returned, whatever `$select` names. */
302
+ const alwaysReturned: ReadonlyArray<string> = [
303
+ '@odata.etag',
304
+ '@odata.type',
305
+ '@odata.mediaContentType',
306
+ 'id'
307
+ ]
308
+
309
+ /** Keep only the selected keys (plus annotations and `id`); everything without `$select`. */
310
+ export const project = (
311
+ full: Schema.JsonObject,
312
+ fields: ReadonlyArray<string> | undefined
313
+ ): Schema.JsonObject =>
314
+ fields === undefined
315
+ ? full
316
+ : Object.fromEntries(
317
+ Object.entries(full).filter(([key]) => alwaysReturned.includes(key) || fields.includes(key))
318
+ )
319
+
320
+ const integerQuery = (
321
+ request: RouteRequest,
322
+ key: string,
323
+ minimum: number,
324
+ maximum: number
325
+ ): number | undefined | Response => {
326
+ const raw = request.query.get(key)
327
+
328
+ if (raw === null) return undefined
329
+
330
+ const value = Number(raw)
331
+
332
+ return /^\d+$/.test(raw) && value >= minimum && value <= maximum
333
+ ? value
334
+ : request.error(
335
+ 400,
336
+ codes.invalidRequest,
337
+ `Synthetic: ${key} must be an integer from ${minimum} to ${maximum}.`
338
+ )
339
+ }
340
+
341
+ /**
342
+ * The required `$top` of a collection whose fixtures always send it: a positive integer (400
343
+ * invalid otherwise), and 400 not emulated when it is missing or above `maxTop`, the largest
344
+ * `$top` any fixture sends to that route.
345
+ */
346
+ const requiredTop = (request: RouteRequest, maxTop: number): number | Response => {
347
+ const top = integerQuery(request, '$top', 1, Number.MAX_SAFE_INTEGER)
348
+
349
+ if (top instanceof Response) return top
350
+
351
+ if (top === undefined) {
352
+ return notEmulated(request, 'requests without $top are not emulated on this collection.')
353
+ }
354
+
355
+ return top > maxTop
356
+ ? notEmulated(
357
+ request,
358
+ `$top above ${maxTop} (the largest page size a fixture sends) is not emulated on this collection.`
359
+ )
360
+ : top
361
+ }
362
+
363
+ export type Page<A> = {
364
+ readonly items: ReadonlyArray<A>
365
+ readonly top: number
366
+ /** `$skip` of the next page, when there is one. */
367
+ readonly nextSkip: number | undefined
368
+ }
369
+
370
+ /**
371
+ * One `$top`/`$skip` page of `items`. `$top` is required and at most `maxTop` (see
372
+ * `requiredTop`); no fixture records a default page size.
373
+ */
374
+ export const pageOf = <A>(
375
+ items: ReadonlyArray<A>,
376
+ request: RouteRequest,
377
+ maxTop: number
378
+ ): Page<A> | Response => {
379
+ const top = requiredTop(request, maxTop)
380
+
381
+ if (top instanceof Response) return top
382
+
383
+ const skip = integerQuery(request, '$skip', 0, Number.MAX_SAFE_INTEGER)
384
+
385
+ if (skip instanceof Response) return skip
386
+
387
+ const start = skip ?? 0
388
+ const end = start + top
389
+
390
+ return {
391
+ items: items.slice(start, end),
392
+ top,
393
+ nextSkip: end < items.length ? end : undefined
394
+ }
395
+ }
396
+
397
+ /**
398
+ * A collection whose fixtures send `$top` and never page (calendar views, children listings):
399
+ * the items, or 400 not emulated without `$top`, with `$top` above `maxTop` (see `requiredTop`),
400
+ * or with more items than `$top` (no fixture records a default page size or a second page of
401
+ * these collections).
402
+ */
403
+ export const singlePage = <A>(
404
+ items: ReadonlyArray<A>,
405
+ request: RouteRequest,
406
+ maxTop: number
407
+ ): ReadonlyArray<A> | Response => {
408
+ const top = requiredTop(request, maxTop)
409
+
410
+ if (top instanceof Response) return top
411
+
412
+ return items.length > top
413
+ ? notEmulated(
414
+ request,
415
+ `more than $top (${top}) results: paging this collection is not emulated.`
416
+ )
417
+ : items
418
+ }
419
+
420
+ /**
421
+ * The `@odata.nextLink` of a page: the configured Graph origin, the request's raw path (so
422
+ * `/users/ada%40example.test` keeps its `%40`), the carried query keys, then `$top` and `$skip`,
423
+ * encoded like the fixtures (`%24select=id%2Csubject...`). `undefined` on the last page or when
424
+ * the `omitNextLink` drill knob is set. Clients follow it unchanged (opaque).
425
+ */
426
+ export const nextLinkOf = (
427
+ env: MicrosoftApiEnv,
428
+ request: RouteRequest,
429
+ page: Page<unknown>,
430
+ carried: ReadonlyArray<string>
431
+ ): string | undefined => {
432
+ if (page.nextSkip === undefined || env.drills.omitNextLink) return undefined
433
+
434
+ const params = new URLSearchParams()
435
+
436
+ for (const key of carried) {
437
+ const value = request.query.get(key)
438
+
439
+ if (value !== null) params.set(key, value)
440
+ }
441
+
442
+ params.set('$top', String(page.top))
443
+ params.set('$skip', String(page.nextSkip))
444
+
445
+ return `${env.graphOrigin}${request.path}?${params.toString()}`
446
+ }
447
+
448
+ /** `{ @odata.context, value, @odata.nextLink? }`, as the fixture pages record them. */
449
+ export const collection = (
450
+ context: string,
451
+ value: ReadonlyArray<unknown>,
452
+ nextLink: string | undefined
453
+ ) =>
454
+ nextLink === undefined
455
+ ? { '@odata.context': context, value }
456
+ : { '@odata.context': context, value, '@odata.nextLink': nextLink }
457
+
458
+ /** A write body that is a JSON object with only `allowed` keys, or a 400. */
459
+ export const bodyObject = (
460
+ request: RouteRequest,
461
+ allowed: ReadonlyArray<string>
462
+ ): Schema.JsonObject | Response => {
463
+ if (!isJsonObject(request.body)) {
464
+ return request.error(
465
+ 400,
466
+ codes.invalidBody,
467
+ 'Synthetic: the request body must be a JSON object.'
468
+ )
469
+ }
470
+
471
+ const unknown = Object.keys(request.body).find(key => !allowed.includes(key))
472
+
473
+ return unknown === undefined
474
+ ? request.body
475
+ : request.error(
476
+ 400,
477
+ codes.unsupportedValue,
478
+ `Synthetic: property '${unknown}' is not emulated on this request.`
479
+ )
480
+ }
481
+
482
+ /** 400 for an invalid value. */
483
+ export const invalidValue = (request: RouteRequest, message: string): Response =>
484
+ request.error(400, codes.invalidRequest, `Synthetic: ${message}`)
485
+
486
+ /** 400 for a valid Graph request the emulator does not emulate (fail closed). */
487
+ export const notEmulated = (request: RouteRequest, message: string): Response =>
488
+ request.error(400, codes.unsupportedValue, `Synthetic: ${message}`)
489
+
490
+ /**
491
+ * A nested JSON object with only `allowed` keys (fail closed on anything else, before any write),
492
+ * or a 400. `expected` names the expected object in the error message.
493
+ */
494
+ export const nestedObject = (
495
+ request: RouteRequest,
496
+ value: Schema.Json | undefined,
497
+ allowed: ReadonlyArray<string>,
498
+ expected: string
499
+ ): Schema.JsonObject | Response => {
500
+ if (!isJsonObject(value)) return invalidValue(request, `expected ${expected}.`)
501
+
502
+ const unknown = Object.keys(value).find(key => !allowed.includes(key))
503
+
504
+ return unknown === undefined
505
+ ? value
506
+ : notEmulated(request, `property '${unknown}' is not emulated in ${expected}.`)
507
+ }
508
+
509
+ /**
510
+ * An `itemBody` write value, `{ contentType, content }` with only those keys. The fixtures write
511
+ * text bodies only (`Text`/`text`); any other content type is not emulated.
512
+ */
513
+ export const textBody = (
514
+ request: RouteRequest,
515
+ value: Schema.Json | undefined
516
+ ): string | Response => {
517
+ const body = nestedObject(
518
+ request,
519
+ value,
520
+ ['contentType', 'content'],
521
+ 'body { contentType, content }'
522
+ )
523
+
524
+ if (body instanceof Response) return body
525
+
526
+ if (!Predicate.isString(body.contentType) || !Predicate.isString(body.content)) {
527
+ return invalidValue(request, 'body must be { contentType, content } with string values.')
528
+ }
529
+
530
+ return body.contentType.toLowerCase() === 'text'
531
+ ? body.content
532
+ : notEmulated(request, `body contentType ${body.contentType} is not emulated (text only).`)
533
+ }
534
+
535
+ /** A Graph timestamp (`2026-09-29T10:00:00Z`) from the emulator clock. */
536
+ export const nowTimestamp = (env: MicrosoftApiEnv): string =>
537
+ new Date(Math.floor(env.now() / 1000) * 1000).toISOString().replace('.000Z', 'Z')
538
+
539
+ /** The next change key (bumps the counter). */
540
+ export const nextChangeKey = (state: MicrosoftEmulatorState, prefix: string): string => {
541
+ const number = state.counters.nextChangeKeyNumber
542
+
543
+ state.counters = { ...state.counters, nextChangeKeyNumber: number + 1 }
544
+
545
+ return `${prefix}${padded(number, 4)}`
546
+ }
547
+
548
+ /** The `personal/{site}` segment of the mailbox owner (`ada@example.test` → `ada_example_test`). */
549
+ export const personalSite = (user: MicrosoftEmulatorUser): string =>
550
+ user.mail.toLowerCase().replace(/[^a-z0-9]/g, '_')