@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,2388 @@
1
+ import { validateWebSessionAuthState } from "../web-session-read-errors";
2
+ import * as Effect from "effect/Effect";
3
+ import { runReadEffect } from "../read-effect-runtime";
4
+ import { LinkedInSelfPlatformLive } from "./linkedin-self-platform";
5
+ import { LinkedInCompanyPlatformLive } from "./linkedin-company-platform";
6
+ import { linkedInCompanyReadProgram } from "./linkedin-company-program";
7
+ import { LinkedInProfileActivityPlatformLive } from "./linkedin-profile-activity-platform";
8
+ import { linkedInProfileActivityReadProgram } from "./linkedin-profile-activity-program";
9
+ import { LinkedInContactPlatformLive } from "./linkedin-contact-platform";
10
+ import { linkedInContactReadProgram } from "./linkedin-contact-program";
11
+ import { linkedInSelfReadProgram } from "./linkedin-self-program";
12
+ import { linkedInContactInfoTarget } from "./linkedin-web-contact";
13
+ import { constants } from "node:fs";
14
+ import { open } from "node:fs/promises";
15
+
16
+ import {
17
+ filterCookies,
18
+ } from "@hraness/kb/clip/cookies";
19
+
20
+ import type { GhostgetAuth } from "../auth";
21
+ import {
22
+ browserCleanupBarrier,
23
+ type BrowserFileResolver,
24
+ } from "../browser";
25
+ import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model";
26
+ import { canonicalJson, sha256 } from "../canonical-json";
27
+ import {
28
+ parseArticleDraftDocument,
29
+ parseArticleDraftDocumentV2,
30
+ } from "../article-draft-document";
31
+ import {
32
+ materializeArticleDraftImage,
33
+ materializeArticleDraftImages,
34
+ } from "../article-draft-images";
35
+ import {
36
+ readSessionSecretSnapshot,
37
+ writeSessionSecretIfUnchanged,
38
+ type SessionSecretSnapshot,
39
+ type SessionSecretWriteResult,
40
+ } from "../session-secrets";
41
+ import {
42
+ OperationDeadline,
43
+ } from "../operation-deadline";
44
+ import {
45
+ createWebSessionClient,
46
+ webSessionAuthSubject,
47
+ webSessionCookie,
48
+ type WebSessionClient,
49
+ type WebSessionCookieRotationEntry,
50
+ type WebSessionCookieRotationState,
51
+ type WebSessionCookieRotationTombstone,
52
+ type WebSessionNetworkDependencies,
53
+ } from "../web-session-client";
54
+ import type {
55
+ WebSessionCleanupBarrierRegistrar,
56
+ WebSessionCleanupResourcePublisher,
57
+ WebSessionDispatchEvent,
58
+ WebSessionExecution,
59
+ WebSessionOperationDeadline,
60
+ WebSessionProviderAcceptedMutationTargetEvent,
61
+ } from "../web-session-execution";
62
+ import {
63
+ startWebSessionCleanupTrackedOperation,
64
+ } from "../web-session-execution";
65
+ import {
66
+ LINKEDIN_ARTICLE_PAGE_MAX_CHARACTERS,
67
+ LINKEDIN_FIRST_PARTY_ARTICLES_PATH,
68
+ LINKEDIN_MESSENGER_CONVERSATIONS_OBSERVED_QUERY_ID,
69
+ buildLinkedInArticleCoverPatch,
70
+ buildLinkedInArticleContentPatch,
71
+ buildLinkedInArticleContentPatchV2,
72
+ buildLinkedInArticleCreateBody,
73
+ buildLinkedInArticleTitlePatch,
74
+ buildLinkedInPostCreateVariables,
75
+ linkedInArticleDraftEditUrl,
76
+ linkedInArticleDraftEnvelopeFromHtml,
77
+ linkedInArticleDraftEntityUrl,
78
+ linkedInArticleDraftId,
79
+ linkedInCsrfTokenFromJSessionId,
80
+ linkedInMailboxUrnFromMiniProfile,
81
+ linkedInMessengerConversationsUrl,
82
+ linkedInPostAltText,
83
+ linkedInPostEntityUrn,
84
+ linkedInPostMediaUrn,
85
+ linkedInPostText,
86
+ linkedInPostVisibility,
87
+ linkedInOrganizationTarget,
88
+ linkedInPersonalProfilePublicIdentifier,
89
+ linkedInPersonalProfileTarget,
90
+ normalizeLinkedInPostProjection,
91
+ normalizeLinkedInArticleDraft,
92
+ normalizeLinkedInArticleDraftMetadata,
93
+ normalizeLinkedInArticleDraftSnapshot,
94
+ normalizeLinkedInArticleDraftV2,
95
+ normalizeLinkedInArticleDraftV2Metadata,
96
+ normalizeLinkedInMessagingList,
97
+ } from "./linkedin-web";
98
+ import { resolveLinkedInMessengerConversationsQueryId } from "./linkedin-web-bootstrap";
99
+ import {
100
+ createLinkedInArticleBrowserTransport,
101
+ type LinkedInArticleBrowserTransport,
102
+ } from "./linkedin-web-article-browser";
103
+ import {
104
+ createLinkedInPostBrowserTransport,
105
+ LinkedInPostCreateResponseError,
106
+ LinkedInPostImagePreparationError,
107
+ type LinkedInPostBrowserTransport,
108
+ } from "./linkedin-web-post-browser";
109
+ import {
110
+ createLinkedInProfileBrowserTransport,
111
+ LinkedInProfileBrowserFailure,
112
+ LinkedInProfileBrowserResponseRejectedError,
113
+ type LinkedInProfileBrowserTransport,
114
+ } from "./linkedin-web-profile-browser";
115
+ import {
116
+ LINKEDIN_PROFILE_ACTIVITY_MAX_ITEMS,
117
+ linkedInProfileActivityFeed,
118
+ linkedInProfileActivityTarget,
119
+ parseLinkedInProfileActivityCursor,
120
+ } from "./linkedin-web-feed";
121
+ import {
122
+ createLinkedInFeedBrowserTransport,
123
+ } from "./linkedin-web-feed-browser";
124
+
125
+ const LINKEDIN_ORIGIN = "https://www.linkedin.com";
126
+ const MAX_SUBJECT_BYTES = 2 * 1024 * 1024;
127
+ const LINKEDIN_COOKIE_ROTATION_NAMESPACE = "linkedin-cookie-rotation";
128
+ const LINKEDIN_ROTATING_COOKIE_NAMES = Object.freeze(["__cf_bm"] as const);
129
+ const LINKEDIN_ROTATING_COOKIE_MAX_CACHE_AGE_SECONDS = 24 * 60 * 60;
130
+ const LINKEDIN_ROTATING_COOKIE_TOMBSTONE_TTL_SECONDS = 60 * 60;
131
+ const MAX_LINKEDIN_ARTICLE_BLOCKS = 5_000;
132
+ const MAX_LINKEDIN_ARTICLE_CHARACTERS = 125_000;
133
+ const MAX_LINKEDIN_ARTICLE_INLINE_IMAGES = 20;
134
+ const MAX_LINKEDIN_ARTICLE_IMAGE_BYTES = 5 * 1024 * 1024;
135
+ const MAX_LINKEDIN_POST_IMAGE_BYTES = 20 * 1024 * 1024;
136
+
137
+ type JsonRecord = Record<string, unknown>;
138
+
139
+ export type LinkedInWebRuntimeDependencies = Partial<WebSessionNetworkDependencies> & {
140
+ readonly createArticleBrowserTransport?: typeof createLinkedInArticleBrowserTransport;
141
+ readonly createPostBrowserTransport?: typeof createLinkedInPostBrowserTransport;
142
+ readonly createProfileBrowserTransport?: typeof createLinkedInProfileBrowserTransport;
143
+ readonly createFeedBrowserTransport?: typeof createLinkedInFeedBrowserTransport;
144
+ readonly resolveMessengerConversationsQueryId?: typeof resolveLinkedInMessengerConversationsQueryId;
145
+ readonly now?: () => number;
146
+ /** Test seam for the auth-hash-bound encrypted LinkedIn rotation cache. */
147
+ readonly loadCachedCookies?: (
148
+ auth: GhostgetAuth,
149
+ authHash: string,
150
+ ) => SessionSecretSnapshot | Promise<SessionSecretSnapshot>;
151
+ /** Test seam for the auth-hash-bound encrypted LinkedIn rotation cache. */
152
+ readonly saveCachedCookies?: (
153
+ auth: GhostgetAuth,
154
+ authHash: string,
155
+ value: unknown,
156
+ expectedContentSha256: string | null,
157
+ ) => SessionSecretWriteResult | Promise<SessionSecretWriteResult>;
158
+ };
159
+
160
+ export type LinkedInWebExecutionOptions = {
161
+ readonly dependencies?: LinkedInWebRuntimeDependencies;
162
+ readonly fileResolver?: BrowserFileResolver;
163
+ readonly signal?: AbortSignal;
164
+ readonly operationDeadline?: WebSessionOperationDeadline;
165
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
166
+ readonly publishCleanupResource?: WebSessionCleanupResourcePublisher;
167
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
168
+ readonly afterProviderAcceptedMutationTarget?: (
169
+ event: WebSessionProviderAcceptedMutationTargetEvent,
170
+ ) => Promise<void>;
171
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
172
+ };
173
+
174
+ function isRecord(value: unknown): value is JsonRecord {
175
+ return typeof value === "object" && value !== null && !Array.isArray(value);
176
+ }
177
+
178
+ function record(value: unknown, label: string): JsonRecord {
179
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
180
+ return value;
181
+ }
182
+
183
+ function exactKeys(value: JsonRecord, keys: readonly string[], label: string): void {
184
+ if (Object.keys(value).sort().join(",") !== [...keys].sort().join(",")) {
185
+ throw new Error(`${label} has unsupported fields`);
186
+ }
187
+ }
188
+
189
+ const LINKEDIN_COOKIE_KEYS = Object.freeze([
190
+ "name",
191
+ "value",
192
+ "domain",
193
+ "hostOnly",
194
+ "path",
195
+ "secure",
196
+ "httpOnly",
197
+ "sameSite",
198
+ "expires",
199
+ ] as const);
200
+
201
+ function parseCachedLinkedInCookie(
202
+ value: unknown,
203
+ acceptedAtSeconds: number,
204
+ nowSeconds: number,
205
+ includesAcceptanceTime: boolean,
206
+ ): WebSessionCookieRotationEntry | null {
207
+ const cookie = record(value, "LinkedIn rotating-cookie cache entry");
208
+ const expectedKeys = includesAcceptanceTime
209
+ ? [...LINKEDIN_COOKIE_KEYS, "acceptedAtSeconds"]
210
+ : [...LINKEDIN_COOKIE_KEYS];
211
+ exactKeys(cookie, expectedKeys, "LinkedIn rotating-cookie cache entry");
212
+ if (
213
+ !LINKEDIN_ROTATING_COOKIE_NAMES.includes(cookie.name as (typeof LINKEDIN_ROTATING_COOKIE_NAMES)[number])
214
+ ) throw new Error("LinkedIn rotating-cookie cache contains an unreviewed cookie");
215
+ if (
216
+ typeof cookie.expires !== "number"
217
+ || !Number.isSafeInteger(cookie.expires)
218
+ || cookie.expires < 0
219
+ || cookie.expires > 253_402_300_799
220
+ ) throw new Error("LinkedIn rotating-cookie cache has an invalid expiry");
221
+ const expired = cookie.expires > 0 && cookie.expires <= nowSeconds;
222
+ const candidate = { ...cookie };
223
+ delete candidate.acceptedAtSeconds;
224
+ if (expired) candidate.expires = 0;
225
+ const validated = filterCookies([candidate], new URL(LINKEDIN_ORIGIN), nowSeconds);
226
+ if (validated.rejected !== 0 || validated.cookies.length !== 1) {
227
+ throw new Error("LinkedIn rotating-cookie cache is malformed");
228
+ }
229
+ const parsed = validated.cookies[0];
230
+ if (parsed === undefined) throw new Error("LinkedIn rotating-cookie cache is malformed");
231
+ // Schema one did not record when a session cookie was accepted. Preserve
232
+ // legacy values only when their own absolute expiry bounds their lifetime.
233
+ if (expired || (!includesAcceptanceTime && parsed.expires === 0)) return null;
234
+ return Object.freeze({ acceptedAtSeconds, cookie: parsed });
235
+ }
236
+
237
+ function parseCachedLinkedInCookies(value: unknown): WebSessionCookieRotationState {
238
+ if (value === null) {
239
+ return Object.freeze({
240
+ cookies: Object.freeze([]),
241
+ tombstones: Object.freeze([]),
242
+ });
243
+ }
244
+ const cache = record(value, "LinkedIn rotating-cookie cache");
245
+ if (cache.schemaVersion === 1) {
246
+ exactKeys(cache, ["schemaVersion", "origin", "cookies"], "LinkedIn rotating-cookie cache");
247
+ } else if (cache.schemaVersion === 2) {
248
+ exactKeys(
249
+ cache,
250
+ ["schemaVersion", "origin", "cookies", "tombstones"],
251
+ "LinkedIn rotating-cookie cache",
252
+ );
253
+ } else {
254
+ throw new Error("LinkedIn rotating-cookie cache is malformed");
255
+ }
256
+ if (
257
+ cache.origin !== LINKEDIN_ORIGIN
258
+ || !Array.isArray(cache.cookies)
259
+ || cache.cookies.length > 4
260
+ || (cache.schemaVersion === 2 && (!Array.isArray(cache.tombstones) || cache.tombstones.length > 4))
261
+ ) throw new Error("LinkedIn rotating-cookie cache is malformed");
262
+ const nowSeconds = Math.floor(Date.now() / 1_000);
263
+ const cookies: WebSessionCookieRotationEntry[] = [];
264
+ for (const item of cache.cookies) {
265
+ const raw = record(item, "LinkedIn rotating-cookie cache entry");
266
+ const acceptedAtSeconds = cache.schemaVersion === 1 ? nowSeconds : raw.acceptedAtSeconds;
267
+ if (
268
+ !Number.isSafeInteger(acceptedAtSeconds)
269
+ || (acceptedAtSeconds as number) < 0
270
+ || (acceptedAtSeconds as number) > nowSeconds + 300
271
+ ) throw new Error("LinkedIn rotating-cookie cache has an invalid acceptance time");
272
+ const parsed = parseCachedLinkedInCookie(
273
+ item,
274
+ acceptedAtSeconds as number,
275
+ nowSeconds,
276
+ cache.schemaVersion === 2,
277
+ );
278
+ if (parsed !== null) cookies.push(parsed);
279
+ }
280
+ const tombstones: WebSessionCookieRotationTombstone[] = [];
281
+ if (cache.schemaVersion === 2) {
282
+ for (const item of cache.tombstones as unknown[]) {
283
+ const tombstone = record(item, "LinkedIn rotating-cookie cache tombstone");
284
+ exactKeys(
285
+ tombstone,
286
+ ["acceptedAtSeconds", "name", "domain", "hostOnly", "path"],
287
+ "LinkedIn rotating-cookie cache tombstone",
288
+ );
289
+ if (
290
+ !Number.isSafeInteger(tombstone.acceptedAtSeconds)
291
+ || (tombstone.acceptedAtSeconds as number) < 0
292
+ || (tombstone.acceptedAtSeconds as number) > nowSeconds + 300
293
+ || !LINKEDIN_ROTATING_COOKIE_NAMES.includes(
294
+ tombstone.name as (typeof LINKEDIN_ROTATING_COOKIE_NAMES)[number],
295
+ )
296
+ || typeof tombstone.domain !== "string"
297
+ || typeof tombstone.hostOnly !== "boolean"
298
+ || typeof tombstone.path !== "string"
299
+ ) throw new Error("LinkedIn rotating-cookie cache tombstone is malformed");
300
+ tombstones.push(Object.freeze({
301
+ acceptedAtSeconds: tombstone.acceptedAtSeconds as number,
302
+ domain: tombstone.domain,
303
+ hostOnly: tombstone.hostOnly,
304
+ name: tombstone.name as string,
305
+ path: tombstone.path,
306
+ }));
307
+ }
308
+ }
309
+ return Object.freeze({
310
+ cookies: Object.freeze(cookies),
311
+ tombstones: Object.freeze(tombstones),
312
+ });
313
+ }
314
+
315
+ type LinkedInRotationCandidate =
316
+ | {
317
+ readonly kind: "cookie";
318
+ readonly acceptedAtSeconds: number;
319
+ readonly entry: WebSessionCookieRotationEntry;
320
+ }
321
+ | {
322
+ readonly kind: "tombstone";
323
+ readonly acceptedAtSeconds: number;
324
+ readonly entry: WebSessionCookieRotationTombstone;
325
+ };
326
+
327
+ function linkedInRotationIdentity(
328
+ value: StrictCookieIdentity,
329
+ ): string {
330
+ return `${value.domain}\0${value.hostOnly ? "host" : "domain"}\0${value.path}\0${value.name}`;
331
+ }
332
+
333
+ type StrictCookieIdentity = {
334
+ readonly domain: string;
335
+ readonly hostOnly: boolean;
336
+ readonly name: string;
337
+ readonly path: string;
338
+ };
339
+
340
+ function linkedInRotationCandidates(
341
+ state: WebSessionCookieRotationState,
342
+ ): ReadonlyMap<string, LinkedInRotationCandidate> {
343
+ const candidates = new Map<string, LinkedInRotationCandidate>();
344
+ for (const entry of state.cookies) {
345
+ const identity = linkedInRotationIdentity(entry.cookie);
346
+ if (candidates.has(identity)) {
347
+ throw new Error("LinkedIn rotating-cookie cache contains a duplicate");
348
+ }
349
+ candidates.set(identity, Object.freeze({
350
+ kind: "cookie",
351
+ acceptedAtSeconds: entry.acceptedAtSeconds,
352
+ entry,
353
+ }));
354
+ }
355
+ for (const entry of state.tombstones) {
356
+ const identity = linkedInRotationIdentity(entry);
357
+ if (candidates.has(identity)) {
358
+ throw new Error("LinkedIn rotating-cookie cache contains a duplicate");
359
+ }
360
+ candidates.set(identity, Object.freeze({
361
+ kind: "tombstone",
362
+ acceptedAtSeconds: entry.acceptedAtSeconds,
363
+ entry,
364
+ }));
365
+ }
366
+ return candidates;
367
+ }
368
+
369
+ function sameLinkedInRotationCandidate(
370
+ left: LinkedInRotationCandidate,
371
+ right: LinkedInRotationCandidate,
372
+ ): boolean {
373
+ return left.kind === right.kind
374
+ && canonicalJson(left.entry) === canonicalJson(right.entry);
375
+ }
376
+
377
+ function mergeLinkedInRotationStates(
378
+ attempted: WebSessionCookieRotationState,
379
+ latest: WebSessionCookieRotationState,
380
+ ): WebSessionCookieRotationState {
381
+ const merged = new Map(linkedInRotationCandidates(latest));
382
+ for (const [identity, candidate] of linkedInRotationCandidates(attempted)) {
383
+ const current = merged.get(identity);
384
+ if (
385
+ current === undefined
386
+ || candidate.acceptedAtSeconds > current.acceptedAtSeconds
387
+ ) {
388
+ merged.set(identity, candidate);
389
+ continue;
390
+ }
391
+ if (candidate.acceptedAtSeconds < current.acceptedAtSeconds) continue;
392
+ if (!sameLinkedInRotationCandidate(candidate, current)) {
393
+ throw new Error(
394
+ "LinkedIn rotating session state changed concurrently without a safe ordering",
395
+ );
396
+ }
397
+ }
398
+ const cookies: WebSessionCookieRotationEntry[] = [];
399
+ const tombstones: WebSessionCookieRotationTombstone[] = [];
400
+ for (const [, candidate] of [...merged].sort(([left], [right]) =>
401
+ left.localeCompare(right))) {
402
+ if (candidate.kind === "cookie") cookies.push(candidate.entry);
403
+ else tombstones.push(candidate.entry);
404
+ }
405
+ if (cookies.length > 4 || tombstones.length > 4) {
406
+ throw new Error(
407
+ "LinkedIn rotating session state changed concurrently beyond its reviewed bounds",
408
+ );
409
+ }
410
+ return Object.freeze({
411
+ cookies: Object.freeze(cookies),
412
+ tombstones: Object.freeze(tombstones),
413
+ });
414
+ }
415
+
416
+ function cachedLinkedInCookiesValue(
417
+ state: WebSessionCookieRotationState,
418
+ ): Readonly<Record<string, unknown>> {
419
+ return Object.freeze({
420
+ schemaVersion: 2,
421
+ origin: LINKEDIN_ORIGIN,
422
+ cookies: Object.freeze(state.cookies.map(({ acceptedAtSeconds, cookie }) =>
423
+ Object.freeze({ ...cookie, acceptedAtSeconds }))),
424
+ tombstones: Object.freeze(state.tombstones.map((tombstone) =>
425
+ Object.freeze({ ...tombstone }))),
426
+ });
427
+ }
428
+
429
+ function linkedInCacheHasOrderedProvenance(
430
+ value: unknown,
431
+ contentSha256: string | null,
432
+ ): boolean {
433
+ if (value === null) return contentSha256 === null;
434
+ return isRecord(value) && value.schemaVersion === 2;
435
+ }
436
+
437
+ async function loadLinkedInCookieSnapshot(
438
+ auth: GhostgetAuth,
439
+ authHash: string,
440
+ dependencies: LinkedInWebRuntimeDependencies | undefined,
441
+ ): Promise<SessionSecretSnapshot> {
442
+ if (
443
+ (dependencies?.loadCachedCookies === undefined)
444
+ !== (dependencies?.saveCachedCookies === undefined)
445
+ ) {
446
+ throw new Error(
447
+ "LinkedIn rotating-session cache dependencies must be provided together",
448
+ );
449
+ }
450
+ return dependencies?.loadCachedCookies === undefined
451
+ ? readSessionSecretSnapshot(
452
+ LINKEDIN_COOKIE_ROTATION_NAMESPACE,
453
+ auth.id,
454
+ authHash,
455
+ )
456
+ : dependencies.loadCachedCookies(auth, authHash);
457
+ }
458
+
459
+ async function saveLinkedInCookieSnapshot(
460
+ auth: GhostgetAuth,
461
+ authHash: string,
462
+ value: unknown,
463
+ expectedContentSha256: string | null,
464
+ dependencies: LinkedInWebRuntimeDependencies | undefined,
465
+ ): Promise<SessionSecretWriteResult> {
466
+ return dependencies?.saveCachedCookies === undefined
467
+ ? writeSessionSecretIfUnchanged(
468
+ LINKEDIN_COOKIE_ROTATION_NAMESPACE,
469
+ auth.id,
470
+ authHash,
471
+ value,
472
+ expectedContentSha256,
473
+ )
474
+ : dependencies.saveCachedCookies(
475
+ auth,
476
+ authHash,
477
+ value,
478
+ expectedContentSha256,
479
+ );
480
+ }
481
+
482
+ async function createLinkedInClient(
483
+ auth: GhostgetAuth,
484
+ timeoutMs: number,
485
+ dependencies: LinkedInWebRuntimeDependencies | undefined,
486
+ budget: {
487
+ readonly signal?: AbortSignal;
488
+ readonly operationDeadline?: WebSessionOperationDeadline;
489
+ } = {},
490
+ ): Promise<WebSessionClient> {
491
+ const authHash = sha256(canonicalJson(auth));
492
+ const initialSnapshot = await loadLinkedInCookieSnapshot(
493
+ auth,
494
+ authHash,
495
+ dependencies,
496
+ );
497
+ let expectedContentSha256 = initialSnapshot.contentSha256;
498
+ let cacheHasOrderedProvenance = linkedInCacheHasOrderedProvenance(
499
+ initialSnapshot.value,
500
+ initialSnapshot.contentSha256,
501
+ );
502
+ const cachedState = validateWebSessionAuthState(() => parseCachedLinkedInCookies(initialSnapshot.value));
503
+ return createWebSessionClient(LINKEDIN_ORIGIN, auth, {
504
+ timeoutMs,
505
+ ...(budget.signal === undefined ? {} : { signal: budget.signal }),
506
+ ...(budget.operationDeadline === undefined
507
+ ? {}
508
+ : { operationDeadline: budget.operationDeadline }),
509
+ ...(dependencies === undefined ? {} : { dependencies }),
510
+ cookieRotation: {
511
+ allowedNames: LINKEDIN_ROTATING_COOKIE_NAMES,
512
+ cachedState,
513
+ maxCachedCookieAgeSeconds: LINKEDIN_ROTATING_COOKIE_MAX_CACHE_AGE_SECONDS,
514
+ tombstoneTtlSeconds: LINKEDIN_ROTATING_COOKIE_TOMBSTONE_TTL_SECONDS,
515
+ save: async (state) => {
516
+ const value = validateWebSessionAuthState(() => cachedLinkedInCookiesValue(state));
517
+ const saved = await saveLinkedInCookieSnapshot(
518
+ auth,
519
+ authHash,
520
+ value,
521
+ expectedContentSha256,
522
+ dependencies,
523
+ );
524
+ if (saved.written) {
525
+ expectedContentSha256 = saved.contentSha256;
526
+ cacheHasOrderedProvenance = true;
527
+ return;
528
+ }
529
+ const latestSnapshot = await loadLinkedInCookieSnapshot(
530
+ auth,
531
+ authHash,
532
+ dependencies,
533
+ );
534
+ if (
535
+ latestSnapshot.contentSha256 === null
536
+ || latestSnapshot.contentSha256 === expectedContentSha256
537
+ ) {
538
+ throw new Error(
539
+ "LinkedIn rotating session state changed concurrently; retry with a fresh session",
540
+ );
541
+ }
542
+ if (
543
+ !cacheHasOrderedProvenance
544
+ || !linkedInCacheHasOrderedProvenance(
545
+ latestSnapshot.value,
546
+ latestSnapshot.contentSha256,
547
+ )
548
+ ) {
549
+ throw new Error(
550
+ "LinkedIn rotating session state changed concurrently without ordered provenance",
551
+ );
552
+ }
553
+ const latest = validateWebSessionAuthState(() => parseCachedLinkedInCookies(latestSnapshot.value));
554
+ const merged = validateWebSessionAuthState(() => mergeLinkedInRotationStates(state, latest));
555
+ if (canonicalJson(merged) !== canonicalJson(latest)) {
556
+ const reconciled = await saveLinkedInCookieSnapshot(
557
+ auth,
558
+ authHash,
559
+ cachedLinkedInCookiesValue(merged),
560
+ latestSnapshot.contentSha256,
561
+ dependencies,
562
+ );
563
+ if (!reconciled.written) {
564
+ throw new Error(
565
+ "LinkedIn rotating session state changed repeatedly; retry with a fresh session",
566
+ );
567
+ }
568
+ }
569
+ throw new Error(
570
+ "LinkedIn rotating session state was reconciled concurrently; retry with a fresh session",
571
+ );
572
+ },
573
+ },
574
+ });
575
+ }
576
+
577
+ function responseErrors(value: JsonRecord, label: string): void {
578
+ if (value.serviceErrorCode !== undefined) throw new Error(`${label} contained a service error`);
579
+ if (value.errors !== undefined) {
580
+ if (!Array.isArray(value.errors) || value.errors.length > 0) throw new Error(`${label} contained provider errors`);
581
+ }
582
+ if (typeof value.status === "number" && value.status >= 400) throw new Error(`${label} contained a failure status`);
583
+ }
584
+
585
+ function headers(csrf: string, referer: string): Readonly<Record<string, string>> {
586
+ return {
587
+ accept: "application/vnd.linkedin.normalized+json+2.1",
588
+ "csrf-token": csrf,
589
+ referer,
590
+ "x-li-lang": "en_US",
591
+ "x-requested-with": "XMLHttpRequest",
592
+ "x-restli-protocol-version": "2.0.0",
593
+ };
594
+ }
595
+
596
+ function linkedInMemberIdFromUrn(value: unknown): string | null {
597
+ if (typeof value !== "string") return null;
598
+ return /^urn:li:(?:fsd_profile|member):([0-9]{1,32})$/u.exec(value)?.[1] ?? null;
599
+ }
600
+
601
+ function linkedInMiniProfileUrn(value: unknown): string | null {
602
+ if (typeof value !== "string" || value.length > 512) return null;
603
+ return /^urn:li:fs_miniProfile:[A-Za-z0-9_-]{1,256}$/u.test(value) ? value : null;
604
+ }
605
+
606
+ type LinkedInCurrentIdentity = {
607
+ readonly subject: string;
608
+ readonly mailboxUrn: string | null;
609
+ readonly profileUrn: string | null;
610
+ readonly publicIdentifier: string | null;
611
+ };
612
+
613
+ function optionalLinkedInPublicIdentifier(value: unknown): string | null {
614
+ return value === undefined || value === null
615
+ ? null
616
+ : linkedInPersonalProfilePublicIdentifier(value);
617
+ }
618
+
619
+ function identityFromMeResponse(value: unknown): LinkedInCurrentIdentity {
620
+ const envelope = record(value, "LinkedIn /voyager/api/me response");
621
+ responseErrors(envelope, "LinkedIn /voyager/api/me response");
622
+ const data = record(envelope.data, "LinkedIn /voyager/api/me response.data");
623
+ const primaryId = typeof data.plainId === "string" && /^[0-9]{1,32}$/u.test(data.plainId)
624
+ ? data.plainId
625
+ : Number.isSafeInteger(data.plainId) && (data.plainId as number) > 0
626
+ ? String(data.plainId)
627
+ : null;
628
+ if (primaryId === null) {
629
+ throw new Error("LinkedIn /voyager/api/me omitted its exact primary member subject");
630
+ }
631
+ const included = envelope.included === undefined ? [] : envelope.included;
632
+ if (!Array.isArray(included) || included.length > 10_000) {
633
+ throw new Error("LinkedIn /voyager/api/me response.included must be a bounded array");
634
+ }
635
+ const entities: JsonRecord[] = [];
636
+ for (const item of included) {
637
+ if (!isRecord(item)) throw new Error("LinkedIn /voyager/api/me included an invalid entity");
638
+ entities.push(item);
639
+ }
640
+
641
+ const profileReferences = [data["*miniProfile"], data.miniProfile]
642
+ .filter((reference) => reference !== undefined);
643
+ if (profileReferences.length > 1) {
644
+ throw new Error("LinkedIn /voyager/api/me included ambiguous normalized profile references");
645
+ }
646
+ if (profileReferences.length === 1) {
647
+ const reference = linkedInMiniProfileUrn(profileReferences[0]);
648
+ if (reference === null) {
649
+ throw new Error("LinkedIn /voyager/api/me included an invalid normalized profile reference");
650
+ }
651
+ const referenced = entities.filter((item) => item.entityUrn === reference || item.urn === reference);
652
+ if (referenced.length === 0) {
653
+ throw new Error("LinkedIn /voyager/api/me did not corroborate its normalized profile reference");
654
+ }
655
+ if (referenced.length !== 1) {
656
+ throw new Error("LinkedIn /voyager/api/me included an ambiguous normalized profile reference");
657
+ }
658
+ const memberId = typeof referenced[0]?.objectUrn === "string"
659
+ ? /^urn:li:member:([0-9]{1,32})$/u.exec(referenced[0].objectUrn)?.[1] ?? null
660
+ : null;
661
+ if (memberId === null) {
662
+ throw new Error("LinkedIn /voyager/api/me did not bind its normalized profile to one member subject");
663
+ }
664
+ if (memberId !== primaryId) {
665
+ throw new Error("LinkedIn /voyager/api/me included a conflicting member subject");
666
+ }
667
+ return Object.freeze({
668
+ subject: `urn:li:fsd_profile:${primaryId}`,
669
+ mailboxUrn: linkedInMailboxUrnFromMiniProfile(reference),
670
+ profileUrn: linkedInMailboxUrnFromMiniProfile(reference),
671
+ publicIdentifier: optionalLinkedInPublicIdentifier(
672
+ referenced[0]?.publicIdentifier,
673
+ ),
674
+ });
675
+ }
676
+
677
+ const corroboratedEntities = entities.filter((item) =>
678
+ ["entityUrn", "backendUrn", "objectUrn", "urn"]
679
+ .some((field) => linkedInMemberIdFromUrn(item[field]) === primaryId));
680
+ if (corroboratedEntities.length === 0) {
681
+ throw new Error("LinkedIn /voyager/api/me did not corroborate its primary member subject");
682
+ }
683
+ const publicIdentifiers = new Set(
684
+ corroboratedEntities.flatMap((item) => {
685
+ const publicIdentifier = optionalLinkedInPublicIdentifier(
686
+ item.publicIdentifier,
687
+ );
688
+ return publicIdentifier === null ? [] : [publicIdentifier];
689
+ }),
690
+ );
691
+ if (publicIdentifiers.size > 1) {
692
+ throw new Error(
693
+ "LinkedIn /voyager/api/me included conflicting public profile identifiers for its primary member subject",
694
+ );
695
+ }
696
+ return Object.freeze({
697
+ subject: `urn:li:fsd_profile:${primaryId}`,
698
+ mailboxUrn: null,
699
+ profileUrn: null,
700
+ publicIdentifier: publicIdentifiers.values().next().value ?? null,
701
+ });
702
+ }
703
+
704
+ async function currentIdentity(
705
+ client: WebSessionClient,
706
+ csrf: string,
707
+ ): Promise<LinkedInCurrentIdentity> {
708
+ const response = await client.requestJson({
709
+ url: new URL("/voyager/api/me", LINKEDIN_ORIGIN),
710
+ method: "GET",
711
+ headers: headers(csrf, `${LINKEDIN_ORIGIN}/feed/`),
712
+ expectedContentTypes: ["application/vnd.linkedin.normalized+json+2.1", "application/json"],
713
+ maxBytes: MAX_SUBJECT_BYTES,
714
+ });
715
+ return identityFromMeResponse(response);
716
+ }
717
+
718
+ export async function probeLinkedInWebSubject(
719
+ auth: GhostgetAuth,
720
+ options: {
721
+ readonly timeoutMs?: number;
722
+ readonly dependencies?: LinkedInWebRuntimeDependencies;
723
+ readonly signal?: AbortSignal;
724
+ } = {},
725
+ ): Promise<string> {
726
+ if (auth.kind === "browser-profile") {
727
+ const timeoutMs = options.timeoutMs ?? 60_000;
728
+ const deadline = new OperationDeadline(timeoutMs, {
729
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
730
+ });
731
+ const createTransport = options.dependencies?.createProfileBrowserTransport
732
+ ?? createLinkedInProfileBrowserTransport;
733
+ let transport: LinkedInProfileBrowserTransport | null = null;
734
+ try {
735
+ deadline.throwIfUnavailable("authenticated web subject probe");
736
+ transport = await deadline.run(
737
+ () => createTransport(auth, {
738
+ timeoutMs,
739
+ maxOutputBytes: MAX_SUBJECT_BYTES,
740
+ operationDeadline: deadline,
741
+ }),
742
+ "authenticated web subject probe",
743
+ );
744
+ return identityFromMeResponse(
745
+ await deadline.run(
746
+ () => transport!.currentIdentityResponse(),
747
+ "authenticated web subject probe",
748
+ ),
749
+ ).subject;
750
+ } finally {
751
+ try {
752
+ await transport?.close();
753
+ } finally {
754
+ deadline.dispose();
755
+ }
756
+ }
757
+ }
758
+ const client = await createLinkedInClient(
759
+ auth,
760
+ options.timeoutMs ?? 60_000,
761
+ options.dependencies,
762
+ {
763
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
764
+ },
765
+ );
766
+ const csrf = linkedInCsrfTokenFromJSessionId(webSessionCookie(client.cookies, "JSESSIONID"));
767
+ return (await currentIdentity(client, csrf)).subject;
768
+ }
769
+
770
+ function boundLinkedInStatsIdentity(
771
+ auth: GhostgetAuth,
772
+ identity: Pick<LinkedInCurrentIdentity, "subject">,
773
+ ): string {
774
+ const expected = webSessionAuthSubject(auth);
775
+ if (expected === null || expected !== identity.subject) {
776
+ throw new Error("LinkedIn current member no longer matches the bound auth subject");
777
+ }
778
+ return expected;
779
+ }
780
+
781
+ function linkedInHtmlHeaders(referer: string): Readonly<Record<string, string>> {
782
+ return Object.freeze({
783
+ accept: "text/html",
784
+ "accept-language": "en-US,en;q=0.9",
785
+ referer,
786
+ });
787
+ }
788
+
789
+ function linkedInProfileStatsFailure(
790
+ error: unknown,
791
+ target: "personal" | "organization",
792
+ requestStage: string,
793
+ ): string {
794
+ const message = error instanceof Error ? error.message : "";
795
+ const browserFailureStage = error instanceof LinkedInProfileBrowserFailure
796
+ ? {
797
+ authwall: "signed-out authwall",
798
+ "body-envelope": "bounded response body envelope",
799
+ bootstrap: "contained-browser origin bootstrap",
800
+ "browser-envelope": "contained-browser command envelope",
801
+ "browser-command": "contained-browser command",
802
+ "execution-context": "contained-browser execution context",
803
+ "identity-json": "signed-in identity JSON",
804
+ "output-bound": "contained-browser output bound",
805
+ "provider-fetch": "contained-browser first-party fetch",
806
+ "response-envelope": "bounded response envelope",
807
+ "response-rejected": "first-party page response",
808
+ "session-cookie": "signed-in browser CSRF cookie",
809
+ startup: "contained-browser startup",
810
+ }[error.category]
811
+ : null;
812
+ const typedStage = error instanceof LinkedInProfileBrowserResponseRejectedError
813
+ ? `first-party page response ${error.status}/${error.contentType}`
814
+ : browserFailureStage;
815
+ const stage = typedStage ?? (message.includes("current member")
816
+ || message.includes("cookie")
817
+ || message.includes("session")
818
+ ? "signed-in account binding"
819
+ : message.includes("byte") || message.includes("size")
820
+ ? "bounded page read"
821
+ : message.includes("follower")
822
+ ? "exact follower projection"
823
+ : message.includes("connection")
824
+ ? "exact connection projection"
825
+ : message.includes("Company")
826
+ || message.includes("FollowingState")
827
+ || message.includes("universal name")
828
+ ? "target company-state projection"
829
+ : message.includes("status") || message.includes("content type")
830
+ ? "first-party page response"
831
+ : "reviewed response projection");
832
+ return `LinkedIn ${target} profile stats failed during ${requestStage} at ${stage}; no remote write occurred`;
833
+ }
834
+
835
+ async function createLinkedInStatsBrowserTransport(
836
+ auth: GhostgetAuth,
837
+ recipe: WebSessionRecipe,
838
+ options: LinkedInWebExecutionOptions,
839
+ ): Promise<LinkedInProfileBrowserTransport> {
840
+ const createTransport = options.dependencies?.createProfileBrowserTransport
841
+ ?? createLinkedInProfileBrowserTransport;
842
+ return createTransport(auth, {
843
+ timeoutMs: recipe.timeoutMs,
844
+ maxOutputBytes: recipe.maxOutputBytes,
845
+ ...(options.operationDeadline === undefined
846
+ ? {}
847
+ : { operationDeadline: options.operationDeadline }),
848
+ ...(options.publishCleanupResource === undefined
849
+ ? {}
850
+ : { publishCleanupResource: options.publishCleanupResource }),
851
+ });
852
+ }
853
+
854
+ async function executeLinkedInContactInfoRead(
855
+ recipe: WebSessionRecipe,
856
+ input: OperationInput,
857
+ auth: GhostgetAuth,
858
+ options: LinkedInWebExecutionOptions,
859
+ ): Promise<WebSessionExecution> {
860
+ const target = linkedInContactInfoTarget(input.profile_url);
861
+ return runReadEffect(linkedInContactReadProgram(target).pipe(
862
+ Effect.provide(LinkedInContactPlatformLive({
863
+ openBrowser: () => createLinkedInStatsBrowserTransport(auth, recipe, options),
864
+ decodeIdentity: identityFromMeResponse,
865
+ bindIdentity: identity => boundLinkedInStatsIdentity(auth, identity),
866
+ observedAt: () => new Date(options.dependencies?.now?.() ?? Date.now()).toISOString(),
867
+ })),
868
+ ));
869
+ }
870
+
871
+ async function executeLinkedInPersonalProfileRead(
872
+ recipe: WebSessionRecipe,
873
+ input: OperationInput,
874
+ auth: GhostgetAuth,
875
+ options: LinkedInWebExecutionOptions,
876
+ ): Promise<WebSessionExecution> {
877
+ const target = linkedInPersonalProfileTarget(input.profile_url);
878
+ const includeConnections = input.include_connections ?? false;
879
+ if (typeof includeConnections !== "boolean") {
880
+ throw new Error("input.include_connections must be boolean");
881
+ }
882
+ return runReadEffect(linkedInSelfReadProgram(target, includeConnections, auth.kind === "browser-profile", (error, stage) => linkedInProfileStatsFailure(error, "personal", stage)).pipe(
883
+ Effect.provide(LinkedInSelfPlatformLive({
884
+ openBrowser: () => createLinkedInStatsBrowserTransport(auth, recipe, options),
885
+ openDirect: () => createLinkedInClient(auth, recipe.timeoutMs, options.dependencies, options),
886
+ directIdentity: client => currentIdentity(client, validateWebSessionAuthState(() => linkedInCsrfTokenFromJSessionId(webSessionCookie(client.cookies, "JSESSIONID")))),
887
+ decodeIdentity: identityFromMeResponse,
888
+ bindIdentity: identity => boundLinkedInStatsIdentity(auth, identity),
889
+ readProfile: (client, url) => client.requestText({ url: new URL(url), method: "GET", headers: linkedInHtmlHeaders(`${LINKEDIN_ORIGIN}/feed/`), expectedContentTypes: ["text/html"], maxBytes: recipe.maxOutputBytes }),
890
+ readConnections: (client, url) => client.requestText({ url: new URL(`${LINKEDIN_ORIGIN}/mynetwork/invite-connect/connections/`), method: "GET", headers: linkedInHtmlHeaders(url), expectedContentTypes: ["text/html"], maxBytes: recipe.maxOutputBytes }),
891
+ observedAt: () => new Date(options.dependencies?.now?.() ?? Date.now()).toISOString(),
892
+ })),
893
+ ));
894
+ }
895
+
896
+ async function executeLinkedInOrganizationRead(
897
+ recipe: WebSessionRecipe,
898
+ input: OperationInput,
899
+ auth: GhostgetAuth,
900
+ options: LinkedInWebExecutionOptions,
901
+ ): Promise<WebSessionExecution> {
902
+ const target = linkedInOrganizationTarget(input.organization_url);
903
+ return runReadEffect(linkedInCompanyReadProgram(
904
+ target,
905
+ auth.kind === "browser-profile",
906
+ (error, stage) => linkedInProfileStatsFailure(error, "organization", stage),
907
+ ).pipe(Effect.provide(LinkedInCompanyPlatformLive({
908
+ openBrowser: () => createLinkedInStatsBrowserTransport(auth, recipe, options),
909
+ openDirect: () => createLinkedInClient(auth, recipe.timeoutMs, options.dependencies, options),
910
+ directIdentity: client => currentIdentity(client, validateWebSessionAuthState(() => linkedInCsrfTokenFromJSessionId(webSessionCookie(client.cookies, "JSESSIONID")))),
911
+ decodeIdentity: identityFromMeResponse,
912
+ bindIdentity: identity => boundLinkedInStatsIdentity(auth, identity),
913
+ readCompany: (client, url) => client.requestText({
914
+ url: new URL(url),
915
+ method: "GET",
916
+ headers: linkedInHtmlHeaders(`${LINKEDIN_ORIGIN}/feed/`),
917
+ expectedContentTypes: ["text/html"],
918
+ maxBytes: recipe.maxOutputBytes,
919
+ }),
920
+ observedAt: () => new Date(options.dependencies?.now?.() ?? Date.now()).toISOString(),
921
+ }))));
922
+ }
923
+
924
+ function integerInput(
925
+ input: OperationInput,
926
+ name: string,
927
+ fallback: number,
928
+ minimum: number,
929
+ maximum: number,
930
+ ): number {
931
+ const value = input[name] ?? fallback;
932
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
933
+ throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
934
+ }
935
+ return value as number;
936
+ }
937
+
938
+ function linkedInReadFailure(error: unknown): string {
939
+ const message = error instanceof Error ? error.message : "";
940
+ if (
941
+ message.includes("cookie")
942
+ || message.includes("session")
943
+ || message.includes("current member")
944
+ || message.includes("primary member subject")
945
+ || /status\/content type (?:302|401|403)\//u.test(message)
946
+ ) {
947
+ return "LinkedIn signed-in session or account binding failed preflight; refresh the selected browser realm and bind it again";
948
+ }
949
+ if (
950
+ message.includes("registered revision")
951
+ || message.includes("query failed")
952
+ || message.includes("query revision")
953
+ ) {
954
+ return "LinkedIn inbox query revision drifted; capture and review the current first-party contract before retrying";
955
+ }
956
+ if (
957
+ message.includes("mailbox")
958
+ || message.includes("normalized profile")
959
+ || message.includes("/voyager/api/me")
960
+ ) {
961
+ return "LinkedIn current-account projection drifted before the inbox read; capture and review the new identity binding";
962
+ }
963
+ return "LinkedIn inbox read failed before any remote write; no conversation was opened or acknowledged";
964
+ }
965
+
966
+ function assertLinkedInWebExecutionAvailable(): void {
967
+ throw new Error(
968
+ "LinkedIn authenticated web operations are capture-required; recapture and review the current first-party contract before execution",
969
+ );
970
+ }
971
+
972
+ function linkedInArticleHeaders(
973
+ csrf: string,
974
+ referer: string,
975
+ ): Readonly<Record<string, string>> {
976
+ return Object.freeze({
977
+ ...headers(csrf, referer),
978
+ "content-type": "application/json; charset=UTF-8",
979
+ });
980
+ }
981
+
982
+ async function createDirectLinkedInArticleTransport(
983
+ auth: GhostgetAuth,
984
+ timeoutMs: number,
985
+ options: LinkedInWebExecutionOptions,
986
+ ): Promise<LinkedInArticleBrowserTransport> {
987
+ const client = await createLinkedInClient(
988
+ auth,
989
+ timeoutMs,
990
+ options.dependencies,
991
+ options,
992
+ );
993
+ const csrf = linkedInCsrfTokenFromJSessionId(
994
+ webSessionCookie(client.cookies, "JSESSIONID"),
995
+ );
996
+ const transport: LinkedInArticleBrowserTransport = {
997
+ currentIdentityResponse: () => client.requestJson({
998
+ url: new URL("/voyager/api/me", LINKEDIN_ORIGIN),
999
+ method: "GET",
1000
+ headers: headers(csrf, `${LINKEDIN_ORIGIN}/feed/`),
1001
+ expectedContentTypes: [
1002
+ "application/vnd.linkedin.normalized+json+2.1",
1003
+ "application/json",
1004
+ ],
1005
+ maxBytes: MAX_SUBJECT_BYTES,
1006
+ }),
1007
+ prepareCreateDraft: () => Promise.resolve(),
1008
+ createDraft: async (profileUrn, title) => {
1009
+ const response = await client.requestStatus({
1010
+ url: new URL(`${LINKEDIN_FIRST_PARTY_ARTICLES_PATH}/`, LINKEDIN_ORIGIN),
1011
+ method: "POST",
1012
+ headers: linkedInArticleHeaders(
1013
+ csrf,
1014
+ `${LINKEDIN_ORIGIN}/article/new/`,
1015
+ ),
1016
+ body: canonicalJson(buildLinkedInArticleCreateBody(profileUrn, title)),
1017
+ expectedStatuses: [201],
1018
+ reviewedResponseIdHeader: "x-restli-id",
1019
+ });
1020
+ return response.responseId ?? null;
1021
+ },
1022
+ readDraftResponse: async (draftId) => {
1023
+ const url = linkedInArticleDraftEditUrl(draftId);
1024
+ const html = await client.requestText({
1025
+ url,
1026
+ headers: { accept: "text/html", referer: url.href },
1027
+ expectedContentTypes: ["text/html"],
1028
+ maxBytes: LINKEDIN_ARTICLE_PAGE_MAX_CHARACTERS,
1029
+ });
1030
+ return linkedInArticleDraftEnvelopeFromHtml(html, draftId);
1031
+ },
1032
+ updateTitle: async (draftId, title) => {
1033
+ await client.requestStatus({
1034
+ url: linkedInArticleDraftEntityUrl(draftId),
1035
+ method: "POST",
1036
+ headers: linkedInArticleHeaders(csrf, articleEditUrl(draftId)),
1037
+ body: canonicalJson(buildLinkedInArticleTitlePatch(title)),
1038
+ expectedStatuses: [200],
1039
+ });
1040
+ },
1041
+ updateContent: async (draftId, document) => {
1042
+ await client.requestStatus({
1043
+ url: linkedInArticleDraftEntityUrl(draftId),
1044
+ method: "POST",
1045
+ headers: linkedInArticleHeaders(csrf, articleEditUrl(draftId)),
1046
+ body: canonicalJson(buildLinkedInArticleContentPatch(document)),
1047
+ expectedStatuses: [200],
1048
+ });
1049
+ },
1050
+ uploadInlineImage: () => {
1051
+ throw new Error(
1052
+ "LinkedIn Article inline images require the reviewed browser-bound upload transport",
1053
+ );
1054
+ },
1055
+ uploadCoverImage: () => {
1056
+ throw new Error(
1057
+ "LinkedIn Article cover images require the reviewed browser-bound upload transport",
1058
+ );
1059
+ },
1060
+ updateCover: async (draftId, assetUrn) => {
1061
+ await client.requestStatus({
1062
+ url: linkedInArticleDraftEntityUrl(draftId),
1063
+ method: "POST",
1064
+ headers: linkedInArticleHeaders(csrf, articleEditUrl(draftId)),
1065
+ body: canonicalJson(buildLinkedInArticleCoverPatch(assetUrn)),
1066
+ expectedStatuses: [200],
1067
+ });
1068
+ },
1069
+ updateContentV2: async (draftId, document, imageAssetUrns) => {
1070
+ await client.requestStatus({
1071
+ url: linkedInArticleDraftEntityUrl(draftId),
1072
+ method: "POST",
1073
+ headers: linkedInArticleHeaders(csrf, articleEditUrl(draftId)),
1074
+ body: canonicalJson(buildLinkedInArticleContentPatchV2(document, imageAssetUrns)),
1075
+ expectedStatuses: [200],
1076
+ });
1077
+ },
1078
+ close: () => Promise.resolve(),
1079
+ };
1080
+ return Object.freeze(transport);
1081
+ }
1082
+
1083
+ async function createLinkedInArticleTransport(
1084
+ auth: GhostgetAuth,
1085
+ timeoutMs: number,
1086
+ options: LinkedInWebExecutionOptions,
1087
+ ): Promise<LinkedInArticleBrowserTransport> {
1088
+ const injected = options.dependencies?.createArticleBrowserTransport;
1089
+ if (injected !== undefined) {
1090
+ return injected(auth, {
1091
+ timeoutMs,
1092
+ ...(options.operationDeadline === undefined
1093
+ ? {}
1094
+ : { operationDeadline: options.operationDeadline }),
1095
+ ...(options.publishCleanupResource === undefined
1096
+ ? {}
1097
+ : { publishCleanupResource: options.publishCleanupResource }),
1098
+ });
1099
+ }
1100
+ // Secret-free runtime tests inject the exact network seam. Production never
1101
+ // uses pinned HTTP for LinkedIn Articles because LinkedIn binds this editor
1102
+ // family to its browser/device session after the first authenticated probe.
1103
+ if (options.dependencies?.fetch !== undefined) {
1104
+ return createDirectLinkedInArticleTransport(auth, timeoutMs, options);
1105
+ }
1106
+ return createLinkedInArticleBrowserTransport(auth, {
1107
+ timeoutMs,
1108
+ ...(options.operationDeadline === undefined
1109
+ ? {}
1110
+ : { operationDeadline: options.operationDeadline }),
1111
+ ...(options.publishCleanupResource === undefined
1112
+ ? {}
1113
+ : { publishCleanupResource: options.publishCleanupResource }),
1114
+ });
1115
+ }
1116
+
1117
+ type LinkedInPostImage = {
1118
+ readonly bytes: Uint8Array;
1119
+ readonly height: number;
1120
+ readonly width: number;
1121
+ };
1122
+
1123
+ function linkedInPostFileInput(value: unknown): FileInputValue | null {
1124
+ if (value === undefined) return null;
1125
+ if (!Array.isArray(value) || value.length !== 1 || !isRecord(value[0])) {
1126
+ throw new Error("LinkedIn posts.publish media must contain exactly one plan-bound PNG");
1127
+ }
1128
+ const descriptor = value[0];
1129
+ if (
1130
+ Object.keys(descriptor).sort().join(",") !== "kind,reference"
1131
+ || descriptor.kind !== "file"
1132
+ || typeof descriptor.reference !== "string"
1133
+ || descriptor.reference.length < 1
1134
+ || descriptor.reference.length > 1_024
1135
+ ) throw new Error("LinkedIn posts.publish media must contain exactly one plan-bound PNG");
1136
+ return descriptor as FileInputValue;
1137
+ }
1138
+
1139
+ async function materializeLinkedInPostImage(
1140
+ media: FileInputValue | null,
1141
+ fileResolver: BrowserFileResolver | undefined,
1142
+ operationDeadline: WebSessionOperationDeadline | undefined,
1143
+ ): Promise<LinkedInPostImage | null> {
1144
+ if (media === null) return null;
1145
+ if (fileResolver === undefined) {
1146
+ throw new Error("LinkedIn image upload requires the plan-bound file resolver");
1147
+ }
1148
+ const resolveFile = () => fileResolver([media]);
1149
+ const paths = operationDeadline === undefined
1150
+ ? await resolveFile()
1151
+ : await operationDeadline.run(
1152
+ resolveFile,
1153
+ "authenticated web operation deadline",
1154
+ );
1155
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
1156
+ throw new Error("LinkedIn image resolver did not return exactly one file");
1157
+ }
1158
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
1159
+ const handle = operationDeadline === undefined
1160
+ ? await open(paths[0], constants.O_RDONLY | noFollow)
1161
+ : await operationDeadline.run(
1162
+ () => open(paths[0]!, constants.O_RDONLY | noFollow),
1163
+ "authenticated web operation deadline",
1164
+ );
1165
+ try {
1166
+ const before = operationDeadline === undefined
1167
+ ? await handle.stat()
1168
+ : await operationDeadline.run(
1169
+ () => handle.stat(),
1170
+ "authenticated web operation deadline",
1171
+ );
1172
+ if (
1173
+ !before.isFile()
1174
+ || before.size < 24
1175
+ || before.size > MAX_LINKEDIN_POST_IMAGE_BYTES
1176
+ ) throw new Error("LinkedIn image must be a regular PNG no larger than 20 MiB");
1177
+ const bytes = operationDeadline === undefined
1178
+ ? await handle.readFile()
1179
+ : await operationDeadline.run(
1180
+ () => handle.readFile(),
1181
+ "authenticated web operation deadline",
1182
+ );
1183
+ const after = operationDeadline === undefined
1184
+ ? await handle.stat()
1185
+ : await operationDeadline.run(
1186
+ () => handle.stat(),
1187
+ "authenticated web operation deadline",
1188
+ );
1189
+ if (
1190
+ before.dev !== after.dev
1191
+ || before.ino !== after.ino
1192
+ || before.size !== after.size
1193
+ || before.mtimeMs !== after.mtimeMs
1194
+ || before.ctimeMs !== after.ctimeMs
1195
+ || bytes.byteLength !== before.size
1196
+ ) throw new Error("LinkedIn image changed while it was materialized");
1197
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
1198
+ if (
1199
+ signature.some((value, index) => bytes[index] !== value)
1200
+ || bytes.subarray(12, 16).toString("ascii") !== "IHDR"
1201
+ ) throw new Error("LinkedIn image must be a PNG fixture");
1202
+ const width = bytes.readUInt32BE(16);
1203
+ const height = bytes.readUInt32BE(20);
1204
+ if (
1205
+ width < 1
1206
+ || height < 1
1207
+ || width > 20_000
1208
+ || height > 20_000
1209
+ || width * height > 36_152_320
1210
+ ) throw new Error("LinkedIn PNG dimensions are outside the reviewed bound");
1211
+ return Object.freeze({
1212
+ bytes: new Uint8Array(bytes),
1213
+ height,
1214
+ width,
1215
+ });
1216
+ } finally {
1217
+ await handle.close();
1218
+ }
1219
+ }
1220
+
1221
+ async function createLinkedInPostTransport(
1222
+ auth: GhostgetAuth,
1223
+ timeoutMs: number,
1224
+ options: LinkedInWebExecutionOptions,
1225
+ ): Promise<LinkedInPostBrowserTransport> {
1226
+ const createTransport = options.dependencies?.createPostBrowserTransport
1227
+ ?? createLinkedInPostBrowserTransport;
1228
+ return createTransport(auth, {
1229
+ timeoutMs,
1230
+ ...(options.operationDeadline === undefined
1231
+ ? {}
1232
+ : { operationDeadline: options.operationDeadline }),
1233
+ ...(options.publishCleanupResource === undefined
1234
+ ? {}
1235
+ : { publishCleanupResource: options.publishCleanupResource }),
1236
+ });
1237
+ }
1238
+
1239
+ type LinkedInAcceptedPostTarget = Readonly<{
1240
+ entityUrn: string;
1241
+ mediaUrn: string | null;
1242
+ }>;
1243
+
1244
+ function parseLinkedInAcceptedPostTarget(
1245
+ identifier: unknown,
1246
+ ): LinkedInAcceptedPostTarget {
1247
+ if (
1248
+ typeof identifier !== "string"
1249
+ || identifier.length < 1
1250
+ || identifier.length > 2_048
1251
+ || /[\0\r\n]/u.test(identifier)
1252
+ ) throw new Error("LinkedIn accepted post target must be bounded canonical JSON");
1253
+ let value: unknown;
1254
+ try {
1255
+ value = JSON.parse(identifier) as unknown;
1256
+ } catch {
1257
+ throw new Error("LinkedIn accepted post target must be bounded canonical JSON");
1258
+ }
1259
+ if (!isRecord(value)) {
1260
+ throw new Error("LinkedIn accepted post target changed shape");
1261
+ }
1262
+ exactKeys(value, ["entityUrn", "mediaUrn"], "LinkedIn accepted post target");
1263
+ if (canonicalJson(value) !== identifier) {
1264
+ throw new Error("LinkedIn accepted post target must use canonical JSON");
1265
+ }
1266
+ return Object.freeze({
1267
+ entityUrn: linkedInPostEntityUrn(value.entityUrn),
1268
+ mediaUrn: value.mediaUrn === null ? null : linkedInPostMediaUrn(value.mediaUrn),
1269
+ });
1270
+ }
1271
+
1272
+ async function readLinkedInWebAcceptedPostTargetPresenceInternal(
1273
+ recipe: WebSessionRecipe,
1274
+ input: OperationInput,
1275
+ auth: GhostgetAuth,
1276
+ acceptedIdentifier: string,
1277
+ options: LinkedInWebExecutionOptions,
1278
+ ): Promise<Readonly<{
1279
+ present: true;
1280
+ entityUrn: string;
1281
+ mediaUrn: string | null;
1282
+ }>> {
1283
+ if (
1284
+ recipe.site !== "linkedin"
1285
+ || recipe.action !== "posts.publish"
1286
+ || recipe.contractVersion !== 3
1287
+ ) throw new Error("LinkedIn accepted post readback supports only posts.publish@3");
1288
+ if (input.media_title !== undefined || input.link_url !== undefined) {
1289
+ throw new Error("LinkedIn reviewed post publishing supports text or one PNG image only");
1290
+ }
1291
+ const target = parseLinkedInAcceptedPostTarget(acceptedIdentifier);
1292
+ const body = linkedInPostText(input.body);
1293
+ const visibility = linkedInPostVisibility(input.visibility);
1294
+ const media = linkedInPostFileInput(input.media);
1295
+ const altText = linkedInPostAltText(input.alt_text, media !== null);
1296
+ if ((media !== null) !== (target.mediaUrn !== null)) {
1297
+ throw new Error("LinkedIn accepted post target did not bind the confirmed media input");
1298
+ }
1299
+ const transport = await createLinkedInPostTransport(auth, recipe.timeoutMs, options);
1300
+ try {
1301
+ const identity = identityFromMeResponse(await transport.currentIdentityResponse());
1302
+ const profileUrn = requireBoundLinkedInIdentity(identity, auth);
1303
+ const expectedSubject = webSessionAuthSubject(auth);
1304
+ if (expectedSubject === null || expectedSubject !== identity.subject) {
1305
+ throw new Error("LinkedIn current member no longer matches the bound auth subject");
1306
+ }
1307
+ const variables = buildLinkedInPostCreateVariables({
1308
+ altText,
1309
+ body,
1310
+ mediaUrn: target.mediaUrn,
1311
+ visibility,
1312
+ });
1313
+ const projection = normalizeLinkedInPostProjection(
1314
+ await transport.readPost(
1315
+ expectedSubject,
1316
+ profileUrn,
1317
+ variables,
1318
+ target.mediaUrn,
1319
+ target.entityUrn,
1320
+ ),
1321
+ { body, mediaUrn: target.mediaUrn, profileUrn },
1322
+ );
1323
+ if (projection.entityUrn !== target.entityUrn) {
1324
+ throw new Error("LinkedIn accepted post target readback changed entity identity");
1325
+ }
1326
+ return Object.freeze({
1327
+ present: true as const,
1328
+ entityUrn: target.entityUrn,
1329
+ mediaUrn: target.mediaUrn,
1330
+ });
1331
+ } finally {
1332
+ await transport.close();
1333
+ }
1334
+ }
1335
+
1336
+ /** Read only the exact provider-accepted LinkedIn post target; never dispatch. */
1337
+ export function readLinkedInWebAcceptedPostTargetPresence(
1338
+ recipe: WebSessionRecipe,
1339
+ input: OperationInput,
1340
+ auth: GhostgetAuth,
1341
+ acceptedIdentifier: string,
1342
+ options: LinkedInWebExecutionOptions = {},
1343
+ ): Promise<Readonly<{
1344
+ present: true;
1345
+ entityUrn: string;
1346
+ mediaUrn: string | null;
1347
+ }>> {
1348
+ return startWebSessionCleanupTrackedOperation(
1349
+ options.registerCleanupBarrier,
1350
+ (publishCleanupResource) => readLinkedInWebAcceptedPostTargetPresenceInternal(
1351
+ recipe,
1352
+ input,
1353
+ auth,
1354
+ acceptedIdentifier,
1355
+ {
1356
+ ...options,
1357
+ ...(publishCleanupResource === undefined
1358
+ ? {}
1359
+ : { publishCleanupResource }),
1360
+ },
1361
+ ),
1362
+ browserCleanupBarrier,
1363
+ );
1364
+ }
1365
+
1366
+ function linkedInArticleTitle(value: unknown): string {
1367
+ if (
1368
+ typeof value !== "string"
1369
+ || value.length < 1
1370
+ || value.length > 150
1371
+ || /[\0\r\n]/u.test(value)
1372
+ ) throw new Error("input.title must be one bounded plain-text line");
1373
+ return value;
1374
+ }
1375
+
1376
+ function articleEditUrl(id: string): string {
1377
+ return `${LINKEDIN_ORIGIN}/article/edit/${id}/`;
1378
+ }
1379
+
1380
+ function linkedInCreatedArticleId(value: unknown): string {
1381
+ if (typeof value !== "string") {
1382
+ throw new Error("LinkedIn Article create response omitted its stable draft identity");
1383
+ }
1384
+ const urnMatch = /^urn:li:fsd_firstPartyArticle:([0-9]{1,32})$/u.exec(value);
1385
+ return linkedInArticleDraftId(urnMatch?.[1] ?? value, "LinkedIn created Article draft ID");
1386
+ }
1387
+
1388
+ function linkedInArticleFailureCategory(error: unknown): string {
1389
+ const message = error instanceof Error ? error.message : "";
1390
+ const response = /\((session-rejected|request-rejected-(?:400|422)(?::[a-z0-9+_-]+)?|contract-route-(?:not-found|retired)|provider-(?:conflict|throttled|unavailable)|status-drift|content-type-drift|response-drift)\)$/u.exec(message)?.[1];
1391
+ if (response !== undefined) return response;
1392
+ if (message.includes("agent-browser")) return "browser-command-failed";
1393
+ if (message.includes("cleanup") || message.includes("finalization")) return "browser-cleanup-failed";
1394
+ if (message.includes("page-instance") || message.includes("page instance")) {
1395
+ return "page-instance-binding-missing";
1396
+ }
1397
+ if (message.includes("bootstrap")) return "bootstrap-response-drift";
1398
+ if (message.includes("readback")) return "readback-rejected";
1399
+ if (message.includes("image registration request")) return "image-registration-request-failed";
1400
+ const imageRegistrationShape = /image registration shape drifted:([a-z0-9-]{1,192})/u
1401
+ .exec(message)?.[1];
1402
+ if (imageRegistrationShape !== undefined) {
1403
+ return `image-registration-shape-${imageRegistrationShape}`;
1404
+ }
1405
+ if (message.includes("image registration shape")) return "image-registration-shape-drift";
1406
+ if (
1407
+ message.includes("image registration response")
1408
+ || /Article (?:cover |inline )?image registration/u.test(message)
1409
+ ) return "image-registration-response-drift";
1410
+ if (message.includes("image staging changed shape")) return "image-byte-staging-failed";
1411
+ if (message.includes("image staging")) return "image-staging-failed";
1412
+ if (message.includes("image bytes")) return "image-byte-staging-failed";
1413
+ if (message.includes("image signed transfer status")) return "image-transfer-status-drift";
1414
+ if (message.includes("image signed transfer")) return "image-transfer-failed";
1415
+ if (/Article (?:cover |inline )?image upload/u.test(message)) {
1416
+ return "image-transfer-response-drift";
1417
+ }
1418
+ return "contract-step-failed";
1419
+ }
1420
+
1421
+ async function readLinkedInArticleDraft(
1422
+ transport: LinkedInArticleBrowserTransport,
1423
+ draftId: string,
1424
+ profileUrn: string,
1425
+ ): Promise<ReturnType<typeof normalizeLinkedInArticleDraft>> {
1426
+ const response = await transport.readDraftResponse(draftId);
1427
+ return normalizeLinkedInArticleDraft(response, draftId, profileUrn);
1428
+ }
1429
+
1430
+ async function readLinkedInArticleDraftMetadata(
1431
+ transport: LinkedInArticleBrowserTransport,
1432
+ draftId: string,
1433
+ profileUrn: string,
1434
+ ): Promise<ReturnType<typeof normalizeLinkedInArticleDraftMetadata>> {
1435
+ const response = await transport.readDraftResponse(draftId);
1436
+ return normalizeLinkedInArticleDraftMetadata(response, draftId, profileUrn);
1437
+ }
1438
+
1439
+ async function readLinkedInArticleDraftSnapshot(
1440
+ transport: LinkedInArticleBrowserTransport,
1441
+ draftId: string,
1442
+ profileUrn: string,
1443
+ ): Promise<ReturnType<typeof normalizeLinkedInArticleDraftSnapshot>> {
1444
+ const response = await transport.readDraftResponse(draftId);
1445
+ return normalizeLinkedInArticleDraftSnapshot(response, draftId, profileUrn);
1446
+ }
1447
+
1448
+ async function readLinkedInArticleDraftV2(
1449
+ transport: LinkedInArticleBrowserTransport,
1450
+ draftId: string,
1451
+ profileUrn: string,
1452
+ ): Promise<ReturnType<typeof normalizeLinkedInArticleDraftV2>> {
1453
+ const response = await transport.readDraftResponse(draftId);
1454
+ return normalizeLinkedInArticleDraftV2(response, draftId, profileUrn);
1455
+ }
1456
+
1457
+ async function readLinkedInArticleDraftV2Metadata(
1458
+ transport: LinkedInArticleBrowserTransport,
1459
+ draftId: string,
1460
+ profileUrn: string,
1461
+ ): Promise<ReturnType<typeof normalizeLinkedInArticleDraftV2Metadata>> {
1462
+ const response = await transport.readDraftResponse(draftId);
1463
+ return normalizeLinkedInArticleDraftV2Metadata(response, draftId, profileUrn);
1464
+ }
1465
+
1466
+ function requireBoundLinkedInIdentity(
1467
+ identity: LinkedInCurrentIdentity,
1468
+ auth: GhostgetAuth,
1469
+ ): string {
1470
+ const expectedSubject = webSessionAuthSubject(auth);
1471
+ if (expectedSubject === null || expectedSubject !== identity.subject) {
1472
+ throw new Error("LinkedIn current member no longer matches the bound auth subject");
1473
+ }
1474
+ if (identity.profileUrn === null) {
1475
+ throw new Error("LinkedIn current-account response omitted the Article author profile binding");
1476
+ }
1477
+ return identity.profileUrn;
1478
+ }
1479
+
1480
+ function articleDispatchEvent(
1481
+ id: string,
1482
+ index: number,
1483
+ planned: number,
1484
+ started: number,
1485
+ verified: number,
1486
+ ): WebSessionDispatchEvent {
1487
+ return { id, index, progress: { planned, started, verified } };
1488
+ }
1489
+
1490
+ export async function readLinkedInWebArticleDraftDesiredState(
1491
+ recipe: WebSessionRecipe,
1492
+ input: OperationInput,
1493
+ auth: GhostgetAuth,
1494
+ options: {
1495
+ readonly dependencies?: LinkedInWebRuntimeDependencies;
1496
+ readonly operationDeadline?: WebSessionOperationDeadline;
1497
+ readonly publishCleanupResource?: WebSessionCleanupResourcePublisher;
1498
+ } = {},
1499
+ ): Promise<{ readonly matches: boolean; readonly draftId: string }> {
1500
+ if (
1501
+ recipe.site !== "linkedin"
1502
+ || recipe.action !== "articles.draft.save"
1503
+ || recipe.contractVersion !== 2
1504
+ ) throw new Error("LinkedIn Article draft recovery supports only articles.draft.save@2");
1505
+ const draftId = linkedInArticleDraftId(input.draft_id, "input.draft_id");
1506
+ const title = linkedInArticleTitle(input.title);
1507
+ const document = parseArticleDraftDocument(input.document, {
1508
+ maximumBlocks: MAX_LINKEDIN_ARTICLE_BLOCKS,
1509
+ maximumCharacters: MAX_LINKEDIN_ARTICLE_CHARACTERS,
1510
+ });
1511
+ // Validate the provider-owned subset before opening the authenticated realm.
1512
+ buildLinkedInArticleContentPatch(document);
1513
+ const transport = await createLinkedInArticleTransport(auth, recipe.timeoutMs, {
1514
+ ...(options.dependencies === undefined
1515
+ ? {}
1516
+ : { dependencies: options.dependencies }),
1517
+ ...(options.operationDeadline === undefined
1518
+ ? {}
1519
+ : { operationDeadline: options.operationDeadline }),
1520
+ ...(options.publishCleanupResource === undefined
1521
+ ? {}
1522
+ : { publishCleanupResource: options.publishCleanupResource }),
1523
+ });
1524
+ try {
1525
+ const profileUrn = requireBoundLinkedInIdentity(
1526
+ identityFromMeResponse(await transport.currentIdentityResponse()),
1527
+ auth,
1528
+ );
1529
+ const actual = await readLinkedInArticleDraftSnapshot(
1530
+ transport,
1531
+ draftId,
1532
+ profileUrn,
1533
+ );
1534
+ return Object.freeze({
1535
+ draftId,
1536
+ matches: actual.title === title
1537
+ && actual.document !== null
1538
+ && canonicalJson(actual.document) === canonicalJson(document),
1539
+ });
1540
+ } finally {
1541
+ await transport.close();
1542
+ }
1543
+ }
1544
+
1545
+ async function executeLinkedInPostPublish(
1546
+ recipe: WebSessionRecipe,
1547
+ input: OperationInput,
1548
+ auth: GhostgetAuth,
1549
+ options: LinkedInWebExecutionOptions,
1550
+ ): Promise<WebSessionExecution> {
1551
+ if (
1552
+ recipe.site !== "linkedin"
1553
+ || recipe.action !== "posts.publish"
1554
+ || recipe.contractVersion !== 3
1555
+ ) throw new Error("LinkedIn post publishing supports only posts.publish@3");
1556
+ if (input.media_title !== undefined || input.link_url !== undefined) {
1557
+ throw new Error("LinkedIn reviewed post publishing supports text or one PNG image only");
1558
+ }
1559
+ const body = linkedInPostText(input.body);
1560
+ const visibility = linkedInPostVisibility(input.visibility);
1561
+ const media = linkedInPostFileInput(input.media);
1562
+ const altText = linkedInPostAltText(input.alt_text, media !== null);
1563
+ const image = await materializeLinkedInPostImage(
1564
+ media,
1565
+ options.fileResolver,
1566
+ options.operationDeadline,
1567
+ );
1568
+ let started = 0;
1569
+ let verified = 0;
1570
+ let transport: LinkedInPostBrowserTransport | null = null;
1571
+ let projection: ReturnType<typeof normalizeLinkedInPostProjection> | null = null;
1572
+ let failureStage = "opening the contained post transport";
1573
+ try {
1574
+ transport = await createLinkedInPostTransport(auth, recipe.timeoutMs, options);
1575
+ failureStage = "current-member binding";
1576
+ const identity = identityFromMeResponse(await transport.currentIdentityResponse());
1577
+ const profileUrn = requireBoundLinkedInIdentity(identity, auth);
1578
+ const expectedSubject = webSessionAuthSubject(auth);
1579
+ if (expectedSubject === null || expectedSubject !== identity.subject) {
1580
+ throw new Error("LinkedIn current member no longer matches the bound auth subject");
1581
+ }
1582
+ failureStage = image === null ? "public post dispatch admission" : "image preparation";
1583
+ // Page staging and IMAGE_SHARING upload can leave an orphaned private
1584
+ // provider asset, but neither can publish a feed post. Keep the durable
1585
+ // public-post dispatch boundary immediately in front of createPost so a
1586
+ // preparatory failure remains safely retryable.
1587
+ const mediaUrn = image === null
1588
+ ? null
1589
+ : await transport.uploadImage(expectedSubject, image.bytes);
1590
+ const variables = buildLinkedInPostCreateVariables({
1591
+ altText,
1592
+ body,
1593
+ mediaUrn,
1594
+ visibility,
1595
+ });
1596
+ failureStage = "public post dispatch admission";
1597
+ await options.beforeDispatch?.(
1598
+ articleDispatchEvent("posts.publish", 1, 1, started, verified),
1599
+ );
1600
+ started = 1;
1601
+ failureStage = "post create response";
1602
+ const entityUrn = await transport.createPost(
1603
+ expectedSubject,
1604
+ profileUrn,
1605
+ variables,
1606
+ mediaUrn,
1607
+ );
1608
+ failureStage = "accepted target retention";
1609
+ await options.afterProviderAcceptedMutationTarget?.({
1610
+ id: "posts.publish",
1611
+ index: 1,
1612
+ target: {
1613
+ schemaVersion: 1,
1614
+ identifier: canonicalJson({ entityUrn, mediaUrn }),
1615
+ },
1616
+ });
1617
+ failureStage = "independent post readback";
1618
+ projection = normalizeLinkedInPostProjection(
1619
+ await transport.readPost(
1620
+ expectedSubject,
1621
+ profileUrn,
1622
+ variables,
1623
+ mediaUrn,
1624
+ entityUrn,
1625
+ ),
1626
+ { body, mediaUrn, profileUrn },
1627
+ );
1628
+ verified = 1;
1629
+ failureStage = "dispatch verification";
1630
+ await options.afterDispatchVerified?.(
1631
+ articleDispatchEvent("posts.publish", 1, 1, started, verified),
1632
+ );
1633
+ return {
1634
+ status: "succeeded",
1635
+ output: Object.freeze({
1636
+ provider: "linkedin",
1637
+ operation: "posts.publish",
1638
+ post: Object.freeze({
1639
+ entityUrn: projection.entityUrn,
1640
+ url: projection.url,
1641
+ }),
1642
+ visibility,
1643
+ ...(image === null
1644
+ ? {}
1645
+ : {
1646
+ image: Object.freeze({
1647
+ altText,
1648
+ height: image.height,
1649
+ mediaType: "image/png",
1650
+ width: image.width,
1651
+ }),
1652
+ }),
1653
+ }),
1654
+ finalUrl: projection.url,
1655
+ dispatchStarted: true,
1656
+ dispatch: { planned: 1, started, verified },
1657
+ };
1658
+ } catch (error) {
1659
+ const publicFailureStage = error instanceof LinkedInPostImagePreparationError
1660
+ ? error.stage
1661
+ : error instanceof LinkedInPostCreateResponseError
1662
+ ? error.stage
1663
+ : failureStage;
1664
+ return {
1665
+ status: started > verified ? "indeterminate" : "failed",
1666
+ output: null,
1667
+ finalUrl: projection?.url ?? null,
1668
+ dispatchStarted: started > 0,
1669
+ dispatch: { planned: 1, started, verified },
1670
+ error: started > verified
1671
+ ? `LinkedIn may have accepted the post but exact member, text, media, and permalink readback was not verified; failure stage: ${publicFailureStage}; reconcile before retrying`
1672
+ : `LinkedIn post publishing failed before public post submission; failure stage: ${publicFailureStage}; retry with a fresh confirmed plan`,
1673
+ };
1674
+ } finally {
1675
+ await transport?.close();
1676
+ }
1677
+ }
1678
+
1679
+ async function executeLinkedInArticleDraftSave(
1680
+ recipe: WebSessionRecipe,
1681
+ input: OperationInput,
1682
+ auth: GhostgetAuth,
1683
+ options: LinkedInWebExecutionOptions,
1684
+ ): Promise<WebSessionExecution> {
1685
+ if (
1686
+ recipe.site !== "linkedin"
1687
+ || recipe.action !== "articles.draft.save"
1688
+ || recipe.contractVersion !== 2
1689
+ ) throw new Error("LinkedIn Article draft saving supports only articles.draft.save@2");
1690
+ const title = linkedInArticleTitle(input.title);
1691
+ const document = parseArticleDraftDocument(input.document, {
1692
+ maximumBlocks: MAX_LINKEDIN_ARTICLE_BLOCKS,
1693
+ maximumCharacters: MAX_LINKEDIN_ARTICLE_CHARACTERS,
1694
+ });
1695
+ // Validate the exact provider projection before opening any authenticated
1696
+ // browser session or crossing the durable dispatch boundary.
1697
+ buildLinkedInArticleContentPatch(document);
1698
+ const requestedDraftId = input.draft_id === undefined
1699
+ ? null
1700
+ : linkedInArticleDraftId(input.draft_id, "input.draft_id");
1701
+ const planned = requestedDraftId === null ? 2 : 1;
1702
+ let started = 0;
1703
+ let verified = 0;
1704
+ let nextIndex = 0;
1705
+ let draftId = requestedDraftId;
1706
+ let failureStage = "starting the contained LinkedIn Article browser";
1707
+ let transport: LinkedInArticleBrowserTransport | null = null;
1708
+ const begin = async (id: string): Promise<number> => {
1709
+ const index = nextIndex + 1;
1710
+ await options.beforeDispatch?.(
1711
+ articleDispatchEvent(id, index, planned, started, verified),
1712
+ );
1713
+ nextIndex = index;
1714
+ started = index;
1715
+ return index;
1716
+ };
1717
+ const complete = async (id: string, index: number): Promise<void> => {
1718
+ await options.afterDispatchVerified?.(
1719
+ articleDispatchEvent(id, index, planned, started, index),
1720
+ );
1721
+ verified = index;
1722
+ };
1723
+
1724
+ try {
1725
+ transport = await createLinkedInArticleTransport(
1726
+ auth,
1727
+ recipe.timeoutMs,
1728
+ options,
1729
+ );
1730
+ failureStage = "reading the current LinkedIn member in the contained browser";
1731
+ const currentIdentity = identityFromMeResponse(
1732
+ await transport.currentIdentityResponse(),
1733
+ );
1734
+ failureStage = "binding the current LinkedIn member";
1735
+ const profileUrn = requireBoundLinkedInIdentity(currentIdentity, auth);
1736
+
1737
+ let finalDispatchId: string;
1738
+ let finalDispatchIndex: number;
1739
+ if (draftId === null) {
1740
+ failureStage = "opening the private Article editor before creation";
1741
+ await transport.prepareCreateDraft();
1742
+ const dispatchId = "articles.create";
1743
+ failureStage = "creating the private Article title shell";
1744
+ const index = await begin(dispatchId);
1745
+ draftId = linkedInCreatedArticleId(
1746
+ await transport.createDraft(profileUrn, title),
1747
+ );
1748
+ failureStage = "verifying the new private Article title shell";
1749
+ const created = await readLinkedInArticleDraftMetadata(
1750
+ transport,
1751
+ draftId,
1752
+ profileUrn,
1753
+ );
1754
+ if (created.title !== title) {
1755
+ throw new Error("LinkedIn Article create readback did not bind the confirmed title");
1756
+ }
1757
+ await complete(dispatchId, index);
1758
+ finalDispatchId = "articles.content";
1759
+ failureStage = "replacing the new private Article document";
1760
+ finalDispatchIndex = await begin(finalDispatchId);
1761
+ await transport.updateContent(draftId, document);
1762
+ } else {
1763
+ failureStage = "reading the exact existing private Article";
1764
+ const existing = await readLinkedInArticleDraftSnapshot(
1765
+ transport,
1766
+ draftId,
1767
+ profileUrn,
1768
+ );
1769
+ const titleMatches = existing.title === title;
1770
+ const documentMatches = existing.document !== null
1771
+ && canonicalJson(existing.document) === canonicalJson(document);
1772
+ if (titleMatches && documentMatches) {
1773
+ const url = articleEditUrl(draftId);
1774
+ return {
1775
+ status: "succeeded",
1776
+ output: {
1777
+ provider: "linkedin",
1778
+ operation: "articles.draft.save",
1779
+ published: false,
1780
+ mode: "draft",
1781
+ draftId,
1782
+ title,
1783
+ documentSchemaVersion: 1,
1784
+ effect: "already-satisfied",
1785
+ },
1786
+ finalUrl: url,
1787
+ noOp: true,
1788
+ dispatchStarted: false,
1789
+ dispatch: { planned, started: 0, verified: 0 },
1790
+ };
1791
+ }
1792
+ finalDispatchId = "articles.replace";
1793
+ failureStage = "starting the exact private Article replacement";
1794
+ finalDispatchIndex = await begin(finalDispatchId);
1795
+ if (!titleMatches) {
1796
+ failureStage = "replacing the exact private Article title";
1797
+ await transport.updateTitle(draftId, title);
1798
+ }
1799
+ if (!documentMatches) {
1800
+ failureStage = "replacing the exact private Article document";
1801
+ await transport.updateContent(draftId, document);
1802
+ }
1803
+ }
1804
+
1805
+ failureStage = "verifying the replaced private Article document";
1806
+ const final = await readLinkedInArticleDraft(
1807
+ transport,
1808
+ draftId,
1809
+ profileUrn,
1810
+ );
1811
+ if (
1812
+ final.title !== title
1813
+ || canonicalJson(final.document) !== canonicalJson(document)
1814
+ ) throw new Error("LinkedIn Article final readback did not bind the confirmed draft");
1815
+ await complete(finalDispatchId, finalDispatchIndex);
1816
+
1817
+ if (nextIndex !== planned || verified !== planned) {
1818
+ throw new Error("LinkedIn Article draft workflow did not complete its exact dispatch schedule");
1819
+ }
1820
+ const url = articleEditUrl(draftId);
1821
+ return {
1822
+ status: "succeeded",
1823
+ output: {
1824
+ provider: "linkedin",
1825
+ operation: "articles.draft.save",
1826
+ published: false,
1827
+ mode: "draft",
1828
+ draftId,
1829
+ title,
1830
+ documentSchemaVersion: 1,
1831
+ url,
1832
+ },
1833
+ finalUrl: url,
1834
+ dispatchStarted: started > 0,
1835
+ dispatch: { planned, started, verified },
1836
+ };
1837
+ } catch (error) {
1838
+ const url = draftId === null ? null : articleEditUrl(draftId);
1839
+ const diagnostic = `${failureStage}; ${linkedInArticleFailureCategory(error)}`;
1840
+ return {
1841
+ status: started > verified ? "indeterminate" : verified > 0 ? "partial" : "failed",
1842
+ output: null,
1843
+ finalUrl: url,
1844
+ dispatchStarted: started > 0,
1845
+ dispatch: { planned, started, verified },
1846
+ error: started > verified
1847
+ ? requestedDraftId === null && verified === 0
1848
+ ? "LinkedIn may have accepted the private Article create, but the confirmed input has no exact draft ID for safe reconciliation; preserve the indeterminate run and do not retry"
1849
+ : `LinkedIn may have accepted the current private Article replacement dispatch while ${diagnostic}; reconcile the exact existing draft before retrying`
1850
+ : verified > 0
1851
+ ? `LinkedIn verified only part of the confirmed private Article workflow while ${diagnostic}; inspect the draft before retrying`
1852
+ : `LinkedIn Article draft failed before remote submission while ${diagnostic}`,
1853
+ };
1854
+ } finally {
1855
+ await transport?.close();
1856
+ }
1857
+ }
1858
+
1859
+ async function executeLinkedInArticleDraftSaveV7(
1860
+ recipe: WebSessionRecipe,
1861
+ input: OperationInput,
1862
+ auth: GhostgetAuth,
1863
+ options: LinkedInWebExecutionOptions,
1864
+ ): Promise<WebSessionExecution> {
1865
+ if (
1866
+ recipe.site !== "linkedin"
1867
+ || recipe.action !== "articles.draft.save"
1868
+ || recipe.contractVersion !== 7
1869
+ ) throw new Error("LinkedIn image Article draft saving supports only articles.draft.save@7");
1870
+ const title = linkedInArticleTitle(input.title);
1871
+ const document = parseArticleDraftDocumentV2(input.document, {
1872
+ maximumBlocks: MAX_LINKEDIN_ARTICLE_BLOCKS,
1873
+ maximumCharacters: MAX_LINKEDIN_ARTICLE_CHARACTERS,
1874
+ maximumImages: MAX_LINKEDIN_ARTICLE_INLINE_IMAGES,
1875
+ });
1876
+ const imageCount = document.blocks.filter((block) => block.type === "image").length;
1877
+ if (imageCount < 1) {
1878
+ throw new Error("LinkedIn ArticleDraftDocument schemaVersion 2 requires at least one inline image");
1879
+ }
1880
+ const trailing = document.blocks.at(-1);
1881
+ const beforeTrailing = document.blocks.at(-2);
1882
+ if (
1883
+ trailing?.type === "paragraph"
1884
+ && trailing.text === ""
1885
+ && beforeTrailing?.type === "image"
1886
+ ) {
1887
+ throw new Error(
1888
+ "LinkedIn Article documents must omit the editor-owned empty paragraph after a final image",
1889
+ );
1890
+ }
1891
+ const fixtureAssets = Object.freeze(Array.from(
1892
+ { length: imageCount },
1893
+ (_, index) => `urn:li:digitalmediaAsset:ghostgetFixture${index}`,
1894
+ ));
1895
+ buildLinkedInArticleContentPatchV2(document, fixtureAssets);
1896
+ const requestedDraftId = input.draft_id === undefined
1897
+ ? null
1898
+ : linkedInArticleDraftId(input.draft_id, "input.draft_id");
1899
+ if (input.cover_image === undefined && requestedDraftId === null) {
1900
+ throw new Error("input.cover_image is required when creating a LinkedIn Article draft");
1901
+ }
1902
+ const coverImage = input.cover_image === undefined
1903
+ ? null
1904
+ : await materializeArticleDraftImage(
1905
+ input.cover_image,
1906
+ options.fileResolver,
1907
+ {
1908
+ maximumBytes: MAX_LINKEDIN_ARTICLE_IMAGE_BYTES,
1909
+ inputLabel: "input.cover_image",
1910
+ filenamePrefix: "cover-image",
1911
+ ...(options.operationDeadline === undefined
1912
+ ? {}
1913
+ : { operationDeadline: options.operationDeadline }),
1914
+ },
1915
+ );
1916
+ const images = await materializeArticleDraftImages(
1917
+ input.inline_images,
1918
+ options.fileResolver,
1919
+ {
1920
+ maximumBytes: MAX_LINKEDIN_ARTICLE_IMAGE_BYTES,
1921
+ maximumImages: MAX_LINKEDIN_ARTICLE_INLINE_IMAGES,
1922
+ ...(options.operationDeadline === undefined
1923
+ ? {}
1924
+ : { operationDeadline: options.operationDeadline }),
1925
+ },
1926
+ );
1927
+ if (images.length !== imageCount) {
1928
+ throw new Error("input.inline_images must match every document imageIndex exactly");
1929
+ }
1930
+ const planned = images.length
1931
+ + (requestedDraftId === null ? 2 : 1)
1932
+ + (coverImage === null ? 0 : 1);
1933
+ let started = 0;
1934
+ let verified = 0;
1935
+ let nextIndex = 0;
1936
+ let draftId = requestedDraftId;
1937
+ let failureStage = "starting the contained LinkedIn Article browser";
1938
+ let transport: LinkedInArticleBrowserTransport | null = null;
1939
+ const begin = async (id: string): Promise<number> => {
1940
+ const index = nextIndex + 1;
1941
+ await options.beforeDispatch?.(
1942
+ articleDispatchEvent(id, index, planned, started, verified),
1943
+ );
1944
+ nextIndex = index;
1945
+ started = index;
1946
+ return index;
1947
+ };
1948
+ const complete = async (id: string, index: number): Promise<void> => {
1949
+ await options.afterDispatchVerified?.(
1950
+ articleDispatchEvent(id, index, planned, started, index),
1951
+ );
1952
+ verified = index;
1953
+ };
1954
+
1955
+ try {
1956
+ transport = await createLinkedInArticleTransport(
1957
+ auth,
1958
+ recipe.timeoutMs,
1959
+ options,
1960
+ );
1961
+ failureStage = "reading the current LinkedIn member in the contained browser";
1962
+ const currentIdentity = identityFromMeResponse(
1963
+ await transport.currentIdentityResponse(),
1964
+ );
1965
+ failureStage = "binding the current LinkedIn member";
1966
+ const profileUrn = requireBoundLinkedInIdentity(currentIdentity, auth);
1967
+ if (
1968
+ transport.uploadInlineImage === undefined
1969
+ || transport.updateContentV2 === undefined
1970
+ ) throw new Error("LinkedIn Article cover and inline image transport is unavailable");
1971
+ const uploadInlineImage = transport.uploadInlineImage;
1972
+ const updateContentV2 = transport.updateContentV2;
1973
+ let coverAssetUrn: string | null = null;
1974
+
1975
+ if (draftId === null) {
1976
+ failureStage = "opening the private Article editor before creation";
1977
+ await transport.prepareCreateDraft();
1978
+ const dispatchId = "articles.create";
1979
+ failureStage = "creating the private Article title shell";
1980
+ const index = await begin(dispatchId);
1981
+ draftId = linkedInCreatedArticleId(
1982
+ await transport.createDraft(profileUrn, title),
1983
+ );
1984
+ failureStage = "verifying the new private Article title shell";
1985
+ const created = await readLinkedInArticleDraftMetadata(
1986
+ transport,
1987
+ draftId,
1988
+ profileUrn,
1989
+ );
1990
+ if (created.title !== title) {
1991
+ throw new Error("LinkedIn Article create readback did not bind the confirmed title");
1992
+ }
1993
+ await complete(dispatchId, index);
1994
+ } else {
1995
+ failureStage = "reading the exact existing private Article";
1996
+ const existing = await readLinkedInArticleDraftV2Metadata(
1997
+ transport,
1998
+ draftId,
1999
+ profileUrn,
2000
+ );
2001
+ coverAssetUrn = existing.coverAssetUrn;
2002
+ if (coverImage === null && coverAssetUrn === null) {
2003
+ throw new Error("LinkedIn Article replacement without input.cover_image requires one existing private banner");
2004
+ }
2005
+ }
2006
+
2007
+ if (coverImage !== null) {
2008
+ if (
2009
+ transport.uploadCoverImage === undefined
2010
+ || transport.updateCover === undefined
2011
+ ) throw new Error("LinkedIn Article cover transport is unavailable");
2012
+ const coverDispatchId = "articles.cover";
2013
+ failureStage = "uploading the private Article cover image";
2014
+ const coverDispatchIndex = await begin(coverDispatchId);
2015
+ coverAssetUrn = await transport.uploadCoverImage(draftId, coverImage);
2016
+ failureStage = "binding the private Article cover only to the banner slot";
2017
+ await transport.updateCover(draftId, coverAssetUrn);
2018
+ failureStage = "verifying the private Article cover banner";
2019
+ const covered = await readLinkedInArticleDraftV2Metadata(
2020
+ transport,
2021
+ draftId,
2022
+ profileUrn,
2023
+ );
2024
+ if (covered.coverAssetUrn !== coverAssetUrn) {
2025
+ throw new Error("LinkedIn Article cover readback did not bind the confirmed banner asset");
2026
+ }
2027
+ await complete(coverDispatchId, coverDispatchIndex);
2028
+ }
2029
+ if (coverAssetUrn === null) {
2030
+ throw new Error("LinkedIn Article draft requires one exact private banner");
2031
+ }
2032
+
2033
+ const imageAssetUrns: string[] = [];
2034
+ for (const [imageIndex, image] of images.entries()) {
2035
+ const dispatchId = `articles.image[${imageIndex + 1}]`;
2036
+ failureStage = `uploading private Article inline image ${imageIndex + 1}`;
2037
+ const index = await begin(dispatchId);
2038
+ imageAssetUrns.push(await uploadInlineImage(draftId, image));
2039
+ failureStage = `verifying private Article inline image ${imageIndex + 1}`;
2040
+ await complete(dispatchId, index);
2041
+ }
2042
+
2043
+ const finalDispatchId = requestedDraftId === null
2044
+ ? "articles.content"
2045
+ : "articles.replace";
2046
+ failureStage = "starting the exact private Article replacement";
2047
+ const finalDispatchIndex = await begin(finalDispatchId);
2048
+ if (requestedDraftId !== null) {
2049
+ failureStage = "replacing the exact private Article title";
2050
+ await transport.updateTitle(draftId, title);
2051
+ }
2052
+ failureStage = "replacing the exact private Article document and inline images";
2053
+ await updateContentV2(draftId, document, imageAssetUrns);
2054
+
2055
+ failureStage = "verifying the replaced private Article document and inline images";
2056
+ const final = await readLinkedInArticleDraftV2(
2057
+ transport,
2058
+ draftId,
2059
+ profileUrn,
2060
+ );
2061
+ if (
2062
+ final.title !== title
2063
+ || canonicalJson(final.document) !== canonicalJson(document)
2064
+ || final.coverAssetUrn !== coverAssetUrn
2065
+ || canonicalJson(final.imageAssetUrns) !== canonicalJson(imageAssetUrns)
2066
+ ) throw new Error("LinkedIn Article final readback did not bind the confirmed draft, cover, and inline images");
2067
+ await complete(finalDispatchId, finalDispatchIndex);
2068
+
2069
+ if (nextIndex !== planned || verified !== planned) {
2070
+ throw new Error("LinkedIn Article image workflow did not complete its exact dispatch schedule");
2071
+ }
2072
+ const url = articleEditUrl(draftId);
2073
+ return {
2074
+ status: "succeeded",
2075
+ output: {
2076
+ provider: "linkedin",
2077
+ operation: "articles.draft.save",
2078
+ published: false,
2079
+ mode: "draft",
2080
+ draftId,
2081
+ title,
2082
+ documentSchemaVersion: 2,
2083
+ coverImageCount: 1,
2084
+ inlineImageCount: images.length,
2085
+ url,
2086
+ },
2087
+ finalUrl: url,
2088
+ dispatchStarted: started > 0,
2089
+ dispatch: { planned, started, verified },
2090
+ };
2091
+ } catch (error) {
2092
+ const url = draftId === null ? null : articleEditUrl(draftId);
2093
+ const diagnostic = `${failureStage}; ${linkedInArticleFailureCategory(error)}`;
2094
+ return {
2095
+ status: started > verified ? "indeterminate" : verified > 0 ? "partial" : "failed",
2096
+ output: null,
2097
+ finalUrl: url,
2098
+ dispatchStarted: started > 0,
2099
+ dispatch: { planned, started, verified },
2100
+ error: started > verified
2101
+ ? `LinkedIn may have accepted the current private Article cover, inline image, or replacement dispatch while ${diagnostic}; preserve the indeterminate run, inspect the exact draft, and do not retry`
2102
+ : verified > 0
2103
+ ? `LinkedIn verified only part of the confirmed private Article image workflow while ${diagnostic}; inspect the draft before retrying`
2104
+ : `LinkedIn Article image draft failed before remote submission while ${diagnostic}`,
2105
+ };
2106
+ } finally {
2107
+ await transport?.close();
2108
+ }
2109
+ }
2110
+
2111
+
2112
+ async function executeLinkedInProfileActivityRead(
2113
+ recipe: WebSessionRecipe,
2114
+ input: OperationInput,
2115
+ auth: GhostgetAuth,
2116
+ options: LinkedInWebExecutionOptions,
2117
+ ): Promise<WebSessionExecution> {
2118
+ const feed = linkedInProfileActivityFeed(input.feed);
2119
+ if (feed === "home") {
2120
+ throw new Error(
2121
+ "LinkedIn home-feed read remains capture-required; recapture and review the current first-party contract before execution",
2122
+ );
2123
+ }
2124
+ const target = linkedInProfileActivityTarget({
2125
+ profile_url: input.profile_url,
2126
+ vanity: input.vanity,
2127
+ });
2128
+ const cursor = parseLinkedInProfileActivityCursor(input.cursor, target.slug);
2129
+ const limit = integerInput(input, "limit", 20, 1, LINKEDIN_PROFILE_ACTIVITY_MAX_ITEMS);
2130
+ return runReadEffect(linkedInProfileActivityReadProgram(target, cursor.start, limit).pipe(
2131
+ Effect.provide(LinkedInProfileActivityPlatformLive({
2132
+ openBrowser: () => {
2133
+ const createTransport = options.dependencies?.createFeedBrowserTransport
2134
+ ?? createLinkedInFeedBrowserTransport;
2135
+ return createTransport(auth, {
2136
+ timeoutMs: recipe.timeoutMs,
2137
+ maxOutputBytes: recipe.maxOutputBytes,
2138
+ ...(options.operationDeadline === undefined
2139
+ ? {}
2140
+ : { operationDeadline: options.operationDeadline }),
2141
+ ...(options.publishCleanupResource === undefined
2142
+ ? {}
2143
+ : { publishCleanupResource: options.publishCleanupResource }),
2144
+ });
2145
+ },
2146
+ decodeIdentity: identityFromMeResponse,
2147
+ bindIdentity: identity => boundLinkedInStatsIdentity(auth, identity),
2148
+ observedAt: () => new Date(options.dependencies?.now?.() ?? Date.now()).toISOString(),
2149
+ })),
2150
+ ));
2151
+ }
2152
+
2153
+ export async function executeLinkedInWebOperation(
2154
+ recipe: WebSessionRecipe,
2155
+ input: OperationInput,
2156
+ auth: GhostgetAuth,
2157
+ options: LinkedInWebExecutionOptions = {},
2158
+ ): Promise<WebSessionExecution> {
2159
+ if (
2160
+ recipe.site === "linkedin"
2161
+ && recipe.contractVersion === 2
2162
+ && recipe.action === "feeds.read"
2163
+ ) {
2164
+ if (input.feed === "home") {
2165
+ throw new Error(
2166
+ "LinkedIn home-feed read remains capture-required; recapture and review the current first-party contract before execution",
2167
+ );
2168
+ }
2169
+ return startWebSessionCleanupTrackedOperation(
2170
+ options.registerCleanupBarrier,
2171
+ (publishCleanupResource) => executeLinkedInProfileActivityRead(
2172
+ recipe,
2173
+ input,
2174
+ auth,
2175
+ {
2176
+ ...options,
2177
+ ...(publishCleanupResource === undefined
2178
+ ? {}
2179
+ : { publishCleanupResource }),
2180
+ },
2181
+ ),
2182
+ browserCleanupBarrier,
2183
+ );
2184
+ }
2185
+ if (
2186
+ recipe.site === "linkedin"
2187
+ && recipe.contractVersion === 1
2188
+ && recipe.action === "contacts.read"
2189
+ ) {
2190
+ return startWebSessionCleanupTrackedOperation(
2191
+ options.registerCleanupBarrier,
2192
+ (publishCleanupResource) => executeLinkedInContactInfoRead(
2193
+ recipe,
2194
+ input,
2195
+ auth,
2196
+ {
2197
+ ...options,
2198
+ ...(publishCleanupResource === undefined
2199
+ ? {}
2200
+ : { publishCleanupResource }),
2201
+ },
2202
+ ),
2203
+ browserCleanupBarrier,
2204
+ );
2205
+ }
2206
+ if (
2207
+ recipe.site === "linkedin"
2208
+ && recipe.contractVersion === 1
2209
+ && recipe.action === "profiles.read"
2210
+ ) {
2211
+ return startWebSessionCleanupTrackedOperation(
2212
+ options.registerCleanupBarrier,
2213
+ (publishCleanupResource) => executeLinkedInPersonalProfileRead(
2214
+ recipe,
2215
+ input,
2216
+ auth,
2217
+ {
2218
+ ...options,
2219
+ ...(publishCleanupResource === undefined
2220
+ ? {}
2221
+ : { publishCleanupResource }),
2222
+ },
2223
+ ),
2224
+ browserCleanupBarrier,
2225
+ );
2226
+ }
2227
+ if (
2228
+ recipe.site === "linkedin"
2229
+ && recipe.contractVersion === 1
2230
+ && recipe.action === "organizations.read"
2231
+ ) {
2232
+ return startWebSessionCleanupTrackedOperation(
2233
+ options.registerCleanupBarrier,
2234
+ (publishCleanupResource) => executeLinkedInOrganizationRead(
2235
+ recipe,
2236
+ input,
2237
+ auth,
2238
+ {
2239
+ ...options,
2240
+ ...(publishCleanupResource === undefined
2241
+ ? {}
2242
+ : { publishCleanupResource }),
2243
+ },
2244
+ ),
2245
+ browserCleanupBarrier,
2246
+ );
2247
+ }
2248
+ if (
2249
+ recipe.site === "linkedin"
2250
+ && recipe.contractVersion === 7
2251
+ && recipe.action === "articles.draft.save"
2252
+ ) {
2253
+ return startWebSessionCleanupTrackedOperation(
2254
+ options.registerCleanupBarrier,
2255
+ (publishCleanupResource) => executeLinkedInArticleDraftSaveV7(
2256
+ recipe,
2257
+ input,
2258
+ auth,
2259
+ {
2260
+ ...options,
2261
+ ...(publishCleanupResource === undefined
2262
+ ? {}
2263
+ : { publishCleanupResource }),
2264
+ },
2265
+ ),
2266
+ browserCleanupBarrier,
2267
+ );
2268
+ }
2269
+ if (
2270
+ recipe.site === "linkedin"
2271
+ && recipe.contractVersion === 3
2272
+ && recipe.action === "posts.publish"
2273
+ ) {
2274
+ return startWebSessionCleanupTrackedOperation(
2275
+ options.registerCleanupBarrier,
2276
+ (publishCleanupResource) => executeLinkedInPostPublish(
2277
+ recipe,
2278
+ input,
2279
+ auth,
2280
+ {
2281
+ ...options,
2282
+ ...(publishCleanupResource === undefined
2283
+ ? {}
2284
+ : { publishCleanupResource }),
2285
+ },
2286
+ ),
2287
+ browserCleanupBarrier,
2288
+ );
2289
+ }
2290
+ if (
2291
+ recipe.site === "linkedin"
2292
+ && recipe.contractVersion === 2
2293
+ && recipe.action === "articles.draft.save"
2294
+ ) {
2295
+ return startWebSessionCleanupTrackedOperation(
2296
+ options.registerCleanupBarrier,
2297
+ (publishCleanupResource) => executeLinkedInArticleDraftSave(
2298
+ recipe,
2299
+ input,
2300
+ auth,
2301
+ {
2302
+ ...options,
2303
+ ...(publishCleanupResource === undefined
2304
+ ? {}
2305
+ : { publishCleanupResource }),
2306
+ },
2307
+ ),
2308
+ browserCleanupBarrier,
2309
+ );
2310
+ }
2311
+ // Every ungraduated operation stays inert before cookies, browser state, or network.
2312
+ assertLinkedInWebExecutionAvailable();
2313
+ if (
2314
+ recipe.site !== "linkedin"
2315
+ || recipe.contractVersion !== 1
2316
+ || recipe.action !== "messaging.list"
2317
+ ) throw new Error(`LinkedIn authenticated web operation ${recipe.action} has no executable reviewed contract`);
2318
+ try {
2319
+ if (input.cursor !== undefined) {
2320
+ throw new Error("LinkedIn messaging.list cursor pagination is capture-required");
2321
+ }
2322
+ const folder = input.folder;
2323
+ if (typeof folder !== "string") throw new Error("input.folder must be a LinkedIn inbox folder");
2324
+ const limit = integerInput(input, "limit", 20, 1, 100);
2325
+ const client = await createLinkedInClient(
2326
+ auth,
2327
+ recipe.timeoutMs,
2328
+ options.dependencies,
2329
+ options,
2330
+ );
2331
+ const csrf = linkedInCsrfTokenFromJSessionId(webSessionCookie(client.cookies, "JSESSIONID"));
2332
+ const identity = await currentIdentity(client, csrf);
2333
+ const expectedSubject = webSessionAuthSubject(auth);
2334
+ if (expectedSubject === null || expectedSubject !== identity.subject) {
2335
+ throw new Error("LinkedIn current member no longer matches the bound auth subject");
2336
+ }
2337
+ if (identity.mailboxUrn === null) {
2338
+ throw new Error("LinkedIn current-account response omitted the mailbox-bound normalized profile");
2339
+ }
2340
+ const requestConversations = (queryId: string): Promise<unknown> => client.requestJson({
2341
+ url: linkedInMessengerConversationsUrl(identity.mailboxUrn, queryId),
2342
+ method: "GET",
2343
+ headers: headers(csrf, `${LINKEDIN_ORIGIN}/feed/`),
2344
+ expectedContentTypes: [
2345
+ "application/graphql",
2346
+ "application/vnd.linkedin.normalized+json+2.1",
2347
+ "application/json",
2348
+ ],
2349
+ maxBytes: recipe.maxOutputBytes,
2350
+ });
2351
+ let response: unknown;
2352
+ try {
2353
+ response = await requestConversations(LINKEDIN_MESSENGER_CONVERSATIONS_OBSERVED_QUERY_ID);
2354
+ } catch (initialError) {
2355
+ const resolveQueryId = options.dependencies?.resolveMessengerConversationsQueryId
2356
+ ?? resolveLinkedInMessengerConversationsQueryId;
2357
+ let currentQueryId: string;
2358
+ try {
2359
+ currentQueryId = await resolveQueryId(auth, identity.mailboxUrn, {
2360
+ timeoutMs: options.operationDeadline?.remainingTimeMs()
2361
+ ?? recipe.timeoutMs,
2362
+ });
2363
+ } catch (bootstrapError) {
2364
+ throw new Error("LinkedIn inbox query failed and its current registered revision could not be resolved", {
2365
+ cause: bootstrapError,
2366
+ });
2367
+ }
2368
+ if (currentQueryId === LINKEDIN_MESSENGER_CONVERSATIONS_OBSERVED_QUERY_ID) throw initialError;
2369
+ response = await requestConversations(currentQueryId);
2370
+ }
2371
+ return {
2372
+ status: "succeeded",
2373
+ output: normalizeLinkedInMessagingList(response, folder, limit),
2374
+ finalUrl: `${LINKEDIN_ORIGIN}/messaging/`,
2375
+ dispatchStarted: false,
2376
+ dispatch: { planned: 0, started: 0, verified: 0 },
2377
+ };
2378
+ } catch (error) {
2379
+ return {
2380
+ status: "failed",
2381
+ output: null,
2382
+ finalUrl: `${LINKEDIN_ORIGIN}/messaging/`,
2383
+ dispatchStarted: false,
2384
+ dispatch: { planned: 0, started: 0, verified: 0 },
2385
+ error: linkedInReadFailure(error),
2386
+ };
2387
+ }
2388
+ }