@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,2332 @@
1
+ /**
2
+ * X consumer-web internal API policy primitives.
3
+ *
4
+ * This module deliberately does not perform requests. It turns current,
5
+ * browser-observed X operations into narrowly reviewed bindings that a
6
+ * browser-session executor can enforce without exporting session material.
7
+ */
8
+
9
+ export type XWebOperationType = "query" | "mutation";
10
+
11
+ export type XWebQueryDescriptorKey = {
12
+ readonly operationName: string;
13
+ readonly operationType: XWebOperationType;
14
+ readonly queryId: string;
15
+ };
16
+
17
+ export type XWebBundleQueryDescriptor = XWebQueryDescriptorKey & {
18
+ readonly metadata: {
19
+ readonly featureSwitches: readonly string[];
20
+ readonly fieldToggles: readonly string[];
21
+ };
22
+ };
23
+
24
+ export type XWebQueryDescriptorEvidence = XWebQueryDescriptorKey & {
25
+ readonly sourceChunk: string;
26
+ readonly observedOn?: string;
27
+ };
28
+
29
+ type JsonRecord = Record<string, unknown>;
30
+
31
+ function isRecord(value: unknown): value is JsonRecord {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ function record(value: unknown, label: string): JsonRecord {
36
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
37
+ return value;
38
+ }
39
+
40
+ function exactKeys(value: JsonRecord, expected: readonly string[], label: string): void {
41
+ const expectedSet = new Set(expected);
42
+ const extra = Object.keys(value).filter((key) => !expectedSet.has(key));
43
+ const missing = expected.filter((key) => !Object.hasOwn(value, key));
44
+ if (missing.length > 0) throw new Error(`${label} omitted ${missing.join(", ")}`);
45
+ if (extra.length > 0) throw new Error(`${label} contained unsupported field(s): ${extra.join(", ")}`);
46
+ }
47
+
48
+ function requiredString(value: JsonRecord, key: string, label: string): string {
49
+ const candidate = value[key];
50
+ if (typeof candidate !== "string" || candidate.length === 0) {
51
+ throw new Error(`${label}.${key} must be a non-empty string`);
52
+ }
53
+ return candidate;
54
+ }
55
+
56
+ function exactStringArray(value: unknown, label: string): readonly string[] {
57
+ if (!Array.isArray(value)) throw new Error(`${label} must be a string array`);
58
+ const result: string[] = [];
59
+ for (const [index, item] of value.entries()) {
60
+ if (typeof item !== "string" || !/^[A-Za-z][A-Za-z0-9_]{0,199}$/u.test(item)) {
61
+ throw new Error(`${label}[${index}] must be a feature or field-toggle name`);
62
+ }
63
+ result.push(item);
64
+ }
65
+ if (new Set(result).size !== result.length) throw new Error(`${label} contained duplicates`);
66
+ return Object.freeze(result);
67
+ }
68
+
69
+ function exactQueryId(value: string, label: string): string {
70
+ if (!/^[A-Za-z0-9_-]{8,128}$/u.test(value)) {
71
+ throw new Error(`${label} must be an exact X query ID`);
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function exactOperationName(value: string, label: string): string {
77
+ if (!/^[A-Za-z][A-Za-z0-9_]{1,127}$/u.test(value)) {
78
+ throw new Error(`${label} must be an exact X operation name`);
79
+ }
80
+ return value;
81
+ }
82
+
83
+ function exactOperationType(value: unknown, label: string): XWebOperationType {
84
+ if (value !== "query" && value !== "mutation") {
85
+ throw new Error(`${label} must be query or mutation`);
86
+ }
87
+ return value;
88
+ }
89
+
90
+ function parseBundleQueryDescriptor(value: unknown, label: string): XWebBundleQueryDescriptor {
91
+ const descriptor = record(value, label);
92
+ exactKeys(descriptor, ["queryId", "operationName", "operationType", "metadata"], label);
93
+ const metadata = record(descriptor.metadata, `${label}.metadata`);
94
+ exactKeys(metadata, ["featureSwitches", "fieldToggles"], `${label}.metadata`);
95
+ return Object.freeze({
96
+ queryId: exactQueryId(requiredString(descriptor, "queryId", label), `${label}.queryId`),
97
+ operationName: exactOperationName(
98
+ requiredString(descriptor, "operationName", label),
99
+ `${label}.operationName`,
100
+ ),
101
+ operationType: exactOperationType(descriptor.operationType, `${label}.operationType`),
102
+ metadata: Object.freeze({
103
+ featureSwitches: exactStringArray(metadata.featureSwitches, `${label}.metadata.featureSwitches`),
104
+ fieldToggles: exactStringArray(metadata.fieldToggles, `${label}.metadata.fieldToggles`),
105
+ }),
106
+ });
107
+ }
108
+
109
+ function parseDescriptorKey(value: unknown, label: string): XWebQueryDescriptorKey {
110
+ const key = record(value, label);
111
+ const required = ["queryId", "operationName", "operationType"] as const;
112
+ const allowed = new Set([...required, "metadata", "sourceChunk", "observedOn"]);
113
+ const missing = required.filter((name) => !Object.hasOwn(key, name));
114
+ const extra = Object.keys(key).filter((name) => !allowed.has(name));
115
+ if (missing.length > 0) throw new Error(`${label} omitted ${missing.join(", ")}`);
116
+ if (extra.length > 0) throw new Error(`${label} contained unsupported field(s): ${extra.join(", ")}`);
117
+ if (key.sourceChunk !== undefined && (typeof key.sourceChunk !== "string" || !key.sourceChunk.endsWith(".js"))) {
118
+ throw new Error(`${label}.sourceChunk must be a JavaScript bundle name`);
119
+ }
120
+ if (key.observedOn !== undefined && (typeof key.observedOn !== "string" || !/^\d{4}-\d{2}-\d{2}$/u.test(key.observedOn))) {
121
+ throw new Error(`${label}.observedOn must be an ISO date`);
122
+ }
123
+ if (key.metadata !== undefined) {
124
+ const metadata = record(key.metadata, `${label}.metadata`);
125
+ exactKeys(metadata, ["featureSwitches", "fieldToggles"], `${label}.metadata`);
126
+ exactStringArray(metadata.featureSwitches, `${label}.metadata.featureSwitches`);
127
+ exactStringArray(metadata.fieldToggles, `${label}.metadata.fieldToggles`);
128
+ }
129
+ return Object.freeze({
130
+ queryId: exactQueryId(requiredString(key, "queryId", label), `${label}.queryId`),
131
+ operationName: exactOperationName(requiredString(key, "operationName", label), `${label}.operationName`),
132
+ operationType: exactOperationType(key.operationType, `${label}.operationType`),
133
+ });
134
+ }
135
+
136
+ export function xWebQueryDescriptorKey(descriptor: XWebQueryDescriptorKey): string {
137
+ return `${descriptor.operationName}:${descriptor.operationType}:${descriptor.queryId}`;
138
+ }
139
+
140
+ /**
141
+ * Resolve one reviewed operation from descriptors parsed out of the current
142
+ * first-party bundle. A changed query ID is drift, not a transparent update.
143
+ */
144
+ export function resolveUniqueXWebBundleDescriptor(
145
+ candidates: readonly unknown[],
146
+ expectedValue: unknown,
147
+ ): XWebBundleQueryDescriptor {
148
+ if (!Array.isArray(candidates)) throw new Error("X bundle descriptors must be an array");
149
+ const expected = parseDescriptorKey(expectedValue, "expected X descriptor");
150
+ const parsed = candidates.map((candidate, index) => (
151
+ parseBundleQueryDescriptor(candidate, `X bundle descriptor ${index + 1}`)
152
+ ));
153
+ const nameMatches = parsed.filter((candidate) => candidate.operationName === expected.operationName);
154
+ if (nameMatches.length === 0) {
155
+ throw new Error(`X bundle omitted operation ${expected.operationName}`);
156
+ }
157
+ const typeMatches = nameMatches.filter((candidate) => candidate.operationType === expected.operationType);
158
+ if (typeMatches.length === 0) {
159
+ const actual = [...new Set(nameMatches.map((candidate) => candidate.operationType))].join(", ");
160
+ throw new Error(
161
+ `X operation-type drift for ${expected.operationName}: expected ${expected.operationType}, observed ${actual}`,
162
+ );
163
+ }
164
+ if (typeMatches.length > 1) {
165
+ const ids = [...new Set(typeMatches.map((candidate) => candidate.queryId))];
166
+ if (ids.length === 1) {
167
+ throw new Error(`X bundle contained duplicate descriptor ${xWebQueryDescriptorKey(typeMatches[0]!)}`);
168
+ }
169
+ throw new Error(
170
+ `X bundle contained ambiguous query-ID drift for ${expected.operationName}:${expected.operationType}`,
171
+ );
172
+ }
173
+ const descriptor = typeMatches[0]!;
174
+ if (descriptor.queryId !== expected.queryId) {
175
+ throw new Error(
176
+ `X query-ID drift for ${expected.operationName}:${expected.operationType}; reviewed evidence is stale`,
177
+ );
178
+ }
179
+ return descriptor;
180
+ }
181
+
182
+ export type XWebOperationMetadataValues = {
183
+ readonly features: Readonly<Record<string, boolean>>;
184
+ readonly fieldToggles: Readonly<Record<string, boolean>>;
185
+ };
186
+
187
+ function exactBooleanMap(
188
+ value: unknown,
189
+ expectedNames: readonly string[],
190
+ label: string,
191
+ ): Readonly<Record<string, boolean>> {
192
+ const source = record(value, label);
193
+ const expected = new Set(expectedNames);
194
+ const missing = expectedNames.filter((name) => !Object.hasOwn(source, name));
195
+ const extra = Object.keys(source).filter((name) => !expected.has(name));
196
+ if (missing.length > 0) throw new Error(`${label} omitted ${missing.join(", ")}`);
197
+ if (extra.length > 0) throw new Error(`${label} contained unreviewed key(s): ${extra.join(", ")}`);
198
+ const result: Record<string, boolean> = {};
199
+ for (const name of [...expectedNames].sort()) {
200
+ const item = source[name];
201
+ if (typeof item !== "boolean") throw new Error(`${label}.${name} must be boolean`);
202
+ result[name] = item;
203
+ }
204
+ return Object.freeze(result);
205
+ }
206
+
207
+ /**
208
+ * Bind current feature values to exactly the names declared by this operation's
209
+ * resolved bundle descriptor. Missing, borrowed, or newly introduced keys are
210
+ * drift and cannot be dispatched silently.
211
+ */
212
+ export function bindXWebOperationMetadataValues(
213
+ descriptorValue: XWebBundleQueryDescriptor,
214
+ value: unknown,
215
+ ): XWebOperationMetadataValues {
216
+ const descriptor = parseBundleQueryDescriptor(descriptorValue, "resolved X descriptor");
217
+ const metadataValues = record(value, "X operation metadata values");
218
+ exactKeys(metadataValues, ["features", "fieldToggles"], "X operation metadata values");
219
+ return Object.freeze({
220
+ features: exactBooleanMap(
221
+ metadataValues.features,
222
+ descriptor.metadata.featureSwitches,
223
+ "X operation feature values",
224
+ ),
225
+ fieldToggles: exactBooleanMap(
226
+ metadataValues.fieldToggles,
227
+ descriptor.metadata.fieldToggles,
228
+ "X operation field-toggle values",
229
+ ),
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Revision evidence observed in X's public first-party web bundles on the
235
+ * stated date. These IDs are never dispatch constants. Dispatch must resolve
236
+ * the current bundle with `resolveUniqueXWebBundleDescriptor` first.
237
+ */
238
+ export const xWebQueryDescriptorEvidenceSnapshot = Object.freeze({
239
+ schemaVersion: 1,
240
+ role: "revision-evidence-only" as const,
241
+ observedOn: "2026-07-22",
242
+ currentBundleResolutionRequired: true,
243
+ mainBundleUrl: "https://abs.twimg.com/responsive-web/client-web/main.7792f4fa.js",
244
+ descriptors: Object.freeze([
245
+ { operationName: "HomeTimeline", operationType: "query", queryId: "wp06oo3fRGU4P1sK8rECqQ", sourceChunk: "shared~bundle.LoggedInMain~bundle.HomeTimeline.c12c8a9a.js", observedOn: "2026-08-20" },
246
+ { operationName: "HomeLatestTimeline", operationType: "query", queryId: "BLQWpfVqtgBqAqwRRJcJjA", sourceChunk: "shared~bundle.LoggedInMain~bundle.HomeTimeline.c12c8a9a.js", observedOn: "2026-08-20" },
247
+ { operationName: "ListLatestTweetsTimeline", operationType: "query", queryId: "LV64djPRhnsVhGCK76s13w", sourceChunk: "shared~loader.Dock~bundle.BookmarkFolders~bundle.Bookmarks~bundle.Explore~bundle.HomeTimeline~bundle.Notifica.3b894e0a.js" },
248
+ { operationName: "ListRankedTweetsTimeline", operationType: "query", queryId: "dPN7GrkxeMF4SUYCo9D9YA", sourceChunk: "shared~loader.Dock~bundle.BookmarkFolders~bundle.Bookmarks~bundle.Explore~bundle.HomeTimeline~bundle.Notifica.3b894e0a.js" },
249
+ { operationName: "Bookmarks", operationType: "query", queryId: "tF6KOjmZM0WGcB2Q0mfwhw", sourceChunk: "shared~bundle.BookmarkFolders~bundle.Bookmarks.433463ce78e2afaba.js", observedOn: "2026-09-09" },
250
+ { operationName: "BookmarkSearchTimeline", operationType: "query", queryId: "SpDsqmz6FfYESd1e7TPcAw", sourceChunk: "main.9929b02a.js" },
251
+ { operationName: "SearchTimeline", operationType: "query", queryId: "hyPfJYJ_XAtDYoslQc-Rgg", sourceChunk: "main.ae82e9d02d3328bba.js", observedOn: "2026-09-06" },
252
+ { operationName: "NotificationsTimeline", operationType: "query", queryId: "dDSNxYH-uWwVo2r3Y5VVqg", sourceChunk: "bundle.Notifications.eea6257a.js" },
253
+ { operationName: "TweetDetail", operationType: "query", queryId: "rZA6K31W4E90vZKBmxXV3g", sourceChunk: "main.9929b02a.js" },
254
+ { operationName: "TweetResultByRestId", operationType: "query", queryId: "4hhGRbehkcUVTKf8n0f0xw", sourceChunk: "main.9929b02a.js" },
255
+ { operationName: "TweetResultsByRestIds", operationType: "query", queryId: "aSkhsainBPfEWA4mG8wnFA", sourceChunk: "main.9929b02a.js" },
256
+ { operationName: "UserTweets", operationType: "query", queryId: "eviprbEPLvNG88V3smUngQ", sourceChunk: "main.ae82e9d02d3328bba.js", observedOn: "2026-09-06" },
257
+ { operationName: "UserTweetsAndReplies", operationType: "query", queryId: "klja8a2iJX_3to5RdfVlgw", sourceChunk: "main.9929b02a.js" },
258
+ { operationName: "UserMedia", operationType: "query", queryId: "IS3w9vvPg1SJysLErvnFGg", sourceChunk: "main.9929b02a.js" },
259
+ { operationName: "FavoriteTweet", operationType: "mutation", queryId: "lI07N6Otwv1PhnEgXILM7A", sourceChunk: "main.9929b02a.js" },
260
+ { operationName: "UnfavoriteTweet", operationType: "mutation", queryId: "ZYKSe-w7KEslx3JhSIk5LA", sourceChunk: "main.9929b02a.js" },
261
+ { operationName: "CreateBookmark", operationType: "mutation", queryId: "aoDbu3RHznuiSkQ9aNM67Q", sourceChunk: "main.9929b02a.js" },
262
+ { operationName: "DeleteBookmark", operationType: "mutation", queryId: "Wlmlj2-xzyS1GN3a6cj-mQ", sourceChunk: "main.9929b02a.js" },
263
+ { operationName: "CreateRetweet", operationType: "mutation", queryId: "mbRO74GrOvSfRcJnlMapnQ", sourceChunk: "main.9929b02a.js" },
264
+ { operationName: "DeleteRetweet", operationType: "mutation", queryId: "ZyZigVsNiFO6v1dEks1eWg", sourceChunk: "main.9929b02a.js" },
265
+ { operationName: "CreateTweet", operationType: "mutation", queryId: "WXTdKnLddrQOunD6MhWi3g", sourceChunk: "main.7792f4fa.js", observedOn: "2026-08-20" },
266
+ { operationName: "CreateNoteTweet", operationType: "mutation", queryId: "uGXMU9aKbNB9qxxAg4jxkA", sourceChunk: "main.9929b02a.js" },
267
+ { operationName: "DeleteTweet", operationType: "mutation", queryId: "nxpZCY2K-I6QoFHAHeojFQ", sourceChunk: "main.9929b02a.js" },
268
+ { operationName: "DmAllSearchSlice", operationType: "query", queryId: "zd0F6a_svKAXdlMGbCZDFg", sourceChunk: "bundle.DirectMessages.265735ba.js" },
269
+ { operationName: "DmGroupSearchSlice", operationType: "query", queryId: "LxrvmqF3Lokl_BYZ1c83LA", sourceChunk: "bundle.DirectMessages.265735ba.js" },
270
+ { operationName: "DmPeopleSearchSlice", operationType: "query", queryId: "c1MnRRmI-_Bggpntlq9-hQ", sourceChunk: "bundle.DirectMessages.265735ba.js" },
271
+ { operationName: "Viewer", operationType: "query", queryId: "9t128XgFic52jPUEkJMf6w", sourceChunk: "main.cd39a626fdb81748a.js", observedOn: "2026-09-09" },
272
+ { operationName: "UserByScreenName", operationType: "query", queryId: "Gb-d6r0vxPOADdG62OEBpQ", sourceChunk: "main.dd6a5b6a.js", observedOn: "2026-08-21" },
273
+ { operationName: "ArticleEntityDraftCreate", operationType: "mutation", queryId: "btD9FyMDa3_vydVp7fr87Q", sourceChunk: "bundle.TwitterArticles.305538ca.js", observedOn: "2026-08-14" },
274
+ { operationName: "ArticleEntityUpdateContent", operationType: "mutation", queryId: "P5Nc3DYs9D4XqVthNrig8w", sourceChunk: "bundle.TwitterArticles.305538ca.js", observedOn: "2026-08-14" },
275
+ { operationName: "ArticleEntityUpdateTitle", operationType: "mutation", queryId: "z_xdvTUbZjSVjt232b4D4A", sourceChunk: "bundle.TwitterArticles.305538ca.js", observedOn: "2026-08-14" },
276
+ { operationName: "ArticleEntityPublish", operationType: "mutation", queryId: "UyL9qgpV23A8471opeYQbw", sourceChunk: "bundle.TwitterArticles.305538ca.js", observedOn: "2026-08-14" },
277
+ { operationName: "ArticleEntityResultByRestId", operationType: "query", queryId: "rPdndX2XxQoXIMUafLSSJQ", sourceChunk: "bundle.TwitterArticles.305538ca.js", observedOn: "2026-08-14" },
278
+ ] satisfies readonly XWebQueryDescriptorEvidence[]),
279
+ });
280
+
281
+ type XWebGraphQlReadDefinition = {
282
+ readonly semanticOperation: "feeds.read" | "posts.read" | "profiles.read";
283
+ readonly risk: "R1";
284
+ readonly transport: "graphql-query";
285
+ readonly operationName: string;
286
+ readonly operationType: "query";
287
+ readonly responseRoot: readonly string[];
288
+ };
289
+
290
+ type XWebLegacyDmReadDefinition = {
291
+ readonly semanticOperation: "messaging.list" | "messaging.read";
292
+ readonly risk: "R1";
293
+ readonly transport: "legacy-dm-read";
294
+ readonly inbox: XWebLegacyDmReadSurface;
295
+ };
296
+
297
+ type XWebDesiredStateDefinition = {
298
+ readonly semanticOperation: "likes.set" | "content.save" | "posts.repost";
299
+ readonly risk: "R2" | "R3";
300
+ readonly transport: "graphql-desired-state";
301
+ readonly enabled: { readonly operationName: string; readonly operationType: "mutation" };
302
+ readonly disabled: { readonly operationName: string; readonly operationType: "mutation" };
303
+ };
304
+
305
+ export type XWebSemanticOperationDefinition =
306
+ | XWebGraphQlReadDefinition
307
+ | XWebLegacyDmReadDefinition
308
+ | XWebDesiredStateDefinition;
309
+
310
+ export const xWebSemanticOperationRegistry = Object.freeze({
311
+ "feeds.for-you": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "HomeTimeline", operationType: "query", responseRoot: ["home", "home_timeline_urt"] },
312
+ "feeds.following": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "HomeLatestTimeline", operationType: "query", responseRoot: ["home", "home_timeline_urt"] },
313
+ "feeds.list-latest": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "ListLatestTweetsTimeline", operationType: "query", responseRoot: ["list", "tweets_timeline", "timeline"] },
314
+ "feeds.list-ranked": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "ListRankedTweetsTimeline", operationType: "query", responseRoot: ["list", "tweets_timeline", "timeline"] },
315
+ "feeds.bookmarks": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "Bookmarks", operationType: "query", responseRoot: ["bookmark_timeline_v2", "timeline"] },
316
+ "feeds.bookmark-search": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "BookmarkSearchTimeline", operationType: "query", responseRoot: ["bookmark_search_timeline", "timeline"] },
317
+ "feeds.search": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "SearchTimeline", operationType: "query", responseRoot: ["search_by_raw_query", "search_timeline", "timeline"] },
318
+ "feeds.notifications": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "NotificationsTimeline", operationType: "query", responseRoot: ["viewer_v2", "user_results", "result", "notification_timeline", "timeline"] },
319
+ "profiles.by-handle": { semanticOperation: "profiles.read", risk: "R1", transport: "graphql-query", operationName: "UserByScreenName", operationType: "query", responseRoot: ["user", "result"] },
320
+ "posts.detail": { semanticOperation: "posts.read", risk: "R1", transport: "graphql-query", operationName: "TweetDetail", operationType: "query", responseRoot: ["threaded_conversation_with_injections_v2"] },
321
+ "posts.by-id": { semanticOperation: "posts.read", risk: "R1", transport: "graphql-query", operationName: "TweetResultByRestId", operationType: "query", responseRoot: ["tweetResult", "result"] },
322
+ "posts.by-ids": { semanticOperation: "posts.read", risk: "R1", transport: "graphql-query", operationName: "TweetResultsByRestIds", operationType: "query", responseRoot: ["tweetResult"] },
323
+ "feeds.user": { semanticOperation: "feeds.read", risk: "R1", transport: "graphql-query", operationName: "UserTweets", operationType: "query", responseRoot: ["user", "result", "timeline", "timeline"] },
324
+ "messaging.primary": { semanticOperation: "messaging.list", risk: "R1", transport: "legacy-dm-read", inbox: "primary" },
325
+ "messaging.requests": { semanticOperation: "messaging.list", risk: "R1", transport: "legacy-dm-read", inbox: "requests" },
326
+ "messaging.additional": { semanticOperation: "messaging.list", risk: "R1", transport: "legacy-dm-read", inbox: "additional" },
327
+ "messaging.conversation": { semanticOperation: "messaging.read", risk: "R1", transport: "legacy-dm-read", inbox: "conversation" },
328
+ "likes.set": { semanticOperation: "likes.set", risk: "R2", transport: "graphql-desired-state", enabled: { operationName: "FavoriteTweet", operationType: "mutation" }, disabled: { operationName: "UnfavoriteTweet", operationType: "mutation" } },
329
+ "bookmarks.set": { semanticOperation: "content.save", risk: "R2", transport: "graphql-desired-state", enabled: { operationName: "CreateBookmark", operationType: "mutation" }, disabled: { operationName: "DeleteBookmark", operationType: "mutation" } },
330
+ "reposts.set": { semanticOperation: "posts.repost", risk: "R3", transport: "graphql-desired-state", enabled: { operationName: "CreateRetweet", operationType: "mutation" }, disabled: { operationName: "DeleteRetweet", operationType: "mutation" } },
331
+ } as const satisfies Readonly<Record<string, XWebSemanticOperationDefinition>>);
332
+
333
+ export type XWebSemanticOperationId = keyof typeof xWebSemanticOperationRegistry;
334
+
335
+ export const xWebHeaderSinkPolicy = Object.freeze({
336
+ browserManaged: Object.freeze([
337
+ "cookie",
338
+ "host",
339
+ "origin",
340
+ "referer",
341
+ "user-agent",
342
+ "content-length",
343
+ ]),
344
+ browserManagedPrefixes: Object.freeze(["sec-", "proxy-"]),
345
+ inOriginEphemeral: Object.freeze([
346
+ "authorization",
347
+ "x-csrf-token",
348
+ "x-client-transaction-id",
349
+ ]),
350
+ fixedCodeHeaders: Object.freeze([
351
+ "accept",
352
+ "content-type",
353
+ "x-twitter-auth-type",
354
+ "x-twitter-active-user",
355
+ "x-twitter-client-language",
356
+ ]),
357
+ permittedRawSink: "network-request" as const,
358
+ persistentSinks: Object.freeze(["plan", "receipt", "log", "fixture"] as const),
359
+ forbiddenSources: Object.freeze(["manifest", "adapter", "user-input"] as const),
360
+ });
361
+
362
+ export type XWebHeaderSource = "code" | "in-origin-session" | "manifest" | "adapter" | "user-input";
363
+ export type XWebHeaderSink = "network-request" | "plan" | "receipt" | "log" | "fixture";
364
+
365
+ const forbiddenXWebHeaderSourceSet = new Set<XWebHeaderSource>(xWebHeaderSinkPolicy.forbiddenSources);
366
+
367
+ export type XWebHeaderPolicyInput = {
368
+ readonly source: XWebHeaderSource;
369
+ readonly sink: XWebHeaderSink;
370
+ readonly headers: Readonly<Record<string, string>>;
371
+ };
372
+
373
+ export type XWebAuthorizedHeaders = {
374
+ readonly names: readonly string[];
375
+ readonly values: Readonly<Record<string, string>>;
376
+ };
377
+
378
+ function hasAsciiControl(value: string): boolean {
379
+ for (let index = 0; index < value.length; index += 1) {
380
+ const code = value.charCodeAt(index);
381
+ if (code <= 0x1f || code === 0x7f) return true;
382
+ }
383
+ return false;
384
+ }
385
+
386
+ function assertFixedHeaderValue(name: string, value: string): void {
387
+ if (name === "accept" && value !== "application/json") {
388
+ throw new Error("X fixed request header accept had an unsupported value");
389
+ }
390
+ if (
391
+ name === "content-type"
392
+ && value !== "application/json"
393
+ && !/^multipart\/form-data; boundary=wrench-x-media-[a-f0-9]{32}$/u.test(value)
394
+ ) {
395
+ throw new Error("X fixed request header content-type had an unsupported value");
396
+ }
397
+ if (name === "x-twitter-auth-type" && value !== "OAuth2Session") {
398
+ throw new Error("X fixed request header x-twitter-auth-type had an unsupported value");
399
+ }
400
+ if (name === "x-twitter-active-user" && value !== "yes") {
401
+ throw new Error("X fixed request header x-twitter-active-user had an unsupported value");
402
+ }
403
+ if (name === "x-twitter-client-language" && !/^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/u.test(value)) {
404
+ throw new Error("X fixed request header x-twitter-client-language had an unsupported value");
405
+ }
406
+ }
407
+
408
+ function assertEphemeralHeaderValue(name: string, value: string): void {
409
+ if (value.length === 0 || value.length > 8_192 || hasAsciiControl(value)) {
410
+ throw new Error(`X ephemeral request header ${name} had an invalid value`);
411
+ }
412
+ if (name === "authorization" && !/^Bearer [^\s]+$/u.test(value)) {
413
+ throw new Error("X ephemeral request header authorization must be a bearer value");
414
+ }
415
+ if (name === "x-csrf-token" && !/^[A-Za-z0-9_-]{16,512}$/u.test(value)) {
416
+ throw new Error("X ephemeral request header x-csrf-token had an invalid value");
417
+ }
418
+ if (name === "x-client-transaction-id" && !/^[A-Za-z0-9_+/=-]{8,2048}$/u.test(value)) {
419
+ throw new Error("X ephemeral request header x-client-transaction-id had an invalid value");
420
+ }
421
+ }
422
+
423
+ /** Enforce that raw session headers only ever flow into the in-origin request. */
424
+ export function enforceXWebHeaderSinkPolicy(input: XWebHeaderPolicyInput): XWebAuthorizedHeaders {
425
+ if (!isRecord(input.headers)) throw new Error("X headers must be an object");
426
+ const entries = Object.entries(input.headers);
427
+ if (input.sink !== "network-request") {
428
+ if (entries.length > 0) throw new Error(`raw X headers may not flow to ${input.sink}`);
429
+ return Object.freeze({ names: Object.freeze([]), values: Object.freeze({}) });
430
+ }
431
+ if (forbiddenXWebHeaderSourceSet.has(input.source)) {
432
+ if (entries.length > 0) throw new Error(`${input.source} may not supply X request headers`);
433
+ return Object.freeze({ names: Object.freeze([]), values: Object.freeze({}) });
434
+ }
435
+ const normalized: Record<string, string> = {};
436
+ for (const [rawName, value] of entries) {
437
+ const name = rawName.toLowerCase();
438
+ if (!/^[a-z0-9-]+$/u.test(name)) throw new Error("X request contained an invalid header name");
439
+ if (Object.hasOwn(normalized, name)) throw new Error(`X request contained duplicate header ${name}`);
440
+ if (typeof value !== "string" || hasAsciiControl(value)) {
441
+ throw new Error(`X request header ${name} had an invalid value`);
442
+ }
443
+ if (
444
+ xWebHeaderSinkPolicy.browserManaged.includes(name)
445
+ || xWebHeaderSinkPolicy.browserManagedPrefixes.some((prefix) => name.startsWith(prefix))
446
+ ) {
447
+ throw new Error(`X request header ${name} must be browser-managed`);
448
+ }
449
+ if (xWebHeaderSinkPolicy.inOriginEphemeral.includes(name)) {
450
+ if (input.source !== "in-origin-session") {
451
+ throw new Error(`X request header ${name} must come from the in-origin session`);
452
+ }
453
+ assertEphemeralHeaderValue(name, value);
454
+ } else if (xWebHeaderSinkPolicy.fixedCodeHeaders.includes(name)) {
455
+ if (input.source !== "code" && input.source !== "in-origin-session") {
456
+ throw new Error(`X request header ${name} must be code-owned`);
457
+ }
458
+ assertFixedHeaderValue(name, value);
459
+ } else {
460
+ throw new Error(`X request header ${name} is not allowlisted`);
461
+ }
462
+ normalized[name] = value;
463
+ }
464
+ return Object.freeze({
465
+ names: Object.freeze(Object.keys(normalized).sort()),
466
+ values: Object.freeze(normalized),
467
+ });
468
+ }
469
+
470
+ export type XWebRedactedHeaderEvidence = {
471
+ readonly name: string;
472
+ readonly ownership: "browser-managed" | "in-origin-ephemeral" | "fixed-code" | "forbidden";
473
+ };
474
+
475
+ /** Return header names/classes only; values are intentionally not accepted. */
476
+ export function classifyXWebHeaderNamesForEvidence(names: readonly string[]): readonly XWebRedactedHeaderEvidence[] {
477
+ const unique = new Set<string>();
478
+ const result: XWebRedactedHeaderEvidence[] = [];
479
+ for (const rawName of names) {
480
+ const name = rawName.toLowerCase();
481
+ if (unique.has(name)) throw new Error(`X evidence contained duplicate header name ${name}`);
482
+ unique.add(name);
483
+ let ownership: XWebRedactedHeaderEvidence["ownership"] = "forbidden";
484
+ if (
485
+ xWebHeaderSinkPolicy.browserManaged.includes(name)
486
+ || xWebHeaderSinkPolicy.browserManagedPrefixes.some((prefix) => name.startsWith(prefix))
487
+ ) ownership = "browser-managed";
488
+ else if (xWebHeaderSinkPolicy.inOriginEphemeral.includes(name)) ownership = "in-origin-ephemeral";
489
+ else if (xWebHeaderSinkPolicy.fixedCodeHeaders.includes(name)) ownership = "fixed-code";
490
+ result.push(Object.freeze({ name, ownership }));
491
+ }
492
+ return Object.freeze(result.sort((left, right) => left.name.localeCompare(right.name)));
493
+ }
494
+
495
+ function exactUrl(value: string | URL, label: string): URL {
496
+ let url: URL;
497
+ try {
498
+ url = value instanceof URL ? new URL(value.href) : new URL(value);
499
+ } catch {
500
+ throw new Error(`${label} must be an absolute URL`);
501
+ }
502
+ if (url.origin !== "https://x.com" || url.username !== "" || url.password !== "") {
503
+ throw new Error(`${label} must use the exact https://x.com origin`);
504
+ }
505
+ if (url.hash !== "") throw new Error(`${label} may not contain a fragment`);
506
+ return url;
507
+ }
508
+
509
+ function normalizedMethod(value: string): "GET" | "POST" {
510
+ const method = value.toUpperCase();
511
+ if (method !== "GET" && method !== "POST") throw new Error("X GraphQL method must be GET or POST");
512
+ return method;
513
+ }
514
+
515
+ function bodyRecord(value: unknown): JsonRecord {
516
+ if (typeof value === "string") {
517
+ try {
518
+ return record(JSON.parse(value) as unknown, "X GraphQL body");
519
+ } catch (error) {
520
+ if (error instanceof Error && error.message.startsWith("X GraphQL body")) throw error;
521
+ throw new Error("X GraphQL body must be valid JSON");
522
+ }
523
+ }
524
+ return record(value, "X GraphQL body");
525
+ }
526
+
527
+ export function buildXWebGraphQlPath(descriptorValue: XWebQueryDescriptorKey): string {
528
+ const descriptor = parseDescriptorKey(descriptorValue, "X GraphQL descriptor");
529
+ return `/i/api/graphql/${descriptor.queryId}/${descriptor.operationName}`;
530
+ }
531
+
532
+ export type XWebGraphQlBindingInput = {
533
+ readonly url: string | URL;
534
+ readonly method: string;
535
+ readonly descriptor: XWebBundleQueryDescriptor;
536
+ readonly body?: unknown;
537
+ };
538
+
539
+ export type XWebGraphQlBinding = {
540
+ readonly method: "GET" | "POST";
541
+ readonly operationName: string;
542
+ readonly operationType: XWebOperationType;
543
+ readonly queryId: string;
544
+ readonly path: string;
545
+ };
546
+
547
+ /** Prove URL path, optional query/body identity, method, and descriptor agree. */
548
+ export function assertExactXWebGraphQlBinding(input: XWebGraphQlBindingInput): XWebGraphQlBinding {
549
+ const descriptor = parseBundleQueryDescriptor(input.descriptor, "resolved X descriptor");
550
+ const method = normalizedMethod(input.method);
551
+ if (descriptor.operationType === "mutation" && method !== "POST") {
552
+ throw new Error("X GraphQL mutations require POST");
553
+ }
554
+ if (method === "GET" && input.body !== undefined) throw new Error("X GraphQL GET may not contain a body");
555
+ const url = exactUrl(input.url, "X GraphQL URL");
556
+ const expectedPath = buildXWebGraphQlPath(descriptor);
557
+ if (url.pathname !== expectedPath) {
558
+ throw new Error("X GraphQL path did not bind the resolved operation name and query ID");
559
+ }
560
+ const allowedParameters = new Set(["variables", "features", "fieldToggles", "queryId", "operationName"]);
561
+ const seenParameters = new Set<string>();
562
+ for (const [name, value] of url.searchParams) {
563
+ if (!allowedParameters.has(name)) throw new Error(`X GraphQL URL contained unsupported parameter ${name}`);
564
+ if (seenParameters.has(name)) throw new Error(`X GraphQL URL repeated parameter ${name}`);
565
+ seenParameters.add(name);
566
+ if (name === "queryId" && value !== descriptor.queryId) throw new Error("X GraphQL URL queryId drifted from its path");
567
+ if (name === "operationName" && value !== descriptor.operationName) throw new Error("X GraphQL URL operationName drifted from its path");
568
+ }
569
+ if (input.body !== undefined) {
570
+ const body = bodyRecord(input.body);
571
+ if (body.queryId !== undefined && body.queryId !== descriptor.queryId) {
572
+ throw new Error("X GraphQL body queryId drifted from its path");
573
+ }
574
+ if (body.operationName !== undefined && body.operationName !== descriptor.operationName) {
575
+ throw new Error("X GraphQL body operationName drifted from its path");
576
+ }
577
+ if (body.operationType !== undefined && body.operationType !== descriptor.operationType) {
578
+ throw new Error("X GraphQL body operationType drifted from its descriptor");
579
+ }
580
+ }
581
+ return Object.freeze({
582
+ method,
583
+ operationName: descriptor.operationName,
584
+ operationType: descriptor.operationType,
585
+ queryId: descriptor.queryId,
586
+ path: expectedPath,
587
+ });
588
+ }
589
+
590
+ export function authorizeXWebR1GraphQlRequest(
591
+ operationId: XWebSemanticOperationId,
592
+ input: XWebGraphQlBindingInput,
593
+ ): XWebGraphQlBinding {
594
+ const definition = xWebSemanticOperationRegistry[operationId];
595
+ if (definition.transport !== "graphql-query" || definition.risk !== "R1") {
596
+ throw new Error(`${operationId} is not an allowlisted X R1 GraphQL read`);
597
+ }
598
+ const binding = assertExactXWebGraphQlBinding(input);
599
+ if (
600
+ binding.operationType !== "query"
601
+ || binding.operationName !== definition.operationName
602
+ ) {
603
+ throw new Error(`${operationId} did not bind its reviewed X query operation`);
604
+ }
605
+ return binding;
606
+ }
607
+
608
+ export const xWebMutationOperationIds = Object.freeze([
609
+ "posts.publish",
610
+ "threads.publish",
611
+ "threads.reply",
612
+ "replies.create",
613
+ "posts.quote",
614
+ "likes.enable",
615
+ "likes.disable",
616
+ "bookmarks.enable",
617
+ "bookmarks.disable",
618
+ "reposts.enable",
619
+ "reposts.disable",
620
+ "articles.create",
621
+ "articles.title",
622
+ "articles.content",
623
+ ] as const);
624
+
625
+ export type XWebMutationOperationId = (typeof xWebMutationOperationIds)[number];
626
+
627
+ const mutationOperationNames = Object.freeze({
628
+ "posts.publish": "CreateTweet",
629
+ "threads.publish": "CreateTweet",
630
+ "threads.reply": "CreateTweet",
631
+ "replies.create": "CreateTweet",
632
+ "posts.quote": "CreateTweet",
633
+ "likes.enable": "FavoriteTweet",
634
+ "likes.disable": "UnfavoriteTweet",
635
+ "bookmarks.enable": "CreateBookmark",
636
+ "bookmarks.disable": "DeleteBookmark",
637
+ "reposts.enable": "CreateRetweet",
638
+ "reposts.disable": "DeleteRetweet",
639
+ "articles.create": "ArticleEntityDraftCreate",
640
+ "articles.title": "ArticleEntityUpdateTitle",
641
+ "articles.content": "ArticleEntityUpdateContent",
642
+ } as const satisfies Readonly<Record<XWebMutationOperationId, string>>);
643
+
644
+ function exactMutationKeys(value: JsonRecord, keys: readonly string[], label: string): void {
645
+ exactKeys(value, keys, label);
646
+ }
647
+
648
+ function exactMutationPostId(value: unknown, label: string): string {
649
+ if (typeof value !== "string" || !/^[0-9]{1,19}$/u.test(value)) {
650
+ throw new Error(`${label} must be an exact X post ID`);
651
+ }
652
+ return value;
653
+ }
654
+
655
+ /**
656
+ * Reviewed CreateTweet `tweet_text` ceiling. Live X long posts use this bound
657
+ * (not the stale 280 short-post schema). UTF-16 length matches the existing
658
+ * mutation validator; the conservative weighted catalog uses the same number
659
+ * as its unit ceiling so a 1158-character ASCII body stays valid input.
660
+ */
661
+ export const MAX_X_CREATE_TWEET_TEXT_LENGTH = 25_000;
662
+
663
+ function exactMutationText(value: unknown): void {
664
+ if (
665
+ typeof value !== "string"
666
+ || value.length < 1
667
+ || value.length > MAX_X_CREATE_TWEET_TEXT_LENGTH
668
+ || /[\0\r]/u.test(value)
669
+ ) {
670
+ throw new Error("X CreateTweet text must be bounded and contain no NUL or carriage return");
671
+ }
672
+ }
673
+
674
+ const CREATE_TWEET_AI_DISCLOSURE_FIELDS = [
675
+ "made_with_ai",
676
+ "content_disclosure",
677
+ "ai_generated_disclosure",
678
+ ] as const;
679
+
680
+ function rejectCreateTweetAiDisclosureFields(
681
+ variables: JsonRecord,
682
+ operationId: XWebMutationOperationId,
683
+ ): void {
684
+ for (const field of CREATE_TWEET_AI_DISCLOSURE_FIELDS) {
685
+ if (Object.hasOwn(variables, field)) {
686
+ throw new Error(
687
+ `X ${operationId} ${field} is outside the reviewed CreateTweet contract`,
688
+ );
689
+ }
690
+ }
691
+ }
692
+
693
+ function validateCreateTweetVariables(operationId: XWebMutationOperationId, variables: JsonRecord): void {
694
+ rejectCreateTweetAiDisclosureFields(variables, operationId);
695
+ const relationKey = operationId === "replies.create" || operationId === "threads.reply"
696
+ ? "reply"
697
+ : operationId === "posts.quote" ? "attachment_url" : null;
698
+ exactMutationKeys(
699
+ variables,
700
+ ["tweet_text", "dark_request", "media", "semantic_annotation_ids", ...(relationKey === null ? [] : [relationKey])],
701
+ `X ${operationId} variables`,
702
+ );
703
+ exactMutationText(variables.tweet_text);
704
+ if (variables.dark_request !== false) throw new Error(`X ${operationId} dark_request must be false`);
705
+ const media = record(variables.media, `X ${operationId} media`);
706
+ exactMutationKeys(media, ["media_entities", "possibly_sensitive"], `X ${operationId} media`);
707
+ if (!Array.isArray(media.media_entities) || media.possibly_sensitive !== false) {
708
+ throw new Error(`X ${operationId} media left the reviewed shape`);
709
+ }
710
+ if (operationId === "posts.publish") {
711
+ if (media.media_entities.length > 1) {
712
+ throw new Error("X posts.publish supports at most one reviewed image or video");
713
+ }
714
+ if (media.media_entities.length === 1) {
715
+ const entity = record(media.media_entities[0], "X posts.publish media entity");
716
+ exactMutationKeys(entity, ["media_id", "tagged_users"], "X posts.publish media entity");
717
+ exactMutationPostId(entity.media_id, "X posts.publish media entity ID");
718
+ if (!Array.isArray(entity.tagged_users) || entity.tagged_users.length !== 0) {
719
+ throw new Error("X posts.publish media tagged_users must be empty");
720
+ }
721
+ }
722
+ } else if (media.media_entities.length !== 0) {
723
+ throw new Error(`X ${operationId} supports only the reviewed text-only media shape`);
724
+ }
725
+ if (!Array.isArray(variables.semantic_annotation_ids) || variables.semantic_annotation_ids.length !== 0) {
726
+ throw new Error(`X ${operationId} semantic annotations are outside the reviewed contract`);
727
+ }
728
+ if (operationId === "replies.create" || operationId === "threads.reply") {
729
+ const reply = record(variables.reply, "X replies.create reply");
730
+ exactMutationKeys(reply, ["in_reply_to_tweet_id", "exclude_reply_user_ids"], "X replies.create reply");
731
+ exactMutationPostId(reply.in_reply_to_tweet_id, "X replies.create parent");
732
+ if (!Array.isArray(reply.exclude_reply_user_ids) || reply.exclude_reply_user_ids.length !== 0) {
733
+ throw new Error("X replies.create exclude_reply_user_ids must be empty");
734
+ }
735
+ }
736
+ if (operationId === "posts.quote") {
737
+ if (typeof variables.attachment_url !== "string") throw new Error("X posts.quote attachment_url must be a string");
738
+ const match = /^https:\/\/x\.com\/i\/status\/([0-9]{1,19})$/u.exec(variables.attachment_url);
739
+ if (match?.[1] === undefined) throw new Error("X posts.quote attachment_url must bind one exact X post");
740
+ }
741
+ }
742
+
743
+ function validateDesiredStateVariables(operationId: XWebMutationOperationId, variables: JsonRecord): void {
744
+ if (operationId.startsWith("reposts.")) {
745
+ const key = operationId === "reposts.enable" ? "tweet_id" : "source_tweet_id";
746
+ exactMutationKeys(variables, [key, "dark_request"], `X ${operationId} variables`);
747
+ exactMutationPostId(variables[key], `X ${operationId} target`);
748
+ if (variables.dark_request !== false) throw new Error(`X ${operationId} dark_request must be false`);
749
+ return;
750
+ }
751
+ exactMutationKeys(variables, ["tweet_id"], `X ${operationId} variables`);
752
+ exactMutationPostId(variables.tweet_id, `X ${operationId} target`);
753
+ }
754
+
755
+ const richArticleBlockTypes = new Set([
756
+ "unstyled",
757
+ "header-one",
758
+ "header-two",
759
+ "blockquote",
760
+ "unordered-list-item",
761
+ "ordered-list-item",
762
+ "atomic",
763
+ ]);
764
+ const richArticleInlineStyles = new Set(["Bold", "Italic", "Strikethrough"]);
765
+
766
+ function exactArticleRange(
767
+ value: unknown,
768
+ label: string,
769
+ maximum: number,
770
+ ): { readonly key?: number; readonly offset: number; readonly length: number; readonly style?: string } {
771
+ const range = record(value, label);
772
+ const hasKey = Object.hasOwn(range, "key");
773
+ const hasStyle = Object.hasOwn(range, "style");
774
+ if (hasKey === hasStyle) throw new Error(`${label} must be exactly one entity or style range`);
775
+ exactMutationKeys(range, hasKey ? ["key", "offset", "length"] : ["length", "offset", "style"], label);
776
+ if (
777
+ !Number.isSafeInteger(range.offset)
778
+ || !Number.isSafeInteger(range.length)
779
+ || (range.offset as number) < 0
780
+ || (range.length as number) < 1
781
+ || (range.offset as number) + (range.length as number) > maximum
782
+ ) throw new Error(`${label} must stay inside its block text`);
783
+ if (hasKey && (!Number.isSafeInteger(range.key) || (range.key as number) < 0)) {
784
+ throw new Error(`${label}.key must be a non-negative integer`);
785
+ }
786
+ if (hasStyle && (typeof range.style !== "string" || !richArticleInlineStyles.has(range.style))) {
787
+ throw new Error(`${label}.style is outside the reviewed Article styles`);
788
+ }
789
+ return Object.freeze({
790
+ ...(hasKey ? { key: range.key as number } : { style: range.style as string }),
791
+ offset: range.offset as number,
792
+ length: range.length as number,
793
+ });
794
+ }
795
+
796
+ function exactArticleUrl(value: unknown, label: string): string {
797
+ if (typeof value !== "string" || value.length < 1 || value.length > 2_048 || /[\0\r\n]/u.test(value)) {
798
+ throw new Error(`${label} must be a bounded HTTPS URL`);
799
+ }
800
+ let parsed: URL;
801
+ try {
802
+ parsed = new URL(value);
803
+ } catch {
804
+ throw new Error(`${label} must be a bounded HTTPS URL`);
805
+ }
806
+ if (parsed.protocol !== "https:" || parsed.username !== "" || parsed.password !== "") {
807
+ throw new Error(`${label} must be a bounded HTTPS URL`);
808
+ }
809
+ return parsed.href;
810
+ }
811
+
812
+ /** Validate the exact API-side rich content state emitted by X's current converter. */
813
+ export function validateXWebRichArticleContentState(value: unknown): void {
814
+ const contentState = record(value, "X rich Article content_state");
815
+ exactMutationKeys(contentState, ["blocks", "entity_map"], "X rich Article content_state");
816
+ if (!Array.isArray(contentState.blocks) || contentState.blocks.length < 1 || contentState.blocks.length > 2_000) {
817
+ throw new Error("X rich Article content_state.blocks must contain 1-2000 blocks");
818
+ }
819
+ if (!Array.isArray(contentState.entity_map) || contentState.entity_map.length > 2_000) {
820
+ throw new Error("X rich Article content_state.entity_map exceeded its reviewed bound");
821
+ }
822
+ const entityKinds: ("LINK" | "MEDIA")[] = [];
823
+ let mediaCount = 0;
824
+ for (const [index, value] of contentState.entity_map.entries()) {
825
+ const entity = record(value, `X rich Article entity ${index}`);
826
+ exactMutationKeys(entity, ["key", "value"], `X rich Article entity ${index}`);
827
+ if (entity.key !== `${index}`) throw new Error("X rich Article entity keys must be contiguous strings");
828
+ const entry = record(entity.value, `X rich Article entity ${index}.value`);
829
+ exactMutationKeys(entry, ["data", "type", "mutability"], `X rich Article entity ${index}.value`);
830
+ const data = record(entry.data, `X rich Article entity ${index}.data`);
831
+ if (entry.type === "LINK") {
832
+ if (entry.mutability !== "Mutable") throw new Error("X rich Article links must be mutable");
833
+ exactMutationKeys(data, ["url"], `X rich Article entity ${index}.data`);
834
+ exactArticleUrl(data.url, `X rich Article entity ${index}.data.url`);
835
+ entityKinds.push("LINK");
836
+ continue;
837
+ }
838
+ if (entry.type !== "MEDIA" || entry.mutability !== "Immutable") {
839
+ throw new Error("X rich Article entities support only reviewed LINK and MEDIA values");
840
+ }
841
+ const mediaKeys = Object.keys(data).sort().join(",");
842
+ if (mediaKeys !== "entity_key,media_items" && mediaKeys !== "caption,entity_key,media_items") {
843
+ throw new Error(`X rich Article entity ${index}.data contained unsupported media fields`);
844
+ }
845
+ if (data.entity_key !== `${index}`) throw new Error("X rich Article media entity_key must bind its entity");
846
+ if (data.caption !== undefined && (
847
+ typeof data.caption !== "string"
848
+ || data.caption.length > 1_000
849
+ || /[\0\r]/u.test(data.caption)
850
+ )) throw new Error("X rich Article media caption must be bounded text");
851
+ if (!Array.isArray(data.media_items) || data.media_items.length !== 1) {
852
+ throw new Error("X rich Article media entities must contain one image");
853
+ }
854
+ const media = record(data.media_items[0], `X rich Article entity ${index}.media_items[0]`);
855
+ exactMutationKeys(media, ["local_media_id", "media_category", "media_id"], `X rich Article entity ${index}.media_items[0]`);
856
+ if (!Number.isSafeInteger(media.local_media_id) || (media.local_media_id as number) < 1) {
857
+ throw new Error("X rich Article local media IDs must be positive integers");
858
+ }
859
+ if (media.media_category !== "DraftTweetImage") {
860
+ throw new Error("X rich Article inline media must use DraftTweetImage");
861
+ }
862
+ exactMutationPostId(media.media_id, "X rich Article media ID");
863
+ mediaCount += 1;
864
+ if (mediaCount > 20) throw new Error("X rich Article supports at most 20 inline images");
865
+ entityKinds.push("MEDIA");
866
+ }
867
+
868
+ const references = Array.from({ length: entityKinds.length }, () => 0);
869
+ const blockKeys = new Set<string>();
870
+ let characters = 0;
871
+ for (const [index, value] of contentState.blocks.entries()) {
872
+ const block = record(value, `X rich Article block ${index + 1}`);
873
+ exactMutationKeys(
874
+ block,
875
+ ["data", "text", "key", "type", "entity_ranges", "inline_style_ranges"],
876
+ `X rich Article block ${index + 1}`,
877
+ );
878
+ const data = record(block.data, `X rich Article block ${index + 1}.data`);
879
+ exactMutationKeys(data, [], `X rich Article block ${index + 1}.data`);
880
+ if (
881
+ typeof block.text !== "string"
882
+ || /[\0\r\n]/u.test(block.text)
883
+ || typeof block.key !== "string"
884
+ || !/^[a-z0-9]{5}$/u.test(block.key)
885
+ || blockKeys.has(block.key)
886
+ || typeof block.type !== "string"
887
+ || !richArticleBlockTypes.has(block.type)
888
+ || !Array.isArray(block.entity_ranges)
889
+ || !Array.isArray(block.inline_style_ranges)
890
+ ) throw new Error(`X rich Article block ${index + 1} left the reviewed shape`);
891
+ const blockText = block.text;
892
+ blockKeys.add(block.key);
893
+ characters += blockText.length;
894
+ if (characters > 20_000) throw new Error("X rich Article text exceeds 20000 characters");
895
+ const entityRanges = block.entity_ranges.map((range, rangeIndex) =>
896
+ exactArticleRange(range, `X rich Article block ${index + 1}.entity_ranges[${rangeIndex}]`, blockText.length));
897
+ block.inline_style_ranges.forEach((range, rangeIndex) => {
898
+ exactArticleRange(range, `X rich Article block ${index + 1}.inline_style_ranges[${rangeIndex}]`, blockText.length);
899
+ });
900
+ let previousEnd = 0;
901
+ for (const range of entityRanges) {
902
+ if (range.offset < previousEnd) throw new Error("X rich Article entity ranges may not overlap");
903
+ previousEnd = range.offset + range.length;
904
+ const key = range.key!;
905
+ const kind = entityKinds[key];
906
+ if (kind === undefined) throw new Error("X rich Article entity range referenced an unknown entity");
907
+ references[key] = (references[key] ?? 0) + 1;
908
+ if (block.type === "atomic" ? kind !== "MEDIA" : kind !== "LINK") {
909
+ throw new Error("X rich Article entity range used the wrong block kind");
910
+ }
911
+ }
912
+ if (block.type === "atomic") {
913
+ if (
914
+ blockText !== " "
915
+ || entityRanges.length !== 1
916
+ || entityRanges[0]?.offset !== 0
917
+ || entityRanges[0]?.length !== 1
918
+ || block.inline_style_ranges.length !== 0
919
+ ) throw new Error("X rich Article atomic image blocks must bind one media entity");
920
+ }
921
+ }
922
+ if (references.some((count) => count !== 1)) {
923
+ throw new Error("X rich Article entities must each be referenced exactly once");
924
+ }
925
+ }
926
+
927
+ function validateRichArticleCreateVariables(variables: JsonRecord): void {
928
+ exactMutationKeys(variables, ["content_state", "title"], "X articles.create variables");
929
+ if (
930
+ typeof variables.title !== "string"
931
+ || variables.title.length < 1
932
+ || variables.title.length > 100
933
+ || /[\0\r\n]/u.test(variables.title)
934
+ ) throw new Error("X articles.create title must be one bounded line");
935
+ validateXWebRichArticleContentState(variables.content_state);
936
+ }
937
+
938
+ function validateRichArticleUpdateVariables(operationId: XWebMutationOperationId, variables: JsonRecord): void {
939
+ if (operationId === "articles.title") {
940
+ exactMutationKeys(variables, ["articleEntityId", "title"], "X articles.title variables");
941
+ exactMutationPostId(variables.articleEntityId, "X articles.title draft");
942
+ if (
943
+ typeof variables.title !== "string"
944
+ || variables.title.length < 1
945
+ || variables.title.length > 100
946
+ || /[\0\r\n]/u.test(variables.title)
947
+ ) throw new Error("X articles.title title must be one bounded line");
948
+ return;
949
+ }
950
+ if (operationId === "articles.content") {
951
+ exactMutationKeys(variables, ["content_state", "article_entity"], "X articles.content variables");
952
+ exactMutationPostId(variables.article_entity, "X articles.content draft");
953
+ validateXWebRichArticleContentState(variables.content_state);
954
+ return;
955
+ }
956
+ throw new Error("X rich Article update operation is outside the reviewed title/content contract");
957
+ }
958
+
959
+ /**
960
+ * Bind a state-changing request to one semantic operation and its complete
961
+ * variable/metadata shape before the durable dispatch boundary is crossed.
962
+ */
963
+ export function authorizeXWebMutationRequest(
964
+ operationId: XWebMutationOperationId,
965
+ input: XWebGraphQlBindingInput,
966
+ ): XWebGraphQlBinding {
967
+ const expectedName = mutationOperationNames[operationId];
968
+ if (expectedName === undefined) throw new Error("X mutation operation is not allowlisted");
969
+ const binding = assertExactXWebGraphQlBinding(input);
970
+ if (binding.operationType !== "mutation" || binding.operationName !== expectedName || binding.method !== "POST") {
971
+ throw new Error(`${operationId} did not bind its reviewed X mutation operation`);
972
+ }
973
+ const body = bodyRecord(input.body);
974
+ const descriptor = parseBundleQueryDescriptor(input.descriptor, `X ${operationId} descriptor`);
975
+ exactMutationKeys(
976
+ body,
977
+ ["variables", "features", "queryId", ...(descriptor.metadata.fieldToggles.length === 0 ? [] : ["fieldToggles"])],
978
+ `X ${operationId} body`,
979
+ );
980
+ if (body.queryId !== binding.queryId) throw new Error(`X ${operationId} body queryId drifted`);
981
+ const variables = record(body.variables, `X ${operationId} variables`);
982
+ if (expectedName === "CreateTweet") validateCreateTweetVariables(operationId, variables);
983
+ else if (operationId === "articles.create") validateRichArticleCreateVariables(variables);
984
+ else if (operationId === "articles.title" || operationId === "articles.content") {
985
+ validateRichArticleUpdateVariables(operationId, variables);
986
+ }
987
+ else validateDesiredStateVariables(operationId, variables);
988
+ exactBooleanMap(body.features, descriptor.metadata.featureSwitches, `X ${operationId} features`);
989
+ if (descriptor.metadata.fieldToggles.length > 0) {
990
+ exactBooleanMap(body.fieldToggles, descriptor.metadata.fieldToggles, `X ${operationId} fieldToggles`);
991
+ }
992
+ return binding;
993
+ }
994
+
995
+ function responseData(value: unknown, label: string): JsonRecord {
996
+ const body = record(value, label);
997
+ if (body.errors !== undefined) {
998
+ if (!Array.isArray(body.errors)) throw new Error(`${label}.errors must be an array`);
999
+ if (body.errors.length > 0) throw new Error(`${label} contained provider errors`);
1000
+ }
1001
+ return record(body.data, `${label}.data`);
1002
+ }
1003
+
1004
+ export type XWebProfileStats = {
1005
+ readonly schemaVersion: 1;
1006
+ readonly provider: "x";
1007
+ readonly target: {
1008
+ readonly kind: "profile";
1009
+ readonly id: string;
1010
+ readonly url: string;
1011
+ };
1012
+ readonly observedAt: string;
1013
+ readonly completeness: "complete";
1014
+ readonly metrics: {
1015
+ readonly followers: {
1016
+ readonly status: "available";
1017
+ readonly value: number;
1018
+ readonly precision: "exact";
1019
+ readonly unit: "count";
1020
+ };
1021
+ readonly following: {
1022
+ readonly status: "available";
1023
+ readonly value: number;
1024
+ readonly precision: "exact";
1025
+ readonly unit: "count";
1026
+ };
1027
+ };
1028
+ readonly metadata: {
1029
+ readonly handle: string;
1030
+ readonly displayName: string;
1031
+ readonly bio: string | null;
1032
+ readonly websiteUrl: string | null;
1033
+ };
1034
+ };
1035
+
1036
+ export function normalizeXWebProfileHandle(value: unknown): string {
1037
+ if (typeof value !== "string" || !/^[A-Za-z0-9_]{1,15}$/u.test(value)) {
1038
+ throw new Error("X profile handle must contain 1-15 letters, digits, or underscores");
1039
+ }
1040
+ return value.toLowerCase();
1041
+ }
1042
+
1043
+ function xProfileText(
1044
+ value: unknown,
1045
+ label: string,
1046
+ maximum: number,
1047
+ optional = false,
1048
+ ): string | null {
1049
+ if (value === undefined || value === null || (optional && value === "")) return null;
1050
+ if (
1051
+ typeof value !== "string"
1052
+ || value.length < 1
1053
+ || value.length > maximum
1054
+ || hasAsciiControl(value)
1055
+ ) throw new Error(`${label} must be bounded public text`);
1056
+ return value;
1057
+ }
1058
+
1059
+ function xProfileCount(value: unknown, label: string) {
1060
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
1061
+ throw new Error(`${label} must be an exact nonnegative safe integer`);
1062
+ }
1063
+ return Object.freeze({
1064
+ status: "available" as const,
1065
+ value: value as number,
1066
+ precision: "exact" as const,
1067
+ unit: "count" as const,
1068
+ });
1069
+ }
1070
+
1071
+ function xProfileBio(value: unknown): string | null {
1072
+ if (value === undefined || value === null || value === "") return null;
1073
+ if (
1074
+ typeof value !== "string"
1075
+ || value.length > 10_000
1076
+ || /[\0-\x08\x0b\x0c\x0e-\x1f\x7f]/u.test(value)
1077
+ ) throw new Error("X profile description must be bounded public text");
1078
+ return value;
1079
+ }
1080
+
1081
+ function xProfileWebsite(legacy: JsonRecord): string | null {
1082
+ if (legacy.entities === undefined || legacy.entities === null) return null;
1083
+ const entities = record(legacy.entities, "X profile legacy.entities");
1084
+ if (entities.url === undefined || entities.url === null) return null;
1085
+ const urlEntity = record(entities.url, "X profile legacy.entities.url");
1086
+ if (!Array.isArray(urlEntity.urls) || urlEntity.urls.length > 10) {
1087
+ throw new Error("X profile website URL projection exceeded its reviewed bound");
1088
+ }
1089
+ const expanded = urlEntity.urls.map((item, index) => {
1090
+ const url = record(item, `X profile website URL ${index + 1}`);
1091
+ return xProfileText(url.expanded_url, `X profile website URL ${index + 1}`, 2_048);
1092
+ });
1093
+ const unique = [...new Set(expanded.filter((value): value is string => value !== null))];
1094
+ if (unique.length === 0) return null;
1095
+ if (unique.length !== 1) throw new Error("X profile exposed ambiguous website URLs");
1096
+ const parsed = new URL(unique[0]!);
1097
+ if (
1098
+ (parsed.protocol !== "https:" && parsed.protocol !== "http:")
1099
+ || parsed.username !== ""
1100
+ || parsed.password !== ""
1101
+ ) throw new Error("X profile website URL is not a safe public HTTP URL");
1102
+ return parsed.href;
1103
+ }
1104
+
1105
+ function xProfileModernWebsite(value: unknown): string | null {
1106
+ if (value === undefined || value === null) return null;
1107
+ const website = record(value, "X profile website");
1108
+ const rawUrl = xProfileText(website.url, "X profile website URL", 2_048, true);
1109
+ if (rawUrl === null) return null;
1110
+ const parsed = new URL(rawUrl);
1111
+ if (
1112
+ (parsed.protocol !== "https:" && parsed.protocol !== "http:")
1113
+ || parsed.username !== ""
1114
+ || parsed.password !== ""
1115
+ ) throw new Error("X profile website URL is not a safe public HTTP URL");
1116
+ return parsed.href;
1117
+ }
1118
+
1119
+ function xProfileObservationTime(value: string): string {
1120
+ if (
1121
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
1122
+ || !Number.isFinite(Date.parse(value))
1123
+ ) throw new Error("X profile observedAt must be an exact UTC timestamp");
1124
+ return value;
1125
+ }
1126
+
1127
+ /** Bind and project one exact UserByScreenName response. */
1128
+ export class XProfileTargetUnavailableError extends Error {
1129
+ constructor() {
1130
+ super("X profile target is unavailable");
1131
+ this.name = "XProfileTargetUnavailableError";
1132
+ }
1133
+ }
1134
+
1135
+ export function projectXWebProfileStats(
1136
+ response: unknown,
1137
+ expectedHandleValue: unknown,
1138
+ observedAt: string,
1139
+ ): XWebProfileStats {
1140
+ const expectedHandle = normalizeXWebProfileHandle(expectedHandleValue);
1141
+ const result = record(
1142
+ extractXWebGraphQlReadResponseRoot("profiles.by-handle", response),
1143
+ "X profile response result",
1144
+ );
1145
+ if (result.__typename === "UserUnavailable") {
1146
+ throw new XProfileTargetUnavailableError();
1147
+ }
1148
+ if (result.__typename !== "User") {
1149
+ throw new Error("X profile response contained an unreviewed result typename");
1150
+ }
1151
+ const id = xProfileText(result.rest_id, "X profile rest_id", 19);
1152
+ if (id === null || !/^[0-9]{1,19}$/u.test(id)) {
1153
+ throw new Error("X profile rest_id must be an exact 1-19 digit identifier");
1154
+ }
1155
+ const core = record(result.core, "X profile core");
1156
+ const handle = normalizeXWebProfileHandle(core.screen_name);
1157
+ if (handle !== expectedHandle) {
1158
+ throw new Error("X profile response did not bind the requested handle");
1159
+ }
1160
+ const displayName = xProfileText(core.name, "X profile display name", 1_000);
1161
+ if (displayName === null) throw new Error("X profile display name is unavailable");
1162
+ let followerValue: unknown;
1163
+ let followingValue: unknown;
1164
+ let bio: string | null;
1165
+ let websiteUrl: string | null;
1166
+ if (isRecord(result.legacy)) {
1167
+ const legacy = result.legacy;
1168
+ if (
1169
+ legacy.screen_name !== undefined
1170
+ && normalizeXWebProfileHandle(legacy.screen_name) !== expectedHandle
1171
+ ) throw new Error("X profile legacy response changed the requested handle");
1172
+ followerValue = legacy.followers_count;
1173
+ followingValue = legacy.friends_count;
1174
+ bio = xProfileBio(legacy.description);
1175
+ websiteUrl = xProfileWebsite(legacy);
1176
+ } else {
1177
+ const relationships = record(
1178
+ result.relationship_counts,
1179
+ "X profile relationship_counts",
1180
+ );
1181
+ const profileBio = result.profile_bio === undefined || result.profile_bio === null
1182
+ ? null
1183
+ : record(result.profile_bio, "X profile profile_bio");
1184
+ followerValue = relationships.followers;
1185
+ followingValue = relationships.following;
1186
+ if (
1187
+ profileBio !== null
1188
+ && typeof profileBio.description !== "string"
1189
+ && profileBio.description !== null
1190
+ && profileBio.description !== undefined
1191
+ ) {
1192
+ throw new Error(
1193
+ `X profile description changed shape; fields: ${isRecord(profileBio.description) ? Object.keys(profileBio.description).sort().join(",") : typeof profileBio.description}`,
1194
+ );
1195
+ }
1196
+ bio = profileBio === null ? null : xProfileBio(profileBio.description);
1197
+ websiteUrl = xProfileModernWebsite(result.website);
1198
+ }
1199
+ return Object.freeze({
1200
+ schemaVersion: 1,
1201
+ provider: "x",
1202
+ target: Object.freeze({
1203
+ kind: "profile",
1204
+ id,
1205
+ url: `https://x.com/${handle}`,
1206
+ }),
1207
+ observedAt: xProfileObservationTime(observedAt),
1208
+ completeness: "complete",
1209
+ metrics: Object.freeze({
1210
+ followers: xProfileCount(followerValue, "X profile followers"),
1211
+ following: xProfileCount(followingValue, "X profile following"),
1212
+ }),
1213
+ metadata: Object.freeze({
1214
+ handle,
1215
+ displayName,
1216
+ bio,
1217
+ websiteUrl,
1218
+ }),
1219
+ });
1220
+ }
1221
+
1222
+ const X_USER_SNOWFLAKE = /^[0-9]{1,19}$/u;
1223
+ const X_USER_RELAY = /^User:([0-9]{1,19})$/u;
1224
+ const X_USER_RELAY_BASE64 = /^[A-Za-z0-9+/]+=*$/u;
1225
+
1226
+ function decodeXWebUserRelayId(value: string): string | null {
1227
+ const raw = X_USER_RELAY.exec(value);
1228
+ if (raw) return raw[1]!;
1229
+ if (!X_USER_RELAY_BASE64.test(value) || value.length > 64) return null;
1230
+ const decoded = Buffer.from(value, "base64").toString("utf8");
1231
+ return X_USER_RELAY.exec(decoded)?.[1] ?? null;
1232
+ }
1233
+
1234
+ function xWebUserIdentity(value: unknown, label: string): string | null {
1235
+ if (value === undefined || value === null) return null;
1236
+ if (typeof value === "number") {
1237
+ if (!Number.isSafeInteger(value) || value < 1) {
1238
+ throw new Error(`${label} must be a 1-19 digit X identifier`);
1239
+ }
1240
+ return String(value);
1241
+ }
1242
+ if (typeof value !== "string" || value.length < 1 || value.length > 64 || /[\0\r]/u.test(value)) {
1243
+ throw new Error(`${label} must be a bounded string`);
1244
+ }
1245
+ if (X_USER_SNOWFLAKE.test(value)) return value;
1246
+ return decodeXWebUserRelayId(value);
1247
+ }
1248
+
1249
+ function unwrapXWebUserResult(value: unknown, label: string): JsonRecord {
1250
+ let result = record(value, label);
1251
+ for (let depth = 0; depth < 3; depth += 1) {
1252
+ const typename = result.__typename;
1253
+ if (typename === "UserUnavailable") {
1254
+ throw new Error("X user feed target is unavailable");
1255
+ }
1256
+ if (typename === undefined || typename === "User") return result;
1257
+ if (typename !== "UserWithVisibilityResults") {
1258
+ throw new Error("X user feed response contained an unreviewed result typename");
1259
+ }
1260
+ if (isRecord(result.user)) {
1261
+ result = record(result.user, `${label}.user`);
1262
+ continue;
1263
+ }
1264
+ if (isRecord(result.user_results)) {
1265
+ const userResults = record(result.user_results, `${label}.user_results`);
1266
+ result = record(userResults.result, `${label}.user_results.result`);
1267
+ continue;
1268
+ }
1269
+ throw new Error(`${label} omitted a nested User`);
1270
+ }
1271
+ throw new Error(`${label} nested User wrappers exceeded the reviewed bound`);
1272
+ }
1273
+
1274
+ function collectXWebUserIdentities(
1275
+ user: JsonRecord,
1276
+ result: JsonRecord,
1277
+ ): ReadonlySet<string> {
1278
+ const returnedIds = new Set<string>();
1279
+ const candidates = [
1280
+ [result.rest_id, "X user feed response rest_id"],
1281
+ [result.id, "X user feed response id"],
1282
+ [isRecord(result.legacy) ? result.legacy.id_str : undefined, "X user feed response legacy.id_str"],
1283
+ [isRecord(result.core) ? result.core.rest_id : undefined, "X user feed response core.rest_id"],
1284
+ [user.rest_id, "X user feed response.data.user rest_id"],
1285
+ [user.id, "X user feed response.data.user id"],
1286
+ [user.id_str, "X user feed response.data.user id_str"],
1287
+ ] as const;
1288
+ for (const [value, label] of candidates) {
1289
+ const id = xWebUserIdentity(value, label);
1290
+ if (id !== null) returnedIds.add(id);
1291
+ }
1292
+ return returnedIds;
1293
+ }
1294
+
1295
+ function userFeedTimelineContainer(result: JsonRecord): JsonRecord | null {
1296
+ if (Object.hasOwn(result, "timeline") && result.timeline !== undefined && result.timeline !== null) {
1297
+ return record(result.timeline, "X user feed response.data.user.result.timeline");
1298
+ }
1299
+ if (Object.hasOwn(result, "timeline_v2") && result.timeline_v2 !== undefined && result.timeline_v2 !== null) {
1300
+ return record(result.timeline_v2, "X user feed response.data.user.result.timeline_v2");
1301
+ }
1302
+ return null;
1303
+ }
1304
+
1305
+ function collectXWebUserTimelineAuthorIds(result: JsonRecord): ReadonlySet<string> {
1306
+ const container = userFeedTimelineContainer(result);
1307
+ if (container === null || !Object.hasOwn(container, "timeline") || container.timeline === undefined || container.timeline === null) {
1308
+ return new Set();
1309
+ }
1310
+ const authorIds = new Set<string>();
1311
+ for (const item of normalizeXWebUrtTimeline(container.timeline).items) {
1312
+ if (item.kind !== "tweet" || item.author.id === null) continue;
1313
+ authorIds.add(item.author.id);
1314
+ }
1315
+ return authorIds;
1316
+ }
1317
+
1318
+ /**
1319
+ * Bind a UserTweets response to the requested user before the timeline is
1320
+ * exposed. The current UserTweets document still returns `__typename: "User"`
1321
+ * and `timeline.timeline`, but the User node may omit every identity field.
1322
+ * Bind from the User snowflake when present, otherwise from authored timeline
1323
+ * posts whose `rest_id` or `legacy.user_id_str` is the requested user.
1324
+ */
1325
+ export function assertXWebUserFeedTargetBound(
1326
+ response: unknown,
1327
+ expectedUserId: unknown,
1328
+ ): void {
1329
+ const expected = exactTweetId(expectedUserId, "input.user_id");
1330
+ const data = responseData(response, "X user feed response");
1331
+ const user = record(data.user, "X user feed response.data.user");
1332
+ const result = unwrapXWebUserResult(user.result, "X user feed response.data.user.result");
1333
+ const nodeIds = collectXWebUserIdentities(user, result);
1334
+ const authorIds = collectXWebUserTimelineAuthorIds(result);
1335
+ const returnedIds = new Set<string>([...nodeIds, ...authorIds]);
1336
+ if (returnedIds.size === 0) {
1337
+ throw new Error("X user feed response omitted a bindable user rest_id");
1338
+ }
1339
+ if (returnedIds.size !== 1 || !returnedIds.has(expected)) {
1340
+ throw new Error("X user feed response did not bind the requested user");
1341
+ }
1342
+ }
1343
+
1344
+ function userFeedRootSegment(
1345
+ operationId: XWebSemanticOperationId,
1346
+ parent: JsonRecord,
1347
+ segment: string,
1348
+ root: readonly string[],
1349
+ ): unknown {
1350
+ if (
1351
+ operationId === "feeds.user"
1352
+ && segment === "timeline"
1353
+ && !Object.hasOwn(parent, "timeline")
1354
+ && Object.hasOwn(parent, "timeline_v2")
1355
+ ) {
1356
+ return parent.timeline_v2;
1357
+ }
1358
+ if (!Object.hasOwn(parent, segment) || parent[segment] === null || parent[segment] === undefined) {
1359
+ throw new Error(`X ${operationId} response omitted reviewed root ${root.join(".")}`);
1360
+ }
1361
+ const next = parent[segment];
1362
+ if (operationId === "feeds.user" && segment === "result") {
1363
+ return unwrapXWebUserResult(next, "X user feed response.data.user.result");
1364
+ }
1365
+ return next;
1366
+ }
1367
+
1368
+ /**
1369
+ * Bind a GraphQL read response to the reviewed operation-specific root before
1370
+ * a caller normalizes it. This catches 200 responses for a different schema.
1371
+ */
1372
+ export function extractXWebGraphQlReadResponseRoot(
1373
+ operationId: XWebSemanticOperationId,
1374
+ response: unknown,
1375
+ ): unknown {
1376
+ const definition = xWebSemanticOperationRegistry[operationId];
1377
+ if (definition.transport !== "graphql-query" || definition.risk !== "R1") {
1378
+ throw new Error(`${operationId} is not an X GraphQL read response contract`);
1379
+ }
1380
+ let current: unknown = responseData(response, `X ${operationId} response`);
1381
+ for (const [index, segment] of definition.responseRoot.entries()) {
1382
+ const parent = record(current, `X ${operationId} response root ${definition.responseRoot.slice(0, index).join(".") || "data"}`);
1383
+ current = userFeedRootSegment(operationId, parent, segment, definition.responseRoot);
1384
+ }
1385
+ return current;
1386
+ }
1387
+
1388
+ export function normalizeXWebGraphQlTimelineResponse(
1389
+ operationId: XWebSemanticOperationId,
1390
+ response: unknown,
1391
+ ): XWebNormalizedTimeline {
1392
+ const definition = xWebSemanticOperationRegistry[operationId];
1393
+ if (
1394
+ definition.transport !== "graphql-query"
1395
+ || (definition.semanticOperation !== "feeds.read" && operationId !== "posts.detail")
1396
+ ) {
1397
+ throw new Error(`${operationId} is not an X URT timeline response contract`);
1398
+ }
1399
+ return normalizeXWebUrtTimeline(extractXWebGraphQlReadResponseRoot(operationId, response));
1400
+ }
1401
+
1402
+ export const xWebLegacyDmInboxMapping = Object.freeze({
1403
+ primary: Object.freeze({
1404
+ providerClass: "PRIMARY" as const,
1405
+ route: "/messages",
1406
+ stateKey: "dmInbox",
1407
+ cursorSelector: "selectInboxCursor",
1408
+ fetchAction: "fetchTrustedInboxHistory",
1409
+ uiMayUpdateLastSeen: true,
1410
+ }),
1411
+ requests: Object.freeze({
1412
+ providerClass: "SECONDARY" as const,
1413
+ route: "/messages/requests",
1414
+ stateKey: "dmUntrustedInbox",
1415
+ cursorSelector: "selectUntrustedCursor",
1416
+ fetchAction: "fetchUntrustedInboxHistory",
1417
+ uiMayUpdateLastSeen: true,
1418
+ }),
1419
+ additional: Object.freeze({
1420
+ providerClass: "TERTIARY" as const,
1421
+ route: "/messages/requests/additional",
1422
+ stateKey: "dmLowQualityUntrustedInbox",
1423
+ cursorSelector: "selectUntrustedLowQualityCursor",
1424
+ fetchAction: "fetchUntrustedLowQualityInboxHistory",
1425
+ uiMayUpdateLastSeen: false,
1426
+ }),
1427
+ });
1428
+
1429
+ export type XWebLegacyDmInbox = keyof typeof xWebLegacyDmInboxMapping;
1430
+ export type XWebLegacyDmReadSurface = XWebLegacyDmInbox | "conversation";
1431
+
1432
+ export function resolveXWebLegacyDmInbox(value: unknown): (typeof xWebLegacyDmInboxMapping)[XWebLegacyDmInbox] {
1433
+ if (value !== "primary" && value !== "requests" && value !== "additional") {
1434
+ throw new Error("X inbox must be primary, requests, or additional");
1435
+ }
1436
+ return xWebLegacyDmInboxMapping[value];
1437
+ }
1438
+
1439
+ const legacyDmStaticPaths = Object.freeze({
1440
+ "/i/api/1.1/dm/inbox_initial_state.json": Object.freeze(["primary"] as const),
1441
+ "/i/api/1.1/dm/inbox_timeline/trusted.json": Object.freeze(["primary"] as const),
1442
+ "/i/api/1.1/dm/inbox_timeline/untrusted.json": Object.freeze(["requests"] as const),
1443
+ "/i/api/1.1/dm/inbox_timeline/untrusted_low_quality.json": Object.freeze(["additional"] as const),
1444
+ });
1445
+
1446
+ export const xWebLegacyDmReadQueryParameterNames = Object.freeze([
1447
+ "cards_platform",
1448
+ "context",
1449
+ "count",
1450
+ "cursor",
1451
+ "dm_users",
1452
+ "ext",
1453
+ "filter_low_quality",
1454
+ "include_blocked_by",
1455
+ "include_blocking",
1456
+ "include_can_dm",
1457
+ "include_can_media_tag",
1458
+ "include_cards",
1459
+ "include_conversation_info",
1460
+ "include_entities",
1461
+ "include_ext_alt_text",
1462
+ "include_ext_has_nft_avatar",
1463
+ "include_ext_is_blue_verified",
1464
+ "include_ext_limited_action_results",
1465
+ "include_ext_media_availability",
1466
+ "include_ext_media_color",
1467
+ "include_ext_profile_image_shape",
1468
+ "include_ext_sensitive_media_warning",
1469
+ "include_ext_trusted_friends_metadata",
1470
+ "include_ext_verified_type",
1471
+ "include_ext_views",
1472
+ "include_followed_by",
1473
+ "include_groups",
1474
+ "include_inbox_timelines",
1475
+ "include_mute_edge",
1476
+ "include_profile_interstitial_type",
1477
+ "include_quality",
1478
+ "include_quote_count",
1479
+ "include_reply_count",
1480
+ "include_want_retweets",
1481
+ "max_id",
1482
+ "min_entry_id",
1483
+ "requestContext",
1484
+ "send_error_codes",
1485
+ "simple_quoted_tweet",
1486
+ "skip_status",
1487
+ "tweet_mode",
1488
+ ] as const);
1489
+
1490
+ const legacyDmQueryParameterSet = new Set<string>(xWebLegacyDmReadQueryParameterNames);
1491
+ const forbiddenLegacyDmParameters = new Set([
1492
+ "accept",
1493
+ "accepted",
1494
+ "last_seen_event_id",
1495
+ "lastseeneventid",
1496
+ "mark_read",
1497
+ "markread",
1498
+ "poll",
1499
+ "update_last_seen",
1500
+ "watch",
1501
+ ]);
1502
+
1503
+ export type XWebLegacyDmReadRequest = {
1504
+ readonly surface: XWebLegacyDmReadSurface;
1505
+ readonly method: string;
1506
+ readonly url: string | URL;
1507
+ readonly polling?: boolean;
1508
+ };
1509
+
1510
+ export type XWebAuthorizedLegacyDmRead = {
1511
+ readonly surface: XWebLegacyDmReadSurface;
1512
+ readonly method: "GET";
1513
+ readonly path: string;
1514
+ readonly conversationId: string | null;
1515
+ readonly queryParameterNames: readonly string[];
1516
+ };
1517
+
1518
+ function exactConversationId(value: string): string {
1519
+ if (!/^(?:[0-9]{1,19}|[0-9]{1,19}-[0-9]{1,19})$/u.test(value)) {
1520
+ throw new Error("X DM conversation path contained an invalid conversation ID");
1521
+ }
1522
+ if (value.includes("-")) {
1523
+ const [left, right] = value.split("-");
1524
+ if (left === right) throw new Error("X DM conversation must contain different participants");
1525
+ }
1526
+ return value;
1527
+ }
1528
+
1529
+ /** Authorize a one-shot, side-effect-free legacy DM read. */
1530
+ export function authorizeXWebLegacyDmR1Read(input: XWebLegacyDmReadRequest): XWebAuthorizedLegacyDmRead {
1531
+ if (input.polling !== undefined && input.polling !== false) {
1532
+ throw new Error("X R1 DM reads may not enable polling");
1533
+ }
1534
+ if (input.method.toUpperCase() !== "GET") throw new Error("X R1 DM reads require GET");
1535
+ const url = exactUrl(input.url, "X legacy DM URL");
1536
+ const lowerPath = url.pathname.toLowerCase();
1537
+ if (
1538
+ lowerPath.includes("mark_read")
1539
+ || lowerPath.includes("last_seen")
1540
+ || lowerPath.includes("/accept")
1541
+ || lowerPath.includes("/new2")
1542
+ || lowerPath.includes("/delete")
1543
+ || lowerPath.includes("user_updates")
1544
+ ) {
1545
+ throw new Error("X R1 DM reads forbid write, last-seen, accept, and polling endpoints");
1546
+ }
1547
+ let conversationId: string | null = null;
1548
+ const conversation = /^\/i\/api\/1\.1\/dm\/conversation\/([^/]+)\.json$/u.exec(url.pathname);
1549
+ if (input.surface === "conversation") {
1550
+ if (conversation === null) throw new Error("X DM conversation read requires the exact conversation endpoint");
1551
+ conversationId = exactConversationId(conversation[1]!);
1552
+ } else {
1553
+ if (conversation !== null) throw new Error("X DM inbox reads may not use the conversation endpoint");
1554
+ const allowedSurfaces = legacyDmStaticPaths[url.pathname as keyof typeof legacyDmStaticPaths];
1555
+ if (allowedSurfaces === undefined || !(allowedSurfaces as readonly string[]).includes(input.surface)) {
1556
+ throw new Error(`X DM ${input.surface} read used a non-allowlisted inbox endpoint`);
1557
+ }
1558
+ }
1559
+ const queryNames: string[] = [];
1560
+ const seen = new Set<string>();
1561
+ for (const [name, value] of url.searchParams) {
1562
+ const normalized = name.toLowerCase();
1563
+ if (seen.has(name)) throw new Error(`X legacy DM URL repeated parameter ${name}`);
1564
+ seen.add(name);
1565
+ if (forbiddenLegacyDmParameters.has(normalized)) {
1566
+ throw new Error(`X R1 DM reads forbid parameter ${name}`);
1567
+ }
1568
+ if (!legacyDmQueryParameterSet.has(name)) {
1569
+ throw new Error(`X legacy DM URL contained unsupported parameter ${name}`);
1570
+ }
1571
+ if (value.length > 32_768 || hasAsciiControl(value)) {
1572
+ throw new Error(`X legacy DM URL parameter ${name} had an invalid value`);
1573
+ }
1574
+ queryNames.push(name);
1575
+ }
1576
+ return Object.freeze({
1577
+ surface: input.surface,
1578
+ method: "GET",
1579
+ path: url.pathname,
1580
+ conversationId,
1581
+ queryParameterNames: Object.freeze(queryNames.sort()),
1582
+ });
1583
+ }
1584
+
1585
+ export type XWebNormalizedTweetAuthor = {
1586
+ readonly id: string | null;
1587
+ readonly username: string | null;
1588
+ readonly name: string | null;
1589
+ };
1590
+
1591
+ export type XWebNormalizedTweet = {
1592
+ readonly kind: "tweet";
1593
+ readonly entryId: string;
1594
+ readonly moduleEntryId: string | null;
1595
+ readonly sortIndex: string | null;
1596
+ readonly tweetId: string;
1597
+ readonly typename: "Tweet" | "TweetWithVisibilityResults" | "TweetPreviewDisplay";
1598
+ readonly legacy: Readonly<JsonRecord> | null;
1599
+ readonly author: XWebNormalizedTweetAuthor;
1600
+ };
1601
+
1602
+ export type XWebNormalizedUnavailable = {
1603
+ readonly kind: "unavailable";
1604
+ readonly entryId: string;
1605
+ readonly moduleEntryId: string | null;
1606
+ readonly sortIndex: string | null;
1607
+ readonly typename: string;
1608
+ readonly reason: string | null;
1609
+ };
1610
+
1611
+ export type XWebNormalizedOther = {
1612
+ readonly kind: "other";
1613
+ readonly entryId: string;
1614
+ readonly moduleEntryId: string | null;
1615
+ readonly sortIndex: string | null;
1616
+ readonly itemType: string;
1617
+ };
1618
+
1619
+ export type XWebNormalizedTimelineItem =
1620
+ | XWebNormalizedTweet
1621
+ | XWebNormalizedUnavailable
1622
+ | XWebNormalizedOther;
1623
+
1624
+ export type XWebUrtCursor = {
1625
+ readonly entryId: string;
1626
+ readonly cursorType: string;
1627
+ readonly value: string;
1628
+ };
1629
+
1630
+ export type XWebNormalizedTimeline = {
1631
+ readonly items: readonly XWebNormalizedTimelineItem[];
1632
+ readonly cursors: {
1633
+ readonly top: XWebUrtCursor | null;
1634
+ readonly bottom: XWebUrtCursor | null;
1635
+ readonly other: readonly XWebUrtCursor[];
1636
+ };
1637
+ readonly duplicateTweetIds: readonly string[];
1638
+ readonly terminatedDirections: readonly string[];
1639
+ readonly finalEntryCount: number;
1640
+ };
1641
+
1642
+ function optionalString(value: JsonRecord, key: string): string | null {
1643
+ const candidate = value[key];
1644
+ if (candidate === undefined || candidate === null) return null;
1645
+ if (typeof candidate !== "string") throw new Error(`X URT ${key} must be a string`);
1646
+ return candidate;
1647
+ }
1648
+
1649
+ function timelineEntry(value: unknown, label: string): JsonRecord {
1650
+ const entry = record(value, label);
1651
+ const entryId = requiredString(entry, "entryId", label);
1652
+ if (entryId.length > 512 || hasAsciiControl(entryId)) throw new Error(`${label}.entryId is invalid`);
1653
+ record(entry.content, `${label}.content`);
1654
+ return entry;
1655
+ }
1656
+
1657
+ function exactTweetId(value: unknown, label: string): string {
1658
+ if (typeof value !== "string" || !/^[0-9]{1,19}$/u.test(value)) {
1659
+ throw new Error(`${label} must be an exact 1-19 digit identifier`);
1660
+ }
1661
+ return value;
1662
+ }
1663
+
1664
+ function optionalTweetId(value: unknown, label: string): string | null {
1665
+ if (value === undefined || value === null) return null;
1666
+ return exactTweetId(value, label);
1667
+ }
1668
+
1669
+ function optionalAuthorName(value: unknown, label: string): string | null {
1670
+ if (value === undefined || value === null) return null;
1671
+ if (typeof value !== "string" || value.length < 1 || value.length > 1_000 || hasAsciiControl(value)) {
1672
+ throw new Error(`${label} must be bounded public text`);
1673
+ }
1674
+ return value;
1675
+ }
1676
+
1677
+ function optionalAuthorHandle(value: unknown, label: string): string | null {
1678
+ if (value === undefined || value === null) return null;
1679
+ try {
1680
+ return normalizeXWebProfileHandle(value);
1681
+ } catch {
1682
+ throw new Error(`${label} must be a valid X handle`);
1683
+ }
1684
+ }
1685
+
1686
+ function projectXWebTweetAuthor(
1687
+ tweet: JsonRecord,
1688
+ legacy: Readonly<JsonRecord> | null,
1689
+ label: string,
1690
+ ): XWebNormalizedTweetAuthor {
1691
+ const legacyId = legacy === null ? null : optionalTweetId(legacy.user_id_str, `${label}.legacy.user_id_str`);
1692
+ if (tweet.core === undefined || tweet.core === null) {
1693
+ return Object.freeze({ id: legacyId, username: null, name: null });
1694
+ }
1695
+ const core = record(tweet.core, `${label}.core`);
1696
+ if (core.user_results === undefined || core.user_results === null) {
1697
+ return Object.freeze({ id: legacyId, username: null, name: null });
1698
+ }
1699
+ const userResults = record(core.user_results, `${label}.core.user_results`);
1700
+ if (userResults.result === undefined || userResults.result === null) {
1701
+ return Object.freeze({ id: legacyId, username: null, name: null });
1702
+ }
1703
+ const user = record(userResults.result, `${label}.core.user_results.result`);
1704
+ const typename = typeof user.__typename === "string" ? user.__typename : null;
1705
+ if (typename === "UserUnavailable") {
1706
+ return Object.freeze({ id: legacyId, username: null, name: null });
1707
+ }
1708
+ if (typename !== null && typename !== "User") {
1709
+ throw new Error(`${label} author had an unreviewed typename`);
1710
+ }
1711
+ const userId = optionalTweetId(user.rest_id, `${label} author rest_id`);
1712
+ if (legacyId !== null && userId !== null && legacyId !== userId) {
1713
+ throw new Error(`${label} author id disagreed with tweet legacy.user_id_str`);
1714
+ }
1715
+ const userCore = user.core === undefined || user.core === null
1716
+ ? null
1717
+ : record(user.core, `${label} author core`);
1718
+ const userLegacy = user.legacy === undefined || user.legacy === null
1719
+ ? null
1720
+ : record(user.legacy, `${label} author legacy`);
1721
+ const coreHandle = userCore === null ? null : optionalAuthorHandle(userCore.screen_name, `${label} author core.screen_name`);
1722
+ const legacyHandle = userLegacy === null ? null : optionalAuthorHandle(userLegacy.screen_name, `${label} author legacy.screen_name`);
1723
+ if (coreHandle !== null && legacyHandle !== null && coreHandle !== legacyHandle) {
1724
+ throw new Error(`${label} author handle disagreed across core and legacy`);
1725
+ }
1726
+ const coreName = userCore === null ? null : optionalAuthorName(userCore.name, `${label} author core.name`);
1727
+ const legacyName = userLegacy === null ? null : optionalAuthorName(userLegacy.name, `${label} author legacy.name`);
1728
+ if (coreName !== null && legacyName !== null && coreName !== legacyName) {
1729
+ throw new Error(`${label} author name disagreed across core and legacy`);
1730
+ }
1731
+ return Object.freeze({
1732
+ id: userId ?? legacyId,
1733
+ username: coreHandle ?? legacyHandle,
1734
+ name: coreName ?? legacyName,
1735
+ });
1736
+ }
1737
+
1738
+ function unwrapTweetResult(value: JsonRecord, label: string): {
1739
+ readonly result: JsonRecord | null;
1740
+ readonly typename: string;
1741
+ readonly reason: string | null;
1742
+ } {
1743
+ const typename = requiredString(value, "__typename", label);
1744
+ if (typename === "Tweet") return { result: value, typename, reason: null };
1745
+ if (typename === "TweetWithVisibilityResults" || typename === "TweetPreviewDisplay") {
1746
+ const nested = value.tweet;
1747
+ if (isRecord(nested)) return { result: nested, typename, reason: null };
1748
+ const nestedResults = isRecord(value.tweet_results) ? value.tweet_results.result : undefined;
1749
+ if (isRecord(nestedResults)) return { result: nestedResults, typename, reason: null };
1750
+ throw new Error(`X URT ${typename} omitted its nested tweet`);
1751
+ }
1752
+ const reason = typeof value.reason === "string"
1753
+ ? value.reason
1754
+ : isRecord(value.tombstone) && typeof value.tombstone.text === "string"
1755
+ ? value.tombstone.text
1756
+ : null;
1757
+ return { result: null, typename, reason };
1758
+ }
1759
+
1760
+ function normalizeItemContent(
1761
+ itemValue: unknown,
1762
+ entryId: string,
1763
+ moduleEntryId: string | null,
1764
+ sortIndex: string | null,
1765
+ ): XWebNormalizedTimelineItem {
1766
+ const item = record(itemValue, `X URT item ${entryId}`);
1767
+ const itemType = requiredString(item, "itemType", `X URT item ${entryId}`);
1768
+ if (itemType !== "TimelineTweet") {
1769
+ return Object.freeze({ kind: "other", entryId, moduleEntryId, sortIndex, itemType });
1770
+ }
1771
+ const tweetResults = record(item.tweet_results, `X URT tweet ${entryId}.tweet_results`);
1772
+ const resultValue = tweetResults.result;
1773
+ if (resultValue === null || resultValue === undefined) {
1774
+ return Object.freeze({
1775
+ kind: "unavailable",
1776
+ entryId,
1777
+ moduleEntryId,
1778
+ sortIndex,
1779
+ typename: "MissingTweetResult",
1780
+ reason: null,
1781
+ });
1782
+ }
1783
+ const result = record(resultValue, `X URT tweet ${entryId}.tweet_results.result`);
1784
+ const unwrapped = unwrapTweetResult(result, `X URT tweet ${entryId}`);
1785
+ if (unwrapped.result === null) {
1786
+ return Object.freeze({
1787
+ kind: "unavailable",
1788
+ entryId,
1789
+ moduleEntryId,
1790
+ sortIndex,
1791
+ typename: unwrapped.typename,
1792
+ reason: unwrapped.reason,
1793
+ });
1794
+ }
1795
+ const tweetId = exactTweetId(unwrapped.result.rest_id, `X URT tweet ${entryId}.rest_id`);
1796
+ const legacy = unwrapped.result.legacy === undefined || unwrapped.result.legacy === null
1797
+ ? null
1798
+ : Object.freeze(record(unwrapped.result.legacy, `X URT tweet ${entryId}.legacy`));
1799
+ return Object.freeze({
1800
+ kind: "tweet",
1801
+ entryId,
1802
+ moduleEntryId,
1803
+ sortIndex,
1804
+ tweetId,
1805
+ typename: unwrapped.typename as XWebNormalizedTweet["typename"],
1806
+ legacy,
1807
+ author: projectXWebTweetAuthor(unwrapped.result, legacy, `X URT tweet ${entryId}`),
1808
+ });
1809
+ }
1810
+
1811
+ function uniqueDirectionalCursor(cursors: readonly XWebUrtCursor[], type: "Top" | "Bottom"): XWebUrtCursor | null {
1812
+ const matching = cursors.filter((cursor) => cursor.cursorType === type);
1813
+ if (matching.length === 0) return null;
1814
+ const values = new Set(matching.map((cursor) => cursor.value));
1815
+ if (matching.length > 1 || values.size > 1) {
1816
+ throw new Error(`X URT timeline contained ambiguous ${type.toLowerCase()} cursors`);
1817
+ }
1818
+ return matching[0]!;
1819
+ }
1820
+
1821
+ /** Normalize current URT add/replace/remove/terminate instructions fail-closed. */
1822
+ export function normalizeXWebUrtTimeline(value: unknown): XWebNormalizedTimeline {
1823
+ const timeline = record(value, "X URT timeline");
1824
+ if (!Array.isArray(timeline.instructions)) throw new Error("X URT timeline.instructions must be an array");
1825
+ const entries = new Map<string, JsonRecord>();
1826
+ const terminatedDirections: string[] = [];
1827
+ for (const [instructionIndex, instructionValue] of timeline.instructions.entries()) {
1828
+ const label = `X URT instruction ${instructionIndex + 1}`;
1829
+ const instruction = record(instructionValue, label);
1830
+ const type = requiredString(instruction, "type", label);
1831
+ if (type === "TimelineAddEntries") {
1832
+ if (!Array.isArray(instruction.entries)) throw new Error(`${label}.entries must be an array`);
1833
+ for (const [entryIndex, entryValue] of instruction.entries.entries()) {
1834
+ const entry = timelineEntry(entryValue, `${label}.entries[${entryIndex}]`);
1835
+ entries.set(requiredString(entry, "entryId", label), entry);
1836
+ }
1837
+ } else if (type === "TimelineReplaceEntry" || type === "TimelinePinEntry") {
1838
+ const entry = timelineEntry(instruction.entry, `${label}.entry`);
1839
+ const replacementId = optionalString(instruction, "entryIdToReplace");
1840
+ if (replacementId !== null && replacementId !== entry.entryId) entries.delete(replacementId);
1841
+ entries.set(requiredString(entry, "entryId", label), entry);
1842
+ } else if (type === "TimelineRemoveEntries") {
1843
+ if (!Array.isArray(instruction.entryIds) || !instruction.entryIds.every((id) => typeof id === "string")) {
1844
+ throw new Error(`${label}.entryIds must be a string array`);
1845
+ }
1846
+ for (const entryId of instruction.entryIds) entries.delete(entryId);
1847
+ } else if (type === "TimelineClearCache") {
1848
+ entries.clear();
1849
+ } else if (type === "TimelineTerminateTimeline") {
1850
+ const direction = requiredString(instruction, "direction", label);
1851
+ if (!terminatedDirections.includes(direction)) terminatedDirections.push(direction);
1852
+ } else {
1853
+ throw new Error(`X URT instruction type ${type} is not reviewed`);
1854
+ }
1855
+ }
1856
+
1857
+ const items: XWebNormalizedTimelineItem[] = [];
1858
+ const cursors: XWebUrtCursor[] = [];
1859
+ for (const entry of entries.values()) {
1860
+ const entryId = requiredString(entry, "entryId", "X URT entry");
1861
+ const sortIndex = optionalString(entry, "sortIndex");
1862
+ const content = record(entry.content, `X URT entry ${entryId}.content`);
1863
+ const entryType = requiredString(content, "entryType", `X URT entry ${entryId}.content`);
1864
+ if (entryType === "TimelineTimelineCursor") {
1865
+ const cursorType = requiredString(content, "cursorType", `X URT cursor ${entryId}`);
1866
+ const cursorValue = requiredString(content, "value", `X URT cursor ${entryId}`);
1867
+ if (cursorValue.length > 16_384 || hasAsciiControl(cursorValue)) {
1868
+ throw new Error(`X URT cursor ${entryId} had an invalid value`);
1869
+ }
1870
+ cursors.push(Object.freeze({ entryId, cursorType, value: cursorValue }));
1871
+ } else if (entryType === "TimelineTimelineItem") {
1872
+ items.push(normalizeItemContent(content.itemContent, entryId, null, sortIndex));
1873
+ } else if (entryType === "TimelineTimelineModule") {
1874
+ if (!Array.isArray(content.items)) throw new Error(`X URT module ${entryId}.items must be an array`);
1875
+ for (const [moduleIndex, moduleItemValue] of content.items.entries()) {
1876
+ const moduleItem = record(moduleItemValue, `X URT module ${entryId}.items[${moduleIndex}]`);
1877
+ const moduleItemId = requiredString(moduleItem, "entryId", `X URT module ${entryId}.items[${moduleIndex}]`);
1878
+ const itemContainer = record(moduleItem.item, `X URT module item ${moduleItemId}.item`);
1879
+ items.push(normalizeItemContent(itemContainer.itemContent, moduleItemId, entryId, sortIndex));
1880
+ }
1881
+ } else {
1882
+ throw new Error(`X URT entry type ${entryType} is not reviewed`);
1883
+ }
1884
+ }
1885
+
1886
+ const deduplicated: XWebNormalizedTimelineItem[] = [];
1887
+ const seenTweetIds = new Set<string>();
1888
+ const duplicateTweetIds: string[] = [];
1889
+ for (const item of items) {
1890
+ if (item.kind !== "tweet") {
1891
+ deduplicated.push(item);
1892
+ continue;
1893
+ }
1894
+ if (seenTweetIds.has(item.tweetId)) {
1895
+ if (!duplicateTweetIds.includes(item.tweetId)) duplicateTweetIds.push(item.tweetId);
1896
+ continue;
1897
+ }
1898
+ seenTweetIds.add(item.tweetId);
1899
+ deduplicated.push(item);
1900
+ }
1901
+ const top = uniqueDirectionalCursor(cursors, "Top");
1902
+ const bottom = uniqueDirectionalCursor(cursors, "Bottom");
1903
+ return Object.freeze({
1904
+ items: Object.freeze(deduplicated),
1905
+ cursors: Object.freeze({
1906
+ top,
1907
+ bottom,
1908
+ other: Object.freeze(cursors.filter((cursor) => cursor.cursorType !== "Top" && cursor.cursorType !== "Bottom")),
1909
+ }),
1910
+ duplicateTweetIds: Object.freeze(duplicateTweetIds),
1911
+ terminatedDirections: Object.freeze(terminatedDirections),
1912
+ finalEntryCount: entries.size,
1913
+ });
1914
+ }
1915
+
1916
+ export function extractXWebUrtBottomCursor(value: unknown): string | null {
1917
+ return normalizeXWebUrtTimeline(value).cursors.bottom?.value ?? null;
1918
+ }
1919
+
1920
+ const X_STATUS_ORIGIN = "https://x.com";
1921
+ const X_BOOKMARK_EXPORT_RECORD_KEYS = Object.freeze([
1922
+ "post_id",
1923
+ "url",
1924
+ "author_username",
1925
+ "author_name",
1926
+ "text",
1927
+ "created_at",
1928
+ "folder_id",
1929
+ "bookmarked_at",
1930
+ ] as const);
1931
+ const X_BOOKMARK_EXPORT_PAGE_KEYS = Object.freeze([
1932
+ "feed",
1933
+ "items",
1934
+ "posts",
1935
+ "cursor",
1936
+ "terminatedDirections",
1937
+ ] as const);
1938
+ const X_FEED_POST_KEYS = Object.freeze([
1939
+ "kind",
1940
+ "id",
1941
+ "text",
1942
+ "createdAt",
1943
+ "authorId",
1944
+ "authorUsername",
1945
+ "authorName",
1946
+ "replyToPostId",
1947
+ "liked",
1948
+ "reposted",
1949
+ "saved",
1950
+ "metrics",
1951
+ "url",
1952
+ ] as const);
1953
+
1954
+ export type XWebProjectedPostMetrics = {
1955
+ readonly replies: number | null;
1956
+ readonly reposts: number | null;
1957
+ readonly likes: number | null;
1958
+ readonly bookmarks: number | null;
1959
+ };
1960
+
1961
+ export type XWebProjectedPost = {
1962
+ readonly kind: "post";
1963
+ readonly id: string;
1964
+ readonly text: string;
1965
+ readonly createdAt: string | null;
1966
+ readonly authorId: string | null;
1967
+ readonly authorUsername: string | null;
1968
+ readonly authorName: string | null;
1969
+ readonly replyToPostId: string | null;
1970
+ readonly liked: boolean | null;
1971
+ readonly reposted: boolean | null;
1972
+ readonly saved: boolean | null;
1973
+ readonly metrics: XWebProjectedPostMetrics;
1974
+ readonly url: string;
1975
+ };
1976
+
1977
+ export type XWebBookmarkExportRecord = {
1978
+ readonly post_id: string;
1979
+ readonly url: string;
1980
+ readonly author_username: string | null;
1981
+ readonly author_name: string | null;
1982
+ readonly text: string;
1983
+ readonly created_at: string | null;
1984
+ readonly folder_id: string | null;
1985
+ readonly bookmarked_at: string | null;
1986
+ };
1987
+
1988
+ export type XWebBookmarkExportPage = {
1989
+ readonly feed: "bookmarks";
1990
+ readonly items: readonly XWebBookmarkExportRecord[];
1991
+ readonly posts: readonly XWebProjectedPost[];
1992
+ readonly cursor: string | null;
1993
+ readonly terminatedDirections: readonly string[];
1994
+ };
1995
+
1996
+ export type XWebFeedPage = {
1997
+ readonly posts: readonly XWebProjectedPost[];
1998
+ readonly cursor: string | null;
1999
+ readonly terminatedDirections: readonly string[];
2000
+ };
2001
+
2002
+ /** Admit TAB, LF, and CR in tweet bodies. Headers and cursors stay on hasAsciiControl. */
2003
+ function hasForbiddenPublicTextControl(value: string): boolean {
2004
+ for (let index = 0; index < value.length; index += 1) {
2005
+ const code = value.charCodeAt(index);
2006
+ if (code === 0x09 || code === 0x0a || code === 0x0d) continue;
2007
+ if (code <= 0x1f || code === 0x7f) return true;
2008
+ }
2009
+ return false;
2010
+ }
2011
+
2012
+ function requireBoundedPublicText(value: unknown, label: string, maximum: number): string {
2013
+ if (typeof value !== "string" || value.length > maximum || hasForbiddenPublicTextControl(value)) {
2014
+ throw new Error(`${label} must be bounded public text`);
2015
+ }
2016
+ return value;
2017
+ }
2018
+
2019
+ function optionalBoundedText(value: unknown, label: string, maximum: number): string | null {
2020
+ if (value === undefined || value === null) return null;
2021
+ if (typeof value !== "string" || value.length > maximum || hasAsciiControl(value)) {
2022
+ throw new Error(`${label} must be bounded public text`);
2023
+ }
2024
+ return value;
2025
+ }
2026
+
2027
+ function optionalBoolean(value: unknown, label: string): boolean | null {
2028
+ if (value === undefined || value === null) return null;
2029
+ if (typeof value !== "boolean") throw new Error(`${label} must be a boolean`);
2030
+ return value;
2031
+ }
2032
+
2033
+ function optionalMetric(value: unknown, label: string): number | null {
2034
+ if (value === undefined || value === null) return null;
2035
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
2036
+ throw new Error(`${label} must be an exact nonnegative safe integer`);
2037
+ }
2038
+ return value as number;
2039
+ }
2040
+
2041
+ function optionalCursor(value: unknown, label: string): string | null {
2042
+ if (value === undefined || value === null) return null;
2043
+ if (typeof value !== "string" || value.length < 1 || value.length > 16_384 || hasAsciiControl(value)) {
2044
+ throw new Error(`${label} must be an opaque provider cursor`);
2045
+ }
2046
+ return value;
2047
+ }
2048
+
2049
+ function xWebStatusUrl(postId: string): string {
2050
+ return `${X_STATUS_ORIGIN}/i/status/${postId}`;
2051
+ }
2052
+
2053
+ /** Keep the provider cursor unpublished when the page was truncated. */
2054
+ export function boundXWebProviderPage<T>(
2055
+ items: readonly T[],
2056
+ limit: number,
2057
+ continuationAvailable: boolean,
2058
+ label: string,
2059
+ ): { readonly items: readonly T[]; readonly truncated: boolean } {
2060
+ if (items.length <= limit) return { items, truncated: false };
2061
+ if (!continuationAvailable) {
2062
+ throw new Error(`${label} returned more entries than the requested limit; no continuation cursor was exposed`);
2063
+ }
2064
+ return { items: items.slice(0, limit), truncated: true };
2065
+ }
2066
+
2067
+ export function projectXWebFeedPost(
2068
+ item: XWebNormalizedTimelineItem,
2069
+ ): XWebProjectedPost | null {
2070
+ if (item.kind !== "tweet") return null;
2071
+ const legacy = item.legacy;
2072
+ const createdAt = optionalBoundedText(legacy?.created_at, "X post createdAt", 128);
2073
+ const text = typeof legacy?.full_text === "string"
2074
+ ? requireBoundedPublicText(legacy.full_text, "X post text", 32_768)
2075
+ : "";
2076
+ return Object.freeze({
2077
+ kind: "post",
2078
+ id: item.tweetId,
2079
+ text,
2080
+ createdAt,
2081
+ authorId: item.author.id,
2082
+ authorUsername: item.author.username,
2083
+ authorName: item.author.name,
2084
+ replyToPostId: optionalTweetId(legacy?.in_reply_to_status_id_str, "X post replyToPostId"),
2085
+ liked: optionalBoolean(legacy?.favorited, "X post liked"),
2086
+ reposted: optionalBoolean(legacy?.retweeted, "X post reposted"),
2087
+ saved: optionalBoolean(legacy?.bookmarked, "X post saved"),
2088
+ metrics: Object.freeze({
2089
+ replies: optionalMetric(legacy?.reply_count, "X post reply_count"),
2090
+ reposts: optionalMetric(legacy?.retweet_count, "X post retweet_count"),
2091
+ likes: optionalMetric(legacy?.favorite_count, "X post favorite_count"),
2092
+ bookmarks: optionalMetric(legacy?.bookmark_count, "X post bookmark_count"),
2093
+ }),
2094
+ url: xWebStatusUrl(item.tweetId),
2095
+ });
2096
+ }
2097
+
2098
+ export function projectXWebBookmarkExportRecord(
2099
+ post: XWebProjectedPost,
2100
+ ): XWebBookmarkExportRecord {
2101
+ return Object.freeze({
2102
+ post_id: post.id,
2103
+ url: post.url,
2104
+ author_username: post.authorUsername,
2105
+ author_name: post.authorName,
2106
+ text: post.text,
2107
+ created_at: post.createdAt,
2108
+ folder_id: null,
2109
+ bookmarked_at: null,
2110
+ });
2111
+ }
2112
+
2113
+ export function projectXWebFeedPage(
2114
+ operationId: XWebSemanticOperationId,
2115
+ response: unknown,
2116
+ limit: number,
2117
+ ): XWebFeedPage {
2118
+ const normalized = normalizeXWebGraphQlTimelineResponse(operationId, response);
2119
+ const posts = normalized.items
2120
+ .map(projectXWebFeedPost)
2121
+ .filter((row): row is XWebProjectedPost => row !== null);
2122
+ const page = boundXWebProviderPage(
2123
+ posts,
2124
+ limit,
2125
+ normalized.cursors.bottom !== null,
2126
+ "X feed page",
2127
+ );
2128
+ return Object.freeze({
2129
+ posts: Object.freeze(page.items),
2130
+ cursor: page.truncated ? null : normalized.cursors.bottom?.value ?? null,
2131
+ terminatedDirections: normalized.terminatedDirections,
2132
+ });
2133
+ }
2134
+
2135
+ export function projectXWebBookmarkExportPage(
2136
+ response: unknown,
2137
+ limit: number,
2138
+ ): XWebBookmarkExportPage {
2139
+ const page = projectXWebFeedPage("feeds.bookmarks", response, limit);
2140
+ return Object.freeze({
2141
+ feed: "bookmarks",
2142
+ items: Object.freeze(page.posts.map(projectXWebBookmarkExportRecord)),
2143
+ posts: page.posts,
2144
+ cursor: page.cursor,
2145
+ terminatedDirections: page.terminatedDirections,
2146
+ });
2147
+ }
2148
+
2149
+ function parseXWebProjectedPost(value: unknown, label: string): XWebProjectedPost {
2150
+ const post = record(value, label);
2151
+ exactKeys(post, X_FEED_POST_KEYS, label);
2152
+ if (post.kind !== "post") throw new Error(`${label}.kind must be post`);
2153
+ const id = exactTweetId(post.id, `${label}.id`);
2154
+ const url = requiredString(post, "url", label);
2155
+ if (url !== xWebStatusUrl(id)) throw new Error(`${label}.url must be the status permalink for id`);
2156
+ const metrics = record(post.metrics, `${label}.metrics`);
2157
+ exactKeys(metrics, ["replies", "reposts", "likes", "bookmarks"], `${label}.metrics`);
2158
+ const text = requireBoundedPublicText(post.text, `${label}.text`, 32_768);
2159
+ return Object.freeze({
2160
+ kind: "post",
2161
+ id,
2162
+ text,
2163
+ createdAt: optionalBoundedText(post.createdAt, `${label}.createdAt`, 128),
2164
+ authorId: optionalTweetId(post.authorId, `${label}.authorId`),
2165
+ authorUsername: optionalAuthorHandle(post.authorUsername, `${label}.authorUsername`),
2166
+ authorName: optionalAuthorName(post.authorName, `${label}.authorName`),
2167
+ replyToPostId: optionalTweetId(post.replyToPostId, `${label}.replyToPostId`),
2168
+ liked: optionalBoolean(post.liked, `${label}.liked`),
2169
+ reposted: optionalBoolean(post.reposted, `${label}.reposted`),
2170
+ saved: optionalBoolean(post.saved, `${label}.saved`),
2171
+ metrics: Object.freeze({
2172
+ replies: optionalMetric(metrics.replies, `${label}.metrics.replies`),
2173
+ reposts: optionalMetric(metrics.reposts, `${label}.metrics.reposts`),
2174
+ likes: optionalMetric(metrics.likes, `${label}.metrics.likes`),
2175
+ bookmarks: optionalMetric(metrics.bookmarks, `${label}.metrics.bookmarks`),
2176
+ }),
2177
+ url,
2178
+ });
2179
+ }
2180
+
2181
+ export function parseXWebBookmarkExportRecord(value: unknown): XWebBookmarkExportRecord {
2182
+ const item = record(value, "X bookmark export record");
2183
+ exactKeys(item, X_BOOKMARK_EXPORT_RECORD_KEYS, "X bookmark export record");
2184
+ const postId = exactTweetId(item.post_id, "X bookmark export record.post_id");
2185
+ const url = requiredString(item, "url", "X bookmark export record");
2186
+ if (url !== xWebStatusUrl(postId)) {
2187
+ throw new Error("X bookmark export record.url must be the status permalink for post_id");
2188
+ }
2189
+ const text = requireBoundedPublicText(item.text, "X bookmark export record.text", 32_768);
2190
+ return Object.freeze({
2191
+ post_id: postId,
2192
+ url,
2193
+ author_username: optionalAuthorHandle(item.author_username, "X bookmark export record.author_username"),
2194
+ author_name: optionalAuthorName(item.author_name, "X bookmark export record.author_name"),
2195
+ text,
2196
+ created_at: optionalBoundedText(item.created_at, "X bookmark export record.created_at", 128),
2197
+ folder_id: optionalTweetId(item.folder_id, "X bookmark export record.folder_id"),
2198
+ bookmarked_at: optionalBoundedText(item.bookmarked_at, "X bookmark export record.bookmarked_at", 128),
2199
+ });
2200
+ }
2201
+
2202
+ export function parseXWebBookmarkExportPage(value: unknown): XWebBookmarkExportPage {
2203
+ const page = record(value, "X bookmark export page");
2204
+ exactKeys(page, X_BOOKMARK_EXPORT_PAGE_KEYS, "X bookmark export page");
2205
+ if (page.feed !== "bookmarks") throw new Error("X bookmark export page.feed must be bookmarks");
2206
+ if (!Array.isArray(page.items)) throw new Error("X bookmark export page.items must be an array");
2207
+ if (!Array.isArray(page.posts)) throw new Error("X bookmark export page.posts must be an array");
2208
+ if (page.items.length !== page.posts.length) {
2209
+ throw new Error("X bookmark export page items and posts must have the same length");
2210
+ }
2211
+ if (!Array.isArray(page.terminatedDirections)
2212
+ || !page.terminatedDirections.every((direction) => typeof direction === "string" && direction.length > 0 && direction.length <= 32)
2213
+ ) {
2214
+ throw new Error("X bookmark export page.terminatedDirections must be a string array");
2215
+ }
2216
+ const items = page.items.map((item) => parseXWebBookmarkExportRecord(item));
2217
+ const posts = page.posts.map((post, index) => parseXWebProjectedPost(post, `X bookmark export page.posts[${index}]`));
2218
+ for (const [index, item] of items.entries()) {
2219
+ const post = posts[index]!;
2220
+ if (item.post_id !== post.id || item.url !== post.url || item.text !== post.text) {
2221
+ throw new Error("X bookmark export page items did not bind the projected posts");
2222
+ }
2223
+ if (item.author_username !== post.authorUsername || item.author_name !== post.authorName) {
2224
+ throw new Error("X bookmark export page items did not bind the projected posts");
2225
+ }
2226
+ if (item.created_at !== post.createdAt) {
2227
+ throw new Error("X bookmark export page items did not bind the projected posts");
2228
+ }
2229
+ }
2230
+ const ids = items.map((item) => item.post_id);
2231
+ if (new Set(ids).size !== ids.length) {
2232
+ throw new Error("X bookmark export page contained duplicate post_id values");
2233
+ }
2234
+ return Object.freeze({
2235
+ feed: "bookmarks",
2236
+ items: Object.freeze(items),
2237
+ posts: Object.freeze(posts),
2238
+ cursor: optionalCursor(page.cursor, "X bookmark export page.cursor"),
2239
+ terminatedDirections: Object.freeze([...page.terminatedDirections]),
2240
+ });
2241
+ }
2242
+
2243
+ export type XWebDesiredStateKind = "like" | "bookmark" | "repost";
2244
+
2245
+ export type XWebDesiredStateValidation = {
2246
+ readonly kind: XWebDesiredStateKind;
2247
+ readonly enabled: boolean;
2248
+ readonly targetPostId: string;
2249
+ readonly providerResultId: string | null;
2250
+ readonly requiresReadback: boolean;
2251
+ };
2252
+
2253
+ function exactPostId(value: string): string {
2254
+ if (!/^[0-9]{1,19}$/u.test(value)) throw new Error("X desired-state target must be an exact post ID");
2255
+ return value;
2256
+ }
2257
+
2258
+ function graphQlData(response: unknown): JsonRecord {
2259
+ return responseData(response, "X mutation response");
2260
+ }
2261
+
2262
+ function optionalPostIdentity(result: JsonRecord): string | null {
2263
+ if (typeof result.rest_id === "string") return exactPostId(result.rest_id);
2264
+ if (isRecord(result.legacy) && typeof result.legacy.id_str === "string") return exactPostId(result.legacy.id_str);
2265
+ return null;
2266
+ }
2267
+
2268
+ function nestedRetweetTarget(result: JsonRecord): string | null {
2269
+ const candidates = [
2270
+ result.retweeted_status_result,
2271
+ isRecord(result.legacy) ? result.legacy.retweeted_status_result : undefined,
2272
+ ];
2273
+ for (const candidate of candidates) {
2274
+ if (!isRecord(candidate) || !isRecord(candidate.result)) continue;
2275
+ const identity = optionalPostIdentity(candidate.result);
2276
+ if (identity !== null) return identity;
2277
+ }
2278
+ return null;
2279
+ }
2280
+
2281
+ /** Validate exact X consumer-web desired-state success markers and identities. */
2282
+ export function validateXWebDesiredStateMutation(input: {
2283
+ readonly kind: XWebDesiredStateKind;
2284
+ readonly enabled: boolean;
2285
+ readonly targetPostId: string;
2286
+ readonly response: unknown;
2287
+ }): XWebDesiredStateValidation {
2288
+ const targetPostId = exactPostId(input.targetPostId);
2289
+ const data = graphQlData(input.response);
2290
+ if (input.kind === "like") {
2291
+ const key = input.enabled ? "favorite_tweet" : "unfavorite_tweet";
2292
+ if (data[key] !== "Done") throw new Error(`X ${input.enabled ? "like" : "unlike"} response omitted the exact Done marker`);
2293
+ return Object.freeze({ kind: input.kind, enabled: input.enabled, targetPostId, providerResultId: null, requiresReadback: true });
2294
+ }
2295
+ if (input.kind === "bookmark") {
2296
+ const key = input.enabled ? "tweet_bookmark_put" : "tweet_bookmark_delete";
2297
+ if (data[key] !== "Done") throw new Error(`X ${input.enabled ? "bookmark" : "unbookmark"} response omitted the exact Done marker`);
2298
+ return Object.freeze({ kind: input.kind, enabled: input.enabled, targetPostId, providerResultId: null, requiresReadback: true });
2299
+ }
2300
+ if (input.kind !== "repost") throw new Error("X desired-state kind is not reviewed");
2301
+ if (input.enabled) {
2302
+ const create = record(data.create_retweet, "X repost response.create_retweet");
2303
+ const results = record(create.retweet_results, "X repost response.retweet_results");
2304
+ const result = record(results.result, "X repost response.result");
2305
+ const resultId = optionalPostIdentity(result);
2306
+ if (resultId === null) throw new Error("X repost response omitted the created repost ID");
2307
+ const returnedTarget = nestedRetweetTarget(result);
2308
+ if (returnedTarget !== null && returnedTarget !== targetPostId) {
2309
+ throw new Error("X repost response targeted a different post");
2310
+ }
2311
+ return Object.freeze({
2312
+ kind: input.kind,
2313
+ enabled: true,
2314
+ targetPostId,
2315
+ providerResultId: resultId,
2316
+ requiresReadback: returnedTarget === null,
2317
+ });
2318
+ }
2319
+ const unretweet = record(data.unretweet, "X unrepost response.unretweet");
2320
+ const sourceResults = record(unretweet.source_tweet_results, "X unrepost response.source_tweet_results");
2321
+ const result = record(sourceResults.result, "X unrepost response.result");
2322
+ const returnedTarget = optionalPostIdentity(result);
2323
+ if (returnedTarget === null) throw new Error("X unrepost response omitted the source post ID");
2324
+ if (returnedTarget !== targetPostId) throw new Error("X unrepost response targeted a different post");
2325
+ return Object.freeze({
2326
+ kind: input.kind,
2327
+ enabled: false,
2328
+ targetPostId,
2329
+ providerResultId: null,
2330
+ requiresReadback: false,
2331
+ });
2332
+ }