@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,2895 @@
1
+ import { constants } from "node:fs";
2
+ import { open } from "node:fs/promises";
3
+
4
+ import { BoundedByteBuffer } from "@hraness/kb/clip/bounded-byte-buffer";
5
+
6
+ import type { GhostgetAuth } from "../auth";
7
+ import {
8
+ browserCleanupBarrier,
9
+ browserResultData,
10
+ createBrowserSession,
11
+ PreservedBrowserArtifactsError,
12
+ type BrowserFileResolver,
13
+ type BrowserSession,
14
+ } from "../browser";
15
+ import type {
16
+ FileInputValue,
17
+ OperationInput,
18
+ GhostgetManifest,
19
+ WebSessionRecipe,
20
+ } from "../model";
21
+ import { canonicalJson, sha256 } from "../canonical-json";
22
+ import { OperationDeadline, OperationDeadlineError } from "../operation-deadline";
23
+ import { pinnedHttpsFetch } from "../pinned-https";
24
+ import {
25
+ readSessionSecretSnapshot,
26
+ writeSessionSecretIfUnchanged,
27
+ type SessionSecretSnapshot,
28
+ type SessionSecretWriteResult,
29
+ } from "../session-secrets";
30
+ import { webSessionAuthSubject } from "../web-session-client";
31
+ import {
32
+ startWebSessionCleanupTrackedOperation,
33
+ type WebSessionCleanupBarrierRegistrar,
34
+ type WebSessionCleanupResourcePublisher,
35
+ type WebSessionDispatchEvent,
36
+ type WebSessionExecution,
37
+ type WebSessionOperationDeadline,
38
+ type WebSessionProviderAcceptedMutationTargetEvent,
39
+ } from "../web-session-execution";
40
+ import {
41
+ failedProviderRead,
42
+ ProviderReadResponseRejectedError,
43
+ ProviderReadTransportError,
44
+ } from "./read-failure";
45
+ import {
46
+ BLUESKY_APPVIEW_PROXY,
47
+ BLUESKY_APP_ORIGIN,
48
+ BLUESKY_CHAT_PROXY,
49
+ BLUESKY_NOTIFICATION_PROXY,
50
+ BLUESKY_VIDEO_SERVICE_ORIGIN,
51
+ BLUESKY_WEB_OPERATIONS,
52
+ BLUESKY_WEB_OPERATION_NAMES,
53
+ BLUESKY_XRPC_METHODS,
54
+ assertBlueskyText,
55
+ authorizeBlueskyVideoRequest,
56
+ authorizeBlueskyXrpcRequest,
57
+ blueskyCid,
58
+ blueskyDid,
59
+ blueskyPostUri,
60
+ parseBlueskyAtUri,
61
+ parseBlueskyBootstrapAccount,
62
+ parseBlueskyCreateRecordResponse,
63
+ parseBlueskyCurrentPostRecordResponse,
64
+ parseBlueskyDeleteRecordResponse,
65
+ parseBlueskyGetRecordResponse,
66
+ parseBlueskyRecordNotFoundResponse,
67
+ parseBlueskyRefreshSessionResponse,
68
+ parseBlueskySessionResponse,
69
+ parseBlueskyServiceAuthResponse,
70
+ parseBlueskyUploadBlobResponse,
71
+ parseBlueskyVideoJobStatusResponse,
72
+ parseBlueskyVideoUploadResponse,
73
+ projectBlueskyBookmarks,
74
+ projectBlueskyConvo,
75
+ projectBlueskyConvoList,
76
+ projectBlueskyFeed,
77
+ projectBlueskyMessages,
78
+ projectBlueskyNotifications,
79
+ projectBlueskyPostsResponse,
80
+ projectBlueskyProfile,
81
+ projectBlueskyProfileStats,
82
+ projectBlueskyThread,
83
+ type BlueskyBlobRef,
84
+ type BlueskyProjectedPost,
85
+ type BlueskyRequestBinding,
86
+ type BlueskySessionMaterial,
87
+ type BlueskyStrongRef,
88
+ type BlueskyVideoJobStatus,
89
+ type BlueskyWebOperationName,
90
+ type BlueskyXrpcMethod,
91
+ } from "./bluesky-web";
92
+ import { isoBmffVideoDimensions } from "./iso-bmff";
93
+
94
+ const MAX_BOOTSTRAP_BYTES = 64 * 1024;
95
+ const MAX_READ_BYTES = 8 * 1024 * 1024;
96
+ const MAX_IMAGE_BYTES = 2_000_000;
97
+ const MAX_VIDEO_BYTES = 100_000_000;
98
+ const BLUESKY_PUBLIC_APPVIEW_ORIGIN = "https://public.api.bsky.app";
99
+ const DEFAULT_LIMIT = 25;
100
+ const WEB_SESSION_OPERATION_LABEL = "authenticated web operation deadline";
101
+ const PUBLISH_READBACK_DELAYS_MS = Object.freeze([0, 250, 750, 1_500]);
102
+ const VIDEO_PROCESSING_DELAYS_MS = Object.freeze([
103
+ 1_000,
104
+ 1_000,
105
+ 2_000,
106
+ 2_000,
107
+ 4_000,
108
+ 4_000,
109
+ 8_000,
110
+ 8_000,
111
+ 10_000,
112
+ 10_000,
113
+ 15_000,
114
+ 15_000,
115
+ 20_000,
116
+ 20_000,
117
+ 30_000,
118
+ 30_000,
119
+ 30_000,
120
+ 30_000,
121
+ 30_000,
122
+ ] as const);
123
+ const BLUESKY_RECORD_NOT_FOUND = Symbol("bluesky-record-not-found");
124
+
125
+ type JsonRecord = Record<string, unknown>;
126
+ type BlueskyFetch = (
127
+ input: string | URL | Request,
128
+ init?: RequestInit,
129
+ ) => Promise<Response>;
130
+
131
+ export type BlueskyWebRuntimeDependencies = {
132
+ readonly fetch?: BlueskyFetch;
133
+ readonly createBrowserSession?: typeof createBrowserSession;
134
+ readonly now?: () => number;
135
+ readonly sleep?: (milliseconds: number) => Promise<void>;
136
+ /**
137
+ * Test-only seam. The result is still parsed by the sealed account parser
138
+ * before it can reach an authorization header.
139
+ */
140
+ readonly bootstrapAccount?: (
141
+ auth: GhostgetAuth,
142
+ ) => Promise<unknown>;
143
+ /** Test-only encrypted-session cache seam. */
144
+ readonly loadCachedSession?: (
145
+ auth: GhostgetAuth,
146
+ authHash: string,
147
+ ) => SessionSecretSnapshot | Promise<SessionSecretSnapshot>;
148
+ /** Test-only encrypted-session cache seam. */
149
+ readonly saveCachedSession?: (
150
+ auth: GhostgetAuth,
151
+ authHash: string,
152
+ value: unknown,
153
+ expectedContentSha256: string | null,
154
+ ) => SessionSecretWriteResult | Promise<SessionSecretWriteResult>;
155
+ };
156
+
157
+ export type BlueskyWebDesiredStateKind =
158
+ | "bookmark"
159
+ | "follow"
160
+ | "like"
161
+ | "repost";
162
+
163
+ export type BlueskyWebDesiredStateReadback =
164
+ | {
165
+ readonly kind: Exclude<BlueskyWebDesiredStateKind, "follow">;
166
+ readonly enabled: boolean;
167
+ readonly postUri: string;
168
+ }
169
+ | {
170
+ readonly kind: "follow";
171
+ readonly enabled: boolean;
172
+ readonly actorDid: string;
173
+ };
174
+
175
+ type BlueskyClient = {
176
+ readonly session: BlueskySessionMaterial;
177
+ readonly timeoutMs: number;
178
+ readonly maxOutputBytes: number;
179
+ readonly fetch: BlueskyFetch;
180
+ readonly operationDeadline?: WebSessionOperationDeadline;
181
+ };
182
+
183
+ function remainingTimeoutMs(
184
+ timeoutMs: number,
185
+ deadline: WebSessionOperationDeadline | undefined,
186
+ ): number {
187
+ deadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
188
+ const remaining = Math.min(
189
+ timeoutMs,
190
+ deadline?.remainingTimeMs() ?? timeoutMs,
191
+ );
192
+ if (remaining < 1) {
193
+ throw new Error("Bluesky authenticated web operation timed out");
194
+ }
195
+ return remaining;
196
+ }
197
+
198
+ const blueskyBootstrapManifest = Object.freeze({
199
+ schemaVersion: 2,
200
+ id: "wrench-bluesky-session-bootstrap",
201
+ version: "1.0.0",
202
+ displayName: "Ghostget Bluesky session bootstrap",
203
+ origins: Object.freeze([BLUESKY_APP_ORIGIN]),
204
+ browserDomains: Object.freeze(["bsky.app"]),
205
+ operations: Object.freeze({}),
206
+ } satisfies GhostgetManifest);
207
+
208
+ function isRecord(value: unknown): value is JsonRecord {
209
+ return typeof value === "object" && value !== null && !Array.isArray(value);
210
+ }
211
+
212
+ function record(value: unknown, label: string): JsonRecord {
213
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
214
+ return value;
215
+ }
216
+
217
+ function isBlueskyOperation(value: string): value is BlueskyWebOperationName {
218
+ return (BLUESKY_WEB_OPERATION_NAMES as readonly string[]).includes(value);
219
+ }
220
+
221
+ function inputString(
222
+ input: OperationInput,
223
+ name: string,
224
+ maximum: number,
225
+ ): string {
226
+ const value = input[name];
227
+ if (
228
+ typeof value !== "string"
229
+ || value.length < 1
230
+ || value.length > maximum
231
+ || /[\0\r]/u.test(value)
232
+ ) throw new Error(`input.${name} must be bounded text`);
233
+ return value;
234
+ }
235
+
236
+ function optionalInputString(
237
+ input: OperationInput,
238
+ name: string,
239
+ maximum: number,
240
+ ): string | undefined {
241
+ if (input[name] === undefined) return undefined;
242
+ return inputString(input, name, maximum);
243
+ }
244
+
245
+ function inputBoolean(input: OperationInput, name: string): boolean {
246
+ const value = input[name];
247
+ if (typeof value !== "boolean") throw new Error(`input.${name} must be boolean`);
248
+ return value;
249
+ }
250
+
251
+ function inputInteger(
252
+ input: OperationInput,
253
+ name: string,
254
+ fallback: number,
255
+ minimum: number,
256
+ maximum: number,
257
+ ): number {
258
+ const value = input[name] ?? fallback;
259
+ if (
260
+ !Number.isSafeInteger(value)
261
+ || (value as number) < minimum
262
+ || (value as number) > maximum
263
+ ) throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
264
+ return value as number;
265
+ }
266
+
267
+ function optionalCursor(input: OperationInput): string | undefined {
268
+ return optionalInputString(input, "cursor", 8_192);
269
+ }
270
+
271
+ function postUriInput(input: OperationInput): string {
272
+ return blueskyPostUri(inputString(input, "post_uri", 1_024), "input.post_uri").uri;
273
+ }
274
+
275
+ function convoIdInput(input: OperationInput): string {
276
+ const result = inputString(input, "convo_id", 512);
277
+ if (!/^[A-Za-z0-9._~:-]{1,512}$/u.test(result)) {
278
+ throw new Error("input.convo_id must be an exact Bluesky conversation ID");
279
+ }
280
+ return result;
281
+ }
282
+
283
+ function browserEvaluationSource(): string {
284
+ return `(()=>{if(location.origin!=="${BLUESKY_APP_ORIGIN}")throw new Error("unexpected Bluesky origin");const raw=localStorage.getItem("BSKY_STORAGE");if(typeof raw!=="string"||raw.length<2||raw.length>1048576)throw new Error("Bluesky storage unavailable");const root=JSON.parse(raw);const session=root&&typeof root==="object"&&root.session;const current=session&&typeof session==="object"&&session.currentAccount;const accounts=session&&typeof session==="object"&&session.accounts;if(!current||typeof current.did!=="string"||!Array.isArray(accounts)||accounts.length>100)throw new Error("Bluesky current account unavailable");const matches=accounts.filter(account=>account&&typeof account==="object"&&account.did===current.did);if(matches.length!==1)throw new Error("Bluesky current account ambiguous");const account=matches[0];return{did:account.did,handle:account.handle,accessJwt:account.accessJwt,refreshJwt:account.refreshJwt,service:account.service,pdsUrl:typeof account.pdsUrl==="string"?account.pdsUrl:null}})()`;
285
+ }
286
+
287
+ function bootstrapEvaluationResult(value: unknown): unknown {
288
+ if (!isRecord(value)) throw new Error("Bluesky browser bootstrap returned a malformed envelope");
289
+ let observedOrigin: string | null = null;
290
+ if (typeof value.origin === "string") {
291
+ try {
292
+ observedOrigin = new URL(value.origin).origin;
293
+ } catch {
294
+ observedOrigin = null;
295
+ }
296
+ }
297
+ if (observedOrigin !== BLUESKY_APP_ORIGIN) {
298
+ throw new Error("Bluesky browser bootstrap escaped its reviewed origin");
299
+ }
300
+ return value.result;
301
+ }
302
+
303
+ type BrowserFinalizationResult = {
304
+ readonly closeVerified: boolean;
305
+ readonly cleanupVerified: boolean;
306
+ readonly failures: readonly unknown[];
307
+ };
308
+
309
+ async function finalizeBrowserSession(
310
+ session: BrowserSession,
311
+ ): Promise<BrowserFinalizationResult> {
312
+ const failures: unknown[] = [];
313
+ let closeVerified = false;
314
+ try {
315
+ await session.close();
316
+ closeVerified = true;
317
+ } catch (error) {
318
+ failures.push(error);
319
+ }
320
+ let cleanupVerified = false;
321
+ try {
322
+ await session.cleanup();
323
+ cleanupVerified = true;
324
+ } catch (error) {
325
+ failures.push(error);
326
+ }
327
+ return Object.freeze({
328
+ closeVerified,
329
+ cleanupVerified,
330
+ failures: Object.freeze(failures),
331
+ });
332
+ }
333
+
334
+ async function bootstrapFromBrowser(
335
+ auth: GhostgetAuth,
336
+ timeoutMs: number,
337
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
338
+ operationDeadline?: WebSessionOperationDeadline,
339
+ publishCleanupResource?: WebSessionCleanupResourcePublisher,
340
+ ): Promise<unknown> {
341
+ if (auth.kind !== "browser-profile") {
342
+ throw new Error("Bluesky storage bootstrap requires bound browser-profile auth");
343
+ }
344
+ const storageAuth = {
345
+ schemaVersion: 1,
346
+ id: auth.id,
347
+ kind: "browser-profile",
348
+ profile: auth.profile,
349
+ trustUnfilteredEgress: true,
350
+ ...(auth.browserExecutable === undefined
351
+ ? {}
352
+ : { browserExecutable: auth.browserExecutable }),
353
+ ...(auth.subject === undefined ? {} : { subject: auth.subject }),
354
+ } as const satisfies GhostgetAuth;
355
+ const createSession = dependencies?.createBrowserSession ?? createBrowserSession;
356
+ const createTimeoutMs = remainingTimeoutMs(
357
+ timeoutMs,
358
+ operationDeadline,
359
+ );
360
+ const session = await createSession(blueskyBootstrapManifest, storageAuth, {
361
+ headed: false,
362
+ timeoutMs: createTimeoutMs,
363
+ maxOutputBytes: MAX_BOOTSTRAP_BYTES,
364
+ allowCodeOwnedEvaluation: true,
365
+ ...(operationDeadline === undefined
366
+ ? {}
367
+ : { operationDeadline }),
368
+ ...(publishCleanupResource === undefined
369
+ ? {}
370
+ : { publishCleanupResource }),
371
+ });
372
+ let result: unknown;
373
+ let failure: unknown;
374
+ try {
375
+ // A same-origin inert text asset exposes localStorage without executing
376
+ // the Bluesky app or allowing it to rotate the refresh token in the clone.
377
+ await session.runBatch(
378
+ [["open", `${BLUESKY_APP_ORIGIN}/robots.txt`]],
379
+ remainingTimeoutMs(timeoutMs, operationDeadline),
380
+ MAX_BOOTSTRAP_BYTES,
381
+ );
382
+ const [urlEntry] = await session.runBatch(
383
+ [["get", "url"]],
384
+ remainingTimeoutMs(timeoutMs, operationDeadline),
385
+ MAX_BOOTSTRAP_BYTES,
386
+ );
387
+ const urlData = urlEntry === undefined ? null : browserResultData(urlEntry);
388
+ const currentUrl = isRecord(urlData) && typeof urlData.url === "string"
389
+ ? new URL(urlData.url)
390
+ : null;
391
+ if (currentUrl?.origin !== BLUESKY_APP_ORIGIN) {
392
+ throw new Error("Bluesky browser bootstrap did not reach its reviewed origin");
393
+ }
394
+ const [evaluationEntry] = await session.runBatch(
395
+ [["eval", browserEvaluationSource()]],
396
+ remainingTimeoutMs(timeoutMs, operationDeadline),
397
+ MAX_BOOTSTRAP_BYTES,
398
+ );
399
+ if (evaluationEntry === undefined) {
400
+ throw new Error("Bluesky browser bootstrap omitted its evaluation result");
401
+ }
402
+ result = bootstrapEvaluationResult(browserResultData(evaluationEntry));
403
+ } catch (error) {
404
+ failure = error;
405
+ }
406
+ const finalization = await finalizeBrowserSession(session);
407
+ if (!finalization.closeVerified || !finalization.cleanupVerified) {
408
+ const cleanupEvidence = (
409
+ finalization.closeVerified
410
+ && !finalization.cleanupVerified
411
+ && session.cleanupResourceIdentity !== undefined
412
+ )
413
+ ? Object.freeze({
414
+ kind: "agent-browser-closed-artifacts-v1" as const,
415
+ resource: session.cleanupResourceIdentity,
416
+ })
417
+ : undefined;
418
+ throw new PreservedBrowserArtifactsError(
419
+ "Bluesky browser bootstrap finalization failed; private artifacts were preserved",
420
+ session.recoveryHandle ?? "session=bluesky-bootstrap;artifacts=unknown",
421
+ new AggregateError(
422
+ [
423
+ ...(failure === undefined ? [] : [failure]),
424
+ ...finalization.failures,
425
+ ],
426
+ "Bluesky browser bootstrap finalization failed",
427
+ ),
428
+ cleanupEvidence,
429
+ );
430
+ }
431
+ if (failure !== undefined) {
432
+ throw failure instanceof Error
433
+ ? failure
434
+ : new Error("Bluesky browser bootstrap failed");
435
+ }
436
+ return result;
437
+ }
438
+
439
+ type SelectedBlueskySession = {
440
+ readonly session: BlueskySessionMaterial;
441
+ readonly cache: {
442
+ readonly enabled: boolean;
443
+ readonly contentSha256: string | null;
444
+ };
445
+ };
446
+
447
+ async function loadBlueskySessionSnapshot(
448
+ auth: GhostgetAuth,
449
+ authHash: string,
450
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
451
+ ): Promise<{
452
+ readonly enabled: boolean;
453
+ readonly snapshot: SessionSecretSnapshot;
454
+ }> {
455
+ if (dependencies?.loadCachedSession !== undefined) {
456
+ return {
457
+ enabled: true,
458
+ snapshot: await dependencies.loadCachedSession(auth, authHash),
459
+ };
460
+ }
461
+ if (dependencies?.bootstrapAccount !== undefined) {
462
+ return {
463
+ enabled: false,
464
+ snapshot: Object.freeze({ value: null, contentSha256: null }),
465
+ };
466
+ }
467
+ return {
468
+ enabled: true,
469
+ snapshot: readSessionSecretSnapshot("bluesky", auth.id, authHash),
470
+ };
471
+ }
472
+
473
+ async function selectedSession(
474
+ auth: GhostgetAuth,
475
+ timeoutMs: number,
476
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
477
+ operationDeadline?: WebSessionOperationDeadline,
478
+ registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar,
479
+ ): Promise<SelectedBlueskySession> {
480
+ if (auth.kind !== "browser-profile") {
481
+ throw new Error("Bluesky authenticated API requires browser-profile auth");
482
+ }
483
+ operationDeadline?.throwIfUnavailable(
484
+ "authenticated web operation deadline",
485
+ );
486
+ if (
487
+ (dependencies?.loadCachedSession === undefined)
488
+ !== (dependencies?.saveCachedSession === undefined)
489
+ ) {
490
+ throw new Error(
491
+ "Bluesky rotating-session cache dependencies must be provided together",
492
+ );
493
+ }
494
+ const nowSeconds = Math.floor((dependencies?.now?.() ?? Date.now()) / 1_000);
495
+ const loadBrowserSession = (): Promise<unknown> => {
496
+ if (dependencies?.bootstrapAccount !== undefined) {
497
+ return dependencies.bootstrapAccount(auth);
498
+ }
499
+ return startWebSessionCleanupTrackedOperation(
500
+ registerCleanupBarrier,
501
+ (publishCleanupResource) => bootstrapFromBrowser(
502
+ auth,
503
+ timeoutMs,
504
+ dependencies,
505
+ operationDeadline,
506
+ publishCleanupResource,
507
+ ),
508
+ browserCleanupBarrier,
509
+ );
510
+ };
511
+ const value = (
512
+ operationDeadline === undefined
513
+ || dependencies?.bootstrapAccount === undefined
514
+ )
515
+ ? await loadBrowserSession()
516
+ : await operationDeadline.run(
517
+ loadBrowserSession,
518
+ "authenticated web operation deadline",
519
+ );
520
+ operationDeadline?.throwIfUnavailable(
521
+ "authenticated web operation deadline",
522
+ );
523
+ const browserSession = parseBlueskyBootstrapAccount(
524
+ value,
525
+ nowSeconds,
526
+ );
527
+ const authHash = sha256(canonicalJson(auth));
528
+ const cache = await loadBlueskySessionSnapshot(
529
+ auth,
530
+ authHash,
531
+ dependencies,
532
+ );
533
+ operationDeadline?.throwIfUnavailable(
534
+ "authenticated web operation deadline",
535
+ );
536
+ const result = (session: BlueskySessionMaterial): SelectedBlueskySession =>
537
+ Object.freeze({
538
+ session,
539
+ cache: Object.freeze({
540
+ enabled: cache.enabled,
541
+ contentSha256: cache.snapshot.contentSha256,
542
+ }),
543
+ });
544
+ if (cache.snapshot.value === null) return result(browserSession);
545
+ const cachedSession = parseBlueskyBootstrapAccount(
546
+ cache.snapshot.value,
547
+ nowSeconds,
548
+ );
549
+ if (
550
+ cachedSession.did !== browserSession.did
551
+ || cachedSession.pdsOrigin !== browserSession.pdsOrigin
552
+ ) return result(browserSession);
553
+ return result(cachedSession.refreshExpiresAt > browserSession.refreshExpiresAt
554
+ || (
555
+ cachedSession.refreshExpiresAt === browserSession.refreshExpiresAt
556
+ && cachedSession.accessExpiresAt > browserSession.accessExpiresAt
557
+ )
558
+ ? cachedSession
559
+ : browserSession);
560
+ }
561
+
562
+ function cachedSessionValue(session: BlueskySessionMaterial): Readonly<Record<string, unknown>> {
563
+ return Object.freeze({
564
+ did: session.did,
565
+ handle: session.handle,
566
+ accessJwt: session.accessJwt,
567
+ refreshJwt: session.refreshJwt,
568
+ service: session.pdsOrigin,
569
+ pdsUrl: session.pdsOrigin,
570
+ });
571
+ }
572
+
573
+ async function writeBlueskySessionSnapshot(
574
+ auth: GhostgetAuth,
575
+ authHash: string,
576
+ value: unknown,
577
+ expectedContentSha256: string | null,
578
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
579
+ ): Promise<SessionSecretWriteResult> {
580
+ return dependencies?.saveCachedSession === undefined
581
+ ? writeSessionSecretIfUnchanged(
582
+ "bluesky",
583
+ auth.id,
584
+ authHash,
585
+ value,
586
+ expectedContentSha256,
587
+ )
588
+ : dependencies.saveCachedSession(
589
+ auth,
590
+ authHash,
591
+ value,
592
+ expectedContentSha256,
593
+ );
594
+ }
595
+
596
+ function blueskySessionIsAtLeastAsFresh(
597
+ candidate: BlueskySessionMaterial,
598
+ attempted: BlueskySessionMaterial,
599
+ ): boolean {
600
+ return candidate.refreshExpiresAt > attempted.refreshExpiresAt
601
+ || (
602
+ candidate.refreshExpiresAt === attempted.refreshExpiresAt
603
+ && candidate.accessExpiresAt >= attempted.accessExpiresAt
604
+ );
605
+ }
606
+
607
+ async function saveSelectedSession(
608
+ auth: GhostgetAuth,
609
+ session: BlueskySessionMaterial,
610
+ cache: SelectedBlueskySession["cache"],
611
+ nowSeconds: number,
612
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
613
+ ): Promise<BlueskySessionMaterial> {
614
+ if (!cache.enabled) return session;
615
+ const authHash = sha256(canonicalJson(auth));
616
+ const value = cachedSessionValue(session);
617
+ const saved = await writeBlueskySessionSnapshot(
618
+ auth,
619
+ authHash,
620
+ value,
621
+ cache.contentSha256,
622
+ dependencies,
623
+ );
624
+ if (saved.written) return session;
625
+
626
+ const latest = await loadBlueskySessionSnapshot(
627
+ auth,
628
+ authHash,
629
+ dependencies,
630
+ );
631
+ if (
632
+ !latest.enabled
633
+ || latest.snapshot.contentSha256 === null
634
+ || latest.snapshot.contentSha256 === cache.contentSha256
635
+ || latest.snapshot.value === null
636
+ ) {
637
+ throw new Error(
638
+ "Bluesky rotating session state changed concurrently; retry with a fresh session",
639
+ );
640
+ }
641
+ const candidate = parseBlueskyBootstrapAccount(
642
+ latest.snapshot.value,
643
+ nowSeconds,
644
+ );
645
+ if (
646
+ candidate.did !== session.did
647
+ || candidate.pdsOrigin !== session.pdsOrigin
648
+ || !blueskySessionIsAtLeastAsFresh(candidate, session)
649
+ ) {
650
+ throw new Error(
651
+ "Bluesky rotating session state changed concurrently without a safe newer session",
652
+ );
653
+ }
654
+ return candidate;
655
+ }
656
+
657
+ async function boundedBytes(
658
+ response: Response,
659
+ maximum: number,
660
+ operationDeadline?: WebSessionOperationDeadline,
661
+ ): Promise<Uint8Array> {
662
+ operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
663
+ const declared = response.headers.get("content-length");
664
+ if (declared !== null) {
665
+ const count = Number(declared);
666
+ if (!Number.isSafeInteger(count) || count < 0 || count > maximum) {
667
+ void response.body?.cancel().catch(() => undefined);
668
+ throw new Error("Bluesky XRPC response exceeded its reviewed byte limit");
669
+ }
670
+ }
671
+ if (response.body === null) return new Uint8Array();
672
+ const reader = response.body.getReader();
673
+ const buffer = new BoundedByteBuffer(maximum);
674
+ try {
675
+ for (;;) {
676
+ const item = await (async () => {
677
+ try {
678
+ return operationDeadline === undefined
679
+ ? await reader.read()
680
+ : await operationDeadline.run(
681
+ () => reader.read(),
682
+ WEB_SESSION_OPERATION_LABEL,
683
+ );
684
+ } catch (error) {
685
+ if (error instanceof OperationDeadlineError) throw error;
686
+ throw new ProviderReadTransportError(error);
687
+ }
688
+ })();
689
+ if (item.done) break;
690
+ if (!(item.value instanceof Uint8Array) || !buffer.append(item.value)) {
691
+ void reader.cancel().catch(() => undefined);
692
+ throw new Error("Bluesky XRPC response exceeded its reviewed byte limit");
693
+ }
694
+ }
695
+ } catch (error) {
696
+ void reader.cancel().catch(() => undefined);
697
+ throw error;
698
+ } finally {
699
+ reader.releaseLock();
700
+ }
701
+ operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
702
+ return buffer.toUint8Array();
703
+ }
704
+
705
+ function jsonContentType(response: Response): boolean {
706
+ const raw = response.headers.get("content-type");
707
+ if (raw === null) return false;
708
+ const type = raw.split(";", 1)[0]?.trim().toLowerCase();
709
+ return type === "application/json" || type?.endsWith("+json") === true;
710
+ }
711
+
712
+ function parseJson(bytes: Uint8Array): unknown {
713
+ if (bytes.byteLength === 0) return Object.freeze({});
714
+ let text: string;
715
+ try {
716
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
717
+ } catch {
718
+ throw new Error("Bluesky XRPC returned invalid UTF-8");
719
+ }
720
+ try {
721
+ return JSON.parse(text) as unknown;
722
+ } catch {
723
+ throw new Error("Bluesky XRPC returned malformed JSON");
724
+ }
725
+ }
726
+
727
+ type XrpcOptions = {
728
+ readonly query?: Readonly<Record<string, readonly string[]>>;
729
+ readonly proxy?: BlueskyRequestBinding["proxy"];
730
+ readonly jsonBody?: Readonly<Record<string, unknown>>;
731
+ readonly blobBody?: {
732
+ readonly bytes: Uint8Array;
733
+ readonly mediaType: string;
734
+ };
735
+ readonly maximumBytes?: number;
736
+ readonly authorization?: {
737
+ readonly kind: "refresh";
738
+ readonly token: string;
739
+ };
740
+ /** Restricted to authoritative getRecord absence reconciliation. */
741
+ readonly recordNotFound?: true;
742
+ };
743
+
744
+ async function xrpc(
745
+ client: BlueskyClient,
746
+ nsid: BlueskyXrpcMethod,
747
+ options: XrpcOptions = {},
748
+ ): Promise<unknown> {
749
+ const query = options.query ?? {};
750
+ const proxy = options.proxy ?? null;
751
+ const method = BLUESKY_XRPC_METHODS[nsid];
752
+ const refreshRequest = options.authorization?.kind === "refresh";
753
+ if (refreshRequest !== (nsid === "com.atproto.server.refreshSession")) {
754
+ throw new Error("Bluesky refresh authorization is restricted to refreshSession");
755
+ }
756
+ if (
757
+ options.recordNotFound === true
758
+ && nsid !== "com.atproto.repo.getRecord"
759
+ ) {
760
+ throw new Error("Bluesky RecordNotFound handling is restricted to getRecord");
761
+ }
762
+ const authorizationToken = refreshRequest
763
+ ? options.authorization?.token
764
+ : client.session.accessJwt;
765
+ if (authorizationToken === undefined) {
766
+ throw new Error("Bluesky refresh authorization token is unavailable");
767
+ }
768
+ if (options.jsonBody !== undefined && options.blobBody !== undefined) {
769
+ throw new Error("Bluesky XRPC request cannot contain two body types");
770
+ }
771
+ const hasBody = options.jsonBody !== undefined || options.blobBody !== undefined;
772
+ const url = new URL(`/xrpc/${nsid}`, client.session.pdsOrigin);
773
+ for (const [name, values] of Object.entries(query)) {
774
+ for (const value of values) url.searchParams.append(name, value);
775
+ }
776
+ authorizeBlueskyXrpcRequest({
777
+ pdsOrigin: client.session.pdsOrigin,
778
+ nsid,
779
+ url,
780
+ method,
781
+ expectedQuery: query,
782
+ hasBody,
783
+ proxy,
784
+ });
785
+ const headers = new Headers({
786
+ accept: "application/json",
787
+ authorization: `Bearer ${authorizationToken}`,
788
+ ...(proxy === null ? {} : { "atproto-proxy": proxy }),
789
+ });
790
+ let body: RequestInit["body"];
791
+ if (options.jsonBody !== undefined) {
792
+ headers.set("content-type", "application/json");
793
+ body = JSON.stringify(options.jsonBody);
794
+ } else if (options.blobBody !== undefined) {
795
+ headers.set("content-type", options.blobBody.mediaType);
796
+ // Keep the upload body in the owned-byte form accepted by Ghostget's
797
+ // DNS-pinned HTTPS transport. A Web Blob reaches custom fetch fixtures,
798
+ // but the production transport deliberately rejects that body shape
799
+ // before opening a socket.
800
+ body = new Uint8Array(options.blobBody.bytes);
801
+ }
802
+ const operationDeadline = client.operationDeadline;
803
+ const controller = operationDeadline === undefined
804
+ ? new AbortController()
805
+ : undefined;
806
+ const timeoutMs = remainingTimeoutMs(client.timeoutMs, operationDeadline);
807
+ const timeout = controller === undefined
808
+ ? undefined
809
+ : setTimeout(() => controller.abort(), timeoutMs);
810
+ const signal = operationDeadline?.signal ?? controller?.signal;
811
+ if (signal === undefined) {
812
+ throw new Error("Bluesky XRPC request signal was not initialized");
813
+ }
814
+ let response: Response | undefined;
815
+ try {
816
+ try {
817
+ const request = () => client.fetch(url, {
818
+ method,
819
+ headers,
820
+ ...(body === undefined ? {} : { body }),
821
+ redirect: "error",
822
+ signal,
823
+ });
824
+ response = operationDeadline === undefined
825
+ ? await request()
826
+ : await operationDeadline.run(request, WEB_SESSION_OPERATION_LABEL);
827
+ } catch (error) {
828
+ throw new Error("Bluesky XRPC failed before a reviewed response was received", {
829
+ cause: error,
830
+ });
831
+ }
832
+ if (
833
+ response.status === 400
834
+ && options.recordNotFound === true
835
+ ) {
836
+ const bytes = await boundedBytes(
837
+ response,
838
+ 64 * 1024,
839
+ operationDeadline,
840
+ );
841
+ if (!jsonContentType(response)) {
842
+ throw new Error("Bluesky RecordNotFound response used an unreviewed content type");
843
+ }
844
+ parseBlueskyRecordNotFoundResponse(parseJson(bytes));
845
+ return BLUESKY_RECORD_NOT_FOUND;
846
+ }
847
+ if (response.status !== 200) {
848
+ response.body?.cancel().catch(() => undefined);
849
+ throw new Error(`Bluesky XRPC returned unreviewed status ${response.status}`);
850
+ }
851
+ const bytes = await boundedBytes(
852
+ response,
853
+ Math.min(options.maximumBytes ?? client.maxOutputBytes, MAX_READ_BYTES),
854
+ operationDeadline,
855
+ );
856
+ if (bytes.byteLength > 0 && !jsonContentType(response)) {
857
+ throw new Error("Bluesky XRPC returned an unreviewed content type");
858
+ }
859
+ return parseJson(bytes);
860
+ } finally {
861
+ if (timeout !== undefined) clearTimeout(timeout);
862
+ if (signal.aborted) {
863
+ void response?.body?.cancel().catch(() => undefined);
864
+ }
865
+ }
866
+ }
867
+
868
+ async function videoServiceJson(
869
+ client: BlueskyClient,
870
+ input:
871
+ | {
872
+ readonly kind: "job-status";
873
+ readonly jobId: string;
874
+ }
875
+ | {
876
+ readonly kind: "upload";
877
+ readonly bytes: Uint8Array;
878
+ readonly token: string;
879
+ },
880
+ ): Promise<unknown> {
881
+ const url = new URL(
882
+ input.kind === "upload"
883
+ ? "/xrpc/app.bsky.video.uploadVideo"
884
+ : "/xrpc/app.bsky.video.getJobStatus",
885
+ BLUESKY_VIDEO_SERVICE_ORIGIN,
886
+ );
887
+ if (input.kind === "upload") {
888
+ url.searchParams.set("did", client.session.did);
889
+ url.searchParams.set("name", "wrench-video.mp4");
890
+ } else {
891
+ url.searchParams.set("jobId", input.jobId);
892
+ }
893
+ const method = input.kind === "upload" ? "POST" : "GET";
894
+ authorizeBlueskyVideoRequest({
895
+ kind: input.kind,
896
+ url,
897
+ method,
898
+ ...(input.kind === "upload"
899
+ ? { did: client.session.did }
900
+ : { jobId: input.jobId }),
901
+ });
902
+ const headers = new Headers({ accept: "application/json" });
903
+ let body: Uint8Array | undefined;
904
+ if (input.kind === "upload") {
905
+ headers.set("authorization", `Bearer ${input.token}`);
906
+ headers.set("content-type", "video/mp4");
907
+ headers.set("content-length", String(input.bytes.byteLength));
908
+ body = new Uint8Array(input.bytes);
909
+ }
910
+ const operationDeadline = client.operationDeadline;
911
+ const controller = operationDeadline === undefined
912
+ ? new AbortController()
913
+ : undefined;
914
+ const timeoutMs = remainingTimeoutMs(client.timeoutMs, operationDeadline);
915
+ const timeout = controller === undefined
916
+ ? undefined
917
+ : setTimeout(() => controller.abort(), timeoutMs);
918
+ const signal = operationDeadline?.signal ?? controller?.signal;
919
+ if (signal === undefined) {
920
+ throw new Error("Bluesky video request signal was not initialized");
921
+ }
922
+ let response: Response | undefined;
923
+ try {
924
+ const request = () => client.fetch(url, {
925
+ method,
926
+ headers,
927
+ ...(body === undefined ? {} : { body }),
928
+ redirect: "error",
929
+ signal,
930
+ });
931
+ try {
932
+ response = operationDeadline === undefined
933
+ ? await request()
934
+ : await operationDeadline.run(request, WEB_SESSION_OPERATION_LABEL);
935
+ } catch (error) {
936
+ throw new Error("Bluesky video service failed before a reviewed response was received", {
937
+ cause: error,
938
+ });
939
+ }
940
+ if (response.status !== 200) {
941
+ response.body?.cancel().catch(() => undefined);
942
+ throw new Error(`Bluesky video service returned unreviewed status ${response.status}`);
943
+ }
944
+ const bytes = await boundedBytes(response, 512 * 1024, operationDeadline);
945
+ if (!jsonContentType(response)) {
946
+ throw new Error("Bluesky video service returned an unreviewed content type");
947
+ }
948
+ return parseJson(bytes);
949
+ } finally {
950
+ if (timeout !== undefined) clearTimeout(timeout);
951
+ if (signal.aborted) void response?.body?.cancel().catch(() => undefined);
952
+ }
953
+ }
954
+
955
+ function clientFor(
956
+ session: BlueskySessionMaterial,
957
+ timeoutMs: number,
958
+ maxOutputBytes: number,
959
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
960
+ operationDeadline?: WebSessionOperationDeadline,
961
+ ): BlueskyClient {
962
+ return Object.freeze({
963
+ session,
964
+ timeoutMs,
965
+ maxOutputBytes,
966
+ fetch: dependencies?.fetch ?? ((input, init = {}) =>
967
+ pinnedHttpsFetch(
968
+ input instanceof Request ? new URL(input.url) : new URL(input),
969
+ init,
970
+ remainingTimeoutMs(timeoutMs, operationDeadline),
971
+ )),
972
+ ...(operationDeadline === undefined ? {} : { operationDeadline }),
973
+ });
974
+ }
975
+
976
+ async function currentSession(
977
+ client: BlueskyClient,
978
+ ): Promise<{ readonly did: string; readonly handle: string; readonly active: boolean }> {
979
+ const current = parseBlueskySessionResponse(
980
+ await xrpc(client, "com.atproto.server.getSession", {
981
+ maximumBytes: 512 * 1024,
982
+ }),
983
+ );
984
+ if (!current.active || current.did !== client.session.did) {
985
+ throw new Error("Bluesky PDS session did not match the selected browser account");
986
+ }
987
+ return current;
988
+ }
989
+
990
+ function requireBoundSubject(auth: GhostgetAuth, session: BlueskySessionMaterial): string {
991
+ const expected = webSessionAuthSubject(auth);
992
+ if (expected === null) {
993
+ throw new Error("Bluesky authenticated operations require an auth locator bound to an exact DID");
994
+ }
995
+ const did = blueskyDid(expected, "Bluesky auth subject");
996
+ if (did !== session.did) {
997
+ throw new Error("Bluesky browser account did not match the bound auth subject");
998
+ }
999
+ return did;
1000
+ }
1001
+
1002
+ async function bootstrapClient(
1003
+ auth: GhostgetAuth,
1004
+ timeoutMs: number,
1005
+ maxOutputBytes: number,
1006
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
1007
+ operationDeadline?: WebSessionOperationDeadline,
1008
+ registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar,
1009
+ ): Promise<BlueskyClient> {
1010
+ const selected = await selectedSession(
1011
+ auth,
1012
+ timeoutMs,
1013
+ dependencies,
1014
+ operationDeadline,
1015
+ registerCleanupBarrier,
1016
+ );
1017
+ const nowSeconds = Math.floor((dependencies?.now?.() ?? Date.now()) / 1_000);
1018
+ let session = selected.session;
1019
+ if (selected.session.accessExpiresAt <= nowSeconds) {
1020
+ const refreshClient = clientFor(
1021
+ selected.session,
1022
+ timeoutMs,
1023
+ Math.min(maxOutputBytes, 512 * 1024),
1024
+ dependencies,
1025
+ operationDeadline,
1026
+ );
1027
+ session = parseBlueskyRefreshSessionResponse(
1028
+ await xrpc(refreshClient, "com.atproto.server.refreshSession", {
1029
+ authorization: { kind: "refresh", token: selected.session.refreshJwt },
1030
+ maximumBytes: 512 * 1024,
1031
+ }),
1032
+ selected.session,
1033
+ nowSeconds,
1034
+ );
1035
+ session = await saveSelectedSession(
1036
+ auth,
1037
+ session,
1038
+ selected.cache,
1039
+ nowSeconds,
1040
+ dependencies,
1041
+ );
1042
+ }
1043
+ const client = clientFor(
1044
+ session,
1045
+ timeoutMs,
1046
+ maxOutputBytes,
1047
+ dependencies,
1048
+ operationDeadline,
1049
+ );
1050
+ await currentSession(client);
1051
+ return client;
1052
+ }
1053
+
1054
+ export async function probeBlueskyWebSubject(
1055
+ auth: GhostgetAuth,
1056
+ options: {
1057
+ readonly timeoutMs?: number;
1058
+ readonly dependencies?: BlueskyWebRuntimeDependencies;
1059
+ readonly signal?: AbortSignal;
1060
+ } = {},
1061
+ ): Promise<string> {
1062
+ const timeoutMs = options.timeoutMs ?? 60_000;
1063
+ const deadline = new OperationDeadline(timeoutMs, {
1064
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1065
+ });
1066
+ try {
1067
+ const client = await bootstrapClient(
1068
+ auth,
1069
+ timeoutMs,
1070
+ 512 * 1024,
1071
+ options.dependencies,
1072
+ deadline,
1073
+ );
1074
+ deadline.throwIfUnavailable("authenticated web subject probe");
1075
+ return client.session.did;
1076
+ } finally {
1077
+ deadline.dispose();
1078
+ }
1079
+ }
1080
+
1081
+ async function getPost(
1082
+ client: BlueskyClient,
1083
+ uri: string,
1084
+ ): Promise<BlueskyProjectedPost> {
1085
+ return projectBlueskyPostsResponse(
1086
+ await xrpc(client, "app.bsky.feed.getPosts", {
1087
+ query: { uris: [uri] },
1088
+ proxy: BLUESKY_APPVIEW_PROXY,
1089
+ }),
1090
+ uri,
1091
+ );
1092
+ }
1093
+
1094
+ async function getAuthoritativeRecord(
1095
+ client: BlueskyClient,
1096
+ expected: BlueskyStrongRef,
1097
+ expectedValue: Readonly<Record<string, unknown>>,
1098
+ ): Promise<BlueskyStrongRef> {
1099
+ const parsed = parseBlueskyAtUri(
1100
+ expected.uri,
1101
+ "Bluesky created post URI",
1102
+ "app.bsky.feed.post",
1103
+ );
1104
+ if (parsed.actor !== client.session.did) {
1105
+ throw new Error("Bluesky created post actor did not match the bound viewer");
1106
+ }
1107
+ return parseBlueskyGetRecordResponse(
1108
+ await xrpc(client, "com.atproto.repo.getRecord", {
1109
+ query: {
1110
+ repo: [client.session.did],
1111
+ collection: ["app.bsky.feed.post"],
1112
+ rkey: [parsed.rkey],
1113
+ },
1114
+ }),
1115
+ expected,
1116
+ expectedValue,
1117
+ );
1118
+ }
1119
+
1120
+ type BlueskyAuthoritativePostPresence =
1121
+ | { readonly present: false }
1122
+ | { readonly present: true; readonly ref: BlueskyStrongRef };
1123
+
1124
+ async function authoritativePostPresence(
1125
+ client: BlueskyClient,
1126
+ uri: string,
1127
+ expectedCid: string,
1128
+ ): Promise<BlueskyAuthoritativePostPresence> {
1129
+ const parsed = parseBlueskyAtUri(
1130
+ uri,
1131
+ "Bluesky deletion target URI",
1132
+ "app.bsky.feed.post",
1133
+ );
1134
+ if (parsed.actor !== client.session.did) {
1135
+ throw new Error("Bluesky deletion target actor did not match the bound viewer");
1136
+ }
1137
+ const response = await xrpc(client, "com.atproto.repo.getRecord", {
1138
+ query: {
1139
+ repo: [client.session.did],
1140
+ collection: ["app.bsky.feed.post"],
1141
+ rkey: [parsed.rkey],
1142
+ },
1143
+ recordNotFound: true,
1144
+ });
1145
+ if (response === BLUESKY_RECORD_NOT_FOUND) {
1146
+ return Object.freeze({ present: false });
1147
+ }
1148
+ return Object.freeze({
1149
+ present: true,
1150
+ ref: parseBlueskyCurrentPostRecordResponse(
1151
+ response,
1152
+ parsed.uri,
1153
+ expectedCid,
1154
+ ),
1155
+ });
1156
+ }
1157
+
1158
+ /**
1159
+ * Reconcile deletion from the authoritative account repository only. A
1160
+ * different current CID is revision drift, not absence.
1161
+ */
1162
+ export async function readBlueskyWebContentDeleteDesiredState(
1163
+ recipe: WebSessionRecipe,
1164
+ input: OperationInput,
1165
+ auth: GhostgetAuth,
1166
+ options: {
1167
+ readonly dependencies?: BlueskyWebRuntimeDependencies;
1168
+ readonly signal?: AbortSignal;
1169
+ } = {},
1170
+ ): Promise<{ readonly present: boolean; readonly postUri: string }> {
1171
+ if (
1172
+ recipe.site !== "bluesky"
1173
+ || recipe.action !== "content.delete"
1174
+ || recipe.contractVersion !== 1
1175
+ ) {
1176
+ throw new Error("Bluesky deletion recovery supports only content.delete@1");
1177
+ }
1178
+ const deadline = new OperationDeadline(recipe.timeoutMs, {
1179
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1180
+ });
1181
+ try {
1182
+ const client = await bootstrapClient(
1183
+ auth,
1184
+ recipe.timeoutMs,
1185
+ recipe.maxOutputBytes,
1186
+ options.dependencies,
1187
+ deadline,
1188
+ );
1189
+ requireBoundSubject(auth, client.session);
1190
+ const postUri = postUriInput(input);
1191
+ const expectedCid = blueskyCid(
1192
+ inputString(input, "expected_cid", 201),
1193
+ "input.expected_cid",
1194
+ );
1195
+ const presence = await authoritativePostPresence(
1196
+ client,
1197
+ postUri,
1198
+ expectedCid,
1199
+ );
1200
+ return Object.freeze({ present: presence.present, postUri });
1201
+ } finally {
1202
+ deadline.dispose();
1203
+ }
1204
+ }
1205
+
1206
+ type BlueskyPublishedMutationTarget = {
1207
+ readonly uri: string;
1208
+ readonly cid: string;
1209
+ readonly createdAt: string;
1210
+ readonly media: null
1211
+ | {
1212
+ readonly cid: string;
1213
+ readonly mediaType: "image/jpeg" | "image/png" | "image/webp";
1214
+ readonly size: number;
1215
+ }
1216
+ | {
1217
+ readonly cid: string;
1218
+ readonly height: number;
1219
+ readonly jobId: string;
1220
+ readonly mediaType: "video/mp4";
1221
+ readonly size: number;
1222
+ readonly width: number;
1223
+ };
1224
+ };
1225
+
1226
+ function parseBlueskyPublishedMutationTarget(
1227
+ identifier: string,
1228
+ ): BlueskyPublishedMutationTarget {
1229
+ let value: unknown;
1230
+ try {
1231
+ value = JSON.parse(identifier);
1232
+ } catch {
1233
+ throw new Error("Bluesky provider-accepted post target is not canonical JSON");
1234
+ }
1235
+ const target = record(value, "Bluesky provider-accepted post target");
1236
+ if (Object.keys(target).sort().join(",") !== "cid,createdAt,media,uri") {
1237
+ throw new Error("Bluesky provider-accepted post target contained unsupported fields");
1238
+ }
1239
+ const parsedUri = parseBlueskyAtUri(
1240
+ target.uri,
1241
+ "Bluesky provider-accepted post target URI",
1242
+ "app.bsky.feed.post",
1243
+ );
1244
+ const strongRef = parseBlueskyCreateRecordResponse(
1245
+ { uri: parsedUri.uri, cid: target.cid },
1246
+ parsedUri.actor,
1247
+ "app.bsky.feed.post",
1248
+ );
1249
+ if (
1250
+ typeof target.createdAt !== "string"
1251
+ || target.createdAt.length > 64
1252
+ || Number.isNaN(Date.parse(target.createdAt))
1253
+ || new Date(target.createdAt).toISOString() !== target.createdAt
1254
+ ) {
1255
+ throw new Error("Bluesky provider-accepted post target createdAt is malformed");
1256
+ }
1257
+ let media: BlueskyPublishedMutationTarget["media"] = null;
1258
+ if (target.media !== null) {
1259
+ const rawMedia = record(
1260
+ target.media,
1261
+ "Bluesky provider-accepted post target media",
1262
+ );
1263
+ const cid = typeof rawMedia.cid === "string"
1264
+ && /^b[a-z2-7]{10,200}$/u.test(rawMedia.cid)
1265
+ ? rawMedia.cid
1266
+ : null;
1267
+ if (rawMedia.mediaType === "video/mp4") {
1268
+ if (
1269
+ Object.keys(rawMedia).sort().join(",") !== "cid,height,jobId,mediaType,size,width"
1270
+ || cid === null
1271
+ || typeof rawMedia.jobId !== "string"
1272
+ || rawMedia.jobId.length < 1
1273
+ || rawMedia.jobId.length > 1_024
1274
+ || /[\0\r]/u.test(rawMedia.jobId)
1275
+ || !Number.isSafeInteger(rawMedia.size)
1276
+ || (rawMedia.size as number) < 1
1277
+ || (rawMedia.size as number) > MAX_VIDEO_BYTES
1278
+ || !Number.isSafeInteger(rawMedia.width)
1279
+ || (rawMedia.width as number) < 1
1280
+ || (rawMedia.width as number) > 20_000
1281
+ || !Number.isSafeInteger(rawMedia.height)
1282
+ || (rawMedia.height as number) < 1
1283
+ || (rawMedia.height as number) > 20_000
1284
+ ) throw new Error("Bluesky provider-accepted video target is malformed");
1285
+ media = Object.freeze({
1286
+ cid,
1287
+ height: rawMedia.height as number,
1288
+ jobId: rawMedia.jobId,
1289
+ mediaType: "video/mp4",
1290
+ size: rawMedia.size as number,
1291
+ width: rawMedia.width as number,
1292
+ });
1293
+ } else {
1294
+ if (
1295
+ Object.keys(rawMedia).sort().join(",") !== "cid,mediaType,size"
1296
+ || cid === null
1297
+ || (
1298
+ rawMedia.mediaType !== "image/jpeg"
1299
+ && rawMedia.mediaType !== "image/png"
1300
+ && rawMedia.mediaType !== "image/webp"
1301
+ )
1302
+ || !Number.isSafeInteger(rawMedia.size)
1303
+ || (rawMedia.size as number) < 1
1304
+ || (rawMedia.size as number) > MAX_IMAGE_BYTES
1305
+ ) throw new Error("Bluesky provider-accepted post target media is malformed");
1306
+ media = Object.freeze({
1307
+ cid,
1308
+ mediaType: rawMedia.mediaType,
1309
+ size: rawMedia.size as number,
1310
+ });
1311
+ }
1312
+ }
1313
+ const parsed = Object.freeze({
1314
+ uri: strongRef.uri,
1315
+ cid: strongRef.cid,
1316
+ createdAt: target.createdAt,
1317
+ media,
1318
+ });
1319
+ if (canonicalJson(parsed) !== identifier) {
1320
+ throw new Error("Bluesky provider-accepted post target is not canonical");
1321
+ }
1322
+ return parsed;
1323
+ }
1324
+
1325
+ /**
1326
+ * Reconcile one exact response-bound Bluesky publish target using only the
1327
+ * authoritative PDS record and its public AppView projection.
1328
+ */
1329
+ export async function readBlueskyWebPublishedMutationTarget(
1330
+ recipe: WebSessionRecipe,
1331
+ input: OperationInput,
1332
+ auth: GhostgetAuth,
1333
+ identifier: string,
1334
+ options: {
1335
+ readonly dependencies?: BlueskyWebRuntimeDependencies;
1336
+ } = {},
1337
+ ): Promise<{ readonly present: true; readonly uri: string; readonly cid: string }> {
1338
+ if (
1339
+ recipe.site !== "bluesky"
1340
+ || !(
1341
+ (recipe.action === "posts.publish" && recipe.contractVersion === 3)
1342
+ || (recipe.action === "media.publish" && recipe.contractVersion === 2)
1343
+ )
1344
+ ) {
1345
+ throw new Error("Bluesky publish recovery supports only posts.publish@3 or media.publish@2");
1346
+ }
1347
+ const target = parseBlueskyPublishedMutationTarget(identifier);
1348
+ const body = assertBlueskyText(input.body, "input.body", 280, 3_000);
1349
+ const rawAlt = input.alt;
1350
+ const alt = rawAlt === undefined
1351
+ ? ""
1352
+ : typeof rawAlt === "string" && rawAlt.length <= 10_000 && !/[\0\r]/u.test(rawAlt)
1353
+ ? rawAlt
1354
+ : (() => {
1355
+ throw new Error("input.alt must be bounded text");
1356
+ })();
1357
+ const expectsVideo = recipe.action === "media.publish";
1358
+ if (
1359
+ (input.media === undefined) !== (target.media === null)
1360
+ || (expectsVideo
1361
+ ? target.media?.mediaType !== "video/mp4" || input.media_type !== undefined
1362
+ : target.media === null
1363
+ ? input.media_type !== undefined || input.alt !== undefined
1364
+ : target.media.mediaType === "video/mp4"
1365
+ || input.media_type !== target.media.mediaType)
1366
+ ) {
1367
+ throw new Error("Bluesky provider-accepted post target did not bind the confirmed attachment shape");
1368
+ }
1369
+ const recordValue = Object.freeze({
1370
+ $type: "app.bsky.feed.post",
1371
+ text: body,
1372
+ createdAt: target.createdAt,
1373
+ ...(target.media === null
1374
+ ? {}
1375
+ : target.media.mediaType === "video/mp4"
1376
+ ? {
1377
+ embed: {
1378
+ $type: "app.bsky.embed.video",
1379
+ video: {
1380
+ $type: "blob",
1381
+ ref: { $link: target.media.cid },
1382
+ mimeType: target.media.mediaType,
1383
+ size: target.media.size,
1384
+ },
1385
+ alt,
1386
+ aspectRatio: {
1387
+ width: target.media.width,
1388
+ height: target.media.height,
1389
+ },
1390
+ },
1391
+ }
1392
+ : {
1393
+ embed: {
1394
+ $type: "app.bsky.embed.images",
1395
+ images: [{
1396
+ image: {
1397
+ $type: "blob",
1398
+ ref: { $link: target.media.cid },
1399
+ mimeType: target.media.mediaType,
1400
+ size: target.media.size,
1401
+ },
1402
+ alt,
1403
+ }],
1404
+ },
1405
+ }),
1406
+ });
1407
+ const client = await bootstrapClient(
1408
+ auth,
1409
+ recipe.timeoutMs,
1410
+ recipe.maxOutputBytes,
1411
+ options.dependencies,
1412
+ );
1413
+ requireBoundSubject(auth, client.session);
1414
+ const strongRef = Object.freeze({ uri: target.uri, cid: target.cid });
1415
+ await getAuthoritativeRecord(client, strongRef, recordValue);
1416
+ const projected = await getPost(client, target.uri);
1417
+ if (projected.cid !== target.cid || projected.createdAt !== target.createdAt) {
1418
+ throw new Error("Bluesky publish recovery readback changed the accepted record revision");
1419
+ }
1420
+ assertPublishedPost(projected, {
1421
+ actorDid: client.session.did,
1422
+ text: body,
1423
+ reply: null,
1424
+ quote: null,
1425
+ attachment: target.media === null
1426
+ ? null
1427
+ : {
1428
+ alt,
1429
+ cid: target.media.mediaType === "video/mp4" ? target.media.cid : null,
1430
+ kind: target.media.mediaType === "video/mp4" ? "video" : "image",
1431
+ },
1432
+ });
1433
+ return Object.freeze({ present: true, uri: target.uri, cid: target.cid });
1434
+ }
1435
+
1436
+ async function waitForPublishReadback(
1437
+ client: BlueskyClient,
1438
+ uri: string,
1439
+ sleep: (milliseconds: number) => Promise<void>,
1440
+ ): Promise<BlueskyProjectedPost> {
1441
+ let lastError: unknown;
1442
+ for (const delay of PUBLISH_READBACK_DELAYS_MS) {
1443
+ if (delay > 0) {
1444
+ const pause = () => sleep(delay);
1445
+ if (client.operationDeadline === undefined) await pause();
1446
+ else await client.operationDeadline.run(pause, WEB_SESSION_OPERATION_LABEL);
1447
+ }
1448
+ try {
1449
+ return await getPost(client, uri);
1450
+ } catch (error) {
1451
+ lastError = error;
1452
+ }
1453
+ }
1454
+ throw new Error("Bluesky public post readback did not settle within the reviewed bound", {
1455
+ cause: lastError,
1456
+ });
1457
+ }
1458
+
1459
+ async function getProfile(
1460
+ client: BlueskyClient,
1461
+ did: string,
1462
+ ) {
1463
+ return projectBlueskyProfile(
1464
+ await xrpc(client, "app.bsky.actor.getProfile", {
1465
+ query: { actor: [did] },
1466
+ proxy: BLUESKY_APPVIEW_PROXY,
1467
+ }),
1468
+ did,
1469
+ );
1470
+ }
1471
+
1472
+ /**
1473
+ * Independently observe one exact Bluesky desired-state target through the
1474
+ * already account-bound XRPC client. This path cannot create or delete a
1475
+ * record and remains available only as a reconciliation readback.
1476
+ */
1477
+ export async function readBlueskyWebDesiredState(
1478
+ recipe: WebSessionRecipe,
1479
+ input: OperationInput,
1480
+ auth: GhostgetAuth,
1481
+ options: {
1482
+ readonly dependencies?: BlueskyWebRuntimeDependencies;
1483
+ readonly signal?: AbortSignal;
1484
+ } = {},
1485
+ ): Promise<BlueskyWebDesiredStateReadback> {
1486
+ if (
1487
+ recipe.site !== "bluesky"
1488
+ || recipe.contractVersion !== 1
1489
+ || (
1490
+ recipe.action !== "likes.set"
1491
+ && recipe.action !== "content.save"
1492
+ && recipe.action !== "relationships.follow.set"
1493
+ && recipe.action !== "posts.repost"
1494
+ )
1495
+ ) {
1496
+ throw new Error(
1497
+ "Bluesky recovery readback supports only likes.set, content.save, relationships.follow.set, and posts.repost",
1498
+ );
1499
+ }
1500
+ const deadline = new OperationDeadline(recipe.timeoutMs, {
1501
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1502
+ });
1503
+ try {
1504
+ const client = await bootstrapClient(
1505
+ auth,
1506
+ recipe.timeoutMs,
1507
+ recipe.maxOutputBytes,
1508
+ options.dependencies,
1509
+ deadline,
1510
+ );
1511
+ requireBoundSubject(auth, client.session);
1512
+ if (recipe.action === "relationships.follow.set") {
1513
+ const actorDid = blueskyDid(
1514
+ inputString(input, "actor_did", 255),
1515
+ "input.actor_did",
1516
+ );
1517
+ if (actorDid === client.session.did) {
1518
+ throw new Error("Bluesky cannot follow the bound viewer");
1519
+ }
1520
+ const profile = await getProfile(client, actorDid);
1521
+ return Object.freeze({
1522
+ kind: "follow",
1523
+ enabled: profile.following !== null,
1524
+ actorDid,
1525
+ });
1526
+ }
1527
+ const postUri = postUriInput(input);
1528
+ const kind = recipe.action === "likes.set"
1529
+ ? "like"
1530
+ : recipe.action === "content.save"
1531
+ ? "bookmark"
1532
+ : "repost";
1533
+ const post = await getPost(client, postUri);
1534
+ return Object.freeze({
1535
+ kind,
1536
+ enabled: postState(post, kind),
1537
+ postUri,
1538
+ });
1539
+ } finally {
1540
+ deadline.dispose();
1541
+ }
1542
+ }
1543
+
1544
+ async function executeFeedRead(
1545
+ client: BlueskyClient,
1546
+ input: OperationInput,
1547
+ ): Promise<WebSessionExecution> {
1548
+ const feed = inputString(input, "feed", 32);
1549
+ const limit = inputInteger(input, "limit", DEFAULT_LIMIT, 1, 100);
1550
+ const cursor = optionalCursor(input);
1551
+ let output: unknown;
1552
+ if (feed === "home") {
1553
+ output = projectBlueskyFeed(
1554
+ await xrpc(client, "app.bsky.feed.getTimeline", {
1555
+ query: {
1556
+ limit: [String(limit)],
1557
+ ...(cursor === undefined ? {} : { cursor: [cursor] }),
1558
+ },
1559
+ proxy: BLUESKY_APPVIEW_PROXY,
1560
+ }),
1561
+ limit,
1562
+ );
1563
+ } else if (feed === "notifications") {
1564
+ output = projectBlueskyNotifications(
1565
+ await xrpc(client, "app.bsky.notification.listNotifications", {
1566
+ query: {
1567
+ limit: [String(limit)],
1568
+ ...(cursor === undefined ? {} : { cursor: [cursor] }),
1569
+ },
1570
+ proxy: BLUESKY_NOTIFICATION_PROXY,
1571
+ }),
1572
+ limit,
1573
+ );
1574
+ } else if (feed === "bookmarks") {
1575
+ output = projectBlueskyBookmarks(
1576
+ await xrpc(client, "app.bsky.bookmark.getBookmarks", {
1577
+ query: {
1578
+ limit: [String(limit)],
1579
+ ...(cursor === undefined ? {} : { cursor: [cursor] }),
1580
+ },
1581
+ proxy: BLUESKY_APPVIEW_PROXY,
1582
+ }),
1583
+ limit,
1584
+ );
1585
+ } else {
1586
+ throw new Error("input.feed must name home, notifications, or bookmarks");
1587
+ }
1588
+ return {
1589
+ status: "succeeded",
1590
+ output: Object.freeze({ feed, result: output }),
1591
+ finalUrl: feed === "home"
1592
+ ? `${BLUESKY_APP_ORIGIN}/`
1593
+ : feed === "notifications"
1594
+ ? `${BLUESKY_APP_ORIGIN}/notifications`
1595
+ : `${BLUESKY_APP_ORIGIN}/saved`,
1596
+ dispatchStarted: false,
1597
+ dispatch: { planned: 0, started: 0, verified: 0 },
1598
+ };
1599
+ }
1600
+
1601
+ export async function executeBlueskyPublicProfileRead(
1602
+ recipe: WebSessionRecipe,
1603
+ input: OperationInput,
1604
+ dependencies: BlueskyWebRuntimeDependencies | undefined,
1605
+ operationDeadline: WebSessionOperationDeadline | undefined,
1606
+ ): Promise<WebSessionExecution> {
1607
+ if (
1608
+ recipe.site !== "bluesky"
1609
+ || recipe.action !== "profiles.read"
1610
+ || recipe.contractVersion !== 2
1611
+ ) {
1612
+ throw new Error("Bluesky public profiles.read contract is not installed");
1613
+ }
1614
+ const handle = inputString(input, "handle", 253).toLowerCase();
1615
+ const url = new URL(
1616
+ "/xrpc/app.bsky.actor.getProfile",
1617
+ BLUESKY_PUBLIC_APPVIEW_ORIGIN,
1618
+ );
1619
+ url.searchParams.set("actor", handle);
1620
+ const fetch = dependencies?.fetch ?? ((inputValue, init = {}) =>
1621
+ pinnedHttpsFetch(
1622
+ inputValue instanceof Request ? new URL(inputValue.url) : new URL(inputValue),
1623
+ init,
1624
+ remainingTimeoutMs(recipe.timeoutMs, operationDeadline),
1625
+ ));
1626
+ operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
1627
+ const controller = operationDeadline === undefined ? new AbortController() : null;
1628
+ const timeout = controller === null
1629
+ ? null
1630
+ : setTimeout(() => controller.abort(), recipe.timeoutMs);
1631
+ const signal = operationDeadline?.signal ?? controller?.signal;
1632
+ let response: Response;
1633
+ try {
1634
+ try {
1635
+ response = await fetch(url, {
1636
+ method: "GET",
1637
+ headers: { accept: "application/json" },
1638
+ redirect: "error",
1639
+ ...(signal === undefined ? {} : { signal }),
1640
+ });
1641
+ } catch (error) {
1642
+ if (signal?.aborted === true) {
1643
+ if (operationDeadline !== undefined) {
1644
+ operationDeadline.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
1645
+ }
1646
+ throw new OperationDeadlineError(WEB_SESSION_OPERATION_LABEL, "timed-out");
1647
+ }
1648
+ throw new ProviderReadTransportError(error);
1649
+ }
1650
+ if (response.status !== 200) {
1651
+ void response.body?.cancel().catch(() => undefined);
1652
+ throw new ProviderReadResponseRejectedError(response.status);
1653
+ }
1654
+ const bytes = await boundedBytes(
1655
+ response,
1656
+ Math.min(recipe.maxOutputBytes, MAX_READ_BYTES),
1657
+ operationDeadline,
1658
+ );
1659
+ if (!jsonContentType(response)) {
1660
+ throw new Error("Bluesky public AppView returned an unreviewed content type");
1661
+ }
1662
+ const output = projectBlueskyProfileStats(
1663
+ parseJson(bytes),
1664
+ handle,
1665
+ new Date(dependencies?.now?.() ?? Date.now()).toISOString(),
1666
+ );
1667
+ return {
1668
+ status: "succeeded",
1669
+ output,
1670
+ finalUrl: output.target.url,
1671
+ dispatchStarted: false,
1672
+ dispatch: { planned: 0, started: 0, verified: 0 },
1673
+ };
1674
+ } catch (error) {
1675
+ return failedProviderRead(
1676
+ "Bluesky profile",
1677
+ error,
1678
+ `https://bsky.app/profile/${handle}`,
1679
+ {
1680
+ stage: "target",
1681
+ authenticated: false,
1682
+ targetStatusUnavailable: true,
1683
+ },
1684
+ );
1685
+ } finally {
1686
+ if (timeout !== null) clearTimeout(timeout);
1687
+ }
1688
+ }
1689
+
1690
+ async function executePostRead(
1691
+ client: BlueskyClient,
1692
+ input: OperationInput,
1693
+ mediaOnly: boolean,
1694
+ ): Promise<WebSessionExecution> {
1695
+ const uri = postUriInput(input);
1696
+ const post = await getPost(client, uri);
1697
+ return {
1698
+ status: "succeeded",
1699
+ output: mediaOnly
1700
+ ? Object.freeze({ postUri: uri, media: post.attachments })
1701
+ : post,
1702
+ finalUrl: `${BLUESKY_APP_ORIGIN}/profile/${post.author.did}/post/${blueskyPostUri(uri).rkey}`,
1703
+ dispatchStarted: false,
1704
+ dispatch: { planned: 0, started: 0, verified: 0 },
1705
+ };
1706
+ }
1707
+
1708
+ async function executeCommentsRead(
1709
+ client: BlueskyClient,
1710
+ input: OperationInput,
1711
+ ): Promise<WebSessionExecution> {
1712
+ const uri = postUriInput(input);
1713
+ const limit = inputInteger(input, "limit", DEFAULT_LIMIT, 1, 100);
1714
+ const output = projectBlueskyThread(
1715
+ await xrpc(client, "app.bsky.feed.getPostThread", {
1716
+ query: {
1717
+ uri: [uri],
1718
+ depth: ["10"],
1719
+ parentHeight: ["0"],
1720
+ },
1721
+ proxy: BLUESKY_APPVIEW_PROXY,
1722
+ }),
1723
+ uri,
1724
+ limit,
1725
+ );
1726
+ return {
1727
+ status: "succeeded",
1728
+ output,
1729
+ finalUrl: `${BLUESKY_APP_ORIGIN}/profile/${output.post.author.did}/post/${blueskyPostUri(uri).rkey}`,
1730
+ dispatchStarted: false,
1731
+ dispatch: { planned: 0, started: 0, verified: 0 },
1732
+ };
1733
+ }
1734
+
1735
+ async function getConvo(
1736
+ client: BlueskyClient,
1737
+ convoId: string,
1738
+ ) {
1739
+ const response = record(
1740
+ await xrpc(client, "chat.bsky.convo.getConvo", {
1741
+ query: { convoId: [convoId] },
1742
+ proxy: BLUESKY_CHAT_PROXY,
1743
+ }),
1744
+ "Bluesky getConvo response",
1745
+ );
1746
+ return projectBlueskyConvo(response.convo, client.session.did, convoId);
1747
+ }
1748
+
1749
+ async function getMessages(
1750
+ client: BlueskyClient,
1751
+ convoId: string,
1752
+ limit: number,
1753
+ cursor?: string,
1754
+ ) {
1755
+ return projectBlueskyMessages(
1756
+ await xrpc(client, "chat.bsky.convo.getMessages", {
1757
+ query: {
1758
+ convoId: [convoId],
1759
+ limit: [String(limit)],
1760
+ ...(cursor === undefined ? {} : { cursor: [cursor] }),
1761
+ },
1762
+ proxy: BLUESKY_CHAT_PROXY,
1763
+ }),
1764
+ limit,
1765
+ );
1766
+ }
1767
+
1768
+ async function executeMessagingList(
1769
+ client: BlueskyClient,
1770
+ input: OperationInput,
1771
+ ): Promise<WebSessionExecution> {
1772
+ const limit = inputInteger(input, "limit", DEFAULT_LIMIT, 1, 100);
1773
+ const cursor = optionalCursor(input);
1774
+ const output = projectBlueskyConvoList(
1775
+ await xrpc(client, "chat.bsky.convo.listConvos", {
1776
+ query: {
1777
+ limit: [String(limit)],
1778
+ ...(cursor === undefined ? {} : { cursor: [cursor] }),
1779
+ },
1780
+ proxy: BLUESKY_CHAT_PROXY,
1781
+ }),
1782
+ client.session.did,
1783
+ limit,
1784
+ );
1785
+ return {
1786
+ status: "succeeded",
1787
+ output,
1788
+ finalUrl: `${BLUESKY_APP_ORIGIN}/messages`,
1789
+ dispatchStarted: false,
1790
+ dispatch: { planned: 0, started: 0, verified: 0 },
1791
+ };
1792
+ }
1793
+
1794
+ async function executeMessagingRead(
1795
+ client: BlueskyClient,
1796
+ input: OperationInput,
1797
+ ): Promise<WebSessionExecution> {
1798
+ const convoId = convoIdInput(input);
1799
+ const limit = inputInteger(input, "limit", DEFAULT_LIMIT, 1, 100);
1800
+ const cursor = optionalCursor(input);
1801
+ const convo = await getConvo(client, convoId);
1802
+ const messages = await getMessages(client, convoId, limit, cursor);
1803
+ return {
1804
+ status: "succeeded",
1805
+ output: Object.freeze({ convo, ...messages }),
1806
+ finalUrl: `${BLUESKY_APP_ORIGIN}/messages/${encodeURIComponent(convoId)}`,
1807
+ dispatchStarted: false,
1808
+ dispatch: { planned: 0, started: 0, verified: 0 },
1809
+ };
1810
+ }
1811
+
1812
+ function dispatchEvent(
1813
+ id: string,
1814
+ index: number,
1815
+ planned: number,
1816
+ started: number,
1817
+ verified: number,
1818
+ ): WebSessionDispatchEvent {
1819
+ return Object.freeze({
1820
+ id,
1821
+ index,
1822
+ progress: Object.freeze({ planned, started, verified }),
1823
+ });
1824
+ }
1825
+
1826
+ async function rebindBeforeDispatch(client: BlueskyClient): Promise<void> {
1827
+ const rebound = await currentSession(client);
1828
+ if (rebound.did !== client.session.did) {
1829
+ throw new Error("Bluesky account changed during dispatch preparation");
1830
+ }
1831
+ }
1832
+
1833
+ function desiredStateResult(
1834
+ action: BlueskyWebOperationName,
1835
+ output: unknown,
1836
+ ): WebSessionExecution {
1837
+ return {
1838
+ status: "succeeded",
1839
+ output: Object.freeze({
1840
+ action,
1841
+ noOp: true,
1842
+ effect: "already-satisfied",
1843
+ result: output,
1844
+ }),
1845
+ finalUrl: BLUESKY_APP_ORIGIN,
1846
+ dispatchStarted: false,
1847
+ dispatch: { planned: 1, started: 0, verified: 0 },
1848
+ };
1849
+ }
1850
+
1851
+ async function createRecord(
1852
+ client: BlueskyClient,
1853
+ collection: string,
1854
+ value: Readonly<Record<string, unknown>>,
1855
+ ): Promise<BlueskyStrongRef> {
1856
+ return parseBlueskyCreateRecordResponse(
1857
+ await xrpc(client, "com.atproto.repo.createRecord", {
1858
+ jsonBody: {
1859
+ repo: client.session.did,
1860
+ collection,
1861
+ record: value,
1862
+ },
1863
+ }),
1864
+ client.session.did,
1865
+ collection,
1866
+ );
1867
+ }
1868
+
1869
+ async function deleteRecord(
1870
+ client: BlueskyClient,
1871
+ uri: string,
1872
+ collection: string,
1873
+ ): Promise<void> {
1874
+ const parsed = parseBlueskyAtUri(uri, "Bluesky record to delete", collection);
1875
+ if (parsed.actor !== client.session.did) {
1876
+ throw new Error("Bluesky record deletion actor did not match the bound viewer");
1877
+ }
1878
+ await xrpc(client, "com.atproto.repo.deleteRecord", {
1879
+ jsonBody: {
1880
+ repo: client.session.did,
1881
+ collection,
1882
+ rkey: parsed.rkey,
1883
+ },
1884
+ });
1885
+ }
1886
+
1887
+ async function executeContentDelete(
1888
+ client: BlueskyClient,
1889
+ recipe: WebSessionRecipe,
1890
+ input: OperationInput,
1891
+ options: {
1892
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
1893
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
1894
+ },
1895
+ ): Promise<WebSessionExecution> {
1896
+ const postUri = postUriInput(input);
1897
+ const expectedCid = blueskyCid(
1898
+ inputString(input, "expected_cid", 201),
1899
+ "input.expected_cid",
1900
+ );
1901
+ const parsed = parseBlueskyAtUri(
1902
+ postUri,
1903
+ "Bluesky deletion target URI",
1904
+ "app.bsky.feed.post",
1905
+ );
1906
+ if (parsed.actor !== client.session.did) {
1907
+ throw new Error("Bluesky deletion target actor did not match the bound viewer");
1908
+ }
1909
+ const finalUrl = `${BLUESKY_APP_ORIGIN}/profile/${client.session.did}/post/${parsed.rkey}`;
1910
+ let started = 0;
1911
+ let verified = 0;
1912
+ let failureStage = "authoritative pre-read";
1913
+ try {
1914
+ const before = await authoritativePostPresence(
1915
+ client,
1916
+ postUri,
1917
+ expectedCid,
1918
+ );
1919
+ if (!before.present) {
1920
+ return {
1921
+ status: "succeeded",
1922
+ output: Object.freeze({
1923
+ postUri,
1924
+ expectedCid,
1925
+ deleted: true,
1926
+ effect: "already-absent",
1927
+ }),
1928
+ finalUrl,
1929
+ noOp: true,
1930
+ dispatchStarted: false,
1931
+ dispatch: { planned: 1, started: 0, verified: 0 },
1932
+ };
1933
+ }
1934
+ failureStage = "dispatch rebinding";
1935
+ await rebindBeforeDispatch(client);
1936
+ failureStage = "dispatch admission";
1937
+ await options.beforeDispatch?.(
1938
+ dispatchEvent(recipe.action, 1, 1, 0, 0),
1939
+ );
1940
+ started = 1;
1941
+ failureStage = "delete response";
1942
+ parseBlueskyDeleteRecordResponse(
1943
+ await xrpc(client, "com.atproto.repo.deleteRecord", {
1944
+ jsonBody: {
1945
+ repo: client.session.did,
1946
+ collection: "app.bsky.feed.post",
1947
+ rkey: parsed.rkey,
1948
+ swapRecord: before.ref.cid,
1949
+ },
1950
+ }),
1951
+ );
1952
+ failureStage = "authoritative absence readback";
1953
+ const after = await authoritativePostPresence(
1954
+ client,
1955
+ postUri,
1956
+ expectedCid,
1957
+ );
1958
+ if (after.present) {
1959
+ throw new Error("Bluesky deletion readback still found the confirmed record");
1960
+ }
1961
+ verified = 1;
1962
+ failureStage = "verification recording";
1963
+ await options.afterDispatchVerified?.(
1964
+ dispatchEvent(recipe.action, 1, 1, 1, 1),
1965
+ );
1966
+ return {
1967
+ status: "succeeded",
1968
+ output: Object.freeze({
1969
+ postUri,
1970
+ expectedCid,
1971
+ deleted: true,
1972
+ effect: "deleted",
1973
+ }),
1974
+ finalUrl,
1975
+ dispatchStarted: true,
1976
+ dispatch: { planned: 1, started, verified },
1977
+ };
1978
+ } catch {
1979
+ return {
1980
+ status: started > 0 ? "indeterminate" : "failed",
1981
+ output: null,
1982
+ finalUrl,
1983
+ dispatchStarted: started > 0,
1984
+ dispatch: { planned: 1, started, verified },
1985
+ error: started > 0
1986
+ ? `Bluesky may have deleted the exact confirmed post; failure stage: ${failureStage}; reconcile authoritative record absence and never retry automatically`
1987
+ : `Bluesky deletion failed before submission; failure stage: ${failureStage}`,
1988
+ };
1989
+ }
1990
+ }
1991
+
1992
+ type PostStateKind = "like" | "repost" | "bookmark";
1993
+
1994
+ function postState(
1995
+ post: BlueskyProjectedPost,
1996
+ kind: PostStateKind,
1997
+ ): boolean {
1998
+ return kind === "like"
1999
+ ? post.viewer.like !== null
2000
+ : kind === "repost"
2001
+ ? post.viewer.repost !== null
2002
+ : post.viewer.bookmarked;
2003
+ }
2004
+
2005
+ function postStateUri(
2006
+ post: BlueskyProjectedPost,
2007
+ kind: Exclude<PostStateKind, "bookmark">,
2008
+ ): string | null {
2009
+ return kind === "like" ? post.viewer.like : post.viewer.repost;
2010
+ }
2011
+
2012
+ async function executePostDesiredState(
2013
+ client: BlueskyClient,
2014
+ recipe: WebSessionRecipe,
2015
+ input: OperationInput,
2016
+ options: {
2017
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2018
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2019
+ readonly now: () => number;
2020
+ },
2021
+ ): Promise<WebSessionExecution> {
2022
+ const uri = postUriInput(input);
2023
+ const kind: PostStateKind = recipe.action === "likes.set"
2024
+ ? "like"
2025
+ : recipe.action === "content.save"
2026
+ ? "bookmark"
2027
+ : "repost";
2028
+ const desired = inputBoolean(
2029
+ input,
2030
+ kind === "like" ? "liked" : kind === "bookmark" ? "saved" : "reposted",
2031
+ );
2032
+ let started = 0;
2033
+ let verified = 0;
2034
+ try {
2035
+ const before = await getPost(client, uri);
2036
+ if (postState(before, kind) === desired) {
2037
+ return desiredStateResult(recipe.action as BlueskyWebOperationName, {
2038
+ postUri: uri,
2039
+ desired,
2040
+ });
2041
+ }
2042
+ await rebindBeforeDispatch(client);
2043
+ await options.beforeDispatch?.(dispatchEvent(recipe.action, 1, 1, 0, 0));
2044
+ started = 1;
2045
+ let createdUri: string | null = null;
2046
+ if (kind === "bookmark") {
2047
+ await xrpc(
2048
+ client,
2049
+ desired
2050
+ ? "app.bsky.bookmark.createBookmark"
2051
+ : "app.bsky.bookmark.deleteBookmark",
2052
+ {
2053
+ proxy: BLUESKY_APPVIEW_PROXY,
2054
+ jsonBody: desired
2055
+ ? { uri: before.uri, cid: before.cid }
2056
+ : { uri: before.uri },
2057
+ },
2058
+ );
2059
+ } else {
2060
+ const collection = kind === "like"
2061
+ ? "app.bsky.feed.like"
2062
+ : "app.bsky.feed.repost";
2063
+ if (desired) {
2064
+ const created = await createRecord(client, collection, {
2065
+ $type: collection,
2066
+ subject: { uri: before.uri, cid: before.cid },
2067
+ createdAt: new Date(options.now()).toISOString(),
2068
+ });
2069
+ createdUri = created.uri;
2070
+ } else {
2071
+ const existing = postStateUri(before, kind);
2072
+ if (existing === null) throw new Error("Bluesky desired-state deletion omitted its exact record URI");
2073
+ await deleteRecord(client, existing, collection);
2074
+ }
2075
+ }
2076
+ const after = await getPost(client, uri);
2077
+ if (postState(after, kind) !== desired) {
2078
+ throw new Error("Bluesky desired-state readback did not match the confirmed state");
2079
+ }
2080
+ if (
2081
+ createdUri !== null
2082
+ && postStateUri(after, kind as Exclude<PostStateKind, "bookmark">) !== createdUri
2083
+ ) throw new Error("Bluesky desired-state readback did not bind the created record");
2084
+ verified = 1;
2085
+ await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1, 1, 1));
2086
+ return {
2087
+ status: "succeeded",
2088
+ output: Object.freeze({ postUri: uri, desired, noOp: false }),
2089
+ finalUrl: `${BLUESKY_APP_ORIGIN}/profile/${before.author.did}/post/${blueskyPostUri(uri).rkey}`,
2090
+ dispatchStarted: true,
2091
+ dispatch: { planned: 1, started, verified },
2092
+ };
2093
+ } catch {
2094
+ return {
2095
+ status: started > 0 ? "indeterminate" : "failed",
2096
+ output: null,
2097
+ finalUrl: BLUESKY_APP_ORIGIN,
2098
+ dispatchStarted: started > 0,
2099
+ dispatch: { planned: 1, started, verified },
2100
+ error: started > 0
2101
+ ? "Bluesky may have changed the requested post state but exact readback was not verified; reconcile before retrying"
2102
+ : "Bluesky desired post state failed before submission",
2103
+ };
2104
+ }
2105
+ }
2106
+
2107
+ async function executeFollowDesiredState(
2108
+ client: BlueskyClient,
2109
+ recipe: WebSessionRecipe,
2110
+ input: OperationInput,
2111
+ options: {
2112
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2113
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2114
+ readonly now: () => number;
2115
+ },
2116
+ ): Promise<WebSessionExecution> {
2117
+ const targetDid = blueskyDid(inputString(input, "actor_did", 255), "input.actor_did");
2118
+ const desired = inputBoolean(input, "followed");
2119
+ let started = 0;
2120
+ let verified = 0;
2121
+ try {
2122
+ if (targetDid === client.session.did) throw new Error("Bluesky cannot follow the bound viewer");
2123
+ const before = await getProfile(client, targetDid);
2124
+ if ((before.following !== null) === desired) {
2125
+ return desiredStateResult("relationships.follow.set", { actorDid: targetDid, desired });
2126
+ }
2127
+ await rebindBeforeDispatch(client);
2128
+ await options.beforeDispatch?.(dispatchEvent(recipe.action, 1, 1, 0, 0));
2129
+ started = 1;
2130
+ let createdUri: string | null = null;
2131
+ if (desired) {
2132
+ createdUri = (await createRecord(client, "app.bsky.graph.follow", {
2133
+ $type: "app.bsky.graph.follow",
2134
+ subject: targetDid,
2135
+ createdAt: new Date(options.now()).toISOString(),
2136
+ })).uri;
2137
+ } else {
2138
+ if (before.following === null) throw new Error("Bluesky unfollow omitted its exact follow URI");
2139
+ await deleteRecord(client, before.following, "app.bsky.graph.follow");
2140
+ }
2141
+ const after = await getProfile(client, targetDid);
2142
+ if ((after.following !== null) !== desired) {
2143
+ throw new Error("Bluesky follow readback did not match the confirmed state");
2144
+ }
2145
+ if (createdUri !== null && after.following !== createdUri) {
2146
+ throw new Error("Bluesky follow readback did not bind the created record");
2147
+ }
2148
+ verified = 1;
2149
+ await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1, 1, 1));
2150
+ return {
2151
+ status: "succeeded",
2152
+ output: Object.freeze({ actorDid: targetDid, desired, noOp: false }),
2153
+ finalUrl: `${BLUESKY_APP_ORIGIN}/profile/${targetDid}`,
2154
+ dispatchStarted: true,
2155
+ dispatch: { planned: 1, started, verified },
2156
+ };
2157
+ } catch {
2158
+ return {
2159
+ status: started > 0 ? "indeterminate" : "failed",
2160
+ output: null,
2161
+ finalUrl: `${BLUESKY_APP_ORIGIN}/profile/${targetDid}`,
2162
+ dispatchStarted: started > 0,
2163
+ dispatch: { planned: 1, started, verified },
2164
+ error: started > 0
2165
+ ? "Bluesky may have changed the follow state but exact readback was not verified; reconcile before retrying"
2166
+ : "Bluesky follow state failed before submission",
2167
+ };
2168
+ }
2169
+ }
2170
+
2171
+ function fileInput(value: OperationInput[string]): FileInputValue {
2172
+ if (
2173
+ !isRecord(value)
2174
+ || value.kind !== "file"
2175
+ || typeof value.reference !== "string"
2176
+ || Object.keys(value).sort().join(",") !== "kind,reference"
2177
+ ) throw new Error("input.media must be one plan-bound file");
2178
+ return Object.freeze({ kind: "file", reference: value.reference });
2179
+ }
2180
+
2181
+ async function readBoundMedia(
2182
+ input: OperationInput,
2183
+ fileResolver: BrowserFileResolver | undefined,
2184
+ operationDeadline?: WebSessionOperationDeadline,
2185
+ ): Promise<{
2186
+ readonly bytes: Uint8Array;
2187
+ readonly mediaType: "image/jpeg" | "image/png" | "image/webp";
2188
+ readonly alt: string;
2189
+ } | null> {
2190
+ if (input.media === undefined) {
2191
+ if (input.media_type !== undefined || input.alt !== undefined) {
2192
+ throw new Error("input.media_type and input.alt require input.media");
2193
+ }
2194
+ return null;
2195
+ }
2196
+ if (fileResolver === undefined) throw new Error("Bluesky media upload requires the plan-bound file resolver");
2197
+ const media = fileInput(input.media);
2198
+ const mediaType = inputString(input, "media_type", 32);
2199
+ if (
2200
+ mediaType !== "image/jpeg"
2201
+ && mediaType !== "image/png"
2202
+ && mediaType !== "image/webp"
2203
+ ) throw new Error("input.media_type must be a reviewed Bluesky image type");
2204
+ const rawAlt = input.alt;
2205
+ const alt = rawAlt === undefined
2206
+ ? ""
2207
+ : typeof rawAlt === "string" && rawAlt.length <= 10_000 && !/[\0\r]/u.test(rawAlt)
2208
+ ? rawAlt
2209
+ : (() => {
2210
+ throw new Error("input.alt must be bounded text");
2211
+ })();
2212
+ const paths = operationDeadline === undefined
2213
+ ? await fileResolver([media])
2214
+ : await operationDeadline.run(
2215
+ () => fileResolver([media]),
2216
+ WEB_SESSION_OPERATION_LABEL,
2217
+ );
2218
+ operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
2219
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
2220
+ throw new Error("Bluesky file resolver did not return one exact path");
2221
+ }
2222
+ const path = paths[0];
2223
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
2224
+ const handle = operationDeadline === undefined
2225
+ ? await open(path, constants.O_RDONLY | noFollow)
2226
+ : await operationDeadline.run(
2227
+ () => open(path, constants.O_RDONLY | noFollow),
2228
+ WEB_SESSION_OPERATION_LABEL,
2229
+ );
2230
+ try {
2231
+ const before = operationDeadline === undefined
2232
+ ? await handle.stat()
2233
+ : await operationDeadline.run(
2234
+ () => handle.stat(),
2235
+ WEB_SESSION_OPERATION_LABEL,
2236
+ );
2237
+ if (!before.isFile() || before.size < 1 || before.size > MAX_IMAGE_BYTES) {
2238
+ throw new Error("Bluesky image must be a regular file no larger than 2,000,000 bytes");
2239
+ }
2240
+ const bytes = operationDeadline === undefined
2241
+ ? await handle.readFile()
2242
+ : await operationDeadline.run(
2243
+ () => handle.readFile(),
2244
+ WEB_SESSION_OPERATION_LABEL,
2245
+ );
2246
+ const after = operationDeadline === undefined
2247
+ ? await handle.stat()
2248
+ : await operationDeadline.run(
2249
+ () => handle.stat(),
2250
+ WEB_SESSION_OPERATION_LABEL,
2251
+ );
2252
+ if (
2253
+ after.dev !== before.dev
2254
+ || after.ino !== before.ino
2255
+ || after.size !== before.size
2256
+ || bytes.byteLength !== before.size
2257
+ ) throw new Error("Bluesky image changed while it was materialized");
2258
+ return Object.freeze({
2259
+ bytes: new Uint8Array(bytes),
2260
+ mediaType,
2261
+ alt,
2262
+ });
2263
+ } finally {
2264
+ await handle.close();
2265
+ }
2266
+ }
2267
+
2268
+ type BoundBlueskyVideo = Readonly<{
2269
+ alt: string;
2270
+ bytes: Uint8Array;
2271
+ height: number;
2272
+ mediaType: "video/mp4";
2273
+ width: number;
2274
+ }>;
2275
+
2276
+ async function readBoundVideo(
2277
+ input: OperationInput,
2278
+ fileResolver: BrowserFileResolver | undefined,
2279
+ operationDeadline?: WebSessionOperationDeadline,
2280
+ ): Promise<BoundBlueskyVideo> {
2281
+ if (input.media === undefined) {
2282
+ throw new Error("Bluesky video publishing requires input.media");
2283
+ }
2284
+ if (input.media_type !== undefined) {
2285
+ throw new Error("Bluesky video publishing does not accept input.media_type");
2286
+ }
2287
+ if (fileResolver === undefined) {
2288
+ throw new Error("Bluesky video upload requires the plan-bound file resolver");
2289
+ }
2290
+ const media = fileInput(input.media);
2291
+ const rawAlt = input.alt;
2292
+ const alt = rawAlt === undefined
2293
+ ? ""
2294
+ : typeof rawAlt === "string" && rawAlt.length <= 10_000 && !/[\0\r]/u.test(rawAlt)
2295
+ ? rawAlt
2296
+ : (() => {
2297
+ throw new Error("input.alt must be bounded text");
2298
+ })();
2299
+ const paths = operationDeadline === undefined
2300
+ ? await fileResolver([media])
2301
+ : await operationDeadline.run(
2302
+ () => fileResolver([media]),
2303
+ WEB_SESSION_OPERATION_LABEL,
2304
+ );
2305
+ operationDeadline?.throwIfUnavailable(WEB_SESSION_OPERATION_LABEL);
2306
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
2307
+ throw new Error("Bluesky file resolver did not return one exact video path");
2308
+ }
2309
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
2310
+ const handle = operationDeadline === undefined
2311
+ ? await open(paths[0], constants.O_RDONLY | noFollow)
2312
+ : await operationDeadline.run(
2313
+ () => open(paths[0]!, constants.O_RDONLY | noFollow),
2314
+ WEB_SESSION_OPERATION_LABEL,
2315
+ );
2316
+ try {
2317
+ const before = operationDeadline === undefined
2318
+ ? await handle.stat()
2319
+ : await operationDeadline.run(
2320
+ () => handle.stat(),
2321
+ WEB_SESSION_OPERATION_LABEL,
2322
+ );
2323
+ if (!before.isFile() || before.size < 24 || before.size > MAX_VIDEO_BYTES) {
2324
+ throw new Error("Bluesky video must be a regular MP4 no larger than 100,000,000 bytes");
2325
+ }
2326
+ const fileBytes = operationDeadline === undefined
2327
+ ? await handle.readFile()
2328
+ : await operationDeadline.run(
2329
+ () => handle.readFile(),
2330
+ WEB_SESSION_OPERATION_LABEL,
2331
+ );
2332
+ const after = operationDeadline === undefined
2333
+ ? await handle.stat()
2334
+ : await operationDeadline.run(
2335
+ () => handle.stat(),
2336
+ WEB_SESSION_OPERATION_LABEL,
2337
+ );
2338
+ if (
2339
+ before.dev !== after.dev
2340
+ || before.ino !== after.ino
2341
+ || before.size !== after.size
2342
+ || before.mtimeMs !== after.mtimeMs
2343
+ || before.ctimeMs !== after.ctimeMs
2344
+ || fileBytes.byteLength !== before.size
2345
+ ) throw new Error("Bluesky video changed while it was materialized");
2346
+ const bytes = new Uint8Array(fileBytes);
2347
+ const dimensions = isoBmffVideoDimensions(bytes, "Bluesky video");
2348
+ return Object.freeze({
2349
+ alt,
2350
+ bytes,
2351
+ height: dimensions.height,
2352
+ mediaType: "video/mp4",
2353
+ width: dimensions.width,
2354
+ });
2355
+ } finally {
2356
+ await handle.close();
2357
+ }
2358
+ }
2359
+
2360
+ async function uploadImage(
2361
+ client: BlueskyClient,
2362
+ media: NonNullable<Awaited<ReturnType<typeof readBoundMedia>>>,
2363
+ ): Promise<BlueskyBlobRef> {
2364
+ return parseBlueskyUploadBlobResponse(
2365
+ await xrpc(client, "com.atproto.repo.uploadBlob", {
2366
+ blobBody: { bytes: media.bytes, mediaType: media.mediaType },
2367
+ maximumBytes: 512 * 1024,
2368
+ }),
2369
+ media.mediaType,
2370
+ media.bytes.byteLength,
2371
+ );
2372
+ }
2373
+
2374
+ async function uploadVideo(
2375
+ client: BlueskyClient,
2376
+ media: BoundBlueskyVideo,
2377
+ options: {
2378
+ readonly now: () => number;
2379
+ readonly sleep: (milliseconds: number) => Promise<void>;
2380
+ },
2381
+ ): Promise<Readonly<{ blob: BlueskyBlobRef; jobId: string }>> {
2382
+ const nowSeconds = Math.floor(options.now() / 1_000);
2383
+ const expiresAt = nowSeconds + 1_800;
2384
+ const audience = `did:web:${new URL(client.session.pdsOrigin).hostname}`;
2385
+ const lxm = "com.atproto.repo.uploadBlob";
2386
+ const token = parseBlueskyServiceAuthResponse(
2387
+ await xrpc(client, "com.atproto.server.getServiceAuth", {
2388
+ query: {
2389
+ aud: [audience],
2390
+ exp: [String(expiresAt)],
2391
+ lxm: [lxm],
2392
+ },
2393
+ maximumBytes: 64 * 1024,
2394
+ }),
2395
+ {
2396
+ did: client.session.did,
2397
+ audience,
2398
+ lxm,
2399
+ expiresAt,
2400
+ nowSeconds,
2401
+ },
2402
+ );
2403
+ let status = parseBlueskyVideoUploadResponse(
2404
+ await videoServiceJson(client, {
2405
+ kind: "upload",
2406
+ bytes: media.bytes,
2407
+ token,
2408
+ }),
2409
+ client.session.did,
2410
+ );
2411
+ const completed = (candidate: BlueskyVideoJobStatus): BlueskyBlobRef | null => {
2412
+ if (candidate.state === "JOB_STATE_FAILED") {
2413
+ throw new Error("Bluesky video processing reported a terminal failure");
2414
+ }
2415
+ return candidate.state === "JOB_STATE_COMPLETED" ? candidate.blob : null;
2416
+ };
2417
+ let blob = completed(status);
2418
+ for (const delay of VIDEO_PROCESSING_DELAYS_MS) {
2419
+ if (blob !== null) break;
2420
+ const pause = () => options.sleep(delay);
2421
+ if (client.operationDeadline === undefined) await pause();
2422
+ else await client.operationDeadline.run(pause, WEB_SESSION_OPERATION_LABEL);
2423
+ status = parseBlueskyVideoJobStatusResponse(
2424
+ await videoServiceJson(client, {
2425
+ kind: "job-status",
2426
+ jobId: status.jobId,
2427
+ }),
2428
+ client.session.did,
2429
+ status.jobId,
2430
+ );
2431
+ blob = completed(status);
2432
+ }
2433
+ if (blob === null) {
2434
+ throw new Error("Bluesky video processing did not complete within the reviewed poll bound");
2435
+ }
2436
+ return Object.freeze({ blob, jobId: status.jobId });
2437
+ }
2438
+
2439
+ function publishTexts(
2440
+ action: WebSessionRecipe["action"],
2441
+ input: OperationInput,
2442
+ ): readonly string[] {
2443
+ if (action !== "threads.publish") {
2444
+ return [assertBlueskyText(input.body, "input.body", 280, 3_000)];
2445
+ }
2446
+ if (!Array.isArray(input.items) || input.items.length < 1 || input.items.length > 25) {
2447
+ throw new Error("input.items must contain between one and twenty-five thread items");
2448
+ }
2449
+ return Object.freeze(input.items.map((item, index) =>
2450
+ assertBlueskyText(item, `input.items[${index}]`, 280, 3_000)
2451
+ ));
2452
+ }
2453
+
2454
+ function assertPublishedPost(
2455
+ post: BlueskyProjectedPost,
2456
+ expected: {
2457
+ readonly actorDid: string;
2458
+ readonly text: string;
2459
+ readonly reply: { readonly root: BlueskyStrongRef; readonly parent: BlueskyStrongRef } | null;
2460
+ readonly quote: BlueskyStrongRef | null;
2461
+ readonly attachment: null | Readonly<{
2462
+ alt: string;
2463
+ cid: string | null;
2464
+ kind: "image" | "video";
2465
+ }>;
2466
+ },
2467
+ ): void {
2468
+ if (post.author.did !== expected.actorDid || post.text !== expected.text) {
2469
+ throw new Error("Bluesky post readback did not bind the confirmed actor and text");
2470
+ }
2471
+ if (JSON.stringify(post.reply) !== JSON.stringify(expected.reply)) {
2472
+ throw new Error("Bluesky post readback did not bind the confirmed reply root and parent");
2473
+ }
2474
+ if (JSON.stringify(post.quote) !== JSON.stringify(expected.quote)) {
2475
+ throw new Error("Bluesky post readback did not bind the confirmed quoted record");
2476
+ }
2477
+ if (expected.attachment !== null) {
2478
+ const attachment = post.attachments[0];
2479
+ if (
2480
+ post.attachments.length !== 1
2481
+ || attachment?.kind !== expected.attachment.kind
2482
+ || attachment.alt !== expected.attachment.alt
2483
+ || (
2484
+ expected.attachment.cid !== null
2485
+ && attachment.cid !== expected.attachment.cid
2486
+ )
2487
+ ) throw new Error("Bluesky post readback did not bind the confirmed attachment");
2488
+ }
2489
+ }
2490
+
2491
+ type BlueskyPublishFailureStage =
2492
+ | "media-upload"
2493
+ | "record-preparation"
2494
+ | "dispatch-rebinding"
2495
+ | "dispatch-admission"
2496
+ | "create-record"
2497
+ | "accepted-target-recording"
2498
+ | "authoritative-record-readback"
2499
+ | "public-post-readback"
2500
+ | "verification-recording";
2501
+
2502
+ async function executePublish(
2503
+ client: BlueskyClient,
2504
+ recipe: WebSessionRecipe,
2505
+ input: OperationInput,
2506
+ options: {
2507
+ readonly fileResolver?: BrowserFileResolver;
2508
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2509
+ readonly afterProviderAcceptedMutationTarget?: (
2510
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2511
+ ) => Promise<void>;
2512
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2513
+ readonly now: () => number;
2514
+ readonly sleep: (milliseconds: number) => Promise<void>;
2515
+ },
2516
+ ): Promise<WebSessionExecution> {
2517
+ let started = 0;
2518
+ let verified = 0;
2519
+ let failureStage: BlueskyPublishFailureStage = "record-preparation";
2520
+ const posts: BlueskyStrongRef[] = [];
2521
+ let planned = recipe.action === "threads.publish" && Array.isArray(input.items)
2522
+ ? input.items.length
2523
+ : 1;
2524
+ try {
2525
+ const texts = publishTexts(recipe.action, input);
2526
+ planned = texts.length;
2527
+ if (
2528
+ recipe.action !== "posts.publish"
2529
+ && recipe.action !== "replies.create"
2530
+ && recipe.action !== "media.publish"
2531
+ && input.media !== undefined
2532
+ ) throw new Error("Bluesky media is supported only for a post, reply, or video post");
2533
+ const media = recipe.action === "media.publish"
2534
+ ? await readBoundVideo(
2535
+ input,
2536
+ options.fileResolver,
2537
+ client.operationDeadline,
2538
+ )
2539
+ : recipe.action === "posts.publish" || recipe.action === "replies.create"
2540
+ ? await readBoundMedia(
2541
+ input,
2542
+ options.fileResolver,
2543
+ client.operationDeadline,
2544
+ )
2545
+ : null;
2546
+ let reply: { readonly root: BlueskyStrongRef; readonly parent: BlueskyStrongRef } | null = null;
2547
+ let quote: BlueskyStrongRef | null = null;
2548
+ if (recipe.action === "replies.create") {
2549
+ const parent = await getPost(client, postUriInput(input));
2550
+ reply = Object.freeze({
2551
+ root: parent.reply?.root ?? Object.freeze({ uri: parent.uri, cid: parent.cid }),
2552
+ parent: Object.freeze({ uri: parent.uri, cid: parent.cid }),
2553
+ });
2554
+ } else if (recipe.action === "posts.quote") {
2555
+ const target = await getPost(client, postUriInput(input));
2556
+ quote = Object.freeze({ uri: target.uri, cid: target.cid });
2557
+ }
2558
+ for (const [offset, text] of texts.entries()) {
2559
+ const index = offset + 1;
2560
+ const id = recipe.action === "threads.publish"
2561
+ ? `${recipe.action}[${index}]`
2562
+ : recipe.action;
2563
+ if (recipe.action === "threads.publish" && offset > 0) {
2564
+ const parent = posts[offset - 1]!;
2565
+ reply = Object.freeze({
2566
+ root: posts[0]!,
2567
+ parent,
2568
+ });
2569
+ }
2570
+ failureStage = "media-upload";
2571
+ let blob: BlueskyBlobRef | null = null;
2572
+ let videoJobId: string | null = null;
2573
+ if (media !== null && offset === 0) {
2574
+ if (media.mediaType === "video/mp4") {
2575
+ const uploaded = await uploadVideo(client, media, {
2576
+ now: options.now,
2577
+ sleep: options.sleep,
2578
+ });
2579
+ blob = uploaded.blob;
2580
+ videoJobId = uploaded.jobId;
2581
+ } else {
2582
+ blob = await uploadImage(client, media);
2583
+ }
2584
+ }
2585
+ failureStage = "record-preparation";
2586
+ const createdAt = new Date(options.now()).toISOString();
2587
+ const record = Object.freeze({
2588
+ $type: "app.bsky.feed.post",
2589
+ text,
2590
+ createdAt,
2591
+ ...(reply === null ? {} : { reply }),
2592
+ ...(quote === null
2593
+ ? {}
2594
+ : {
2595
+ embed: {
2596
+ $type: "app.bsky.embed.record",
2597
+ record: quote,
2598
+ },
2599
+ }),
2600
+ ...(blob === null
2601
+ ? {}
2602
+ : media?.mediaType === "video/mp4"
2603
+ ? {
2604
+ embed: {
2605
+ $type: "app.bsky.embed.video",
2606
+ video: blob,
2607
+ alt: media.alt,
2608
+ aspectRatio: {
2609
+ width: media.width,
2610
+ height: media.height,
2611
+ },
2612
+ },
2613
+ }
2614
+ : {
2615
+ embed: {
2616
+ $type: "app.bsky.embed.images",
2617
+ images: [{ image: blob, alt: media!.alt }],
2618
+ },
2619
+ }),
2620
+ });
2621
+ failureStage = "dispatch-rebinding";
2622
+ await rebindBeforeDispatch(client);
2623
+ failureStage = "dispatch-admission";
2624
+ await options.beforeDispatch?.(
2625
+ dispatchEvent(id, index, planned, started, verified),
2626
+ );
2627
+ started = index;
2628
+ // createRecord is the externally visible public-post mutation. Keep the
2629
+ // optional uploadBlob preparation entirely before this durable dispatch
2630
+ // boundary: an uploaded blob is not a feed record and cannot publish by
2631
+ // itself.
2632
+ failureStage = "create-record";
2633
+ const created = await createRecord(client, "app.bsky.feed.post", record);
2634
+ failureStage = "accepted-target-recording";
2635
+ await options.afterProviderAcceptedMutationTarget?.({
2636
+ id,
2637
+ index,
2638
+ target: {
2639
+ schemaVersion: 1,
2640
+ identifier: canonicalJson({
2641
+ uri: created.uri,
2642
+ cid: created.cid,
2643
+ createdAt,
2644
+ media: blob === null
2645
+ ? null
2646
+ : media?.mediaType === "video/mp4"
2647
+ ? {
2648
+ cid: blob.ref.$link,
2649
+ height: media.height,
2650
+ jobId: videoJobId,
2651
+ mediaType: blob.mimeType,
2652
+ size: blob.size,
2653
+ width: media.width,
2654
+ }
2655
+ : {
2656
+ cid: blob.ref.$link,
2657
+ mediaType: blob.mimeType,
2658
+ size: blob.size,
2659
+ },
2660
+ }),
2661
+ },
2662
+ });
2663
+ failureStage = "authoritative-record-readback";
2664
+ await getAuthoritativeRecord(client, created, record);
2665
+ failureStage = "public-post-readback";
2666
+ const readback = await waitForPublishReadback(
2667
+ client,
2668
+ created.uri,
2669
+ options.sleep,
2670
+ );
2671
+ if (readback.cid !== created.cid || readback.createdAt !== createdAt) {
2672
+ throw new Error("Bluesky post readback did not bind the created record revision");
2673
+ }
2674
+ assertPublishedPost(readback, {
2675
+ actorDid: client.session.did,
2676
+ text,
2677
+ reply,
2678
+ quote,
2679
+ attachment: blob === null
2680
+ ? null
2681
+ : {
2682
+ alt: media!.alt,
2683
+ cid: media?.mediaType === "video/mp4" ? blob.ref.$link : null,
2684
+ kind: media?.mediaType === "video/mp4" ? "video" : "image",
2685
+ },
2686
+ });
2687
+ posts.push(created);
2688
+ verified = index;
2689
+ failureStage = "verification-recording";
2690
+ await options.afterDispatchVerified?.(
2691
+ dispatchEvent(id, index, planned, started, verified),
2692
+ );
2693
+ }
2694
+ return {
2695
+ status: "succeeded",
2696
+ output: Object.freeze({
2697
+ posts: Object.freeze(posts.map((post) => Object.freeze({
2698
+ uri: post.uri,
2699
+ cid: post.cid,
2700
+ url: `${BLUESKY_APP_ORIGIN}/profile/${client.session.did}/post/${blueskyPostUri(post.uri).rkey}`,
2701
+ }))),
2702
+ }),
2703
+ finalUrl: posts.length === 0
2704
+ ? BLUESKY_APP_ORIGIN
2705
+ : `${BLUESKY_APP_ORIGIN}/profile/${client.session.did}/post/${blueskyPostUri(posts.at(-1)!.uri).rkey}`,
2706
+ dispatchStarted: started > 0,
2707
+ dispatch: { planned, started, verified },
2708
+ };
2709
+ } catch {
2710
+ const status = started > verified ? "indeterminate" : verified > 0 ? "partial" : "failed";
2711
+ return {
2712
+ status,
2713
+ output: posts.length === 0 ? null : Object.freeze({ posts }),
2714
+ finalUrl: posts.length === 0
2715
+ ? BLUESKY_APP_ORIGIN
2716
+ : `${BLUESKY_APP_ORIGIN}/profile/${client.session.did}/post/${blueskyPostUri(posts.at(-1)!.uri).rkey}`,
2717
+ dispatchStarted: started > 0,
2718
+ dispatch: { planned, started, verified },
2719
+ error: status === "indeterminate"
2720
+ ? `Bluesky may have accepted the current post dispatch; failure stage: ${failureStage}; reconcile before retrying`
2721
+ : status === "partial"
2722
+ ? `Bluesky verified only part of the confirmed post workflow; failure stage: ${failureStage}; inspect the verified results before retrying`
2723
+ : `Bluesky post preparation failed before public record submission; failure stage: ${failureStage}; retry with a fresh confirmed plan`,
2724
+ };
2725
+ }
2726
+ }
2727
+
2728
+ async function executeMessageSend(
2729
+ client: BlueskyClient,
2730
+ recipe: WebSessionRecipe,
2731
+ input: OperationInput,
2732
+ options: {
2733
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2734
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2735
+ },
2736
+ ): Promise<WebSessionExecution> {
2737
+ const convoId = convoIdInput(input);
2738
+ const text = assertBlueskyText(input.body, "input.body", 1_000, 10_000);
2739
+ let started = 0;
2740
+ let verified = 0;
2741
+ try {
2742
+ await getConvo(client, convoId);
2743
+ await rebindBeforeDispatch(client);
2744
+ await options.beforeDispatch?.(dispatchEvent(recipe.action, 1, 1, 0, 0));
2745
+ started = 1;
2746
+ const sent = record(
2747
+ await xrpc(client, "chat.bsky.convo.sendMessage", {
2748
+ proxy: BLUESKY_CHAT_PROXY,
2749
+ jsonBody: { convoId, message: { text } },
2750
+ }),
2751
+ "Bluesky sendMessage response",
2752
+ );
2753
+ const sentIdInput: OperationInput = {
2754
+ sent: typeof sent.id === "string" ? sent.id : "",
2755
+ };
2756
+ const sentId = inputString(sentIdInput, "sent", 512);
2757
+ const sentProjection = projectBlueskyMessages(
2758
+ { messages: [sent] },
2759
+ 1,
2760
+ ).messages[0]!;
2761
+ if (sentProjection.senderDid !== client.session.did || sentProjection.text !== text) {
2762
+ throw new Error("Bluesky sendMessage response did not bind the confirmed sender and text");
2763
+ }
2764
+ const readback = await getMessages(client, convoId, 100);
2765
+ const found = readback.messages.find((message) => message.id === sentId);
2766
+ if (
2767
+ found === undefined
2768
+ || found.senderDid !== client.session.did
2769
+ || found.text !== text
2770
+ ) throw new Error("Bluesky message readback did not bind the sent message");
2771
+ verified = 1;
2772
+ await options.afterDispatchVerified?.(dispatchEvent(recipe.action, 1, 1, 1, 1));
2773
+ return {
2774
+ status: "succeeded",
2775
+ output: found,
2776
+ finalUrl: `${BLUESKY_APP_ORIGIN}/messages/${encodeURIComponent(convoId)}`,
2777
+ dispatchStarted: true,
2778
+ dispatch: { planned: 1, started, verified },
2779
+ };
2780
+ } catch {
2781
+ return {
2782
+ status: started > 0 ? "indeterminate" : "failed",
2783
+ output: null,
2784
+ finalUrl: `${BLUESKY_APP_ORIGIN}/messages/${encodeURIComponent(convoId)}`,
2785
+ dispatchStarted: started > 0,
2786
+ dispatch: { planned: 1, started, verified },
2787
+ error: started > 0
2788
+ ? "Bluesky may have accepted the message but exact readback was not verified; reconcile before retrying"
2789
+ : "Bluesky message dispatch failed before submission",
2790
+ };
2791
+ }
2792
+ }
2793
+
2794
+ export async function executeBlueskyWebOperation(
2795
+ recipe: WebSessionRecipe,
2796
+ input: OperationInput,
2797
+ auth: GhostgetAuth,
2798
+ options: {
2799
+ readonly fileResolver?: BrowserFileResolver;
2800
+ readonly signal?: AbortSignal;
2801
+ readonly operationDeadline?: WebSessionOperationDeadline;
2802
+ readonly registerCleanupBarrier?: WebSessionCleanupBarrierRegistrar;
2803
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2804
+ readonly afterProviderAcceptedMutationTarget?: (
2805
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2806
+ ) => Promise<void>;
2807
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2808
+ readonly dependencies?: BlueskyWebRuntimeDependencies;
2809
+ } = {},
2810
+ ): Promise<WebSessionExecution> {
2811
+ if (
2812
+ recipe.site !== "bluesky"
2813
+ || !isBlueskyOperation(recipe.action)
2814
+ ) throw new Error("Bluesky authenticated web recipe is not installed");
2815
+ const expectedContractVersion = recipe.action === "posts.publish"
2816
+ ? 3
2817
+ : recipe.action === "media.publish"
2818
+ ? 2
2819
+ : recipe.action === "profiles.read" ? 2 : 1;
2820
+ if (recipe.contractVersion !== expectedContractVersion) {
2821
+ throw new Error(
2822
+ `Bluesky authenticated web operation ${recipe.action} contract version ${recipe.contractVersion} is not installed`,
2823
+ );
2824
+ }
2825
+ const contract = BLUESKY_WEB_OPERATIONS[recipe.action];
2826
+ if (contract.state !== "observed") {
2827
+ throw new Error(
2828
+ `Bluesky authenticated web operation ${recipe.action} is capture-required: ${contract.reason}`,
2829
+ );
2830
+ }
2831
+ if (recipe.action === "profiles.read") {
2832
+ throw new Error(
2833
+ "Bluesky profiles.read requires the reviewed public runtime hook",
2834
+ );
2835
+ }
2836
+ const client = await bootstrapClient(
2837
+ auth,
2838
+ recipe.timeoutMs,
2839
+ recipe.maxOutputBytes,
2840
+ options.dependencies,
2841
+ options.operationDeadline,
2842
+ options.registerCleanupBarrier,
2843
+ );
2844
+ requireBoundSubject(auth, client.session);
2845
+ if (recipe.action === "feeds.read") return executeFeedRead(client, input);
2846
+ if (recipe.action === "posts.read") return executePostRead(client, input, false);
2847
+ if (recipe.action === "media.read") return executePostRead(client, input, true);
2848
+ if (recipe.action === "comments.read") return executeCommentsRead(client, input);
2849
+ if (recipe.action === "messaging.list") return executeMessagingList(client, input);
2850
+ if (recipe.action === "messaging.read") return executeMessagingRead(client, input);
2851
+ const mutationOptions = {
2852
+ ...(options.beforeDispatch === undefined ? {} : { beforeDispatch: options.beforeDispatch }),
2853
+ ...(options.afterDispatchVerified === undefined
2854
+ ? {}
2855
+ : { afterDispatchVerified: options.afterDispatchVerified }),
2856
+ ...(options.afterProviderAcceptedMutationTarget === undefined
2857
+ ? {}
2858
+ : {
2859
+ afterProviderAcceptedMutationTarget:
2860
+ options.afterProviderAcceptedMutationTarget,
2861
+ }),
2862
+ now: options.dependencies?.now ?? Date.now,
2863
+ sleep: options.dependencies?.sleep
2864
+ ?? ((milliseconds) => new Promise<void>((resolve) => setTimeout(resolve, milliseconds))),
2865
+ };
2866
+ if (
2867
+ recipe.action === "likes.set"
2868
+ || recipe.action === "content.save"
2869
+ || recipe.action === "posts.repost"
2870
+ ) return executePostDesiredState(client, recipe, input, mutationOptions);
2871
+ if (recipe.action === "content.delete") {
2872
+ return executeContentDelete(client, recipe, input, mutationOptions);
2873
+ }
2874
+ if (recipe.action === "relationships.follow.set") {
2875
+ return executeFollowDesiredState(client, recipe, input, mutationOptions);
2876
+ }
2877
+ if (
2878
+ recipe.action === "posts.publish"
2879
+ || recipe.action === "media.publish"
2880
+ || recipe.action === "replies.create"
2881
+ || recipe.action === "posts.quote"
2882
+ || recipe.action === "threads.publish"
2883
+ ) {
2884
+ return executePublish(client, recipe, input, {
2885
+ ...mutationOptions,
2886
+ ...(options.fileResolver === undefined ? {} : { fileResolver: options.fileResolver }),
2887
+ });
2888
+ }
2889
+ if (recipe.action === "messaging.send") {
2890
+ return executeMessageSend(client, recipe, input, mutationOptions);
2891
+ }
2892
+ throw new Error(
2893
+ `Bluesky authenticated web operation ${recipe.action} has no executable reviewed contract`,
2894
+ );
2895
+ }