@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,2812 @@
1
+ import { Blob } from "node:buffer";
2
+ import { createHash } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { open } from "node:fs/promises";
5
+ import { types as nodeTypes } from "node:util";
6
+
7
+ import { renderCookieHeader } from "@hraness/kb/clip/cookies";
8
+
9
+ import type { GhostgetAuth } from "../auth";
10
+ import type { BrowserFileResolver } from "../browser";
11
+ import { canonicalJson } from "../canonical-json";
12
+ import type { FileInputValue, OperationInput, WebSessionRecipe } from "../model";
13
+ import { OperationDeadline } from "../operation-deadline";
14
+ import { pinnedHttpsFetch } from "../pinned-https";
15
+ import {
16
+ createWebSessionClient,
17
+ webSessionAuthSubject,
18
+ type WebSessionClient,
19
+ type WebSessionNetworkDependencies,
20
+ } from "../web-session-client";
21
+ import type {
22
+ WebSessionDispatchEvent,
23
+ WebSessionExecution,
24
+ WebSessionOperationDeadline,
25
+ WebSessionProviderAcceptedMutationTargetEvent,
26
+ } from "../web-session-execution";
27
+ import { failedProviderRead } from "./read-failure";
28
+ import { substackMp4Metadata } from "./substack-video-mp4";
29
+ import {
30
+ SUBSTACK_WEB_OPERATION_NAMES,
31
+ SUBSTACK_WEB_OPERATIONS,
32
+ authorizeSubstackWebReadRequest,
33
+ normalizeSubstackArticleResponse,
34
+ normalizeSubstackCommentsResponse,
35
+ normalizeSubstackFeedResponse,
36
+ normalizeSubstackMediaResponse,
37
+ normalizeSubstackMessageInbox,
38
+ normalizeSubstackNoteResponse,
39
+ normalizeSubstackPublicationStatsResponse,
40
+ normalizeSubstackProfileStatsResponse,
41
+ parseSubstackLoggedInResponse,
42
+ parseSubstackPreloadsHtml,
43
+ SubstackAuthRepairRequiredError,
44
+ type SubstackFeedName,
45
+ type SubstackWebOperationName,
46
+ type SubstackWebViewer,
47
+ } from "./substack-web";
48
+
49
+ const SUBSTACK_ORIGIN = "https://substack.com";
50
+ const MAX_BOOTSTRAP_BYTES = 8 * 1024 * 1024;
51
+ const MAX_LOGIN_BYTES = 256 * 1024;
52
+ const MAX_READ_BYTES = 8 * 1024 * 1024;
53
+ const MAX_SUBSTACK_IMAGE_BYTES = 20 * 1024 * 1024;
54
+ const MAX_SUBSTACK_VIDEO_BYTES = 128 * 1024 * 1024;
55
+ const SUBSTACK_VIDEO_BINDING_KEYS = Object.freeze([
56
+ "byteLength",
57
+ "bytes",
58
+ "durationSeconds",
59
+ "height",
60
+ "mediaType",
61
+ "sha256",
62
+ "width",
63
+ ] as const);
64
+ export const SUBSTACK_VIDEO_MULTIPART_CHUNK_BYTES = 50 * 1024 * 1024;
65
+ const MAX_SUBSTACK_VIDEO_PARTS = Math.ceil(
66
+ MAX_SUBSTACK_VIDEO_BYTES / SUBSTACK_VIDEO_MULTIPART_CHUNK_BYTES,
67
+ );
68
+ const MAX_SUBSTACK_RECOVERY_IDENTIFIER_BYTES = 4_096;
69
+ const DEFAULT_LIMIT = 20;
70
+ const SUBSTACK_NOTE_READBACK_DELAYS_MS = Object.freeze([500, 1_500, 4_000]);
71
+ const SUBSTACK_DELETE_REQUEST_LABEL = "Substack personal Note deletion request";
72
+ const MIN_PINNED_HTTPS_TIMEOUT_MS = 1_000;
73
+ const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(
74
+ Uint8Array.prototype,
75
+ ) as object;
76
+ const TYPED_ARRAY_BUFFER_GETTER = Object.getOwnPropertyDescriptor(
77
+ TYPED_ARRAY_PROTOTYPE,
78
+ "buffer",
79
+ )?.get;
80
+ const TYPED_ARRAY_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor(
81
+ TYPED_ARRAY_PROTOTYPE,
82
+ "byteLength",
83
+ )?.get;
84
+
85
+ type SubstackWebSleep = (
86
+ milliseconds: number,
87
+ signal?: AbortSignal,
88
+ ) => Promise<void>;
89
+
90
+ export type SubstackWebRuntimeDependencies = Partial<WebSessionNetworkDependencies> & {
91
+ readonly now?: () => number;
92
+ readonly sleep?: SubstackWebSleep;
93
+ };
94
+
95
+ function sleepForSubstackReadback(
96
+ milliseconds: number,
97
+ signal?: AbortSignal,
98
+ ): Promise<void> {
99
+ return new Promise((resolve, reject) => {
100
+ if (signal?.aborted === true) {
101
+ reject(new Error("Substack Note readback wait was cancelled"));
102
+ return;
103
+ }
104
+ const timer = setTimeout(() => {
105
+ signal?.removeEventListener("abort", abort);
106
+ resolve();
107
+ }, milliseconds);
108
+ const abort = (): void => {
109
+ clearTimeout(timer);
110
+ signal?.removeEventListener("abort", abort);
111
+ reject(new Error("Substack Note readback wait was cancelled"));
112
+ };
113
+ signal?.addEventListener("abort", abort, { once: true });
114
+ });
115
+ }
116
+
117
+ function isSubstackOperation(value: string): value is SubstackWebOperationName {
118
+ return (SUBSTACK_WEB_OPERATION_NAMES as readonly string[]).includes(value);
119
+ }
120
+
121
+ function integerInput(
122
+ input: OperationInput,
123
+ name: string,
124
+ fallback: number,
125
+ minimum: number,
126
+ maximum: number,
127
+ ): number {
128
+ const value = input[name] ?? fallback;
129
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
130
+ throw new Error(`input.${name} must be an integer between ${minimum} and ${maximum}`);
131
+ }
132
+ return value as number;
133
+ }
134
+
135
+ function positiveIdInput(input: OperationInput, name: string): number {
136
+ return integerInput(input, name, Number.NaN, 1, Number.MAX_SAFE_INTEGER);
137
+ }
138
+
139
+ function optionalStringInput(
140
+ input: OperationInput,
141
+ name: string,
142
+ maximum: number,
143
+ ): string | undefined {
144
+ const value = input[name];
145
+ if (value === undefined) return undefined;
146
+ if (
147
+ typeof value !== "string"
148
+ || value.length < 1
149
+ || value.length > maximum
150
+ || /[\0\r\n]/u.test(value)
151
+ ) throw new Error(`input.${name} must be bounded text`);
152
+ return value;
153
+ }
154
+
155
+ function substackProfileInput(input: OperationInput): string {
156
+ const value = input.profile;
157
+ if (
158
+ typeof value !== "string"
159
+ || !/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u.test(value)
160
+ ) throw new Error("input.profile must be one canonical lowercase Substack handle");
161
+ return value;
162
+ }
163
+
164
+ function substackOrganizationInput(input: OperationInput): string {
165
+ const value = input.organization;
166
+ if (
167
+ typeof value !== "string"
168
+ || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(value)
169
+ ) throw new Error("input.organization must be one canonical lowercase Substack subdomain");
170
+ return value;
171
+ }
172
+
173
+ function isRecord(value: unknown): value is Record<string, unknown> {
174
+ return typeof value === "object" && value !== null && !Array.isArray(value);
175
+ }
176
+
177
+ function requireExactKeys(
178
+ value: Readonly<Record<string, unknown>>,
179
+ expected: readonly string[],
180
+ label: string,
181
+ ): void {
182
+ if (Object.keys(value).sort().join(",") !== [...expected].sort().join(",")) {
183
+ throw new Error(`${label} keys did not match the reviewed contract`);
184
+ }
185
+ }
186
+
187
+ function requireAllowedKeys(
188
+ value: Readonly<Record<string, unknown>>,
189
+ allowed: readonly string[],
190
+ label: string,
191
+ ): void {
192
+ const permitted = new Set(allowed);
193
+ if (Object.keys(value).some((key) => !permitted.has(key))) {
194
+ throw new Error(`${label} contained fields outside the reviewed contract`);
195
+ }
196
+ }
197
+
198
+ function requireExactInputKeys(input: OperationInput, allowed: readonly string[]): void {
199
+ const permitted = new Set(allowed);
200
+ const unexpected = Object.keys(input).filter((key) => !permitted.has(key));
201
+ if (unexpected.length > 0) {
202
+ throw new Error(`input contains unsupported keys: ${unexpected.join(", ")}`);
203
+ }
204
+ }
205
+
206
+ function fileInput(value: OperationInput[string] | undefined): FileInputValue {
207
+ if (
208
+ !isRecord(value)
209
+ || value.kind !== "file"
210
+ || typeof value.reference !== "string"
211
+ || value.reference.length < 1
212
+ || value.reference.length > 4_096
213
+ || /[\0\r\n]/u.test(value.reference)
214
+ || Object.keys(value).sort().join(",") !== "kind,reference"
215
+ ) throw new Error("input.media must be one plan-bound file");
216
+ return Object.freeze({ kind: "file", reference: value.reference });
217
+ }
218
+
219
+ function substackNoteText(value: unknown, label: string): string {
220
+ if (
221
+ typeof value !== "string"
222
+ || value.length < 1
223
+ || value.length > 500
224
+ || /[\0\r]/u.test(value)
225
+ ) throw new Error(`${label} must be bounded Substack Note text`);
226
+ return value;
227
+ }
228
+
229
+ export type SubstackVideoNotePublishPlan = Readonly<{
230
+ readonly body: string;
231
+ readonly media: FileInputValue;
232
+ }>;
233
+
234
+ /** Validate the complete capture-neutral Note-video plan without resolving it. */
235
+ export function prepareSubstackVideoNotePublishInput(
236
+ input: OperationInput,
237
+ ): SubstackVideoNotePublishPlan {
238
+ requireExactInputKeys(input, ["body", "media"]);
239
+ return Object.freeze({
240
+ body: substackNoteText(input.body, "input.body"),
241
+ media: fileInput(input.media),
242
+ });
243
+ }
244
+
245
+ export type SubstackPersonalNoteDeletePlan = Readonly<{
246
+ readonly expectedBody: string;
247
+ readonly noteId: number;
248
+ }>;
249
+
250
+ /** Validate the exact authored-personal-Note deletion confirmation. */
251
+ export function prepareSubstackPersonalNoteDeleteInput(
252
+ input: OperationInput,
253
+ ): SubstackPersonalNoteDeletePlan {
254
+ requireExactInputKeys(input, ["expected_body", "note_id"]);
255
+ return Object.freeze({
256
+ expectedBody: substackNoteText(input.expected_body, "input.expected_body"),
257
+ noteId: positiveIdInput(input, "note_id"),
258
+ });
259
+ }
260
+
261
+ type SubstackImage = {
262
+ readonly bytes: Uint8Array;
263
+ readonly height: number;
264
+ readonly mediaType: "image/png";
265
+ readonly width: number;
266
+ };
267
+
268
+ export type SubstackVideo = Readonly<{
269
+ readonly bytes: Uint8Array<ArrayBuffer>;
270
+ readonly byteLength: number;
271
+ readonly durationSeconds: number;
272
+ readonly height: number;
273
+ readonly mediaType: "video/mp4";
274
+ readonly sha256: string;
275
+ readonly width: number;
276
+ }>;
277
+
278
+ function substackVideoSha256(bytes: Uint8Array): string {
279
+ return createHash("sha256").update(bytes).digest("hex");
280
+ }
281
+
282
+ function exactSubstackVideoBinding(
283
+ value: unknown,
284
+ ): Readonly<Record<string, unknown>> {
285
+ if (
286
+ typeof value !== "object"
287
+ || value === null
288
+ ) throw new Error("Substack video binding must be one exact object");
289
+ if (nodeTypes.isProxy(value)) {
290
+ throw new Error("Substack video binding must not be a proxy");
291
+ }
292
+ if (Array.isArray(value)) {
293
+ throw new Error("Substack video binding must be one exact object");
294
+ }
295
+ const prototype: unknown = Object.getPrototypeOf(value);
296
+ if (prototype !== Object.prototype && prototype !== null) {
297
+ throw new Error("Substack video binding must use a plain prototype");
298
+ }
299
+ const descriptors = Object.getOwnPropertyDescriptors(value);
300
+ const ownKeys = Reflect.ownKeys(descriptors);
301
+ if (
302
+ ownKeys.length !== SUBSTACK_VIDEO_BINDING_KEYS.length
303
+ || ownKeys.some((key) => typeof key !== "string")
304
+ || (ownKeys as string[]).sort().join(",")
305
+ !== [...SUBSTACK_VIDEO_BINDING_KEYS].sort().join(",")
306
+ ) throw new Error("Substack video binding contained unsupported fields");
307
+ const snapshot = Object.create(null) as Record<string, unknown>;
308
+ for (const key of SUBSTACK_VIDEO_BINDING_KEYS) {
309
+ const descriptor = descriptors[key];
310
+ if (
311
+ descriptor === undefined
312
+ || !descriptor.enumerable
313
+ || !("value" in descriptor)
314
+ ) {
315
+ throw new Error(
316
+ "Substack video binding must contain only enumerable data properties",
317
+ );
318
+ }
319
+ snapshot[key] = descriptor.value;
320
+ }
321
+ return Object.freeze(snapshot);
322
+ }
323
+
324
+ function snapshotSubstackVideoBytes(value: unknown): Uint8Array<ArrayBuffer> {
325
+ if (
326
+ typeof value !== "object"
327
+ || value === null
328
+ || nodeTypes.isProxy(value)
329
+ || !(value instanceof Uint8Array)
330
+ || Object.getPrototypeOf(value) !== Uint8Array.prototype
331
+ || TYPED_ARRAY_BUFFER_GETTER === undefined
332
+ || TYPED_ARRAY_BYTE_LENGTH_GETTER === undefined
333
+ ) throw new Error("Substack video binding must contain one bounded MP4");
334
+ let buffer: unknown;
335
+ let byteLength: unknown;
336
+ try {
337
+ buffer = TYPED_ARRAY_BUFFER_GETTER.call(value) as unknown;
338
+ byteLength = TYPED_ARRAY_BYTE_LENGTH_GETTER.call(value) as unknown;
339
+ } catch {
340
+ throw new Error("Substack video binding must contain one bounded MP4");
341
+ }
342
+ if (
343
+ typeof byteLength !== "number"
344
+ || !Number.isSafeInteger(byteLength)
345
+ || byteLength < 24
346
+ || byteLength > MAX_SUBSTACK_VIDEO_BYTES
347
+ || nodeTypes.isSharedArrayBuffer(buffer)
348
+ ) throw new Error("Substack video binding must contain one bounded MP4");
349
+ const bytes = new Uint8Array(byteLength);
350
+ try {
351
+ Uint8Array.prototype.set.call(bytes, value);
352
+ } catch {
353
+ throw new Error("Substack video binding must contain one bounded MP4");
354
+ }
355
+ return bytes;
356
+ }
357
+
358
+ /** Reparse one local video binding so mutable or caller-forged metadata fails. */
359
+ export function parseSubstackVideoBinding(value: unknown): SubstackVideo {
360
+ const binding = exactSubstackVideoBinding(value);
361
+ const bytes = snapshotSubstackVideoBytes(binding.bytes);
362
+ if (binding.mediaType !== "video/mp4") {
363
+ throw new Error("Substack video binding must contain one bounded MP4");
364
+ }
365
+ const metadata = substackMp4Metadata(bytes, "Substack video");
366
+ const sha256 = substackVideoSha256(bytes);
367
+ if (
368
+ !Number.isSafeInteger(binding.byteLength)
369
+ || binding.byteLength !== bytes.byteLength
370
+ || typeof binding.sha256 !== "string"
371
+ || !/^[a-f0-9]{64}$/u.test(binding.sha256)
372
+ || binding.sha256 !== sha256
373
+ ) throw new Error("Substack video binding byte integrity changed from its exact bytes");
374
+ if (
375
+ binding.durationSeconds !== metadata.durationSeconds
376
+ || binding.height !== metadata.height
377
+ || binding.width !== metadata.width
378
+ ) throw new Error("Substack video binding metadata changed from its exact bytes");
379
+ return Object.freeze({
380
+ bytes,
381
+ byteLength: bytes.byteLength,
382
+ durationSeconds: metadata.durationSeconds,
383
+ height: metadata.height,
384
+ mediaType: "video/mp4" as const,
385
+ sha256,
386
+ width: metadata.width,
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Materialize one plan-bound MP4 without following a final symlink and bind
392
+ * duration plus dimensions to the exact stable bytes. This is shared protocol
393
+ * groundwork only; media.publish remains network-inert until its response and
394
+ * Note attachment contracts are captured.
395
+ */
396
+ export async function materializeSubstackVideo(
397
+ media: FileInputValue,
398
+ fileResolver: BrowserFileResolver | undefined,
399
+ operationDeadline: WebSessionOperationDeadline | undefined,
400
+ ): Promise<SubstackVideo> {
401
+ if (fileResolver === undefined) {
402
+ throw new Error("Substack video upload requires the plan-bound file resolver");
403
+ }
404
+ const paths = operationDeadline === undefined
405
+ ? await fileResolver([media])
406
+ : await operationDeadline.run(
407
+ () => fileResolver([media]),
408
+ "authenticated web operation deadline",
409
+ );
410
+ operationDeadline?.throwIfUnavailable("authenticated web operation deadline");
411
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
412
+ throw new Error("Substack file resolver did not return one exact path");
413
+ }
414
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
415
+ const handle = operationDeadline === undefined
416
+ ? await open(paths[0], constants.O_RDONLY | noFollow)
417
+ : await operationDeadline.run(
418
+ () => open(paths[0]!, constants.O_RDONLY | noFollow),
419
+ "authenticated web operation deadline",
420
+ );
421
+ try {
422
+ const before = operationDeadline === undefined
423
+ ? await handle.stat()
424
+ : await operationDeadline.run(
425
+ () => handle.stat(),
426
+ "authenticated web operation deadline",
427
+ );
428
+ if (!before.isFile() || before.size < 24 || before.size > MAX_SUBSTACK_VIDEO_BYTES) {
429
+ throw new Error(
430
+ "Substack video must be a regular MP4 no larger than the 128 MiB in-memory publish limit",
431
+ );
432
+ }
433
+ const bytes = operationDeadline === undefined
434
+ ? await handle.readFile()
435
+ : await operationDeadline.run(
436
+ () => handle.readFile(),
437
+ "authenticated web operation deadline",
438
+ );
439
+ const after = operationDeadline === undefined
440
+ ? await handle.stat()
441
+ : await operationDeadline.run(
442
+ () => handle.stat(),
443
+ "authenticated web operation deadline",
444
+ );
445
+ if (
446
+ before.dev !== after.dev
447
+ || before.ino !== after.ino
448
+ || before.size !== after.size
449
+ || before.mtimeMs !== after.mtimeMs
450
+ || before.ctimeMs !== after.ctimeMs
451
+ || bytes.byteLength !== before.size
452
+ ) throw new Error("Substack video changed while it was materialized");
453
+ const snapshot = new Uint8Array(bytes);
454
+ const metadata = substackMp4Metadata(snapshot, "Substack video");
455
+ return Object.freeze({
456
+ bytes: snapshot,
457
+ byteLength: snapshot.byteLength,
458
+ durationSeconds: metadata.durationSeconds,
459
+ height: metadata.height,
460
+ mediaType: "video/mp4" as const,
461
+ sha256: substackVideoSha256(snapshot),
462
+ width: metadata.width,
463
+ });
464
+ } finally {
465
+ await handle.close();
466
+ }
467
+ }
468
+
469
+ export type SubstackVideoMultipartPart = Readonly<{
470
+ readonly byteLength: number;
471
+ readonly endExclusive: number;
472
+ readonly partNumber: number;
473
+ readonly start: number;
474
+ }>;
475
+
476
+ /** Exact byte coverage used by the current first-party 50 MiB uploader. */
477
+ export function planSubstackVideoMultipartParts(
478
+ byteLength: number,
479
+ uploadUrlCount: number,
480
+ ): readonly SubstackVideoMultipartPart[] {
481
+ if (
482
+ !Number.isSafeInteger(byteLength)
483
+ || byteLength < 24
484
+ || byteLength > MAX_SUBSTACK_VIDEO_BYTES
485
+ ) throw new Error("Substack video byte length is outside the reviewed bound");
486
+ const expectedCount = Math.ceil(byteLength / SUBSTACK_VIDEO_MULTIPART_CHUNK_BYTES);
487
+ if (
488
+ !Number.isSafeInteger(uploadUrlCount)
489
+ || uploadUrlCount !== expectedCount
490
+ || uploadUrlCount < 1
491
+ || uploadUrlCount > MAX_SUBSTACK_VIDEO_PARTS
492
+ ) throw new Error("Substack multipart URL count does not exactly cover the video");
493
+ return Object.freeze(Array.from({ length: uploadUrlCount }, (_, index) => {
494
+ const start = index * SUBSTACK_VIDEO_MULTIPART_CHUNK_BYTES;
495
+ const endExclusive = Math.min(
496
+ byteLength,
497
+ start + SUBSTACK_VIDEO_MULTIPART_CHUNK_BYTES,
498
+ );
499
+ return Object.freeze({
500
+ byteLength: endExclusive - start,
501
+ endExclusive,
502
+ partNumber: index + 1,
503
+ start,
504
+ });
505
+ }));
506
+ }
507
+
508
+ export type SubstackVideoMultipartTransferBody = Readonly<{
509
+ readonly body: Blob;
510
+ readonly byteLength: number;
511
+ readonly credentials: "omit";
512
+ readonly endExclusive: number;
513
+ readonly formData: false;
514
+ readonly method: "PUT";
515
+ readonly partNumber: number;
516
+ readonly start: number;
517
+ }>;
518
+
519
+ export type SubstackVideoMultipartDispatchCheckpoint = Readonly<{
520
+ readonly byteLength: number;
521
+ readonly durationSeconds: number;
522
+ readonly height: number;
523
+ readonly mediaType: "video/mp4";
524
+ readonly partCount: number;
525
+ readonly schemaVersion: 1;
526
+ readonly sha256: string;
527
+ readonly width: number;
528
+ }>;
529
+
530
+ export type SubstackVideoMultipartDispatchSnapshot = Readonly<{
531
+ readonly checkpoint: SubstackVideoMultipartDispatchCheckpoint;
532
+ readonly parts: readonly SubstackVideoMultipartTransferBody[];
533
+ }>;
534
+
535
+ function parseSubstackVideoMultipartDispatchCheckpoint(
536
+ value: unknown,
537
+ ): SubstackVideoMultipartDispatchCheckpoint {
538
+ if (!isRecord(value)) {
539
+ throw new Error("Substack multipart dispatch checkpoint must be an object");
540
+ }
541
+ requireExactKeys(
542
+ value,
543
+ [
544
+ "byteLength",
545
+ "durationSeconds",
546
+ "height",
547
+ "mediaType",
548
+ "partCount",
549
+ "schemaVersion",
550
+ "sha256",
551
+ "width",
552
+ ],
553
+ "Substack multipart dispatch checkpoint",
554
+ );
555
+ if (
556
+ value.schemaVersion !== 1
557
+ || value.mediaType !== "video/mp4"
558
+ || !Number.isSafeInteger(value.byteLength)
559
+ || !Number.isSafeInteger(value.partCount)
560
+ || typeof value.durationSeconds !== "number"
561
+ || !Number.isFinite(value.durationSeconds)
562
+ || value.durationSeconds <= 0
563
+ || !Number.isSafeInteger(value.height)
564
+ || (value.height as number) < 1
565
+ || (value.height as number) > 20_000
566
+ || !Number.isSafeInteger(value.width)
567
+ || (value.width as number) < 1
568
+ || (value.width as number) > 20_000
569
+ || typeof value.sha256 !== "string"
570
+ || !/^[a-f0-9]{64}$/u.test(value.sha256)
571
+ ) throw new Error("Substack multipart dispatch checkpoint changed shape");
572
+ planSubstackVideoMultipartParts(
573
+ value.byteLength as number,
574
+ value.partCount as number,
575
+ );
576
+ return Object.freeze({
577
+ byteLength: value.byteLength as number,
578
+ durationSeconds: value.durationSeconds,
579
+ height: value.height as number,
580
+ mediaType: "video/mp4" as const,
581
+ partCount: value.partCount as number,
582
+ schemaVersion: 1 as const,
583
+ sha256: value.sha256,
584
+ width: value.width as number,
585
+ });
586
+ }
587
+
588
+ /**
589
+ * Pin the exact local byte version and canonical part count before any future
590
+ * multipart dispatch. This checkpoint contains no target or retry authority.
591
+ */
592
+ export function createSubstackVideoMultipartDispatchCheckpoint(
593
+ videoValue: unknown,
594
+ uploadUrlCount: number,
595
+ ): SubstackVideoMultipartDispatchCheckpoint {
596
+ const video = parseSubstackVideoBinding(videoValue);
597
+ const parts = planSubstackVideoMultipartParts(video.byteLength, uploadUrlCount);
598
+ return Object.freeze({
599
+ byteLength: video.byteLength,
600
+ durationSeconds: video.durationSeconds,
601
+ height: video.height,
602
+ mediaType: video.mediaType,
603
+ partCount: parts.length,
604
+ schemaVersion: 1 as const,
605
+ sha256: video.sha256,
606
+ width: video.width,
607
+ });
608
+ }
609
+
610
+ /**
611
+ * Reparse and digest the entire current binding once immediately before a
612
+ * future dispatch, require its original checkpoint, then snapshot every part
613
+ * as an immutable Blob from that one byte version. Provider-issued URLs,
614
+ * accepted PUT statuses, response ETags, and dispatch hooks remain absent
615
+ * until an authorized capture proves those contracts.
616
+ */
617
+ export function revalidateAndSnapshotSubstackVideoMultipartDispatch(
618
+ videoValue: unknown,
619
+ checkpointValue: unknown,
620
+ ): SubstackVideoMultipartDispatchSnapshot {
621
+ const video = parseSubstackVideoBinding(videoValue);
622
+ const checkpoint = parseSubstackVideoMultipartDispatchCheckpoint(checkpointValue);
623
+ if (
624
+ video.byteLength !== checkpoint.byteLength
625
+ || video.durationSeconds !== checkpoint.durationSeconds
626
+ || video.height !== checkpoint.height
627
+ || video.mediaType !== checkpoint.mediaType
628
+ || video.sha256 !== checkpoint.sha256
629
+ || video.width !== checkpoint.width
630
+ ) throw new Error("Substack video changed after its multipart dispatch checkpoint");
631
+ const plannedParts = planSubstackVideoMultipartParts(
632
+ checkpoint.byteLength,
633
+ checkpoint.partCount,
634
+ );
635
+ const immutableVideo = new Blob([
636
+ // Parsing rejected shared storage and copied the bytes into a fresh buffer.
637
+ video.bytes as Uint8Array<ArrayBuffer>,
638
+ ], { type: video.mediaType });
639
+ if (
640
+ immutableVideo.size !== checkpoint.byteLength
641
+ || immutableVideo.type !== checkpoint.mediaType
642
+ ) throw new Error("Substack video multipart snapshot changed shape");
643
+ return Object.freeze({
644
+ checkpoint,
645
+ parts: Object.freeze(plannedParts.map((part) => Object.freeze({
646
+ body: immutableVideo.slice(part.start, part.endExclusive, video.mediaType),
647
+ byteLength: part.byteLength,
648
+ credentials: "omit" as const,
649
+ endExclusive: part.endExclusive,
650
+ formData: false as const,
651
+ method: "PUT" as const,
652
+ partNumber: part.partNumber,
653
+ start: part.start,
654
+ }))),
655
+ });
656
+ }
657
+
658
+ function substackResponseBoundIdentifier(value: unknown, label: string): string {
659
+ const candidate = typeof value === "number" && Number.isSafeInteger(value) && value > 0
660
+ ? String(value)
661
+ : value;
662
+ if (
663
+ typeof candidate !== "string"
664
+ || !/^[A-Za-z0-9_-]{1,256}$/u.test(candidate)
665
+ ) throw new Error(`${label} must be one response-bound identifier`);
666
+ return candidate;
667
+ }
668
+
669
+ /** Parse ordered strong ETag-shaped values for a future captured PUT contract. */
670
+ export function parseSubstackVideoMultipartEtags(
671
+ value: unknown,
672
+ expectedCount: number,
673
+ ): readonly string[] {
674
+ if (
675
+ !Number.isSafeInteger(expectedCount)
676
+ || expectedCount < 1
677
+ || expectedCount > MAX_SUBSTACK_VIDEO_PARTS
678
+ ) throw new Error("Substack multipart ETags did not bind every ordered part");
679
+ if (typeof value !== "object" || value === null || nodeTypes.isProxy(value)) {
680
+ throw new Error("Substack multipart ETags must be one exact data-only array");
681
+ }
682
+ if (
683
+ !Array.isArray(value)
684
+ || Object.getPrototypeOf(value) !== Array.prototype
685
+ ) throw new Error("Substack multipart ETags must be one exact data-only array");
686
+ const descriptors = Object.getOwnPropertyDescriptors(value) as unknown as Readonly<
687
+ Record<PropertyKey, PropertyDescriptor | undefined>
688
+ >;
689
+ const ownKeys = Reflect.ownKeys(descriptors);
690
+ const lengthDescriptor = descriptors.length;
691
+ if (
692
+ lengthDescriptor === undefined
693
+ || !("value" in lengthDescriptor)
694
+ || lengthDescriptor.value !== expectedCount
695
+ ) throw new Error("Substack multipart ETags did not bind every ordered part");
696
+ if (
697
+ ownKeys.length !== expectedCount + 1
698
+ || ownKeys.some((key) => typeof key !== "string")
699
+ ) throw new Error("Substack multipart ETags must be one exact data-only array");
700
+ const snapshot: string[] = [];
701
+ for (let index = 0; index < expectedCount; index += 1) {
702
+ const descriptor = descriptors[String(index)];
703
+ if (
704
+ descriptor === undefined
705
+ || !descriptor.enumerable
706
+ || !("value" in descriptor)
707
+ ) {
708
+ throw new Error("Substack multipart ETags must be one exact data-only array");
709
+ }
710
+ const entry = descriptor.value;
711
+ if (
712
+ typeof entry !== "string"
713
+ || !/^"[\x21\x23-\x7e]{1,256}"$/u.test(entry)
714
+ ) throw new Error("Substack multipart ETag changed from a bounded strong entity-tag");
715
+ snapshot.push(entry);
716
+ }
717
+ return Object.freeze(snapshot);
718
+ }
719
+
720
+ export type SubstackVideoUploadState =
721
+ | "cancelled"
722
+ | "created"
723
+ | "error"
724
+ | "transcoded"
725
+ | "uploaded";
726
+
727
+ export function parseSubstackVideoUploadState(value: unknown): SubstackVideoUploadState {
728
+ if (
729
+ value !== "cancelled"
730
+ && value !== "created"
731
+ && value !== "error"
732
+ && value !== "transcoded"
733
+ && value !== "uploaded"
734
+ ) throw new Error("Substack video upload returned an unreviewed state");
735
+ return value;
736
+ }
737
+
738
+ export type SubstackVideoUploadSettlement = Readonly<{
739
+ readonly state: SubstackVideoUploadState;
740
+ readonly status: "complete" | "pending" | "terminal-failure";
741
+ }>;
742
+
743
+ /** Classify only the lifecycle states named by the current first-party bundle. */
744
+ export function classifySubstackVideoUploadState(
745
+ value: unknown,
746
+ ): SubstackVideoUploadSettlement {
747
+ const state = parseSubstackVideoUploadState(value);
748
+ return Object.freeze({
749
+ state,
750
+ status: state === "transcoded"
751
+ ? "complete" as const
752
+ : state === "created" || state === "uploaded"
753
+ ? "pending" as const
754
+ : "terminal-failure" as const,
755
+ });
756
+ }
757
+
758
+ /** Bundle-derived initialization request-shape candidate; contextual IDs are omitted. */
759
+ export function substackVideoUploadInitializationRequest(
760
+ byteLength: number,
761
+ ): Readonly<{ method: "POST"; url: string }> {
762
+ planSubstackVideoMultipartParts(
763
+ byteLength,
764
+ Math.ceil(byteLength / SUBSTACK_VIDEO_MULTIPART_CHUNK_BYTES),
765
+ );
766
+ const url = new URL("/api/v1/video/upload", SUBSTACK_ORIGIN);
767
+ url.searchParams.set("filetype", "video/mp4");
768
+ url.searchParams.set("fileSize", String(byteLength));
769
+ url.searchParams.set("fileName", "wrench-video.mp4");
770
+ return Object.freeze({ method: "POST" as const, url: url.href });
771
+ }
772
+
773
+ /** Build initialization only after reparsing the exact local MP4 binding. */
774
+ export function substackVideoUploadInitializationRequestForBinding(
775
+ videoValue: unknown,
776
+ ): Readonly<{ method: "POST"; url: string }> {
777
+ const video = parseSubstackVideoBinding(videoValue);
778
+ return substackVideoUploadInitializationRequest(video.bytes.byteLength);
779
+ }
780
+
781
+ /** Bundle-derived transcode request-shape candidate over response-bound identifiers. */
782
+ export function substackVideoTranscodeRequest(
783
+ mediaUploadId: unknown,
784
+ multipartUploadId: unknown,
785
+ durationSeconds: number,
786
+ videoByteLength: number,
787
+ uploadUrlCount: number,
788
+ etags: unknown,
789
+ ): Readonly<{
790
+ body: Readonly<{
791
+ duration: number;
792
+ multipart_upload_etags: readonly string[];
793
+ multipart_upload_id: string;
794
+ }>;
795
+ method: "POST";
796
+ url: string;
797
+ }> {
798
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
799
+ throw new Error("Substack video duration must be finite and positive");
800
+ }
801
+ const uploadId = substackResponseBoundIdentifier(
802
+ mediaUploadId,
803
+ "Substack media upload ID",
804
+ );
805
+ const multipartId = substackResponseBoundIdentifier(
806
+ multipartUploadId,
807
+ "Substack multipart upload ID",
808
+ );
809
+ const parts = planSubstackVideoMultipartParts(videoByteLength, uploadUrlCount);
810
+ const parsedEtags = parseSubstackVideoMultipartEtags(etags, parts.length);
811
+ return Object.freeze({
812
+ body: Object.freeze({
813
+ duration: durationSeconds,
814
+ multipart_upload_id: multipartId,
815
+ multipart_upload_etags: parsedEtags,
816
+ }),
817
+ method: "POST" as const,
818
+ url: new URL(
819
+ `/api/v1/video/upload/${encodeURIComponent(uploadId)}/transcode`,
820
+ SUBSTACK_ORIGIN,
821
+ ).href,
822
+ });
823
+ }
824
+
825
+ /** Bind transcode duration and byte coverage to the same reparsed local MP4. */
826
+ export function substackVideoTranscodeRequestForBinding(
827
+ mediaUploadId: unknown,
828
+ multipartUploadId: unknown,
829
+ videoValue: unknown,
830
+ uploadUrlCount: number,
831
+ etags: unknown,
832
+ ): ReturnType<typeof substackVideoTranscodeRequest> {
833
+ const video = parseSubstackVideoBinding(videoValue);
834
+ return substackVideoTranscodeRequest(
835
+ mediaUploadId,
836
+ multipartUploadId,
837
+ video.durationSeconds,
838
+ video.bytes.byteLength,
839
+ uploadUrlCount,
840
+ etags,
841
+ );
842
+ }
843
+
844
+ export function substackVideoStatusRequest(
845
+ mediaUploadId: unknown,
846
+ ): Readonly<{ method: "GET"; url: string }> {
847
+ const uploadId = substackResponseBoundIdentifier(
848
+ mediaUploadId,
849
+ "Substack media upload ID",
850
+ );
851
+ return Object.freeze({
852
+ method: "GET" as const,
853
+ url: new URL(
854
+ `/api/v1/video/upload/${encodeURIComponent(uploadId)}`,
855
+ SUBSTACK_ORIGIN,
856
+ ).href,
857
+ });
858
+ }
859
+
860
+ export type SubstackVideoUploadRecoveryTarget = Readonly<{
861
+ readonly mediaUploadId: string;
862
+ readonly schemaVersion: 1;
863
+ }>;
864
+
865
+ /** Serialize only the response-bound upload identity needed for a status GET. */
866
+ export function substackVideoUploadRecoveryTargetIdentifier(
867
+ mediaUploadId: unknown,
868
+ ): string {
869
+ return canonicalJson({
870
+ mediaUploadId: substackResponseBoundIdentifier(
871
+ mediaUploadId,
872
+ "Substack video recovery media upload ID",
873
+ ),
874
+ schemaVersion: 1,
875
+ });
876
+ }
877
+
878
+ /** Parse a private canonical upload checkpoint without authorizing any retry. */
879
+ export function parseSubstackVideoUploadRecoveryTargetIdentifier(
880
+ identifier: unknown,
881
+ ): SubstackVideoUploadRecoveryTarget {
882
+ if (
883
+ typeof identifier !== "string"
884
+ || identifier.length < 1
885
+ || identifier.length > MAX_SUBSTACK_RECOVERY_IDENTIFIER_BYTES
886
+ || /[\0\r\n]/u.test(identifier)
887
+ ) throw new Error("Substack video recovery target must be bounded canonical JSON");
888
+ let value: unknown;
889
+ try {
890
+ value = JSON.parse(identifier) as unknown;
891
+ } catch {
892
+ throw new Error("Substack video recovery target must be bounded canonical JSON");
893
+ }
894
+ if (!isRecord(value)) {
895
+ throw new Error("Substack video recovery target changed shape");
896
+ }
897
+ requireExactKeys(
898
+ value,
899
+ ["mediaUploadId", "schemaVersion"],
900
+ "Substack video recovery target",
901
+ );
902
+ if (value.schemaVersion !== 1) {
903
+ throw new Error("Substack video recovery target changed schema version");
904
+ }
905
+ const target = Object.freeze({
906
+ mediaUploadId: substackResponseBoundIdentifier(
907
+ value.mediaUploadId,
908
+ "Substack video recovery media upload ID",
909
+ ),
910
+ schemaVersion: 1 as const,
911
+ });
912
+ if (canonicalJson(target) !== identifier) {
913
+ throw new Error("Substack video recovery target must use canonical JSON");
914
+ }
915
+ return target;
916
+ }
917
+
918
+ /** Build the bundle-derived read-only status candidate from a private checkpoint. */
919
+ export function substackVideoUploadRecoveryStatusRequest(
920
+ identifier: unknown,
921
+ ): Readonly<{ method: "GET"; url: string }> {
922
+ const target = parseSubstackVideoUploadRecoveryTargetIdentifier(identifier);
923
+ return substackVideoStatusRequest(target.mediaUploadId);
924
+ }
925
+
926
+ async function materializeSubstackImage(
927
+ media: FileInputValue,
928
+ fileResolver: BrowserFileResolver | undefined,
929
+ operationDeadline: WebSessionOperationDeadline | undefined,
930
+ ): Promise<SubstackImage> {
931
+ if (fileResolver === undefined) {
932
+ throw new Error("Substack image upload requires the plan-bound file resolver");
933
+ }
934
+ const paths = operationDeadline === undefined
935
+ ? await fileResolver([media])
936
+ : await operationDeadline.run(
937
+ () => fileResolver([media]),
938
+ "authenticated web operation deadline",
939
+ );
940
+ operationDeadline?.throwIfUnavailable("authenticated web operation deadline");
941
+ if (paths.length !== 1 || typeof paths[0] !== "string") {
942
+ throw new Error("Substack file resolver did not return one exact path");
943
+ }
944
+ const noFollow = "O_NOFOLLOW" in constants ? constants.O_NOFOLLOW : 0;
945
+ const handle = operationDeadline === undefined
946
+ ? await open(paths[0], constants.O_RDONLY | noFollow)
947
+ : await operationDeadline.run(
948
+ () => open(paths[0]!, constants.O_RDONLY | noFollow),
949
+ "authenticated web operation deadline",
950
+ );
951
+ try {
952
+ const before = operationDeadline === undefined
953
+ ? await handle.stat()
954
+ : await operationDeadline.run(
955
+ () => handle.stat(),
956
+ "authenticated web operation deadline",
957
+ );
958
+ if (!before.isFile() || before.size < 24 || before.size > MAX_SUBSTACK_IMAGE_BYTES) {
959
+ throw new Error("Substack image must be a regular PNG no larger than 20 MiB");
960
+ }
961
+ const bytes = operationDeadline === undefined
962
+ ? await handle.readFile()
963
+ : await operationDeadline.run(
964
+ () => handle.readFile(),
965
+ "authenticated web operation deadline",
966
+ );
967
+ const after = operationDeadline === undefined
968
+ ? await handle.stat()
969
+ : await operationDeadline.run(
970
+ () => handle.stat(),
971
+ "authenticated web operation deadline",
972
+ );
973
+ if (
974
+ before.dev !== after.dev
975
+ || before.ino !== after.ino
976
+ || before.size !== after.size
977
+ || before.mtimeMs !== after.mtimeMs
978
+ || before.ctimeMs !== after.ctimeMs
979
+ || bytes.byteLength !== before.size
980
+ ) throw new Error("Substack image changed while it was materialized");
981
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
982
+ if (
983
+ signature.some((value, index) => bytes[index] !== value)
984
+ || bytes.subarray(12, 16).toString("ascii") !== "IHDR"
985
+ ) throw new Error("Substack image must be a PNG fixture");
986
+ const width = bytes.readUInt32BE(16);
987
+ const height = bytes.readUInt32BE(20);
988
+ if (width < 1 || height < 1 || width > 20_000 || height > 20_000) {
989
+ throw new Error("Substack PNG dimensions are outside the reviewed bound");
990
+ }
991
+ return Object.freeze({
992
+ bytes: new Uint8Array(bytes),
993
+ height,
994
+ mediaType: "image/png",
995
+ width,
996
+ });
997
+ } finally {
998
+ await handle.close();
999
+ }
1000
+ }
1001
+
1002
+ function jsonHeaders(): Readonly<Record<string, string>> {
1003
+ return Object.freeze({
1004
+ accept: "application/json",
1005
+ referer: `${SUBSTACK_ORIGIN}/`,
1006
+ });
1007
+ }
1008
+
1009
+ function jsonPostHeaders(): Readonly<Record<string, string>> {
1010
+ return Object.freeze({
1011
+ accept: "application/json",
1012
+ "content-type": "application/json",
1013
+ referer: `${SUBSTACK_ORIGIN}/`,
1014
+ });
1015
+ }
1016
+
1017
+ function htmlHeaders(): Readonly<Record<string, string>> {
1018
+ return Object.freeze({
1019
+ accept: "text/html",
1020
+ referer: `${SUBSTACK_ORIGIN}/`,
1021
+ });
1022
+ }
1023
+
1024
+ async function currentViewer(
1025
+ client: WebSessionClient,
1026
+ maximumBytes = MAX_BOOTSTRAP_BYTES,
1027
+ ): Promise<SubstackWebViewer> {
1028
+ const loggedInUrl = new URL("/api/v1/am_i_logged_in", SUBSTACK_ORIGIN);
1029
+ authorizeSubstackWebReadRequest({
1030
+ operation: "viewer.logged-in",
1031
+ url: loggedInUrl,
1032
+ method: "GET",
1033
+ });
1034
+ parseSubstackLoggedInResponse(await client.requestJson({
1035
+ url: loggedInUrl,
1036
+ method: "GET",
1037
+ headers: jsonHeaders(),
1038
+ expectedStatuses: [200],
1039
+ expectedContentTypes: ["application/json"],
1040
+ maxBytes: Math.min(maximumBytes, MAX_LOGIN_BYTES),
1041
+ }));
1042
+
1043
+ const rootUrl = new URL("/", SUBSTACK_ORIGIN);
1044
+ authorizeSubstackWebReadRequest({
1045
+ operation: "viewer.root",
1046
+ url: rootUrl,
1047
+ method: "GET",
1048
+ });
1049
+ const html = await client.requestText({
1050
+ url: rootUrl,
1051
+ headers: htmlHeaders(),
1052
+ expectedContentTypes: ["text/html"],
1053
+ maxBytes: Math.min(maximumBytes, MAX_BOOTSTRAP_BYTES),
1054
+ });
1055
+ return parseSubstackPreloadsHtml(html);
1056
+ }
1057
+
1058
+ function viewerSubject(viewer: SubstackWebViewer): string {
1059
+ return `substack:${viewer.id}`;
1060
+ }
1061
+
1062
+ export async function probeSubstackWebSubject(
1063
+ auth: GhostgetAuth,
1064
+ options: {
1065
+ readonly timeoutMs?: number;
1066
+ readonly dependencies?: SubstackWebRuntimeDependencies;
1067
+ readonly signal?: AbortSignal;
1068
+ } = {},
1069
+ ): Promise<string> {
1070
+ const client = await createWebSessionClient(SUBSTACK_ORIGIN, auth, {
1071
+ timeoutMs: options.timeoutMs ?? 60_000,
1072
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1073
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
1074
+ });
1075
+ return viewerSubject(await currentViewer(client));
1076
+ }
1077
+
1078
+ async function requireBoundViewer(
1079
+ client: WebSessionClient,
1080
+ auth: GhostgetAuth,
1081
+ maximumBytes: number,
1082
+ ): Promise<SubstackWebViewer> {
1083
+ const expected = webSessionAuthSubject(auth);
1084
+ if (expected === null || !/^substack:[0-9]{1,32}$/u.test(expected)) {
1085
+ throw new Error(
1086
+ "Substack authenticated operations require an auth locator bound to an exact substack:<user-id> subject",
1087
+ );
1088
+ }
1089
+ const viewer = await currentViewer(client, maximumBytes);
1090
+ if (viewerSubject(viewer) !== expected) {
1091
+ throw new Error("Substack browser session viewer no longer matches the confirmed auth subject");
1092
+ }
1093
+ return viewer;
1094
+ }
1095
+
1096
+ function boundedMaximum(recipe: WebSessionRecipe): number {
1097
+ return Math.min(recipe.maxOutputBytes, MAX_READ_BYTES);
1098
+ }
1099
+
1100
+ function feedName(input: OperationInput): SubstackFeedName {
1101
+ const value = input.feed;
1102
+ if (value !== "notes" && value !== "inbox" && value !== "reader-posts") {
1103
+ throw new Error("input.feed must name notes, inbox, or reader-posts");
1104
+ }
1105
+ return value;
1106
+ }
1107
+
1108
+ async function readFeed(
1109
+ client: WebSessionClient,
1110
+ recipe: WebSessionRecipe,
1111
+ input: OperationInput,
1112
+ ): Promise<unknown> {
1113
+ const feed = feedName(input);
1114
+ const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
1115
+ const request = {
1116
+ notes: {
1117
+ operation: "feeds.reader" as const,
1118
+ path: "/api/v1/reader/feed",
1119
+ },
1120
+ inbox: {
1121
+ operation: "feeds.inbox" as const,
1122
+ path: "/api/v1/inbox/top",
1123
+ },
1124
+ "reader-posts": {
1125
+ operation: "feeds.posts" as const,
1126
+ path: "/api/v1/reader/posts",
1127
+ },
1128
+ }[feed];
1129
+ const url = new URL(request.path, SUBSTACK_ORIGIN);
1130
+ authorizeSubstackWebReadRequest({
1131
+ operation: request.operation,
1132
+ url,
1133
+ method: "GET",
1134
+ });
1135
+ const response = await client.requestJson({
1136
+ url,
1137
+ method: "GET",
1138
+ headers: jsonHeaders(),
1139
+ maxBytes: boundedMaximum(recipe),
1140
+ });
1141
+ return normalizeSubstackFeedResponse(response, feed, limit);
1142
+ }
1143
+
1144
+ async function readProfile(
1145
+ client: WebSessionClient,
1146
+ recipe: WebSessionRecipe,
1147
+ viewer: SubstackWebViewer,
1148
+ input: OperationInput,
1149
+ now: () => number,
1150
+ ): Promise<unknown> {
1151
+ requireExactInputKeys(input, ["profile"]);
1152
+ const profile = substackProfileInput(input);
1153
+ if (viewer.handle !== profile) {
1154
+ throw new Error("Substack requested profile does not match the signed-in viewer");
1155
+ }
1156
+ const url = new URL(`/api/v1/user/${profile}/public_profile`, SUBSTACK_ORIGIN);
1157
+ authorizeSubstackWebReadRequest({
1158
+ operation: "profiles.read",
1159
+ url,
1160
+ method: "GET",
1161
+ profile,
1162
+ });
1163
+ const response = await client.requestJson({
1164
+ url,
1165
+ method: "GET",
1166
+ headers: jsonHeaders(),
1167
+ expectedStatuses: [200],
1168
+ expectedContentTypes: ["application/json"],
1169
+ maxBytes: boundedMaximum(recipe),
1170
+ });
1171
+ return normalizeSubstackProfileStatsResponse(
1172
+ response,
1173
+ viewer.id,
1174
+ profile,
1175
+ new Date(now()).toISOString(),
1176
+ );
1177
+ }
1178
+
1179
+ async function readOrganization(
1180
+ recipe: WebSessionRecipe,
1181
+ input: OperationInput,
1182
+ auth: GhostgetAuth,
1183
+ viewer: SubstackWebViewer,
1184
+ options: {
1185
+ readonly signal?: AbortSignal;
1186
+ readonly operationDeadline?: WebSessionOperationDeadline;
1187
+ readonly dependencies?: SubstackWebRuntimeDependencies;
1188
+ },
1189
+ ): Promise<unknown> {
1190
+ requireExactInputKeys(input, ["organization"]);
1191
+ const organization = substackOrganizationInput(input);
1192
+ const origin = `https://${organization}.substack.com`;
1193
+ const owned = viewer.publications.filter((publication) =>
1194
+ publication.origin === origin);
1195
+ if (owned.length !== 1) {
1196
+ throw new Error("Substack requested organization is not one exact signed-in viewer-owned publication");
1197
+ }
1198
+ const publication = owned[0]!;
1199
+ const client = await createWebSessionClient(origin, auth, {
1200
+ timeoutMs: recipe.timeoutMs,
1201
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1202
+ ...(options.operationDeadline === undefined
1203
+ ? {}
1204
+ : { operationDeadline: options.operationDeadline }),
1205
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
1206
+ });
1207
+ const url = new URL("/api/v1/publish-dashboard/summary", origin);
1208
+ authorizeSubstackWebReadRequest({
1209
+ operation: "organizations.read",
1210
+ url,
1211
+ method: "GET",
1212
+ organization,
1213
+ publicationOrigin: publication.origin,
1214
+ });
1215
+ const response = await client.requestJson({
1216
+ url,
1217
+ method: "GET",
1218
+ headers: Object.freeze({
1219
+ accept: "application/json",
1220
+ referer: `${origin}/publish/home`,
1221
+ }),
1222
+ expectedStatuses: [200],
1223
+ expectedContentTypes: ["application/json"],
1224
+ maxBytes: boundedMaximum(recipe),
1225
+ });
1226
+ return normalizeSubstackPublicationStatsResponse(
1227
+ response,
1228
+ {
1229
+ id: publication.id,
1230
+ organization,
1231
+ origin: publication.origin,
1232
+ },
1233
+ new Date((options.dependencies?.now ?? Date.now)()).toISOString(),
1234
+ );
1235
+ }
1236
+
1237
+ async function readNote(
1238
+ client: WebSessionClient,
1239
+ recipe: WebSessionRecipe,
1240
+ input: OperationInput,
1241
+ ): Promise<unknown> {
1242
+ const noteId = positiveIdInput(input, "note_id");
1243
+ const url = new URL(`/api/v1/reader/comment/${noteId}`, SUBSTACK_ORIGIN);
1244
+ authorizeSubstackWebReadRequest({
1245
+ operation: "posts.note",
1246
+ url,
1247
+ method: "GET",
1248
+ targetId: noteId,
1249
+ });
1250
+ const response = await client.requestJson({
1251
+ url,
1252
+ method: "GET",
1253
+ headers: jsonHeaders(),
1254
+ maxBytes: boundedMaximum(recipe),
1255
+ });
1256
+ return normalizeSubstackNoteResponse(response, noteId);
1257
+ }
1258
+
1259
+ async function articleResponse(
1260
+ client: WebSessionClient,
1261
+ recipe: WebSessionRecipe,
1262
+ articleId: number,
1263
+ operation: "articles.read" | "media.read",
1264
+ ): Promise<unknown> {
1265
+ const url = new URL(`/api/v1/posts/by-id/${articleId}`, SUBSTACK_ORIGIN);
1266
+ authorizeSubstackWebReadRequest({
1267
+ operation,
1268
+ url,
1269
+ method: "GET",
1270
+ targetId: articleId,
1271
+ });
1272
+ return client.requestJson({
1273
+ url,
1274
+ method: "GET",
1275
+ headers: jsonHeaders(),
1276
+ maxBytes: boundedMaximum(recipe),
1277
+ });
1278
+ }
1279
+
1280
+ async function readArticle(
1281
+ client: WebSessionClient,
1282
+ recipe: WebSessionRecipe,
1283
+ input: OperationInput,
1284
+ media: boolean,
1285
+ ): Promise<unknown> {
1286
+ const articleId = positiveIdInput(input, "article_id");
1287
+ const response = await articleResponse(
1288
+ client,
1289
+ recipe,
1290
+ articleId,
1291
+ media ? "media.read" : "articles.read",
1292
+ );
1293
+ return media
1294
+ ? normalizeSubstackMediaResponse(response, articleId)
1295
+ : normalizeSubstackArticleResponse(response, articleId);
1296
+ }
1297
+
1298
+ async function readComments(
1299
+ client: WebSessionClient,
1300
+ recipe: WebSessionRecipe,
1301
+ input: OperationInput,
1302
+ ): Promise<unknown> {
1303
+ const articleId = positiveIdInput(input, "article_id");
1304
+ const publicationId = positiveIdInput(input, "publication_id");
1305
+ const limit = integerInput(input, "limit", 50, 1, 100);
1306
+
1307
+ // Bind the caller-supplied publication before requesting its reply tree.
1308
+ const article = normalizeSubstackArticleResponse(
1309
+ await articleResponse(client, recipe, articleId, "articles.read"),
1310
+ articleId,
1311
+ ) as {
1312
+ readonly post: { readonly publicationId: number };
1313
+ };
1314
+ if (article.post.publicationId !== publicationId) {
1315
+ throw new Error("input.publication_id did not match the requested Substack article");
1316
+ }
1317
+
1318
+ const url = new URL(`/api/v1/reader/post/${articleId}/replies`, SUBSTACK_ORIGIN);
1319
+ url.searchParams.set("publication_id", String(publicationId));
1320
+ const cursor = optionalStringInput(input, "cursor", 4_096);
1321
+ if (cursor !== undefined) url.searchParams.set("cursor", cursor);
1322
+ authorizeSubstackWebReadRequest({
1323
+ operation: "comments.read",
1324
+ url,
1325
+ method: "GET",
1326
+ targetId: articleId,
1327
+ publicationId,
1328
+ });
1329
+ const response = await client.requestJson({
1330
+ url,
1331
+ method: "GET",
1332
+ headers: jsonHeaders(),
1333
+ maxBytes: boundedMaximum(recipe),
1334
+ });
1335
+ return normalizeSubstackCommentsResponse(response, articleId, limit);
1336
+ }
1337
+
1338
+ type MessageFolder = "all" | "people" | "unread";
1339
+
1340
+ function messageFolder(input: OperationInput): MessageFolder {
1341
+ const value = input.folder;
1342
+ if (value !== "all" && value !== "people" && value !== "unread") {
1343
+ throw new Error("input.folder must name all, people, or unread");
1344
+ }
1345
+ return value;
1346
+ }
1347
+
1348
+ async function listMessages(
1349
+ client: WebSessionClient,
1350
+ recipe: WebSessionRecipe,
1351
+ input: OperationInput,
1352
+ ): Promise<unknown> {
1353
+ const folder = messageFolder(input);
1354
+ const limit = integerInput(input, "limit", DEFAULT_LIMIT, 1, 100);
1355
+ const url = new URL("/api/v1/messages/inbox", SUBSTACK_ORIGIN);
1356
+ url.searchParams.set("tab", folder);
1357
+ const cursor = optionalStringInput(input, "cursor", 4_096);
1358
+ if (cursor !== undefined) url.searchParams.set("cursor", cursor);
1359
+ authorizeSubstackWebReadRequest({
1360
+ operation: "messages.list",
1361
+ url,
1362
+ method: "GET",
1363
+ folder,
1364
+ });
1365
+ const response = await client.requestJson({
1366
+ url,
1367
+ method: "GET",
1368
+ headers: jsonHeaders(),
1369
+ maxBytes: boundedMaximum(recipe),
1370
+ });
1371
+ return normalizeSubstackMessageInbox(response, folder, limit);
1372
+ }
1373
+
1374
+ type UploadedSubstackImage = {
1375
+ readonly id: number;
1376
+ readonly url: string;
1377
+ };
1378
+
1379
+ type SubstackImageAttachment = {
1380
+ readonly id: string;
1381
+ readonly url: string;
1382
+ };
1383
+
1384
+ function positiveInteger(value: unknown, label: string): number {
1385
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
1386
+ throw new Error(`${label} must be a positive safe integer`);
1387
+ }
1388
+ return value as number;
1389
+ }
1390
+
1391
+ function exactSubstackImageUrl(
1392
+ value: unknown,
1393
+ width: number,
1394
+ height: number,
1395
+ label: string,
1396
+ ): string {
1397
+ if (typeof value !== "string" || value.length > 8_192) {
1398
+ throw new Error(`${label} must be a bounded URL`);
1399
+ }
1400
+ let parsed: URL;
1401
+ try {
1402
+ parsed = new URL(value);
1403
+ } catch {
1404
+ throw new Error(`${label} must be an absolute URL`);
1405
+ }
1406
+ const uuid = "[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
1407
+ const path = new RegExp(`^/public/images/${uuid}_${width}x${height}\\.png$`, "iu");
1408
+ if (
1409
+ parsed.protocol !== "https:"
1410
+ || parsed.hostname !== "substack-post-media.s3.amazonaws.com"
1411
+ || parsed.port !== ""
1412
+ || parsed.username !== ""
1413
+ || parsed.password !== ""
1414
+ || parsed.search !== ""
1415
+ || parsed.hash !== ""
1416
+ || !path.test(parsed.pathname)
1417
+ ) throw new Error(`${label} did not match the reviewed Substack image asset URL`);
1418
+ return parsed.href;
1419
+ }
1420
+
1421
+ function parseUploadedSubstackImage(
1422
+ value: unknown,
1423
+ image: SubstackImage,
1424
+ ): UploadedSubstackImage {
1425
+ if (!isRecord(value)) throw new Error("Substack image upload response must be an object");
1426
+ requireExactKeys(value, [
1427
+ "bytes",
1428
+ "contentType",
1429
+ "id",
1430
+ "imageHeight",
1431
+ "imageWidth",
1432
+ "url",
1433
+ ], "Substack image upload response");
1434
+ if (
1435
+ value.bytes !== image.bytes.byteLength
1436
+ || value.contentType !== image.mediaType
1437
+ || value.imageHeight !== image.height
1438
+ || value.imageWidth !== image.width
1439
+ ) throw new Error("Substack image upload response did not bind the reviewed PNG");
1440
+ return Object.freeze({
1441
+ id: positiveInteger(value.id, "Substack image upload response.id"),
1442
+ url: exactSubstackImageUrl(
1443
+ value.url,
1444
+ image.width,
1445
+ image.height,
1446
+ "Substack image upload response.url",
1447
+ ),
1448
+ });
1449
+ }
1450
+
1451
+ function attachmentUuid(value: unknown, label: string): string {
1452
+ if (
1453
+ typeof value !== "string"
1454
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(value)
1455
+ ) throw new Error(`${label} must be an exact UUID`);
1456
+ return value;
1457
+ }
1458
+
1459
+ function parseSubstackImageAttachment(
1460
+ value: unknown,
1461
+ image: SubstackImage,
1462
+ expectedUrl: string,
1463
+ ): SubstackImageAttachment {
1464
+ if (!isRecord(value)) throw new Error("Substack image attachment response must be an object");
1465
+ requireExactKeys(value, [
1466
+ "explicit",
1467
+ "id",
1468
+ "imageHeight",
1469
+ "imageUrl",
1470
+ "imageWidth",
1471
+ "type",
1472
+ ], "Substack image attachment response");
1473
+ if (
1474
+ value.explicit !== false
1475
+ || value.type !== "image"
1476
+ || value.imageHeight !== image.height
1477
+ || value.imageWidth !== image.width
1478
+ || value.imageUrl !== expectedUrl
1479
+ ) throw new Error("Substack image attachment response did not bind the reviewed PNG");
1480
+ return Object.freeze({
1481
+ id: attachmentUuid(value.id, "Substack image attachment response.id"),
1482
+ url: expectedUrl,
1483
+ });
1484
+ }
1485
+
1486
+ async function uploadSubstackImage(
1487
+ client: WebSessionClient,
1488
+ image: SubstackImage,
1489
+ ): Promise<SubstackImageAttachment> {
1490
+ const encoded = `data:${image.mediaType};base64,${Buffer.from(image.bytes).toString("base64")}`;
1491
+ const uploaded = parseUploadedSubstackImage(await client.requestJson({
1492
+ url: new URL("/api/v1/image", SUBSTACK_ORIGIN),
1493
+ method: "POST",
1494
+ headers: jsonPostHeaders(),
1495
+ body: JSON.stringify({ image: encoded }),
1496
+ expectedStatuses: [200],
1497
+ expectedContentTypes: ["application/json"],
1498
+ maxBytes: 256 * 1024,
1499
+ }), image);
1500
+ void uploaded.id;
1501
+ return parseSubstackImageAttachment(await client.requestJson({
1502
+ url: new URL("/api/v1/comment/attachment", SUBSTACK_ORIGIN),
1503
+ method: "POST",
1504
+ headers: jsonPostHeaders(),
1505
+ body: JSON.stringify({ type: "image", url: uploaded.url }),
1506
+ expectedStatuses: [200],
1507
+ expectedContentTypes: ["application/json"],
1508
+ maxBytes: 256 * 1024,
1509
+ }), image, uploaded.url);
1510
+ }
1511
+
1512
+ type SubstackBodyJson = Readonly<{
1513
+ type: "doc";
1514
+ attrs: Readonly<{ schemaVersion: "v1"; title: null }>;
1515
+ content: readonly Readonly<{
1516
+ type: "paragraph";
1517
+ content?: readonly Readonly<{ type: "text"; text: string }>[];
1518
+ }>[];
1519
+ }>;
1520
+
1521
+ function substackBodyJson(body: string): SubstackBodyJson {
1522
+ const content = body.split("\n").map((line) => Object.freeze({
1523
+ type: "paragraph" as const,
1524
+ ...(line.length === 0
1525
+ ? {}
1526
+ : { content: Object.freeze([Object.freeze({ type: "text" as const, text: line })]) }),
1527
+ }));
1528
+ return Object.freeze({
1529
+ type: "doc",
1530
+ attrs: Object.freeze({ schemaVersion: "v1", title: null }),
1531
+ content: Object.freeze(content),
1532
+ });
1533
+ }
1534
+
1535
+ function noteBodyInput(input: OperationInput): string {
1536
+ return substackNoteText(input.body, "input.body");
1537
+ }
1538
+
1539
+ const CREATED_NOTE_KEYS = Object.freeze([
1540
+ "ancestor_path",
1541
+ "attachments",
1542
+ "autotranslate_to",
1543
+ "body",
1544
+ "body_json",
1545
+ "children",
1546
+ "children_count",
1547
+ "date",
1548
+ "deleted",
1549
+ "edited_at",
1550
+ "handle",
1551
+ "id",
1552
+ "is_ai_generated_text",
1553
+ "language",
1554
+ "media_clip_id",
1555
+ "name",
1556
+ "photo_url",
1557
+ "post_id",
1558
+ "publication_id",
1559
+ "reaction_count",
1560
+ "reactions",
1561
+ "reply_minimum_role",
1562
+ "restacked",
1563
+ "restacks",
1564
+ "status",
1565
+ "type",
1566
+ "userStatus",
1567
+ "user_bestseller_tier",
1568
+ "user_id",
1569
+ "user_primary_publication",
1570
+ ]);
1571
+
1572
+ type SubstackNoteCreateRequestFailureStage =
1573
+ | "note-create-transport"
1574
+ | "note-create-http-status"
1575
+ | "note-create-content-type"
1576
+ | "note-create-json"
1577
+ | "note-create-response-bounds";
1578
+
1579
+ type SubstackNoteCreateBindingFailureStage =
1580
+ | "note-create-response-object"
1581
+ | "note-create-response-fields"
1582
+ | "note-create-actor"
1583
+ | "note-create-body"
1584
+ | "note-create-kind"
1585
+ | "note-create-deleted-state"
1586
+ | "note-create-parent-post"
1587
+ | "note-create-publication"
1588
+ | "note-create-reply-role"
1589
+ | "note-create-body-json"
1590
+ | "note-create-publication-status"
1591
+ | "note-create-attachments-shape"
1592
+ | "note-create-attachments-count"
1593
+ | "note-create-attachment-object"
1594
+ | "note-create-attachment-fields"
1595
+ | "note-create-attachment-id"
1596
+ | "note-create-attachment-url"
1597
+ | "note-create-attachment-kind"
1598
+ | "note-create-id";
1599
+
1600
+ class SubstackNoteCreateBindingError extends Error {
1601
+ constructor(
1602
+ readonly stage: SubstackNoteCreateBindingFailureStage,
1603
+ message: string,
1604
+ ) {
1605
+ super(message);
1606
+ this.name = "SubstackNoteCreateBindingError";
1607
+ }
1608
+ }
1609
+
1610
+ function noteCreateBindingFailure(
1611
+ stage: SubstackNoteCreateBindingFailureStage,
1612
+ message: string,
1613
+ ): never {
1614
+ throw new SubstackNoteCreateBindingError(stage, message);
1615
+ }
1616
+
1617
+ function substackNoteCreateRequestFailureStage(
1618
+ error: unknown,
1619
+ ): SubstackNoteCreateRequestFailureStage {
1620
+ const message = error instanceof Error ? error.message : "";
1621
+ const statusPrefix =
1622
+ "authenticated web API returned unreviewed status/content type ";
1623
+ if (message.startsWith(statusPrefix)) {
1624
+ const separator = message.indexOf("/", statusPrefix.length);
1625
+ const status = separator < 0
1626
+ ? ""
1627
+ : message.slice(statusPrefix.length, separator);
1628
+ return status === "200"
1629
+ ? "note-create-content-type"
1630
+ : "note-create-http-status";
1631
+ }
1632
+ if (
1633
+ message === "authenticated web API returned invalid UTF-8 JSON"
1634
+ || message === "authenticated web API returned malformed JSON"
1635
+ ) return "note-create-json";
1636
+ if (
1637
+ message === "authenticated web response exceeded its reviewed byte limit"
1638
+ || message === "authenticated web response yielded a non-byte chunk"
1639
+ ) return "note-create-response-bounds";
1640
+ return "note-create-transport";
1641
+ }
1642
+
1643
+ function parseCreatedSubstackNote(
1644
+ value: unknown,
1645
+ viewer: SubstackWebViewer,
1646
+ body: string,
1647
+ bodyJson: SubstackBodyJson,
1648
+ attachment: SubstackImageAttachment | null,
1649
+ ): number {
1650
+ if (!isRecord(value)) {
1651
+ noteCreateBindingFailure(
1652
+ "note-create-response-object",
1653
+ "Substack Note create response must be an object",
1654
+ );
1655
+ }
1656
+ try {
1657
+ requireAllowedKeys(value, CREATED_NOTE_KEYS, "Substack Note create response");
1658
+ } catch {
1659
+ noteCreateBindingFailure(
1660
+ "note-create-response-fields",
1661
+ "Substack Note create response fields changed",
1662
+ );
1663
+ }
1664
+ if (value.user_id !== viewer.id) {
1665
+ noteCreateBindingFailure(
1666
+ "note-create-actor",
1667
+ "Substack Note create response did not bind the confirmed actor",
1668
+ );
1669
+ }
1670
+ if (value.body !== body) {
1671
+ noteCreateBindingFailure(
1672
+ "note-create-body",
1673
+ "Substack Note create response did not bind the confirmed body",
1674
+ );
1675
+ }
1676
+ if (value.type !== "feed") {
1677
+ noteCreateBindingFailure(
1678
+ "note-create-kind",
1679
+ "Substack Note create response did not bind the confirmed Note kind",
1680
+ );
1681
+ }
1682
+ if (value.deleted !== undefined && value.deleted !== false) {
1683
+ noteCreateBindingFailure(
1684
+ "note-create-deleted-state",
1685
+ "Substack Note create response returned a deleted Note",
1686
+ );
1687
+ }
1688
+ if (value.post_id !== null) {
1689
+ noteCreateBindingFailure(
1690
+ "note-create-parent-post",
1691
+ "Substack Note create response did not bind the confirmed parent post",
1692
+ );
1693
+ }
1694
+ if (value.publication_id !== null) {
1695
+ noteCreateBindingFailure(
1696
+ "note-create-publication",
1697
+ "Substack Note create response did not bind the confirmed publication",
1698
+ );
1699
+ }
1700
+ if (value.reply_minimum_role !== "everyone") {
1701
+ noteCreateBindingFailure(
1702
+ "note-create-reply-role",
1703
+ "Substack Note create response did not bind the confirmed reply role",
1704
+ );
1705
+ }
1706
+ let bodyJsonMatches = false;
1707
+ try {
1708
+ bodyJsonMatches = canonicalJson(value.body_json) === canonicalJson(bodyJson);
1709
+ } catch {
1710
+ bodyJsonMatches = false;
1711
+ }
1712
+ if (!bodyJsonMatches) {
1713
+ noteCreateBindingFailure(
1714
+ "note-create-body-json",
1715
+ "Substack Note create response did not bind the confirmed body document",
1716
+ );
1717
+ }
1718
+ if (value.status !== undefined && value.status !== "published") {
1719
+ noteCreateBindingFailure(
1720
+ "note-create-publication-status",
1721
+ "Substack Note create response did not bind the confirmed publication status",
1722
+ );
1723
+ }
1724
+ const attachments = value.attachments;
1725
+ if (attachments !== undefined && !Array.isArray(attachments)) {
1726
+ noteCreateBindingFailure(
1727
+ "note-create-attachments-shape",
1728
+ "Substack Note create response attachments changed shape",
1729
+ );
1730
+ }
1731
+ // The create response may omit the attachment echo or return an empty echo.
1732
+ // The exact target readback below remains authoritative for image binding.
1733
+ if (Array.isArray(attachments) && attachments.length > 0) {
1734
+ if (attachment === null || attachments.length !== 1) {
1735
+ noteCreateBindingFailure(
1736
+ "note-create-attachments-count",
1737
+ "Substack Note create response did not bind the confirmed attachment count",
1738
+ );
1739
+ }
1740
+ const item = attachments[0];
1741
+ if (!isRecord(item)) {
1742
+ noteCreateBindingFailure(
1743
+ "note-create-attachment-object",
1744
+ "Substack Note create attachment must be an object",
1745
+ );
1746
+ }
1747
+ try {
1748
+ requireExactKeys(item, [
1749
+ "explicit",
1750
+ "id",
1751
+ "imageHeight",
1752
+ "imageUrl",
1753
+ "imageWidth",
1754
+ "type",
1755
+ ], "Substack Note create attachment");
1756
+ } catch {
1757
+ noteCreateBindingFailure(
1758
+ "note-create-attachment-fields",
1759
+ "Substack Note create attachment fields changed",
1760
+ );
1761
+ }
1762
+ if (item.id !== attachment.id) {
1763
+ noteCreateBindingFailure(
1764
+ "note-create-attachment-id",
1765
+ "Substack Note create response attachment did not bind the uploaded image identifier",
1766
+ );
1767
+ }
1768
+ if (item.imageUrl !== attachment.url) {
1769
+ noteCreateBindingFailure(
1770
+ "note-create-attachment-url",
1771
+ "Substack Note create response attachment did not bind the uploaded image URL",
1772
+ );
1773
+ }
1774
+ if (item.type !== "image") {
1775
+ noteCreateBindingFailure(
1776
+ "note-create-attachment-kind",
1777
+ "Substack Note create response attachment did not bind the uploaded image kind",
1778
+ );
1779
+ }
1780
+ }
1781
+ try {
1782
+ return positiveInteger(value.id, "Substack Note create response.id");
1783
+ } catch {
1784
+ noteCreateBindingFailure(
1785
+ "note-create-id",
1786
+ "Substack Note create response did not return a valid Note identifier",
1787
+ );
1788
+ }
1789
+ }
1790
+
1791
+ type ProjectedSubstackNote = Readonly<{
1792
+ entityKey: string | null;
1793
+ comment: Readonly<{
1794
+ id: number;
1795
+ userId: number;
1796
+ publicationId: number | null;
1797
+ postId: number | null;
1798
+ body: string;
1799
+ type: string | null;
1800
+ attachments: readonly Readonly<{
1801
+ id: string | null;
1802
+ type: string | null;
1803
+ imageUrl: string | null;
1804
+ width: number | null;
1805
+ height: number | null;
1806
+ }>[];
1807
+ }>;
1808
+ post: unknown | null;
1809
+ }>;
1810
+
1811
+ export type SubstackNoteDeletionRecoveryTarget = Readonly<{
1812
+ readonly noteId: number;
1813
+ readonly publicationId: null;
1814
+ readonly schemaVersion: 1;
1815
+ }>;
1816
+
1817
+ /** Bind deletion to one exact current-account personal Note before dispatch. */
1818
+ export function assertSubstackNoteDeletionPreRead(
1819
+ value: unknown,
1820
+ noteId: number,
1821
+ viewerId: number,
1822
+ expectedBody: string,
1823
+ ): SubstackNoteDeletionRecoveryTarget {
1824
+ if (!Number.isSafeInteger(noteId) || noteId < 1) {
1825
+ throw new Error("Substack deletion Note ID must be positive");
1826
+ }
1827
+ if (!Number.isSafeInteger(viewerId) || viewerId < 1) {
1828
+ throw new Error("Substack deletion viewer ID must be positive");
1829
+ }
1830
+ substackNoteText(expectedBody, "Substack deletion expected body");
1831
+ const note = normalizeSubstackNoteResponse(value, noteId) as ProjectedSubstackNote;
1832
+ if (
1833
+ note.entityKey !== `c-${noteId}`
1834
+ || note.comment.id !== noteId
1835
+ || note.comment.userId !== viewerId
1836
+ || note.comment.publicationId !== null
1837
+ || note.comment.postId !== null
1838
+ || note.comment.body !== expectedBody
1839
+ || note.comment.type !== "feed"
1840
+ || note.post !== null
1841
+ ) throw new Error("Substack deletion pre-read did not bind the exact authored Note");
1842
+ return Object.freeze({ noteId, publicationId: null, schemaVersion: 1 as const });
1843
+ }
1844
+
1845
+ /** Serialize the pre-read authored target without retaining its private body. */
1846
+ export function substackNoteDeletionRecoveryTargetIdentifier(
1847
+ targetValue: unknown,
1848
+ ): string {
1849
+ if (!isRecord(targetValue)) {
1850
+ throw new Error("Substack deletion recovery target changed shape");
1851
+ }
1852
+ requireExactKeys(
1853
+ targetValue,
1854
+ ["noteId", "publicationId", "schemaVersion"],
1855
+ "Substack deletion recovery target",
1856
+ );
1857
+ if (targetValue.publicationId !== null || targetValue.schemaVersion !== 1) {
1858
+ throw new Error("Substack deletion recovery target changed shape");
1859
+ }
1860
+ return canonicalJson({
1861
+ noteId: positiveInteger(
1862
+ targetValue.noteId,
1863
+ "Substack deletion recovery target.noteId",
1864
+ ),
1865
+ publicationId: null,
1866
+ schemaVersion: 1,
1867
+ });
1868
+ }
1869
+
1870
+ /** Parse only one canonical personal-Note target for read-only reconciliation. */
1871
+ export function parseSubstackNoteDeletionRecoveryTargetIdentifier(
1872
+ identifier: unknown,
1873
+ ): SubstackNoteDeletionRecoveryTarget {
1874
+ if (
1875
+ typeof identifier !== "string"
1876
+ || identifier.length < 1
1877
+ || identifier.length > MAX_SUBSTACK_RECOVERY_IDENTIFIER_BYTES
1878
+ || /[\0\r\n]/u.test(identifier)
1879
+ ) throw new Error("Substack deletion recovery target must be bounded canonical JSON");
1880
+ let value: unknown;
1881
+ try {
1882
+ value = JSON.parse(identifier) as unknown;
1883
+ } catch {
1884
+ throw new Error("Substack deletion recovery target must be bounded canonical JSON");
1885
+ }
1886
+ if (!isRecord(value)) {
1887
+ throw new Error("Substack deletion recovery target changed shape");
1888
+ }
1889
+ const canonical = substackNoteDeletionRecoveryTargetIdentifier(value);
1890
+ if (canonical !== identifier) {
1891
+ throw new Error("Substack deletion recovery target must use canonical JSON");
1892
+ }
1893
+ return Object.freeze({
1894
+ noteId: positiveInteger(value.noteId, "Substack deletion recovery target.noteId"),
1895
+ publicationId: null,
1896
+ schemaVersion: 1 as const,
1897
+ });
1898
+ }
1899
+
1900
+ /** Build the exact independent Note GET used for pre-read and absence checks. */
1901
+ export function substackNoteDeletionRecoveryReadRequest(
1902
+ identifier: unknown,
1903
+ ): Readonly<{ method: "GET"; url: string }> {
1904
+ const target = parseSubstackNoteDeletionRecoveryTargetIdentifier(identifier);
1905
+ return Object.freeze({
1906
+ method: "GET" as const,
1907
+ url: new URL(`/api/v1/reader/comment/${target.noteId}`, SUBSTACK_ORIGIN).href,
1908
+ });
1909
+ }
1910
+
1911
+ export function substackPersonalNoteDeleteRequest(
1912
+ noteId: unknown,
1913
+ ): Readonly<{ method: "DELETE"; url: string }> {
1914
+ const target = positiveInteger(noteId, "Substack deletion Note ID");
1915
+ return Object.freeze({
1916
+ method: "DELETE" as const,
1917
+ url: new URL(`/api/v1/comment/${target}`, SUBSTACK_ORIGIN).href,
1918
+ });
1919
+ }
1920
+
1921
+ async function dispatchSubstackPersonalNoteDelete(
1922
+ client: WebSessionClient,
1923
+ noteId: number,
1924
+ options: {
1925
+ readonly timeoutMs: number;
1926
+ readonly signal?: AbortSignal;
1927
+ readonly operationDeadline?: WebSessionOperationDeadline;
1928
+ readonly dependencies?: SubstackWebRuntimeDependencies;
1929
+ },
1930
+ ): Promise<void> {
1931
+ const request = substackPersonalNoteDeleteRequest(noteId);
1932
+ const url = new URL(request.url);
1933
+ if (
1934
+ url.origin !== SUBSTACK_ORIGIN
1935
+ || url.pathname !== `/api/v1/comment/${noteId}`
1936
+ || url.search !== ""
1937
+ || url.hash !== ""
1938
+ || url.username !== ""
1939
+ || url.password !== ""
1940
+ ) throw new Error("Substack personal Note deletion escaped its exact reviewed target");
1941
+
1942
+ const ownedDeadline = options.operationDeadline === undefined
1943
+ ? new OperationDeadline(options.timeoutMs, {
1944
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
1945
+ })
1946
+ : null;
1947
+ const deadline = options.operationDeadline ?? ownedDeadline;
1948
+ if (deadline === null) {
1949
+ throw new Error("Substack personal Note deletion deadline is unavailable");
1950
+ }
1951
+ const headers = new Headers(jsonPostHeaders());
1952
+ headers.set("cookie", renderCookieHeader(client.cookies));
1953
+ let response: Response | undefined;
1954
+ try {
1955
+ deadline.throwIfUnavailable(SUBSTACK_DELETE_REQUEST_LABEL);
1956
+ const timeoutMs = deadline.remainingTimeMs();
1957
+ if (options.dependencies?.fetch === undefined && timeoutMs < MIN_PINNED_HTTPS_TIMEOUT_MS) {
1958
+ throw new Error("Substack personal Note deletion has insufficient time for its request");
1959
+ }
1960
+ try {
1961
+ response = await deadline.run(
1962
+ (signal) => {
1963
+ const init: RequestInit = {
1964
+ method: request.method,
1965
+ headers,
1966
+ redirect: "error",
1967
+ signal,
1968
+ };
1969
+ return options.dependencies?.fetch === undefined
1970
+ ? pinnedHttpsFetch(url, init, timeoutMs)
1971
+ : options.dependencies.fetch(url, init);
1972
+ },
1973
+ SUBSTACK_DELETE_REQUEST_LABEL,
1974
+ );
1975
+ } catch (error) {
1976
+ throw new Error(
1977
+ "Substack personal Note deletion failed before a reviewed response was received",
1978
+ { cause: error },
1979
+ );
1980
+ }
1981
+ if (response.status !== 200 || response.headers.get("location") !== null) {
1982
+ void response.body?.cancel().catch(() => undefined);
1983
+ throw new Error(
1984
+ `Substack personal Note deletion returned unreviewed status/redirect ${response.status}`,
1985
+ );
1986
+ }
1987
+ void response.body?.cancel().catch(() => undefined);
1988
+ deadline.throwIfUnavailable(SUBSTACK_DELETE_REQUEST_LABEL);
1989
+ } finally {
1990
+ if (deadline.signal.aborted) {
1991
+ void response?.body?.cancel().catch(() => undefined);
1992
+ }
1993
+ ownedDeadline?.dispose();
1994
+ }
1995
+ }
1996
+
1997
+ type SubstackPersonalNoteDeletionPresence = Readonly<{
1998
+ present: boolean;
1999
+ target: SubstackNoteDeletionRecoveryTarget | null;
2000
+ }>;
2001
+
2002
+ async function readSubstackPersonalNoteDeletionPresence(
2003
+ client: WebSessionClient,
2004
+ recipe: WebSessionRecipe,
2005
+ noteId: number,
2006
+ viewerId: number,
2007
+ expectedBody: string,
2008
+ ): Promise<SubstackPersonalNoteDeletionPresence> {
2009
+ const request = substackNoteDeletionRecoveryReadRequest(
2010
+ substackNoteDeletionRecoveryTargetIdentifier({
2011
+ noteId,
2012
+ publicationId: null,
2013
+ schemaVersion: 1,
2014
+ }),
2015
+ );
2016
+ const url = new URL(request.url);
2017
+ authorizeSubstackWebReadRequest({
2018
+ operation: "posts.note",
2019
+ url,
2020
+ method: "GET",
2021
+ targetId: noteId,
2022
+ });
2023
+ const status = await client.requestStatus({
2024
+ url,
2025
+ method: "GET",
2026
+ headers: jsonHeaders(),
2027
+ expectedStatuses: [200, 404],
2028
+ });
2029
+ if (status.status === 404) {
2030
+ return Object.freeze({ present: false, target: null });
2031
+ }
2032
+ const value = await client.requestJson({
2033
+ url,
2034
+ method: "GET",
2035
+ headers: jsonHeaders(),
2036
+ expectedStatuses: [200],
2037
+ expectedContentTypes: ["application/json"],
2038
+ maxBytes: boundedMaximum(recipe),
2039
+ });
2040
+ return Object.freeze({
2041
+ present: true,
2042
+ target: assertSubstackNoteDeletionPreRead(
2043
+ value,
2044
+ noteId,
2045
+ viewerId,
2046
+ expectedBody,
2047
+ ),
2048
+ });
2049
+ }
2050
+
2051
+ type SubstackNoteImageExpectation = Readonly<{
2052
+ readonly height: number;
2053
+ readonly width: number;
2054
+ }>;
2055
+
2056
+ function assertSubstackNoteReadback(
2057
+ note: ProjectedSubstackNote,
2058
+ noteId: number,
2059
+ viewer: SubstackWebViewer,
2060
+ body: string,
2061
+ image: SubstackNoteImageExpectation | null,
2062
+ attachment: SubstackImageAttachment | null,
2063
+ ): void {
2064
+ if (
2065
+ note.entityKey !== `c-${noteId}`
2066
+ || note.comment.id !== noteId
2067
+ || note.comment.userId !== viewer.id
2068
+ || note.comment.publicationId !== null
2069
+ || note.comment.postId !== null
2070
+ || note.comment.body !== body
2071
+ || note.comment.type !== "feed"
2072
+ || note.post !== null
2073
+ ) throw new Error("Substack Note readback did not bind the confirmed Note");
2074
+ if (image === null || attachment === null) {
2075
+ if (note.comment.attachments.length !== 0) {
2076
+ throw new Error("Substack Note readback contained an unexpected attachment");
2077
+ }
2078
+ return;
2079
+ }
2080
+ if (
2081
+ note.comment.attachments.length !== 1
2082
+ || note.comment.attachments[0]?.id !== attachment.id
2083
+ || note.comment.attachments[0]?.type !== "image"
2084
+ || note.comment.attachments[0]?.imageUrl !== attachment.url
2085
+ || note.comment.attachments[0]?.width !== image.width
2086
+ || note.comment.attachments[0]?.height !== image.height
2087
+ ) throw new Error("Substack Note readback did not bind the confirmed image");
2088
+ }
2089
+
2090
+ function substackDispatchEvent(
2091
+ started: number,
2092
+ verified: number,
2093
+ ): WebSessionDispatchEvent {
2094
+ return {
2095
+ id: "posts.publish",
2096
+ index: 1,
2097
+ progress: { planned: 1, started, verified },
2098
+ };
2099
+ }
2100
+
2101
+ function substackDeleteDispatchEvent(
2102
+ started: number,
2103
+ verified: number,
2104
+ ): WebSessionDispatchEvent {
2105
+ return {
2106
+ id: "content.delete",
2107
+ index: 1,
2108
+ progress: { planned: 1, started, verified },
2109
+ };
2110
+ }
2111
+
2112
+ function substackNoteUrl(handle: string, noteId: number): string {
2113
+ return new URL(`/@${encodeURIComponent(handle)}/note/c-${noteId}`, SUBSTACK_ORIGIN).href;
2114
+ }
2115
+
2116
+ type SubstackPostFailureStage =
2117
+ | "dispatch-admission"
2118
+ | "image-upload"
2119
+ | SubstackNoteCreateRequestFailureStage
2120
+ | SubstackNoteCreateBindingFailureStage
2121
+ | "accepted-target-recording"
2122
+ | "note-readback"
2123
+ | "verification-recording";
2124
+
2125
+ async function waitForSubstackNoteReadback(
2126
+ milliseconds: number,
2127
+ sleep: SubstackWebSleep,
2128
+ signal: AbortSignal | undefined,
2129
+ operationDeadline: WebSessionOperationDeadline | undefined,
2130
+ ): Promise<void> {
2131
+ if (operationDeadline === undefined) {
2132
+ await sleep(milliseconds, signal);
2133
+ return;
2134
+ }
2135
+ await operationDeadline.run(
2136
+ (deadlineSignal) => sleep(milliseconds, deadlineSignal),
2137
+ "authenticated web operation deadline",
2138
+ );
2139
+ }
2140
+
2141
+ async function readExactSubstackNoteAfterPublish(
2142
+ client: WebSessionClient,
2143
+ recipe: WebSessionRecipe,
2144
+ noteId: number,
2145
+ viewer: SubstackWebViewer,
2146
+ body: string,
2147
+ image: SubstackImage | null,
2148
+ attachment: SubstackImageAttachment | null,
2149
+ options: {
2150
+ readonly signal?: AbortSignal;
2151
+ readonly operationDeadline?: WebSessionOperationDeadline;
2152
+ readonly sleep: SubstackWebSleep;
2153
+ },
2154
+ ): Promise<ProjectedSubstackNote> {
2155
+ const readbackUrl = new URL(`/api/v1/reader/comment/${noteId}`, SUBSTACK_ORIGIN);
2156
+ authorizeSubstackWebReadRequest({
2157
+ operation: "posts.note",
2158
+ url: readbackUrl,
2159
+ method: "GET",
2160
+ targetId: noteId,
2161
+ });
2162
+ for (let attempt = 0; attempt <= SUBSTACK_NOTE_READBACK_DELAYS_MS.length; attempt += 1) {
2163
+ if (attempt > 0) {
2164
+ await waitForSubstackNoteReadback(
2165
+ SUBSTACK_NOTE_READBACK_DELAYS_MS[attempt - 1]!,
2166
+ options.sleep,
2167
+ options.signal,
2168
+ options.operationDeadline,
2169
+ );
2170
+ }
2171
+ try {
2172
+ const note = normalizeSubstackNoteResponse(await client.requestJson({
2173
+ url: readbackUrl,
2174
+ method: "GET",
2175
+ headers: jsonHeaders(),
2176
+ maxBytes: boundedMaximum(recipe),
2177
+ }), noteId) as ProjectedSubstackNote;
2178
+ assertSubstackNoteReadback(note, noteId, viewer, body, image, attachment);
2179
+ return note;
2180
+ } catch {
2181
+ options.operationDeadline?.throwIfUnavailable(
2182
+ "authenticated web operation deadline",
2183
+ );
2184
+ if (options.signal?.aborted === true) {
2185
+ throw new Error("Substack Note readback was cancelled");
2186
+ }
2187
+ if (attempt === SUBSTACK_NOTE_READBACK_DELAYS_MS.length) {
2188
+ throw new Error("Substack exact Note readback exhausted its reviewed window");
2189
+ }
2190
+ }
2191
+ }
2192
+ throw new Error("Substack exact Note readback exhausted its reviewed window");
2193
+ }
2194
+
2195
+ type SubstackAcceptedNoteAttachment = Readonly<{
2196
+ id: string;
2197
+ url: string;
2198
+ height: number;
2199
+ width: number;
2200
+ mediaType: "image/png";
2201
+ }>;
2202
+
2203
+ type SubstackAcceptedNoteTarget = Readonly<{
2204
+ noteId: number;
2205
+ attachment: SubstackAcceptedNoteAttachment | null;
2206
+ }>;
2207
+
2208
+ function parseSubstackAcceptedNoteTarget(
2209
+ identifier: unknown,
2210
+ ): SubstackAcceptedNoteTarget {
2211
+ if (
2212
+ typeof identifier !== "string"
2213
+ || identifier.length < 1
2214
+ || identifier.length > 8_192
2215
+ || /[\0\r\n]/u.test(identifier)
2216
+ ) throw new Error("Substack accepted Note target must be bounded canonical JSON");
2217
+ let value: unknown;
2218
+ try {
2219
+ value = JSON.parse(identifier) as unknown;
2220
+ } catch {
2221
+ throw new Error("Substack accepted Note target must be bounded canonical JSON");
2222
+ }
2223
+ if (!isRecord(value)) throw new Error("Substack accepted Note target changed shape");
2224
+ requireExactKeys(value, ["attachment", "noteId"], "Substack accepted Note target");
2225
+ if (canonicalJson(value) !== identifier) {
2226
+ throw new Error("Substack accepted Note target must use canonical JSON");
2227
+ }
2228
+ const noteId = positiveInteger(value.noteId, "Substack accepted Note target.noteId");
2229
+ if (value.attachment === null) return Object.freeze({ noteId, attachment: null });
2230
+ if (!isRecord(value.attachment)) {
2231
+ throw new Error("Substack accepted Note attachment changed shape");
2232
+ }
2233
+ requireExactKeys(
2234
+ value.attachment,
2235
+ ["height", "id", "mediaType", "url", "width"],
2236
+ "Substack accepted Note attachment",
2237
+ );
2238
+ const width = positiveInteger(
2239
+ value.attachment.width,
2240
+ "Substack accepted Note attachment.width",
2241
+ );
2242
+ const height = positiveInteger(
2243
+ value.attachment.height,
2244
+ "Substack accepted Note attachment.height",
2245
+ );
2246
+ if (width > 20_000 || height > 20_000 || value.attachment.mediaType !== "image/png") {
2247
+ throw new Error("Substack accepted Note attachment changed shape");
2248
+ }
2249
+ return Object.freeze({
2250
+ noteId,
2251
+ attachment: Object.freeze({
2252
+ id: attachmentUuid(value.attachment.id, "Substack accepted Note attachment.id"),
2253
+ url: exactSubstackImageUrl(
2254
+ value.attachment.url,
2255
+ width,
2256
+ height,
2257
+ "Substack accepted Note attachment.url",
2258
+ ),
2259
+ height,
2260
+ width,
2261
+ mediaType: "image/png" as const,
2262
+ }),
2263
+ });
2264
+ }
2265
+
2266
+ /** Read only the exact provider-accepted Substack Note target; never dispatch. */
2267
+ export async function readSubstackWebAcceptedNoteTargetPresence(
2268
+ recipe: WebSessionRecipe,
2269
+ input: OperationInput,
2270
+ auth: GhostgetAuth,
2271
+ acceptedIdentifier: string,
2272
+ options: {
2273
+ readonly signal?: AbortSignal;
2274
+ readonly operationDeadline?: WebSessionOperationDeadline;
2275
+ readonly dependencies?: SubstackWebRuntimeDependencies;
2276
+ } = {},
2277
+ ): Promise<Readonly<{ present: true; noteId: number }>> {
2278
+ if (
2279
+ recipe.site !== "substack"
2280
+ || recipe.action !== "posts.publish"
2281
+ || recipe.contractVersion !== 3
2282
+ ) throw new Error("Substack accepted Note readback supports only posts.publish@3");
2283
+ requireExactInputKeys(input, ["body", "media"]);
2284
+ const body = noteBodyInput(input);
2285
+ const media = input.media === undefined ? null : fileInput(input.media);
2286
+ const target = parseSubstackAcceptedNoteTarget(acceptedIdentifier);
2287
+ if ((media !== null) !== (target.attachment !== null)) {
2288
+ throw new Error("Substack accepted Note target did not bind the confirmed media input");
2289
+ }
2290
+ const client = await createWebSessionClient(SUBSTACK_ORIGIN, auth, {
2291
+ timeoutMs: recipe.timeoutMs,
2292
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2293
+ ...(options.operationDeadline === undefined
2294
+ ? {}
2295
+ : { operationDeadline: options.operationDeadline }),
2296
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
2297
+ });
2298
+ const viewer = await requireBoundViewer(client, auth, recipe.maxOutputBytes);
2299
+ const readbackUrl = new URL(
2300
+ `/api/v1/reader/comment/${target.noteId}`,
2301
+ SUBSTACK_ORIGIN,
2302
+ );
2303
+ authorizeSubstackWebReadRequest({
2304
+ operation: "posts.note",
2305
+ url: readbackUrl,
2306
+ method: "GET",
2307
+ targetId: target.noteId,
2308
+ });
2309
+ const note = normalizeSubstackNoteResponse(await client.requestJson({
2310
+ url: readbackUrl,
2311
+ method: "GET",
2312
+ headers: jsonHeaders(),
2313
+ maxBytes: boundedMaximum(recipe),
2314
+ }), target.noteId) as ProjectedSubstackNote;
2315
+ assertSubstackNoteReadback(
2316
+ note,
2317
+ target.noteId,
2318
+ viewer,
2319
+ body,
2320
+ target.attachment,
2321
+ target.attachment,
2322
+ );
2323
+ return Object.freeze({ present: true as const, noteId: target.noteId });
2324
+ }
2325
+
2326
+ async function executeSubstackPost(
2327
+ client: WebSessionClient,
2328
+ recipe: WebSessionRecipe,
2329
+ viewer: SubstackWebViewer,
2330
+ input: OperationInput,
2331
+ options: {
2332
+ readonly fileResolver?: BrowserFileResolver;
2333
+ readonly signal?: AbortSignal;
2334
+ readonly operationDeadline?: WebSessionOperationDeadline;
2335
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2336
+ readonly afterProviderAcceptedMutationTarget?: (
2337
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2338
+ ) => Promise<void>;
2339
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2340
+ readonly sleep: SubstackWebSleep;
2341
+ },
2342
+ ): Promise<WebSessionExecution> {
2343
+ requireExactInputKeys(input, ["body", "media"]);
2344
+ const body = noteBodyInput(input);
2345
+ const bodyJson = substackBodyJson(body);
2346
+ if (viewer.handle === null) {
2347
+ throw new Error("Substack Note publication requires the bound viewer's public handle");
2348
+ }
2349
+ const image = input.media === undefined
2350
+ ? null
2351
+ : await materializeSubstackImage(
2352
+ fileInput(input.media),
2353
+ options.fileResolver,
2354
+ options.operationDeadline,
2355
+ );
2356
+ const reboundViewer = await currentViewer(client, boundedMaximum(recipe));
2357
+ if (viewerSubject(reboundViewer) !== viewerSubject(viewer)) {
2358
+ throw new Error("Substack current viewer changed before the Note dispatch");
2359
+ }
2360
+ if (reboundViewer.handle === null) {
2361
+ throw new Error("Substack Note publication requires the bound viewer's public handle");
2362
+ }
2363
+ let started = 0;
2364
+ let verified = 0;
2365
+ let noteId: number | null = null;
2366
+ let attachment: SubstackImageAttachment | null = null;
2367
+ let failureStage: SubstackPostFailureStage = "dispatch-admission";
2368
+ try {
2369
+ failureStage = "image-upload";
2370
+ attachment = image === null ? null : await uploadSubstackImage(client, image);
2371
+ failureStage = "dispatch-admission";
2372
+ await options.beforeDispatch?.(substackDispatchEvent(started, verified));
2373
+ started = 1;
2374
+ failureStage = "note-create-transport";
2375
+ let createdNoteResponse: unknown;
2376
+ try {
2377
+ createdNoteResponse = await client.requestJson({
2378
+ url: new URL("/api/v1/comment/feed", SUBSTACK_ORIGIN),
2379
+ method: "POST",
2380
+ headers: jsonPostHeaders(),
2381
+ body: JSON.stringify({
2382
+ bodyJson,
2383
+ ...(attachment === null ? {} : { attachmentIds: [attachment.id] }),
2384
+ tabId: "for-you",
2385
+ surface: "feed",
2386
+ replyMinimumRole: "everyone",
2387
+ }),
2388
+ expectedStatuses: [200],
2389
+ expectedContentTypes: ["application/json"],
2390
+ maxBytes: boundedMaximum(recipe),
2391
+ });
2392
+ } catch (error) {
2393
+ failureStage = substackNoteCreateRequestFailureStage(error);
2394
+ throw error;
2395
+ }
2396
+ try {
2397
+ noteId = parseCreatedSubstackNote(
2398
+ createdNoteResponse,
2399
+ reboundViewer,
2400
+ body,
2401
+ bodyJson,
2402
+ attachment,
2403
+ );
2404
+ } catch (error) {
2405
+ failureStage = error instanceof SubstackNoteCreateBindingError
2406
+ ? error.stage
2407
+ : "note-create-response-object";
2408
+ throw error;
2409
+ }
2410
+ failureStage = "accepted-target-recording";
2411
+ await options.afterProviderAcceptedMutationTarget?.({
2412
+ id: "posts.publish",
2413
+ index: 1,
2414
+ target: {
2415
+ schemaVersion: 1,
2416
+ identifier: canonicalJson({
2417
+ noteId,
2418
+ attachment: attachment === null
2419
+ ? null
2420
+ : {
2421
+ id: attachment.id,
2422
+ url: attachment.url,
2423
+ height: image!.height,
2424
+ width: image!.width,
2425
+ mediaType: image!.mediaType,
2426
+ },
2427
+ }),
2428
+ },
2429
+ });
2430
+ failureStage = "note-readback";
2431
+ const note = await readExactSubstackNoteAfterPublish(
2432
+ client,
2433
+ recipe,
2434
+ noteId,
2435
+ reboundViewer,
2436
+ body,
2437
+ image,
2438
+ attachment,
2439
+ options,
2440
+ );
2441
+ verified = 1;
2442
+ failureStage = "verification-recording";
2443
+ await options.afterDispatchVerified?.(substackDispatchEvent(started, verified));
2444
+ return {
2445
+ status: "succeeded",
2446
+ output: Object.freeze({
2447
+ note,
2448
+ attachment: image === null
2449
+ ? null
2450
+ : Object.freeze({
2451
+ height: image.height,
2452
+ mediaType: image.mediaType,
2453
+ width: image.width,
2454
+ }),
2455
+ }),
2456
+ finalUrl: substackNoteUrl(reboundViewer.handle, noteId),
2457
+ dispatchStarted: true,
2458
+ dispatch: { planned: 1, started, verified },
2459
+ };
2460
+ } catch {
2461
+ return {
2462
+ status: started > 0 ? "indeterminate" : "failed",
2463
+ output: null,
2464
+ finalUrl: noteId === null ? SUBSTACK_ORIGIN : substackNoteUrl(reboundViewer.handle, noteId),
2465
+ dispatchStarted: started > 0,
2466
+ dispatch: { planned: 1, started, verified },
2467
+ error: started > 0
2468
+ ? `Substack may have accepted the image upload or Note but exact actor, text, attachment, and permalink readback was not verified; reconcile before retrying (stage: ${failureStage})`
2469
+ : `Substack Note dispatch failed before submission (stage: ${failureStage})`,
2470
+ };
2471
+ }
2472
+ }
2473
+
2474
+ type SubstackDeleteFailureStage =
2475
+ | "accepted-target-recording"
2476
+ | "delete-readback"
2477
+ | "delete-transport"
2478
+ | "dispatch-admission"
2479
+ | "verification-recording";
2480
+
2481
+ async function executeSubstackPersonalNoteDelete(
2482
+ client: WebSessionClient,
2483
+ recipe: WebSessionRecipe,
2484
+ viewer: SubstackWebViewer,
2485
+ input: OperationInput,
2486
+ options: {
2487
+ readonly signal?: AbortSignal;
2488
+ readonly operationDeadline?: WebSessionOperationDeadline;
2489
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2490
+ readonly afterProviderAcceptedMutationTarget?: (
2491
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2492
+ ) => Promise<void>;
2493
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2494
+ readonly sleep: SubstackWebSleep;
2495
+ readonly dependencies?: SubstackWebRuntimeDependencies;
2496
+ },
2497
+ ): Promise<WebSessionExecution> {
2498
+ const plan = prepareSubstackPersonalNoteDeleteInput(input);
2499
+ const before = await readSubstackPersonalNoteDeletionPresence(
2500
+ client,
2501
+ recipe,
2502
+ plan.noteId,
2503
+ viewer.id,
2504
+ plan.expectedBody,
2505
+ );
2506
+ const finalUrl = viewer.handle === null
2507
+ ? SUBSTACK_ORIGIN
2508
+ : substackNoteUrl(viewer.handle, plan.noteId);
2509
+ if (!before.present) {
2510
+ return {
2511
+ status: "succeeded",
2512
+ output: Object.freeze({ deleted: true, noOp: true, noteId: plan.noteId }),
2513
+ finalUrl,
2514
+ noOp: true,
2515
+ dispatchStarted: false,
2516
+ dispatch: { planned: 1, started: 0, verified: 0 },
2517
+ };
2518
+ }
2519
+
2520
+ const reboundViewer = await currentViewer(client, boundedMaximum(recipe));
2521
+ if (viewerSubject(reboundViewer) !== viewerSubject(viewer)) {
2522
+ throw new Error("Substack current viewer changed before the Note deletion dispatch");
2523
+ }
2524
+ const fresh = await readSubstackPersonalNoteDeletionPresence(
2525
+ client,
2526
+ recipe,
2527
+ plan.noteId,
2528
+ reboundViewer.id,
2529
+ plan.expectedBody,
2530
+ );
2531
+ if (!fresh.present) {
2532
+ return {
2533
+ status: "succeeded",
2534
+ output: Object.freeze({ deleted: true, noOp: true, noteId: plan.noteId }),
2535
+ finalUrl,
2536
+ noOp: true,
2537
+ dispatchStarted: false,
2538
+ dispatch: { planned: 1, started: 0, verified: 0 },
2539
+ };
2540
+ }
2541
+ if (fresh.target === null) {
2542
+ throw new Error("Substack deletion pre-read omitted its exact recovery target");
2543
+ }
2544
+
2545
+ let started = 0;
2546
+ let verified = 0;
2547
+ let failureStage: SubstackDeleteFailureStage = "dispatch-admission";
2548
+ try {
2549
+ await options.beforeDispatch?.(substackDeleteDispatchEvent(started, verified));
2550
+ started = 1;
2551
+ failureStage = "delete-transport";
2552
+ await dispatchSubstackPersonalNoteDelete(client, plan.noteId, {
2553
+ timeoutMs: recipe.timeoutMs,
2554
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2555
+ ...(options.operationDeadline === undefined
2556
+ ? {}
2557
+ : { operationDeadline: options.operationDeadline }),
2558
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
2559
+ });
2560
+ failureStage = "accepted-target-recording";
2561
+ await options.afterProviderAcceptedMutationTarget?.({
2562
+ id: "content.delete",
2563
+ index: 1,
2564
+ target: {
2565
+ schemaVersion: 1,
2566
+ identifier: substackNoteDeletionRecoveryTargetIdentifier(fresh.target),
2567
+ },
2568
+ });
2569
+ failureStage = "delete-readback";
2570
+ let after = await readSubstackPersonalNoteDeletionPresence(
2571
+ client,
2572
+ recipe,
2573
+ plan.noteId,
2574
+ reboundViewer.id,
2575
+ plan.expectedBody,
2576
+ );
2577
+ for (const delay of SUBSTACK_NOTE_READBACK_DELAYS_MS) {
2578
+ if (!after.present) break;
2579
+ await waitForSubstackNoteReadback(
2580
+ delay,
2581
+ options.sleep,
2582
+ options.signal,
2583
+ options.operationDeadline,
2584
+ );
2585
+ after = await readSubstackPersonalNoteDeletionPresence(
2586
+ client,
2587
+ recipe,
2588
+ plan.noteId,
2589
+ reboundViewer.id,
2590
+ plan.expectedBody,
2591
+ );
2592
+ }
2593
+ if (after.present) {
2594
+ throw new Error("Substack exact Note deletion readback still returned the authored Note");
2595
+ }
2596
+ verified = 1;
2597
+ failureStage = "verification-recording";
2598
+ await options.afterDispatchVerified?.(substackDeleteDispatchEvent(started, verified));
2599
+ return {
2600
+ status: "succeeded",
2601
+ output: Object.freeze({ deleted: true, noOp: false, noteId: plan.noteId }),
2602
+ finalUrl,
2603
+ dispatchStarted: true,
2604
+ dispatch: { planned: 1, started, verified },
2605
+ };
2606
+ } catch {
2607
+ return {
2608
+ status: started > 0 ? "indeterminate" : "failed",
2609
+ output: null,
2610
+ finalUrl,
2611
+ dispatchStarted: started > 0,
2612
+ dispatch: { planned: 1, started, verified },
2613
+ error: started > 0
2614
+ ? `Substack may have deleted the exact authored Note, but independent absence was not verified; reconcile before retrying (stage: ${failureStage})`
2615
+ : `Substack Note deletion failed before submission (stage: ${failureStage})`,
2616
+ };
2617
+ }
2618
+ }
2619
+
2620
+ /** Independently read the exact confirmed personal Note's desired deletion state. */
2621
+ export async function readSubstackWebContentDeleteDesiredState(
2622
+ recipe: WebSessionRecipe,
2623
+ input: OperationInput,
2624
+ auth: GhostgetAuth,
2625
+ options: {
2626
+ readonly signal?: AbortSignal;
2627
+ readonly operationDeadline?: WebSessionOperationDeadline;
2628
+ readonly dependencies?: SubstackWebRuntimeDependencies;
2629
+ } = {},
2630
+ ): Promise<Readonly<{ present: boolean; noteId: number }>> {
2631
+ if (
2632
+ recipe.site !== "substack"
2633
+ || recipe.action !== "content.delete"
2634
+ || recipe.contractVersion !== 1
2635
+ ) throw new Error("Substack deletion recovery supports only content.delete@1");
2636
+ const plan = prepareSubstackPersonalNoteDeleteInput(input);
2637
+ const client = await createWebSessionClient(SUBSTACK_ORIGIN, auth, {
2638
+ timeoutMs: recipe.timeoutMs,
2639
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2640
+ ...(options.operationDeadline === undefined
2641
+ ? {}
2642
+ : { operationDeadline: options.operationDeadline }),
2643
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
2644
+ });
2645
+ const viewer = await requireBoundViewer(client, auth, recipe.maxOutputBytes);
2646
+ const presence = await readSubstackPersonalNoteDeletionPresence(
2647
+ client,
2648
+ recipe,
2649
+ plan.noteId,
2650
+ viewer.id,
2651
+ plan.expectedBody,
2652
+ );
2653
+ return Object.freeze({ present: presence.present, noteId: plan.noteId });
2654
+ }
2655
+
2656
+ export async function executeSubstackWebOperation(
2657
+ recipe: WebSessionRecipe,
2658
+ input: OperationInput,
2659
+ auth: GhostgetAuth,
2660
+ options: {
2661
+ readonly fileResolver?: BrowserFileResolver;
2662
+ readonly signal?: AbortSignal;
2663
+ readonly operationDeadline?: WebSessionOperationDeadline;
2664
+ readonly beforeDispatch?: (event: WebSessionDispatchEvent) => Promise<void>;
2665
+ readonly afterProviderAcceptedMutationTarget?: (
2666
+ event: WebSessionProviderAcceptedMutationTargetEvent,
2667
+ ) => Promise<void>;
2668
+ readonly afterDispatchVerified?: (event: WebSessionDispatchEvent) => Promise<void>;
2669
+ readonly dependencies?: SubstackWebRuntimeDependencies;
2670
+ } = {},
2671
+ ): Promise<WebSessionExecution> {
2672
+ if (
2673
+ recipe.site !== "substack"
2674
+ || !isSubstackOperation(recipe.action)
2675
+ ) throw new Error("Substack authenticated web recipe is not installed");
2676
+ const expectedContractVersion = recipe.action === "posts.publish" ? 3 : 1;
2677
+ if (recipe.contractVersion !== expectedContractVersion) {
2678
+ throw new Error(
2679
+ `Substack authenticated web operation ${recipe.action} contract version ${recipe.contractVersion} is not installed`,
2680
+ );
2681
+ }
2682
+ const contract = SUBSTACK_WEB_OPERATIONS[recipe.action];
2683
+ if (contract.state !== "observed") {
2684
+ throw new Error(
2685
+ `Substack authenticated web operation ${recipe.action} is capture-required: ${contract.reason}`,
2686
+ );
2687
+ }
2688
+ if (
2689
+ recipe.action !== "feeds.read"
2690
+ && recipe.action !== "posts.read"
2691
+ && recipe.action !== "articles.read"
2692
+ && recipe.action !== "comments.read"
2693
+ && recipe.action !== "media.read"
2694
+ && recipe.action !== "messaging.list"
2695
+ && recipe.action !== "profiles.read"
2696
+ && recipe.action !== "organizations.read"
2697
+ && recipe.action !== "posts.publish"
2698
+ && recipe.action !== "content.delete"
2699
+ ) throw new Error(`Substack authenticated web operation ${recipe.action} has no executable reviewed contract`);
2700
+
2701
+ const statisticsRead = recipe.action === "profiles.read"
2702
+ || recipe.action === "organizations.read";
2703
+ const statisticsFinalUrl = recipe.action === "profiles.read"
2704
+ ? `https://substack.com/@${substackProfileInput(input)}`
2705
+ : recipe.action === "organizations.read"
2706
+ ? `https://${substackOrganizationInput(input)}.substack.com/`
2707
+ : null;
2708
+ let client: WebSessionClient;
2709
+ try {
2710
+ client = await createWebSessionClient(SUBSTACK_ORIGIN, auth, {
2711
+ timeoutMs: recipe.timeoutMs,
2712
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
2713
+ ...(options.operationDeadline === undefined
2714
+ ? {}
2715
+ : { operationDeadline: options.operationDeadline }),
2716
+ ...(options.dependencies === undefined ? {} : { dependencies: options.dependencies }),
2717
+ });
2718
+ } catch (error) {
2719
+ if (!statisticsRead) throw error;
2720
+ return failedProviderRead("Substack statistics", error, statisticsFinalUrl, {
2721
+ stage: "bootstrap",
2722
+ authenticated: true,
2723
+ });
2724
+ }
2725
+ let viewer: SubstackWebViewer;
2726
+ try {
2727
+ viewer = await requireBoundViewer(client, auth, recipe.maxOutputBytes);
2728
+ } catch (error) {
2729
+ if (!statisticsRead) throw error;
2730
+ return failedProviderRead("Substack statistics", error, statisticsFinalUrl, {
2731
+ stage: "identity",
2732
+ authenticated: true,
2733
+ accountMismatch: (candidate) => candidate.message.includes("no longer matches"),
2734
+ authRepairRequired: (candidate) => candidate.message.includes("auth locator bound")
2735
+ || candidate instanceof SubstackAuthRepairRequiredError,
2736
+ });
2737
+ }
2738
+ if (statisticsRead) {
2739
+ try {
2740
+ const output = recipe.action === "profiles.read"
2741
+ ? await readProfile(
2742
+ client,
2743
+ recipe,
2744
+ viewer,
2745
+ input,
2746
+ options.dependencies?.now ?? Date.now,
2747
+ )
2748
+ : await readOrganization(recipe, input, auth, viewer, options);
2749
+ return {
2750
+ status: "succeeded",
2751
+ output,
2752
+ finalUrl: statisticsFinalUrl,
2753
+ dispatchStarted: false,
2754
+ dispatch: { planned: 0, started: 0, verified: 0 },
2755
+ };
2756
+ } catch (error) {
2757
+ return failedProviderRead("Substack statistics", error, statisticsFinalUrl, {
2758
+ stage: "target",
2759
+ authenticated: true,
2760
+ accountMismatch: (candidate) => candidate.message.includes("does not match the signed-in viewer")
2761
+ || candidate.message.includes("viewer-owned publication")
2762
+ || candidate.message.includes("did not bind the current viewer ID"),
2763
+ });
2764
+ }
2765
+ }
2766
+ if (recipe.action === "posts.publish") {
2767
+ return executeSubstackPost(client, recipe, viewer, input, {
2768
+ ...options,
2769
+ sleep: options.dependencies?.sleep ?? sleepForSubstackReadback,
2770
+ });
2771
+ }
2772
+ if (recipe.action === "content.delete") {
2773
+ return executeSubstackPersonalNoteDelete(client, recipe, viewer, input, {
2774
+ ...options,
2775
+ sleep: options.dependencies?.sleep ?? sleepForSubstackReadback,
2776
+ });
2777
+ }
2778
+ // Executable Substack reads never enter the mutation dispatch ledger.
2779
+ void options.fileResolver;
2780
+ void options.beforeDispatch;
2781
+ void options.afterDispatchVerified;
2782
+
2783
+ let output: unknown;
2784
+ let finalUrl = SUBSTACK_ORIGIN;
2785
+ switch (recipe.action) {
2786
+ case "feeds.read":
2787
+ output = await readFeed(client, recipe, input);
2788
+ break;
2789
+ case "posts.read":
2790
+ output = await readNote(client, recipe, input);
2791
+ break;
2792
+ case "articles.read":
2793
+ output = await readArticle(client, recipe, input, false);
2794
+ break;
2795
+ case "comments.read":
2796
+ output = await readComments(client, recipe, input);
2797
+ break;
2798
+ case "media.read":
2799
+ output = await readArticle(client, recipe, input, true);
2800
+ break;
2801
+ case "messaging.list":
2802
+ output = await listMessages(client, recipe, input);
2803
+ break;
2804
+ }
2805
+ return {
2806
+ status: "succeeded",
2807
+ output,
2808
+ finalUrl,
2809
+ dispatchStarted: false,
2810
+ dispatch: { planned: 0, started: 0, verified: 0 },
2811
+ };
2812
+ }