@yolk-sdk/emulators 0.1.0-canary.96

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +2135 -0
  3. package/dist/anthropic.d.mts +269 -0
  4. package/dist/anthropic.d.mts.map +1 -0
  5. package/dist/anthropic.mjs +177 -0
  6. package/dist/anthropic.mjs.map +1 -0
  7. package/dist/chat-completions.d.mts +288 -0
  8. package/dist/chat-completions.d.mts.map +1 -0
  9. package/dist/chat-completions.mjs +451 -0
  10. package/dist/chat-completions.mjs.map +1 -0
  11. package/dist/codex.d.mts +265 -0
  12. package/dist/codex.d.mts.map +1 -0
  13. package/dist/codex.mjs +199 -0
  14. package/dist/codex.mjs.map +1 -0
  15. package/dist/dropbox/api.d.mts +63 -0
  16. package/dist/dropbox/api.d.mts.map +1 -0
  17. package/dist/dropbox/api.mjs +565 -0
  18. package/dist/dropbox/api.mjs.map +1 -0
  19. package/dist/dropbox/state.d.mts +136 -0
  20. package/dist/dropbox/state.d.mts.map +1 -0
  21. package/dist/dropbox/state.mjs +209 -0
  22. package/dist/dropbox/state.mjs.map +1 -0
  23. package/dist/dropbox.d.mts +79 -0
  24. package/dist/dropbox.d.mts.map +1 -0
  25. package/dist/dropbox.mjs +124 -0
  26. package/dist/dropbox.mjs.map +1 -0
  27. package/dist/email-fixtures.d.mts +36 -0
  28. package/dist/email-fixtures.d.mts.map +1 -0
  29. package/dist/email-fixtures.mjs +1080 -0
  30. package/dist/email-fixtures.mjs.map +1 -0
  31. package/dist/email.d.mts +160 -0
  32. package/dist/email.d.mts.map +1 -0
  33. package/dist/email.mjs +608 -0
  34. package/dist/email.mjs.map +1 -0
  35. package/dist/emulator-compose.d.mts +40 -0
  36. package/dist/emulator-compose.d.mts.map +1 -0
  37. package/dist/emulator-compose.mjs +91 -0
  38. package/dist/emulator-compose.mjs.map +1 -0
  39. package/dist/emulator-http.d.mts +53 -0
  40. package/dist/emulator-http.d.mts.map +1 -0
  41. package/dist/emulator-http.mjs +117 -0
  42. package/dist/emulator-http.mjs.map +1 -0
  43. package/dist/emulator-kernel.d.mts +176 -0
  44. package/dist/emulator-kernel.d.mts.map +1 -0
  45. package/dist/emulator-kernel.mjs +413 -0
  46. package/dist/emulator-kernel.mjs.map +1 -0
  47. package/dist/fixture-route.d.mts +129 -0
  48. package/dist/fixture-route.d.mts.map +1 -0
  49. package/dist/fixture-route.mjs +257 -0
  50. package/dist/fixture-route.mjs.map +1 -0
  51. package/dist/fortnox/api.d.mts +92 -0
  52. package/dist/fortnox/api.d.mts.map +1 -0
  53. package/dist/fortnox/api.mjs +750 -0
  54. package/dist/fortnox/api.mjs.map +1 -0
  55. package/dist/fortnox/state.d.mts +533 -0
  56. package/dist/fortnox/state.d.mts.map +1 -0
  57. package/dist/fortnox/state.mjs +612 -0
  58. package/dist/fortnox/state.mjs.map +1 -0
  59. package/dist/fortnox.d.mts +128 -0
  60. package/dist/fortnox.d.mts.map +1 -0
  61. package/dist/fortnox.mjs +403 -0
  62. package/dist/fortnox.mjs.map +1 -0
  63. package/dist/gateway-evaluate-recordings.d.mts +12 -0
  64. package/dist/gateway-evaluate-recordings.d.mts.map +1 -0
  65. package/dist/gateway-evaluate-recordings.mjs +128 -0
  66. package/dist/gateway-evaluate-recordings.mjs.map +1 -0
  67. package/dist/gateway.d.mts +272 -0
  68. package/dist/gateway.d.mts.map +1 -0
  69. package/dist/gateway.mjs +400 -0
  70. package/dist/gateway.mjs.map +1 -0
  71. package/dist/github/api.d.mts +63 -0
  72. package/dist/github/api.d.mts.map +1 -0
  73. package/dist/github/api.mjs +527 -0
  74. package/dist/github/api.mjs.map +1 -0
  75. package/dist/github/state.d.mts +272 -0
  76. package/dist/github/state.d.mts.map +1 -0
  77. package/dist/github/state.mjs +303 -0
  78. package/dist/github/state.mjs.map +1 -0
  79. package/dist/github.d.mts +77 -0
  80. package/dist/github.d.mts.map +1 -0
  81. package/dist/github.mjs +180 -0
  82. package/dist/github.mjs.map +1 -0
  83. package/dist/google/calendar.d.mts +10 -0
  84. package/dist/google/calendar.d.mts.map +1 -0
  85. package/dist/google/calendar.mjs +252 -0
  86. package/dist/google/calendar.mjs.map +1 -0
  87. package/dist/google/drive.d.mts +8 -0
  88. package/dist/google/drive.d.mts.map +1 -0
  89. package/dist/google/drive.mjs +252 -0
  90. package/dist/google/drive.mjs.map +1 -0
  91. package/dist/google/gmail.d.mts +10 -0
  92. package/dist/google/gmail.d.mts.map +1 -0
  93. package/dist/google/gmail.mjs +591 -0
  94. package/dist/google/gmail.mjs.map +1 -0
  95. package/dist/google/shared.d.mts +107 -0
  96. package/dist/google/shared.d.mts.map +1 -0
  97. package/dist/google/shared.mjs +146 -0
  98. package/dist/google/shared.mjs.map +1 -0
  99. package/dist/google/state.d.mts +385 -0
  100. package/dist/google/state.d.mts.map +1 -0
  101. package/dist/google/state.mjs +496 -0
  102. package/dist/google/state.mjs.map +1 -0
  103. package/dist/google.d.mts +75 -0
  104. package/dist/google.d.mts.map +1 -0
  105. package/dist/google.mjs +210 -0
  106. package/dist/google.mjs.map +1 -0
  107. package/dist/linkedin-search/api.d.mts +45 -0
  108. package/dist/linkedin-search/api.d.mts.map +1 -0
  109. package/dist/linkedin-search/api.mjs +181 -0
  110. package/dist/linkedin-search/api.mjs.map +1 -0
  111. package/dist/linkedin-search/state.d.mts +123 -0
  112. package/dist/linkedin-search/state.d.mts.map +1 -0
  113. package/dist/linkedin-search/state.mjs +261 -0
  114. package/dist/linkedin-search/state.mjs.map +1 -0
  115. package/dist/linkedin-search.d.mts +69 -0
  116. package/dist/linkedin-search.d.mts.map +1 -0
  117. package/dist/linkedin-search.mjs +158 -0
  118. package/dist/linkedin-search.mjs.map +1 -0
  119. package/dist/mcp/api.d.mts +44 -0
  120. package/dist/mcp/api.d.mts.map +1 -0
  121. package/dist/mcp/api.mjs +557 -0
  122. package/dist/mcp/api.mjs.map +1 -0
  123. package/dist/mcp/recordings.d.mts +40 -0
  124. package/dist/mcp/recordings.d.mts.map +1 -0
  125. package/dist/mcp/recordings.mjs +2390 -0
  126. package/dist/mcp/recordings.mjs.map +1 -0
  127. package/dist/mcp/state.d.mts +71 -0
  128. package/dist/mcp/state.d.mts.map +1 -0
  129. package/dist/mcp/state.mjs +83 -0
  130. package/dist/mcp/state.mjs.map +1 -0
  131. package/dist/mcp.d.mts +84 -0
  132. package/dist/mcp.d.mts.map +1 -0
  133. package/dist/mcp.mjs +241 -0
  134. package/dist/mcp.mjs.map +1 -0
  135. package/dist/messages.d.mts +183 -0
  136. package/dist/messages.d.mts.map +1 -0
  137. package/dist/messages.mjs +532 -0
  138. package/dist/messages.mjs.map +1 -0
  139. package/dist/microsoft/api.d.mts +47 -0
  140. package/dist/microsoft/api.d.mts.map +1 -0
  141. package/dist/microsoft/api.mjs +178 -0
  142. package/dist/microsoft/api.mjs.map +1 -0
  143. package/dist/microsoft/calendar.d.mts +30 -0
  144. package/dist/microsoft/calendar.d.mts.map +1 -0
  145. package/dist/microsoft/calendar.mjs +271 -0
  146. package/dist/microsoft/calendar.mjs.map +1 -0
  147. package/dist/microsoft/drive.d.mts +36 -0
  148. package/dist/microsoft/drive.d.mts.map +1 -0
  149. package/dist/microsoft/drive.mjs +298 -0
  150. package/dist/microsoft/drive.mjs.map +1 -0
  151. package/dist/microsoft/graph.d.mts +198 -0
  152. package/dist/microsoft/graph.d.mts.map +1 -0
  153. package/dist/microsoft/graph.mjs +264 -0
  154. package/dist/microsoft/graph.mjs.map +1 -0
  155. package/dist/microsoft/mail.d.mts +48 -0
  156. package/dist/microsoft/mail.d.mts.map +1 -0
  157. package/dist/microsoft/mail.mjs +488 -0
  158. package/dist/microsoft/mail.mjs.map +1 -0
  159. package/dist/microsoft/state.d.mts +480 -0
  160. package/dist/microsoft/state.d.mts.map +1 -0
  161. package/dist/microsoft/state.mjs +625 -0
  162. package/dist/microsoft/state.mjs.map +1 -0
  163. package/dist/microsoft.d.mts +168 -0
  164. package/dist/microsoft.d.mts.map +1 -0
  165. package/dist/microsoft.mjs +483 -0
  166. package/dist/microsoft.mjs.map +1 -0
  167. package/dist/node.d.mts +47 -0
  168. package/dist/node.d.mts.map +1 -0
  169. package/dist/node.mjs +178 -0
  170. package/dist/node.mjs.map +1 -0
  171. package/dist/notion/api.d.mts +51 -0
  172. package/dist/notion/api.d.mts.map +1 -0
  173. package/dist/notion/api.mjs +507 -0
  174. package/dist/notion/api.mjs.map +1 -0
  175. package/dist/notion/state.d.mts +329 -0
  176. package/dist/notion/state.d.mts.map +1 -0
  177. package/dist/notion/state.mjs +432 -0
  178. package/dist/notion/state.mjs.map +1 -0
  179. package/dist/notion.d.mts +72 -0
  180. package/dist/notion.d.mts.map +1 -0
  181. package/dist/notion.mjs +127 -0
  182. package/dist/notion.mjs.map +1 -0
  183. package/dist/openai.d.mts +165 -0
  184. package/dist/openai.d.mts.map +1 -0
  185. package/dist/openai.mjs +141 -0
  186. package/dist/openai.mjs.map +1 -0
  187. package/dist/opencode-recordings.d.mts +7 -0
  188. package/dist/opencode-recordings.d.mts.map +1 -0
  189. package/dist/opencode-recordings.mjs +222 -0
  190. package/dist/opencode-recordings.mjs.map +1 -0
  191. package/dist/opencode.d.mts +70 -0
  192. package/dist/opencode.d.mts.map +1 -0
  193. package/dist/opencode.mjs +217 -0
  194. package/dist/opencode.mjs.map +1 -0
  195. package/dist/r2-fixtures.d.mts +36 -0
  196. package/dist/r2-fixtures.d.mts.map +1 -0
  197. package/dist/r2-fixtures.mjs +245 -0
  198. package/dist/r2-fixtures.mjs.map +1 -0
  199. package/dist/r2-guard.d.mts +43 -0
  200. package/dist/r2-guard.d.mts.map +1 -0
  201. package/dist/r2-guard.mjs +215 -0
  202. package/dist/r2-guard.mjs.map +1 -0
  203. package/dist/r2.d.mts +166 -0
  204. package/dist/r2.d.mts.map +1 -0
  205. package/dist/r2.mjs +517 -0
  206. package/dist/r2.mjs.map +1 -0
  207. package/dist/responses.d.mts +231 -0
  208. package/dist/responses.d.mts.map +1 -0
  209. package/dist/responses.mjs +556 -0
  210. package/dist/responses.mjs.map +1 -0
  211. package/dist/route-evidence.d.mts +45 -0
  212. package/dist/route-evidence.d.mts.map +1 -0
  213. package/dist/route-evidence.mjs +56 -0
  214. package/dist/route-evidence.mjs.map +1 -0
  215. package/dist/router.d.mts +93 -0
  216. package/dist/router.d.mts.map +1 -0
  217. package/dist/router.mjs +259 -0
  218. package/dist/router.mjs.map +1 -0
  219. package/dist/stateful-core.d.mts +17 -0
  220. package/dist/stateful-core.d.mts.map +1 -0
  221. package/dist/stateful-core.mjs +41 -0
  222. package/dist/stateful-core.mjs.map +1 -0
  223. package/dist/stateful-emulator.d.mts +565 -0
  224. package/dist/stateful-emulator.d.mts.map +1 -0
  225. package/dist/stateful-emulator.mjs +1228 -0
  226. package/dist/stateful-emulator.mjs.map +1 -0
  227. package/dist/stateful-secrets.d.mts +84 -0
  228. package/dist/stateful-secrets.d.mts.map +1 -0
  229. package/dist/stateful-secrets.mjs +216 -0
  230. package/dist/stateful-secrets.mjs.map +1 -0
  231. package/dist/subscription-usage-recordings.d.mts +9 -0
  232. package/dist/subscription-usage-recordings.d.mts.map +1 -0
  233. package/dist/subscription-usage-recordings.mjs +53 -0
  234. package/dist/subscription-usage-recordings.mjs.map +1 -0
  235. package/dist/subscription-usage.d.mts +53 -0
  236. package/dist/subscription-usage.d.mts.map +1 -0
  237. package/dist/subscription-usage.mjs +21 -0
  238. package/dist/subscription-usage.mjs.map +1 -0
  239. package/dist/telegram/api.d.mts +34 -0
  240. package/dist/telegram/api.d.mts.map +1 -0
  241. package/dist/telegram/api.mjs +257 -0
  242. package/dist/telegram/api.mjs.map +1 -0
  243. package/dist/telegram/state.d.mts +128 -0
  244. package/dist/telegram/state.d.mts.map +1 -0
  245. package/dist/telegram/state.mjs +154 -0
  246. package/dist/telegram/state.mjs.map +1 -0
  247. package/dist/telegram.d.mts +62 -0
  248. package/dist/telegram.d.mts.map +1 -0
  249. package/dist/telegram.mjs +127 -0
  250. package/dist/telegram.mjs.map +1 -0
  251. package/dist/todoist/api.d.mts +55 -0
  252. package/dist/todoist/api.d.mts.map +1 -0
  253. package/dist/todoist/api.mjs +415 -0
  254. package/dist/todoist/api.mjs.map +1 -0
  255. package/dist/todoist/state.d.mts +275 -0
  256. package/dist/todoist/state.d.mts.map +1 -0
  257. package/dist/todoist/state.mjs +345 -0
  258. package/dist/todoist/state.mjs.map +1 -0
  259. package/dist/todoist.d.mts +68 -0
  260. package/dist/todoist.d.mts.map +1 -0
  261. package/dist/todoist.mjs +181 -0
  262. package/dist/todoist.mjs.map +1 -0
  263. package/dist/xai.d.mts +267 -0
  264. package/dist/xai.d.mts.map +1 -0
  265. package/dist/xai.mjs +252 -0
  266. package/dist/xai.mjs.map +1 -0
  267. package/package.json +157 -0
  268. package/src/anthropic.ts +289 -0
  269. package/src/chat-completions.ts +924 -0
  270. package/src/codex.ts +296 -0
  271. package/src/dropbox/api.ts +1084 -0
  272. package/src/dropbox/state.ts +364 -0
  273. package/src/dropbox.ts +203 -0
  274. package/src/email-fixtures.ts +1297 -0
  275. package/src/email.ts +1108 -0
  276. package/src/emulator-compose.ts +147 -0
  277. package/src/emulator-http.ts +184 -0
  278. package/src/emulator-kernel.ts +844 -0
  279. package/src/fixture-route.ts +561 -0
  280. package/src/fortnox/api.ts +1352 -0
  281. package/src/fortnox/state.ts +798 -0
  282. package/src/fortnox.ts +801 -0
  283. package/src/gateway-evaluate-recordings.ts +153 -0
  284. package/src/gateway.ts +530 -0
  285. package/src/github/api.ts +986 -0
  286. package/src/github/state.ts +439 -0
  287. package/src/github.ts +271 -0
  288. package/src/google/calendar.ts +486 -0
  289. package/src/google/drive.ts +471 -0
  290. package/src/google/gmail.ts +1011 -0
  291. package/src/google/shared.ts +321 -0
  292. package/src/google/state.ts +686 -0
  293. package/src/google.ts +298 -0
  294. package/src/linkedin-search/api.ts +363 -0
  295. package/src/linkedin-search/state.ts +373 -0
  296. package/src/linkedin-search.ts +241 -0
  297. package/src/mcp/api.ts +995 -0
  298. package/src/mcp/recordings.ts +2665 -0
  299. package/src/mcp/state.ts +125 -0
  300. package/src/mcp.ts +319 -0
  301. package/src/messages.ts +844 -0
  302. package/src/microsoft/api.ts +426 -0
  303. package/src/microsoft/calendar.ts +491 -0
  304. package/src/microsoft/drive.ts +541 -0
  305. package/src/microsoft/graph.ts +550 -0
  306. package/src/microsoft/mail.ts +833 -0
  307. package/src/microsoft/state.ts +822 -0
  308. package/src/microsoft.ts +982 -0
  309. package/src/node.ts +293 -0
  310. package/src/notion/api.ts +976 -0
  311. package/src/notion/state.ts +512 -0
  312. package/src/notion.ts +210 -0
  313. package/src/openai.ts +198 -0
  314. package/src/opencode-recordings.ts +257 -0
  315. package/src/opencode.ts +271 -0
  316. package/src/r2-fixtures.ts +295 -0
  317. package/src/r2-guard.ts +323 -0
  318. package/src/r2.ts +890 -0
  319. package/src/responses.ts +901 -0
  320. package/src/route-evidence.ts +90 -0
  321. package/src/router.ts +490 -0
  322. package/src/stateful-core.ts +70 -0
  323. package/src/stateful-emulator.ts +2571 -0
  324. package/src/stateful-secrets.ts +299 -0
  325. package/src/subscription-usage-recordings.ts +77 -0
  326. package/src/subscription-usage.ts +68 -0
  327. package/src/telegram/api.ts +437 -0
  328. package/src/telegram/state.ts +229 -0
  329. package/src/telegram.ts +227 -0
  330. package/src/todoist/api.ts +736 -0
  331. package/src/todoist/state.ts +456 -0
  332. package/src/todoist.ts +316 -0
  333. package/src/xai.ts +341 -0
package/src/google.ts ADDED
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Stateful Google emulator (the Gmail, Calendar, and Drive routes the Google conformance cases
3
+ * send), built on the upstream `@emulators/core` custom runtime, with a request ledger, status
4
+ * faults, and an `/_emulate/*` control plane.
5
+ *
6
+ * It never imports SDK code: its wire shapes, error envelopes, and default seed are copied as data
7
+ * from the synthetic Google conformance fixtures, and every route names the conformance cases it
8
+ * follows in `googleEmulatorRoutes`. Only the routes those thirteen cases (with their cleanup)
9
+ * send are emulated: Gmail on `https://gmail.googleapis.com` (`/gmail/v1/users/me/...` and the
10
+ * multipart send upload), Calendar and Drive on `https://www.googleapis.com` (`/calendar/v3` and
11
+ * `/drive/v3`). Each route answers only on its recorded origin (`fetchOn(origin)` serves one
12
+ * origin behind a loopback rewrite). Response behaviour comes only from the fixtures: anything
13
+ * they do not show answers one ledgered 400 not-emulated, writes nothing, and uses up no fault.
14
+ * That includes an empty listing and every read of the leftover lookup
15
+ * (`findGoogleConformanceLeftovers`, whose label listing, draft search, and free-text event query
16
+ * no fixture records): the lookup fails, and runners print their lookup-failed WARN.
17
+ *
18
+ * The practice send (`google.gmail.send-practice-address`) is irreversible on Gmail. Here it is
19
+ * only recorded in the state (the sent message reads back; nothing is delivered anywhere), and only
20
+ * the recorded 7-bit message whose sole recipient header is `To: <the seeded practice address>`
21
+ * is accepted, and only while that address is the recorded `practice@example.test` (the recorded
22
+ * `sizeEstimate` covers it, as it covers the draft and send subjects, which therefore take only a
23
+ * run id of the fixtures' 13-character length).
24
+ *
25
+ * Fail closed: Google follows the shared fail-closed rule of `src/stateful-emulator.ts` (its opt-in
26
+ * `failClosed` mode, the same rule as `/github`). Every route parameter has a raw pattern matched
27
+ * in full, so a request is recognised only when its raw path is exactly an emulated route shape
28
+ * under that route's method and any `Authorization` header is exactly `Bearer <token>` with a
29
+ * recognisable bearer (below); every other request is ledgered and answered with constant text only
30
+ * (`/<unrecognised>`, a standard method or `<other>`, an empty query, no body, a constant reason).
31
+ * The bearer value is never compared against anything, stored, forwarded, or ledgered. A recognised
32
+ * request that repeats it in its raw path, any path segment, the query or any query key or value,
33
+ * the recorded `content-type` header, or its raw body (the multipart send body included), or, on
34
+ * the draft compose and update routes, in the base64url-decoded MIME of `message.raw` (Gmail's own
35
+ * wire format wraps the draft this way; the routes give it to the wrapper as a decoded view), is
36
+ * ledgered as the constant credential-repeat entry, checked through the wrapper's fixpoint closure
37
+ * of a tolerant percent-decode and a tolerant JSON-unescape; any other recognised request has the
38
+ * bearer scrubbed from its ledgered fields and every not-emulated reason. A `message.raw` the route
39
+ * would refuse (anything but canonical unpadded base64url of exactly the recorded draft MIME, the
40
+ * run id aside) makes the view throw a `DecodedViewRefusal` with one of the route's declared
41
+ * constant reasons (`viewRefusalReasons`: the canonical-base64url reason, the
42
+ * other-than-the-recorded-run-draft reason, the 13-character run-id reason, the extra-key reason),
43
+ * which the wrapper ledgers in the constant entry before anything is recorded (or
44
+ * `the request body repeats the credential` when the raw decodes cleanly to text holding the
45
+ * bearer), so a refused `message.raw` never reaches the ledger. Google refusals use the wrapper's
46
+ * constant-reason `exactQuery` and `exactBodyKeys`, so they never echo a request's own query or
47
+ * body keys.
48
+ *
49
+ * Request-shape latitude (`/google`, the only accepted deviations): any bearer value in the RFC
50
+ * 6750 `b64token` syntax (`[A-Za-z0-9\-._~+/]+=*`) of at least 8 characters, starting with a
51
+ * character in `[G-Zg-z\-._~+/]` other than `n`, `r`, `t`, `u`, with at least one outside
52
+ * `[0-9.eE+-]` (Google's `ya29.…` access tokens qualify), that occurs nowhere else in the request
53
+ * (never compared against anything, stored, or ledgered); extra request headers (except
54
+ * `X-Goog-Drive-Resource-Keys`, which no fixture sends); JSON key order; `content-type` media-type
55
+ * parameters on JSON requests; query parameters in any order; any `run-` run id (at most 40
56
+ * characters) in a run-scoped label name, event summary, or folder name; in a draft subject
57
+ * (compose and update) and the sent subject, only a run id of exactly 13 characters, the length of
58
+ * the fixtures' `run-synthetic`, because the recorded `sizeEstimate` of the draft and sent messages
59
+ * (answered by their message reads and the draft thread) covers the subject; on the practice send,
60
+ * a `content-type` of exactly `multipart/related; boundary=<b>` with any one unquoted boundary of 1
61
+ * to 70 `[A-Za-z0-9_]` characters and no other parameter; a `gmail.list` `maxResults` from 1 to
62
+ * 500, a `calendar.list_events` `maxResults` from 1 to 2500, and a `drive.list_files` `pageSize`
63
+ * from 1 to 1000; any `timeMin` before `timeMax` (RFC 3339 instants with a real calendar date, hour
64
+ * 0 to 23, minute and second 0 to 59, and a `Z` or in-range numeric offset); any id of an item the
65
+ * state holds where a fixture has an id (writes: only items created here, plus label changes,
66
+ * trash, and untrash of a stored non-draft message); and, for an id the state does not hold, only
67
+ * the recorded not-found answers (a `format=minimal` read of a 16-hex-digit message id, a read of a
68
+ * `Label_<1 to 999999999>` label, a delete of an `r-<digits>` draft, and a Drive file read). A seed
69
+ * may set another `practiceAddress`, but then every send is refused, since the recorded
70
+ * `sizeEstimate` of the sent message also covers the address: the send answers only while the
71
+ * seeded address is the recorded `practice@example.test`. On the draft compose and update routes,
72
+ * `message.raw` must be canonical unpadded base64url of exactly the recorded draft MIME of that
73
+ * route, the run id aside; any other `message.raw` (line-wrapped, the standard alphabet, padded,
74
+ * with a stray character, or with MIME-level encodings such as RFC 2047 encoded-words,
75
+ * quoted-printable, or UTF-16) is refused before anything is recorded or a fault is decided, as a
76
+ * constant entry with the route's own declared reason
77
+ * (`message.raw must be canonical base64url UTF-8 MIME`,
78
+ * `a draft compose other than the recorded run draft is not emulated` or its `update` form, the
79
+ * 13-character run-id reason, or `message has a key this route does not take`), or
80
+ * `the request body repeats the credential` when that raw decodes cleanly to text holding the
81
+ * bearer, so a refused `message.raw` never reaches the ledger and an admitted one is the recorded
82
+ * text. `Authorization` must be exactly `Bearer <token>` (that spelling, one space). Everything
83
+ * else (other keys, values, formats, query parameters, empty query components such as a bare `?` or
84
+ * a stray `&`, recorded headers such as Drive's `accept: application/json` missing, another origin,
85
+ * repeated query parameters, any recipient but the seeded practice address, a draft or send run id
86
+ * of another length, a bearer repeated anywhere in the request, including base64url-encoded inside
87
+ * a draft's `message.raw`, and page tokens not issued for the same list since the last reset or
88
+ * seed, or whose list changed) is not emulated.
89
+ *
90
+ * Node-only: `@emulators/core` imports Node builtins, so the core is loaded lazily by
91
+ * `makeGoogleEmulator` (importing this module has no side effects).
92
+ *
93
+ * @experimental
94
+ */
95
+ import { Data, Predicate } from 'effect'
96
+ import { calendarRoutes } from './google/calendar.ts'
97
+ import { driveRoutes } from './google/drive.ts'
98
+ import { gmailRoutes } from './google/gmail.ts'
99
+ import {
100
+ googleEmulatorDrillKnobs,
101
+ type GoogleApiEnv,
102
+ type GoogleEmulatorDrills,
103
+ type GoogleRoute
104
+ } from './google/shared.ts'
105
+ import {
106
+ buildSeedState,
107
+ decodeState,
108
+ type GoogleEmulatorSeed,
109
+ type GoogleEmulatorState
110
+ } from './google/state.ts'
111
+ import type { EmulatorRouteEvidence } from './route-evidence.ts'
112
+ import {
113
+ StatefulFault,
114
+ StatefulFaultMatch,
115
+ checkBooleanDrills,
116
+ makeStatefulEmulator,
117
+ routeEvidence,
118
+ type StatefulCoverage,
119
+ type StatefulEmulatorApi,
120
+ type StatefulFaultState,
121
+ type StatefulInputKind,
122
+ type StatefulLedgerEntry
123
+ } from './stateful-emulator.ts'
124
+ import { statefulCoreRuntime } from './stateful-core.ts'
125
+
126
+ export type { EmulatorEvidence, EmulatorRouteEvidence } from './route-evidence.ts'
127
+
128
+ export { emulatorEvidenceHeader } from './route-evidence.ts'
129
+
130
+ export {
131
+ googleEmulatorApisOrigin,
132
+ googleEmulatorGmailOrigin,
133
+ type GoogleEmulatorDrills
134
+ } from './google/shared.ts'
135
+
136
+ export {
137
+ GoogleEmulatorAttachment,
138
+ GoogleEmulatorCalendar,
139
+ GoogleEmulatorCalendarEvent,
140
+ GoogleEmulatorDraft,
141
+ GoogleEmulatorDriveFile,
142
+ GoogleEmulatorEventBoundary,
143
+ GoogleEmulatorGmailLabel,
144
+ GoogleEmulatorGmailMessage,
145
+ GoogleEmulatorImpliedMessage,
146
+ GoogleEmulatorPracticeAddress,
147
+ GoogleEmulatorProfile,
148
+ GoogleEmulatorSeed,
149
+ GoogleEmulatorStateSchema,
150
+ type GoogleEmulatorState
151
+ } from './google/state.ts'
152
+
153
+ const googleApiRoutes: ReadonlyArray<GoogleRoute> = [
154
+ ...gmailRoutes,
155
+ ...calendarRoutes,
156
+ ...driveRoutes
157
+ ]
158
+
159
+ /**
160
+ * Route evidence manifest: every emulated route, whether it writes, and the conformance cases
161
+ * whose (currently synthetic, unverified) wire claims it follows. Kept in sync with the handlers
162
+ * by construction (both come from one route table). Every route cites at least one case.
163
+ */
164
+ export const googleEmulatorRoutes: ReadonlyArray<EmulatorRouteEvidence> =
165
+ googleApiRoutes.map(routeEvidence)
166
+
167
+ /**
168
+ * Optional fault filter; an omitted field matches every request. `method` is the HTTP method;
169
+ * `path` is the raw request path (ending in `*`, a prefix); `route` is a route template of the
170
+ * manifest (another value is rejected when the fault is added).
171
+ */
172
+ export const GoogleFaultMatch = StatefulFaultMatch
173
+
174
+ export type GoogleFaultMatch = typeof StatefulFaultMatch.Type
175
+
176
+ /**
177
+ * A status fault (400-599): answer matching requests with this status, headers, and body instead
178
+ * of the route's write, so nothing is written. The body defaults to
179
+ * `{ error: { type: 'emulator_fault', message } }`; `count` limits how many requests it answers.
180
+ * Only a request the emulator would answer reaches a fault: a request that is not emulated, by
181
+ * its shape or by the state, never uses one up.
182
+ */
183
+ export const GoogleFault = StatefulFault
184
+
185
+ export type GoogleFault = StatefulFault
186
+
187
+ export type GoogleFaultState = StatefulFaultState
188
+
189
+ export type GoogleLedgerEntry = StatefulLedgerEntry
190
+
191
+ export type GoogleCoverage = StatefulCoverage
192
+
193
+ /** Invalid emulator input from the JS API: a seed, a fault, or an option. A programmer error. */
194
+ export class GoogleEmulatorInputInvalid extends Data.TaggedError('GoogleEmulatorInputInvalid')<{
195
+ readonly input: StatefulInputKind
196
+ readonly reason: string
197
+ }> {
198
+ override get message(): string {
199
+ return `Invalid Google emulator ${this.input}: ${this.reason}`
200
+ }
201
+ }
202
+
203
+ export type GoogleEmulatorOptions = {
204
+ /** Typed seed; defaults to the fixture entities (`profile: 'default'`). */
205
+ readonly seed?: GoogleEmulatorSeed
206
+ /**
207
+ * Clock in epoch milliseconds (created and updated event times, Drive folder times, and the
208
+ * sent message's `Date` header). Defaults to `Date.now`.
209
+ */
210
+ readonly now?: () => number
211
+ /** Drill knobs (tests only): make the emulator disagree with one conformance case. */
212
+ readonly drills?: GoogleEmulatorDrills
213
+ }
214
+
215
+ export type GoogleEmulator = StatefulEmulatorApi<GoogleEmulatorState, GoogleEmulatorSeed>
216
+
217
+ /** The constant reason of a request on no emulated route shape (nothing of it is ledgered). */
218
+ const googleUnrecognisedReason = 'no emulated Google route for this method and path'
219
+
220
+ /** The constant reason of a request whose `Authorization` header is not one recognisable bearer. */
221
+ const googleUnrecognisedAuthorizationReason =
222
+ 'an unrecognisable Authorization header is not emulated'
223
+
224
+ const inputInvalid = (input: StatefulInputKind, reason: string) =>
225
+ new GoogleEmulatorInputInvalid({ input, reason })
226
+
227
+ /**
228
+ * Create a stateful Google emulator on the `@emulators/core` custom runtime. Each call has its
229
+ * own state, ledger, faults, and page tokens. Rejects with `GoogleEmulatorInputInvalid` for an
230
+ * invalid seed or option. See `src/stateful-emulator.ts` for the request precedence.
231
+ */
232
+ export const makeGoogleEmulator = async (
233
+ options: GoogleEmulatorOptions = {}
234
+ ): Promise<GoogleEmulator> => {
235
+ const initial = buildSeedState(options.seed ?? {})
236
+
237
+ if (Predicate.isString(initial)) {
238
+ throw inputInvalid('seed', initial)
239
+ }
240
+
241
+ checkBooleanDrills(options.drills, googleEmulatorDrillKnobs, inputInvalid)
242
+
243
+ const drills = options.drills ?? {}
244
+
245
+ const env: GoogleApiEnv = {
246
+ now: options.now ?? (() => Date.now()),
247
+ drills: {
248
+ gmailPageRepeats: drills.gmailPageRepeats === true,
249
+ attachmentStandardBase64: drills.attachmentStandardBase64 === true,
250
+ notFoundWithoutMessage: drills.notFoundWithoutMessage === true,
251
+ labelDeleteKeepsOnMessages: drills.labelDeleteKeepsOnMessages === true,
252
+ draftUpdateKeepsContent: drills.draftUpdateKeepsContent === true,
253
+ trashAnswerOmitsTrash: drills.trashAnswerOmitsTrash === true,
254
+ sentMessageWithoutTo: drills.sentMessageWithoutTo === true,
255
+ calendarPageRepeats: drills.calendarPageRepeats === true,
256
+ eventPatchKeepsSummary: drills.eventPatchKeepsSummary === true,
257
+ repeatedEventDeleteConflict: drills.repeatedEventDeleteConflict === true,
258
+ drivePageRepeats: drills.drivePageRepeats === true,
259
+ getFileWithoutParents: drills.getFileWithoutParents === true,
260
+ listIncludesTrashed: drills.listIncludesTrashed === true
261
+ },
262
+ pageTokens: new Map(),
263
+ generation: { current: 0 },
264
+ firstIssued: new Map()
265
+ }
266
+
267
+ return makeStatefulEmulator<GoogleEmulatorState, GoogleApiEnv, GoogleEmulatorSeed>(
268
+ {
269
+ routes: googleApiRoutes,
270
+ env,
271
+ initial,
272
+ buildSeed: buildSeedState,
273
+ requestProblem: header =>
274
+ header('x-goog-drive-resource-keys') === undefined
275
+ ? undefined
276
+ : 'X-Goog-Drive-Resource-Keys is not emulated (no fixture sends it)',
277
+ // `content-type` only (never a credential header); the bearer is checked in it too.
278
+ recordHeaders: [{ name: 'content-type', json: false }],
279
+ // Page token values never cross a reset or seed: the generation makes later tokens distinct.
280
+ clearRuntime: () => {
281
+ env.pageTokens.clear()
282
+ env.generation.current += 1
283
+ },
284
+ runtimeState: () => ({ pageTokens: env.pageTokens.size }),
285
+ seedSummary: state => ({
286
+ messages: state.messages.length,
287
+ events: state.events.length,
288
+ files: state.files.length
289
+ }),
290
+ inputInvalid,
291
+ failClosed: {
292
+ unrecognised: googleUnrecognisedReason,
293
+ unrecognisedAuthorization: googleUnrecognisedAuthorizationReason
294
+ }
295
+ },
296
+ statefulCoreRuntime({ name: 'google', initial, decodeState, inputInvalid })
297
+ )
298
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * LinkedIn search emulator API: the route table (evidence, request-shape checks, and state-reading
3
+ * plans), the fixture error bodies, and answer rendering (internal; re-exported by
4
+ * `src/linkedin-search.ts`).
5
+ *
6
+ * Only the routes the seven LinkedIn search conformance cases send are emulated, each on its
7
+ * recorded origin: the Exa people search (`POST /search` on `https://api.exa.ai`) and the Enrich
8
+ * Layer profile and email lookups (`GET /api/v2/profile` and `GET /api/v2/profile/email` on
9
+ * `https://enrichlayer.com`). Every route is a read: a plan reads the state and answers a fixture's
10
+ * response or refuses (400 not-emulated, no fault used up); no commit writes.
11
+ *
12
+ * @experimental
13
+ */
14
+ import { Predicate } from 'effect'
15
+ import type * as Schema from 'effect/Schema'
16
+ import {
17
+ exactBodyKeys,
18
+ exactQuery,
19
+ integerIn,
20
+ isNotEmulated,
21
+ notEmulated,
22
+ statefulRoute,
23
+ type Commit,
24
+ type EmulatedRequest,
25
+ type NotEmulated,
26
+ type StatefulRoute
27
+ } from '../stateful-emulator.ts'
28
+ import {
29
+ linkedInSearchEmulatorEnrichLayerOrigin,
30
+ linkedInSearchEmulatorExaOrigin,
31
+ linkedInSearchMaxNumResults,
32
+ linkedInSearchProfileUrlPattern,
33
+ linkedInSearchQueryPattern,
34
+ type LinkedInSearchEmulatorProfile,
35
+ type LinkedInSearchEmulatorResult,
36
+ type LinkedInSearchEmulatorSearch,
37
+ type LinkedInSearchEmulatorState
38
+ } from './state.ts'
39
+
40
+ /** Content type of every answer, as the fixtures record it. */
41
+ const contentType = 'application/json'
42
+
43
+ /** Drill knobs (tests only): each makes the emulator disagree with one conformance claim. */
44
+ export type LinkedInSearchEmulatorDrills = {
45
+ /** A search with the default `numResults` (10) answers its results without `text`. */
46
+ readonly defaultSearchWithoutText?: boolean
47
+ /** A search answers the query's seeded search with the most results, whatever `numResults`. */
48
+ readonly numResultsIgnored?: boolean
49
+ /** A profile lookup answers an empty JSON object. */
50
+ readonly profileAnswersEmptyObject?: boolean
51
+ /** An email lookup answers a JSON object without `email`. */
52
+ readonly emailAnswerOmitsEmail?: boolean
53
+ /** A rejected Exa key answers status 500 (with the recorded 401 body). */
54
+ readonly exaUnauthorizedAs5xx?: boolean
55
+ /** A rejected Enrich Layer key answers status 200 (with the recorded 401 body). */
56
+ readonly enrichLayerUnauthorizedAs2xx?: boolean
57
+ /** An absent profile answers status 200 (with the recorded 404 body). */
58
+ readonly absentProfileAs2xx?: boolean
59
+ }
60
+
61
+ export const linkedInSearchEmulatorDrillKnobs: ReadonlyArray<keyof LinkedInSearchEmulatorDrills> = [
62
+ 'defaultSearchWithoutText',
63
+ 'numResultsIgnored',
64
+ 'profileAnswersEmptyObject',
65
+ 'emailAnswerOmitsEmail',
66
+ 'exaUnauthorizedAs5xx',
67
+ 'enrichLayerUnauthorizedAs2xx',
68
+ 'absentProfileAs2xx'
69
+ ]
70
+
71
+ export type LinkedInSearchApiEnv = {
72
+ readonly drills: Readonly<Record<keyof LinkedInSearchEmulatorDrills, boolean>>
73
+ }
74
+
75
+ /**
76
+ * The fixtures' error bodies, byte for byte (`JSON.stringify` keeps this key order): Exa's 401,
77
+ * Enrich Layer's 401 (the profile and the email lookup answer the same body), and Enrich Layer's
78
+ * not-found 404.
79
+ */
80
+ export const linkedInSearchEmulatorErrorBodies = {
81
+ exaUnauthorized: { requestId: 'synthetic-request-0003', error: 'Invalid API key' },
82
+ enrichLayerUnauthorized: { code: 401, description: 'Invalid API Key', name: 'Unauthorized' },
83
+ profileNotFound: { code: 404, description: 'Person profile does not exist', name: 'Not Found' }
84
+ } as const satisfies Readonly<Record<string, Schema.Json>>
85
+
86
+ type Route = StatefulRoute<LinkedInSearchEmulatorState, LinkedInSearchApiEnv>
87
+
88
+ const peopleResultsCase = 'linkedin-search.search.people-results'
89
+
90
+ const numResultsLimitCase = 'linkedin-search.search.num-results-limit'
91
+
92
+ const profileCase = 'linkedin-search.profile.get-profile'
93
+
94
+ const emailCase = 'linkedin-search.email.lookup-answer'
95
+
96
+ const exaUnauthorizedCase = 'linkedin-search.errors.exa-unauthorized'
97
+
98
+ const enrichLayerUnauthorizedCase = 'linkedin-search.errors.enrich-layer-unauthorized'
99
+
100
+ const profileNotFoundCase = 'linkedin-search.errors.profile-not-found'
101
+
102
+ const evidence = (
103
+ method: string,
104
+ path: string,
105
+ origin: string,
106
+ caseIds: ReadonlyArray<string>
107
+ ) => ({
108
+ method,
109
+ path,
110
+ kind: 'connector' as const,
111
+ write: false,
112
+ caseIds,
113
+ evidence: 'unverified' as const,
114
+ origin,
115
+ // No route has a path parameter: every raw path is matched exactly.
116
+ params: {}
117
+ })
118
+
119
+ const json = (status: number, body: Schema.Json): Response =>
120
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': contentType } })
121
+
122
+ /** A read-only commit. */
123
+ const answer =
124
+ (response: () => Response): Commit =>
125
+ () =>
126
+ response()
127
+
128
+ /** Whether the request's per-origin bearer digest is one a seed marks as rejected. */
129
+ const rejected = (digests: ReadonlyArray<string>, digest: string | undefined): boolean =>
130
+ digest !== undefined && digests.includes(digest)
131
+
132
+ // Exa people search.
133
+
134
+ type SearchInput = {
135
+ readonly query: string
136
+ readonly numResults: number
137
+ readonly bearerDigest: string | undefined
138
+ }
139
+
140
+ /** The connector's default `numResults` (the people-results fixture's). */
141
+ const defaultNumResults = 10
142
+
143
+ /** The result fields, in the fixtures' key order. */
144
+ const resultKeys = ['title', 'url', 'author', 'publishedDate', 'text'] as const
145
+
146
+ const renderResult = (result: LinkedInSearchEmulatorResult): Schema.JsonObject => {
147
+ const rendered: Record<string, string> = {}
148
+
149
+ for (const key of resultKeys) {
150
+ const value = result[key]
151
+
152
+ if (value !== undefined) rendered[key] = value
153
+ }
154
+
155
+ return rendered
156
+ }
157
+
158
+ /** The search the state answers for the input, or `undefined` (drills aside: the exact one). */
159
+ const searchFor = (
160
+ state: LinkedInSearchEmulatorState,
161
+ input: SearchInput,
162
+ env: LinkedInSearchApiEnv
163
+ ): LinkedInSearchEmulatorSearch | undefined => {
164
+ const exact = state.searches.find(
165
+ search => search.query === input.query && search.numResults === input.numResults
166
+ )
167
+
168
+ if (exact === undefined || !env.drills.numResultsIgnored) return exact
169
+
170
+ // The drill: the query's widest seeded answer (the first of the widest), whatever was asked.
171
+ return state.searches
172
+ .filter(search => search.query === input.query)
173
+ .reduce((widest, search) => (search.results.length > widest.results.length ? search : widest))
174
+ }
175
+
176
+ const search: Route = statefulRoute(
177
+ {
178
+ ...evidence('POST', '/search', linkedInSearchEmulatorExaOrigin, [
179
+ peopleResultsCase,
180
+ numResultsLimitCase,
181
+ exaUnauthorizedCase
182
+ ])
183
+ },
184
+ 'json',
185
+ (request): SearchInput | NotEmulated => {
186
+ const query = exactQuery(request, [], [], { rawNames: true })
187
+
188
+ if (isNotEmulated(query)) return query
189
+
190
+ const body = exactBodyKeys(request.json, 'the search body', [
191
+ 'query',
192
+ 'category',
193
+ 'numResults',
194
+ 'type',
195
+ 'contents'
196
+ ])
197
+
198
+ if (isNotEmulated(body)) return body
199
+
200
+ const text = body.query
201
+
202
+ if (!Predicate.isString(text) || !linkedInSearchQueryPattern.test(text) || text.length > 500) {
203
+ return notEmulated('query must be one trimmed, non-empty line of at most 500 characters')
204
+ }
205
+
206
+ if (body.category !== 'people') return notEmulated('category other than people is not emulated')
207
+
208
+ if (body.type !== 'auto') return notEmulated('type other than auto is not emulated')
209
+
210
+ const contents = exactBodyKeys(body.contents, 'contents', ['text'])
211
+
212
+ if (isNotEmulated(contents)) return contents
213
+
214
+ if (contents.text !== true)
215
+ return notEmulated('contents other than { text: true } is not emulated')
216
+
217
+ const numResults = integerIn(body.numResults, 'numResults', 1, linkedInSearchMaxNumResults)
218
+
219
+ if (isNotEmulated(numResults)) return numResults
220
+
221
+ return { query: text, numResults, bearerDigest: request.bearerDigest }
222
+ },
223
+ (state, input, { env }) => {
224
+ if (rejected(state.exaRejectedKeyDigests, input.bearerDigest)) {
225
+ // The Exa unauthorized fixture's 401, whatever the (well-formed) search.
226
+ return answer(() =>
227
+ json(
228
+ env.drills.exaUnauthorizedAs5xx ? 500 : 401,
229
+ linkedInSearchEmulatorErrorBodies.exaUnauthorized
230
+ )
231
+ )
232
+ }
233
+
234
+ const found = searchFor(state, input, env)
235
+
236
+ if (found === undefined) {
237
+ return notEmulated(
238
+ 'a search the state holds no answer for (this query and numResults) is not emulated'
239
+ )
240
+ }
241
+
242
+ return answer(() =>
243
+ json(200, {
244
+ results: found.results.map(result => {
245
+ const rendered = renderResult(result)
246
+
247
+ if (!env.drills.defaultSearchWithoutText || input.numResults !== defaultNumResults) {
248
+ return rendered
249
+ }
250
+
251
+ const { text: _text, ...withoutText } = rendered
252
+
253
+ return withoutText
254
+ })
255
+ })
256
+ )
257
+ }
258
+ )
259
+
260
+ // Enrich Layer lookups.
261
+
262
+ type LookupInput = { readonly url: string; readonly bearerDigest: string | undefined }
263
+
264
+ /** The one `linkedin_profile_url` query parameter: a profile URL (decoded once). */
265
+ const lookupInput = (request: EmulatedRequest): LookupInput | NotEmulated => {
266
+ const query = exactQuery(request, ['linkedin_profile_url'], [], { rawNames: true })
267
+
268
+ if (isNotEmulated(query)) return query
269
+
270
+ const url = query.linkedin_profile_url ?? ''
271
+
272
+ if (!linkedInSearchProfileUrlPattern.test(url) || url.length > 2048) {
273
+ return notEmulated('linkedin_profile_url must be a profile URL https://<host>/in/<slug>')
274
+ }
275
+
276
+ return { url, bearerDigest: request.bearerDigest }
277
+ }
278
+
279
+ const profileHeld = (
280
+ state: LinkedInSearchEmulatorState,
281
+ url: string
282
+ ): LinkedInSearchEmulatorProfile | undefined => state.profiles.find(profile => profile.url === url)
283
+
284
+ /** The Enrich Layer unauthorized fixture's 401 (both lookups answer it). */
285
+ const enrichLayerUnauthorized = (env: LinkedInSearchApiEnv): Commit =>
286
+ answer(() =>
287
+ json(
288
+ env.drills.enrichLayerUnauthorizedAs2xx ? 200 : 401,
289
+ linkedInSearchEmulatorErrorBodies.enrichLayerUnauthorized
290
+ )
291
+ )
292
+
293
+ const profile: Route = statefulRoute(
294
+ evidence('GET', '/api/v2/profile', linkedInSearchEmulatorEnrichLayerOrigin, [
295
+ profileCase,
296
+ enrichLayerUnauthorizedCase,
297
+ profileNotFoundCase
298
+ ]),
299
+ 'none',
300
+ lookupInput,
301
+ (state, input, { env }) => {
302
+ if (rejected(state.enrichLayerRejectedKeyDigests, input.bearerDigest)) {
303
+ return enrichLayerUnauthorized(env)
304
+ }
305
+
306
+ const held = profileHeld(state, input.url)
307
+
308
+ if (held !== undefined) {
309
+ return answer(() =>
310
+ json(
311
+ 200,
312
+ env.drills.profileAnswersEmptyObject
313
+ ? {}
314
+ : {
315
+ public_identifier: held.publicIdentifier,
316
+ full_name: held.fullName,
317
+ headline: held.headline
318
+ }
319
+ )
320
+ )
321
+ }
322
+
323
+ if (state.absentProfileUrls.includes(input.url)) {
324
+ return answer(() =>
325
+ json(
326
+ env.drills.absentProfileAs2xx ? 200 : 404,
327
+ linkedInSearchEmulatorErrorBodies.profileNotFound
328
+ )
329
+ )
330
+ }
331
+
332
+ return notEmulated(
333
+ 'a profile URL the state holds neither as a profile nor as absent is not emulated'
334
+ )
335
+ }
336
+ )
337
+
338
+ const email: Route = statefulRoute(
339
+ evidence('GET', '/api/v2/profile/email', linkedInSearchEmulatorEnrichLayerOrigin, [
340
+ emailCase,
341
+ enrichLayerUnauthorizedCase
342
+ ]),
343
+ 'none',
344
+ lookupInput,
345
+ (state, input, { env }) => {
346
+ if (rejected(state.enrichLayerRejectedKeyDigests, input.bearerDigest)) {
347
+ return enrichLayerUnauthorized(env)
348
+ }
349
+
350
+ const address = profileHeld(state, input.url)?.email
351
+
352
+ if (address === undefined) {
353
+ return notEmulated(
354
+ 'an email lookup of a profile the state holds no email answer for is not emulated'
355
+ )
356
+ }
357
+
358
+ return answer(() => json(200, env.drills.emailAnswerOmitsEmail ? {} : { email: address }))
359
+ }
360
+ )
361
+
362
+ /** Every emulated route, in manifest order. */
363
+ export const linkedInSearchApiRoutes: ReadonlyArray<Route> = [search, profile, email]