@hraness/ghostget 0.17.2

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 (500) hide show
  1. package/CHANGELOG.md +458 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1359 -0
  4. package/bunfig.toml +2 -0
  5. package/dist/apple-photos-client.js +704 -0
  6. package/dist/beeper-client.js +4602 -0
  7. package/dist/client.js +1605 -0
  8. package/dist/index-26yq8q16.js +31 -0
  9. package/dist/index-9t13b4k7.js +508 -0
  10. package/dist/index-axwm9b3v.js +5 -0
  11. package/dist/index-gwk7rbyj.js +30 -0
  12. package/dist/index.js +701 -0
  13. package/dist/messaging.js +1130 -0
  14. package/dist/omni-client.js +1406 -0
  15. package/dist/whatsapp-client.js +355 -0
  16. package/docs/imessage-direct-provider.md +106 -0
  17. package/docs/rental-listings.md +94 -0
  18. package/package.json +600 -0
  19. package/skills/ghostget/SKILL.md +251 -0
  20. package/skills/ghostget/agents/openai.yaml +4 -0
  21. package/skills/ghostget/references/adapter-contract.md +181 -0
  22. package/skills/ghostget/references/apple-photos.md +115 -0
  23. package/skills/ghostget/references/article-drafts.md +235 -0
  24. package/skills/ghostget/references/code-owned-provider-scaffold.md +134 -0
  25. package/skills/ghostget/references/cross-posting-video.md +139 -0
  26. package/skills/ghostget/references/cross-posting.md +82 -0
  27. package/skills/ghostget/references/derivation.md +390 -0
  28. package/skills/ghostget/references/hraness-social-profile-stats.json +269 -0
  29. package/skills/ghostget/references/install.md +66 -0
  30. package/skills/ghostget/references/linkedin-adapter.md +442 -0
  31. package/skills/ghostget/references/messaging.md +168 -0
  32. package/skills/ghostget/references/meta-comet-contract.md +153 -0
  33. package/skills/ghostget/references/platform-patterns.md +122 -0
  34. package/skills/ghostget/references/provider-plugins.md +496 -0
  35. package/skills/ghostget/references/reddit-flair.md +78 -0
  36. package/skills/ghostget/references/safety-and-state.md +214 -0
  37. package/skills/ghostget/references/settlement-and-duplicate-cleanup.md +79 -0
  38. package/skills/ghostget/references/social-platform-routing.md +66 -0
  39. package/skills/ghostget/references/social-profile-stats.md +195 -0
  40. package/skills/ghostget/references/social-video-platform-routing.md +89 -0
  41. package/skills/ghostget/references/whatsapp-adapter.md +101 -0
  42. package/skills/ghostget/references/x-adapter.md +332 -0
  43. package/skills/ghostget/references/x-ai-disclosure.md +71 -0
  44. package/src/apple-photos-cli.ts +639 -0
  45. package/src/apple-photos-client-types.ts +168 -0
  46. package/src/apple-photos-client.ts +201 -0
  47. package/src/apple-photos-contact-evidence.ts +838 -0
  48. package/src/apple-photos-local-source.ts +1131 -0
  49. package/src/args.ts +2053 -0
  50. package/src/article-draft-document.ts +417 -0
  51. package/src/article-draft-embeds.ts +123 -0
  52. package/src/article-draft-images.ts +214 -0
  53. package/src/assets/adapter-template/wrench-adapter.json +40 -0
  54. package/src/assets/adapters/beeper/wrench-web-adapter.json +1500 -0
  55. package/src/assets/adapters/beeper/wrench-web-adapter.v1.0.0.json +129 -0
  56. package/src/assets/adapters/beeper/wrench-web-adapter.v1.1.0.json +207 -0
  57. package/src/assets/adapters/beeper/wrench-web-adapter.v2.0.0.json +1463 -0
  58. package/src/assets/adapters/beeper/wrench-web-adapter.v2.1.0.json +1463 -0
  59. package/src/assets/adapters/beeper/wrench-web-adapter.v2.2.0.json +1475 -0
  60. package/src/assets/adapters/beeper/wrench-web-adapter.v2.3.0.json +1488 -0
  61. package/src/assets/adapters/bluesky/wrench-web-adapter.json +684 -0
  62. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.0.0.json +581 -0
  63. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.1.0.json +581 -0
  64. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.2.0.json +581 -0
  65. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.3.0.json +615 -0
  66. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.4.0.json +684 -0
  67. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.5.0.json +684 -0
  68. package/src/assets/adapters/bluesky/wrench-web-adapter.v1.6.0.json +684 -0
  69. package/src/assets/adapters/clasificados/wrench-web-adapter.json +54 -0
  70. package/src/assets/adapters/facebook/wrench-web-adapter.json +750 -0
  71. package/src/assets/adapters/facebook/wrench-web-adapter.v1.0.0.json +706 -0
  72. package/src/assets/adapters/facebook/wrench-web-adapter.v1.1.0.json +703 -0
  73. package/src/assets/adapters/facebook/wrench-web-adapter.v1.2.0.json +750 -0
  74. package/src/assets/adapters/facebook-group/wrench-web-adapter.json +682 -0
  75. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.0.0.json +583 -0
  76. package/src/assets/adapters/facebook-group/wrench-web-adapter.v1.1.0.json +682 -0
  77. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.json +437 -0
  78. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.0.0.json +383 -0
  79. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.0.json +437 -0
  80. package/src/assets/adapters/facebook-marketplace/wrench-web-adapter.v1.1.1.json +437 -0
  81. package/src/assets/adapters/facebook-page/wrench-web-adapter.json +880 -0
  82. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.0.0.json +740 -0
  83. package/src/assets/adapters/facebook-page/wrench-web-adapter.v1.1.0.json +880 -0
  84. package/src/assets/adapters/github/wrench-web-adapter.json +69 -0
  85. package/src/assets/adapters/github/wrench-web-adapter.v1.0.0.json +42 -0
  86. package/src/assets/adapters/github/wrench-web-adapter.v1.1.0.json +69 -0
  87. package/src/assets/adapters/gmail/wrench-adapter.json +161 -0
  88. package/src/assets/adapters/gmail/wrench-adapter.v1.2.0.json +157 -0
  89. package/src/assets/adapters/hacker-news/wrench-web-adapter.json +315 -0
  90. package/src/assets/adapters/hacker-news/wrench-web-adapter.v1.0.0.json +315 -0
  91. package/src/assets/adapters/imessage/wrench-web-adapter.json +236 -0
  92. package/src/assets/adapters/instagram/wrench-web-adapter.json +719 -0
  93. package/src/assets/adapters/instagram/wrench-web-adapter.v1.0.0.json +627 -0
  94. package/src/assets/adapters/instagram/wrench-web-adapter.v1.1.0.json +609 -0
  95. package/src/assets/adapters/instagram/wrench-web-adapter.v1.2.0.json +640 -0
  96. package/src/assets/adapters/instagram/wrench-web-adapter.v1.3.0.json +671 -0
  97. package/src/assets/adapters/instagram/wrench-web-adapter.v1.4.0.json +710 -0
  98. package/src/assets/adapters/instagram/wrench-web-adapter.v1.5.0.json +710 -0
  99. package/src/assets/adapters/instagram/wrench-web-adapter.v1.6.0.json +719 -0
  100. package/src/assets/adapters/instagram/wrench-web-adapter.v1.7.0.json +719 -0
  101. package/src/assets/adapters/linkedin/wrench-adapter.json +442 -0
  102. package/src/assets/adapters/linkedin/wrench-adapter.v0.4.0.json +124 -0
  103. package/src/assets/adapters/linkedin/wrench-adapter.v1.0.0.json +411 -0
  104. package/src/assets/adapters/linkedin/wrench-web-adapter.json +914 -0
  105. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.0.0.json +604 -0
  106. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.1.0.json +604 -0
  107. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.10.0.json +809 -0
  108. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.11.0.json +809 -0
  109. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.12.0.json +820 -0
  110. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.13.0.json +819 -0
  111. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.14.0.json +819 -0
  112. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.15.0.json +819 -0
  113. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.16.0.json +874 -0
  114. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.17.0.json +874 -0
  115. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.18.0.json +874 -0
  116. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.19.0.json +874 -0
  117. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.2.0.json +731 -0
  118. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.3.0.json +768 -0
  119. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.4.0.json +808 -0
  120. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.5.0.json +808 -0
  121. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.6.0.json +808 -0
  122. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.7.0.json +825 -0
  123. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.8.0.json +792 -0
  124. package/src/assets/adapters/linkedin/wrench-web-adapter.v1.9.0.json +809 -0
  125. package/src/assets/adapters/reddit/wrench-web-adapter.json +855 -0
  126. package/src/assets/adapters/reddit/wrench-web-adapter.v1.0.0.json +577 -0
  127. package/src/assets/adapters/reddit/wrench-web-adapter.v1.1.0.json +604 -0
  128. package/src/assets/adapters/reddit/wrench-web-adapter.v1.10.0.json +712 -0
  129. package/src/assets/adapters/reddit/wrench-web-adapter.v1.11.0.json +712 -0
  130. package/src/assets/adapters/reddit/wrench-web-adapter.v1.12.0.json +712 -0
  131. package/src/assets/adapters/reddit/wrench-web-adapter.v1.2.0.json +653 -0
  132. package/src/assets/adapters/reddit/wrench-web-adapter.v1.3.0.json +712 -0
  133. package/src/assets/adapters/reddit/wrench-web-adapter.v1.4.0.json +713 -0
  134. package/src/assets/adapters/reddit/wrench-web-adapter.v1.5.0.json +712 -0
  135. package/src/assets/adapters/reddit/wrench-web-adapter.v1.6.0.json +713 -0
  136. package/src/assets/adapters/reddit/wrench-web-adapter.v1.7.0.json +713 -0
  137. package/src/assets/adapters/reddit/wrench-web-adapter.v1.8.0.json +713 -0
  138. package/src/assets/adapters/reddit/wrench-web-adapter.v1.9.0.json +713 -0
  139. package/src/assets/adapters/substack/wrench-web-adapter.json +936 -0
  140. package/src/assets/adapters/substack/wrench-web-adapter.v1.0.0.json +817 -0
  141. package/src/assets/adapters/substack/wrench-web-adapter.v1.1.0.json +812 -0
  142. package/src/assets/adapters/substack/wrench-web-adapter.v1.2.0.json +812 -0
  143. package/src/assets/adapters/substack/wrench-web-adapter.v1.3.0.json +902 -0
  144. package/src/assets/adapters/substack/wrench-web-adapter.v1.4.0.json +936 -0
  145. package/src/assets/adapters/substack/wrench-web-adapter.v1.5.0.json +936 -0
  146. package/src/assets/adapters/substack/wrench-web-adapter.v1.6.0.json +936 -0
  147. package/src/assets/adapters/threads/wrench-web-adapter.json +692 -0
  148. package/src/assets/adapters/threads/wrench-web-adapter.v1.0.0.json +629 -0
  149. package/src/assets/adapters/threads/wrench-web-adapter.v1.1.0.json +623 -0
  150. package/src/assets/adapters/threads/wrench-web-adapter.v1.2.0.json +622 -0
  151. package/src/assets/adapters/threads/wrench-web-adapter.v1.3.0.json +622 -0
  152. package/src/assets/adapters/threads/wrench-web-adapter.v1.4.0.json +622 -0
  153. package/src/assets/adapters/threads/wrench-web-adapter.v1.5.0.json +699 -0
  154. package/src/assets/adapters/threads/wrench-web-adapter.v1.6.0.json +693 -0
  155. package/src/assets/adapters/threads/wrench-web-adapter.v1.7.0.json +692 -0
  156. package/src/assets/adapters/tiktok/wrench-web-adapter.json +762 -0
  157. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.0.0.json +681 -0
  158. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.1.0.json +708 -0
  159. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.2.0.json +762 -0
  160. package/src/assets/adapters/tiktok/wrench-web-adapter.v1.3.0.json +762 -0
  161. package/src/assets/adapters/twitch/wrench-web-adapter.json +42 -0
  162. package/src/assets/adapters/twitch/wrench-web-adapter.v1.0.0.json +42 -0
  163. package/src/assets/adapters/whatsapp/wrench-web-adapter.json +400 -0
  164. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.0.0.json +355 -0
  165. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.1.0.json +386 -0
  166. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.2.0.json +386 -0
  167. package/src/assets/adapters/whatsapp/wrench-web-adapter.v1.3.0.json +400 -0
  168. package/src/assets/adapters/x/wrench-adapter.json +749 -0
  169. package/src/assets/adapters/x/wrench-adapter.v1.0.0.json +700 -0
  170. package/src/assets/adapters/x/wrench-adapter.v1.1.0.json +707 -0
  171. package/src/assets/adapters/x/wrench-web-adapter.json +712 -0
  172. package/src/assets/adapters/x/wrench-web-adapter.v1.1.0.json +579 -0
  173. package/src/assets/adapters/x/wrench-web-adapter.v1.10.0.json +685 -0
  174. package/src/assets/adapters/x/wrench-web-adapter.v1.11.0.json +712 -0
  175. package/src/assets/adapters/x/wrench-web-adapter.v1.12.0.json +712 -0
  176. package/src/assets/adapters/x/wrench-web-adapter.v1.13.0.json +712 -0
  177. package/src/assets/adapters/x/wrench-web-adapter.v1.2.0.json +578 -0
  178. package/src/assets/adapters/x/wrench-web-adapter.v1.3.0.json +610 -0
  179. package/src/assets/adapters/x/wrench-web-adapter.v1.4.0.json +617 -0
  180. package/src/assets/adapters/x/wrench-web-adapter.v1.5.0.json +634 -0
  181. package/src/assets/adapters/x/wrench-web-adapter.v1.6.0.json +632 -0
  182. package/src/assets/adapters/x/wrench-web-adapter.v1.7.0.json +649 -0
  183. package/src/assets/adapters/x/wrench-web-adapter.v1.8.0.json +649 -0
  184. package/src/assets/adapters/x/wrench-web-adapter.v1.9.0.json +683 -0
  185. package/src/assets/adapters/youtube/wrench-web-adapter.json +561 -0
  186. package/src/assets/adapters/youtube/wrench-web-adapter.v1.0.0.json +465 -0
  187. package/src/assets/adapters/youtube/wrench-web-adapter.v1.1.0.json +519 -0
  188. package/src/assets/adapters/youtube/wrench-web-adapter.v1.2.0.json +561 -0
  189. package/src/assets/adapters/youtube/wrench-web-adapter.v1.3.0.json +561 -0
  190. package/src/assets/code-owned-provider-template/plugin.test.ts.template +54 -0
  191. package/src/assets/code-owned-provider-template/plugin.ts.template +102 -0
  192. package/src/assets/code-owned-provider-template/runtime.internal.test.ts.template +44 -0
  193. package/src/assets/code-owned-provider-template/runtime.ts.template +59 -0
  194. package/src/assets/code-owned-provider-template/wrench-adapter.json.template +33 -0
  195. package/src/assets/fonts/nebula-sans/LICENSE.txt +96 -0
  196. package/src/assets/fonts/nebula-sans/NebulaSans-Book.woff2 +0 -0
  197. package/src/assets/fonts/nebula-sans/PROVENANCE.md +14 -0
  198. package/src/assets.d.ts +4 -0
  199. package/src/auth.ts +1157 -0
  200. package/src/beeper-client-types.ts +169 -0
  201. package/src/beeper-client.ts +250 -0
  202. package/src/beeper-contact-interactions-cli.ts +180 -0
  203. package/src/beeper-contact-interactions.ts +1612 -0
  204. package/src/beeper-message-bundle-v1.ts +257 -0
  205. package/src/beeper-message-like-me-cli.ts +84 -0
  206. package/src/beeper-message-like-me-export.ts +2274 -0
  207. package/src/beeper-message-like-me-recovery.ts +931 -0
  208. package/src/beeper-message-like-me-source.ts +4034 -0
  209. package/src/browser-admission.ts +928 -0
  210. package/src/browser-snapshots.ts +260 -0
  211. package/src/browser.ts +3873 -0
  212. package/src/canonical-json.ts +60 -0
  213. package/src/catalog-cli.ts +638 -0
  214. package/src/cli.ts +208 -0
  215. package/src/client-types.ts +301 -0
  216. package/src/client.ts +2726 -0
  217. package/src/confirmed-write-failure.ts +44 -0
  218. package/src/confirmed-write-model.ts +72 -0
  219. package/src/confirmed-write-platform.ts +1055 -0
  220. package/src/confirmed-write-program.ts +224 -0
  221. package/src/confirmed-write-runtime.ts +16 -0
  222. package/src/cursor-token.ts +228 -0
  223. package/src/derivation-dnr-readiness.ts +367 -0
  224. package/src/derivation-file-chooser.ts +384 -0
  225. package/src/derivation-network-boundary.ts +835 -0
  226. package/src/derivation-network-guard.ts +782 -0
  227. package/src/derivation-network-proxy-helper.ts +440 -0
  228. package/src/derivation-network-proxy.ts +385 -0
  229. package/src/derive-command-helper.ts +693 -0
  230. package/src/derive-fixtures.ts +343 -0
  231. package/src/derive-review.ts +980 -0
  232. package/src/derive.ts +4754 -0
  233. package/src/fixtures/beeper-message-like-me-v1/accounts.ndjson +2 -0
  234. package/src/fixtures/beeper-message-like-me-v1/conversations.ndjson +1 -0
  235. package/src/fixtures/beeper-message-like-me-v1/manifest.json +1 -0
  236. package/src/fixtures/beeper-message-like-me-v1/messages.ndjson +2 -0
  237. package/src/fixtures/beeper-message-like-me-v1/participants.ndjson +3 -0
  238. package/src/fixtures/beeper-message-like-me-v1/reactions.ndjson +1 -0
  239. package/src/fixtures/beeper-message-like-me-v1/tombstones.ndjson +1 -0
  240. package/src/ghostget.ts +3672 -0
  241. package/src/gmail-capture.ts +1244 -0
  242. package/src/har-internal.ts +1242 -0
  243. package/src/har.ts +743 -0
  244. package/src/index.ts +218 -0
  245. package/src/invocation-read-platform.ts +55 -0
  246. package/src/invocation-read-program.ts +93 -0
  247. package/src/invocation-read-runtime.ts +11 -0
  248. package/src/linked-device-lifecycle-admission.ts +450 -0
  249. package/src/linked-device-lifecycle-journal.ts +1560 -0
  250. package/src/linked-device-lifecycle-runtime.ts +1602 -0
  251. package/src/local-cli-admission.ts +135 -0
  252. package/src/local-cli-contracts.ts +271 -0
  253. package/src/local-cli-execution.ts +98 -0
  254. package/src/local-cli-surface-contract.ts +1510 -0
  255. package/src/local-cli-tool-identity.ts +336 -0
  256. package/src/media/archive.ts +2325 -0
  257. package/src/media/args.ts +303 -0
  258. package/src/media/cli.ts +287 -0
  259. package/src/media/doctor.ts +475 -0
  260. package/src/media/ffmpeg.ts +1330 -0
  261. package/src/media/http-capture.ts +711 -0
  262. package/src/media/http-probe.ts +593 -0
  263. package/src/media/http.ts +387 -0
  264. package/src/media/index.ts +10 -0
  265. package/src/media/local-transcription.ts +613 -0
  266. package/src/media/lock.ts +321 -0
  267. package/src/media/manifest.ts +1712 -0
  268. package/src/media/metadata.ts +906 -0
  269. package/src/media/process.ts +723 -0
  270. package/src/media/revision.ts +166 -0
  271. package/src/media/runtime-closure.ts +1063 -0
  272. package/src/media/source-router.ts +187 -0
  273. package/src/media/transcriber-config.ts +1122 -0
  274. package/src/media/transcript-persistence-model.ts +15 -0
  275. package/src/media/transcript-persistence-platform.ts +89 -0
  276. package/src/media/transcript-persistence-program.ts +39 -0
  277. package/src/media/transcript-persistence-runtime.ts +24 -0
  278. package/src/media/transcript.ts +616 -0
  279. package/src/media/utf8-order.ts +30 -0
  280. package/src/media/whisper-cpp.ts +464 -0
  281. package/src/media/whisper-language.ts +58 -0
  282. package/src/media/yt-dlp.ts +439 -0
  283. package/src/message-like-me-agentic-messaging.ts +882 -0
  284. package/src/messaging-action-store.ts +793 -0
  285. package/src/messaging-runtime.ts +2136 -0
  286. package/src/messaging-store.ts +539 -0
  287. package/src/messaging-types.ts +1663 -0
  288. package/src/messaging.ts +334 -0
  289. package/src/model.ts +2671 -0
  290. package/src/oauth-google.ts +888 -0
  291. package/src/omni-client-types.ts +306 -0
  292. package/src/omni-client.ts +2225 -0
  293. package/src/omni-limits.ts +34 -0
  294. package/src/omni-model.ts +1614 -0
  295. package/src/omni-request.ts +379 -0
  296. package/src/omni-runtime.ts +1632 -0
  297. package/src/operation-deadline.ts +189 -0
  298. package/src/path-helper.ts +1656 -0
  299. package/src/pinned-https.ts +194 -0
  300. package/src/plan-assets.ts +832 -0
  301. package/src/platform-catalog.ts +1832 -0
  302. package/src/plugins/beeper-linked-device/plugin.ts +310 -0
  303. package/src/plugins/bluesky-web/plugin.ts +178 -0
  304. package/src/plugins/clasificados-web/plugin.ts +66 -0
  305. package/src/plugins/github-web/plugin.ts +80 -0
  306. package/src/plugins/gmail-official/plugin.ts +110 -0
  307. package/src/plugins/hacker-news-web/plugin.ts +52 -0
  308. package/src/plugins/imessage-direct/plugin.ts +195 -0
  309. package/src/plugins/imessage-direct/vendor/0001-fix-keep-AppleScript-send-payloads-out-of-child-argv.patch +1430 -0
  310. package/src/plugins/imessage-direct/vendor/0002-feat-rpc-add-exact-chat-lookup.patch +225 -0
  311. package/src/plugins/imessage-direct/vendor/provenance.json +80 -0
  312. package/src/plugins/linkedin-official/plugin.ts +84 -0
  313. package/src/plugins/linkedin-web/plugin.ts +412 -0
  314. package/src/plugins/meta-web/plugin.ts +317 -0
  315. package/src/plugins/reddit-web/flair.ts +492 -0
  316. package/src/plugins/reddit-web/plugin.ts +477 -0
  317. package/src/plugins/substack-web/plugin.ts +124 -0
  318. package/src/plugins/tiktok-web/plugin.ts +67 -0
  319. package/src/plugins/twitch-web/plugin.ts +54 -0
  320. package/src/plugins/whatsapp-linked-device/plugin.ts +105 -0
  321. package/src/plugins/x-official/plugin.ts +64 -0
  322. package/src/plugins/x-web/plugin.ts +488 -0
  323. package/src/plugins/youtube-web/plugin.ts +101 -0
  324. package/src/portable-run-recovery.ts +466 -0
  325. package/src/process-identity.ts +501 -0
  326. package/src/profile-clone-helper.ts +104 -0
  327. package/src/provider-catalog-views.ts +51 -0
  328. package/src/provider-context.ts +69 -0
  329. package/src/provider-contract-definitions-x.ts +45 -0
  330. package/src/provider-contract-definitions.ts +948 -0
  331. package/src/provider-contract-input-gmail.ts +176 -0
  332. package/src/provider-contract-input-linkedin.ts +77 -0
  333. package/src/provider-contract-input-x.ts +304 -0
  334. package/src/provider-contract-planning.ts +23 -0
  335. package/src/provider-contract-semantic-identity.ts +54 -0
  336. package/src/provider-contracts.ts +251 -0
  337. package/src/provider-http.ts +723 -0
  338. package/src/provider-plugin-auth.ts +36 -0
  339. package/src/provider-plugin-builtins.ts +229 -0
  340. package/src/provider-plugin-cleanup-barrier.ts +229 -0
  341. package/src/provider-plugin-cleanup-execution.ts +79 -0
  342. package/src/provider-plugin-cleanup-resource.ts +470 -0
  343. package/src/provider-plugin-contract-identity.ts +707 -0
  344. package/src/provider-plugin-host.ts +1972 -0
  345. package/src/provider-plugin-identifiers.ts +60 -0
  346. package/src/provider-plugin-invocation-lease.ts +889 -0
  347. package/src/provider-plugin-lifecycle-kernel.ts +566 -0
  348. package/src/provider-plugin-lifecycle.ts +1455 -0
  349. package/src/provider-plugin-module-analysis.ts +336 -0
  350. package/src/provider-plugin-package.ts +2530 -0
  351. package/src/provider-plugin-portable-authority.ts +106 -0
  352. package/src/provider-plugin-portable-catalog.ts +94 -0
  353. package/src/provider-plugin-portable-identity.ts +315 -0
  354. package/src/provider-plugin-portable-runtime.ts +2057 -0
  355. package/src/provider-plugin-protocol.ts +1625 -0
  356. package/src/provider-plugin-registry.ts +3342 -0
  357. package/src/provider-plugin-store.ts +2155 -0
  358. package/src/provider-plugin-test-fixture.ts +5 -0
  359. package/src/provider-plugin.ts +4907 -0
  360. package/src/provider-plugins.generated.ts +43 -0
  361. package/src/provider-plugins.ts +8 -0
  362. package/src/provider-subject.ts +41 -0
  363. package/src/provider.ts +315 -0
  364. package/src/providers/beeper-local-runtime.ts +5426 -0
  365. package/src/providers/beeper-local.ts +2739 -0
  366. package/src/providers/beeper-messaging.ts +462 -0
  367. package/src/providers/beeper-omni.ts +1159 -0
  368. package/src/providers/bluesky-web-runtime.ts +2895 -0
  369. package/src/providers/bluesky-web.ts +1614 -0
  370. package/src/providers/clasificados-web-runtime.ts +255 -0
  371. package/src/providers/clasificados-web.ts +503 -0
  372. package/src/providers/contact-projection.ts +301 -0
  373. package/src/providers/github-read-model.ts +94 -0
  374. package/src/providers/github-read-platform.ts +165 -0
  375. package/src/providers/github-read-program.ts +97 -0
  376. package/src/providers/github-web-runtime.ts +320 -0
  377. package/src/providers/github-web.ts +403 -0
  378. package/src/providers/gmail-api.ts +2834 -0
  379. package/src/providers/gmail-omni.ts +830 -0
  380. package/src/providers/gmail.ts +1130 -0
  381. package/src/providers/hacker-news-web-runtime.ts +220 -0
  382. package/src/providers/hacker-news-web.ts +809 -0
  383. package/src/providers/imessage-direct-install.ts +354 -0
  384. package/src/providers/imessage-direct-messaging.ts +532 -0
  385. package/src/providers/imessage-direct-omni.ts +400 -0
  386. package/src/providers/imessage-direct-runtime.ts +1498 -0
  387. package/src/providers/imessage-direct.ts +351 -0
  388. package/src/providers/instagram-video-foundations.ts +913 -0
  389. package/src/providers/instagram-web-profile-browser.ts +600 -0
  390. package/src/providers/iso-bmff.ts +338 -0
  391. package/src/providers/linkedin-company-platform.ts +42 -0
  392. package/src/providers/linkedin-company-program.ts +88 -0
  393. package/src/providers/linkedin-contact-failure.ts +84 -0
  394. package/src/providers/linkedin-contact-platform.ts +45 -0
  395. package/src/providers/linkedin-contact-program.ts +91 -0
  396. package/src/providers/linkedin-profile-activity-failure.ts +76 -0
  397. package/src/providers/linkedin-profile-activity-platform.ts +41 -0
  398. package/src/providers/linkedin-profile-activity-program.ts +67 -0
  399. package/src/providers/linkedin-read-failure.ts +52 -0
  400. package/src/providers/linkedin-self-platform.ts +53 -0
  401. package/src/providers/linkedin-self-program.ts +122 -0
  402. package/src/providers/linkedin-web-article-browser.ts +794 -0
  403. package/src/providers/linkedin-web-bootstrap.ts +146 -0
  404. package/src/providers/linkedin-web-contact.ts +830 -0
  405. package/src/providers/linkedin-web-feed-browser.ts +832 -0
  406. package/src/providers/linkedin-web-feed.ts +864 -0
  407. package/src/providers/linkedin-web-post-browser.ts +653 -0
  408. package/src/providers/linkedin-web-profile-browser.ts +683 -0
  409. package/src/providers/linkedin-web-runtime.ts +2388 -0
  410. package/src/providers/linkedin-web.ts +3305 -0
  411. package/src/providers/linkedin.ts +1885 -0
  412. package/src/providers/meta-bootstrap.ts +1043 -0
  413. package/src/providers/meta-facebook-group.ts +690 -0
  414. package/src/providers/meta-marketplace-relay.ts +747 -0
  415. package/src/providers/meta-omni.ts +373 -0
  416. package/src/providers/meta-relay-bundle.ts +1049 -0
  417. package/src/providers/meta-web-descriptors.ts +1740 -0
  418. package/src/providers/meta-web-runtime.ts +3388 -0
  419. package/src/providers/meta-web.ts +2931 -0
  420. package/src/providers/read-failure.ts +139 -0
  421. package/src/providers/reddit-omni.ts +462 -0
  422. package/src/providers/reddit-web-runtime.ts +1929 -0
  423. package/src/providers/reddit-web.ts +1611 -0
  424. package/src/providers/rental-listings.ts +460 -0
  425. package/src/providers/substack-omni.ts +406 -0
  426. package/src/providers/substack-video-mp4.ts +33 -0
  427. package/src/providers/substack-web-runtime.ts +2812 -0
  428. package/src/providers/substack-web.ts +1502 -0
  429. package/src/providers/tiktok-video-mp4.ts +33 -0
  430. package/src/providers/tiktok-web-runtime.ts +770 -0
  431. package/src/providers/tiktok-web.ts +2786 -0
  432. package/src/providers/twitch-web-runtime.ts +226 -0
  433. package/src/providers/twitch-web.ts +274 -0
  434. package/src/providers/whatsapp-account-identity.ts +55 -0
  435. package/src/providers/whatsapp-contact-projection-helper.ts +874 -0
  436. package/src/providers/whatsapp-contact-projection-protocol.ts +403 -0
  437. package/src/providers/whatsapp-interaction-projection-helper.ts +1452 -0
  438. package/src/providers/whatsapp-interaction-projection-protocol.ts +462 -0
  439. package/src/providers/whatsapp-message-export-projection-protocol.ts +532 -0
  440. package/src/providers/whatsapp-messaging.ts +132 -0
  441. package/src/providers/whatsapp-omni.ts +586 -0
  442. package/src/providers/whatsapp-web-runtime.ts +3108 -0
  443. package/src/providers/whatsapp-web.ts +1037 -0
  444. package/src/providers/x-image-provenance.ts +523 -0
  445. package/src/providers/x-made-with-ai.ts +106 -0
  446. package/src/providers/x-transaction-id.ts +413 -0
  447. package/src/providers/x-web-runtime.ts +3485 -0
  448. package/src/providers/x-web.ts +2332 -0
  449. package/src/providers/x.ts +1889 -0
  450. package/src/providers/youtube-web-runtime.ts +1400 -0
  451. package/src/providers/youtube-web.ts +1221 -0
  452. package/src/read-admission-runtime.ts +91 -0
  453. package/src/read-client.ts +521 -0
  454. package/src/read-effect-platform.ts +7 -0
  455. package/src/read-effect-runtime.ts +14 -0
  456. package/src/read-effect.ts +52 -0
  457. package/src/read-projection-admission.ts +866 -0
  458. package/src/read-projections.ts +3151 -0
  459. package/src/recovery.ts +1492 -0
  460. package/src/reviewed-template.ts +146 -0
  461. package/src/run-journal.ts +1272 -0
  462. package/src/runtime.ts +5116 -0
  463. package/src/scripts/generate-provider-plugin-catalog.ts +121 -0
  464. package/src/scripts/install-whatsapp-protocol.sh +232 -0
  465. package/src/scripts/recover-install-stages.ts +1491 -0
  466. package/src/scripts/resolve-state-home.ts +30 -0
  467. package/src/scripts/scaffold-web-provider.ts +973 -0
  468. package/src/scripts/sync-bundled-adapters.ts +695 -0
  469. package/src/session-secrets.ts +1183 -0
  470. package/src/state-helper.bunfig.toml +2 -0
  471. package/src/state-helper.ts +2914 -0
  472. package/src/storage.ts +3842 -0
  473. package/src/transport-policy.ts +2 -0
  474. package/src/usage.ts +182 -0
  475. package/src/version.ts +2 -0
  476. package/src/web-session-authentication-policy.ts +136 -0
  477. package/src/web-session-cleanup-admission.ts +2344 -0
  478. package/src/web-session-cleanup-join.ts +99 -0
  479. package/src/web-session-client.ts +996 -0
  480. package/src/web-session-contract-definitions.ts +820 -0
  481. package/src/web-session-contract-planning.ts +42 -0
  482. package/src/web-session-contracts.ts +228 -0
  483. package/src/web-session-cookies.ts +500 -0
  484. package/src/web-session-execution.ts +385 -0
  485. package/src/web-session-http.ts +373 -0
  486. package/src/web-session-read-errors.ts +19 -0
  487. package/src/web-session-read-runtime.ts +124 -0
  488. package/src/web-session-recovery.ts +818 -0
  489. package/src/web-session-sites.ts +58 -0
  490. package/src/web-session-template.ts +994 -0
  491. package/src/web-session.ts +93 -0
  492. package/src/whatsapp-client-binding.ts +21 -0
  493. package/src/whatsapp-client-types.ts +75 -0
  494. package/src/whatsapp-client.ts +395 -0
  495. package/src/whatsapp-export-coordinate.ts +17 -0
  496. package/src/whatsapp-message-bundle-v2.ts +195 -0
  497. package/src/whatsapp-message-like-me-cli.ts +195 -0
  498. package/src/whatsapp-message-like-me-export.ts +1072 -0
  499. package/src/whatsapp-message-like-me-source.ts +1012 -0
  500. package/tsconfig.json +67 -0
@@ -0,0 +1,3485 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { open } from "node:fs/promises";
4
+
5
+ import {
6
+ parseArticleDraftDocument,
7
+ parseArticleDraftDocumentV2,
8
+ type ArticleDraftDocument,
9
+ type ArticleDraftDocumentV2,
10
+ } from "../article-draft-document";
11
+ import {
12
+ materializeArticleDraftImages,
13
+ type BoundArticleDraftImage,
14
+ } from "../article-draft-images";
15
+ import type { GhostgetAuth } from "../auth";
16
+ import {
17
+ browserCleanupBarrier,
18
+ type BrowserFileResolver,
19
+ } from "../browser";
20
+ import type {
21
+ FileInputValue,
22
+ OperationInput,
23
+ WebSessionRecipe,
24
+ } from "../model";
25
+ import { OperationDeadlineError } from "../operation-deadline";
26
+ import { canonicalJson } from "../canonical-json";
27
+ import {
28
+ createWebSessionClient,
29
+ fetchPublicWebAsset,
30
+ webSessionAuthSubject,
31
+ webSessionCookie,
32
+ type WebSessionClient,
33
+ type WebSessionNetworkDependencies,
34
+ } from "../web-session-client";
35
+ import {
36
+ readFailureProjection,
37
+ startWebSessionCleanupTrackedOperation,
38
+ type ReadFailureProjection,
39
+ type WebSessionCleanupBarrierRegistrar,
40
+ type WebSessionDispatchEvent,
41
+ type WebSessionExecution,
42
+ type WebSessionOperationDeadline,
43
+ type WebSessionProviderAcceptedMutationTargetEvent,
44
+ } from "../web-session-execution";
45
+ import { failedProviderRead, type ProviderReadFailureStage } from "./read-failure";
46
+ import { scrubXUploadImage } from "./x-image-provenance";
47
+ import {
48
+ rejectXTweetMadeWithAiLabel,
49
+ X_UNLABELED_COPY_POLICY_ERROR,
50
+ XUnlabeledCopyPolicyError,
51
+ } from "./x-made-with-ai";
52
+ import {
53
+ generateXClientTransactionId,
54
+ type XTransactionBrowserDependencies,
55
+ } from "./x-transaction-id";
56
+ import {
57
+ assertExactXWebGraphQlBinding,
58
+ assertXWebUserFeedTargetBound,
59
+ authorizeXWebMutationRequest,
60
+ authorizeXWebR1GraphQlRequest,
61
+ bindXWebOperationMetadataValues,
62
+ enforceXWebHeaderSinkPolicy,
63
+ boundXWebProviderPage,
64
+ extractXWebGraphQlReadResponseRoot,
65
+ MAX_X_CREATE_TWEET_TEXT_LENGTH,
66
+ normalizeXWebProfileHandle,
67
+ normalizeXWebGraphQlTimelineResponse,
68
+ projectXWebBookmarkExportPage,
69
+ projectXWebFeedPage,
70
+ projectXWebFeedPost,
71
+ projectXWebProfileStats,
72
+ resolveUniqueXWebBundleDescriptor,
73
+ validateXWebDesiredStateMutation,
74
+ validateXWebRichArticleContentState,
75
+ xWebQueryDescriptorEvidenceSnapshot,
76
+ XProfileTargetUnavailableError,
77
+ type XWebBundleQueryDescriptor,
78
+ type XWebMutationOperationId,
79
+ type XWebOperationType,
80
+ type XWebProjectedPost,
81
+ type XWebQueryDescriptorEvidence,
82
+ type XWebSemanticOperationId,
83
+ } from "./x-web";
84
+
85
+ const X_ORIGIN = "https://x.com";
86
+ const X_UPLOAD_ORIGIN = "https://upload.x.com";
87
+ const X_ASSET_ORIGIN = "https://abs.twimg.com";
88
+ const X_DEFAULT_ARTICLE_UPLOAD_ORIGIN = "https://upload.x.com";
89
+ const X_ARTICLE_UPLOAD_HOSTS = new Set(["upload.x.com", "upload-a.x.com", "upload-b.x.com"]);
90
+ const MAX_HOME_BYTES = 2 * 1024 * 1024;
91
+ const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
92
+ const DEFAULT_LIMIT = 20;
93
+ const MAX_ARTICLE_TITLE_CHARACTERS = 100;
94
+ const MAX_ARTICLE_BODY_CHARACTERS = 20_000;
95
+ const MAX_ARTICLE_BLOCKS = 2_000;
96
+ const MAX_ARTICLE_IMAGE_BYTES = 5 * 1024 * 1024;
97
+ const MAX_ARTICLE_INLINE_IMAGES = 20;
98
+ const MAX_ARTICLE_MEDIA_RESPONSE_BYTES = 2 * 1024 * 1024;
99
+ const MAX_X_IMAGE_BYTES = 20 * 1024 * 1024;
100
+ const MAX_X_VIDEO_BYTES = 512 * 1024 * 1024;
101
+ const X_UPLOAD_CHUNK_BYTES = 5 * 1024 * 1024;
102
+ const MAX_X_IMAGE_UPLOAD_CHUNKS = 4;
103
+ const MAX_X_VIDEO_UPLOAD_CHUNKS = Math.ceil(MAX_X_VIDEO_BYTES / X_UPLOAD_CHUNK_BYTES);
104
+ const MAX_X_MEDIA_STATUS_POLLS = 100;
105
+ const MAX_X_MEDIA_STATUS_WAIT_MS = 8 * 60_000;
106
+ const MAX_X_UPLOAD_RESPONSE_BYTES = 512 * 1024;
107
+ const PUBLISH_READBACK_DELAYS_MS = Object.freeze([0, 250, 750, 1_500]);
108
+
109
+ type JsonRecord = Record<string, unknown>;
110
+
111
+ export type XWebRuntimeDependencies = Partial<WebSessionNetworkDependencies>
112
+ & Pick<XTransactionBrowserDependencies, "createBrowserSession">
113
+ & {
114
+ readonly now?: () => number;
115
+ readonly sleep?: (milliseconds: number) => Promise<void>;
116
+ };
117
+
118
+ type XBootstrap = {
119
+ readonly auth: GhostgetAuth;
120
+ readonly client: WebSessionClient;
121
+ readonly html: string;
122
+ readonly mainUrl: URL;
123
+ readonly mainText: string;
124
+ readonly bearer: string;
125
+ readonly csrf: string;
126
+ readonly features: ReadonlyMap<string, unknown>;
127
+ readonly timeoutMs: number;
128
+ readonly maxOutputBytes: number;
129
+ readonly signal?: AbortSignal;
130
+ readonly operationDeadline?: WebSessionOperationDeadline;
131
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
132
+ readonly dependencies?: XWebRuntimeDependencies;
133
+ readonly chunks: Map<string, string>;
134
+ readonly descriptors: Map<string, XWebBundleQueryDescriptor>;
135
+ };
136
+
137
+ type Viewer = { readonly id: string; readonly screenName: string | null };
138
+ export type XWebDesiredStateKind = "like" | "bookmark";
139
+ export type XWebDesiredStateReadback = {
140
+ readonly kind: XWebDesiredStateKind;
141
+ readonly enabled: boolean;
142
+ readonly postId: string;
143
+ };
144
+
145
+ type FeedRequest = {
146
+ readonly operationId: XWebSemanticOperationId;
147
+ readonly operationName: string;
148
+ readonly variables: Readonly<Record<string, unknown>>;
149
+ readonly method: "GET" | "POST";
150
+ };
151
+
152
+ const viewerEvidence = Object.freeze({
153
+ operationName: "Viewer",
154
+ operationType: "query" as const,
155
+ queryId: "9t128XgFic52jPUEkJMf6w",
156
+ sourceChunk: "main.cd39a626fdb81748a.js",
157
+ observedOn: "2026-09-09",
158
+ });
159
+
160
+ function isRecord(value: unknown): value is JsonRecord {
161
+ return typeof value === "object" && value !== null && !Array.isArray(value);
162
+ }
163
+
164
+ type XProfileReadStage = "bootstrap" | "viewer" | "target";
165
+
166
+ function xProfileReadFailure(
167
+ error: unknown,
168
+ stage: XProfileReadStage,
169
+ ): ReadFailureProjection {
170
+ let deadlineCause: unknown = error;
171
+ for (let depth = 0; depth < 8 && deadlineCause !== undefined; depth += 1) {
172
+ if (deadlineCause instanceof OperationDeadlineError) {
173
+ return deadlineCause.failure === "timed-out"
174
+ ? readFailureProjection("operation-timeout")
175
+ : readFailureProjection("contract-drift");
176
+ }
177
+ deadlineCause = deadlineCause instanceof Error
178
+ ? deadlineCause.cause
179
+ : undefined;
180
+ }
181
+ let current: unknown = error;
182
+ for (let depth = 0; depth < 8 && current !== undefined; depth += 1) {
183
+ const message = current instanceof Error ? current.message : "";
184
+ if (message.includes("viewer no longer matches")) {
185
+ return readFailureProjection("account-mismatch");
186
+ }
187
+ if (stage === "target" && current instanceof XProfileTargetUnavailableError) {
188
+ return readFailureProjection("target-unavailable");
189
+ }
190
+ if (message.includes("ct0 session cookie is invalid or expired")) {
191
+ return readFailureProjection("auth-repair-required");
192
+ }
193
+ const response = /status(?:\/content type)? (302|401|403|404|408|429|5[0-9]{2})(?:\/|$)/u
194
+ .exec(message);
195
+ if (response?.[1] === "401" || response?.[1] === "403") {
196
+ return readFailureProjection("auth-repair-required");
197
+ }
198
+ if (response?.[1] === "404") {
199
+ return readFailureProjection("contract-drift");
200
+ }
201
+ if (response?.[1] === "429") {
202
+ return readFailureProjection("provider-throttled");
203
+ }
204
+ if (
205
+ response?.[1] === "302"
206
+ || response?.[1] === "408"
207
+ || response?.[1]?.startsWith("5")
208
+ ) {
209
+ return readFailureProjection("provider-temporary");
210
+ }
211
+ if (
212
+ message.includes("failed before a reviewed response was received")
213
+ || message === "authenticated web response body stream failed before completion"
214
+ || message === "public first-party web asset request failed"
215
+ || message.includes("Failed to fetch")
216
+ ) return readFailureProjection("provider-temporary");
217
+ current = current instanceof Error ? current.cause : undefined;
218
+ }
219
+ return readFailureProjection("contract-drift");
220
+ }
221
+
222
+ function failedXProfileRead(
223
+ error: unknown,
224
+ finalUrl: string | null,
225
+ stage: XProfileReadStage,
226
+ ): WebSessionExecution {
227
+ return {
228
+ status: "failed",
229
+ output: null,
230
+ finalUrl,
231
+ dispatchStarted: false,
232
+ dispatch: { planned: 0, started: 0, verified: 0 },
233
+ error: "X profile read failed before the dispatch boundary",
234
+ readFailure: xProfileReadFailure(error, stage),
235
+ };
236
+ }
237
+
238
+ function xFeedReadAccountMismatch(error: Error): boolean {
239
+ return error.message.includes("viewer no longer matches")
240
+ || error.message.includes("personalized operations require an auth locator bound");
241
+ }
242
+
243
+ function xFeedReadAuthRepairRequired(error: Error): boolean {
244
+ return error.message.includes("ct0 session cookie is invalid or expired");
245
+ }
246
+
247
+ function feedsReadFinalUrl(input: OperationInput): string | null {
248
+ try {
249
+ return stringInput(input, "feed") === "bookmarks" ? `${X_ORIGIN}/i/bookmarks` : `${X_ORIGIN}/home`;
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+
255
+ function failedXFeedRead(
256
+ error: unknown,
257
+ input: OperationInput,
258
+ stage: ProviderReadFailureStage,
259
+ ): WebSessionExecution {
260
+ const projected = failedProviderRead("X feed", error, feedsReadFinalUrl(input), {
261
+ stage,
262
+ authenticated: true,
263
+ accountMismatch: xFeedReadAccountMismatch,
264
+ authRepairRequired: xFeedReadAuthRepairRequired,
265
+ });
266
+ return {
267
+ ...projected,
268
+ error: error instanceof Error
269
+ ? error.message
270
+ : "X feed read failed before the dispatch boundary",
271
+ };
272
+ }
273
+
274
+ function record(value: unknown, label: string): JsonRecord {
275
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
276
+ return value;
277
+ }
278
+
279
+ function requiredString(value: unknown, label: string, maximum = 32_768): string {
280
+ if (typeof value !== "string" || value.length < 1 || value.length > maximum || /[\0\r]/u.test(value)) {
281
+ throw new Error(`${label} must be a bounded string`);
282
+ }
283
+ return value;
284
+ }
285
+
286
+ function optionalStringInput(input: OperationInput, name: string): string | undefined {
287
+ const value = input[name];
288
+ if (value === undefined) return undefined;
289
+ return requiredString(value, `input.${name}`);
290
+ }
291
+
292
+ function stringInput(input: OperationInput, name: string): string {
293
+ return requiredString(input[name], `input.${name}`);
294
+ }
295
+
296
+ function threadTexts(input: OperationInput): readonly string[] {
297
+ const value = input.items;
298
+ if (!Array.isArray(value)) throw new Error("input.items must be a string array");
299
+ const texts: string[] = [];
300
+ for (const item of value) {
301
+ if (
302
+ typeof item !== "string"
303
+ || item.length === 0
304
+ || item.length > MAX_X_CREATE_TWEET_TEXT_LENGTH
305
+ || /[\0\r]/u.test(item)
306
+ ) throw new Error("input.items must be a string array");
307
+ texts.push(item);
308
+ }
309
+ return texts;
310
+ }
311
+
312
+ function booleanInput(input: OperationInput, name: string): boolean {
313
+ const value = input[name];
314
+ if (typeof value !== "boolean") throw new Error(`input.${name} must be boolean`);
315
+ return value;
316
+ }
317
+
318
+ export type XWebRichArticleContentState = {
319
+ readonly blocks: readonly Readonly<Record<string, unknown>>[];
320
+ readonly entity_map: readonly Readonly<Record<string, unknown>>[];
321
+ };
322
+
323
+ /** Build X's current API content-state shape from the provider-neutral document. */
324
+ export function buildXWebRichArticleContentState(
325
+ document: ArticleDraftDocument | ArticleDraftDocumentV2,
326
+ uploadedImageIds: readonly string[] = Object.freeze([]),
327
+ ): XWebRichArticleContentState {
328
+ if (
329
+ uploadedImageIds.length > MAX_ARTICLE_INLINE_IMAGES
330
+ || uploadedImageIds.some((id) => !/^[0-9]{1,19}$/u.test(id))
331
+ ) throw new Error("X rich Article image uploads must be exact media IDs");
332
+ const entities: Readonly<Record<string, unknown>>[] = [];
333
+ const blocks: Readonly<Record<string, unknown>>[] = [];
334
+ const usedImages = new Set<number>();
335
+ const blockType = Object.freeze({
336
+ paragraph: "unstyled",
337
+ heading1: "header-one",
338
+ heading2: "header-two",
339
+ blockquote: "blockquote",
340
+ "unordered-list-item": "unordered-list-item",
341
+ "ordered-list-item": "ordered-list-item",
342
+ } as const);
343
+ const styleName = Object.freeze({ bold: "Bold", italic: "Italic", strikethrough: "Strikethrough" } as const);
344
+ for (const [blockIndex, block] of document.blocks.entries()) {
345
+ const key = blockIndex.toString(36).padStart(5, "0");
346
+ if (block.type === "image") {
347
+ const mediaId = uploadedImageIds[block.imageIndex];
348
+ if (mediaId === undefined || usedImages.has(block.imageIndex)) {
349
+ throw new Error("input.document imageIndex did not bind one uploaded inline image");
350
+ }
351
+ usedImages.add(block.imageIndex);
352
+ const entityKey = `${entities.length}`;
353
+ entities.push(Object.freeze({
354
+ key: entityKey,
355
+ value: Object.freeze({
356
+ data: Object.freeze({
357
+ ...(block.caption === undefined ? {} : { caption: block.caption }),
358
+ entity_key: entityKey,
359
+ media_items: Object.freeze([Object.freeze({
360
+ local_media_id: block.imageIndex + 1,
361
+ media_category: "DraftTweetImage",
362
+ media_id: mediaId,
363
+ })]),
364
+ }),
365
+ type: "MEDIA",
366
+ mutability: "Immutable",
367
+ }),
368
+ }));
369
+ blocks.push(Object.freeze({
370
+ data: Object.freeze({}),
371
+ key,
372
+ text: " ",
373
+ type: "atomic",
374
+ entity_ranges: Object.freeze([Object.freeze({
375
+ key: Number(entityKey),
376
+ offset: 0,
377
+ length: 1,
378
+ })]),
379
+ inline_style_ranges: Object.freeze([]),
380
+ }));
381
+ continue;
382
+ }
383
+ const entityRanges = block.links.map((link) => {
384
+ const key = entities.length;
385
+ entities.push(Object.freeze({
386
+ key: `${key}`,
387
+ value: Object.freeze({
388
+ data: Object.freeze({ url: link.url }),
389
+ type: "LINK",
390
+ mutability: "Mutable",
391
+ }),
392
+ }));
393
+ return Object.freeze({ key, offset: link.offset, length: link.length });
394
+ });
395
+ blocks.push(Object.freeze({
396
+ data: Object.freeze({}),
397
+ key,
398
+ text: block.text,
399
+ type: blockType[block.type],
400
+ entity_ranges: Object.freeze(entityRanges),
401
+ inline_style_ranges: Object.freeze(block.styles.map((style) => Object.freeze({
402
+ length: style.length,
403
+ offset: style.offset,
404
+ style: styleName[style.style],
405
+ }))),
406
+ }));
407
+ }
408
+ if (usedImages.size !== uploadedImageIds.length) {
409
+ throw new Error("every input.inline_images item must be referenced exactly once by input.document");
410
+ }
411
+ const contentState = Object.freeze({ blocks: Object.freeze(blocks), entity_map: Object.freeze(entities) });
412
+ validateXWebRichArticleContentState(contentState);
413
+ return contentState;
414
+ }
415
+
416
+ function integerInput(input: OperationInput, name: string, fallback: number, minimum: number, maximum: number): number {
417
+ const value = input[name] ?? fallback;
418
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
419
+ throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
420
+ }
421
+ return value as number;
422
+ }
423
+
424
+ function postId(value: unknown, label: string): string {
425
+ const id = requiredString(value, label, 19);
426
+ if (!/^[0-9]{1,19}$/u.test(id)) throw new Error(`${label} must be a 1-19 digit X post ID`);
427
+ return id;
428
+ }
429
+
430
+ function quotedStrings(value: string, label: string): readonly string[] {
431
+ if (value.trim() === "") return [];
432
+ const values = [...value.matchAll(/"([A-Za-z][A-Za-z0-9_]{0,199})"/gu)].map((match) => match[1]!);
433
+ const remainder = value.replace(/"[A-Za-z][A-Za-z0-9_]{0,199}"/gu, "").replaceAll(",", "").trim();
434
+ if (remainder !== "" || new Set(values).size !== values.length) {
435
+ throw new Error(`${label} contained an invalid or duplicate name`);
436
+ }
437
+ return values;
438
+ }
439
+
440
+ export function parseXWebBundleDescriptors(value: string): readonly XWebBundleQueryDescriptor[] {
441
+ if (value.length > MAX_BUNDLE_BYTES) throw new Error("X first-party bundle exceeded its byte limit");
442
+ const descriptors: XWebBundleQueryDescriptor[] = [];
443
+ const pattern = /queryId:"([A-Za-z0-9_-]{8,128})",operationName:"([A-Za-z][A-Za-z0-9_]{1,127})",operationType:"(query|mutation)",metadata:\{featureSwitches:\[([^\]]*)\],fieldToggles:\[([^\]]*)\]\}/gu;
444
+ for (const match of value.matchAll(pattern)) {
445
+ descriptors.push(Object.freeze({
446
+ queryId: match[1]!,
447
+ operationName: match[2]!,
448
+ operationType: match[3] as XWebOperationType,
449
+ metadata: Object.freeze({
450
+ featureSwitches: Object.freeze(quotedStrings(match[4]!, "X featureSwitches")),
451
+ fieldToggles: Object.freeze(quotedStrings(match[5]!, "X fieldToggles")),
452
+ }),
453
+ }));
454
+ }
455
+ if (descriptors.length < 1) throw new Error("X first-party bundle contained no reviewed query descriptors");
456
+ return Object.freeze(descriptors);
457
+ }
458
+
459
+ function currentMainUrl(html: string): URL {
460
+ const urls = new Set<string>();
461
+ for (const match of html.matchAll(/(?:src|href)="(https:\/\/abs\.twimg\.com\/responsive-web\/client-web\/main\.[A-Za-z0-9_-]+\.js)"/gu)) {
462
+ if (match[1] !== undefined) urls.add(match[1]);
463
+ }
464
+ if (urls.size !== 1) throw new Error("X bootstrap did not expose one unique current main bundle");
465
+ return new URL(urls.values().next().value!);
466
+ }
467
+
468
+ function sourceChunkLogicalName(sourceChunk: string): string {
469
+ // Historical 7-hex webpack-map hashes produced 8-hex asset names after the
470
+ // fixed trailing `a`; current 16-hex hashes produce 17-hex asset names.
471
+ const match = /^([A-Za-z0-9_~.-]{1,240})\.(?:[a-f0-9]{8}|[a-f0-9]{17})\.js$/u.exec(sourceChunk);
472
+ if (match?.[1] === undefined) throw new Error("X revision evidence source chunk is not a reviewed hashed JavaScript asset");
473
+ return match[1];
474
+ }
475
+
476
+ const ALLOWED_INSERTED_CHUNK_SEGMENT = /^(?:loader|bundle)\.[A-Za-z][A-Za-z0-9]{0,80}$/u;
477
+
478
+ function matchesReviewedChunkFamily(currentName: string, reviewedName: string): boolean {
479
+ if (
480
+ currentName === reviewedName
481
+ || currentName.startsWith(`${reviewedName}~`)
482
+ || reviewedName.startsWith(`${currentName}~`)
483
+ ) return true;
484
+ // X sometimes inserts extra `loader.*` / `bundle.*` members inside a reviewed
485
+ // shared family (Bookmarks is observed as
486
+ // `shared~loader.Dock~bundle.BookmarkFolders~bundle.Bookmarks~…`). Keep the
487
+ // reviewed segments in order, reject arbitrary prefixes, and still require a
488
+ // unique exact or uniquely-tightest webpack-map hit.
489
+ const reviewed = reviewedName.split("~");
490
+ const current = currentName.split("~");
491
+ if (reviewed.length === 0 || current.length < reviewed.length) return false;
492
+ let next = 0;
493
+ for (const segment of current) {
494
+ if (next < reviewed.length && segment === reviewed[next]) {
495
+ next += 1;
496
+ continue;
497
+ }
498
+ if (!ALLOWED_INSERTED_CHUNK_SEGMENT.test(segment)) return false;
499
+ }
500
+ return next === reviewed.length;
501
+ }
502
+
503
+ function setUniqueChunkMapValue(
504
+ target: Map<string, string>,
505
+ id: string,
506
+ value: string,
507
+ label: "name" | "hash",
508
+ ): void {
509
+ if (target.has(id)) throw new Error(`X bootstrap contained a duplicate webpack chunk ${label}`);
510
+ target.set(id, value);
511
+ }
512
+
513
+ function extraReviewedChunkSegments(currentName: string, reviewedName: string): number {
514
+ return currentName.split("~").length - reviewedName.split("~").length;
515
+ }
516
+
517
+ function uniqueReviewedChunkMatch(
518
+ names: ReadonlyMap<string, string>,
519
+ logicalName: string,
520
+ ): readonly [string, string] {
521
+ const exact = [...names].filter(([, name]) => name === logicalName);
522
+ if (exact.length === 1) return exact[0]!;
523
+ const family = exact.length === 0
524
+ ? [...names].filter(([, name]) => matchesReviewedChunkFamily(name, logicalName))
525
+ : exact;
526
+ if (family.length === 1) return family[0]!;
527
+ if (family.length === 0) {
528
+ throw new Error(
529
+ "X current build did not bind one unique reviewed logical chunk; reviewed evidence is stale",
530
+ );
531
+ }
532
+ let minimumExtras = Number.POSITIVE_INFINITY;
533
+ const tightest: Array<readonly [string, string]> = [];
534
+ for (const candidate of family) {
535
+ const extras = extraReviewedChunkSegments(candidate[1], logicalName);
536
+ if (extras < minimumExtras) {
537
+ minimumExtras = extras;
538
+ tightest.length = 0;
539
+ tightest.push(candidate);
540
+ continue;
541
+ }
542
+ if (extras === minimumExtras) tightest.push(candidate);
543
+ }
544
+ if (tightest.length !== 1) {
545
+ throw new Error(
546
+ "X current build did not bind one unique reviewed logical chunk; reviewed evidence is stale",
547
+ );
548
+ }
549
+ return tightest[0]!;
550
+ }
551
+
552
+ /** Resolve a reviewed logical chunk through the current page's webpack map. */
553
+ export function resolveCurrentXWebChunkUrl(html: string, sourceChunk: string): URL {
554
+ if (html.length > MAX_HOME_BYTES) throw new Error("X bootstrap exceeded its byte limit");
555
+ const logicalName = sourceChunkLogicalName(sourceChunk);
556
+ const start = html.indexOf("p.u=e=>");
557
+ const separator = "})[e]||e)+\".\"+({";
558
+ const middle = start < 0 ? -1 : html.indexOf(separator, start);
559
+ const suffix = middle < 0 ? -1 : html.indexOf("})[e]+\"a.js\"", middle + separator.length);
560
+ if (start < 0 || middle < 0 || suffix < 0 || suffix - start > 256 * 1024) {
561
+ throw new Error("X bootstrap omitted its bounded current webpack chunk map");
562
+ }
563
+ const names = new Map<string, string>();
564
+ for (const match of html.slice(start, middle).matchAll(/(?:\{|,)([0-9A-Za-z]+):"([A-Za-z0-9_~.-]{1,240})"/gu)) {
565
+ if (match[1] !== undefined && match[2] !== undefined) {
566
+ setUniqueChunkMapValue(names, match[1], match[2], "name");
567
+ }
568
+ }
569
+ // X may append or remove a `~bundle.*` member when it rebalances a shared
570
+ // chunk while retaining the reviewed chunk family and operation descriptor.
571
+ // Prefer the exact reviewed name. When that dedicated family is absent,
572
+ // keep only a uniquely tightest family match so overlapping Bookmarks
573
+ // mega-chunks cannot make a signed-in map fail closed.
574
+ const [id, currentName] = uniqueReviewedChunkMatch(names, logicalName);
575
+ const hashes = new Map<string, string>();
576
+ const hashStart = middle + separator.length;
577
+ // Historical maps used 7 hex chars. Current (2026-08) maps use 16. Accept
578
+ // only those reviewed widths so a truncated lookup cannot invent an asset URL.
579
+ for (const match of html.slice(hashStart, suffix).matchAll(/(?:^|,)([0-9A-Za-z]+):"([a-f0-9]{7}|[a-f0-9]{16})"/gu)) {
580
+ if (match[1] !== undefined && match[2] !== undefined) {
581
+ setUniqueChunkMapValue(hashes, match[1], match[2], "hash");
582
+ }
583
+ }
584
+ const hash = hashes.get(id);
585
+ if (hash === undefined) throw new Error("X current build omitted the reviewed logical chunk hash");
586
+ return new URL(`/responsive-web/client-web/${currentName}.${hash}a.js`, X_ASSET_ORIGIN);
587
+ }
588
+
589
+ function currentBearer(mainText: string): string {
590
+ const values = new Set<string>();
591
+ for (const match of mainText.matchAll(/AAAA[A-Za-z0-9._~+/%=-]{80,250}/gu)) {
592
+ try {
593
+ const decoded = decodeURIComponent(match[0]);
594
+ if (/^AAAA[A-Za-z0-9._~+/=-]{80,250}$/u.test(decoded)) values.add(decoded);
595
+ } catch {
596
+ // Ignore malformed public bundle literals.
597
+ }
598
+ }
599
+ if (values.size !== 1) throw new Error("X bootstrap did not expose one unique current web authorization value");
600
+ return values.values().next().value!;
601
+ }
602
+
603
+ function currentFeatureConfig(html: string): ReadonlyMap<string, unknown> {
604
+ const prefix = "window.__INITIAL_STATE__=";
605
+ const start = html.indexOf(prefix);
606
+ if (start < 0 || html.indexOf(prefix, start + prefix.length) >= 0) {
607
+ throw new Error("X bootstrap did not expose one unique initial-state payload");
608
+ }
609
+ const jsonStart = start + prefix.length;
610
+ const jsonEnd = html.indexOf(";window.__META_DATA__=", jsonStart);
611
+ if (jsonEnd < jsonStart || jsonEnd - jsonStart > MAX_HOME_BYTES) {
612
+ throw new Error("X bootstrap initial-state payload exceeded its reviewed boundary");
613
+ }
614
+ let parsed: unknown;
615
+ try {
616
+ parsed = JSON.parse(html.slice(jsonStart, jsonEnd)) as unknown;
617
+ } catch {
618
+ throw new Error("X bootstrap returned malformed initial-state JSON");
619
+ }
620
+ const root = record(parsed, "X initial state");
621
+ const featureSwitch = record(root.featureSwitch, "X initial state.featureSwitch");
622
+ const user = record(featureSwitch.user, "X initial state.featureSwitch.user");
623
+ const config = record(user.config, "X initial state.featureSwitch.user.config");
624
+ if (Object.keys(config).length > 10_000) throw new Error("X user feature configuration exceeded its reviewed limit");
625
+ return new Map(Object.entries(config));
626
+ }
627
+
628
+ async function bootstrapX(
629
+ auth: GhostgetAuth,
630
+ recipe: WebSessionRecipe,
631
+ dependencies?: XWebRuntimeDependencies,
632
+ budget: {
633
+ readonly signal?: AbortSignal;
634
+ readonly operationDeadline?: WebSessionOperationDeadline;
635
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
636
+ } = {},
637
+ ): Promise<XBootstrap> {
638
+ const client = await createWebSessionClient(X_ORIGIN, auth, {
639
+ timeoutMs: recipe.timeoutMs,
640
+ ...(budget.signal === undefined ? {} : { signal: budget.signal }),
641
+ ...(budget.operationDeadline === undefined
642
+ ? {}
643
+ : { operationDeadline: budget.operationDeadline }),
644
+ ...(dependencies === undefined ? {} : { dependencies }),
645
+ });
646
+ const csrf = webSessionCookie(client.cookies, "ct0");
647
+ if (!/^[A-Za-z0-9_-]{16,512}$/u.test(csrf)) throw new Error("X ct0 session cookie is invalid or expired");
648
+ const html = await client.requestText({
649
+ url: new URL("/home", X_ORIGIN),
650
+ headers: { accept: "text/html" },
651
+ expectedContentTypes: ["text/html"],
652
+ maxBytes: MAX_HOME_BYTES,
653
+ });
654
+ const mainUrl = currentMainUrl(html);
655
+ const mainText = await fetchPublicWebAsset(mainUrl, {
656
+ allowedOrigin: X_ASSET_ORIGIN,
657
+ contentTypes: ["application/javascript", "text/javascript"],
658
+ maxBytes: MAX_BUNDLE_BYTES,
659
+ timeoutMs: recipe.timeoutMs,
660
+ ...(budget.signal === undefined ? {} : { signal: budget.signal }),
661
+ ...(budget.operationDeadline === undefined
662
+ ? {}
663
+ : { operationDeadline: budget.operationDeadline }),
664
+ ...(dependencies === undefined ? {} : { dependencies }),
665
+ });
666
+ return {
667
+ auth,
668
+ client,
669
+ html,
670
+ mainUrl,
671
+ mainText,
672
+ bearer: currentBearer(mainText),
673
+ csrf,
674
+ features: currentFeatureConfig(html),
675
+ timeoutMs: recipe.timeoutMs,
676
+ maxOutputBytes: recipe.maxOutputBytes,
677
+ ...(budget.signal === undefined ? {} : { signal: budget.signal }),
678
+ ...(budget.operationDeadline === undefined
679
+ ? {}
680
+ : { operationDeadline: budget.operationDeadline }),
681
+ ...(budget.registerCleanupBarrier === undefined
682
+ ? {}
683
+ : { registerCleanupBarrier: budget.registerCleanupBarrier }),
684
+ ...(dependencies === undefined ? {} : { dependencies }),
685
+ chunks: new Map([[mainUrl.pathname.split("/").at(-1)!, mainText]]),
686
+ descriptors: new Map(),
687
+ };
688
+ }
689
+
690
+ function descriptorEvidence(operationName: string, operationType: XWebOperationType): XWebQueryDescriptorEvidence {
691
+ const matches = xWebQueryDescriptorEvidenceSnapshot.descriptors.filter((candidate) =>
692
+ candidate.operationName === operationName && candidate.operationType === operationType);
693
+ if (matches.length !== 1) throw new Error(`X ${operationName} has no unique reviewed revision evidence`);
694
+ return matches[0]!;
695
+ }
696
+
697
+ async function sourceText(bootstrap: XBootstrap, evidence: XWebQueryDescriptorEvidence): Promise<string> {
698
+ return currentChunkText(bootstrap, evidence.sourceChunk);
699
+ }
700
+
701
+ async function currentChunkText(bootstrap: XBootstrap, sourceChunk: string): Promise<string> {
702
+ if (sourceChunk.startsWith("main.")) return bootstrap.mainText;
703
+ const cached = bootstrap.chunks.get(sourceChunk);
704
+ if (cached !== undefined) return cached;
705
+ const currentUrl = resolveCurrentXWebChunkUrl(bootstrap.html, sourceChunk);
706
+ const text = await fetchPublicWebAsset(
707
+ currentUrl,
708
+ {
709
+ allowedOrigin: X_ASSET_ORIGIN,
710
+ contentTypes: ["application/javascript", "text/javascript"],
711
+ maxBytes: MAX_BUNDLE_BYTES,
712
+ timeoutMs: bootstrap.timeoutMs,
713
+ ...(bootstrap.signal === undefined ? {} : { signal: bootstrap.signal }),
714
+ ...(bootstrap.operationDeadline === undefined
715
+ ? {}
716
+ : { operationDeadline: bootstrap.operationDeadline }),
717
+ ...(bootstrap.dependencies === undefined ? {} : { dependencies: bootstrap.dependencies }),
718
+ },
719
+ );
720
+ bootstrap.chunks.set(sourceChunk, text);
721
+ return text;
722
+ }
723
+
724
+ const articleRichContractEvidence = Object.freeze({
725
+ uploader: "shared~bundle.LoggedInMain~ondemand.HoverCard~loader.AudioDock~loader.Dock~bundle.BookmarkFolders~bundle.Book.a9bac6ba.js",
726
+ entities: "shared~bundle.TwitterArticles~ondemand.Verified~bundle.SettingsExtendedProfile~bundle.WorkHistory.d1314bba.js",
727
+ converter: "shared~bundle.Grok~bundle.GrokDrawer~bundle.ReaderMode~bundle.Birdwatch~bundle.TwitterArticles~bundle.Compose.02f6dc7a.js",
728
+ observedOn: "2026-08-14",
729
+ });
730
+
731
+ function requireCurrentBundleTokens(text: string, tokens: readonly string[], label: string): void {
732
+ if (tokens.some((token) => !text.includes(token))) {
733
+ throw new Error(`X current ${label} bundle drifted outside the reviewed rich Article contract`);
734
+ }
735
+ }
736
+
737
+ async function assertCurrentArticleRichContract(
738
+ bootstrap: XBootstrap,
739
+ includeImages: boolean,
740
+ ): Promise<void> {
741
+ const [entities, converter] = await Promise.all([
742
+ currentChunkText(bootstrap, articleRichContractEvidence.entities),
743
+ currentChunkText(bootstrap, articleRichContractEvidence.converter),
744
+ ]);
745
+ requireCurrentBundleTokens(entities, [
746
+ 'createEntity(w.Sg,"MUTABLE",{url:',
747
+ ], "Article entity");
748
+ requireCurrentBundleTokens(converter, [
749
+ 'mutability:s[r.mutability]',
750
+ 'inline_style_ranges:',
751
+ ], "Article content converter");
752
+ if (!includeImages) return;
753
+ const uploader = await currentChunkText(
754
+ bootstrap,
755
+ articleRichContractEvidence.uploader,
756
+ );
757
+ requireCurrentBundleTokens(uploader, [
758
+ '"upload.x.com"',
759
+ '"upload-a.x.com"',
760
+ '"upload-b.x.com"',
761
+ '/i/media/${l}',
762
+ '"INIT"',
763
+ '"APPEND"',
764
+ '"FINALIZE"',
765
+ 'media_category=${p}',
766
+ 'TweetImage:"tweet_image"',
767
+ 'TwitterArticle:"twitter_article"',
768
+ ], "media uploader");
769
+ requireCurrentBundleTokens(entities, [
770
+ 'createEntity(p.LA.MEDIA,p.Ei.IMMUTABLE',
771
+ 'mediaCategory:E(e)',
772
+ 'mediaId:e.uploadId',
773
+ ], "Article entity");
774
+ requireCurrentBundleTokens(converter, [
775
+ 'media_items:r.data?.mediaItems?.map',
776
+ 'media_category:e.mediaCategory',
777
+ ], "Article content converter");
778
+ }
779
+
780
+ async function resolveDescriptor(
781
+ bootstrap: XBootstrap,
782
+ operationName: string,
783
+ operationType: XWebOperationType,
784
+ explicitEvidence?: XWebQueryDescriptorEvidence,
785
+ ): Promise<XWebBundleQueryDescriptor> {
786
+ const key = `${operationName}:${operationType}`;
787
+ const cached = bootstrap.descriptors.get(key);
788
+ if (cached !== undefined) return cached;
789
+ const evidence = explicitEvidence ?? descriptorEvidence(operationName, operationType);
790
+ const descriptor = resolveUniqueXWebBundleDescriptor(
791
+ parseXWebBundleDescriptors(await sourceText(bootstrap, evidence)),
792
+ evidence,
793
+ );
794
+ bootstrap.descriptors.set(key, descriptor);
795
+ return descriptor;
796
+ }
797
+
798
+ function featureValue(bootstrap: XBootstrap, name: string): boolean {
799
+ const entry = bootstrap.features.get(name);
800
+ if (entry === undefined) return false;
801
+ if (!isRecord(entry) || typeof entry.value !== "boolean") {
802
+ throw new Error(`X user feature configuration changed type for feature ${name}`);
803
+ }
804
+ return entry.value;
805
+ }
806
+
807
+ function featureStringValue(bootstrap: XBootstrap, name: string): string | undefined {
808
+ const entry = bootstrap.features.get(name);
809
+ if (entry === undefined) return undefined;
810
+ if (!isRecord(entry) || (entry.value !== undefined && typeof entry.value !== "string")) {
811
+ throw new Error(`X user feature configuration changed type for feature ${name}`);
812
+ }
813
+ return entry.value as string | undefined;
814
+ }
815
+
816
+ function articleUploadOrigin(bootstrap: XBootstrap): string {
817
+ const configured = featureStringValue(bootstrap, "responsive_web_media_upload_host");
818
+ if (configured === undefined) return X_DEFAULT_ARTICLE_UPLOAD_ORIGIN;
819
+ if (!X_ARTICLE_UPLOAD_HOSTS.has(configured)) {
820
+ throw new Error("X selected an unreviewed media upload host");
821
+ }
822
+ return new URL(`https://${configured}`).origin;
823
+ }
824
+
825
+ function fieldToggleValue(bootstrap: XBootstrap, name: string): boolean {
826
+ if (name === "withArticlePlainText" || name === "withPayments" || name === "withAuxiliaryUserLabels" || name === "isDelegate") {
827
+ return false;
828
+ }
829
+ if (name === "withArticleRichContentState") {
830
+ return featureValue(bootstrap, "responsive_web_twitter_article_seed_tweet_detail_enabled");
831
+ }
832
+ if (name === "withArticleSummaryText" || name === "withArticleVoiceOver") {
833
+ return featureValue(bootstrap, "responsive_web_grok_article_summary_enabled");
834
+ }
835
+ if (name === "withGrokAnalyze") return featureValue(bootstrap, "subscriptions_inapp_grok_analyze");
836
+ if (name === "withDisallowedReplyControls") {
837
+ return featureValue(bootstrap, "disallowed_reply_controls_callout_enabled");
838
+ }
839
+ throw new Error(`X field toggle ${name} requires fresh reviewed evidence`);
840
+ }
841
+
842
+ function operationMetadata(bootstrap: XBootstrap, descriptor: XWebBundleQueryDescriptor): {
843
+ readonly features: Readonly<Record<string, boolean>>;
844
+ readonly fieldToggles: Readonly<Record<string, boolean>>;
845
+ } {
846
+ const features = Object.fromEntries(descriptor.metadata.featureSwitches.map((name) => [name, featureValue(bootstrap, name)]));
847
+ const fieldToggles: Record<string, boolean> = {};
848
+ for (const name of descriptor.metadata.fieldToggles) {
849
+ fieldToggles[name] = fieldToggleValue(bootstrap, name);
850
+ }
851
+ return bindXWebOperationMetadataValues(descriptor, { features, fieldToggles });
852
+ }
853
+
854
+ function xHeaders(
855
+ bootstrap: XBootstrap,
856
+ json: boolean,
857
+ transactionId?: string,
858
+ ): Readonly<Record<string, string>> {
859
+ const fixed = enforceXWebHeaderSinkPolicy({
860
+ source: "code",
861
+ sink: "network-request",
862
+ headers: {
863
+ accept: "application/json",
864
+ "x-twitter-auth-type": "OAuth2Session",
865
+ "x-twitter-active-user": "yes",
866
+ "x-twitter-client-language": "en",
867
+ ...(json ? { "content-type": "application/json" } : {}),
868
+ },
869
+ }).values;
870
+ const session = enforceXWebHeaderSinkPolicy({
871
+ source: "in-origin-session",
872
+ sink: "network-request",
873
+ headers: {
874
+ authorization: `Bearer ${bootstrap.bearer}`,
875
+ "x-csrf-token": bootstrap.csrf,
876
+ ...(transactionId === undefined ? {} : { "x-client-transaction-id": transactionId }),
877
+ },
878
+ }).values;
879
+ return { ...fixed, ...session, referer: `${X_ORIGIN}/` };
880
+ }
881
+
882
+ async function graphQl(
883
+ bootstrap: XBootstrap,
884
+ descriptor: XWebBundleQueryDescriptor,
885
+ variables: Readonly<Record<string, unknown>>,
886
+ method: "GET" | "POST",
887
+ readOperation?: XWebSemanticOperationId,
888
+ mutationOperation?: XWebMutationOperationId,
889
+ beforeRequest?: () => Promise<void>,
890
+ ): Promise<unknown> {
891
+ const metadata = operationMetadata(bootstrap, descriptor);
892
+ const url = new URL(`/i/api/graphql/${descriptor.queryId}/${descriptor.operationName}`, X_ORIGIN);
893
+ let body: string | undefined;
894
+ if (method === "GET") {
895
+ url.searchParams.set("variables", JSON.stringify(variables));
896
+ if (Object.keys(metadata.features).length > 0) url.searchParams.set("features", JSON.stringify(metadata.features));
897
+ if (Object.keys(metadata.fieldToggles).length > 0) url.searchParams.set("fieldToggles", JSON.stringify(metadata.fieldToggles));
898
+ } else {
899
+ body = JSON.stringify({
900
+ variables,
901
+ features: metadata.features,
902
+ ...(Object.keys(metadata.fieldToggles).length === 0 ? {} : { fieldToggles: metadata.fieldToggles }),
903
+ queryId: descriptor.queryId,
904
+ });
905
+ }
906
+ let mutationBinding: ReturnType<typeof authorizeXWebMutationRequest> | null = null;
907
+ if (mutationOperation !== undefined) {
908
+ mutationBinding = authorizeXWebMutationRequest(
909
+ mutationOperation,
910
+ { url, method, descriptor, ...(body === undefined ? {} : { body }) },
911
+ );
912
+ } else if (readOperation === undefined) {
913
+ assertExactXWebGraphQlBinding({ url, method, descriptor, ...(body === undefined ? {} : { body }) });
914
+ } else {
915
+ authorizeXWebR1GraphQlRequest(readOperation, { url, method, descriptor, ...(body === undefined ? {} : { body }) });
916
+ }
917
+ let transactionId: string | undefined;
918
+ if (mutationBinding !== null) {
919
+ const transaction = startWebSessionCleanupTrackedOperation(
920
+ bootstrap.registerCleanupBarrier,
921
+ (publishCleanupResource) => generateXClientTransactionId({
922
+ auth: bootstrap.auth,
923
+ mainBundleText: bootstrap.mainText,
924
+ mainBundleUrl: bootstrap.mainUrl,
925
+ method: "POST",
926
+ path: mutationBinding.path,
927
+ timeoutMs: bootstrap.timeoutMs,
928
+ maxOutputBytes: bootstrap.maxOutputBytes,
929
+ ...(bootstrap.operationDeadline === undefined
930
+ ? {}
931
+ : { operationDeadline: bootstrap.operationDeadline }),
932
+ ...(publishCleanupResource === undefined
933
+ ? {}
934
+ : { publishCleanupResource }),
935
+ dependencies: {
936
+ ...(bootstrap.dependencies?.createBrowserSession === undefined
937
+ ? {}
938
+ : { createBrowserSession: bootstrap.dependencies.createBrowserSession }),
939
+ ...(bootstrap.dependencies?.acquireCookies === undefined
940
+ ? {}
941
+ : { acquireCookieRecords: bootstrap.dependencies.acquireCookies }),
942
+ },
943
+ }),
944
+ browserCleanupBarrier,
945
+ );
946
+ transactionId = await transaction;
947
+ }
948
+ await beforeRequest?.();
949
+ return bootstrap.client.requestJson({
950
+ url,
951
+ method,
952
+ headers: xHeaders(bootstrap, body !== undefined, transactionId),
953
+ ...(body === undefined ? {} : { body }),
954
+ maxBytes: bootstrap.maxOutputBytes,
955
+ });
956
+ }
957
+
958
+ async function viewer(bootstrap: XBootstrap): Promise<Viewer> {
959
+ const descriptor = await resolveDescriptor(bootstrap, "Viewer", "query", viewerEvidence);
960
+ const response = record(await graphQl(bootstrap, descriptor, {}, "GET"), "X Viewer response");
961
+ if (response.errors !== undefined && (!Array.isArray(response.errors) || response.errors.length > 0)) {
962
+ throw new Error("X Viewer response contained provider errors");
963
+ }
964
+ const data = record(response.data, "X Viewer response.data");
965
+ const viewerRoot = record(data.viewer, "X Viewer response.data.viewer");
966
+ const userResults = record(viewerRoot.user_results, "X Viewer user_results");
967
+ const result = record(userResults.result, "X Viewer result");
968
+ const id = postId(result.rest_id, "X Viewer rest_id");
969
+ const core = isRecord(result.core) ? result.core : null;
970
+ const legacy = isRecord(result.legacy) ? result.legacy : null;
971
+ const screenName = typeof core?.screen_name === "string"
972
+ ? core.screen_name
973
+ : typeof legacy?.screen_name === "string" ? legacy.screen_name : null;
974
+ return { id, screenName };
975
+ }
976
+
977
+ export async function probeXWebSubject(
978
+ auth: GhostgetAuth,
979
+ options: {
980
+ readonly timeoutMs?: number;
981
+ readonly dependencies?: XWebRuntimeDependencies;
982
+ readonly signal?: AbortSignal;
983
+ } = {},
984
+ ): Promise<string> {
985
+ const bootstrap = await bootstrapX(auth, {
986
+ site: "x",
987
+ action: "feeds.read",
988
+ contractVersion: 1,
989
+ timeoutMs: options.timeoutMs ?? 60_000,
990
+ maxOutputBytes: 2 * 1024 * 1024,
991
+ }, options.dependencies, {
992
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
993
+ });
994
+ return (await viewer(bootstrap)).id;
995
+ }
996
+
997
+ async function requireBoundViewer(bootstrap: XBootstrap, auth: GhostgetAuth): Promise<Viewer> {
998
+ const expected = webSessionAuthSubject(auth);
999
+ if (expected === null) {
1000
+ throw new Error("X personalized operations require an auth locator bound to the exact viewer subject");
1001
+ }
1002
+ const current = await viewer(bootstrap);
1003
+ if (current.id !== expected) throw new Error("X browser session viewer no longer matches the confirmed auth subject");
1004
+ return current;
1005
+ }
1006
+
1007
+ function feedRequest(bootstrap: XBootstrap, input: OperationInput): FeedRequest {
1008
+ const feed = stringInput(input, "feed");
1009
+ const count = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
1010
+ const cursor = optionalStringInput(input, "cursor");
1011
+ const withCursor = (value: Record<string, unknown>): Readonly<Record<string, unknown>> =>
1012
+ cursor === undefined ? value : { ...value, cursor };
1013
+ if (feed === "for-you") {
1014
+ return {
1015
+ operationId: "feeds.for-you",
1016
+ operationName: "HomeTimeline",
1017
+ method: "GET",
1018
+ variables: withCursor({ count, includePromotedContent: false, latestControlAvailable: true, requestContext: "launch", withCommunity: true }),
1019
+ };
1020
+ }
1021
+ if (feed === "following") {
1022
+ return {
1023
+ operationId: "feeds.following",
1024
+ operationName: "HomeLatestTimeline",
1025
+ method: "POST",
1026
+ variables: withCursor({ count, includePromotedContent: false, latestControlAvailable: true, requestContext: "launch", seenTweetIds: [] }),
1027
+ };
1028
+ }
1029
+ if (feed === "bookmarks") {
1030
+ return {
1031
+ operationId: "feeds.bookmarks",
1032
+ operationName: "Bookmarks",
1033
+ method: "GET",
1034
+ variables: withCursor({ count, includePromotedContent: false }),
1035
+ };
1036
+ }
1037
+ if (feed === "list") {
1038
+ const listId = postId(input.list_id, "input.list_id");
1039
+ return {
1040
+ operationId: "feeds.list-latest",
1041
+ operationName: "ListLatestTweetsTimeline",
1042
+ method: "GET",
1043
+ variables: withCursor({ listId, count }),
1044
+ };
1045
+ }
1046
+ if (feed === "user") {
1047
+ const userId = postId(input.user_id, "input.user_id");
1048
+ return {
1049
+ operationId: "feeds.user",
1050
+ operationName: "UserTweets",
1051
+ method: "GET",
1052
+ variables: withCursor({
1053
+ userId,
1054
+ count,
1055
+ includePromotedContent: true,
1056
+ withQuickPromoteEligibilityTweetFields: true,
1057
+ withVoice: featureValue(bootstrap, "voice_consumption_enabled"),
1058
+ }),
1059
+ };
1060
+ }
1061
+ if (feed === "search") {
1062
+ const rawQuery = stringInput(input, "query");
1063
+ return {
1064
+ operationId: "feeds.search",
1065
+ operationName: "SearchTimeline",
1066
+ method: "GET",
1067
+ variables: withCursor({ rawQuery, count, querySource: "typed_query", product: "Latest" }),
1068
+ };
1069
+ }
1070
+ throw new Error("input.feed is not an implemented X internal feed");
1071
+ }
1072
+
1073
+ function assertFeedTargetBound(
1074
+ request: FeedRequest,
1075
+ input: OperationInput,
1076
+ response: unknown,
1077
+ ): void {
1078
+ if (request.operationId === "feeds.user") {
1079
+ assertXWebUserFeedTargetBound(response, input.user_id);
1080
+ return;
1081
+ }
1082
+ if (request.operationId !== "feeds.list-latest") return;
1083
+ const data = graphQlData(response, `X ${request.operationId} response`);
1084
+
1085
+ const expected = postId(input.list_id, "input.list_id");
1086
+ const list = record(data.list, "X List feed response.data.list");
1087
+ const returnedIds = new Set<string>();
1088
+ for (const field of ["rest_id", "id_str", "id"] as const) {
1089
+ if (list[field] === undefined) continue;
1090
+ returnedIds.add(postId(list[field], `X List feed response ${field}`));
1091
+ }
1092
+ if (returnedIds.size !== 1 || !returnedIds.has(expected)) {
1093
+ throw new Error("X List feed response did not bind the requested List");
1094
+ }
1095
+ }
1096
+
1097
+ async function readFeed(bootstrap: XBootstrap, input: OperationInput): Promise<unknown> {
1098
+ const request = feedRequest(bootstrap, input);
1099
+ const descriptor = await resolveDescriptor(bootstrap, request.operationName, "query");
1100
+ const response = await graphQl(bootstrap, descriptor, request.variables, request.method, request.operationId);
1101
+ assertFeedTargetBound(request, input, response);
1102
+ const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
1103
+ if (request.operationId === "feeds.bookmarks") {
1104
+ return projectXWebBookmarkExportPage(response, limit);
1105
+ }
1106
+ return projectXWebFeedPage(request.operationId, response, limit);
1107
+ }
1108
+
1109
+ async function readProfile(
1110
+ bootstrap: XBootstrap,
1111
+ input: OperationInput,
1112
+ ): Promise<ReturnType<typeof projectXWebProfileStats>> {
1113
+ const handle = normalizeXWebProfileHandle(input.handle);
1114
+ const descriptor = await resolveDescriptor(
1115
+ bootstrap,
1116
+ "UserByScreenName",
1117
+ "query",
1118
+ );
1119
+ const response = await graphQl(
1120
+ bootstrap,
1121
+ descriptor,
1122
+ {
1123
+ screen_name: handle,
1124
+ withGrokTranslatedBio: featureValue(
1125
+ bootstrap,
1126
+ "responsive_web_grok_bio_auto_translation_is_enabled",
1127
+ ),
1128
+ },
1129
+ "GET",
1130
+ "profiles.by-handle",
1131
+ );
1132
+ return projectXWebProfileStats(
1133
+ response,
1134
+ handle,
1135
+ new Date(bootstrap.dependencies?.now?.() ?? Date.now()).toISOString(),
1136
+ );
1137
+ }
1138
+
1139
+ function tweetDetailVariables(bootstrap: XBootstrap, id: string, input: OperationInput): Readonly<Record<string, unknown>> {
1140
+ const cursor = optionalStringInput(input, "cursor");
1141
+ return {
1142
+ focalTweetId: id,
1143
+ referrer: "tweet",
1144
+ with_rux_injections: false,
1145
+ includePromotedContent: false,
1146
+ rankingMode: "Recency",
1147
+ withCommunity: featureValue(bootstrap, "c9s_enabled"),
1148
+ withQuickPromoteEligibilityTweetFields: true,
1149
+ withBirdwatchNotes: featureValue(bootstrap, "responsive_web_birdwatch_consumption_enabled"),
1150
+ withVoice: featureValue(bootstrap, "voice_consumption_enabled"),
1151
+ ...(cursor === undefined ? {} : { cursor }),
1152
+ };
1153
+ }
1154
+
1155
+ async function readConversation(bootstrap: XBootstrap, input: OperationInput, commentsOnly: boolean): Promise<unknown> {
1156
+ const id = postId(input.post_id, "input.post_id");
1157
+ const descriptor = await resolveDescriptor(bootstrap, "TweetDetail", "query");
1158
+ const response = await graphQl(bootstrap, descriptor, tweetDetailVariables(bootstrap, id, input), "GET", "posts.detail");
1159
+ const normalized = normalizeXWebGraphQlTimelineResponse("posts.detail", response);
1160
+ const posts = normalized.items
1161
+ .map(projectXWebFeedPost)
1162
+ .filter((row): row is NonNullable<typeof row> => row !== null);
1163
+ const root = posts.find((row) => row.id === id);
1164
+ if (root === undefined) throw new Error("X TweetDetail response did not contain the requested focal post");
1165
+ if (!commentsOnly) return { post: root };
1166
+ const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
1167
+ const descendants: XWebProjectedPost[] = [];
1168
+ const acceptedIds = new Set([id]);
1169
+ let changed = true;
1170
+ while (changed) {
1171
+ changed = false;
1172
+ for (const row of posts) {
1173
+ if (row.id === id || acceptedIds.has(row.id)) continue;
1174
+ if (row.replyToPostId === null || !acceptedIds.has(row.replyToPostId)) continue;
1175
+ acceptedIds.add(row.id);
1176
+ descendants.push(row);
1177
+ changed = true;
1178
+ }
1179
+ }
1180
+ const page = boundXWebProviderPage(
1181
+ descendants,
1182
+ limit,
1183
+ normalized.cursors.bottom !== null,
1184
+ "X conversation page",
1185
+ );
1186
+ return {
1187
+ comments: page.items,
1188
+ cursor: page.truncated ? null : normalized.cursors.bottom?.value ?? null,
1189
+ };
1190
+ }
1191
+
1192
+ function graphQlData(value: unknown, label: string): JsonRecord {
1193
+ const body = record(value, label);
1194
+ if (body.errors !== undefined) {
1195
+ if (!Array.isArray(body.errors) || body.errors.length > 0) throw new Error(`${label} contained provider errors`);
1196
+ }
1197
+ return record(body.data, `${label}.data`);
1198
+ }
1199
+
1200
+ function unwrapTweet(value: unknown, label: string): JsonRecord {
1201
+ let result = record(value, label);
1202
+ if (isRecord(result.tweet)) result = result.tweet;
1203
+ if (result.__typename === "TweetWithVisibilityResults" && isRecord(result.tweet)) result = result.tweet;
1204
+ return result;
1205
+ }
1206
+
1207
+ async function tweetReadback(bootstrap: XBootstrap, id: string): Promise<JsonRecord> {
1208
+ const descriptor = await resolveDescriptor(bootstrap, "TweetResultByRestId", "query");
1209
+ const response = await graphQl(bootstrap, descriptor, {
1210
+ tweetId: id,
1211
+ withCommunity: false,
1212
+ includePromotedContent: false,
1213
+ withVoice: false,
1214
+ }, "GET", "posts.by-id");
1215
+ const result = unwrapTweet(extractXWebGraphQlReadResponseRoot("posts.by-id", response), "X post readback result");
1216
+ if (postId(result.rest_id, "X post readback rest_id") !== id) {
1217
+ throw new Error("X desired-state readback did not bind the requested post");
1218
+ }
1219
+ return result;
1220
+ }
1221
+
1222
+ async function waitForTweetPublishReadback(
1223
+ bootstrap: XBootstrap,
1224
+ id: string,
1225
+ sleep: (milliseconds: number) => Promise<void>,
1226
+ ): Promise<JsonRecord> {
1227
+ let lastError: unknown;
1228
+ for (const delay of PUBLISH_READBACK_DELAYS_MS) {
1229
+ if (delay > 0) {
1230
+ const pause = () => sleep(delay);
1231
+ if (bootstrap.operationDeadline === undefined) await pause();
1232
+ else await bootstrap.operationDeadline.run(
1233
+ pause,
1234
+ "authenticated web operation deadline",
1235
+ );
1236
+ }
1237
+ try {
1238
+ return await tweetReadback(bootstrap, id);
1239
+ } catch (error) {
1240
+ lastError = error;
1241
+ }
1242
+ }
1243
+ throw new Error("X independent post readback did not settle within the reviewed bound", {
1244
+ cause: lastError,
1245
+ });
1246
+ }
1247
+
1248
+ type XWebPublishedMutationTarget = {
1249
+ readonly postId: string;
1250
+ readonly mediaId: string | null;
1251
+ };
1252
+
1253
+ function parseXWebPublishedMutationTarget(
1254
+ identifier: string,
1255
+ ): XWebPublishedMutationTarget {
1256
+ let value: unknown;
1257
+ try {
1258
+ value = JSON.parse(identifier);
1259
+ } catch {
1260
+ throw new Error("X provider-accepted post target is not canonical JSON");
1261
+ }
1262
+ const target = record(value, "X provider-accepted post target");
1263
+ if (Object.keys(target).sort().join(",") !== "mediaId,postId") {
1264
+ throw new Error("X provider-accepted post target contained unsupported fields");
1265
+ }
1266
+ const postIdValue = postId(
1267
+ target.postId,
1268
+ "X provider-accepted post target post ID",
1269
+ );
1270
+ const mediaId = target.mediaId === null
1271
+ ? null
1272
+ : xMediaId(
1273
+ target.mediaId,
1274
+ "X provider-accepted post target media ID",
1275
+ );
1276
+ const parsed = Object.freeze({ postId: postIdValue, mediaId });
1277
+ if (canonicalJson(parsed) !== identifier) {
1278
+ throw new Error("X provider-accepted post target is not canonical");
1279
+ }
1280
+ return parsed;
1281
+ }
1282
+
1283
+ /**
1284
+ * Reconcile one exact response-bound X publish target with reviewed reads
1285
+ * only. Absence or drift throws so the kernel retains the unsettled ledger.
1286
+ */
1287
+ export async function readXWebPublishedMutationTarget(
1288
+ recipe: WebSessionRecipe,
1289
+ input: OperationInput,
1290
+ auth: GhostgetAuth,
1291
+ identifier: string,
1292
+ options: {
1293
+ readonly dependencies?: XWebRuntimeDependencies;
1294
+ } = {},
1295
+ ): Promise<{ readonly present: true; readonly postId: string }> {
1296
+ if (
1297
+ recipe.site !== "x"
1298
+ || recipe.action !== "posts.publish"
1299
+ || (recipe.contractVersion !== 3 && recipe.contractVersion !== 4 && recipe.contractVersion !== 5)
1300
+ ) {
1301
+ throw new Error("X publish recovery supports only posts.publish@3, posts.publish@4, or posts.publish@5");
1302
+ }
1303
+ const target = parseXWebPublishedMutationTarget(identifier);
1304
+ const hasMedia = input.media !== undefined;
1305
+ const mediaType = xPublishMediaType(input.media_type, hasMedia);
1306
+ if (hasMedia !== (target.mediaId !== null)) {
1307
+ throw new Error("X provider-accepted post target did not bind the confirmed attachment shape");
1308
+ }
1309
+ const text = requiredString(input.body, "input.body", MAX_X_CREATE_TWEET_TEXT_LENGTH);
1310
+ const bootstrap = await bootstrapX(auth, recipe, options.dependencies);
1311
+ const viewer = await requireBoundViewer(bootstrap, auth);
1312
+ const readback = await tweetReadback(bootstrap, target.postId);
1313
+ const rebound = assertTweetBinding(
1314
+ readback,
1315
+ text,
1316
+ null,
1317
+ null,
1318
+ viewer.id,
1319
+ target.mediaId,
1320
+ mediaType,
1321
+ "X publish recovery readback",
1322
+ );
1323
+ if (rebound.id !== target.postId) {
1324
+ throw new Error("X publish recovery readback changed the accepted post ID");
1325
+ }
1326
+ return Object.freeze({ present: true, postId: target.postId });
1327
+ }
1328
+
1329
+ async function desiredStateReadback(
1330
+ bootstrap: XBootstrap,
1331
+ id: string,
1332
+ kind: XWebDesiredStateKind | "repost",
1333
+ ): Promise<boolean> {
1334
+ const result = await tweetReadback(bootstrap, id);
1335
+ const legacy = record(result.legacy, "X desired-state readback legacy");
1336
+ const actual = kind === "like"
1337
+ ? legacy.favorited
1338
+ : kind === "bookmark" ? legacy.bookmarked : legacy.retweeted;
1339
+ if (typeof actual !== "boolean") {
1340
+ throw new Error(`X desired-state readback omitted the exact ${kind} boolean`);
1341
+ }
1342
+ return actual;
1343
+ }
1344
+
1345
+ /**
1346
+ * Independently observe one X desired-state mutation target. This path only
1347
+ * performs the reviewed viewer and TweetResultByRestId reads; it cannot resolve
1348
+ * a mutation descriptor, create a transaction ID, or submit a provider write.
1349
+ */
1350
+ export async function readXWebDesiredState(
1351
+ recipe: WebSessionRecipe,
1352
+ input: OperationInput,
1353
+ auth: GhostgetAuth,
1354
+ options: {
1355
+ readonly dependencies?: XWebRuntimeDependencies;
1356
+ } = {},
1357
+ ): Promise<XWebDesiredStateReadback> {
1358
+ if (recipe.site !== "x" || (recipe.action !== "likes.set" && recipe.action !== "content.save")) {
1359
+ throw new Error("X recovery readback supports only likes.set and content.save");
1360
+ }
1361
+ const bootstrap = await bootstrapX(auth, recipe, options.dependencies);
1362
+ await requireBoundViewer(bootstrap, auth);
1363
+ const id = postId(input.post_id, "input.post_id");
1364
+ const kind: XWebDesiredStateKind = recipe.action === "likes.set" ? "like" : "bookmark";
1365
+ return {
1366
+ kind,
1367
+ enabled: await desiredStateReadback(bootstrap, id, kind),
1368
+ postId: id,
1369
+ };
1370
+ }
1371
+
1372
+ export async function readXWebArticleDraftDesiredState(
1373
+ recipe: WebSessionRecipe,
1374
+ input: OperationInput,
1375
+ auth: GhostgetAuth,
1376
+ options: {
1377
+ readonly dependencies?: XWebRuntimeDependencies;
1378
+ } = {},
1379
+ ): Promise<{
1380
+ readonly matches: boolean;
1381
+ readonly draftId: string;
1382
+ }> {
1383
+ if (
1384
+ recipe.site !== "x"
1385
+ || recipe.action !== "articles.draft.save"
1386
+ || recipe.contractVersion !== 1
1387
+ ) {
1388
+ throw new Error("X Article draft recovery supports only articles.draft.save@1");
1389
+ }
1390
+ const draftId = postId(input.draft_id, "input.draft_id");
1391
+ const title = requiredString(
1392
+ input.title,
1393
+ "input.title",
1394
+ MAX_ARTICLE_TITLE_CHARACTERS,
1395
+ );
1396
+ const document = parseArticleDraftDocument(input.document, {
1397
+ maximumBlocks: MAX_ARTICLE_BLOCKS,
1398
+ maximumCharacters: MAX_ARTICLE_BODY_CHARACTERS,
1399
+ });
1400
+ const expectedContent = buildXWebRichArticleContentState(document);
1401
+ const bootstrap = await bootstrapX(auth, recipe, options.dependencies);
1402
+ const currentViewer = await requireBoundViewer(bootstrap, auth);
1403
+ const article = await readArticleDraft(bootstrap, draftId);
1404
+ requirePrivateDraftArticle(article, draftId, currentViewer.id);
1405
+ const actualContent = normalizeArticleContentReadback(article.content_state);
1406
+ return {
1407
+ matches: articleTitle(article, "X Article recovery readback") === title
1408
+ && canonicalJson(actualContent) === canonicalJson(expectedContent),
1409
+ draftId,
1410
+ };
1411
+ }
1412
+
1413
+ type XPublishMediaType = "image/png" | "video/mp4";
1414
+
1415
+ function xPublishMediaType(value: unknown, hasMedia: boolean): XPublishMediaType | null {
1416
+ if (!hasMedia) {
1417
+ if (value !== undefined) throw new Error("X media_type requires one plan-bound PNG or MP4");
1418
+ return null;
1419
+ }
1420
+ if (value !== "image/png" && value !== "video/mp4") {
1421
+ throw new Error("X reviewed media upload supports one PNG or one MP4");
1422
+ }
1423
+ return value;
1424
+ }
1425
+
1426
+ function xMediaMaxBytes(mediaType: XPublishMediaType): number {
1427
+ return mediaType === "video/mp4" ? MAX_X_VIDEO_BYTES : MAX_X_IMAGE_BYTES;
1428
+ }
1429
+
1430
+ function xMediaCategory(mediaType: XPublishMediaType): "tweet_image" | "tweet_video" {
1431
+ return mediaType === "video/mp4" ? "tweet_video" : "tweet_image";
1432
+ }
1433
+
1434
+ function xMediaUploadName(mediaType: XPublishMediaType): "media.png" | "upload.mp4" {
1435
+ return mediaType === "video/mp4" ? "upload.mp4" : "media.png";
1436
+ }
1437
+
1438
+ function xMediaReadbackType(mediaType: XPublishMediaType): "photo" | "video" {
1439
+ return mediaType === "video/mp4" ? "video" : "photo";
1440
+ }
1441
+
1442
+ type XBoundMedia = {
1443
+ readonly bytes: Uint8Array;
1444
+ readonly mediaType: XPublishMediaType;
1445
+ };
1446
+
1447
+ async function readBoundXMedia(
1448
+ input: OperationInput,
1449
+ fileResolver: BrowserFileResolver | undefined,
1450
+ operationDeadline?: WebSessionOperationDeadline,
1451
+ ): Promise<XBoundMedia | null> {
1452
+ const mediaType = xPublishMediaType(input.media_type, input.media !== undefined);
1453
+ if (input.media === undefined || mediaType === null) return null;
1454
+ if (!isRecord(input.media)) throw new Error("X media must be one plan-bound file");
1455
+ const descriptor = input.media;
1456
+ if (
1457
+ Object.keys(descriptor).sort().join(",") !== "kind,reference"
1458
+ || descriptor.kind !== "file"
1459
+ || typeof descriptor.reference !== "string"
1460
+ || descriptor.reference.length < 1
1461
+ || descriptor.reference.length > 1_024
1462
+ ) throw new Error("X media must be one plan-bound file");
1463
+ if (fileResolver === undefined) {
1464
+ throw new Error("X media upload requires the plan-bound file resolver");
1465
+ }
1466
+ const resolveFile = () => fileResolver([descriptor as FileInputValue]);
1467
+ const paths = operationDeadline === undefined
1468
+ ? await resolveFile()
1469
+ : await operationDeadline.run(resolveFile, "authenticated web operation deadline");
1470
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
1471
+ throw new Error("X media resolver did not return exactly one file");
1472
+ }
1473
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
1474
+ const handle = await open(paths[0], constants.O_RDONLY | noFollow);
1475
+ const maxBytes = xMediaMaxBytes(mediaType);
1476
+ try {
1477
+ const before = operationDeadline === undefined
1478
+ ? await handle.stat()
1479
+ : await operationDeadline.run(
1480
+ () => handle.stat(),
1481
+ "authenticated web operation deadline",
1482
+ );
1483
+ if (!before.isFile() || before.size < 1 || before.size > maxBytes) {
1484
+ throw new Error(
1485
+ mediaType === "video/mp4"
1486
+ ? "X video must be a regular MP4 no larger than 512 MiB"
1487
+ : "X image must be a regular PNG no larger than 20 MiB",
1488
+ );
1489
+ }
1490
+ const bytes = operationDeadline === undefined
1491
+ ? await handle.readFile()
1492
+ : await operationDeadline.run(
1493
+ () => handle.readFile(),
1494
+ "authenticated web operation deadline",
1495
+ );
1496
+ const after = operationDeadline === undefined
1497
+ ? await handle.stat()
1498
+ : await operationDeadline.run(
1499
+ () => handle.stat(),
1500
+ "authenticated web operation deadline",
1501
+ );
1502
+ if (
1503
+ after.dev !== before.dev
1504
+ || after.ino !== before.ino
1505
+ || after.size !== before.size
1506
+ || bytes.byteLength !== before.size
1507
+ ) throw new Error("X media changed while it was materialized");
1508
+ const materialized = new Uint8Array(bytes);
1509
+ return Object.freeze({
1510
+ bytes: mediaType === "image/png" ? scrubXUploadImage(materialized, "image/png") : materialized,
1511
+ mediaType,
1512
+ });
1513
+ } finally {
1514
+ await handle.close();
1515
+ }
1516
+ }
1517
+
1518
+ type XUploadedMedia = {
1519
+ readonly id: string;
1520
+ readonly key: string;
1521
+ readonly mediaType: XPublishMediaType;
1522
+ };
1523
+
1524
+ function xMediaId(value: unknown, label: string): string {
1525
+ const id = requiredString(value, label, 20);
1526
+ if (!/^[1-9][0-9]{0,19}$/u.test(id)) {
1527
+ throw new Error(`${label} must be an exact X media ID`);
1528
+ }
1529
+ return id;
1530
+ }
1531
+
1532
+ function exactResponseKeys(
1533
+ value: JsonRecord,
1534
+ required: readonly string[],
1535
+ optional: readonly string[],
1536
+ label: string,
1537
+ ): void {
1538
+ const keys = new Set(Object.keys(value));
1539
+ for (const name of required) {
1540
+ if (!keys.delete(name)) throw new Error(`${label} omitted ${name}`);
1541
+ }
1542
+ for (const name of optional) keys.delete(name);
1543
+ if (keys.size > 0) throw new Error(`${label} contained unsupported fields`);
1544
+ }
1545
+
1546
+ function xMediaKey(value: unknown, id: string, label: string): string {
1547
+ const key = requiredString(value, label, 64);
1548
+ if (!/^[1-9][0-9]{0,2}_[1-9][0-9]{0,19}$/u.test(key) || !key.endsWith(`_${id}`)) {
1549
+ throw new Error(`${label} did not bind the initialized media ID`);
1550
+ }
1551
+ return key;
1552
+ }
1553
+
1554
+ function parseXMediaInit(
1555
+ value: unknown,
1556
+ expectedSize: number,
1557
+ mediaType: XPublishMediaType,
1558
+ ): XUploadedMedia {
1559
+ const response = record(value, "X media INIT response");
1560
+ exactResponseKeys(
1561
+ response,
1562
+ ["expires_after_secs", "media_id", "media_id_string", "media_key"],
1563
+ [],
1564
+ "X media INIT response",
1565
+ );
1566
+ if (typeof response.media_id !== "number" || !Number.isFinite(response.media_id) || response.media_id < 1) {
1567
+ throw new Error("X media INIT response media_id was invalid");
1568
+ }
1569
+ if (
1570
+ !Number.isSafeInteger(response.expires_after_secs)
1571
+ || (response.expires_after_secs as number) < 1
1572
+ || (response.expires_after_secs as number) > 7 * 24 * 60 * 60
1573
+ ) throw new Error("X media INIT response expiry was invalid");
1574
+ const id = xMediaId(response.media_id_string, "X media INIT response media_id_string");
1575
+ const key = xMediaKey(response.media_key, id, "X media INIT response media_key");
1576
+ if (!Number.isSafeInteger(expectedSize) || expectedSize < 1 || expectedSize > xMediaMaxBytes(mediaType)) {
1577
+ throw new Error("X media INIT expected size was invalid");
1578
+ }
1579
+ return Object.freeze({ id, key, mediaType });
1580
+ }
1581
+
1582
+ type XMediaProcessingState = "pending" | "in_progress" | "succeeded" | "failed";
1583
+
1584
+ type XMediaProcessing = {
1585
+ readonly state: XMediaProcessingState;
1586
+ readonly checkAfterSeconds: number;
1587
+ };
1588
+
1589
+ function parseXMediaProcessingInfo(value: unknown, label: string): XMediaProcessing {
1590
+ const processing = record(value, label);
1591
+ exactResponseKeys(
1592
+ processing,
1593
+ ["state"],
1594
+ ["check_after_secs", "error", "progress_percent"],
1595
+ label,
1596
+ );
1597
+ if (
1598
+ processing.state !== "pending"
1599
+ && processing.state !== "in_progress"
1600
+ && processing.state !== "succeeded"
1601
+ && processing.state !== "failed"
1602
+ ) throw new Error(`${label} contained an unsupported state`);
1603
+ const check = processing.check_after_secs;
1604
+ if (
1605
+ check !== undefined
1606
+ && (!Number.isSafeInteger(check) || (check as number) < 0 || (check as number) > 60)
1607
+ ) throw new Error(`${label} contained an invalid check_after_secs`);
1608
+ if (
1609
+ processing.progress_percent !== undefined
1610
+ && (
1611
+ !Number.isSafeInteger(processing.progress_percent)
1612
+ || (processing.progress_percent as number) < 0
1613
+ || (processing.progress_percent as number) > 100
1614
+ )
1615
+ ) throw new Error(`${label} contained an invalid progress_percent`);
1616
+ if (processing.error !== undefined) record(processing.error, `${label}.error`);
1617
+ return Object.freeze({
1618
+ state: processing.state,
1619
+ checkAfterSeconds: check === undefined ? 1 : check as number,
1620
+ });
1621
+ }
1622
+
1623
+ type XMediaFinalizeBindingFailureStage =
1624
+ | "media-upload-finalize-response-object"
1625
+ | "media-upload-finalize-response-fields"
1626
+ | "media-upload-finalize-media-id"
1627
+ | "media-upload-finalize-media-key"
1628
+ | "media-upload-finalize-size-type"
1629
+ | "media-upload-finalize-size-value"
1630
+ | "media-upload-finalize-expiry"
1631
+ | "media-upload-finalize-image"
1632
+ | "media-upload-finalize-video"
1633
+ | "media-upload-finalize-processing";
1634
+
1635
+ function parseXMediaFinalize(
1636
+ value: unknown,
1637
+ expected: XUploadedMedia,
1638
+ expectedSize: number,
1639
+ expectedMediaType: XPublishMediaType,
1640
+ onFailureStage?: (stage: XMediaFinalizeBindingFailureStage) => void,
1641
+ ): XMediaProcessing | null {
1642
+ onFailureStage?.("media-upload-finalize-response-object");
1643
+ const response = record(value, "X media FINALIZE response");
1644
+ onFailureStage?.("media-upload-finalize-response-fields");
1645
+ exactResponseKeys(
1646
+ response,
1647
+ ["expires_after_secs", "media_id", "media_id_string", "size"],
1648
+ expectedMediaType === "video/mp4"
1649
+ ? ["media_key", "processing_info", "video"]
1650
+ : ["image", "media_key"],
1651
+ "X media FINALIZE response",
1652
+ );
1653
+ onFailureStage?.("media-upload-finalize-media-id");
1654
+ if (xMediaId(response.media_id_string, "X media FINALIZE response media_id_string") !== expected.id) {
1655
+ throw new Error("X media FINALIZE response did not bind the initialized media ID");
1656
+ }
1657
+ if (response.media_key !== undefined) {
1658
+ onFailureStage?.("media-upload-finalize-media-key");
1659
+ if (xMediaKey(response.media_key, expected.id, "X media FINALIZE response media_key") !== expected.key) {
1660
+ throw new Error("X media FINALIZE response changed the initialized media key");
1661
+ }
1662
+ }
1663
+ onFailureStage?.("media-upload-finalize-size-type");
1664
+ if (
1665
+ !Number.isSafeInteger(response.size)
1666
+ || (response.size as number) < 1
1667
+ || (response.size as number) > xMediaMaxBytes(expectedMediaType)
1668
+ ) throw new Error("X media FINALIZE response size was invalid");
1669
+ onFailureStage?.("media-upload-finalize-size-value");
1670
+ if (response.size !== expectedSize) {
1671
+ throw new Error("X media FINALIZE response did not bind the confirmed file size");
1672
+ }
1673
+ onFailureStage?.("media-upload-finalize-expiry");
1674
+ if (
1675
+ !Number.isSafeInteger(response.expires_after_secs)
1676
+ || (response.expires_after_secs as number) < 1
1677
+ || (response.expires_after_secs as number) > 7 * 24 * 60 * 60
1678
+ ) throw new Error("X media FINALIZE response expiry was invalid");
1679
+ if (response.image !== undefined) {
1680
+ onFailureStage?.("media-upload-finalize-image");
1681
+ const image = record(response.image, "X media FINALIZE response image");
1682
+ exactResponseKeys(image, ["h", "image_type", "w"], [], "X media FINALIZE response image");
1683
+ if (
1684
+ image.image_type !== expectedMediaType
1685
+ || !Number.isSafeInteger(image.w)
1686
+ || !Number.isSafeInteger(image.h)
1687
+ || (image.w as number) < 1
1688
+ || (image.h as number) < 1
1689
+ || (image.w as number) > 100_000
1690
+ || (image.h as number) > 100_000
1691
+ ) throw new Error("X media FINALIZE response image metadata was invalid");
1692
+ }
1693
+ if (response.video !== undefined) {
1694
+ onFailureStage?.("media-upload-finalize-video");
1695
+ const video = record(response.video, "X media FINALIZE response video");
1696
+ exactResponseKeys(video, ["video_type"], [], "X media FINALIZE response video");
1697
+ if (video.video_type !== expectedMediaType) {
1698
+ throw new Error("X media FINALIZE response video metadata was invalid");
1699
+ }
1700
+ }
1701
+ if (response.processing_info === undefined) return null;
1702
+ onFailureStage?.("media-upload-finalize-processing");
1703
+ return parseXMediaProcessingInfo(response.processing_info, "X media FINALIZE response processing_info");
1704
+ }
1705
+
1706
+ function xMediaUploadUrl(
1707
+ command: "INIT" | "APPEND" | "FINALIZE" | "STATUS",
1708
+ values: {
1709
+ readonly id?: string;
1710
+ readonly segmentIndex?: number;
1711
+ readonly totalBytes?: number;
1712
+ readonly mediaType?: XPublishMediaType;
1713
+ },
1714
+ ): URL {
1715
+ const url = new URL("/i/media/upload.json", X_UPLOAD_ORIGIN);
1716
+ url.searchParams.set("command", command);
1717
+ if (command === "INIT") {
1718
+ const mediaType = values.mediaType;
1719
+ if (mediaType !== "image/png" && mediaType !== "video/mp4") {
1720
+ throw new Error("X media INIT request escaped its reviewed contract");
1721
+ }
1722
+ if (
1723
+ !Number.isSafeInteger(values.totalBytes)
1724
+ || values.totalBytes! < 1
1725
+ || values.totalBytes! > xMediaMaxBytes(mediaType)
1726
+ || values.id !== undefined
1727
+ || values.segmentIndex !== undefined
1728
+ ) throw new Error("X media INIT request escaped its reviewed contract");
1729
+ url.searchParams.set("total_bytes", String(values.totalBytes));
1730
+ url.searchParams.set("media_type", mediaType);
1731
+ url.searchParams.set("media_category", xMediaCategory(mediaType));
1732
+ } else {
1733
+ const id = xMediaId(values.id, `X media ${command} media ID`);
1734
+ url.searchParams.set("media_id", id);
1735
+ if (command === "APPEND") {
1736
+ if (
1737
+ !Number.isSafeInteger(values.segmentIndex)
1738
+ || values.segmentIndex! < 0
1739
+ || values.segmentIndex! > MAX_X_VIDEO_UPLOAD_CHUNKS - 1
1740
+ || values.totalBytes !== undefined
1741
+ || values.mediaType !== undefined
1742
+ ) throw new Error("X media APPEND request escaped its reviewed contract");
1743
+ url.searchParams.set("segment_index", String(values.segmentIndex));
1744
+ } else if (
1745
+ values.segmentIndex !== undefined
1746
+ || values.totalBytes !== undefined
1747
+ || values.mediaType !== undefined
1748
+ ) throw new Error(`X media ${command} request escaped its reviewed contract`);
1749
+ }
1750
+ return url;
1751
+ }
1752
+
1753
+ function multipartMediaChunk(
1754
+ chunk: Uint8Array,
1755
+ segmentIndex: number,
1756
+ mediaType: XPublishMediaType,
1757
+ ): { readonly body: Uint8Array; readonly contentType: string } {
1758
+ const digest = createHash("sha256")
1759
+ .update(chunk)
1760
+ .update(`:${segmentIndex}`)
1761
+ .digest("hex")
1762
+ .slice(0, 32);
1763
+ const boundary = `wrench-x-media-${digest}`;
1764
+ const boundaryBytes = Buffer.from(boundary, "ascii");
1765
+ if (Buffer.from(chunk).includes(boundaryBytes)) {
1766
+ throw new Error("X media chunk collided with its deterministic multipart boundary");
1767
+ }
1768
+ const filename = xMediaUploadName(mediaType);
1769
+ const prefix = Buffer.from(
1770
+ `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="${filename}"\r\nContent-Type: ${mediaType}\r\n\r\n`,
1771
+ "utf8",
1772
+ );
1773
+ const suffix = Buffer.from(`\r\n--${boundary}--\r\n`, "ascii");
1774
+ return Object.freeze({
1775
+ body: new Uint8Array(Buffer.concat([prefix, Buffer.from(chunk), suffix])),
1776
+ contentType: `multipart/form-data; boundary=${boundary}`,
1777
+ });
1778
+ }
1779
+
1780
+ function xUploadHeaders(
1781
+ bootstrap: XBootstrap,
1782
+ contentType?: string,
1783
+ ): Readonly<Record<string, string>> {
1784
+ if (contentType === undefined) return xHeaders(bootstrap, false);
1785
+ const fixed = enforceXWebHeaderSinkPolicy({
1786
+ source: "code",
1787
+ sink: "network-request",
1788
+ headers: { "content-type": contentType },
1789
+ }).values;
1790
+ return { ...xHeaders(bootstrap, false), ...fixed };
1791
+ }
1792
+
1793
+ type XMediaUploadFailureStage =
1794
+ | "media-upload-session"
1795
+ | "media-upload-init"
1796
+ | "media-upload-append"
1797
+ | "media-upload-finalize-request"
1798
+ | "media-upload-status"
1799
+ | XMediaFinalizeBindingFailureStage;
1800
+
1801
+ function parseXMediaStatus(value: unknown, expected: XUploadedMedia): XMediaProcessing {
1802
+ const response = record(value, "X media STATUS response");
1803
+ exactResponseKeys(
1804
+ response,
1805
+ ["media_id_string", "processing_info"],
1806
+ ["expires_after_secs", "media_id", "media_key", "size", "video"],
1807
+ "X media STATUS response",
1808
+ );
1809
+ if (xMediaId(response.media_id_string, "X media STATUS response media_id_string") !== expected.id) {
1810
+ throw new Error("X media STATUS response did not bind the initialized media ID");
1811
+ }
1812
+ if (response.media_key !== undefined) {
1813
+ if (xMediaKey(response.media_key, expected.id, "X media STATUS response media_key") !== expected.key) {
1814
+ throw new Error("X media STATUS response changed the initialized media key");
1815
+ }
1816
+ }
1817
+ return parseXMediaProcessingInfo(response.processing_info, "X media STATUS response processing_info");
1818
+ }
1819
+
1820
+ async function waitForXMediaProcessing(
1821
+ uploadClient: WebSessionClient,
1822
+ bootstrap: XBootstrap,
1823
+ initialized: XUploadedMedia,
1824
+ initial: XMediaProcessing | null,
1825
+ sleep: (milliseconds: number) => Promise<void>,
1826
+ onFailureStage?: (stage: XMediaUploadFailureStage) => void,
1827
+ ): Promise<void> {
1828
+ let current = initial;
1829
+ if (current === null || current.state === "succeeded") return;
1830
+ if (current.state === "failed") throw new Error("X media processing failed");
1831
+ const deadline = Date.now() + MAX_X_MEDIA_STATUS_WAIT_MS;
1832
+ for (let attempt = 0; attempt < MAX_X_MEDIA_STATUS_POLLS; attempt += 1) {
1833
+ const remaining = deadline - Date.now();
1834
+ if (remaining < 1) break;
1835
+ const delay = current.checkAfterSeconds * 1_000;
1836
+ if (delay >= remaining) break;
1837
+ if (delay > 0) await sleep(delay);
1838
+ onFailureStage?.("media-upload-status");
1839
+ current = parseXMediaStatus(
1840
+ await uploadClient.requestJson({
1841
+ url: xMediaUploadUrl("STATUS", { id: initialized.id }),
1842
+ method: "GET",
1843
+ headers: xUploadHeaders(bootstrap),
1844
+ expectedStatuses: [200],
1845
+ maxBytes: MAX_X_UPLOAD_RESPONSE_BYTES,
1846
+ }),
1847
+ initialized,
1848
+ );
1849
+ if (current.state === "succeeded") return;
1850
+ if (current.state === "failed") throw new Error("X media processing failed");
1851
+ }
1852
+ throw new Error("X media processing did not complete within the bounded polling window");
1853
+ }
1854
+
1855
+ async function uploadXMedia(
1856
+ bootstrap: XBootstrap,
1857
+ media: XBoundMedia,
1858
+ onFailureStage?: (stage: XMediaUploadFailureStage) => void,
1859
+ ): Promise<XUploadedMedia> {
1860
+ onFailureStage?.("media-upload-session");
1861
+ const uploadClient = await createWebSessionClient(X_UPLOAD_ORIGIN, bootstrap.auth, {
1862
+ timeoutMs: bootstrap.timeoutMs,
1863
+ ...(bootstrap.signal === undefined ? {} : { signal: bootstrap.signal }),
1864
+ ...(bootstrap.operationDeadline === undefined
1865
+ ? {}
1866
+ : { operationDeadline: bootstrap.operationDeadline }),
1867
+ ...(bootstrap.dependencies === undefined
1868
+ ? {}
1869
+ : { dependencies: bootstrap.dependencies }),
1870
+ });
1871
+ if (webSessionCookie(uploadClient.cookies, "ct0") !== bootstrap.csrf) {
1872
+ throw new Error("X upload origin no longer matched the confirmed session");
1873
+ }
1874
+ onFailureStage?.("media-upload-init");
1875
+ const initialized = parseXMediaInit(
1876
+ await uploadClient.requestJson({
1877
+ url: xMediaUploadUrl("INIT", {
1878
+ totalBytes: media.bytes.byteLength,
1879
+ mediaType: media.mediaType,
1880
+ }),
1881
+ method: "POST",
1882
+ headers: xUploadHeaders(bootstrap),
1883
+ expectedStatuses: [200, 202],
1884
+ maxBytes: MAX_X_UPLOAD_RESPONSE_BYTES,
1885
+ }),
1886
+ media.bytes.byteLength,
1887
+ media.mediaType,
1888
+ );
1889
+ const maxChunks = media.mediaType === "video/mp4"
1890
+ ? MAX_X_VIDEO_UPLOAD_CHUNKS
1891
+ : MAX_X_IMAGE_UPLOAD_CHUNKS;
1892
+ const chunks = Math.ceil(media.bytes.byteLength / X_UPLOAD_CHUNK_BYTES);
1893
+ if (chunks < 1 || chunks > maxChunks) {
1894
+ throw new Error(
1895
+ media.mediaType === "video/mp4"
1896
+ ? "X video exceeded the reviewed upload chunk count"
1897
+ : "X image exceeded the reviewed upload chunk count",
1898
+ );
1899
+ }
1900
+ onFailureStage?.("media-upload-append");
1901
+ for (let segmentIndex = 0; segmentIndex < chunks; segmentIndex += 1) {
1902
+ const start = segmentIndex * X_UPLOAD_CHUNK_BYTES;
1903
+ const chunk = media.bytes.subarray(
1904
+ start,
1905
+ Math.min(media.bytes.byteLength, start + X_UPLOAD_CHUNK_BYTES),
1906
+ );
1907
+ const multipart = multipartMediaChunk(chunk, segmentIndex, media.mediaType);
1908
+ const appended = await uploadClient.requestStatus({
1909
+ url: xMediaUploadUrl("APPEND", { id: initialized.id, segmentIndex }),
1910
+ method: "POST",
1911
+ headers: xUploadHeaders(bootstrap, multipart.contentType),
1912
+ body: multipart.body,
1913
+ expectedStatuses: [204],
1914
+ });
1915
+ if (appended.location !== null) {
1916
+ throw new Error("X media APPEND response added an unsupported location");
1917
+ }
1918
+ }
1919
+ onFailureStage?.("media-upload-finalize-request");
1920
+ const finalized = await uploadClient.requestJson({
1921
+ url: xMediaUploadUrl("FINALIZE", { id: initialized.id }),
1922
+ method: "POST",
1923
+ headers: xUploadHeaders(bootstrap),
1924
+ expectedStatuses: [200, 201],
1925
+ maxBytes: MAX_X_UPLOAD_RESPONSE_BYTES,
1926
+ });
1927
+ const processing = parseXMediaFinalize(
1928
+ finalized,
1929
+ initialized,
1930
+ media.bytes.byteLength,
1931
+ media.mediaType,
1932
+ onFailureStage,
1933
+ );
1934
+ if (media.mediaType === "video/mp4") {
1935
+ await waitForXMediaProcessing(
1936
+ uploadClient,
1937
+ bootstrap,
1938
+ initialized,
1939
+ processing,
1940
+ bootstrap.dependencies?.sleep
1941
+ ?? ((milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds))),
1942
+ onFailureStage,
1943
+ );
1944
+ }
1945
+ return initialized;
1946
+ }
1947
+
1948
+ function xArticleUploadHeaders(bootstrap: XBootstrap): Readonly<Record<string, string>> {
1949
+ const fixed = enforceXWebHeaderSinkPolicy({
1950
+ source: "code",
1951
+ sink: "network-request",
1952
+ headers: {
1953
+ accept: "application/json",
1954
+ "x-twitter-auth-type": "OAuth2Session",
1955
+ "x-twitter-active-user": "yes",
1956
+ },
1957
+ }).values;
1958
+ const session = enforceXWebHeaderSinkPolicy({
1959
+ source: "in-origin-session",
1960
+ sink: "network-request",
1961
+ headers: {
1962
+ authorization: `Bearer ${bootstrap.bearer}`,
1963
+ "x-csrf-token": bootstrap.csrf,
1964
+ },
1965
+ }).values;
1966
+ return { ...fixed, ...session, origin: X_ORIGIN, referer: `${X_ORIGIN}/compose/articles` };
1967
+ }
1968
+
1969
+ function articleMediaUploadUrl(
1970
+ origin: string,
1971
+ command: "INIT" | "APPEND" | "FINALIZE",
1972
+ values: Readonly<Record<string, string>>,
1973
+ ): URL {
1974
+ const url = new URL("/i/media/upload.json", origin);
1975
+ url.searchParams.set("command", command);
1976
+ for (const [name, value] of Object.entries(values)) url.searchParams.set(name, value);
1977
+ const expected = command === "INIT"
1978
+ ? ["command", "media_category", "media_type", "total_bytes"]
1979
+ : command === "APPEND"
1980
+ ? ["command", "media_id", "segment_index"]
1981
+ : ["command", "media_id"];
1982
+ if (Object.keys(Object.fromEntries(url.searchParams)).sort().join(",") !== expected.sort().join(",")) {
1983
+ throw new Error("X Article media upload request left its reviewed query shape");
1984
+ }
1985
+ return url;
1986
+ }
1987
+
1988
+ function articleUploadMediaId(value: unknown, label: string): string {
1989
+ const response = record(value, label);
1990
+ const id = response.media_id_string;
1991
+ if (typeof id !== "string" || !/^[0-9]{1,19}$/u.test(id)) {
1992
+ throw new Error(`${label} did not return one exact media ID`);
1993
+ }
1994
+ if (
1995
+ response.expires_after_secs !== undefined
1996
+ && (!Number.isSafeInteger(response.expires_after_secs)
1997
+ || (response.expires_after_secs as number) < 1)
1998
+ ) throw new Error(`${label} returned an invalid expiry`);
1999
+ return id;
2000
+ }
2001
+
2002
+ function containsBytes(haystack: Uint8Array, needle: Uint8Array): boolean {
2003
+ if (needle.length === 0 || needle.length > haystack.length) return false;
2004
+ outer: for (let index = 0; index <= haystack.length - needle.length; index += 1) {
2005
+ for (let offset = 0; offset < needle.length; offset += 1) {
2006
+ if (haystack[index + offset] !== needle[offset]) continue outer;
2007
+ }
2008
+ return true;
2009
+ }
2010
+ return false;
2011
+ }
2012
+
2013
+ function articleMediaMultipart(image: BoundArticleDraftImage): {
2014
+ readonly body: Uint8Array;
2015
+ readonly contentType: string;
2016
+ } {
2017
+ const digest = createHash("sha256").update(image.bytes).digest("hex");
2018
+ let boundary = `----wrench-x-article-${digest}`;
2019
+ const encoder = new TextEncoder();
2020
+ for (let suffix = 0; containsBytes(image.bytes, encoder.encode(boundary)); suffix += 1) {
2021
+ if (suffix >= 16) throw new Error("X Article media could not bind a safe multipart boundary");
2022
+ boundary = `----wrench-x-article-${digest}-${suffix + 1}`;
2023
+ }
2024
+ const prefix = encoder.encode(
2025
+ `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="blob"\r\nContent-Type: ${image.mediaType}\r\n\r\n`,
2026
+ );
2027
+ const suffix = encoder.encode(`\r\n--${boundary}--\r\n`);
2028
+ const body = new Uint8Array(prefix.length + image.bytes.length + suffix.length);
2029
+ body.set(prefix, 0);
2030
+ body.set(image.bytes, prefix.length);
2031
+ body.set(suffix, prefix.length + image.bytes.length);
2032
+ return Object.freeze({ body, contentType: `multipart/form-data; boundary=${boundary}` });
2033
+ }
2034
+
2035
+ async function uploadArticleImage(
2036
+ bootstrap: XBootstrap,
2037
+ client: WebSessionClient,
2038
+ image: BoundArticleDraftImage,
2039
+ beforeInit: () => Promise<void>,
2040
+ ): Promise<string> {
2041
+ const headers = xArticleUploadHeaders(bootstrap);
2042
+ const initUrl = articleMediaUploadUrl(client.origin, "INIT", {
2043
+ total_bytes: `${image.bytes.byteLength}`,
2044
+ media_type: image.mediaType,
2045
+ media_category: "tweet_image",
2046
+ });
2047
+ await beforeInit();
2048
+ const mediaId = await xArticleImageUploadStep("init", async () => {
2049
+ const init = await client.requestJson({
2050
+ url: initUrl,
2051
+ method: "POST",
2052
+ headers,
2053
+ maxBytes: MAX_ARTICLE_MEDIA_RESPONSE_BYTES,
2054
+ expectedStatuses: [200, 201, 202],
2055
+ });
2056
+ return articleUploadMediaId(init, "X media INIT response");
2057
+ });
2058
+ const multipart = articleMediaMultipart(image);
2059
+ await xArticleImageUploadStep("append", () => client.requestStatus({
2060
+ url: articleMediaUploadUrl(client.origin, "APPEND", {
2061
+ media_id: mediaId,
2062
+ segment_index: "0",
2063
+ }),
2064
+ method: "POST",
2065
+ headers: { ...headers, "content-type": multipart.contentType },
2066
+ body: multipart.body,
2067
+ expectedStatuses: [200, 204],
2068
+ }).then(() => undefined),
2069
+ );
2070
+ await xArticleImageUploadStep("finalize", async () => {
2071
+ const finalized = await client.requestJson({
2072
+ url: articleMediaUploadUrl(client.origin, "FINALIZE", { media_id: mediaId }),
2073
+ method: "POST",
2074
+ headers,
2075
+ maxBytes: MAX_ARTICLE_MEDIA_RESPONSE_BYTES,
2076
+ expectedStatuses: [200, 201, 202],
2077
+ });
2078
+ if (articleUploadMediaId(finalized, "X media FINALIZE response") !== mediaId) {
2079
+ throw new Error("X media FINALIZE response changed the uploaded media ID");
2080
+ }
2081
+ if (record(finalized, "X media FINALIZE response").processing_info !== undefined) {
2082
+ throw new Error("X image upload unexpectedly entered an unreviewed processing branch");
2083
+ }
2084
+ });
2085
+ return mediaId;
2086
+ }
2087
+
2088
+ type XArticleImageUploadStep = "append" | "finalize" | "init";
2089
+
2090
+ class XArticleImageUploadStepError extends Error {
2091
+ readonly category: string;
2092
+ readonly step: XArticleImageUploadStep;
2093
+
2094
+ constructor(step: XArticleImageUploadStep, category: string, cause: unknown) {
2095
+ super(`X Article image ${step} step failed (${category})`, { cause });
2096
+ this.name = "XArticleImageUploadStepError";
2097
+ this.step = step;
2098
+ this.category = category;
2099
+ }
2100
+ }
2101
+
2102
+ async function xArticleImageUploadStep<T>(
2103
+ step: XArticleImageUploadStep,
2104
+ operation: () => Promise<T>,
2105
+ ): Promise<T> {
2106
+ try {
2107
+ return await operation();
2108
+ } catch (error) {
2109
+ throw new XArticleImageUploadStepError(
2110
+ step,
2111
+ xWebArticleImageFailureCategory(error),
2112
+ error,
2113
+ );
2114
+ }
2115
+ }
2116
+
2117
+ export function xWebArticleImageFailureCategory(error: unknown): string {
2118
+ if (error instanceof XArticleImageUploadStepError) {
2119
+ return `media-${error.step}-${error.category}`;
2120
+ }
2121
+ const message = error instanceof Error ? error.message : "";
2122
+ const response = /authenticated web (?:API|API request|request) returned unreviewed status(?:\/content type)? ([0-9]{3})(?:\/([A-Za-z0-9.+-]+\/[A-Za-z0-9.+-]+|missing))?/u.exec(message);
2123
+ if (response !== null) {
2124
+ const status = Number.parseInt(response[1]!, 10);
2125
+ if (status === 401 || status === 403) return "session-rejected";
2126
+ if (status === 400 || status === 413 || status === 415 || status === 422) {
2127
+ return `request-rejected-${status}`;
2128
+ }
2129
+ if (status === 429) return "provider-throttled";
2130
+ if (status >= 500) return "provider-unavailable";
2131
+ if (status !== 200) return "media-status-drift";
2132
+ if (response[2] === "text/plain") return "text-plain-response";
2133
+ if (response[2] === "text/html") return "html-response";
2134
+ if (response[2] === "application/octet-stream") return "binary-response";
2135
+ if (response[2] === "missing") return "missing-content-type";
2136
+ return "media-content-type-drift";
2137
+ }
2138
+ if (message.includes("X media INIT response")) return "media-init-response-drift";
2139
+ if (message.includes("X media FINALIZE response")) return "media-finalize-response-drift";
2140
+ if (message.includes("X articleentity_create_draft response contained provider errors")) {
2141
+ return "article-create-provider-errors";
2142
+ }
2143
+ if (message.includes("X articleentity_create_draft response.data must be an object")) {
2144
+ return "article-create-data-shape-drift";
2145
+ }
2146
+ if (message.includes("X articleentity_create_draft response.articleentity_create_draft must be an object")) {
2147
+ return "article-create-root-shape-drift";
2148
+ }
2149
+ if (message.includes("X articleentity_create_draft response.articleentity_create_draft.article_entity_results.result must be an object")) {
2150
+ return "article-create-result-shape-drift";
2151
+ }
2152
+ if (message.includes("X articleentity_create_draft response Article identifier")) {
2153
+ return "article-create-id-shape-drift";
2154
+ }
2155
+ if (message.includes("processing branch")) return "media-processing-required";
2156
+ if (message.includes("media upload session")) return "media-session-binding-drift";
2157
+ if (message.includes("failed before a reviewed response")) return "transport-failed";
2158
+ if (message.includes("deadline") || message.includes("cancel")) return "operation-interrupted";
2159
+ return "media-contract-step-failed";
2160
+ }
2161
+
2162
+ function tweetMediaIds(legacy: JsonRecord, expectedReadbackType: "photo" | "video"): readonly string[] {
2163
+ const ids: string[][] = [];
2164
+ for (const [name, value] of [
2165
+ ["entities", legacy.entities],
2166
+ ["extended_entities", legacy.extended_entities],
2167
+ ] as const) {
2168
+ if (value === undefined) continue;
2169
+ const container = record(value, `X post ${name}`);
2170
+ if (container.media === undefined) continue;
2171
+ if (!Array.isArray(container.media) || container.media.length > 4) {
2172
+ throw new Error(`X post ${name}.media exceeded the reviewed attachment bound`);
2173
+ }
2174
+ ids.push(container.media.map((item, index) => {
2175
+ const media = record(item, `X post ${name}.media[${index}]`);
2176
+ if (media.type !== expectedReadbackType) {
2177
+ throw new Error("X post readback contained an unsupported media type");
2178
+ }
2179
+ return xMediaId(media.id_str, `X post ${name}.media[${index}].id_str`);
2180
+ }));
2181
+ }
2182
+ if (ids.length === 0) return Object.freeze([]);
2183
+ const first = ids[0]!;
2184
+ if (ids.some((candidate) => canonicalJson(candidate) !== canonicalJson(first))) {
2185
+ throw new Error("X post media projections disagreed");
2186
+ }
2187
+ return Object.freeze(first);
2188
+ }
2189
+
2190
+ /**
2191
+ * Bind confirmed CreateTweet text from the long-form note_tweet record when
2192
+ * present. Live long posts keep a short preview in legacy.full_text; that
2193
+ * preview must not win over the exact note_tweet text or silently truncate.
2194
+ */
2195
+ function boundCreateTweetText(
2196
+ result: Readonly<Record<string, unknown>>,
2197
+ legacy: Readonly<Record<string, unknown>>,
2198
+ ): string | null {
2199
+ if (
2200
+ isRecord(result.note_tweet)
2201
+ && isRecord(result.note_tweet.note_tweet_results)
2202
+ && isRecord(result.note_tweet.note_tweet_results.result)
2203
+ && typeof result.note_tweet.note_tweet_results.result.text === "string"
2204
+ ) {
2205
+ return result.note_tweet.note_tweet_results.result.text;
2206
+ }
2207
+ return typeof legacy.full_text === "string" ? legacy.full_text : null;
2208
+ }
2209
+
2210
+ function assertTweetBinding(
2211
+ value: unknown,
2212
+ expectedText: string,
2213
+ replyTo: string | null,
2214
+ quote: string | null,
2215
+ expectedAuthorId: string,
2216
+ expectedMediaId: string | null,
2217
+ expectedMediaType: XPublishMediaType | null,
2218
+ label: string,
2219
+ ): { readonly id: string; readonly url: string } {
2220
+ const result = unwrapTweet(value, `${label}.result`);
2221
+ const id = postId(result.rest_id, "X created post rest_id");
2222
+ const legacy = record(result.legacy, `${label}.legacy`);
2223
+ const returnedText = boundCreateTweetText(result, legacy);
2224
+ if (returnedText !== expectedText) throw new Error("X created post response did not bind the confirmed text");
2225
+ if (legacy.user_id_str !== expectedAuthorId) throw new Error("X created post response did not bind the confirmed viewer");
2226
+ const returnedReply = typeof legacy.in_reply_to_status_id_str === "string" ? legacy.in_reply_to_status_id_str : null;
2227
+ if (returnedReply !== replyTo) throw new Error("X created post response did not bind the confirmed reply target");
2228
+ const returnedQuote = typeof legacy.quoted_status_id_str === "string" ? legacy.quoted_status_id_str : null;
2229
+ if (returnedQuote !== quote) throw new Error("X created post response did not bind the confirmed quote target");
2230
+ const mediaIds = expectedMediaType === null
2231
+ ? tweetMediaIds(legacy, "photo")
2232
+ : tweetMediaIds(legacy, xMediaReadbackType(expectedMediaType));
2233
+ if (
2234
+ (expectedMediaId === null && mediaIds.length !== 0)
2235
+ || (expectedMediaId !== null && (mediaIds.length !== 1 || mediaIds[0] !== expectedMediaId))
2236
+ ) throw new Error("X created post response did not bind the confirmed media upload");
2237
+ const bound = { id, url: `${X_ORIGIN}/i/status/${id}` };
2238
+ rejectXTweetMadeWithAiLabel(result, bound);
2239
+ return bound;
2240
+ }
2241
+
2242
+ function createdTweet(
2243
+ response: unknown,
2244
+ expectedText: string,
2245
+ replyTo: string | null,
2246
+ quote: string | null,
2247
+ expectedAuthorId: string,
2248
+ expectedMediaId: string | null,
2249
+ expectedMediaType: XPublishMediaType | null,
2250
+ ): { readonly id: string; readonly url: string } {
2251
+ const data = graphQlData(response, "X CreateTweet response");
2252
+ const create = record(data.create_tweet, "X CreateTweet response.create_tweet");
2253
+ const results = record(create.tweet_results, "X CreateTweet response.tweet_results");
2254
+ return assertTweetBinding(
2255
+ results.result,
2256
+ expectedText,
2257
+ replyTo,
2258
+ quote,
2259
+ expectedAuthorId,
2260
+ expectedMediaId,
2261
+ expectedMediaType,
2262
+ "X CreateTweet response",
2263
+ );
2264
+ }
2265
+
2266
+ function createTweetVariables(
2267
+ text: string,
2268
+ replyTo: string | null,
2269
+ quote: string | null,
2270
+ mediaId: string | null,
2271
+ ): Readonly<Record<string, unknown>> {
2272
+ // The reviewed CreateTweet contract has no made_with_ai or content_disclosure
2273
+ // input. Keep semantic_annotation_ids empty so the Ghostget-owned transport
2274
+ // cannot inherit a composer AI annotation.
2275
+ return {
2276
+ tweet_text: text,
2277
+ dark_request: false,
2278
+ media: {
2279
+ media_entities: mediaId === null
2280
+ ? []
2281
+ : [{ media_id: mediaId, tagged_users: [] }],
2282
+ possibly_sensitive: false,
2283
+ },
2284
+ semantic_annotation_ids: [],
2285
+ ...(replyTo === null ? {} : { reply: { in_reply_to_tweet_id: replyTo, exclude_reply_user_ids: [] } }),
2286
+ ...(quote === null ? {} : { attachment_url: `${X_ORIGIN}/i/status/${quote}` }),
2287
+ };
2288
+ }
2289
+
2290
+ function articleEntityResult(response: unknown, rootName: string, label: string): JsonRecord {
2291
+ const data = graphQlData(response, label);
2292
+ const root = record(data[rootName], `${label}.${rootName}`);
2293
+ if (isRecord(root.result)) return record(root.result, `${label}.${rootName}.result`);
2294
+ if (isRecord(root.article_entity_results)) {
2295
+ const results = record(root.article_entity_results, `${label}.${rootName}.article_entity_results`);
2296
+ return record(results.result, `${label}.${rootName}.article_entity_results.result`);
2297
+ }
2298
+ return root;
2299
+ }
2300
+
2301
+ function articleId(article: JsonRecord, label: string): string {
2302
+ const restId = article.rest_id;
2303
+ const entityId = article.id;
2304
+ return postId(
2305
+ typeof restId === "string" ? restId : entityId,
2306
+ `${label} Article identifier`,
2307
+ );
2308
+ }
2309
+
2310
+ function articleTitle(article: JsonRecord, label: string): string {
2311
+ const directTitle = typeof article.title === "string" ? article.title : null;
2312
+ const metadataTitle = isRecord(article.metadata) && typeof article.metadata.title === "string"
2313
+ ? article.metadata.title
2314
+ : null;
2315
+ if (directTitle !== null && metadataTitle !== null && directTitle !== metadataTitle) {
2316
+ throw new Error(`${label} returned conflicting Article titles`);
2317
+ }
2318
+ const title = directTitle ?? metadataTitle;
2319
+ if (title === null) throw new Error(`${label} omitted the Article title`);
2320
+ return title;
2321
+ }
2322
+
2323
+ function responseBoundArticle(
2324
+ response: unknown,
2325
+ rootName: string,
2326
+ expectedId: string | null,
2327
+ expectedTitle?: string,
2328
+ ): JsonRecord {
2329
+ const article = articleEntityResult(response, rootName, `X ${rootName} response`);
2330
+ const id = articleId(article, `X ${rootName} response`);
2331
+ if (expectedId !== null && id !== expectedId) {
2332
+ throw new Error(`X ${rootName} response changed the confirmed Article draft`);
2333
+ }
2334
+ if (expectedTitle !== undefined && articleTitle(article, `X ${rootName} response`) !== expectedTitle) {
2335
+ throw new Error(`X ${rootName} response did not bind the confirmed title`);
2336
+ }
2337
+ return article;
2338
+ }
2339
+
2340
+ function articleAuthorId(article: JsonRecord): string {
2341
+ const metadata = record(article.metadata, "X Article metadata");
2342
+ const authorResults = record(metadata.author_results, "X Article metadata.author_results");
2343
+ const author = record(authorResults.result, "X Article metadata.author_results.result");
2344
+ return postId(author.rest_id, "X Article author rest_id");
2345
+ }
2346
+
2347
+ function requirePrivateDraftArticle(
2348
+ article: JsonRecord,
2349
+ expectedId: string,
2350
+ expectedViewerId: string,
2351
+ ): void {
2352
+ if (articleId(article, "X Article readback") !== expectedId) {
2353
+ throw new Error("X Article readback changed the confirmed draft ID");
2354
+ }
2355
+ if (articleAuthorId(article) !== expectedViewerId) {
2356
+ throw new Error("X Article draft does not belong to the bound viewer");
2357
+ }
2358
+ const lifecycle = record(article.lifecycle_state, "X Article lifecycle_state");
2359
+ if (lifecycle.lifecycle !== "Draft") {
2360
+ throw new Error("X Article target must remain an unpublished private draft");
2361
+ }
2362
+ }
2363
+
2364
+ function normalizedArticleEntityKey(value: unknown, label: string): string {
2365
+ if (
2366
+ typeof value === "number"
2367
+ && Number.isSafeInteger(value)
2368
+ && value >= 0
2369
+ && value <= MAX_ARTICLE_BLOCKS
2370
+ ) {
2371
+ return `${value}`;
2372
+ }
2373
+ if (typeof value === "string" && /^(?:0|[1-9][0-9]{0,3})$/u.test(value)) {
2374
+ return value;
2375
+ }
2376
+ throw new Error(`${label} is not one bounded Article entity key`);
2377
+ }
2378
+
2379
+ function articleReadbackEntityKeyMap(
2380
+ values: readonly unknown[],
2381
+ blocks: readonly unknown[],
2382
+ rangesKey: "entity_ranges" | "entityRanges",
2383
+ ): ReadonlyMap<string, string> {
2384
+ const known = new Set<string>();
2385
+ values.forEach((value, index) => {
2386
+ const entity = record(value, `X Article readback entity ${index}`);
2387
+ const source = normalizedArticleEntityKey(
2388
+ entity.key,
2389
+ `X Article readback entity ${index}.key`,
2390
+ );
2391
+ if (known.has(source)) {
2392
+ throw new Error("X Article readback repeated one entity key");
2393
+ }
2394
+ known.add(source);
2395
+ });
2396
+ const result = new Map<string, string>();
2397
+ blocks.forEach((value, blockIndex) => {
2398
+ const block = record(value, `X Article readback block ${blockIndex + 1}`);
2399
+ const ranges = block[rangesKey];
2400
+ if (!Array.isArray(ranges)) {
2401
+ throw new Error("X Article readback block ranges changed shape");
2402
+ }
2403
+ ranges.forEach((value) => {
2404
+ const range = record(value, "X Article readback entity range");
2405
+ const source = normalizedArticleEntityKey(
2406
+ range.key,
2407
+ "X Article readback entity range key",
2408
+ );
2409
+ if (!known.has(source)) {
2410
+ throw new Error("X Article readback range referenced an unknown entity");
2411
+ }
2412
+ if (result.has(source)) {
2413
+ throw new Error("X Article readback repeated one entity reference");
2414
+ }
2415
+ result.set(source, `${result.size}`);
2416
+ });
2417
+ });
2418
+ if (result.size !== values.length) {
2419
+ throw new Error("X Article readback contained one unreferenced entity");
2420
+ }
2421
+ return result;
2422
+ }
2423
+
2424
+ function orderedArticleReadbackEntities(
2425
+ values: readonly unknown[],
2426
+ keys: ReadonlyMap<string, string>,
2427
+ ): readonly unknown[] {
2428
+ return [...values].sort((left, right) => {
2429
+ const leftKey = normalizedArticleEntityKey(
2430
+ record(left, "X Article readback entity").key,
2431
+ "X Article readback entity key",
2432
+ );
2433
+ const rightKey = normalizedArticleEntityKey(
2434
+ record(right, "X Article readback entity").key,
2435
+ "X Article readback entity key",
2436
+ );
2437
+ return Number(keys.get(leftKey)) - Number(keys.get(rightKey));
2438
+ });
2439
+ }
2440
+
2441
+ function remappedArticleEntityKey(
2442
+ value: unknown,
2443
+ keys: ReadonlyMap<string, string>,
2444
+ label: string,
2445
+ ): string {
2446
+ const source = normalizedArticleEntityKey(value, label);
2447
+ const normalized = keys.get(source);
2448
+ if (normalized === undefined) {
2449
+ throw new Error(`${label} referenced an unknown Article entity`);
2450
+ }
2451
+ return normalized;
2452
+ }
2453
+
2454
+ function normalizedArticleBlockData(
2455
+ value: unknown,
2456
+ label: string,
2457
+ blockText: unknown,
2458
+ ): Readonly<Record<string, never>> {
2459
+ if (typeof blockText !== "string") {
2460
+ throw new Error(`${label} has no bounded block text`);
2461
+ }
2462
+ const data = record(value, label);
2463
+ const keys = Object.keys(data);
2464
+ if (keys.length === 0) return Object.freeze({});
2465
+ if (keys.length !== 1 || keys[0] !== "urls" || !Array.isArray(data.urls)) {
2466
+ throw new Error(`${label} left the reviewed empty-or-urls shape`);
2467
+ }
2468
+ if (data.urls.length > 100) {
2469
+ throw new Error(`${label}.urls exceeded its reviewed bound`);
2470
+ }
2471
+ let previousEnd = 0;
2472
+ data.urls.forEach((value, index) => {
2473
+ const url = record(value, `${label}.urls[${index}]`);
2474
+ const urlKeys = Object.keys(url).sort();
2475
+ if (urlKeys.join(",") !== "fromIndex,text,toIndex") {
2476
+ throw new Error(`${label}.urls[${index}] left its reviewed range shape`);
2477
+ }
2478
+ if (
2479
+ !Number.isSafeInteger(url.fromIndex)
2480
+ || !Number.isSafeInteger(url.toIndex)
2481
+ || (url.fromIndex as number) < previousEnd
2482
+ || (url.fromIndex as number) < 0
2483
+ || (url.toIndex as number) <= (url.fromIndex as number)
2484
+ || (url.toIndex as number) > blockText.length
2485
+ ) {
2486
+ throw new Error(`${label}.urls[${index}] escaped its block text`);
2487
+ }
2488
+ if (
2489
+ typeof url.text !== "string"
2490
+ || /[\0\r\n]/u.test(url.text)
2491
+ || blockText.slice(
2492
+ url.fromIndex as number,
2493
+ url.toIndex as number,
2494
+ ) !== url.text
2495
+ ) {
2496
+ throw new Error(`${label}.urls[${index}].text did not bind its block range`);
2497
+ }
2498
+ previousEnd = url.toIndex as number;
2499
+ });
2500
+ return Object.freeze({});
2501
+ }
2502
+
2503
+ function normalizedArticleBlockKey(
2504
+ value: unknown,
2505
+ index: number,
2506
+ observed: Set<string>,
2507
+ ): string {
2508
+ if (
2509
+ typeof value !== "string"
2510
+ || !/^[a-z0-9]{5}$/u.test(value)
2511
+ || observed.has(value)
2512
+ ) {
2513
+ throw new Error(`X Article readback block ${index + 1} has an invalid key`);
2514
+ }
2515
+ observed.add(value);
2516
+ return index.toString(36).padStart(5, "0");
2517
+ }
2518
+
2519
+ function normalizeArticleContentReadback(value: unknown): XWebRichArticleContentState {
2520
+ const state = record(value, "X Article readback content_state");
2521
+ if (Array.isArray(state.entity_map)) {
2522
+ if (!Array.isArray(state.blocks)) {
2523
+ throw new Error("X Article readback omitted its rich content blocks");
2524
+ }
2525
+ const entityKeys = articleReadbackEntityKeyMap(
2526
+ state.entity_map,
2527
+ state.blocks,
2528
+ "entity_ranges",
2529
+ );
2530
+ const observedBlockKeys = new Set<string>();
2531
+ const blocks = state.blocks.map((value, index) => {
2532
+ const block = record(value, `X Article readback block ${index + 1}`);
2533
+ if (
2534
+ !Array.isArray(block.entity_ranges)
2535
+ || !Array.isArray(block.inline_style_ranges)
2536
+ ) {
2537
+ throw new Error("X Article readback block ranges changed shape");
2538
+ }
2539
+ return Object.freeze({
2540
+ data: normalizedArticleBlockData(
2541
+ block.data,
2542
+ `X Article readback block ${index + 1}.data`,
2543
+ block.text,
2544
+ ),
2545
+ text: block.text,
2546
+ key: normalizedArticleBlockKey(
2547
+ block.key,
2548
+ index,
2549
+ observedBlockKeys,
2550
+ ),
2551
+ type: block.type,
2552
+ entity_ranges: Object.freeze(block.entity_ranges.map((range) => {
2553
+ const item = record(range, "X Article readback entity range");
2554
+ return Object.freeze({
2555
+ key: Number(remappedArticleEntityKey(
2556
+ item.key,
2557
+ entityKeys,
2558
+ "X Article readback entity range key",
2559
+ )),
2560
+ offset: item.offset,
2561
+ length: item.length,
2562
+ });
2563
+ })),
2564
+ inline_style_ranges: Object.freeze(block.inline_style_ranges.map(
2565
+ (range) => {
2566
+ const item = record(range, "X Article readback style range");
2567
+ return Object.freeze({
2568
+ length: item.length,
2569
+ offset: item.offset,
2570
+ style: item.style,
2571
+ });
2572
+ },
2573
+ )),
2574
+ });
2575
+ });
2576
+ const entity_map = orderedArticleReadbackEntities(
2577
+ state.entity_map,
2578
+ entityKeys,
2579
+ ).map((value, index) => {
2580
+ const entity = record(value, `X Article readback entity ${index}`);
2581
+ const sourceEntityKey = normalizedArticleEntityKey(
2582
+ entity.key,
2583
+ `X Article readback entity ${index}.key`,
2584
+ );
2585
+ const entry = record(
2586
+ entity.value,
2587
+ `X Article readback entity ${index}.value`,
2588
+ );
2589
+ const data = record(
2590
+ entry.data,
2591
+ `X Article readback entity ${index}.data`,
2592
+ );
2593
+ const normalizedData = entry.type === "MEDIA"
2594
+ ? Object.freeze({
2595
+ ...(data.caption === undefined || data.caption === null || data.caption === ""
2596
+ ? {}
2597
+ : { caption: data.caption }),
2598
+ entity_key: (() => {
2599
+ const sourceDataKey = normalizedArticleEntityKey(
2600
+ data.entity_key,
2601
+ `X Article readback entity ${index}.data.entity_key`,
2602
+ );
2603
+ if (sourceDataKey !== sourceEntityKey) {
2604
+ throw new Error(`X Article readback entity ${index} changed its media entity key`);
2605
+ }
2606
+ return `${index}`;
2607
+ })(),
2608
+ media_items: data.media_items,
2609
+ })
2610
+ : Object.freeze({ url: data.url });
2611
+ return Object.freeze({
2612
+ key: `${index}`,
2613
+ value: Object.freeze({
2614
+ data: normalizedData,
2615
+ type: entry.type,
2616
+ mutability: entry.mutability,
2617
+ }),
2618
+ });
2619
+ });
2620
+ const normalized = Object.freeze({
2621
+ blocks: Object.freeze(blocks),
2622
+ entity_map: Object.freeze(entity_map),
2623
+ });
2624
+ validateXWebRichArticleContentState(normalized);
2625
+ return normalized;
2626
+ }
2627
+ if (!Array.isArray(state.blocks) || !Array.isArray(state.entityMap)) {
2628
+ throw new Error("X Article readback omitted its rich content state");
2629
+ }
2630
+ const entityKeys = articleReadbackEntityKeyMap(
2631
+ state.entityMap,
2632
+ state.blocks,
2633
+ "entityRanges",
2634
+ );
2635
+ const observedBlockKeys = new Set<string>();
2636
+ const blocks = state.blocks.map((value, index) => {
2637
+ const block = record(value, `X Article readback block ${index + 1}`);
2638
+ if (!Array.isArray(block.entityRanges) || !Array.isArray(block.inlineStyleRanges)) {
2639
+ throw new Error("X Article readback block ranges changed shape");
2640
+ }
2641
+ return Object.freeze({
2642
+ data: normalizedArticleBlockData(
2643
+ block.data,
2644
+ `X Article readback block ${index + 1}.data`,
2645
+ block.text,
2646
+ ),
2647
+ text: block.text,
2648
+ key: normalizedArticleBlockKey(
2649
+ block.key,
2650
+ index,
2651
+ observedBlockKeys,
2652
+ ),
2653
+ type: block.type,
2654
+ entity_ranges: Object.freeze(block.entityRanges.map((range) => {
2655
+ const item = record(range, "X Article readback entity range");
2656
+ return Object.freeze({
2657
+ key: Number(remappedArticleEntityKey(
2658
+ item.key,
2659
+ entityKeys,
2660
+ "X Article readback entity range key",
2661
+ )),
2662
+ offset: item.offset,
2663
+ length: item.length,
2664
+ });
2665
+ })),
2666
+ inline_style_ranges: Object.freeze(block.inlineStyleRanges.map((range) => {
2667
+ const item = record(range, "X Article readback style range");
2668
+ return Object.freeze({ length: item.length, offset: item.offset, style: item.style });
2669
+ })),
2670
+ });
2671
+ });
2672
+ const entity_map = orderedArticleReadbackEntities(
2673
+ state.entityMap,
2674
+ entityKeys,
2675
+ ).map((value, index) => {
2676
+ const entity = record(value, `X Article readback entity ${index}`);
2677
+ const sourceEntityKey = normalizedArticleEntityKey(
2678
+ entity.key,
2679
+ `X Article readback entity ${index}.key`,
2680
+ );
2681
+ const entry = record(entity.value, `X Article readback entity ${index}.value`);
2682
+ const data = record(entry.data, `X Article readback entity ${index}.data`);
2683
+ const normalizedData = entry.type === "MEDIA"
2684
+ ? Object.freeze({
2685
+ ...(data.caption === undefined || data.caption === null || data.caption === ""
2686
+ ? {}
2687
+ : { caption: data.caption }),
2688
+ entity_key: (() => {
2689
+ const sourceDataKey = normalizedArticleEntityKey(
2690
+ data.entityKey,
2691
+ `X Article readback entity ${index}.data.entityKey`,
2692
+ );
2693
+ if (sourceDataKey !== sourceEntityKey) {
2694
+ throw new Error(`X Article readback entity ${index} changed its media entity key`);
2695
+ }
2696
+ return `${index}`;
2697
+ })(),
2698
+ media_items: Array.isArray(data.mediaItems)
2699
+ ? Object.freeze(data.mediaItems.map((value) => {
2700
+ const item = record(value, "X Article readback media item");
2701
+ return Object.freeze({
2702
+ local_media_id: item.localMediaId,
2703
+ media_category: item.mediaCategory,
2704
+ media_id: item.mediaId,
2705
+ });
2706
+ }))
2707
+ : data.mediaItems,
2708
+ })
2709
+ : Object.freeze({ url: data.url });
2710
+ return Object.freeze({
2711
+ key: `${index}`,
2712
+ value: Object.freeze({
2713
+ data: normalizedData,
2714
+ type: entry.type,
2715
+ mutability: entry.mutability,
2716
+ }),
2717
+ });
2718
+ });
2719
+ const normalized = Object.freeze({ blocks: Object.freeze(blocks), entity_map: Object.freeze(entity_map) });
2720
+ validateXWebRichArticleContentState(normalized);
2721
+ return normalized;
2722
+ }
2723
+
2724
+ async function readArticleDraft(
2725
+ bootstrap: XBootstrap,
2726
+ id: string,
2727
+ ): Promise<JsonRecord> {
2728
+ const descriptor = await resolveDescriptor(bootstrap, "ArticleEntityResultByRestId", "query");
2729
+ const response = await graphQl(bootstrap, descriptor, { articleEntityId: id }, "GET");
2730
+ return responseBoundArticle(response, "article_result_by_rest_id", id);
2731
+ }
2732
+
2733
+ type XPrivateArticleDraftReadOutput = Readonly<{
2734
+ provider: "x";
2735
+ operation: "articles.read";
2736
+ article: Readonly<{
2737
+ id: string;
2738
+ ownerId: string;
2739
+ kind: "private-draft";
2740
+ lifecycle: "Draft";
2741
+ published: false;
2742
+ title: string;
2743
+ content: XWebRichArticleContentState;
2744
+ }>;
2745
+ }>;
2746
+
2747
+ function projectPrivateArticleDraftRead(
2748
+ article: JsonRecord,
2749
+ expectedId: string,
2750
+ expectedViewerId: string,
2751
+ ): XPrivateArticleDraftReadOutput {
2752
+ requirePrivateDraftArticle(article, expectedId, expectedViewerId);
2753
+ const title = articleTitle(article, "X private Article draft readback");
2754
+ if (title.length < 1 || title.length > 100 || /[\0\r\n]/u.test(title)) {
2755
+ throw new Error("X private Article draft title must be one bounded plain-text line");
2756
+ }
2757
+ return Object.freeze({
2758
+ provider: "x",
2759
+ operation: "articles.read",
2760
+ article: Object.freeze({
2761
+ id: expectedId,
2762
+ ownerId: expectedViewerId,
2763
+ kind: "private-draft",
2764
+ lifecycle: "Draft",
2765
+ published: false,
2766
+ title,
2767
+ content: normalizeArticleContentReadback(article.content_state),
2768
+ }),
2769
+ });
2770
+ }
2771
+
2772
+ async function executePrivateArticleDraftRead(
2773
+ bootstrap: XBootstrap,
2774
+ recipe: WebSessionRecipe,
2775
+ input: OperationInput,
2776
+ auth: GhostgetAuth,
2777
+ ): Promise<WebSessionExecution> {
2778
+ if (
2779
+ recipe.site !== "x"
2780
+ || recipe.action !== "articles.read"
2781
+ || recipe.contractVersion !== 2
2782
+ ) {
2783
+ throw new Error("X private Article draft reads support only articles.read@2");
2784
+ }
2785
+ const id = postId(input.article_id, "input.article_id");
2786
+ const currentViewer = await requireBoundViewer(bootstrap, auth);
2787
+ const output = projectPrivateArticleDraftRead(
2788
+ await readArticleDraft(bootstrap, id),
2789
+ id,
2790
+ currentViewer.id,
2791
+ );
2792
+ return {
2793
+ status: "succeeded",
2794
+ output,
2795
+ finalUrl: `${X_ORIGIN}/compose/articles/edit/${id}`,
2796
+ dispatchStarted: false,
2797
+ dispatch: { planned: 0, started: 0, verified: 0 },
2798
+ };
2799
+ }
2800
+
2801
+ function verifyFinalRichArticle(
2802
+ article: JsonRecord,
2803
+ expected: {
2804
+ readonly id: string;
2805
+ readonly viewerId: string;
2806
+ readonly title: string;
2807
+ readonly contentState: XWebRichArticleContentState;
2808
+ },
2809
+ ): void {
2810
+ requirePrivateDraftArticle(article, expected.id, expected.viewerId);
2811
+ if (articleTitle(article, "X Article readback") !== expected.title) {
2812
+ throw new Error("X Article readback did not bind the confirmed title");
2813
+ }
2814
+ const content = normalizeArticleContentReadback(article.content_state);
2815
+ if (canonicalJson(content) !== canonicalJson(expected.contentState)) {
2816
+ throw new Error("X Article readback did not bind the confirmed rich content state");
2817
+ }
2818
+ }
2819
+
2820
+ function dispatchEvent(id: string, index: number, planned: number, started: number, verified: number): WebSessionDispatchEvent {
2821
+ return { id, index, progress: { planned, started, verified } };
2822
+ }
2823
+
2824
+ type XPostFailureStage =
2825
+ | "viewer-binding-before-media-upload"
2826
+ | XMediaUploadFailureStage
2827
+ | "viewer-binding-after-media-upload"
2828
+ | "post-request-preparation"
2829
+ | "dispatch-admission"
2830
+ | "post-dispatch"
2831
+ | "create-response-binding"
2832
+ | "accepted-target-recording"
2833
+ | "independent-readback"
2834
+ | "verification-recording";
2835
+
2836
+ async function publishOne(
2837
+ bootstrap: XBootstrap,
2838
+ text: string,
2839
+ replyTo: string | null,
2840
+ quote: string | null,
2841
+ media: XUploadedMedia | null,
2842
+ authorId: string,
2843
+ mutationOperation: XWebMutationOperationId,
2844
+ sleep: (milliseconds: number) => Promise<void>,
2845
+ afterProviderAcceptedMutationTarget?: (
2846
+ post: {
2847
+ readonly id: string;
2848
+ readonly url: string;
2849
+ readonly mediaId: string | null;
2850
+ },
2851
+ ) => Promise<void>,
2852
+ beforeRequest?: () => Promise<void>,
2853
+ onFailureStage?: (stage: XPostFailureStage) => void,
2854
+ ): Promise<{ readonly id: string; readonly url: string }> {
2855
+ onFailureStage?.("post-request-preparation");
2856
+ const descriptor = await resolveDescriptor(bootstrap, "CreateTweet", "mutation");
2857
+ const response = await graphQl(
2858
+ bootstrap,
2859
+ descriptor,
2860
+ createTweetVariables(text, replyTo, quote, media?.id ?? null),
2861
+ "POST",
2862
+ undefined,
2863
+ mutationOperation,
2864
+ beforeRequest,
2865
+ );
2866
+ onFailureStage?.("create-response-binding");
2867
+ const created = createdTweet(
2868
+ response,
2869
+ text,
2870
+ replyTo,
2871
+ quote,
2872
+ authorId,
2873
+ media?.id ?? null,
2874
+ media?.mediaType ?? null,
2875
+ );
2876
+ onFailureStage?.("accepted-target-recording");
2877
+ await afterProviderAcceptedMutationTarget?.({
2878
+ ...created,
2879
+ mediaId: media?.id ?? null,
2880
+ });
2881
+ if (mutationOperation !== "posts.publish") return created;
2882
+ onFailureStage?.("independent-readback");
2883
+ const readback = await waitForTweetPublishReadback(
2884
+ bootstrap,
2885
+ created.id,
2886
+ sleep,
2887
+ );
2888
+ const rebound = assertTweetBinding(
2889
+ readback,
2890
+ text,
2891
+ replyTo,
2892
+ quote,
2893
+ authorId,
2894
+ media?.id ?? null,
2895
+ media?.mediaType ?? null,
2896
+ "X independent post readback",
2897
+ );
2898
+ if (rebound.id !== created.id) {
2899
+ throw new Error("X independent post readback changed the created post ID");
2900
+ }
2901
+ return created;
2902
+ }
2903
+
2904
+ function rejectUnsupportedPostBranches(input: OperationInput): void {
2905
+ for (const field of ["made_with_ai", "content_disclosure", "ai_generated_disclosure", "semantic_annotation_ids"] as const) {
2906
+ if (input[field] !== undefined) {
2907
+ throw new Error(`X posts.publish ${field} is outside the reviewed CreateTweet contract`);
2908
+ }
2909
+ }
2910
+ if (input.root_media !== undefined) {
2911
+ throw new Error("X internal root_media upload requires a separately reviewed thread contract");
2912
+ }
2913
+ const replySettings = optionalStringInput(input, "reply_settings");
2914
+ if (replySettings !== undefined && replySettings !== "everyone") {
2915
+ throw new Error("X restricted reply settings require a separately reviewed conversation-control contract");
2916
+ }
2917
+ }
2918
+
2919
+ async function executePublish(
2920
+ bootstrap: XBootstrap,
2921
+ recipe: WebSessionRecipe,
2922
+ input: OperationInput,
2923
+ auth: GhostgetAuth,
2924
+ options: {
2925
+ readonly signal?: AbortSignal;
2926
+ readonly operationDeadline?: WebSessionOperationDeadline;
2927
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
2928
+ readonly fileResolver?: BrowserFileResolver;
2929
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2930
+ readonly afterProviderAcceptedMutationTarget?: (
2931
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2932
+ ) => Promise<void>;
2933
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2934
+ },
2935
+ ): Promise<WebSessionExecution> {
2936
+ rejectUnsupportedPostBranches(input);
2937
+ const currentViewer = await requireBoundViewer(bootstrap, auth);
2938
+ const action = recipe.action;
2939
+ if (
2940
+ action !== "posts.publish"
2941
+ && (input.media !== undefined || input.media_type !== undefined)
2942
+ ) throw new Error("X media upload is reviewed only for posts.publish");
2943
+ const boundMedia = action === "posts.publish"
2944
+ ? await readBoundXMedia(input, options.fileResolver, bootstrap.operationDeadline)
2945
+ : null;
2946
+ const texts = action === "threads.publish"
2947
+ ? threadTexts(input)
2948
+ : [requiredString(input.body, "input.body", MAX_X_CREATE_TWEET_TEXT_LENGTH)];
2949
+ const planned = texts.length;
2950
+ const posts: { readonly id: string; readonly url: string }[] = [];
2951
+ let started = 0;
2952
+ let verified = 0;
2953
+ let failureStage: XPostFailureStage = "post-request-preparation";
2954
+ let previous: string | null = action === "replies.create" ? postId(input.post_id, "input.post_id") : null;
2955
+ const quote = action === "posts.quote" ? postId(input.post_id, "input.post_id") : null;
2956
+ try {
2957
+ for (const [offset, text] of texts.entries()) {
2958
+ const index = offset + 1;
2959
+ const id = action === "threads.publish" ? `${action}[${index}]` : action;
2960
+ let media: XUploadedMedia | null = null;
2961
+ const beforeRequest = async () => {
2962
+ failureStage = "dispatch-admission";
2963
+ await options.beforeDispatch?.(dispatchEvent(id, index, planned, started, verified));
2964
+ started = index;
2965
+ failureStage = "post-dispatch";
2966
+ };
2967
+ if (boundMedia !== null) {
2968
+ failureStage = "viewer-binding-before-media-upload";
2969
+ const rebound = await requireBoundViewer(bootstrap, auth);
2970
+ if (rebound.id !== currentViewer.id) {
2971
+ throw new Error("X viewer changed during media dispatch preparation");
2972
+ }
2973
+ // The upload produces only an expiring, unpublished provider blob. Keep
2974
+ // it before the durable CreateTweet dispatch boundary so a transport or
2975
+ // upload-response failure cannot strand the public-post intent as
2976
+ // indeterminate when no CreateTweet request was admitted.
2977
+ failureStage = "media-upload-session";
2978
+ media = await uploadXMedia(bootstrap, boundMedia, (stage) => {
2979
+ failureStage = stage;
2980
+ });
2981
+ failureStage = "viewer-binding-after-media-upload";
2982
+ const afterUpload = await requireBoundViewer(bootstrap, auth);
2983
+ if (afterUpload.id !== currentViewer.id) {
2984
+ throw new Error("X viewer changed after media upload");
2985
+ }
2986
+ }
2987
+ const post = await publishOne(
2988
+ bootstrap,
2989
+ text,
2990
+ previous,
2991
+ quote,
2992
+ media,
2993
+ currentViewer.id,
2994
+ action === "threads.publish" && offset > 0 ? "threads.reply" : action as XWebMutationOperationId,
2995
+ bootstrap.dependencies?.sleep
2996
+ ?? ((milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds))),
2997
+ options.afterProviderAcceptedMutationTarget === undefined
2998
+ ? undefined
2999
+ : (accepted) => options.afterProviderAcceptedMutationTarget!({
3000
+ id,
3001
+ index,
3002
+ target: {
3003
+ schemaVersion: 1,
3004
+ identifier: canonicalJson({
3005
+ postId: accepted.id,
3006
+ mediaId: accepted.mediaId,
3007
+ }),
3008
+ },
3009
+ }),
3010
+ beforeRequest,
3011
+ (stage) => {
3012
+ failureStage = stage;
3013
+ },
3014
+ );
3015
+ posts.push(post);
3016
+ previous = post.id;
3017
+ verified = index;
3018
+ failureStage = "verification-recording";
3019
+ await options.afterDispatchVerified?.(dispatchEvent(id, index, planned, started, verified));
3020
+ }
3021
+ return {
3022
+ status: "succeeded",
3023
+ output: { posts },
3024
+ finalUrl: posts.at(-1)?.url ?? X_ORIGIN,
3025
+ dispatchStarted: started > 0,
3026
+ dispatch: { planned, started, verified },
3027
+ };
3028
+ } catch (error) {
3029
+ if (error instanceof XUnlabeledCopyPolicyError) {
3030
+ const labeled = error.post === undefined ? posts : [...posts, error.post];
3031
+ return {
3032
+ status: "indeterminate",
3033
+ output: labeled.length === 0 ? null : { posts: labeled },
3034
+ finalUrl: error.post?.url ?? posts.at(-1)?.url ?? null,
3035
+ dispatchStarted: started > 0,
3036
+ dispatch: { planned, started, verified },
3037
+ error: X_UNLABELED_COPY_POLICY_ERROR,
3038
+ };
3039
+ }
3040
+ const status = started > verified ? "indeterminate" : verified > 0 ? "partial" : "failed";
3041
+ return {
3042
+ status,
3043
+ output: posts.length === 0 ? null : { posts },
3044
+ finalUrl: posts.at(-1)?.url ?? null,
3045
+ dispatchStarted: started > 0,
3046
+ dispatch: { planned, started, verified },
3047
+ error: status === "indeterminate"
3048
+ ? `X may have accepted the current post dispatch; failure stage: ${failureStage}; reconcile before retrying`
3049
+ : status === "partial"
3050
+ ? `X verified only part of the confirmed post workflow; failure stage: ${failureStage}; inspect the verified results before retrying`
3051
+ : `X post preparation failed before public post submission; failure stage: ${failureStage}; retry with a fresh confirmed plan`,
3052
+ };
3053
+ }
3054
+ }
3055
+
3056
+ async function executeArticleDraftSave(
3057
+ bootstrap: XBootstrap,
3058
+ recipe: WebSessionRecipe,
3059
+ input: OperationInput,
3060
+ auth: GhostgetAuth,
3061
+ options: {
3062
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
3063
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
3064
+ readonly fileResolver?: BrowserFileResolver;
3065
+ },
3066
+ ): Promise<WebSessionExecution> {
3067
+ if (
3068
+ (recipe.contractVersion !== 1 && recipe.contractVersion !== 2)
3069
+ || recipe.action !== "articles.draft.save"
3070
+ ) {
3071
+ throw new Error("X Article draft saving supports only articles.draft.save@1 or @2");
3072
+ }
3073
+ const title = requiredString(input.title, "input.title", MAX_ARTICLE_TITLE_CHARACTERS);
3074
+ if (/[\0\r\n]/u.test(title)) throw new Error("input.title must be one plain-text line");
3075
+ const document = recipe.contractVersion === 1
3076
+ ? parseArticleDraftDocument(input.document, {
3077
+ maximumBlocks: MAX_ARTICLE_BLOCKS,
3078
+ maximumCharacters: MAX_ARTICLE_BODY_CHARACTERS,
3079
+ })
3080
+ : parseArticleDraftDocumentV2(input.document, {
3081
+ maximumBlocks: MAX_ARTICLE_BLOCKS,
3082
+ maximumCharacters: MAX_ARTICLE_BODY_CHARACTERS,
3083
+ maximumImages: MAX_ARTICLE_INLINE_IMAGES,
3084
+ });
3085
+ if (
3086
+ recipe.contractVersion === 2
3087
+ && document.blocks.some((block) => block.type === "image" && block.altText !== undefined)
3088
+ ) throw new Error("X Article inline-image alternative text remains capture-required");
3089
+ const images = recipe.contractVersion === 1
3090
+ ? Object.freeze([])
3091
+ : await materializeArticleDraftImages(input.inline_images, options.fileResolver, {
3092
+ maximumBytes: MAX_ARTICLE_IMAGE_BYTES,
3093
+ maximumImages: MAX_ARTICLE_INLINE_IMAGES,
3094
+ ...(bootstrap.operationDeadline === undefined
3095
+ ? {}
3096
+ : { operationDeadline: bootstrap.operationDeadline }),
3097
+ });
3098
+ if (
3099
+ recipe.contractVersion === 2
3100
+ && document.blocks.filter((block) => block.type === "image").length !== images.length
3101
+ ) throw new Error("input.inline_images must align one-to-one with input.document image blocks");
3102
+ await assertCurrentArticleRichContract(bootstrap, recipe.contractVersion === 2);
3103
+ const currentViewer = await requireBoundViewer(bootstrap, auth);
3104
+ const requestedDraftId = input.draft_id === undefined ? null : postId(input.draft_id, "input.draft_id");
3105
+ if (requestedDraftId !== null) {
3106
+ requirePrivateDraftArticle(await readArticleDraft(bootstrap, requestedDraftId), requestedDraftId, currentViewer.id);
3107
+ }
3108
+
3109
+ const planned = images.length + (requestedDraftId === null ? 1 : 2);
3110
+ let started = 0;
3111
+ let verified = 0;
3112
+ let draftId = requestedDraftId;
3113
+ let contentState: XWebRichArticleContentState | null = recipe.contractVersion === 1
3114
+ ? buildXWebRichArticleContentState(document)
3115
+ : null;
3116
+ const uploadedImageIds: string[] = [];
3117
+ let nextIndex = 0;
3118
+ let failureStage = "binding the Article mutation session";
3119
+ const begin = async (id: string): Promise<number> => {
3120
+ const index = nextIndex + 1;
3121
+ await options.beforeDispatch?.(dispatchEvent(id, index, planned, started, verified));
3122
+ nextIndex = index;
3123
+ started = index;
3124
+ return index;
3125
+ };
3126
+ const complete = async (id: string, index: number): Promise<void> => {
3127
+ await options.afterDispatchVerified?.(dispatchEvent(id, index, planned, started, index));
3128
+ verified = index;
3129
+ };
3130
+
3131
+ try {
3132
+ if (images.length > 0) {
3133
+ failureStage = "binding the media upload session";
3134
+ const uploadClient = await createWebSessionClient(articleUploadOrigin(bootstrap), auth, {
3135
+ timeoutMs: bootstrap.timeoutMs,
3136
+ ...(bootstrap.signal === undefined ? {} : { signal: bootstrap.signal }),
3137
+ ...(bootstrap.operationDeadline === undefined
3138
+ ? {}
3139
+ : { operationDeadline: bootstrap.operationDeadline }),
3140
+ ...(bootstrap.dependencies === undefined ? {} : { dependencies: bootstrap.dependencies }),
3141
+ });
3142
+ if (webSessionCookie(uploadClient.cookies, "ct0") !== bootstrap.csrf) {
3143
+ throw new Error("X media upload session did not bind the Article session CSRF realm");
3144
+ }
3145
+ for (const [offset, image] of images.entries()) {
3146
+ const id = `articles.media.inline[${offset + 1}]`;
3147
+ failureStage = `uploading inline image ${offset + 1}`;
3148
+ let index = 0;
3149
+ const mediaId = await uploadArticleImage(bootstrap, uploadClient, image, async () => {
3150
+ index = await begin(id);
3151
+ });
3152
+ uploadedImageIds.push(mediaId);
3153
+ await complete(id, index);
3154
+ }
3155
+ contentState = buildXWebRichArticleContentState(document, uploadedImageIds);
3156
+ }
3157
+ if (contentState === null) {
3158
+ throw new Error("X Article inline images did not produce a verified content state");
3159
+ }
3160
+ if (draftId === null) {
3161
+ const id = "articles.create";
3162
+ failureStage = "resolving the Article create mutation";
3163
+ const descriptor = await resolveDescriptor(bootstrap, "ArticleEntityDraftCreate", "mutation");
3164
+ let index = 0;
3165
+ failureStage = "preparing the Article create mutation";
3166
+ const response = await graphQl(
3167
+ bootstrap,
3168
+ descriptor,
3169
+ { content_state: contentState, title },
3170
+ "POST",
3171
+ undefined,
3172
+ "articles.create",
3173
+ async () => {
3174
+ index = await begin(id);
3175
+ },
3176
+ );
3177
+ failureStage = "binding the Article create response";
3178
+ const article = responseBoundArticle(response, "articleentity_create_draft", null);
3179
+ draftId = articleId(article, "X created Article draft response");
3180
+ failureStage = "reading back the created Article";
3181
+ const finalArticle = await readArticleDraft(bootstrap, draftId);
3182
+ failureStage = "verifying the created Article readback";
3183
+ verifyFinalRichArticle(finalArticle, {
3184
+ id: draftId,
3185
+ viewerId: currentViewer.id,
3186
+ title,
3187
+ contentState,
3188
+ });
3189
+ await complete(id, index);
3190
+ } else {
3191
+ const titleId = "articles.title";
3192
+ failureStage = "resolving the Article title mutation";
3193
+ const titleDescriptor = await resolveDescriptor(bootstrap, "ArticleEntityUpdateTitle", "mutation");
3194
+ let titleIndex = 0;
3195
+ failureStage = "preparing the Article title mutation";
3196
+ const titleResponse = await graphQl(
3197
+ bootstrap,
3198
+ titleDescriptor,
3199
+ { articleEntityId: draftId, title },
3200
+ "POST",
3201
+ undefined,
3202
+ "articles.title",
3203
+ async () => {
3204
+ titleIndex = await begin(titleId);
3205
+ },
3206
+ );
3207
+ responseBoundArticle(titleResponse, "articleentity_update_title", draftId, title);
3208
+ await complete(titleId, titleIndex);
3209
+
3210
+ const contentId = "articles.content";
3211
+ failureStage = "resolving the Article content mutation";
3212
+ const contentDescriptor = await resolveDescriptor(bootstrap, "ArticleEntityUpdateContent", "mutation");
3213
+ let contentIndex = 0;
3214
+ failureStage = "preparing the Article content mutation";
3215
+ const contentResponse = await graphQl(
3216
+ bootstrap,
3217
+ contentDescriptor,
3218
+ { content_state: contentState, article_entity: draftId },
3219
+ "POST",
3220
+ undefined,
3221
+ "articles.content",
3222
+ async () => {
3223
+ contentIndex = await begin(contentId);
3224
+ },
3225
+ );
3226
+ responseBoundArticle(contentResponse, "articleentity_update_content_state", draftId);
3227
+ const finalArticle = await readArticleDraft(bootstrap, draftId);
3228
+ verifyFinalRichArticle(finalArticle, {
3229
+ id: draftId,
3230
+ viewerId: currentViewer.id,
3231
+ title,
3232
+ contentState,
3233
+ });
3234
+ await complete(contentId, contentIndex);
3235
+ }
3236
+
3237
+ if (draftId === null || nextIndex !== planned || verified !== planned) {
3238
+ throw new Error("X Article draft workflow did not complete its exact dispatch schedule");
3239
+ }
3240
+ const url = `${X_ORIGIN}/compose/articles/edit/${draftId}`;
3241
+ return {
3242
+ status: "succeeded",
3243
+ output: {
3244
+ provider: "x",
3245
+ operation: "articles.draft.save",
3246
+ published: false,
3247
+ mode: "draft",
3248
+ draftId,
3249
+ title,
3250
+ documentSchemaVersion: recipe.contractVersion,
3251
+ ...(recipe.contractVersion === 1
3252
+ ? {}
3253
+ : { inlineImageCount: uploadedImageIds.length }),
3254
+ url,
3255
+ },
3256
+ finalUrl: url,
3257
+ dispatchStarted: started > 0,
3258
+ dispatch: { planned, started, verified },
3259
+ };
3260
+ } catch (error) {
3261
+ const url = draftId === null ? null : `${X_ORIGIN}/compose/articles/edit/${draftId}`;
3262
+ return {
3263
+ status: started > verified ? "indeterminate" : verified > 0 ? "partial" : "failed",
3264
+ output: null,
3265
+ finalUrl: url,
3266
+ dispatchStarted: started > 0,
3267
+ dispatch: { planned, started, verified },
3268
+ error: started > verified
3269
+ ? images.length > 0
3270
+ ? `X may have accepted an inline-image upload or private Article dispatch while ${failureStage}; ${xWebArticleImageFailureCategory(error)}; its provider media IDs are not present in the confirmed input, so preserve the indeterminate run and do not retry`
3271
+ : requestedDraftId === null
3272
+ ? "X may have accepted the private Article create, but the confirmed input has no exact draft ID for safe reconciliation; preserve the indeterminate run and do not retry"
3273
+ : "X may have accepted the current private Article replacement dispatch; reconcile the exact existing draft before retrying"
3274
+ : verified > 0
3275
+ ? "X verified only part of the confirmed private Article workflow; inspect the draft before retrying"
3276
+ : `X Article draft failed before remote submission while ${failureStage}`,
3277
+ };
3278
+ }
3279
+ }
3280
+
3281
+ async function executeDesiredState(
3282
+ bootstrap: XBootstrap,
3283
+ recipe: WebSessionRecipe,
3284
+ input: OperationInput,
3285
+ auth: GhostgetAuth,
3286
+ options: {
3287
+ readonly signal?: AbortSignal;
3288
+ readonly operationDeadline?: WebSessionOperationDeadline;
3289
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
3290
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
3291
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
3292
+ },
3293
+ ): Promise<WebSessionExecution> {
3294
+ await requireBoundViewer(bootstrap, auth);
3295
+ const id = postId(input.post_id, "input.post_id");
3296
+ const kind = recipe.action === "likes.set" ? "like" : recipe.action === "content.save" ? "bookmark" : "repost";
3297
+ const enabled = recipe.action === "likes.set"
3298
+ ? booleanInput(input, "liked")
3299
+ : recipe.action === "content.save" ? booleanInput(input, "saved") : booleanInput(input, "reposted");
3300
+ const operationName = kind === "like"
3301
+ ? enabled ? "FavoriteTweet" : "UnfavoriteTweet"
3302
+ : kind === "bookmark"
3303
+ ? enabled ? "CreateBookmark" : "DeleteBookmark"
3304
+ : enabled ? "CreateRetweet" : "DeleteRetweet";
3305
+ const variables = kind === "repost" && !enabled
3306
+ ? { source_tweet_id: id, dark_request: false }
3307
+ : kind === "repost" ? { tweet_id: id, dark_request: false } : { tweet_id: id };
3308
+ let started = 0;
3309
+ let verified = 0;
3310
+ try {
3311
+ const initial = await desiredStateReadback(bootstrap, id, kind);
3312
+ if (initial === enabled) {
3313
+ return {
3314
+ status: "succeeded",
3315
+ output: {
3316
+ effect: "already-satisfied",
3317
+ kind,
3318
+ enabled,
3319
+ postId: id,
3320
+ },
3321
+ finalUrl: `${X_ORIGIN}/i/status/${id}`,
3322
+ noOp: true,
3323
+ dispatchStarted: false,
3324
+ dispatch: { planned: 1, started: 0, verified: 0 },
3325
+ };
3326
+ }
3327
+ const descriptor = await resolveDescriptor(bootstrap, operationName, "mutation");
3328
+ const mutationOperation = `${kind === "bookmark" ? "bookmarks" : kind === "like" ? "likes" : "reposts"}.${enabled ? "enable" : "disable"}` as XWebMutationOperationId;
3329
+ const response = await graphQl(
3330
+ bootstrap,
3331
+ descriptor,
3332
+ variables,
3333
+ "POST",
3334
+ undefined,
3335
+ mutationOperation,
3336
+ async () => {
3337
+ await options.beforeDispatch?.(dispatchEvent(recipe.action, 1, 1, 0, 0));
3338
+ started = 1;
3339
+ },
3340
+ );
3341
+ validateXWebDesiredStateMutation({ kind, enabled, targetPostId: id, response });
3342
+ const actual = await desiredStateReadback(bootstrap, id, kind);
3343
+ if (actual !== enabled) throw new Error("X desired-state readback did not match the confirmed state");
3344
+ verified = 1;
3345
+ await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1, 1, 1));
3346
+ return {
3347
+ status: "succeeded",
3348
+ output: { kind, enabled, postId: id },
3349
+ finalUrl: `${X_ORIGIN}/i/status/${id}`,
3350
+ dispatchStarted: true,
3351
+ dispatch: { planned: 1, started, verified },
3352
+ };
3353
+ } catch {
3354
+ return {
3355
+ status: started > 0 ? "indeterminate" : "failed",
3356
+ output: null,
3357
+ finalUrl: `${X_ORIGIN}/i/status/${id}`,
3358
+ dispatchStarted: started > 0,
3359
+ dispatch: { planned: 1, started, verified },
3360
+ error: started > 0
3361
+ ? "X may have changed the requested state but exact readback was not verified; reconcile before retrying"
3362
+ : "X desired-state dispatch failed before submission",
3363
+ };
3364
+ }
3365
+ }
3366
+
3367
+ export async function executeXWebOperation(
3368
+ recipe: WebSessionRecipe,
3369
+ input: OperationInput,
3370
+ auth: GhostgetAuth,
3371
+ options: {
3372
+ readonly signal?: AbortSignal;
3373
+ readonly operationDeadline?: WebSessionOperationDeadline;
3374
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
3375
+ readonly fileResolver?: BrowserFileResolver;
3376
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
3377
+ readonly afterProviderAcceptedMutationTarget?: (
3378
+ event: WebSessionProviderAcceptedMutationTargetEvent,
3379
+ ) => Promise<void>;
3380
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
3381
+ readonly dependencies?: XWebRuntimeDependencies;
3382
+ } = {},
3383
+ ): Promise<WebSessionExecution> {
3384
+ if (
3385
+ recipe.action === "articles.read"
3386
+ && (recipe.site !== "x" || recipe.contractVersion !== 2)
3387
+ ) {
3388
+ throw new Error("X private Article draft reads support only articles.read@2");
3389
+ }
3390
+ if (
3391
+ recipe.action === "posts.publish"
3392
+ || recipe.action === "threads.publish"
3393
+ || recipe.action === "replies.create"
3394
+ || recipe.action === "posts.quote"
3395
+ ) {
3396
+ rejectUnsupportedPostBranches(input);
3397
+ }
3398
+ let profileUrl: string | null = null;
3399
+ let bootstrap: XBootstrap;
3400
+ try {
3401
+ if (recipe.action === "profiles.read") {
3402
+ profileUrl = `${X_ORIGIN}/${normalizeXWebProfileHandle(input.handle)}`;
3403
+ }
3404
+ bootstrap = await bootstrapX(
3405
+ auth,
3406
+ recipe,
3407
+ options.dependencies,
3408
+ options,
3409
+ );
3410
+ } catch (error) {
3411
+ if (recipe.action === "profiles.read") {
3412
+ return failedXProfileRead(error, profileUrl, "bootstrap");
3413
+ }
3414
+ if (recipe.action === "feeds.read") {
3415
+ return failedXFeedRead(error, input, "bootstrap");
3416
+ }
3417
+ throw error;
3418
+ }
3419
+ if (recipe.action === "feeds.read") {
3420
+ try {
3421
+ await requireBoundViewer(bootstrap, auth);
3422
+ } catch (error) {
3423
+ return failedXFeedRead(error, input, "identity");
3424
+ }
3425
+ try {
3426
+ const output = await readFeed(bootstrap, input);
3427
+ return {
3428
+ status: "succeeded",
3429
+ output,
3430
+ finalUrl: stringInput(input, "feed") === "bookmarks" ? `${X_ORIGIN}/i/bookmarks` : `${X_ORIGIN}/home`,
3431
+ dispatchStarted: false,
3432
+ dispatch: { planned: 0, started: 0, verified: 0 },
3433
+ };
3434
+ } catch (error) {
3435
+ return failedXFeedRead(error, input, "target");
3436
+ }
3437
+ }
3438
+ if (recipe.action === "profiles.read") {
3439
+ try {
3440
+ await requireBoundViewer(bootstrap, auth);
3441
+ } catch (error) {
3442
+ return failedXProfileRead(error, profileUrl, "viewer");
3443
+ }
3444
+ try {
3445
+ const output = await readProfile(bootstrap, input);
3446
+ return {
3447
+ status: "succeeded",
3448
+ output,
3449
+ finalUrl: output.target.url,
3450
+ dispatchStarted: false,
3451
+ dispatch: { planned: 0, started: 0, verified: 0 },
3452
+ };
3453
+ } catch (error) {
3454
+ return failedXProfileRead(error, profileUrl, "target");
3455
+ }
3456
+ }
3457
+ if (recipe.action === "posts.read" || recipe.action === "comments.read") {
3458
+ await requireBoundViewer(bootstrap, auth);
3459
+ const comments = recipe.action === "comments.read";
3460
+ const id = postId(input.post_id, "input.post_id");
3461
+ return {
3462
+ status: "succeeded",
3463
+ output: await readConversation(bootstrap, input, comments),
3464
+ finalUrl: `${X_ORIGIN}/i/status/${id}`,
3465
+ dispatchStarted: false,
3466
+ dispatch: { planned: 0, started: 0, verified: 0 },
3467
+ };
3468
+ }
3469
+ if (recipe.action === "articles.read") {
3470
+ return executePrivateArticleDraftRead(bootstrap, recipe, input, auth);
3471
+ }
3472
+ if (
3473
+ recipe.action === "posts.publish"
3474
+ || recipe.action === "threads.publish"
3475
+ || recipe.action === "replies.create"
3476
+ || recipe.action === "posts.quote"
3477
+ ) return executePublish(bootstrap, recipe, input, auth, options);
3478
+ if (recipe.action === "articles.draft.save") {
3479
+ return executeArticleDraftSave(bootstrap, recipe, input, auth, options);
3480
+ }
3481
+ if (recipe.action === "likes.set" || recipe.action === "content.save" || recipe.action === "posts.repost") {
3482
+ return executeDesiredState(bootstrap, recipe, input, auth, options);
3483
+ }
3484
+ throw new Error(`X authenticated web operation ${recipe.action} has no executable reviewed contract`);
3485
+ }