@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,1885 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ constants,
5
+ fstatSync,
6
+ openSync,
7
+ readSync,
8
+ type BigIntStats,
9
+ } from "node:fs";
10
+ import { isAbsolute } from "node:path";
11
+ import { types as nodeTypes } from "node:util";
12
+
13
+ import { isLinkedInProviderActorSubject } from "../provider-subject";
14
+ import { bearerHeaders } from "../provider-http";
15
+ import type { ProviderActionContext, ProviderFile } from "../provider-context";
16
+ import { projectContactDirectionStats } from "./contact-projection";
17
+
18
+ const LINKEDIN_API_ORIGIN = "https://api.linkedin.com";
19
+ const LINKEDIN_API_HOSTS = ["api.linkedin.com"] as const;
20
+ const LINKEDIN_VERSION = "202606";
21
+ const LINKEDIN_PROTOCOL_VERSION = "2.0.0";
22
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
23
+ const MAX_DOCUMENT_BYTES = 100 * 1024 * 1024;
24
+ const MAX_VIDEO_BYTES = 500 * 1024 * 1024;
25
+ // LinkedIn documents its feed-video floor as 75 KB. Treat KB as the decimal
26
+ // unit used by the API specification instead of silently raising it to 75 KiB.
27
+ const MIN_VIDEO_BYTES = 75_000;
28
+ const FILE_READ_CHUNK_BYTES = 1024 * 1024;
29
+ const MAX_MEDIA_STATUS_POLLS = 100;
30
+ const MAX_MEDIA_STATUS_WAIT_MS = 8 * 60_000;
31
+ const DEFAULT_MEDIA_STATUS_DELAY_MS = 1_000;
32
+ const CONNECTIONS_DEFAULT_COUNT = 10;
33
+ const CONNECTIONS_MAX_COUNT = 50;
34
+ const CONNECTIONS_MAX_START = 100_000;
35
+ const CONNECTIONS_PROJECTION = "(elements(*(to~)),paging)";
36
+ const CONNECTION_STATS_UNAVAILABLE_REASON =
37
+ "linkedin-connections-message-statistics-unavailable";
38
+
39
+ const imageMediaTypes = new Set(["image/jpeg", "image/png", "image/gif"]);
40
+ const videoMediaTypes = new Set(["video/mp4"]);
41
+ const documentMediaTypes = new Set([
42
+ "application/pdf",
43
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
44
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
45
+ ]);
46
+ const reactionTypes = new Set([
47
+ "LIKE",
48
+ "PRAISE",
49
+ "EMPATHY",
50
+ "INTEREST",
51
+ "APPRECIATION",
52
+ "ENTERTAINMENT",
53
+ ]);
54
+
55
+ type PreparedMedia =
56
+ | { readonly kind: "none" }
57
+ | {
58
+ readonly kind: "images";
59
+ readonly items: readonly {
60
+ readonly file: ProviderFile;
61
+ readonly bytes: Uint8Array;
62
+ }[];
63
+ }
64
+ | {
65
+ readonly kind: "document";
66
+ readonly file: ProviderFile;
67
+ readonly bytes: Uint8Array;
68
+ readonly title: string;
69
+ }
70
+ | {
71
+ readonly kind: "video";
72
+ readonly prepared: PreparedProviderFile;
73
+ readonly title: string | null;
74
+ };
75
+
76
+ type PreparedProviderFile = {
77
+ readonly file: ProviderFile;
78
+ /** The exact file identity observed by the complete pre-dispatch digest pass. */
79
+ readonly preflightIdentity: BigIntStats;
80
+ };
81
+
82
+ type VerifiedProviderRead = {
83
+ readonly identity: BigIntStats;
84
+ readonly bytes: Uint8Array | null;
85
+ };
86
+
87
+ type UploadInstruction = {
88
+ readonly firstByte: number;
89
+ readonly lastByte: number;
90
+ readonly uploadUrl: string;
91
+ };
92
+
93
+ type StrictJsonRecord = Readonly<Record<string, unknown>>;
94
+
95
+ type LocalizedConnectionNameSelectionBasis =
96
+ | "preferred-locale-exact"
97
+ | "preferred-locale-unambiguous-variant"
98
+ | "sole-localized-value"
99
+ | "unavailable";
100
+
101
+ type LocalizedConnectionName = Readonly<{
102
+ value: string | null;
103
+ localized: Readonly<Record<string, string>>;
104
+ preferredLocale: Readonly<{
105
+ country: string | null;
106
+ language: string;
107
+ }> | null;
108
+ selectedLocale: string | null;
109
+ selectionBasis: LocalizedConnectionNameSelectionBasis;
110
+ }>;
111
+
112
+ const unavailableConnectionStats = Object.freeze({
113
+ count: null,
114
+ complete: false,
115
+ lowerBound: false,
116
+ truncated: false,
117
+ lastAt: null,
118
+ lastAtComplete: false,
119
+ lastAtBasis: "unavailable",
120
+ incompleteReasons: Object.freeze([CONNECTION_STATS_UNAVAILABLE_REASON]),
121
+ } as const);
122
+
123
+ const unavailableConnectionStatsProjection = projectContactDirectionStats(
124
+ unavailableConnectionStats,
125
+ unavailableConnectionStats,
126
+ );
127
+
128
+ function isRecord(value: unknown): value is Record<string, unknown> {
129
+ return typeof value === "object" && value !== null && !Array.isArray(value);
130
+ }
131
+
132
+ function record(value: unknown, label: string): Record<string, unknown> {
133
+ if (!isRecord(value)) throw new Error(`LinkedIn returned an invalid ${label}`);
134
+ return value;
135
+ }
136
+
137
+ function strictRecord(value: unknown, label: string): StrictJsonRecord {
138
+ if (
139
+ nodeTypes.isProxy(value)
140
+ || typeof value !== "object"
141
+ || value === null
142
+ || Array.isArray(value)
143
+ || (
144
+ Object.getPrototypeOf(value) !== Object.prototype
145
+ && Object.getPrototypeOf(value) !== null
146
+ )
147
+ ) throw new Error(`${label} must be a plain object`);
148
+ const descriptors = Object.getOwnPropertyDescriptors(value);
149
+ for (const key of Reflect.ownKeys(descriptors)) {
150
+ if (typeof key !== "string") throw new Error(`${label} must not contain symbol properties`);
151
+ const descriptor = descriptors[key];
152
+ if (
153
+ descriptor === undefined
154
+ || !("value" in descriptor)
155
+ || !descriptor.enumerable
156
+ ) throw new Error(`${label}.${key} must be an enumerable data property`);
157
+ }
158
+ return value as StrictJsonRecord;
159
+ }
160
+
161
+ function exactKeys(
162
+ value: StrictJsonRecord,
163
+ required: readonly string[],
164
+ optional: readonly string[],
165
+ label: string,
166
+ ): void {
167
+ const allowed = new Set([...required, ...optional]);
168
+ for (const key of Object.keys(value)) {
169
+ if (!allowed.has(key)) throw new Error(`${label} contains unreviewed property ${key}`);
170
+ }
171
+ for (const key of required) {
172
+ if (!Object.hasOwn(value, key)) throw new Error(`${label}.${key} is required`);
173
+ }
174
+ }
175
+
176
+ function strictArray(value: unknown, label: string, maximum: number): readonly unknown[] {
177
+ if (
178
+ nodeTypes.isProxy(value)
179
+ || !Array.isArray(value)
180
+ || Object.getPrototypeOf(value) !== Array.prototype
181
+ || value.length > maximum
182
+ ) throw new Error(`${label} must be a dense array of at most ${maximum} items`);
183
+ const descriptors = Object.getOwnPropertyDescriptors(value);
184
+ const expected = new Set<PropertyKey>([
185
+ "length",
186
+ ...Array.from({ length: value.length }, (_unused, index) => String(index)),
187
+ ]);
188
+ if (
189
+ Reflect.ownKeys(descriptors).length !== expected.size
190
+ || Reflect.ownKeys(descriptors).some((key) => !expected.has(key))
191
+ ) throw new Error(`${label} must be a dense array without named properties`);
192
+ const result: unknown[] = [];
193
+ for (let index = 0; index < value.length; index += 1) {
194
+ const descriptor = descriptors[String(index)];
195
+ if (
196
+ descriptor === undefined
197
+ || !("value" in descriptor)
198
+ || !descriptor.enumerable
199
+ ) throw new Error(`${label}[${index}] must be an enumerable data property`);
200
+ result.push(descriptor.value);
201
+ }
202
+ return Object.freeze(result);
203
+ }
204
+
205
+ function hasUnsafeTextControl(value: string): boolean {
206
+ for (let index = 0; index < value.length; index += 1) {
207
+ const code = value.charCodeAt(index);
208
+ if (code <= 0x08 || code === 0x0b || code === 0x0c || (code >= 0x0e && code <= 0x1f) || code === 0x7f) {
209
+ return true;
210
+ }
211
+ }
212
+ return false;
213
+ }
214
+
215
+ function hasAnyAsciiControl(value: string): boolean {
216
+ for (let index = 0; index < value.length; index += 1) {
217
+ const code = value.charCodeAt(index);
218
+ if (code <= 0x1f || code === 0x7f) return true;
219
+ }
220
+ return false;
221
+ }
222
+
223
+ function requiredString(
224
+ value: unknown,
225
+ label: string,
226
+ maximum = 8_192,
227
+ allowEmpty = false,
228
+ ): string {
229
+ if (
230
+ typeof value !== "string"
231
+ || (!allowEmpty && value.length === 0)
232
+ || value.length > maximum
233
+ || hasUnsafeTextControl(value)
234
+ ) throw new Error(`${label} must be a bounded string without control characters`);
235
+ return value;
236
+ }
237
+
238
+ function optionalString(value: unknown, label: string, maximum = 8_192): string | null {
239
+ return value === undefined ? null : requiredString(value, label, maximum);
240
+ }
241
+
242
+ function inputInteger(
243
+ value: unknown,
244
+ label: string,
245
+ fallback: number,
246
+ minimum: number,
247
+ maximum: number,
248
+ ): number {
249
+ const candidate = value === undefined ? fallback : value;
250
+ if (!Number.isSafeInteger(candidate) || (candidate as number) < minimum || (candidate as number) > maximum) {
251
+ throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`);
252
+ }
253
+ return candidate as number;
254
+ }
255
+
256
+ function responseString(value: unknown, label: string, maximum = 8_192, allowEmpty = false): string {
257
+ return requiredString(value, `LinkedIn ${label}`, maximum, allowEmpty);
258
+ }
259
+
260
+ function responseInteger(value: unknown, label: string): number {
261
+ if (!Number.isSafeInteger(value) || (value as number) < 0) {
262
+ throw new Error(`LinkedIn returned an invalid ${label}`);
263
+ }
264
+ return value as number;
265
+ }
266
+
267
+ function isWellFormedText(value: string): boolean {
268
+ for (let index = 0; index < value.length; index += 1) {
269
+ const code = value.charCodeAt(index);
270
+ if (code >= 0xd800 && code <= 0xdbff) {
271
+ const next = value.charCodeAt(index + 1);
272
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
273
+ index += 1;
274
+ } else if (code >= 0xdc00 && code <= 0xdfff) return false;
275
+ }
276
+ return true;
277
+ }
278
+
279
+ function hasUnsafeStructuredTextControl(value: string): boolean {
280
+ for (let index = 0; index < value.length; index += 1) {
281
+ const code = value.charCodeAt(index);
282
+ if (
283
+ code <= 0x1f
284
+ || code === 0x7f
285
+ || (code >= 0x80 && code <= 0x9f)
286
+ || code === 0x061c
287
+ || code === 0x200e
288
+ || code === 0x200f
289
+ || (code >= 0x2028 && code <= 0x202e)
290
+ || (code >= 0x2066 && code <= 0x2069)
291
+ ) return true;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function structuredResponseText(
297
+ value: unknown,
298
+ label: string,
299
+ maximum: number,
300
+ ): string {
301
+ if (
302
+ typeof value !== "string"
303
+ || value.length === 0
304
+ || Buffer.byteLength(value, "utf8") > maximum
305
+ || value.trim().length === 0
306
+ || !isWellFormedText(value)
307
+ || hasUnsafeStructuredTextControl(value)
308
+ ) throw new Error(`LinkedIn returned an invalid ${label}`);
309
+ return value;
310
+ }
311
+
312
+ function personUrn(value: unknown, label: string): string {
313
+ const urn = structuredResponseText(value, label, 280);
314
+ if (!/^urn:li:person:[A-Za-z0-9_-]{1,256}$/u.test(urn)) {
315
+ throw new Error(`LinkedIn returned an invalid ${label}`);
316
+ }
317
+ return urn;
318
+ }
319
+
320
+ function personId(value: unknown, label: string): string {
321
+ const id = structuredResponseText(value, label, 256);
322
+ if (!/^[A-Za-z0-9_-]{1,256}$/u.test(id)) {
323
+ throw new Error(`LinkedIn returned an invalid ${label}`);
324
+ }
325
+ return id;
326
+ }
327
+
328
+ function localizedConnectionName(value: unknown, label: string): LocalizedConnectionName {
329
+ const source = strictRecord(value, label);
330
+ exactKeys(source, ["localized"], ["preferredLocale"], label);
331
+ let preferredLocale: LocalizedConnectionName["preferredLocale"] = null;
332
+ if (source.preferredLocale !== undefined) {
333
+ const locale = strictRecord(source.preferredLocale, `${label}.preferredLocale`);
334
+ exactKeys(locale, ["language"], ["country"], `${label}.preferredLocale`);
335
+ const language = structuredResponseText(
336
+ locale.language,
337
+ `${label}.preferredLocale.language`,
338
+ 2,
339
+ );
340
+ const country = locale.country === undefined
341
+ ? null
342
+ : structuredResponseText(
343
+ locale.country,
344
+ `${label}.preferredLocale.country`,
345
+ 2,
346
+ );
347
+ if (!/^[a-z]{2}$/u.test(language) || (country !== null && !/^[A-Z]{2}$/u.test(country))) {
348
+ throw new Error(`LinkedIn returned an invalid ${label}.preferredLocale`);
349
+ }
350
+ preferredLocale = Object.freeze({ country, language });
351
+ }
352
+
353
+ const localizedSource = strictRecord(source.localized, `${label}.localized`);
354
+ const entries = Object.keys(localizedSource).sort();
355
+ if (entries.length < 1 || entries.length > 64) {
356
+ throw new Error(`LinkedIn returned an invalid ${label}.localized`);
357
+ }
358
+ const localized: Record<string, string> = {};
359
+ for (const key of entries) {
360
+ if (!/^[a-z]{2}(?:_[A-Z]{2})?(?:_[A-Za-z0-9]{1,16})?$/u.test(key)) {
361
+ throw new Error(`LinkedIn returned an invalid ${label}.localized locale ${key}`);
362
+ }
363
+ localized[key] = structuredResponseText(
364
+ localizedSource[key],
365
+ `${label}.localized.${key}`,
366
+ 512,
367
+ );
368
+ }
369
+ let selectedLocale: string | null = null;
370
+ let selectionBasis: LocalizedConnectionNameSelectionBasis = "unavailable";
371
+ if (preferredLocale !== null) {
372
+ const preferredKey = preferredLocale.country === null
373
+ ? preferredLocale.language
374
+ : `${preferredLocale.language}_${preferredLocale.country}`;
375
+ if (Object.hasOwn(localized, preferredKey)) {
376
+ selectedLocale = preferredKey;
377
+ selectionBasis = "preferred-locale-exact";
378
+ } else {
379
+ const compatibleVariants = entries.filter((key) =>
380
+ key.startsWith(`${preferredKey}_`));
381
+ if (compatibleVariants.length === 1) {
382
+ selectedLocale = compatibleVariants[0] ?? null;
383
+ selectionBasis = "preferred-locale-unambiguous-variant";
384
+ }
385
+ }
386
+ } else if (entries.length === 1) {
387
+ selectedLocale = entries[0] ?? null;
388
+ selectionBasis = "sole-localized-value";
389
+ }
390
+ return Object.freeze({
391
+ value: selectedLocale === null ? null : localized[selectedLocale] ?? null,
392
+ localized: Object.freeze(localized),
393
+ preferredLocale,
394
+ selectedLocale,
395
+ selectionBasis,
396
+ });
397
+ }
398
+
399
+ function exactContactsInput(input: unknown): Readonly<{
400
+ start: number;
401
+ count: number;
402
+ }> {
403
+ const source = strictRecord(input, "LinkedIn contacts.list input");
404
+ exactKeys(source, [], ["start", "count"], "LinkedIn contacts.list input");
405
+ return Object.freeze({
406
+ start: inputInteger(source.start, "input.start", 0, 0, CONNECTIONS_MAX_START),
407
+ count: inputInteger(
408
+ source.count,
409
+ "input.count",
410
+ CONNECTIONS_DEFAULT_COUNT,
411
+ 1,
412
+ CONNECTIONS_MAX_COUNT,
413
+ ),
414
+ });
415
+ }
416
+
417
+ function contactsAccountSubject(context: ProviderActionContext): string {
418
+ const subject = context.auth.subject;
419
+ if (
420
+ typeof subject !== "string"
421
+ || !/^urn:li:person:[A-Za-z0-9_-]{1,256}$/u.test(subject)
422
+ ) {
423
+ throw new Error(
424
+ "LinkedIn contacts.list requires an OAuth locator with an exact person URN subject",
425
+ );
426
+ }
427
+ context.addRequiredScopes(["r_1st_connections", "r_liteprofile"]);
428
+ return subject;
429
+ }
430
+
431
+ function restApiHeaders(context: ProviderActionContext, json = false): Headers {
432
+ return bearerHeaders(context.token.accessToken, {
433
+ Accept: "application/json",
434
+ "Linkedin-Version": LINKEDIN_VERSION,
435
+ "X-Restli-Protocol-Version": LINKEDIN_PROTOCOL_VERSION,
436
+ ...(json ? { "Content-Type": "application/json" } : {}),
437
+ });
438
+ }
439
+
440
+ function v2ApiHeaders(context: ProviderActionContext): Headers {
441
+ return bearerHeaders(context.token.accessToken, {
442
+ Accept: "application/json",
443
+ "X-Restli-Protocol-Version": LINKEDIN_PROTOCOL_VERSION,
444
+ });
445
+ }
446
+
447
+ function jsonBody(value: unknown): string {
448
+ return JSON.stringify(value);
449
+ }
450
+
451
+ function apiUrl(path: string): URL {
452
+ return new URL(path, LINKEDIN_API_ORIGIN);
453
+ }
454
+
455
+ function encodedUrn(value: string): string {
456
+ return encodeURIComponent(value)
457
+ .replaceAll("!", "%21")
458
+ .replaceAll("'", "%27")
459
+ .replaceAll("(", "%28")
460
+ .replaceAll(")", "%29")
461
+ .replaceAll("*", "%2A");
462
+ }
463
+
464
+ function actorKind(value: string): "member" | "organization" {
465
+ if (!isLinkedInProviderActorSubject(value)) {
466
+ throw new Error("LinkedIn actor must be an exact person or organization URN");
467
+ }
468
+ return value.startsWith("urn:li:person:") ? "member" : "organization";
469
+ }
470
+
471
+ function postUrn(value: string): string {
472
+ if (!/^urn:li:(?:share|ugcPost):[0-9]{1,32}$/u.test(value)) {
473
+ throw new Error("LinkedIn post must be an exact share or UGC Post URN");
474
+ }
475
+ return value;
476
+ }
477
+
478
+ function rootObjectUrn(value: string): string {
479
+ if (!/^urn:li:(?:activity|share|ugcPost):[0-9]{1,32}$/u.test(value)) {
480
+ throw new Error("LinkedIn object must be an exact activity, share, or UGC Post URN");
481
+ }
482
+ return value;
483
+ }
484
+
485
+ function commentUrn(value: string): string {
486
+ if (!/^urn:li:comment:\(urn:li:activity:[0-9]{1,32},[0-9]{1,32}\)$/u.test(value)) {
487
+ throw new Error("LinkedIn comment must be an exact composite comment URN");
488
+ }
489
+ return value;
490
+ }
491
+
492
+ function socialTargetUrn(value: string): string {
493
+ if (
494
+ /^urn:li:(?:activity|share|ugcPost):[0-9]{1,32}$/u.test(value)
495
+ || /^urn:li:comment:\(urn:li:activity:[0-9]{1,32},[0-9]{1,32}\)$/u.test(value)
496
+ ) return value;
497
+ throw new Error("LinkedIn social target must be an exact post, activity, or composite comment URN");
498
+ }
499
+
500
+ function commentReadTargetUrn(value: string): string {
501
+ if (
502
+ /^urn:li:(?:share|ugcPost):[0-9]{1,32}$/u.test(value)
503
+ || /^urn:li:comment:\(urn:li:activity:[0-9]{1,32},[0-9]{1,32}\)$/u.test(value)
504
+ ) return value;
505
+ throw new Error("LinkedIn comment target must be an exact share, UGC Post, or composite comment URN");
506
+ }
507
+
508
+ function requireWriteActor(
509
+ context: ProviderActionContext,
510
+ actor: string,
511
+ scopes: { readonly member: string; readonly organization: string },
512
+ ): "member" | "organization" {
513
+ const kind = actorKind(actor);
514
+ if (context.auth.subject === undefined) {
515
+ throw new Error("LinkedIn write capabilities require an OAuth locator with an exact subject URN");
516
+ }
517
+ if (context.auth.subject !== actor) {
518
+ throw new Error("LinkedIn write actor does not match the OAuth locator subject");
519
+ }
520
+ context.addRequiredScopes([kind === "member" ? scopes.member : scopes.organization]);
521
+ return kind;
522
+ }
523
+
524
+ function linkedInPostUrl(id: string): string {
525
+ return `https://www.linkedin.com/feed/update/${postUrn(id)}/`;
526
+ }
527
+
528
+ function linkedInCommentUrl(target: string, createdCommentUrn: string | null): string {
529
+ const url = new URL(linkedInPostUrl(target));
530
+ if (createdCommentUrn !== null) url.searchParams.set("commentUrn", createdCommentUrn);
531
+ return url.toString();
532
+ }
533
+
534
+ function scalarResponseString(record_: Record<string, unknown>, key: string): string | null {
535
+ const value = record_[key];
536
+ return typeof value === "string" && value.length <= 8_192 ? value : null;
537
+ }
538
+
539
+ function scalarResponseNumber(record_: Record<string, unknown>, key: string): number | null {
540
+ const value = record_[key];
541
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
542
+ }
543
+
544
+ function optionalResponseString(
545
+ record_: Record<string, unknown>,
546
+ key: string,
547
+ label: string,
548
+ maximum = 8_192,
549
+ ): string | null {
550
+ return record_[key] === undefined ? null : responseString(record_[key], label, maximum);
551
+ }
552
+
553
+ function exactResponsePost(value: unknown, expectedId: string): Record<string, unknown> {
554
+ const post = record(value, "post resource");
555
+ const id = postUrn(responseString(post.id, "post ID", 500));
556
+ if (id !== expectedId) throw new Error("LinkedIn returned a post other than the requested post URN");
557
+ return normalizePost(post);
558
+ }
559
+
560
+ function exactAuthorPost(value: unknown, expectedAuthor: string): Record<string, unknown> {
561
+ const post = record(value, "author post resource");
562
+ postUrn(responseString(post.id, "author post ID", 500));
563
+ const author = responseString(post.author, "author post author", 500);
564
+ actorKind(author);
565
+ if (author !== expectedAuthor) {
566
+ throw new Error("LinkedIn author finder returned a post for a different author");
567
+ }
568
+ return normalizePost(post);
569
+ }
570
+
571
+ function normalizePost(value: unknown): Record<string, unknown> {
572
+ const post = record(value, "post resource");
573
+ return {
574
+ id: scalarResponseString(post, "id"),
575
+ author: scalarResponseString(post, "author"),
576
+ commentary: scalarResponseString(post, "commentary"),
577
+ visibility: scalarResponseString(post, "visibility"),
578
+ lifecycleState: scalarResponseString(post, "lifecycleState"),
579
+ createdAt: scalarResponseNumber(post, "createdAt"),
580
+ publishedAt: scalarResponseNumber(post, "publishedAt"),
581
+ lastModifiedAt: scalarResponseNumber(post, "lastModifiedAt"),
582
+ distribution: post.distribution ?? null,
583
+ content: post.content ?? null,
584
+ };
585
+ }
586
+
587
+ function normalizeComment(value: unknown): Record<string, unknown> {
588
+ const comment = record(value, "comment resource");
589
+ const message = comment.message === undefined ? null : record(comment.message, "comment message");
590
+ const created = comment.created === undefined ? null : record(comment.created, "comment creation stamp");
591
+ const lastModified = comment.lastModified === undefined
592
+ ? null
593
+ : record(comment.lastModified, "comment modification stamp");
594
+ return {
595
+ id: scalarResponseString(comment, "id"),
596
+ commentUrn: scalarResponseString(comment, "commentUrn"),
597
+ actor: scalarResponseString(comment, "actor"),
598
+ object: scalarResponseString(comment, "object"),
599
+ parentComment: scalarResponseString(comment, "parentComment"),
600
+ text: message === null ? null : scalarResponseString(message, "text"),
601
+ createdAt: created === null ? null : scalarResponseNumber(created, "time"),
602
+ lastModifiedAt: lastModified === null ? null : scalarResponseNumber(lastModified, "time"),
603
+ content: comment.content ?? null,
604
+ };
605
+ }
606
+
607
+ function exactCommentIdentity(comment: Record<string, unknown>, label: string): {
608
+ readonly id: string;
609
+ readonly urn: string;
610
+ } {
611
+ const id = responseString(comment.id, `${label} ID`, 32);
612
+ if (!/^[0-9]{1,32}$/u.test(id)) throw new Error(`LinkedIn returned an invalid ${label} ID`);
613
+ const urn = commentUrn(responseString(comment.commentUrn, `${label} URN`, 1_000));
614
+ if (!urn.endsWith(`,${id})`)) {
615
+ throw new Error(`LinkedIn returned inconsistent ${label} ID and URN fields`);
616
+ }
617
+ return { id, urn };
618
+ }
619
+
620
+ function exactListedComment(
621
+ value: unknown,
622
+ target: string,
623
+ nested: boolean,
624
+ ): Record<string, unknown> {
625
+ const comment = record(value, nested ? "reply resource" : "comment resource");
626
+ exactCommentIdentity(comment, nested ? "reply" : "comment");
627
+ actorKind(responseString(comment.actor, `${nested ? "reply" : "comment"} actor`, 500));
628
+ rootObjectUrn(responseString(comment.object, `${nested ? "reply" : "comment"} object`, 1_000));
629
+ const parent = optionalResponseString(comment, "parentComment", "parent comment URN", 1_000);
630
+ if (nested) {
631
+ if (parent === null || commentUrn(parent) !== target) {
632
+ throw new Error("LinkedIn reply listing returned a row for a different parent comment");
633
+ }
634
+ } else if (parent !== null) {
635
+ commentUrn(parent);
636
+ throw new Error("LinkedIn top-level comment listing returned a nested reply");
637
+ }
638
+ return {
639
+ ...normalizeComment(comment),
640
+ // LinkedIn documents that a comment's object/activity can differ from the
641
+ // share or UGC Post used in the collection URL. Preserve that provider
642
+ // field and separately retain the exact request-bound collection target.
643
+ requestedTarget: target,
644
+ };
645
+ }
646
+
647
+ function exactConnection(value: unknown, index: number): Readonly<Record<string, unknown>> {
648
+ const label = `LinkedIn connections response.elements[${index}]`;
649
+ const source = strictRecord(value, label);
650
+ exactKeys(source, ["to", "to~"], [], label);
651
+ const urn = personUrn(source.to, `${label}.to`);
652
+ const decorated = strictRecord(source["to~"], `${label}.to~`);
653
+ exactKeys(decorated, ["id", "firstName", "lastName"], [], `${label}.to~`);
654
+ const id = personId(decorated.id, `${label}.to~.id`);
655
+ if (urn !== `urn:li:person:${id}`) {
656
+ throw new Error("LinkedIn returned a decorated connection ID that did not match its person URN");
657
+ }
658
+ const firstName = localizedConnectionName(
659
+ decorated.firstName,
660
+ `${label}.to~.firstName`,
661
+ );
662
+ const lastName = localizedConnectionName(
663
+ decorated.lastName,
664
+ `${label}.to~.lastName`,
665
+ );
666
+ return Object.freeze({
667
+ providerId: urn,
668
+ personId: id,
669
+ displayName: firstName.value === null || lastName.value === null
670
+ ? null
671
+ : `${firstName.value} ${lastName.value}`,
672
+ firstName: firstName.value,
673
+ lastName: lastName.value,
674
+ localizedFirstName: firstName.localized,
675
+ localizedLastName: lastName.localized,
676
+ firstNamePreferredLocale: firstName.preferredLocale,
677
+ lastNamePreferredLocale: lastName.preferredLocale,
678
+ firstNameSelectedLocale: firstName.selectedLocale,
679
+ lastNameSelectedLocale: lastName.selectedLocale,
680
+ firstNameSelectionBasis: firstName.selectionBasis,
681
+ lastNameSelectionBasis: lastName.selectionBasis,
682
+ ...unavailableConnectionStatsProjection,
683
+ });
684
+ }
685
+
686
+ function exactQueryEntries(url: URL): readonly string[] {
687
+ return [...url.searchParams.entries()]
688
+ .filter(([key]) => key !== "start" && key !== "count")
689
+ .map(([key, value]) => `${key}\u0000${value}`)
690
+ .sort();
691
+ }
692
+
693
+ function exactPagingParameter(url: URL, key: "start" | "count"): number {
694
+ const values = url.searchParams.getAll(key);
695
+ if (values.length !== 1 || !/^[0-9]{1,6}$/u.test(values[0] ?? "")) {
696
+ throw new Error(`LinkedIn returned an invalid next-page ${key}`);
697
+ }
698
+ return responseInteger(Number(values[0]), `next-page ${key}`);
699
+ }
700
+
701
+ function validatedNextLink(linkValue: unknown, requestUrl: URL): URL {
702
+ const link = record(linkValue, "next paging link");
703
+ const href = responseString(link.href, "next paging link href", 8_192);
704
+ const next = new URL(href, LINKEDIN_API_ORIGIN);
705
+ if (
706
+ next.protocol !== "https:"
707
+ || next.hostname !== "api.linkedin.com"
708
+ || next.port !== ""
709
+ || next.username !== ""
710
+ || next.password !== ""
711
+ || next.hash !== ""
712
+ || next.pathname !== requestUrl.pathname
713
+ || JSON.stringify(exactQueryEntries(next)) !== JSON.stringify(exactQueryEntries(requestUrl))
714
+ ) throw new Error("LinkedIn returned a next paging link for a different collection");
715
+ return next;
716
+ }
717
+
718
+ function connectionPageOutput(
719
+ responseBody: unknown,
720
+ accountSubject: string,
721
+ requestedStart: number,
722
+ requestedCount: number,
723
+ requestUrl: URL,
724
+ ): Readonly<Record<string, unknown>> {
725
+ const body = strictRecord(responseBody, "LinkedIn connections response");
726
+ exactKeys(body, ["elements", "paging"], [], "LinkedIn connections response");
727
+ const elements = strictArray(
728
+ body.elements,
729
+ "LinkedIn connections response.elements",
730
+ requestedCount,
731
+ );
732
+ const paging = strictRecord(body.paging, "LinkedIn connections response.paging");
733
+ exactKeys(
734
+ paging,
735
+ ["start", "count", "total", "links"],
736
+ [],
737
+ "LinkedIn connections response.paging",
738
+ );
739
+ const start = responseInteger(paging.start, "connections paging start");
740
+ const count = responseInteger(paging.count, "connections paging count");
741
+ const total = responseInteger(paging.total, "connections paging total");
742
+ if (start !== requestedStart || count !== requestedCount) {
743
+ throw new Error("LinkedIn connections paging did not bind the exact requested page");
744
+ }
745
+ const expectedReturned = Math.min(count, Math.max(total - start, 0));
746
+ if (elements.length !== expectedReturned) {
747
+ throw new Error(
748
+ "LinkedIn connections page length contradicted its exact paging total",
749
+ );
750
+ }
751
+
752
+ const links = strictArray(
753
+ paging.links,
754
+ "LinkedIn connections response.paging.links",
755
+ 2,
756
+ );
757
+ const seenRelations = new Set<string>();
758
+ for (let index = 0; index < links.length; index += 1) {
759
+ const label = `LinkedIn connections response.paging.links[${index}]`;
760
+ const link = strictRecord(links[index], label);
761
+ exactKeys(link, ["rel", "href"], ["type"], label);
762
+ const relation = structuredResponseText(link.rel, `${label}.rel`, 4);
763
+ if (relation !== "next" && relation !== "prev") {
764
+ throw new Error(`LinkedIn returned an invalid connections paging relation ${relation}`);
765
+ }
766
+ if (seenRelations.has(relation)) {
767
+ throw new Error(`LinkedIn returned duplicate ${relation} connections paging links`);
768
+ }
769
+ seenRelations.add(relation);
770
+ const href = structuredResponseText(link.href, `${label}.href`, 8_192);
771
+ if (/\s/u.test(href)) throw new Error("LinkedIn returned an invalid connections paging URL");
772
+ if (link.type !== undefined && link.type !== "application/json") {
773
+ throw new Error("LinkedIn returned an unsupported connections paging media type");
774
+ }
775
+ let linkedPage: URL;
776
+ try {
777
+ linkedPage = validatedNextLink({ href }, requestUrl);
778
+ } catch (error) {
779
+ throw new Error("LinkedIn returned a connections paging link for a different collection", {
780
+ cause: error,
781
+ });
782
+ }
783
+ const linkedCount = exactPagingParameter(linkedPage, "count");
784
+ const linkedStart = exactPagingParameter(linkedPage, "start");
785
+ if (linkedCount !== count) {
786
+ throw new Error("LinkedIn connections paging link changed the requested count");
787
+ }
788
+ const expectedStart = relation === "next"
789
+ ? start + count
790
+ : Math.max(0, start - count);
791
+ if (
792
+ linkedStart !== expectedStart
793
+ || (relation === "next" && start + elements.length >= total)
794
+ || (relation === "prev" && start === 0)
795
+ ) {
796
+ throw new Error(
797
+ `LinkedIn returned a contradictory ${relation} connections paging link`,
798
+ );
799
+ }
800
+ }
801
+
802
+ const contacts = Object.freeze(elements.map((value, index) =>
803
+ exactConnection(value, index)));
804
+ const providerIds = contacts.map((contact) => contact.providerId);
805
+ if (new Set(providerIds).size !== providerIds.length) {
806
+ throw new Error("LinkedIn returned duplicate connections within one page");
807
+ }
808
+ const hasMore = start + contacts.length < total;
809
+ // The Connections API documents empty paging links even when total exceeds
810
+ // the current page, and documents continuation as start + count. Derive it
811
+ // only after exact request, total, and returned-row binding above.
812
+ const candidateNextStart = hasMore ? start + count : null;
813
+ const nextStart = candidateNextStart !== null
814
+ && candidateNextStart <= CONNECTIONS_MAX_START
815
+ ? candidateNextStart
816
+ : null;
817
+ const offsetBoundReached = candidateNextStart !== null && nextStart === null;
818
+ const contactSetComplete = start === 0 && contacts.length === total;
819
+ const contactSetCompleteness = contactSetComplete
820
+ ? "complete"
821
+ : offsetBoundReached
822
+ ? "bounded"
823
+ : nextStart === null
824
+ ? "terminal-page"
825
+ : "page";
826
+ return Object.freeze({
827
+ schemaVersion: 1,
828
+ provider: "linkedin",
829
+ operation: "contacts.list",
830
+ accountSubject,
831
+ contacts,
832
+ metadataScope: "documented-first-degree-connections-decorated-name-page",
833
+ contactSetCompleteness,
834
+ contactTruncated: !contactSetComplete,
835
+ contactSetIncompleteReasons: Object.freeze(contactSetComplete
836
+ ? []
837
+ : [
838
+ "requested-offset-page-only",
839
+ ...(offsetBoundReached ? ["connection-offset-bound-reached"] : []),
840
+ ]),
841
+ totalItems: total,
842
+ paging: Object.freeze({
843
+ start,
844
+ count,
845
+ returned: contacts.length,
846
+ total,
847
+ }),
848
+ nextStart,
849
+ statsScope: "unavailable-from-linkedin-connections-api",
850
+ });
851
+ }
852
+
853
+ function nextOffset(
854
+ pagingValue: unknown,
855
+ requestedStart: number,
856
+ requestedCount: number,
857
+ returnedCount: number,
858
+ requestUrl: URL,
859
+ ): { readonly known: boolean; readonly value: number | null } {
860
+ if (pagingValue === undefined) return { known: false, value: null };
861
+ const paging = record(pagingValue, "paging object");
862
+ const start = responseInteger(paging.start, "paging start");
863
+ const count = responseInteger(paging.count, "paging count");
864
+ if (start !== requestedStart) throw new Error("LinkedIn paging start did not match the requested page");
865
+ if (count !== requestedCount || returnedCount > count) {
866
+ throw new Error("LinkedIn paging count did not match the requested page");
867
+ }
868
+ const linksValue = paging.links;
869
+ if (linksValue !== undefined && !Array.isArray(linksValue)) {
870
+ throw new Error("LinkedIn returned invalid paging links");
871
+ }
872
+ let nextLink: URL | null = null;
873
+ for (const linkValue of linksValue ?? []) {
874
+ const link = record(linkValue, "paging link");
875
+ const rel = responseString(link.rel, "paging link relation", 100);
876
+ if (rel !== "next") continue;
877
+ if (nextLink !== null) throw new Error("LinkedIn returned multiple next paging links");
878
+ nextLink = validatedNextLink(link, requestUrl);
879
+ }
880
+
881
+ // Never manufacture a continuation from a full page. Some official Rest.li
882
+ // shapes omit links altogether; without an authenticated next link the
883
+ // safest truthful state is unknown rather than a guessed offset.
884
+ if (linksValue === undefined) return { known: false, value: null };
885
+ if (nextLink === null) return { known: true, value: null };
886
+
887
+ // LinkedIn's documented offset contract defines a short page as the end of
888
+ // the collection. A contradictory next link is not safe to follow.
889
+ if (returnedCount < count) {
890
+ throw new Error("LinkedIn returned a next paging link after a terminal short page");
891
+ }
892
+
893
+ const offset = start + count;
894
+ if (!Number.isSafeInteger(offset) || offset <= requestedStart || offset > 100_000) {
895
+ throw new Error("LinkedIn returned a paging cursor outside wrench's bounds");
896
+ }
897
+ const linkedStart = exactPagingParameter(nextLink, "start");
898
+ const linkedCount = exactPagingParameter(nextLink, "count");
899
+ if (linkedStart !== offset || linkedCount !== count) {
900
+ throw new Error("LinkedIn next paging link would skip or repeat collection rows");
901
+ }
902
+ return { known: true, value: offset };
903
+ }
904
+
905
+ function pageOutput(
906
+ operation: "posts.read" | "comments.read",
907
+ coverage: "author-posts" | "comments",
908
+ responseBody: unknown,
909
+ start: number,
910
+ count: number,
911
+ requestUrl: URL,
912
+ normalize: (value: unknown) => Record<string, unknown>,
913
+ limitations: readonly string[],
914
+ ): Record<string, unknown> {
915
+ const body = record(responseBody, "collection response");
916
+ if (!Array.isArray(body.elements) || body.elements.length > count) {
917
+ throw new Error("LinkedIn returned an invalid or over-broad result page");
918
+ }
919
+ const cursor = nextOffset(body.paging, start, count, body.elements.length, requestUrl);
920
+ const items = body.elements.map(normalize);
921
+ const identities = items.map((item) => item.id).filter((id): id is string => typeof id === "string");
922
+ if (new Set(identities).size !== identities.length) {
923
+ throw new Error("LinkedIn returned duplicate resource IDs within one page");
924
+ }
925
+ return {
926
+ schemaVersion: 1,
927
+ provider: "linkedin",
928
+ operation,
929
+ coverage,
930
+ completeness: !cursor.known ? "unknown" : cursor.value === null ? "complete" : "page",
931
+ cursor: cursor.value === null ? null : { kind: "offset", start: cursor.value },
932
+ limitations: [
933
+ ...limitations,
934
+ ...(!cursor.known ? ["LinkedIn omitted verifiable paging-link metadata, so page completeness is unknown."] : []),
935
+ ],
936
+ items,
937
+ };
938
+ }
939
+
940
+ function singleOutput(
941
+ operation: "posts.read",
942
+ item: Record<string, unknown>,
943
+ ): Record<string, unknown> {
944
+ return {
945
+ schemaVersion: 1,
946
+ provider: "linkedin",
947
+ operation,
948
+ coverage: "post",
949
+ completeness: "complete",
950
+ cursor: null,
951
+ limitations: ["This exact Posts API resource is not the algorithmic LinkedIn Home feed."],
952
+ item,
953
+ };
954
+ }
955
+
956
+ function writeOutput(
957
+ operation: "posts.publish" | "posts.repost" | "comments.create" | "replies.create" | "reactions.set",
958
+ result: Record<string, unknown>,
959
+ limitations: readonly string[] = [],
960
+ ): Record<string, unknown> {
961
+ return {
962
+ schemaVersion: 1,
963
+ provider: "linkedin",
964
+ operation,
965
+ coverage: "write-result",
966
+ completeness: "confirmed",
967
+ cursor: null,
968
+ limitations,
969
+ result,
970
+ };
971
+ }
972
+
973
+ async function resharePost(context: ProviderActionContext): Promise<void> {
974
+ const author = requiredString(context.input.author, "input.author", 500);
975
+ const kind = requireWriteActor(context, author, {
976
+ member: "w_member_social",
977
+ organization: "w_organization_social",
978
+ });
979
+ const target = postUrn(requiredString(context.input.target_urn, "input.target_urn", 500));
980
+ const commentary = requiredString(context.input.body, "input.body", 3_000);
981
+ const visibility = context.input.visibility === undefined
982
+ ? "PUBLIC"
983
+ : requiredString(context.input.visibility, "input.visibility", 20);
984
+ if (visibility !== "PUBLIC" && visibility !== "CONNECTIONS") {
985
+ throw new Error("input.visibility must be PUBLIC or CONNECTIONS");
986
+ }
987
+ if (kind === "organization" && visibility === "CONNECTIONS") {
988
+ throw new Error("LinkedIn organization reshares cannot use CONNECTIONS visibility");
989
+ }
990
+
991
+ const id = await context.dispatch(async () => {
992
+ const response = await context.http.request(apiUrl("/rest/posts"), {
993
+ method: "POST",
994
+ headers: restApiHeaders(context, true),
995
+ body: jsonBody({
996
+ author,
997
+ commentary,
998
+ visibility,
999
+ distribution: {
1000
+ feedDistribution: "MAIN_FEED",
1001
+ targetEntities: [],
1002
+ thirdPartyDistributionChannels: [],
1003
+ },
1004
+ lifecycleState: "PUBLISHED",
1005
+ isReshareDisabledByAuthor: false,
1006
+ reshareContext: { parent: target },
1007
+ }),
1008
+ }, [201], LINKEDIN_API_HOSTS);
1009
+ return postUrn(responseString(response.headers.get("x-restli-id"), "created reshare ID", 500));
1010
+ });
1011
+
1012
+ const url = linkedInPostUrl(id);
1013
+ context.setOutput(writeOutput("posts.repost", { id, url, author, target, visibility }));
1014
+ context.setFinalUrl(url);
1015
+ }
1016
+
1017
+ function sameFile(left: BigIntStats, right: BigIntStats): boolean {
1018
+ return left.dev === right.dev
1019
+ && left.ino === right.ino
1020
+ && left.size === right.size
1021
+ && left.mtimeNs === right.mtimeNs
1022
+ && left.ctimeNs === right.ctimeNs
1023
+ && left.mode === right.mode;
1024
+ }
1025
+
1026
+ function openProviderFile(
1027
+ file: ProviderFile,
1028
+ maximumBytes: number,
1029
+ expectedIdentity?: BigIntStats,
1030
+ minimumBytes = 1,
1031
+ ): { readonly descriptor: number; readonly stats: BigIntStats } {
1032
+ if (!isAbsolute(file.path)) throw new Error("LinkedIn media resolver returned a non-absolute path");
1033
+ if (!Number.isSafeInteger(file.bytes) || file.bytes < minimumBytes || file.bytes > maximumBytes) {
1034
+ throw new Error(`LinkedIn media must contain between ${minimumBytes} and ${maximumBytes} bytes`);
1035
+ }
1036
+ const descriptor = openSync(
1037
+ file.path,
1038
+ constants.O_RDONLY
1039
+ | ("O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0)
1040
+ | ("O_NONBLOCK" in constants ? constants.O_NONBLOCK : 0),
1041
+ );
1042
+ try {
1043
+ const stats = fstatSync(descriptor, { bigint: true });
1044
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
1045
+ if (
1046
+ !stats.isFile()
1047
+ || stats.size !== BigInt(file.bytes)
1048
+ || (uid !== undefined && stats.uid !== BigInt(uid))
1049
+ || (process.platform !== "win32" && (stats.mode & 0o777n) !== 0o600n)
1050
+ ) throw new Error("LinkedIn media must resolve to the reviewed, current-user-owned mode-0600 regular file");
1051
+ if (expectedIdentity !== undefined && !sameFile(expectedIdentity, stats)) {
1052
+ throw new Error("LinkedIn media identity changed after its confirmed preflight");
1053
+ }
1054
+ return { descriptor, stats };
1055
+ } catch (error) {
1056
+ closeSync(descriptor);
1057
+ throw error;
1058
+ }
1059
+ }
1060
+
1061
+ function readVerifiedFile(
1062
+ file: ProviderFile,
1063
+ maximumBytes: number,
1064
+ materialize: boolean,
1065
+ minimumBytes = 1,
1066
+ ): VerifiedProviderRead {
1067
+ const opened = openProviderFile(file, maximumBytes, undefined, minimumBytes);
1068
+ const output = materialize ? Buffer.alloc(file.bytes) : null;
1069
+ const scratch = materialize ? null : Buffer.alloc(Math.min(FILE_READ_CHUNK_BYTES, file.bytes));
1070
+ const hash = createHash("sha256");
1071
+ let offset = 0;
1072
+ try {
1073
+ while (offset < file.bytes) {
1074
+ const destination = output ?? scratch;
1075
+ if (destination === null) throw new Error("LinkedIn media read buffer is unavailable");
1076
+ const destinationOffset = output === null ? 0 : offset;
1077
+ const length = Math.min(
1078
+ output === null ? destination.byteLength : FILE_READ_CHUNK_BYTES,
1079
+ file.bytes - offset,
1080
+ );
1081
+ const count = readSync(opened.descriptor, destination, destinationOffset, length, offset);
1082
+ if (count < 1) throw new Error("LinkedIn media ended before its reviewed byte length");
1083
+ hash.update(destination.subarray(destinationOffset, destinationOffset + count));
1084
+ offset += count;
1085
+ }
1086
+ const after = fstatSync(opened.descriptor, { bigint: true });
1087
+ if (!sameFile(opened.stats, after)) throw new Error("LinkedIn media changed while it was validated");
1088
+ if (hash.digest("hex") !== file.sha256) throw new Error("LinkedIn media no longer matches the confirmed attachment digest");
1089
+ return { identity: opened.stats, bytes: output };
1090
+ } finally {
1091
+ closeSync(opened.descriptor);
1092
+ }
1093
+ }
1094
+
1095
+ async function prepareMedia(context: ProviderActionContext): Promise<PreparedMedia> {
1096
+ const rawMedia = context.input.media;
1097
+ if (rawMedia === undefined) return { kind: "none" };
1098
+ if (!Array.isArray(rawMedia) || rawMedia.length < 1 || rawMedia.length > 20) {
1099
+ throw new Error("LinkedIn media must contain between one and twenty reviewed files");
1100
+ }
1101
+ const files = await context.resolveFiles("media");
1102
+ if (files.length !== rawMedia.length) throw new Error("LinkedIn media did not resolve every reviewed file");
1103
+
1104
+ const allImages = files.every((file) => imageMediaTypes.has(file.mediaType));
1105
+ if (allImages) {
1106
+ const items = files.map((file) => {
1107
+ const bytes = readVerifiedFile(file, MAX_IMAGE_BYTES, true).bytes;
1108
+ if (bytes === null) throw new Error("LinkedIn image could not be materialized");
1109
+ return { file, bytes };
1110
+ });
1111
+ return { kind: "images", items };
1112
+ }
1113
+
1114
+ if (files.length !== 1) {
1115
+ throw new Error("LinkedIn media must be all images, one supported document, or one video");
1116
+ }
1117
+ const file = files[0];
1118
+ if (file === undefined) throw new Error("LinkedIn media resolver omitted its file");
1119
+ if (documentMediaTypes.has(file.mediaType)) {
1120
+ const title = requiredString(context.input.media_title, "input.media_title", 200);
1121
+ const bytes = readVerifiedFile(file, MAX_DOCUMENT_BYTES, true).bytes;
1122
+ if (bytes === null) throw new Error("LinkedIn document could not be materialized");
1123
+ return { kind: "document", file, bytes, title };
1124
+ }
1125
+ if (videoMediaTypes.has(file.mediaType)) {
1126
+ const title = optionalString(context.input.media_title, "input.media_title", 200);
1127
+ const verified = readVerifiedFile(file, MAX_VIDEO_BYTES, false, MIN_VIDEO_BYTES);
1128
+ return {
1129
+ kind: "video",
1130
+ prepared: { file, preflightIdentity: verified.identity },
1131
+ title,
1132
+ };
1133
+ }
1134
+ throw new Error("LinkedIn media type is not supported by the official Posts API contract");
1135
+ }
1136
+
1137
+ function validatePostAuxiliaryInputs(context: ProviderActionContext, media: PreparedMedia): {
1138
+ readonly article: Record<string, string> | null;
1139
+ readonly altText: string | null;
1140
+ } {
1141
+ const articleUrl = optionalString(context.input.article_url, "input.article_url");
1142
+ const articleTitle = optionalString(context.input.article_title, "input.article_title", 399);
1143
+ const articleDescription = optionalString(context.input.article_description, "input.article_description", 4_000);
1144
+ const altText = optionalString(context.input.alt_text, "input.alt_text", 4_000);
1145
+
1146
+ if (articleUrl !== null) {
1147
+ if (media.kind !== "none") throw new Error("LinkedIn article cards and uploaded media are mutually exclusive");
1148
+ if (articleTitle === null) throw new Error("input.article_title is required for a LinkedIn external article card");
1149
+ const url = new URL(articleUrl);
1150
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "") {
1151
+ throw new Error("LinkedIn external article cards require a credential-free HTTPS source URL");
1152
+ }
1153
+ } else if (articleTitle !== null || articleDescription !== null) {
1154
+ throw new Error("LinkedIn article title and description require input.article_url");
1155
+ }
1156
+ if (altText !== null && media.kind !== "images") {
1157
+ throw new Error("input.alt_text is only supported for LinkedIn image posts");
1158
+ }
1159
+ if (context.input.media_title !== undefined && media.kind !== "document" && media.kind !== "video") {
1160
+ throw new Error("input.media_title is only supported for LinkedIn document and video posts");
1161
+ }
1162
+
1163
+ return {
1164
+ article: articleUrl === null || articleTitle === null
1165
+ ? null
1166
+ : {
1167
+ source: articleUrl,
1168
+ title: articleTitle,
1169
+ ...(articleDescription === null ? {} : { description: articleDescription }),
1170
+ },
1171
+ altText,
1172
+ };
1173
+ }
1174
+
1175
+ function uploadUrl(value: unknown): URL {
1176
+ const url = new URL(responseString(value, "media upload URL"));
1177
+ const allowedHost = url.hostname === "www.linkedin.com" || url.hostname === "www.linkedin-ei.com";
1178
+ const allowedPath = url.pathname.startsWith("/dms-uploads/") || url.pathname.startsWith("/dms-mpf-uploads/");
1179
+ if (
1180
+ url.protocol !== "https:"
1181
+ || url.username !== ""
1182
+ || url.password !== ""
1183
+ || url.port !== ""
1184
+ || !allowedHost
1185
+ || !allowedPath
1186
+ ) throw new Error("LinkedIn returned an unapproved media upload destination");
1187
+ return url;
1188
+ }
1189
+
1190
+ async function putUploadBytes(
1191
+ context: ProviderActionContext,
1192
+ destination: URL,
1193
+ bytes: Uint8Array,
1194
+ expectedStatuses: readonly number[],
1195
+ headers: Headers,
1196
+ ): Promise<Headers> {
1197
+ const response = await context.http.request(destination, {
1198
+ method: "PUT",
1199
+ headers,
1200
+ body: new Uint8Array(bytes),
1201
+ }, expectedStatuses, [destination.hostname]);
1202
+ return response.headers;
1203
+ }
1204
+
1205
+ async function uploadAuthenticatedBytes(
1206
+ context: ProviderActionContext,
1207
+ destination: URL,
1208
+ bytes: Uint8Array,
1209
+ expectedStatuses: readonly number[],
1210
+ ): Promise<Headers> {
1211
+ return putUploadBytes(
1212
+ context,
1213
+ destination,
1214
+ bytes,
1215
+ expectedStatuses,
1216
+ bearerHeaders(context.token.accessToken, { "Content-Type": "application/octet-stream" }),
1217
+ );
1218
+ }
1219
+
1220
+ async function uploadSignedVideoPart(
1221
+ context: ProviderActionContext,
1222
+ destination: URL,
1223
+ bytes: Uint8Array,
1224
+ ): Promise<Headers> {
1225
+ return putUploadBytes(
1226
+ context,
1227
+ destination,
1228
+ bytes,
1229
+ [200, 201],
1230
+ new Headers({ "Content-Type": "application/octet-stream" }),
1231
+ );
1232
+ }
1233
+
1234
+ type MediaResourceKind = "images" | "documents" | "videos";
1235
+
1236
+ function mediaStatusDelay(headers: Headers): number {
1237
+ const retryAfter = headers.get("retry-after");
1238
+ if (retryAfter !== null && /^[0-9]{1,9}$/u.test(retryAfter)) {
1239
+ const seconds = Number(retryAfter);
1240
+ if (Number.isSafeInteger(seconds) && Number.isSafeInteger(seconds * 1_000)) return seconds * 1_000;
1241
+ }
1242
+ if (retryAfter !== null) {
1243
+ const date = Date.parse(retryAfter);
1244
+ if (Number.isFinite(date)) return Math.max(0, date - Date.now());
1245
+ }
1246
+ return DEFAULT_MEDIA_STATUS_DELAY_MS;
1247
+ }
1248
+
1249
+ async function waitForMediaAvailable(
1250
+ context: ProviderActionContext,
1251
+ kind: MediaResourceKind,
1252
+ id: string,
1253
+ ): Promise<void> {
1254
+ const mediaDeadline = Date.now() + MAX_MEDIA_STATUS_WAIT_MS;
1255
+ for (let attempt = 0; attempt < MAX_MEDIA_STATUS_POLLS; attempt += 1) {
1256
+ const response = await context.http.request(
1257
+ apiUrl(`/rest/${kind}/${encodedUrn(id)}`),
1258
+ { method: "GET", headers: restApiHeaders(context) },
1259
+ [200],
1260
+ LINKEDIN_API_HOSTS,
1261
+ );
1262
+ const resource = record(response.body, `${kind.slice(0, -1)} status response`);
1263
+ const status = responseString(resource.status, `${kind.slice(0, -1)} status`, 100);
1264
+ if (status === "AVAILABLE") return;
1265
+ if (status === "PROCESSING_FAILED") {
1266
+ throw new Error(`LinkedIn ${kind.slice(0, -1)} processing failed`);
1267
+ }
1268
+ if (status !== "WAITING_UPLOAD" && status !== "PROCESSING") {
1269
+ throw new Error(`LinkedIn returned an unsupported ${kind.slice(0, -1)} processing status`);
1270
+ }
1271
+
1272
+ const remaining = Math.min(mediaDeadline - Date.now(), context.http.remainingTimeMs());
1273
+ if (attempt + 1 >= MAX_MEDIA_STATUS_POLLS || remaining <= 0) break;
1274
+ const delay = mediaStatusDelay(response.headers);
1275
+ if (delay >= remaining) break;
1276
+ if (delay > 0) await new Promise<void>((resolve) => setTimeout(resolve, delay));
1277
+ }
1278
+ throw new Error(`LinkedIn ${kind.slice(0, -1)} processing did not become AVAILABLE within the bounded polling window`);
1279
+ }
1280
+
1281
+ async function initializeAndUploadImage(
1282
+ context: ProviderActionContext,
1283
+ owner: string,
1284
+ bytes: Uint8Array,
1285
+ ): Promise<string> {
1286
+ const response = await context.http.request(
1287
+ apiUrl("/rest/images?action=initializeUpload"),
1288
+ {
1289
+ method: "POST",
1290
+ headers: restApiHeaders(context, true),
1291
+ body: jsonBody({ initializeUploadRequest: { owner } }),
1292
+ },
1293
+ [200],
1294
+ LINKEDIN_API_HOSTS,
1295
+ );
1296
+ const value = record(record(response.body, "image initialization response").value, "image initialization value");
1297
+ const id = responseString(value.image, "image URN", 500);
1298
+ if (!/^urn:li:image:[A-Za-z0-9_-]{1,256}$/u.test(id)) {
1299
+ throw new Error("LinkedIn returned an invalid image URN");
1300
+ }
1301
+ await uploadAuthenticatedBytes(context, uploadUrl(value.uploadUrl), bytes, [200, 201]);
1302
+ await waitForMediaAvailable(context, "images", id);
1303
+ return id;
1304
+ }
1305
+
1306
+ async function initializeAndUploadDocument(
1307
+ context: ProviderActionContext,
1308
+ owner: string,
1309
+ bytes: Uint8Array,
1310
+ ): Promise<string> {
1311
+ const response = await context.http.request(
1312
+ apiUrl("/rest/documents?action=initializeUpload"),
1313
+ {
1314
+ method: "POST",
1315
+ headers: restApiHeaders(context, true),
1316
+ body: jsonBody({ initializeUploadRequest: { owner } }),
1317
+ },
1318
+ [200],
1319
+ LINKEDIN_API_HOSTS,
1320
+ );
1321
+ const value = record(record(response.body, "document initialization response").value, "document initialization value");
1322
+ const id = responseString(value.document, "document URN", 500);
1323
+ if (!/^urn:li:document:[A-Za-z0-9_-]{1,256}$/u.test(id)) {
1324
+ throw new Error("LinkedIn returned an invalid document URN");
1325
+ }
1326
+ await uploadAuthenticatedBytes(context, uploadUrl(value.uploadUrl), bytes, [200, 201]);
1327
+ await waitForMediaAvailable(context, "documents", id);
1328
+ return id;
1329
+ }
1330
+
1331
+ function uploadInstructions(value: unknown, fileBytes: number): readonly UploadInstruction[] {
1332
+ if (!Array.isArray(value) || value.length < 1 || value.length > 128) {
1333
+ throw new Error("LinkedIn returned an invalid video upload instruction list");
1334
+ }
1335
+ const instructions: UploadInstruction[] = [];
1336
+ let expectedFirstByte = 0;
1337
+ for (const item of value) {
1338
+ const instruction = record(item, "video upload instruction");
1339
+ const firstByte = responseInteger(instruction.firstByte, "video first byte");
1340
+ const lastByte = responseInteger(instruction.lastByte, "video last byte");
1341
+ if (firstByte !== expectedFirstByte || lastByte < firstByte || lastByte >= fileBytes) {
1342
+ throw new Error("LinkedIn video upload byte ranges are not authoritative contiguous file coverage");
1343
+ }
1344
+ instructions.push({ firstByte, lastByte, uploadUrl: uploadUrl(instruction.uploadUrl).toString() });
1345
+ expectedFirstByte = lastByte + 1;
1346
+ }
1347
+ if (expectedFirstByte !== fileBytes) {
1348
+ throw new Error("LinkedIn video upload byte ranges do not cover the reviewed file exactly");
1349
+ }
1350
+ return instructions;
1351
+ }
1352
+
1353
+ function safeEtag(value: string | null): string {
1354
+ if (
1355
+ value === null
1356
+ || value.length < 1
1357
+ || value.length > 8_192
1358
+ || value.trim() !== value
1359
+ || hasAnyAsciiControl(value)
1360
+ || /^W\//iu.test(value)
1361
+ ) {
1362
+ throw new Error("LinkedIn video upload did not return a usable ETag");
1363
+ }
1364
+ const startsQuoted = value.startsWith('"');
1365
+ const endsQuoted = value.endsWith('"');
1366
+ if (startsQuoted || endsQuoted) {
1367
+ if (!startsQuoted || !endsQuoted || value.length < 3) {
1368
+ throw new Error("LinkedIn video upload did not return a usable ETag");
1369
+ }
1370
+ const unquoted = value.slice(1, -1);
1371
+ if (unquoted.includes('"') || hasAnyAsciiControl(unquoted)) {
1372
+ throw new Error("LinkedIn video upload did not return a usable ETag");
1373
+ }
1374
+ return unquoted;
1375
+ }
1376
+ if (value.includes('"')) throw new Error("LinkedIn video upload did not return a usable ETag");
1377
+ return value;
1378
+ }
1379
+
1380
+ function readFileRange(descriptor: number, firstByte: number, lastByte: number): Buffer {
1381
+ const length = lastByte - firstByte + 1;
1382
+ const buffer = Buffer.alloc(length);
1383
+ let offset = 0;
1384
+ while (offset < length) {
1385
+ const count = readSync(descriptor, buffer, offset, length - offset, firstByte + offset);
1386
+ if (count < 1) throw new Error("LinkedIn video ended before an authoritative upload range");
1387
+ offset += count;
1388
+ }
1389
+ return buffer;
1390
+ }
1391
+
1392
+ async function initializeUploadAndFinalizeVideo(
1393
+ context: ProviderActionContext,
1394
+ owner: string,
1395
+ prepared: PreparedProviderFile,
1396
+ ): Promise<string> {
1397
+ const { file } = prepared;
1398
+ // Keep the exact preflight identity open across initialization, every signed
1399
+ // part upload, digest verification, and finalization. A replaced file is
1400
+ // rejected before the first provider request or remote byte.
1401
+ const opened = openProviderFile(file, MAX_VIDEO_BYTES, prepared.preflightIdentity, MIN_VIDEO_BYTES);
1402
+ const hash = createHash("sha256");
1403
+ const etags: string[] = [];
1404
+ try {
1405
+ const initialized = await context.http.request(
1406
+ apiUrl("/rest/videos?action=initializeUpload"),
1407
+ {
1408
+ method: "POST",
1409
+ headers: restApiHeaders(context, true),
1410
+ body: jsonBody({
1411
+ initializeUploadRequest: {
1412
+ owner,
1413
+ fileSizeBytes: file.bytes,
1414
+ uploadCaptions: false,
1415
+ uploadThumbnail: false,
1416
+ },
1417
+ }),
1418
+ },
1419
+ [200],
1420
+ LINKEDIN_API_HOSTS,
1421
+ );
1422
+ const value = record(record(initialized.body, "video initialization response").value, "video initialization value");
1423
+ const id = responseString(value.video, "video URN", 500);
1424
+ if (!/^urn:li:video:[A-Za-z0-9_-]{1,256}$/u.test(id)) throw new Error("LinkedIn returned an invalid video URN");
1425
+ const token = responseString(value.uploadToken, "video upload token", 4_096, true);
1426
+ const instructions = uploadInstructions(value.uploadInstructions, file.bytes);
1427
+
1428
+ for (const instruction of instructions) {
1429
+ const bytes = readFileRange(opened.descriptor, instruction.firstByte, instruction.lastByte);
1430
+ hash.update(bytes);
1431
+ const headers = await uploadSignedVideoPart(context, new URL(instruction.uploadUrl), bytes);
1432
+ etags.push(safeEtag(headers.get("etag")));
1433
+ }
1434
+ const after = fstatSync(opened.descriptor, { bigint: true });
1435
+ if (!sameFile(opened.stats, after)) throw new Error("LinkedIn video changed during upload");
1436
+ if (hash.digest("hex") !== file.sha256) {
1437
+ throw new Error("LinkedIn video no longer matches the confirmed attachment digest");
1438
+ }
1439
+
1440
+ await context.http.request(
1441
+ apiUrl("/rest/videos?action=finalizeUpload"),
1442
+ {
1443
+ method: "POST",
1444
+ headers: restApiHeaders(context, true),
1445
+ body: jsonBody({
1446
+ finalizeUploadRequest: {
1447
+ video: id,
1448
+ uploadToken: token,
1449
+ uploadedPartIds: etags,
1450
+ },
1451
+ }),
1452
+ },
1453
+ [200],
1454
+ LINKEDIN_API_HOSTS,
1455
+ );
1456
+ await waitForMediaAvailable(context, "videos", id);
1457
+ return id;
1458
+ } finally {
1459
+ closeSync(opened.descriptor);
1460
+ }
1461
+ }
1462
+
1463
+ function exactViewerPersonSubject(value: unknown): string {
1464
+ const viewer = strictRecord(value, "LinkedIn viewer response");
1465
+ exactKeys(viewer, ["id"], [], "LinkedIn viewer response");
1466
+ return `urn:li:person:${personId(viewer.id, "viewer response.id")}`;
1467
+ }
1468
+
1469
+ async function listConnections(context: ProviderActionContext): Promise<void> {
1470
+ const input = exactContactsInput(context.input);
1471
+ const locatorSubject = contactsAccountSubject(context);
1472
+ const viewerUrl = apiUrl("/v2/me");
1473
+ viewerUrl.searchParams.set("projection", "(id)");
1474
+ const viewerResponse = await context.http.request(viewerUrl, {
1475
+ method: "GET",
1476
+ headers: v2ApiHeaders(context),
1477
+ }, [200], LINKEDIN_API_HOSTS, undefined, "application/json");
1478
+ const accountSubject = exactViewerPersonSubject(viewerResponse.body);
1479
+ if (!Buffer.from(accountSubject, "utf8").equals(Buffer.from(locatorSubject, "utf8"))) {
1480
+ throw new Error(
1481
+ "authenticated LinkedIn person does not match the OAuth locator subject",
1482
+ );
1483
+ }
1484
+
1485
+ const url = apiUrl("/v2/connections");
1486
+ url.searchParams.set("q", "viewer");
1487
+ url.searchParams.set("projection", CONNECTIONS_PROJECTION);
1488
+ url.searchParams.set("start", String(input.start));
1489
+ url.searchParams.set("count", String(input.count));
1490
+ const response = await context.http.request(url, {
1491
+ method: "GET",
1492
+ headers: v2ApiHeaders(context),
1493
+ }, [200], LINKEDIN_API_HOSTS, undefined, "application/json");
1494
+ context.setOutput(connectionPageOutput(
1495
+ response.body,
1496
+ accountSubject,
1497
+ input.start,
1498
+ input.count,
1499
+ url,
1500
+ ));
1501
+ }
1502
+
1503
+ async function readPosts(context: ProviderActionContext): Promise<void> {
1504
+ const mode = requiredString(context.input.mode, "input.mode", 20);
1505
+ const view = context.input.view === undefined
1506
+ ? "READER"
1507
+ : requiredString(context.input.view, "input.view", 20);
1508
+ if (view !== "READER" && view !== "AUTHOR") throw new Error("input.view must be READER or AUTHOR");
1509
+
1510
+ if (mode === "one") {
1511
+ const id = postUrn(requiredString(context.input.post_urn, "input.post_urn", 500));
1512
+ const url = apiUrl(`/rest/posts/${encodedUrn(id)}`);
1513
+ url.searchParams.set("viewContext", view);
1514
+ const response = await context.http.request(url, {
1515
+ method: "GET",
1516
+ headers: restApiHeaders(context),
1517
+ }, [200], LINKEDIN_API_HOSTS);
1518
+ context.setOutput(singleOutput("posts.read", exactResponsePost(response.body, id)));
1519
+ context.setFinalUrl(linkedInPostUrl(id));
1520
+ return;
1521
+ }
1522
+ if (mode !== "author") throw new Error("input.mode must be one or author");
1523
+
1524
+ const author = requiredString(context.input.author, "input.author", 500);
1525
+ const kind = actorKind(author);
1526
+ context.addRequiredScopes([kind === "member" ? "r_member_social" : "r_organization_social"]);
1527
+ const start = inputInteger(context.input.start, "input.start", 0, 0, 100_000);
1528
+ const count = inputInteger(context.input.count, "input.count", 10, 1, 100);
1529
+ const sort = context.input.sort === undefined
1530
+ ? "LAST_MODIFIED"
1531
+ : requiredString(context.input.sort, "input.sort", 20);
1532
+ if (sort !== "LAST_MODIFIED" && sort !== "CREATED") {
1533
+ throw new Error("input.sort must be LAST_MODIFIED or CREATED");
1534
+ }
1535
+ const url = apiUrl("/rest/posts");
1536
+ url.searchParams.set("author", author);
1537
+ url.searchParams.set("q", "author");
1538
+ url.searchParams.set("start", String(start));
1539
+ url.searchParams.set("count", String(count));
1540
+ url.searchParams.set("sortBy", sort);
1541
+ url.searchParams.set("viewContext", view);
1542
+ const headers = restApiHeaders(context);
1543
+ headers.set("X-RestLi-Method", "FINDER");
1544
+ const response = await context.http.request(url, { method: "GET", headers }, [200], LINKEDIN_API_HOSTS);
1545
+ context.setOutput(pageOutput(
1546
+ "posts.read",
1547
+ "author-posts",
1548
+ response.body,
1549
+ start,
1550
+ count,
1551
+ url,
1552
+ (value) => exactAuthorPost(value, author),
1553
+ [
1554
+ "The author finder does not reconstruct LinkedIn's algorithmic Home feed.",
1555
+ "Member post reads require LinkedIn's restricted r_member_social approval.",
1556
+ ],
1557
+ ));
1558
+ }
1559
+
1560
+ async function readComments(context: ProviderActionContext): Promise<void> {
1561
+ const target = commentReadTargetUrn(requiredString(context.input.target_urn, "input.target_urn", 1_000));
1562
+ const start = inputInteger(context.input.start, "input.start", 0, 0, 100_000);
1563
+ const count = inputInteger(context.input.count, "input.count", 10, 1, 100);
1564
+ const url = apiUrl(`/rest/socialActions/${encodedUrn(target)}/comments`);
1565
+ url.searchParams.set("start", String(start));
1566
+ url.searchParams.set("count", String(count));
1567
+ const response = await context.http.request(url, {
1568
+ method: "GET",
1569
+ headers: restApiHeaders(context),
1570
+ }, [200], LINKEDIN_API_HOSTS);
1571
+ context.setOutput(pageOutput(
1572
+ "comments.read",
1573
+ "comments",
1574
+ response.body,
1575
+ start,
1576
+ count,
1577
+ url,
1578
+ (value) => exactListedComment(value, target, target.startsWith("urn:li:comment:")),
1579
+ [
1580
+ "One comment level is returned per request; pass a parent comment URN to read its replies.",
1581
+ "LinkedIn comment read scopes are restricted and require approved API access.",
1582
+ ...(!target.startsWith("urn:li:comment:")
1583
+ ? ["For top-level collections, requestedTarget records the exact share or UGC Post request path; LinkedIn's canonical response object/activity may legitimately differ and is preserved separately."]
1584
+ : []),
1585
+ ],
1586
+ ));
1587
+ }
1588
+
1589
+ function createdCommentIdentity(
1590
+ bodyValue: unknown,
1591
+ headers: Headers,
1592
+ expected: {
1593
+ readonly actor: string;
1594
+ readonly object: string;
1595
+ readonly parent: string | null;
1596
+ readonly text: string;
1597
+ },
1598
+ ): {
1599
+ readonly id: string;
1600
+ readonly urn: string | null;
1601
+ readonly comment: Record<string, unknown>;
1602
+ } {
1603
+ const body = record(bodyValue, "created comment response");
1604
+ const headerId = responseString(headers.get("x-restli-id"), "created comment header ID", 32);
1605
+ const bodyId = responseString(body.id, "created comment body ID", 32);
1606
+ if (!/^[0-9]{1,32}$/u.test(headerId) || !/^[0-9]{1,32}$/u.test(bodyId)) {
1607
+ throw new Error("LinkedIn created a comment without returning usable header and body IDs");
1608
+ }
1609
+ if (headerId !== bodyId) {
1610
+ throw new Error("LinkedIn created comment header and body IDs disagreed");
1611
+ }
1612
+ const urn = commentUrn(responseString(body.commentUrn, "created comment URN", 1_000));
1613
+ if (!urn.endsWith(`,${bodyId})`)) {
1614
+ throw new Error("LinkedIn created comment ID and URN disagreed");
1615
+ }
1616
+
1617
+ const actor = responseString(body.actor, "created comment actor", 500);
1618
+ actorKind(actor);
1619
+ if (actor !== expected.actor) throw new Error("LinkedIn created comment actor disagreed with the requested actor");
1620
+ const object = rootObjectUrn(responseString(body.object, "created comment object", 1_000));
1621
+ if (object !== expected.object) throw new Error("LinkedIn created comment object disagreed with the requested object");
1622
+ const parent = optionalResponseString(body, "parentComment", "created comment parent", 1_000);
1623
+ if (expected.parent === null) {
1624
+ if (parent !== null) throw new Error("LinkedIn created a nested reply instead of the requested top-level comment");
1625
+ } else if (parent === null || commentUrn(parent) !== expected.parent) {
1626
+ throw new Error("LinkedIn created comment parent disagreed with the requested parent");
1627
+ }
1628
+ const message = record(body.message, "created comment message");
1629
+ if (responseString(message.text, "created comment text", 500) !== expected.text) {
1630
+ throw new Error("LinkedIn created comment text disagreed with the requested text");
1631
+ }
1632
+ return { id: bodyId, urn, comment: normalizeComment(body) };
1633
+ }
1634
+
1635
+ async function createComment(context: ProviderActionContext, reply: boolean): Promise<void> {
1636
+ const actor = requiredString(context.input.actor, "input.actor", 500);
1637
+ requireWriteActor(context, actor, {
1638
+ member: "w_member_social_feed",
1639
+ organization: "w_organization_social_feed",
1640
+ });
1641
+ const object = rootObjectUrn(requiredString(context.input.object_urn, "input.object_urn", 1_000));
1642
+ const body = requiredString(context.input.body, "input.body", 500);
1643
+ const target = reply
1644
+ ? commentUrn(requiredString(context.input.parent_comment_urn, "input.parent_comment_urn", 1_000))
1645
+ : postUrn(requiredString(context.input.target_urn, "input.target_urn", 1_000));
1646
+ const requestBody = {
1647
+ actor,
1648
+ object,
1649
+ message: { text: body },
1650
+ ...(reply ? { parentComment: target } : {}),
1651
+ };
1652
+
1653
+ const created = await context.dispatch(async () => {
1654
+ const response = await context.http.request(
1655
+ apiUrl(`/rest/socialActions/${encodedUrn(target)}/comments`),
1656
+ {
1657
+ method: "POST",
1658
+ headers: restApiHeaders(context, true),
1659
+ body: jsonBody(requestBody),
1660
+ },
1661
+ [201],
1662
+ LINKEDIN_API_HOSTS,
1663
+ );
1664
+ return createdCommentIdentity(response.body, response.headers, {
1665
+ actor,
1666
+ object,
1667
+ parent: reply ? target : null,
1668
+ text: body,
1669
+ });
1670
+ });
1671
+ const rootPost = reply ? (object.startsWith("urn:li:share:") || object.startsWith("urn:li:ugcPost:") ? object : null) : target;
1672
+ const finalUrl = rootPost === null ? null : linkedInCommentUrl(rootPost, created.urn);
1673
+ context.setOutput(writeOutput(reply ? "replies.create" : "comments.create", {
1674
+ id: created.id,
1675
+ commentUrn: created.urn,
1676
+ actor,
1677
+ object,
1678
+ parentComment: reply ? target : null,
1679
+ url: finalUrl,
1680
+ comment: created.comment,
1681
+ }));
1682
+ if (finalUrl !== null) context.setFinalUrl(finalUrl);
1683
+ }
1684
+
1685
+ async function setReaction(context: ProviderActionContext): Promise<void> {
1686
+ const actor = requiredString(context.input.actor, "input.actor", 500);
1687
+ requireWriteActor(context, actor, {
1688
+ member: "w_member_social_feed",
1689
+ organization: "w_organization_social_feed",
1690
+ });
1691
+ const target = socialTargetUrn(requiredString(context.input.target_urn, "input.target_urn", 1_000));
1692
+ if (typeof context.input.enabled !== "boolean") throw new Error("input.enabled must be a boolean");
1693
+ const enabled = context.input.enabled;
1694
+ let reaction: string | null = null;
1695
+ if (enabled) {
1696
+ reaction = requiredString(context.input.reaction, "input.reaction", 40);
1697
+ if (!reactionTypes.has(reaction)) throw new Error("input.reaction is not a supported LinkedIn reaction type");
1698
+ } else if (context.input.reaction !== undefined) {
1699
+ throw new Error("input.reaction is not accepted when input.enabled is false because LinkedIn clears any current reaction");
1700
+ }
1701
+
1702
+ const result = await context.dispatch(async (): Promise<Record<string, unknown>> => {
1703
+ if (enabled) {
1704
+ if (reaction === null) throw new Error("input.reaction is required when input.enabled is true");
1705
+ const url = apiUrl("/rest/reactions");
1706
+ url.searchParams.set("actor", actor);
1707
+ const response = await context.http.request(url, {
1708
+ method: "POST",
1709
+ headers: restApiHeaders(context, true),
1710
+ body: jsonBody({ root: target, reactionType: reaction }),
1711
+ }, [201], LINKEDIN_API_HOSTS);
1712
+ const body = record(response.body, "created reaction response");
1713
+ const root = socialTargetUrn(responseString(body.root, "created reaction root", 1_000));
1714
+ if (root !== target) throw new Error("LinkedIn created reaction root disagreed with the requested target");
1715
+ const returnedType = responseString(body.reactionType, "created reaction type", 40);
1716
+ if (returnedType !== reaction) {
1717
+ throw new Error("LinkedIn created reaction type disagreed with the requested reaction");
1718
+ }
1719
+ const id = responseString(body.id, "created reaction ID", 2_000);
1720
+ const expectedId = `urn:li:reaction:(${actor},${root})`;
1721
+ if (id !== expectedId) {
1722
+ throw new Error("LinkedIn created reaction ID did not canonically bind the requested actor and target");
1723
+ }
1724
+ const headerId = response.headers.get("x-restli-id");
1725
+ if (headerId !== null && responseString(headerId, "created reaction header ID", 2_000) !== id) {
1726
+ throw new Error("LinkedIn created reaction header and body IDs disagreed");
1727
+ }
1728
+ const topLevelActor = optionalResponseString(body, "actor", "created reaction actor", 500);
1729
+ if (topLevelActor !== null && topLevelActor !== actor) {
1730
+ throw new Error("LinkedIn created reaction actor disagreed with the requested actor");
1731
+ }
1732
+ const created = record(body.created, "created reaction creation stamp");
1733
+ if (responseString(created.actor, "created reaction creation actor", 500) !== actor) {
1734
+ throw new Error("LinkedIn created reaction audit actor disagreed with the requested actor");
1735
+ }
1736
+ if (body.lastModified !== undefined) {
1737
+ const lastModified = record(body.lastModified, "created reaction modification stamp");
1738
+ if (responseString(lastModified.actor, "created reaction modification actor", 500) !== actor) {
1739
+ throw new Error("LinkedIn created reaction audit actor disagreed with the requested actor");
1740
+ }
1741
+ }
1742
+ return {
1743
+ id,
1744
+ actor,
1745
+ target,
1746
+ reaction,
1747
+ enabled: true,
1748
+ };
1749
+ }
1750
+ const key = `(actor:${encodedUrn(actor)},entity:${encodedUrn(target)})`;
1751
+ await context.http.request(apiUrl(`/rest/reactions/${key}`), {
1752
+ method: "DELETE",
1753
+ headers: restApiHeaders(context),
1754
+ }, [204], LINKEDIN_API_HOSTS);
1755
+ return {
1756
+ id: null,
1757
+ actor,
1758
+ target,
1759
+ reaction: null,
1760
+ enabled: false,
1761
+ effect: "clear-any-current-reaction",
1762
+ };
1763
+ });
1764
+ context.setOutput(writeOutput("reactions.set", result));
1765
+ }
1766
+
1767
+ async function publishPost(context: ProviderActionContext): Promise<void> {
1768
+ const author = requiredString(context.input.author, "input.author", 500);
1769
+ const kind = requireWriteActor(context, author, {
1770
+ member: "w_member_social",
1771
+ organization: "w_organization_social",
1772
+ });
1773
+ const commentary = requiredString(context.input.body, "input.body", 3_000);
1774
+ const visibility = context.input.visibility === undefined
1775
+ ? "PUBLIC"
1776
+ : requiredString(context.input.visibility, "input.visibility", 20);
1777
+ if (visibility !== "PUBLIC" && visibility !== "CONNECTIONS") {
1778
+ throw new Error("input.visibility must be PUBLIC or CONNECTIONS");
1779
+ }
1780
+ if (kind === "organization" && visibility === "CONNECTIONS") {
1781
+ throw new Error("LinkedIn organization posts cannot use CONNECTIONS visibility");
1782
+ }
1783
+
1784
+ if (context.input.media !== undefined) {
1785
+ context.addRequiredScopes([kind === "member" ? "r_member_social" : "r_organization_social"]);
1786
+ }
1787
+
1788
+ // Every file and media union is resolved and digest-verified before the
1789
+ // mutation dispatch begins. Only official API and signed-upload work occurs
1790
+ // inside the single confirmed semantic dispatch.
1791
+ const media = await prepareMedia(context);
1792
+ const auxiliary = validatePostAuxiliaryInputs(context, media);
1793
+
1794
+ const created = await context.dispatch(async (): Promise<{
1795
+ readonly id: string;
1796
+ readonly mediaKind: PreparedMedia["kind"] | "article";
1797
+ readonly mediaIds: readonly string[];
1798
+ }> => {
1799
+ let content: Record<string, unknown> | null = null;
1800
+ let mediaKind: PreparedMedia["kind"] | "article" = media.kind;
1801
+ let mediaIds: readonly string[] = [];
1802
+ if (auxiliary.article !== null) {
1803
+ mediaKind = "article";
1804
+ content = { article: auxiliary.article };
1805
+ } else if (media.kind === "images") {
1806
+ const ids: string[] = [];
1807
+ for (const item of media.items) ids.push(await initializeAndUploadImage(context, author, item.bytes));
1808
+ mediaIds = ids;
1809
+ if (ids.length === 1) {
1810
+ const id = ids[0];
1811
+ if (id === undefined) throw new Error("LinkedIn image upload omitted its media URN");
1812
+ content = {
1813
+ media: {
1814
+ id,
1815
+ ...(auxiliary.altText === null ? {} : { altText: auxiliary.altText }),
1816
+ },
1817
+ };
1818
+ } else {
1819
+ content = {
1820
+ multiImage: {
1821
+ images: ids.map((id) => ({
1822
+ id,
1823
+ ...(auxiliary.altText === null ? {} : { altText: auxiliary.altText }),
1824
+ })),
1825
+ },
1826
+ };
1827
+ }
1828
+ } else if (media.kind === "document") {
1829
+ const id = await initializeAndUploadDocument(context, author, media.bytes);
1830
+ mediaIds = [id];
1831
+ content = { media: { title: media.title, id } };
1832
+ } else if (media.kind === "video") {
1833
+ const id = await initializeUploadAndFinalizeVideo(context, author, media.prepared);
1834
+ mediaIds = [id];
1835
+ content = { media: { id, ...(media.title === null ? {} : { title: media.title }) } };
1836
+ }
1837
+
1838
+ const requestBody = {
1839
+ author,
1840
+ commentary,
1841
+ visibility,
1842
+ distribution: {
1843
+ feedDistribution: "MAIN_FEED",
1844
+ targetEntities: [],
1845
+ thirdPartyDistributionChannels: [],
1846
+ },
1847
+ ...(content === null ? {} : { content }),
1848
+ lifecycleState: "PUBLISHED",
1849
+ isReshareDisabledByAuthor: false,
1850
+ };
1851
+ const response = await context.http.request(apiUrl("/rest/posts"), {
1852
+ method: "POST",
1853
+ headers: restApiHeaders(context, true),
1854
+ body: jsonBody(requestBody),
1855
+ }, [201], LINKEDIN_API_HOSTS);
1856
+ const id = responseString(response.headers.get("x-restli-id"), "created post ID", 500);
1857
+ return { id: postUrn(id), mediaKind, mediaIds };
1858
+ });
1859
+
1860
+ const url = linkedInPostUrl(created.id);
1861
+ context.setOutput(writeOutput("posts.publish", {
1862
+ id: created.id,
1863
+ url,
1864
+ author,
1865
+ visibility,
1866
+ contentKind: created.mediaKind,
1867
+ mediaIds: created.mediaIds,
1868
+ }, created.mediaKind === "video"
1869
+ ? ["ghostget validates the reviewed MP4 bytes, type, and size locally; LinkedIn validates its 3-second to 30-minute duration and supported codecs while processing it to AVAILABLE."]
1870
+ : []));
1871
+ context.setFinalUrl(url);
1872
+ }
1873
+
1874
+ /** Execute a fixed LinkedIn REST contract. No endpoint, method, or host is supplied by a manifest. */
1875
+ export async function executeLinkedInProvider(context: ProviderActionContext): Promise<void> {
1876
+ if (context.recipe.action === "contacts.list") await listConnections(context);
1877
+ else if (context.recipe.action === "posts.read") await readPosts(context);
1878
+ else if (context.recipe.action === "posts.publish") await publishPost(context);
1879
+ else if (context.recipe.action === "posts.repost") await resharePost(context);
1880
+ else if (context.recipe.action === "comments.read") await readComments(context);
1881
+ else if (context.recipe.action === "comments.create") await createComment(context, false);
1882
+ else if (context.recipe.action === "replies.create") await createComment(context, true);
1883
+ else if (context.recipe.action === "reactions.set") await setReaction(context);
1884
+ else throw new Error(`official LinkedIn provider does not implement ${context.recipe.action}`);
1885
+ }