@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,1502 @@
1
+ /**
2
+ * Substack consumer-web internal API policy and bounded projections.
3
+ *
4
+ * Browser state may provide the signed-in cookie jar, but this module owns
5
+ * every executable request shape and response projection. It intentionally
6
+ * exposes no raw endpoint, header, cookie, publication origin, or request
7
+ * payload as a semantic input.
8
+ */
9
+
10
+ export const SUBSTACK_WEB_OPERATION_NAMES = Object.freeze([
11
+ "articles.publish",
12
+ "articles.read",
13
+ "comments.create",
14
+ "comments.read",
15
+ "content.delete",
16
+ "content.edit",
17
+ "content.save",
18
+ "content.schedule",
19
+ "content.share",
20
+ "feeds.read",
21
+ "likes.set",
22
+ "media.publish",
23
+ "media.read",
24
+ "messaging.list",
25
+ "messaging.read",
26
+ "messaging.send",
27
+ "posts.publish",
28
+ "posts.quote",
29
+ "posts.read",
30
+ "posts.repost",
31
+ "profiles.read",
32
+ "organizations.read",
33
+ "relationships.follow.set",
34
+ "replies.create",
35
+ ] as const);
36
+
37
+ export type SubstackWebOperationName = (typeof SUBSTACK_WEB_OPERATION_NAMES)[number];
38
+ export type SubstackWebRisk = "R1" | "R2" | "R3";
39
+ export type SubstackWebContractState = "observed" | "capture-required";
40
+ export type SubstackWebEvidence = "live-direct" | "first-party-bundle" | "none";
41
+
42
+ export type SubstackWebOperationContract = {
43
+ readonly effect: "read" | "write";
44
+ readonly risk: SubstackWebRisk;
45
+ readonly state: SubstackWebContractState;
46
+ readonly evidence: SubstackWebEvidence;
47
+ readonly reason: string;
48
+ };
49
+
50
+ const observedRead = (reason: string): SubstackWebOperationContract => Object.freeze({
51
+ effect: "read",
52
+ risk: "R1",
53
+ state: "observed",
54
+ evidence: "live-direct",
55
+ reason,
56
+ });
57
+
58
+ const observedWrite = (reason: string): SubstackWebOperationContract => Object.freeze({
59
+ effect: "write",
60
+ risk: "R3",
61
+ state: "observed",
62
+ evidence: "live-direct",
63
+ reason,
64
+ });
65
+
66
+ const captureRequired = (
67
+ risk: SubstackWebRisk,
68
+ evidence: SubstackWebEvidence,
69
+ reason: string,
70
+ ): SubstackWebOperationContract => Object.freeze({
71
+ effect: "write",
72
+ risk,
73
+ state: "capture-required",
74
+ evidence,
75
+ reason,
76
+ });
77
+
78
+ const captureRequiredRead = (
79
+ evidence: SubstackWebEvidence,
80
+ reason: string,
81
+ ): SubstackWebOperationContract => Object.freeze({
82
+ effect: "read",
83
+ risk: "R1",
84
+ state: "capture-required",
85
+ evidence,
86
+ reason,
87
+ });
88
+
89
+ export const SUBSTACK_WEB_OPERATIONS = Object.freeze({
90
+ "feeds.read": observedRead(
91
+ "current central reader, inbox, and reader-post list endpoints with bounded first-page projection",
92
+ ),
93
+ "posts.read": observedRead(
94
+ "exact Note/comment entity read through /api/v1/reader/comment/{id}",
95
+ ),
96
+ "articles.read": observedRead(
97
+ "exact entitled article read through /api/v1/posts/by-id/{id}",
98
+ ),
99
+ "comments.read": observedRead(
100
+ "exact article reply branch read with post and publication binding",
101
+ ),
102
+ "media.read": observedRead(
103
+ "bounded cover, podcast, video-upload, API audio-item, and exact same-publication inline audio metadata projected from an exact entitled article response",
104
+ ),
105
+ "media.publish": captureRequired(
106
+ "R3",
107
+ "first-party-bundle",
108
+ "Note video upload initialization, byte transfer, processing, viewer binding, returned attachment, and exact public Note readback need an authorized fixture",
109
+ ),
110
+ "messaging.list": observedRead(
111
+ "acknowledgement-free inbox listing for all, people, and unread tabs",
112
+ ),
113
+ "messaging.read": captureRequiredRead(
114
+ "first-party-bundle",
115
+ "the exact DM GET is current-bundle observed, but this account has no low-stakes direct-message fixture proving acknowledgement behavior",
116
+ ),
117
+ "profiles.read": observedRead(
118
+ "exact target-bound /api/v1/user/{handle}/public_profile response with current-viewer ID binding and an exact follower count",
119
+ ),
120
+ "organizations.read": observedRead(
121
+ "exact owned-publication /api/v1/publish-dashboard/summary response with bootstrap-bound publication origin and exact total-email and paid-subscriber counts",
122
+ ),
123
+ "likes.set": captureRequired(
124
+ "R2",
125
+ "first-party-bundle",
126
+ "post and Note/comment reaction endpoints are distinct and need authorized desired-state fixtures plus independent readback",
127
+ ),
128
+ "content.save": captureRequired(
129
+ "R2",
130
+ "first-party-bundle",
131
+ "post and Note save endpoints are distinct and need authorized desired-state fixtures plus independent readback",
132
+ ),
133
+ "relationships.follow.set": captureRequired(
134
+ "R2",
135
+ "first-party-bundle",
136
+ "user-follow and publication-subscription changes have different effects; paid, pledge, and email changes remain blocked",
137
+ ),
138
+ "comments.create": captureRequired(
139
+ "R3",
140
+ "first-party-bundle",
141
+ "article comment publication needs an authorized fixture and exact actor/post response binding",
142
+ ),
143
+ "replies.create": captureRequired(
144
+ "R3",
145
+ "first-party-bundle",
146
+ "Note, article-comment, and chat replies are separate transports and need exact authorized fixtures",
147
+ ),
148
+ "messaging.send": captureRequired(
149
+ "R3",
150
+ "first-party-bundle",
151
+ "DM start/send and optional media URL upload need exact recipient, thread, response, and attachment bindings",
152
+ ),
153
+ "posts.publish": observedWrite(
154
+ "authorized Note composer capture proving one optional PNG upload, exact public create payload, durable accepted-Note targeting, actor and attachment response binding, and four bounded exact readbacks over a six-second visibility window",
155
+ ),
156
+ "posts.quote": captureRequired(
157
+ "R3",
158
+ "first-party-bundle",
159
+ "quote-Note creation is not interchangeable with a plain Note or restack",
160
+ ),
161
+ "posts.repost": captureRequired(
162
+ "R3",
163
+ "first-party-bundle",
164
+ "restack creation needs an authorized fixture and exact source/created-Note response binding",
165
+ ),
166
+ "content.share": captureRequired(
167
+ "R3",
168
+ "first-party-bundle",
169
+ "external sharing and Substack-native restacking are different externally visible effects",
170
+ ),
171
+ "content.edit": captureRequired(
172
+ "R3",
173
+ "first-party-bundle",
174
+ "owned Note, comment, draft, and article edits have distinct origins and response/readback contracts",
175
+ ),
176
+ "content.delete": observedWrite(
177
+ "authorized personal-Note fixture proving an exact current-viewer/body pre-read, one bodyless DELETE /api/v1/comment/{note-id} accepted with 200, durable target retention, and an independent exact Note read returning 404",
178
+ ),
179
+ "articles.publish": captureRequired(
180
+ "R3",
181
+ "first-party-bundle",
182
+ "article authoring may execute only on an exact viewer-owned publication origin and must never default to sending email",
183
+ ),
184
+ "content.schedule": captureRequired(
185
+ "R3",
186
+ "first-party-bundle",
187
+ "scheduled publication needs an exact viewer-owned publication, time zone, audience, notification, and returned-draft binding",
188
+ ),
189
+ } as const satisfies Readonly<Record<SubstackWebOperationName, SubstackWebOperationContract>>);
190
+
191
+ const SUBSTACK_ORIGIN = "https://substack.com";
192
+ const MAX_HTML_BYTES = 8 * 1024 * 1024;
193
+ const MAX_ITEMS = 100;
194
+ const MAX_BODY_BYTES = 2 * 1024 * 1024;
195
+ const MAX_INLINE_AUDIO_EMBEDS = 20;
196
+ const MAX_INLINE_AUDIO_TAG_CODE_UNITS = 16_384;
197
+ const INLINE_AUDIO_UPLOAD_PATH = /^\/api\/v1\/audio\/upload\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/src$/u;
198
+
199
+ type JsonRecord = Record<string, unknown>;
200
+
201
+ function isRecord(value: unknown): value is JsonRecord {
202
+ return typeof value === "object" && value !== null && !Array.isArray(value);
203
+ }
204
+
205
+ function record(value: unknown, label: string): JsonRecord {
206
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
207
+ return value;
208
+ }
209
+
210
+ function boundedString(
211
+ value: unknown,
212
+ label: string,
213
+ maximum: number,
214
+ allowEmpty = false,
215
+ ): string {
216
+ if (
217
+ typeof value !== "string"
218
+ || (!allowEmpty && value.length < 1)
219
+ || value.length > maximum
220
+ || /[\0\r]/u.test(value)
221
+ ) throw new Error(`${label} must be bounded text`);
222
+ return value;
223
+ }
224
+
225
+ function optionalString(value: unknown, label: string, maximum: number): string | null {
226
+ if (value === undefined || value === null || value === "") return null;
227
+ return boundedString(value, label, maximum);
228
+ }
229
+
230
+ function integerId(value: unknown, label: string): number {
231
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
232
+ throw new Error(`${label} must be a positive safe integer`);
233
+ }
234
+ return value as number;
235
+ }
236
+
237
+ function optionalIntegerId(value: unknown, label: string): number | null {
238
+ if (value === undefined || value === null) return null;
239
+ return integerId(value, label);
240
+ }
241
+
242
+ function optionalFiniteNumber(value: unknown, label: string): number | null {
243
+ if (value === undefined || value === null) return null;
244
+ if (typeof value !== "number" || !Number.isFinite(value)) {
245
+ throw new Error(`${label} must be finite`);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function optionalBoolean(value: unknown, label: string): boolean | null {
251
+ if (value === undefined || value === null) return null;
252
+ if (typeof value !== "boolean") throw new Error(`${label} must be boolean`);
253
+ return value;
254
+ }
255
+
256
+ function boundedArray(value: unknown, label: string, maximum = MAX_ITEMS): readonly unknown[] {
257
+ if (!Array.isArray(value) || value.length > maximum) {
258
+ throw new Error(`${label} must be a bounded array`);
259
+ }
260
+ return value;
261
+ }
262
+
263
+ function exactHttpsUrl(value: unknown, label: string, maximum = 8_192): string | null {
264
+ if (value === undefined || value === null || value === "") return null;
265
+ const candidate = boundedString(value, label, maximum);
266
+ let parsed: URL;
267
+ try {
268
+ parsed = new URL(candidate);
269
+ } catch {
270
+ throw new Error(`${label} must be an absolute URL`);
271
+ }
272
+ if (
273
+ parsed.protocol !== "https:"
274
+ || parsed.username !== ""
275
+ || parsed.password !== ""
276
+ || parsed.hash !== ""
277
+ ) throw new Error(`${label} must be a credential-free HTTPS URL`);
278
+ return parsed.href;
279
+ }
280
+
281
+ type SubstackProfileMetric = Readonly<
282
+ | {
283
+ readonly status: "available";
284
+ readonly value: number;
285
+ readonly precision: "exact";
286
+ readonly unit: "count";
287
+ }
288
+ | {
289
+ readonly status: "unavailable";
290
+ readonly reason: "not-exposed" | "not-authorized" | "provider-drift";
291
+ }
292
+ >;
293
+
294
+ function unavailableSubstackProfileMetric(
295
+ reason: "not-exposed" | "not-authorized" | "provider-drift",
296
+ ): SubstackProfileMetric {
297
+ return Object.freeze({ status: "unavailable", reason });
298
+ }
299
+
300
+ function exactSubstackProfileMetric(value: unknown): SubstackProfileMetric {
301
+ if (value === undefined || value === null) {
302
+ return unavailableSubstackProfileMetric("not-exposed");
303
+ }
304
+ const candidate = typeof value === "string" && /^(?:0|[1-9][0-9]*)$/u.test(value)
305
+ ? Number(value)
306
+ : value;
307
+ if (!Number.isSafeInteger(candidate) || (candidate as number) < 0) {
308
+ return unavailableSubstackProfileMetric("provider-drift");
309
+ }
310
+ return Object.freeze({
311
+ status: "available",
312
+ value: candidate as number,
313
+ precision: "exact",
314
+ unit: "count",
315
+ });
316
+ }
317
+
318
+ function canonicalSubstackProfileHandle(value: unknown, label: string): string {
319
+ const handle = boundedString(value, label, 128).toLowerCase();
320
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,126}[a-z0-9])?$/u.test(handle)) {
321
+ throw new Error(`${label} must be one canonical lowercase Substack handle`);
322
+ }
323
+ return handle;
324
+ }
325
+
326
+ function exactSubstackProfileId(value: unknown, label: string): string {
327
+ if (Number.isSafeInteger(value) && (value as number) > 0) return String(value);
328
+ if (
329
+ typeof value === "string"
330
+ && /^[1-9][0-9]{0,15}$/u.test(value)
331
+ && Number.isSafeInteger(Number(value))
332
+ ) return value;
333
+ throw new Error(`${label} must be one positive safe integer identifier`);
334
+ }
335
+
336
+ function exactSubstackProfileObservedAt(value: string): string {
337
+ if (
338
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
339
+ || !Number.isFinite(Date.parse(value))
340
+ ) throw new Error("Substack profile observation time must be one exact UTC instant");
341
+ return value;
342
+ }
343
+
344
+ /** Project one exact target-bound signed-in Substack public-profile response. */
345
+ export function normalizeSubstackProfileStatsResponse(
346
+ value: unknown,
347
+ expectedViewerId: number,
348
+ expectedProfile: string,
349
+ observedAt: string,
350
+ ): Readonly<Record<string, unknown>> {
351
+ const source = record(value, "Substack public-profile response");
352
+ const handle = canonicalSubstackProfileHandle(
353
+ source.handle,
354
+ "Substack public-profile response.handle",
355
+ );
356
+ const profile = canonicalSubstackProfileHandle(
357
+ expectedProfile,
358
+ "Substack requested profile",
359
+ );
360
+ if (handle !== profile) {
361
+ throw new Error("Substack public-profile response did not bind the requested handle");
362
+ }
363
+ const id = exactSubstackProfileId(
364
+ source.id,
365
+ "Substack public-profile response.id",
366
+ );
367
+ if (id !== exactSubstackProfileId(expectedViewerId, "Substack expected viewer ID")) {
368
+ throw new Error("Substack public-profile response did not bind the current viewer ID");
369
+ }
370
+ const camelCount = exactSubstackProfileMetric(source.followerCount);
371
+ const snakeCount = exactSubstackProfileMetric(source.follower_count);
372
+ let followers = source.followerCount === undefined
373
+ ? snakeCount
374
+ : camelCount;
375
+ if (
376
+ source.followerCount !== undefined
377
+ && source.follower_count !== undefined
378
+ && JSON.stringify(camelCount) !== JSON.stringify(snakeCount)
379
+ ) followers = unavailableSubstackProfileMetric("provider-drift");
380
+ const displayName = optionalString(
381
+ source.name,
382
+ "Substack public-profile response.name",
383
+ 512,
384
+ );
385
+ const bio = optionalString(
386
+ source.bio,
387
+ "Substack public-profile response.bio",
388
+ 16_384,
389
+ );
390
+ const websiteUrl = exactHttpsUrl(
391
+ source.websiteUrl ?? source.website_url,
392
+ "Substack public-profile response.websiteUrl",
393
+ 2_048,
394
+ );
395
+ return Object.freeze({
396
+ schemaVersion: 1,
397
+ provider: "substack",
398
+ target: Object.freeze({
399
+ kind: "profile",
400
+ id,
401
+ url: `https://substack.com/@${handle}`,
402
+ }),
403
+ observedAt: exactSubstackProfileObservedAt(observedAt),
404
+ completeness: followers.status === "available" ? "complete" : "partial",
405
+ metrics: Object.freeze({ followers }),
406
+ metadata: Object.freeze({
407
+ handle,
408
+ ...(displayName === null ? {} : { displayName }),
409
+ ...(bio === null ? {} : { bio }),
410
+ ...(websiteUrl === null ? {} : { websiteUrl }),
411
+ }),
412
+ });
413
+ }
414
+
415
+ function unavailableDerivedSubstackMetric(
416
+ values: readonly SubstackProfileMetric[],
417
+ ): SubstackProfileMetric {
418
+ if (values.some((metric) =>
419
+ metric.status === "unavailable" && metric.reason === "provider-drift")) {
420
+ return unavailableSubstackProfileMetric("provider-drift");
421
+ }
422
+ if (values.some((metric) =>
423
+ metric.status === "unavailable" && metric.reason === "not-authorized")) {
424
+ return unavailableSubstackProfileMetric("not-authorized");
425
+ }
426
+ return unavailableSubstackProfileMetric("not-exposed");
427
+ }
428
+
429
+ /** Project exact free and paid counts from one owned publication summary. */
430
+ export function normalizeSubstackPublicationStatsResponse(
431
+ value: unknown,
432
+ expectedPublication: Readonly<{
433
+ id: number;
434
+ organization: string;
435
+ origin: string;
436
+ }>,
437
+ observedAt: string,
438
+ ): Readonly<Record<string, unknown>> {
439
+ const source = record(value, "Substack publication summary response");
440
+ const organization = canonicalSubstackProfileHandle(
441
+ expectedPublication.organization,
442
+ "Substack publication organization",
443
+ );
444
+ const expectedOrigin = `https://${organization}.substack.com`;
445
+ if (exactOrigin(expectedPublication.origin, "Substack publication origin") !== expectedOrigin) {
446
+ throw new Error("Substack publication origin did not bind the requested organization");
447
+ }
448
+ const total = exactSubstackProfileMetric(source.totalEmail);
449
+ const paidSubscribers = exactSubstackProfileMetric(source.subscribers);
450
+ const freeSubscribers = total.status === "available" && paidSubscribers.status === "available"
451
+ ? paidSubscribers.value <= total.value
452
+ ? exactSubstackProfileMetric(total.value - paidSubscribers.value)
453
+ : unavailableSubstackProfileMetric("provider-drift")
454
+ : unavailableDerivedSubstackMetric([total, paidSubscribers]);
455
+ const complete = freeSubscribers.status === "available"
456
+ && paidSubscribers.status === "available";
457
+ return Object.freeze({
458
+ schemaVersion: 1,
459
+ provider: "substack",
460
+ target: Object.freeze({
461
+ kind: "publication",
462
+ id: exactSubstackProfileId(
463
+ expectedPublication.id,
464
+ "Substack publication ID",
465
+ ),
466
+ url: `${expectedOrigin}/`,
467
+ }),
468
+ observedAt: exactSubstackProfileObservedAt(observedAt),
469
+ completeness: complete ? "complete" : "partial",
470
+ metrics: Object.freeze({ freeSubscribers, paidSubscribers }),
471
+ metadata: Object.freeze({ handle: organization }),
472
+ });
473
+ }
474
+
475
+ export type SubstackInlineAudioEmbed = Readonly<{
476
+ uploadId: string;
477
+ url: string;
478
+ }>;
479
+
480
+ function isHtmlWhitespace(value: string | undefined): boolean {
481
+ return value === " "
482
+ || value === "\t"
483
+ || value === "\n"
484
+ || value === "\f"
485
+ || value === "\r";
486
+ }
487
+
488
+ function asciiCaseEqualAt(
489
+ value: string,
490
+ offset: number,
491
+ expected: string,
492
+ ): boolean {
493
+ if (offset < 0 || offset + expected.length > value.length) return false;
494
+ for (let index = 0; index < expected.length; index += 1) {
495
+ const actual = value.charCodeAt(offset + index);
496
+ const folded = actual >= 0x41 && actual <= 0x5a ? actual + 0x20 : actual;
497
+ if (folded !== expected.charCodeAt(index)) return false;
498
+ }
499
+ return true;
500
+ }
501
+
502
+ function isTagBoundary(value: string | undefined): boolean {
503
+ return value === undefined || value === ">" || value === "/" || isHtmlWhitespace(value);
504
+ }
505
+
506
+ function inlineAudioTagEnd(bodyHtml: string, start: number): number {
507
+ let quote: "\"" | "'" | null = null;
508
+ const maximumEnd = Math.min(
509
+ bodyHtml.length,
510
+ start + MAX_INLINE_AUDIO_TAG_CODE_UNITS,
511
+ );
512
+ for (let cursor = start + 1; cursor < maximumEnd; cursor += 1) {
513
+ const character = bodyHtml[cursor];
514
+ if (quote !== null) {
515
+ if (character === quote) quote = null;
516
+ } else if (character === "\"" || character === "'") {
517
+ quote = character;
518
+ } else if (character === ">") {
519
+ return cursor;
520
+ }
521
+ }
522
+ throw new Error("Substack inline audio tag was malformed or exceeded its bound");
523
+ }
524
+
525
+ function inlineAudioSrcAttribute(tag: string): string | null {
526
+ let cursor = "<audio".length;
527
+ let source: string | null = null;
528
+ const end = tag.length - 1;
529
+ while (cursor < end) {
530
+ while (isHtmlWhitespace(tag[cursor])) cursor += 1;
531
+ if (cursor >= end || tag[cursor] === "/") break;
532
+
533
+ const nameStart = cursor;
534
+ while (
535
+ cursor < end
536
+ && !isHtmlWhitespace(tag[cursor])
537
+ && tag[cursor] !== "="
538
+ && tag[cursor] !== "/"
539
+ && tag[cursor] !== ">"
540
+ && tag[cursor] !== "\""
541
+ && tag[cursor] !== "'"
542
+ && tag[cursor] !== "<"
543
+ ) cursor += 1;
544
+ if (cursor === nameStart) {
545
+ throw new Error("Substack inline audio tag contained a malformed attribute");
546
+ }
547
+ const name = tag.slice(nameStart, cursor).toLowerCase();
548
+ while (isHtmlWhitespace(tag[cursor])) cursor += 1;
549
+ if (tag[cursor] !== "=") {
550
+ if (name === "src") {
551
+ throw new Error("Substack inline audio src attribute omitted its value");
552
+ }
553
+ continue;
554
+ }
555
+
556
+ cursor += 1;
557
+ while (isHtmlWhitespace(tag[cursor])) cursor += 1;
558
+ if (cursor >= end) {
559
+ throw new Error("Substack inline audio attribute omitted its value");
560
+ }
561
+ let attributeValue: string;
562
+ const quote = tag[cursor];
563
+ if (quote === "\"" || quote === "'") {
564
+ cursor += 1;
565
+ const valueStart = cursor;
566
+ while (cursor < end && tag[cursor] !== quote) cursor += 1;
567
+ if (cursor >= end) {
568
+ throw new Error("Substack inline audio attribute was unterminated");
569
+ }
570
+ attributeValue = tag.slice(valueStart, cursor);
571
+ cursor += 1;
572
+ } else {
573
+ const valueStart = cursor;
574
+ while (cursor < end && !isHtmlWhitespace(tag[cursor]) && tag[cursor] !== ">") {
575
+ if (tag[cursor] === "\"" || tag[cursor] === "'" || tag[cursor] === "<") {
576
+ throw new Error("Substack inline audio attribute was malformed");
577
+ }
578
+ cursor += 1;
579
+ }
580
+ attributeValue = tag.slice(valueStart, cursor);
581
+ }
582
+ if (name !== "src") continue;
583
+ if (source !== null) {
584
+ throw new Error("Substack inline audio tag contained repeated src attributes");
585
+ }
586
+ source = boundedString(
587
+ attributeValue,
588
+ "Substack inline audio src",
589
+ 2_048,
590
+ );
591
+ }
592
+ return source;
593
+ }
594
+
595
+ function normalizedInlineAudioEmbed(
596
+ source: string,
597
+ publicationBaseUrl: URL,
598
+ ): SubstackInlineAudioEmbed {
599
+ let path = source;
600
+ if (!source.startsWith("/")) {
601
+ let absolute: URL;
602
+ try {
603
+ absolute = new URL(source);
604
+ } catch {
605
+ throw new Error("Substack inline audio src must be an exact publication URL or path");
606
+ }
607
+ if (
608
+ absolute.protocol !== "https:"
609
+ || absolute.username !== ""
610
+ || absolute.password !== ""
611
+ || absolute.origin !== publicationBaseUrl.origin
612
+ || absolute.search !== ""
613
+ || absolute.hash !== ""
614
+ || absolute.href !== source
615
+ ) {
616
+ throw new Error("Substack inline audio src must use the exact publication origin");
617
+ }
618
+ path = absolute.pathname;
619
+ }
620
+ const match = INLINE_AUDIO_UPLOAD_PATH.exec(path);
621
+ if (match?.[1] === undefined) {
622
+ throw new Error("Substack inline audio src must use the exact audio upload route");
623
+ }
624
+ return Object.freeze({
625
+ uploadId: match[1],
626
+ url: new URL(path, publicationBaseUrl.origin).href,
627
+ });
628
+ }
629
+
630
+ /** Project only exact first-party inline audio upload elements in document order. */
631
+ export function parseSubstackInlineAudioEmbeds(
632
+ bodyHtml: unknown,
633
+ publicationBaseUrl: unknown,
634
+ ): readonly SubstackInlineAudioEmbed[] {
635
+ const html = boundedString(
636
+ bodyHtml,
637
+ "Substack inline audio body_html",
638
+ MAX_BODY_BYTES,
639
+ true,
640
+ );
641
+ let publication: URL | null = null;
642
+ const embeds: SubstackInlineAudioEmbed[] = [];
643
+ let cursor = 0;
644
+ while (cursor < html.length) {
645
+ const start = html.indexOf("<", cursor);
646
+ if (start < 0) break;
647
+ if (html.startsWith("<!--", start)) {
648
+ const commentEnd = html.indexOf("-->", start + 4);
649
+ if (commentEnd < 0) {
650
+ throw new Error("Substack inline audio body_html contained an unterminated comment");
651
+ }
652
+ cursor = commentEnd + 3;
653
+ continue;
654
+ }
655
+ if (
656
+ !asciiCaseEqualAt(html, start + 1, "audio")
657
+ || !isTagBoundary(html[start + 6])
658
+ ) {
659
+ cursor = start + 1;
660
+ continue;
661
+ }
662
+ const tagEnd = inlineAudioTagEnd(html, start);
663
+ const source = inlineAudioSrcAttribute(html.slice(start, tagEnd + 1));
664
+ cursor = tagEnd + 1;
665
+ if (source === null) continue;
666
+ if (embeds.length >= MAX_INLINE_AUDIO_EMBEDS) {
667
+ throw new Error(`Substack inline audio embeds exceeded ${String(MAX_INLINE_AUDIO_EMBEDS)} items`);
668
+ }
669
+ if (publication === null) {
670
+ const base = exactHttpsUrl(
671
+ publicationBaseUrl,
672
+ "Substack inline audio publication base_url",
673
+ 2_048,
674
+ );
675
+ if (base === null) {
676
+ throw new Error("Substack inline audio publication base_url is required");
677
+ }
678
+ publication = new URL(base);
679
+ }
680
+ embeds.push(normalizedInlineAudioEmbed(source, publication));
681
+ }
682
+ return Object.freeze(embeds);
683
+ }
684
+
685
+ function exactOrigin(value: unknown, label: string): string {
686
+ const candidate = boundedString(value, label, 2_048);
687
+ let parsed: URL;
688
+ try {
689
+ parsed = new URL(candidate);
690
+ } catch {
691
+ throw new Error(`${label} must be an absolute URL`);
692
+ }
693
+ if (
694
+ parsed.protocol !== "https:"
695
+ || parsed.username !== ""
696
+ || parsed.password !== ""
697
+ || parsed.pathname !== "/"
698
+ || parsed.search !== ""
699
+ || parsed.hash !== ""
700
+ ) throw new Error(`${label} must be an exact credential-free HTTPS origin`);
701
+ return parsed.origin;
702
+ }
703
+
704
+ function exactSubstackUrl(value: string | URL, label: string): URL {
705
+ let parsed: URL;
706
+ try {
707
+ parsed = value instanceof URL ? new URL(value.href) : new URL(value);
708
+ } catch {
709
+ throw new Error(`${label} must be an absolute URL`);
710
+ }
711
+ if (
712
+ parsed.origin !== SUBSTACK_ORIGIN
713
+ || parsed.username !== ""
714
+ || parsed.password !== ""
715
+ || parsed.hash !== ""
716
+ ) throw new Error(`${label} must use the exact ${SUBSTACK_ORIGIN} origin`);
717
+ return parsed;
718
+ }
719
+
720
+ function exactSubstackPublicationUrl(
721
+ value: string | URL,
722
+ organizationValue: unknown,
723
+ publicationOriginValue: unknown,
724
+ ): URL {
725
+ const organization = canonicalSubstackProfileHandle(
726
+ organizationValue,
727
+ "Substack publication organization",
728
+ );
729
+ const publicationOrigin = exactOrigin(
730
+ publicationOriginValue,
731
+ "Substack publication origin",
732
+ );
733
+ if (publicationOrigin !== `https://${organization}.substack.com`) {
734
+ throw new Error("Substack publication origin did not bind the requested organization");
735
+ }
736
+ let parsed: URL;
737
+ try {
738
+ parsed = value instanceof URL ? new URL(value.href) : new URL(value);
739
+ } catch {
740
+ throw new Error("Substack publication read URL must be an absolute URL");
741
+ }
742
+ if (
743
+ parsed.origin !== publicationOrigin
744
+ || parsed.username !== ""
745
+ || parsed.password !== ""
746
+ || parsed.hash !== ""
747
+ ) throw new Error("Substack publication read URL changed its exact owned origin");
748
+ return parsed;
749
+ }
750
+
751
+ function exactQuery(value: URLSearchParams, label: string): ReadonlyMap<string, string> {
752
+ const result = new Map<string, string>();
753
+ for (const [name, item] of value) {
754
+ if (
755
+ result.has(name)
756
+ || name.length < 1
757
+ || name.length > 64
758
+ || item.length > 4_096
759
+ || /[\0\r\n]/u.test(name + item)
760
+ ) throw new Error(`${label} contained an invalid or repeated parameter`);
761
+ result.set(name, item);
762
+ }
763
+ return result;
764
+ }
765
+
766
+ function exactQueryNames(
767
+ query: ReadonlyMap<string, string>,
768
+ required: readonly string[],
769
+ optional: readonly string[],
770
+ label: string,
771
+ ): void {
772
+ const allowed = new Set([...required, ...optional]);
773
+ const missing = required.filter((name) => !query.has(name));
774
+ const extra = [...query.keys()].filter((name) => !allowed.has(name));
775
+ if (missing.length > 0) throw new Error(`${label} omitted ${missing.join(", ")}`);
776
+ if (extra.length > 0) throw new Error(`${label} contained unsupported ${extra.join(", ")}`);
777
+ }
778
+
779
+ function decimalPathId(value: string, label: string): number {
780
+ if (!/^[1-9][0-9]{0,15}$/u.test(value)) throw new Error(`${label} must be a decimal ID`);
781
+ return integerId(Number(value), label);
782
+ }
783
+
784
+ function boundedCursor(value: string | undefined, label: string): void {
785
+ if (value === undefined) return;
786
+ boundedString(value, label, 4_096);
787
+ }
788
+
789
+ export type SubstackWebReadRequestOperation =
790
+ | "viewer.logged-in"
791
+ | "viewer.root"
792
+ | "feeds.reader"
793
+ | "feeds.inbox"
794
+ | "feeds.posts"
795
+ | "posts.note"
796
+ | "articles.read"
797
+ | "comments.read"
798
+ | "media.read"
799
+ | "messages.list"
800
+ | "profiles.read"
801
+ | "organizations.read";
802
+
803
+ export type SubstackWebReadRequestBinding = Readonly<{
804
+ operation: SubstackWebReadRequestOperation;
805
+ method: "GET";
806
+ path: string;
807
+ queryNames: readonly string[];
808
+ }>;
809
+
810
+ export function authorizeSubstackWebReadRequest(input: {
811
+ readonly operation: SubstackWebReadRequestOperation;
812
+ readonly url: string | URL;
813
+ readonly method: string;
814
+ readonly body?: unknown;
815
+ readonly targetId?: number;
816
+ readonly publicationId?: number;
817
+ readonly folder?: "all" | "people" | "unread";
818
+ readonly profile?: string;
819
+ readonly organization?: string;
820
+ readonly publicationOrigin?: string;
821
+ }): SubstackWebReadRequestBinding {
822
+ if (input.method.toUpperCase() !== "GET" || input.body !== undefined) {
823
+ throw new Error("Substack authenticated reads require body-free GET");
824
+ }
825
+ const url = input.operation === "organizations.read"
826
+ ? exactSubstackPublicationUrl(
827
+ input.url,
828
+ input.organization,
829
+ input.publicationOrigin,
830
+ )
831
+ : exactSubstackUrl(input.url, "Substack read URL");
832
+ const query = exactQuery(url.searchParams, "Substack read query");
833
+
834
+ switch (input.operation) {
835
+ case "viewer.logged-in":
836
+ if (url.pathname !== "/api/v1/am_i_logged_in" || query.size !== 0) {
837
+ throw new Error("Substack login-state request changed its reviewed exchange");
838
+ }
839
+ break;
840
+ case "viewer.root":
841
+ if (url.pathname !== "/" || query.size !== 0) {
842
+ throw new Error("Substack viewer bootstrap request changed its reviewed exchange");
843
+ }
844
+ break;
845
+ case "feeds.reader":
846
+ if (url.pathname !== "/api/v1/reader/feed" || query.size !== 0) {
847
+ throw new Error("Substack reader feed request changed its reviewed exchange");
848
+ }
849
+ break;
850
+ case "feeds.inbox":
851
+ if (url.pathname !== "/api/v1/inbox/top" || query.size !== 0) {
852
+ throw new Error("Substack inbox feed request changed its reviewed exchange");
853
+ }
854
+ break;
855
+ case "feeds.posts":
856
+ if (url.pathname !== "/api/v1/reader/posts" || query.size !== 0) {
857
+ throw new Error("Substack reader posts request changed its reviewed exchange");
858
+ }
859
+ break;
860
+ case "posts.note": {
861
+ const match = /^\/api\/v1\/reader\/comment\/([1-9][0-9]{0,15})$/u.exec(url.pathname);
862
+ const target = integerId(input.targetId, "Substack requested Note ID");
863
+ if (
864
+ match === null
865
+ || decimalPathId(match[1]!, "Substack Note path ID") !== target
866
+ || query.size !== 0
867
+ ) throw new Error("Substack Note request did not bind the requested entity");
868
+ break;
869
+ }
870
+ case "articles.read":
871
+ case "media.read": {
872
+ const match = /^\/api\/v1\/posts\/by-id\/([1-9][0-9]{0,15})$/u.exec(url.pathname);
873
+ const target = integerId(input.targetId, "Substack requested article ID");
874
+ if (
875
+ match === null
876
+ || decimalPathId(match[1]!, "Substack article path ID") !== target
877
+ || query.size !== 0
878
+ ) throw new Error("Substack article request did not bind the requested entity");
879
+ break;
880
+ }
881
+ case "comments.read": {
882
+ const match = /^\/api\/v1\/reader\/post\/([1-9][0-9]{0,15})\/replies$/u.exec(url.pathname);
883
+ const target = integerId(input.targetId, "Substack requested article ID");
884
+ const publication = integerId(input.publicationId, "Substack requested publication ID");
885
+ if (
886
+ match === null
887
+ || decimalPathId(match[1]!, "Substack replies path ID") !== target
888
+ ) throw new Error("Substack replies request did not bind the requested article");
889
+ exactQueryNames(query, ["publication_id"], ["cursor"], "Substack replies query");
890
+ if (query.get("publication_id") !== String(publication)) {
891
+ throw new Error("Substack replies request did not bind the requested publication");
892
+ }
893
+ boundedCursor(query.get("cursor"), "Substack replies cursor");
894
+ break;
895
+ }
896
+ case "messages.list": {
897
+ if (url.pathname !== "/api/v1/messages/inbox") {
898
+ throw new Error("Substack message listing path is not reviewed");
899
+ }
900
+ exactQueryNames(query, ["tab"], ["cursor"], "Substack message listing query");
901
+ const folder = input.folder;
902
+ if (
903
+ folder === undefined
904
+ || !["all", "people", "unread"].includes(folder)
905
+ || query.get("tab") !== folder
906
+ ) throw new Error("Substack message listing did not bind the requested folder");
907
+ boundedCursor(query.get("cursor"), "Substack message cursor");
908
+ break;
909
+ }
910
+ case "profiles.read": {
911
+ const profile = canonicalSubstackProfileHandle(
912
+ input.profile,
913
+ "Substack requested profile",
914
+ );
915
+ if (
916
+ url.pathname !== `/api/v1/user/${profile}/public_profile`
917
+ || query.size !== 0
918
+ ) throw new Error("Substack profile request did not bind the requested handle");
919
+ break;
920
+ }
921
+ case "organizations.read":
922
+ if (
923
+ url.pathname !== "/api/v1/publish-dashboard/summary"
924
+ || query.size !== 0
925
+ ) throw new Error("Substack publication summary request changed its reviewed exchange");
926
+ break;
927
+ }
928
+
929
+ return Object.freeze({
930
+ operation: input.operation,
931
+ method: "GET",
932
+ path: url.pathname,
933
+ queryNames: Object.freeze([...query.keys()].sort()),
934
+ });
935
+ }
936
+
937
+ export type SubstackWebViewer = Readonly<{
938
+ id: number;
939
+ handle: string | null;
940
+ name: string | null;
941
+ publications: readonly Readonly<{
942
+ id: number;
943
+ origin: string;
944
+ primaryUserId: number | null;
945
+ canPostNotesAsPrimaryUser: boolean;
946
+ isPublicationPrimaryUser: boolean;
947
+ }>[];
948
+ }>;
949
+
950
+ export class SubstackAuthRepairRequiredError extends Error {
951
+ constructor() {
952
+ super("Substack selected session is not signed in");
953
+ this.name = "SubstackAuthRepairRequiredError";
954
+ }
955
+ }
956
+
957
+ export function parseSubstackLoggedInResponse(value: unknown): void {
958
+ const source = record(value, "Substack login-state response");
959
+ if (source.loggedIn === false) throw new SubstackAuthRepairRequiredError();
960
+ if (source.loggedIn !== true) {
961
+ throw new Error("Substack login-state response did not expose a reviewed signed-in flag");
962
+ }
963
+ }
964
+
965
+ function parseJsonStringLiteral(html: string, start: number): {
966
+ readonly decoded: string;
967
+ readonly end: number;
968
+ } {
969
+ const quote = html[start];
970
+ if (quote !== "\"") throw new Error("Substack preload must use a JSON string literal");
971
+ let escaped = false;
972
+ for (let index = start + 1; index < html.length; index += 1) {
973
+ const character = html[index]!;
974
+ if (escaped) {
975
+ escaped = false;
976
+ continue;
977
+ }
978
+ if (character === "\\") {
979
+ escaped = true;
980
+ continue;
981
+ }
982
+ if (character !== quote) continue;
983
+ try {
984
+ const decoded = JSON.parse(html.slice(start, index + 1)) as unknown;
985
+ if (typeof decoded !== "string") throw new Error("not a string");
986
+ return { decoded, end: index + 1 };
987
+ } catch {
988
+ throw new Error("Substack preload contained malformed JSON string encoding");
989
+ }
990
+ }
991
+ throw new Error("Substack preload string was unterminated");
992
+ }
993
+
994
+ function publicationBinding(value: unknown, label: string): SubstackWebViewer["publications"][number] {
995
+ const source = record(value, label);
996
+ const baseUrl = source.base_url ?? source.baseUrl;
997
+ let origin: string;
998
+ if (baseUrl !== undefined && baseUrl !== null) {
999
+ origin = exactOrigin(baseUrl, `${label}.base_url`);
1000
+ } else {
1001
+ const subdomain = boundedString(source.subdomain, `${label}.subdomain`, 63);
1002
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(subdomain)) {
1003
+ throw new Error(`${label}.subdomain is not a reviewed Substack subdomain`);
1004
+ }
1005
+ origin = `https://${subdomain}.substack.com`;
1006
+ }
1007
+ return Object.freeze({
1008
+ id: integerId(source.id, `${label}.id`),
1009
+ origin,
1010
+ primaryUserId: optionalIntegerId(source.primary_user_id, `${label}.primary_user_id`),
1011
+ canPostNotesAsPrimaryUser: source.can_post_notes_as_primary_user === true,
1012
+ isPublicationPrimaryUser: source.is_publication_primary_user === true,
1013
+ });
1014
+ }
1015
+
1016
+ export function parseSubstackPreloadsHtml(html: unknown): SubstackWebViewer {
1017
+ const source = boundedString(html, "Substack viewer bootstrap", MAX_HTML_BYTES);
1018
+ const assignments = [...source.matchAll(/window\._preloads\s*=/gu)];
1019
+ if (assignments.length !== 1) {
1020
+ throw new Error("Substack viewer bootstrap must contain exactly one preload assignment");
1021
+ }
1022
+ const markerIndex = assignments[0]!.index;
1023
+ const marker = assignments[0]![0];
1024
+ const parseIndex = source.indexOf("JSON.parse(", markerIndex + marker.length);
1025
+ if (parseIndex < 0 || parseIndex - markerIndex > 128) {
1026
+ throw new Error("Substack viewer bootstrap omitted its strict preload JSON");
1027
+ }
1028
+ const literalStart = parseIndex + "JSON.parse(".length;
1029
+ const literal = parseJsonStringLiteral(source, literalStart);
1030
+ const close = source.slice(literal.end, literal.end + 8);
1031
+ if (!/^\s*\)/u.test(close)) throw new Error("Substack preload JSON call was malformed");
1032
+ let preloads: unknown;
1033
+ try {
1034
+ preloads = JSON.parse(literal.decoded) as unknown;
1035
+ } catch {
1036
+ throw new Error("Substack preload payload was not strict JSON");
1037
+ }
1038
+ const root = record(preloads, "Substack preloads");
1039
+ const user = record(root.user, "Substack preloads.user");
1040
+ const publications = boundedArray(
1041
+ user.dashboard_pubs ?? [],
1042
+ "Substack preloads.user.dashboard_pubs",
1043
+ 200,
1044
+ ).map((publication, index) => publicationBinding(
1045
+ publication,
1046
+ `Substack preloads.user.dashboard_pubs[${index}]`,
1047
+ ));
1048
+ return Object.freeze({
1049
+ id: integerId(user.id, "Substack preloads.user.id"),
1050
+ handle: optionalString(user.handle, "Substack preloads.user.handle", 128),
1051
+ name: optionalString(user.name, "Substack preloads.user.name", 512),
1052
+ publications: Object.freeze(publications),
1053
+ });
1054
+ }
1055
+
1056
+ function reactions(value: unknown, label: string): Readonly<Record<string, number>> {
1057
+ if (value === undefined || value === null) return Object.freeze({});
1058
+ const source = record(value, label);
1059
+ if (Object.keys(source).length > 64) throw new Error(`${label} exceeded its key bound`);
1060
+ const result: Record<string, number> = {};
1061
+ for (const [name, count] of Object.entries(source)) {
1062
+ const key = boundedString(name, `${label} key`, 32);
1063
+ if (!Number.isSafeInteger(count) || (count as number) < 0) {
1064
+ throw new Error(`${label}.${key} must be a non-negative integer`);
1065
+ }
1066
+ result[key] = count as number;
1067
+ }
1068
+ return Object.freeze(result);
1069
+ }
1070
+
1071
+ function projectedPublication(value: unknown, label: string): unknown {
1072
+ if (value === undefined || value === null) return null;
1073
+ const source = record(value, label);
1074
+ return Object.freeze({
1075
+ id: integerId(source.id, `${label}.id`),
1076
+ name: optionalString(source.name, `${label}.name`, 512),
1077
+ subdomain: optionalString(source.subdomain, `${label}.subdomain`, 256),
1078
+ hostname: optionalString(source.hostname, `${label}.hostname`, 512),
1079
+ baseUrl: exactHttpsUrl(source.base_url, `${label}.base_url`),
1080
+ authorId: optionalIntegerId(source.author_id, `${label}.author_id`),
1081
+ });
1082
+ }
1083
+
1084
+ function projectedPost(value: unknown, label: string, includeBody: boolean): unknown {
1085
+ const source = record(value, label);
1086
+ const bodyHtml = includeBody
1087
+ ? optionalString(source.body_html, `${label}.body_html`, MAX_BODY_BYTES, )
1088
+ : null;
1089
+ return Object.freeze({
1090
+ id: integerId(source.id, `${label}.id`),
1091
+ publicationId: integerId(source.publication_id, `${label}.publication_id`),
1092
+ title: optionalString(source.title, `${label}.title`, 4_096),
1093
+ subtitle: optionalString(source.subtitle, `${label}.subtitle`, 16_384),
1094
+ description: optionalString(source.description, `${label}.description`, 65_536),
1095
+ truncatedBodyText: optionalString(
1096
+ source.truncated_body_text,
1097
+ `${label}.truncated_body_text`,
1098
+ 262_144,
1099
+ ),
1100
+ bodyHtml,
1101
+ slug: optionalString(source.slug, `${label}.slug`, 1_024),
1102
+ type: optionalString(source.type, `${label}.type`, 128),
1103
+ audience: optionalString(source.audience, `${label}.audience`, 128),
1104
+ postDate: optionalString(source.post_date, `${label}.post_date`, 128),
1105
+ canonicalUrl: exactHttpsUrl(source.canonical_url, `${label}.canonical_url`),
1106
+ coverImage: exactHttpsUrl(source.cover_image, `${label}.cover_image`),
1107
+ podcastUrl: exactHttpsUrl(source.podcast_url, `${label}.podcast_url`),
1108
+ reaction: optionalBoolean(source.reaction, `${label}.reaction`),
1109
+ reactionCount: optionalFiniteNumber(source.reaction_count, `${label}.reaction_count`),
1110
+ reactions: reactions(source.reactions, `${label}.reactions`),
1111
+ commentCount: optionalFiniteNumber(source.comment_count, `${label}.comment_count`),
1112
+ childCommentCount: optionalFiniteNumber(
1113
+ source.child_comment_count,
1114
+ `${label}.child_comment_count`,
1115
+ ),
1116
+ restacks: optionalFiniteNumber(source.restacks, `${label}.restacks`),
1117
+ restacked: optionalBoolean(source.restacked, `${label}.restacked`),
1118
+ saved: optionalBoolean(source.is_saved, `${label}.is_saved`),
1119
+ published: optionalBoolean(source.is_published, `${label}.is_published`),
1120
+ });
1121
+ }
1122
+
1123
+ function projectedAttachment(value: unknown, label: string): unknown {
1124
+ const source = record(value, label);
1125
+ return Object.freeze({
1126
+ id: optionalString(
1127
+ typeof source.id === "number" ? String(source.id) : source.id,
1128
+ `${label}.id`,
1129
+ 256,
1130
+ ),
1131
+ type: optionalString(source.type, `${label}.type`, 128),
1132
+ url: exactHttpsUrl(source.url, `${label}.url`),
1133
+ imageUrl: exactHttpsUrl(source.imageUrl ?? source.image_url, `${label}.imageUrl`),
1134
+ videoUrl: exactHttpsUrl(source.videoUrl ?? source.video_url, `${label}.videoUrl`),
1135
+ audioUrl: exactHttpsUrl(source.audioUrl ?? source.audio_url, `${label}.audioUrl`),
1136
+ altText: optionalString(source.altText ?? source.alt_text, `${label}.altText`, 4_096),
1137
+ width: optionalFiniteNumber(source.width ?? source.imageWidth, `${label}.width`),
1138
+ height: optionalFiniteNumber(source.height ?? source.imageHeight, `${label}.height`),
1139
+ });
1140
+ }
1141
+
1142
+ function projectedComment(value: unknown, label: string): unknown {
1143
+ const source = record(value, label);
1144
+ const attachments = boundedArray(
1145
+ source.attachments ?? [],
1146
+ `${label}.attachments`,
1147
+ 20,
1148
+ ).map((attachment, index) => projectedAttachment(
1149
+ attachment,
1150
+ `${label}.attachments[${index}]`,
1151
+ ));
1152
+ return Object.freeze({
1153
+ id: integerId(source.id, `${label}.id`),
1154
+ userId: integerId(source.user_id, `${label}.user_id`),
1155
+ publicationId: optionalIntegerId(source.publication_id, `${label}.publication_id`),
1156
+ postId: optionalIntegerId(source.post_id, `${label}.post_id`),
1157
+ name: optionalString(source.name, `${label}.name`, 512),
1158
+ handle: optionalString(source.handle, `${label}.handle`, 128),
1159
+ body: boundedString(source.body, `${label}.body`, MAX_BODY_BYTES, true),
1160
+ type: optionalString(source.type, `${label}.type`, 128),
1161
+ date: optionalString(source.date ?? source.created_at, `${label}.date`, 128),
1162
+ editedAt: optionalString(source.edited_at, `${label}.edited_at`, 128),
1163
+ ancestorPath: optionalString(source.ancestor_path, `${label}.ancestor_path`, 4_096),
1164
+ reaction: optionalBoolean(source.reaction, `${label}.reaction`),
1165
+ reactionCount: optionalFiniteNumber(source.reaction_count, `${label}.reaction_count`),
1166
+ reactions: reactions(source.reactions, `${label}.reactions`),
1167
+ restacks: optionalFiniteNumber(source.restacks, `${label}.restacks`),
1168
+ restacked: optionalBoolean(source.restacked, `${label}.restacked`),
1169
+ saved: optionalBoolean(source.is_saved, `${label}.is_saved`),
1170
+ childrenCount: optionalFiniteNumber(source.children_count, `${label}.children_count`),
1171
+ attachments: Object.freeze(attachments),
1172
+ });
1173
+ }
1174
+
1175
+ export type SubstackFeedName = "notes" | "inbox" | "reader-posts";
1176
+
1177
+ export function normalizeSubstackFeedResponse(
1178
+ value: unknown,
1179
+ feed: SubstackFeedName,
1180
+ limit: number,
1181
+ ): unknown {
1182
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_ITEMS) {
1183
+ throw new Error("Substack feed limit is invalid");
1184
+ }
1185
+ const source = record(value, "Substack feed response");
1186
+ if (feed === "notes") {
1187
+ const items = boundedArray(source.items, "Substack reader feed items", 200)
1188
+ .slice(0, limit)
1189
+ .map((item, index) => {
1190
+ const entry = record(item, `Substack reader feed items[${index}]`);
1191
+ const comment = entry.comment === null || entry.comment === undefined
1192
+ ? null
1193
+ : projectedComment(entry.comment, `Substack reader feed items[${index}].comment`);
1194
+ const post = entry.post === null || entry.post === undefined
1195
+ ? null
1196
+ : projectedPost(entry.post, `Substack reader feed items[${index}].post`, false);
1197
+ if (comment === null && post === null) {
1198
+ throw new Error("Substack reader feed item omitted both Note and article entities");
1199
+ }
1200
+ return Object.freeze({
1201
+ entityKey: boundedString(entry.entity_key, `Substack reader feed items[${index}].entity_key`, 512),
1202
+ type: boundedString(entry.type, `Substack reader feed items[${index}].type`, 128),
1203
+ comment,
1204
+ post,
1205
+ publication: projectedPublication(
1206
+ entry.publication,
1207
+ `Substack reader feed items[${index}].publication`,
1208
+ ),
1209
+ canReply: optionalBoolean(entry.canReply, `Substack reader feed items[${index}].canReply`),
1210
+ });
1211
+ });
1212
+ return Object.freeze({
1213
+ feed,
1214
+ items: Object.freeze(items),
1215
+ nextCursor: optionalString(source.nextCursor, "Substack reader feed nextCursor", 4_096),
1216
+ });
1217
+ }
1218
+
1219
+ const posts = boundedArray(source.posts, "Substack article feed posts", 200)
1220
+ .slice(0, limit)
1221
+ .map((post, index) => projectedPost(
1222
+ post,
1223
+ `Substack article feed posts[${index}]`,
1224
+ false,
1225
+ ));
1226
+ return Object.freeze({
1227
+ feed,
1228
+ items: Object.freeze(posts),
1229
+ nextCursor: optionalString(
1230
+ source.cursor ?? source.nextCursor,
1231
+ "Substack article feed cursor",
1232
+ 4_096,
1233
+ ),
1234
+ more: optionalBoolean(source.more, "Substack article feed more"),
1235
+ });
1236
+ }
1237
+
1238
+ export function normalizeSubstackArticleResponse(
1239
+ value: unknown,
1240
+ articleId: number,
1241
+ ): unknown {
1242
+ const source = record(value, "Substack article response");
1243
+ const postSource = record(source.post, "Substack article response.post");
1244
+ if (integerId(postSource.id, "Substack article response.post.id") !== articleId) {
1245
+ throw new Error("Substack article response did not bind the requested article");
1246
+ }
1247
+ const post = projectedPost(postSource, "Substack article response.post", true);
1248
+ const publication = projectedPublication(
1249
+ source.publication,
1250
+ "Substack article response.publication",
1251
+ );
1252
+ if (
1253
+ isRecord(publication)
1254
+ && publication.id !== (post as { readonly publicationId: number }).publicationId
1255
+ ) throw new Error("Substack article response publication did not bind the article");
1256
+ return Object.freeze({ post, publication });
1257
+ }
1258
+
1259
+ export function normalizeSubstackNoteResponse(value: unknown, noteId: number): unknown {
1260
+ const source = record(value, "Substack Note response");
1261
+ const item = record(source.item, "Substack Note response.item");
1262
+ const commentSource = record(item.comment, "Substack Note response.item.comment");
1263
+ if (integerId(commentSource.id, "Substack Note response.item.comment.id") !== noteId) {
1264
+ throw new Error("Substack Note response did not bind the requested entity");
1265
+ }
1266
+ return Object.freeze({
1267
+ entityKey: optionalString(item.entity_key, "Substack Note response.item.entity_key", 512),
1268
+ type: optionalString(item.type, "Substack Note response.item.type", 128),
1269
+ comment: projectedComment(commentSource, "Substack Note response.item.comment"),
1270
+ post: item.post === null || item.post === undefined
1271
+ ? null
1272
+ : projectedPost(item.post, "Substack Note response.item.post", false),
1273
+ publication: projectedPublication(
1274
+ item.publication,
1275
+ "Substack Note response.item.publication",
1276
+ ),
1277
+ });
1278
+ }
1279
+
1280
+ export function normalizeSubstackCommentsResponse(
1281
+ value: unknown,
1282
+ postId: number,
1283
+ limit: number,
1284
+ ): unknown {
1285
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_ITEMS) {
1286
+ throw new Error("Substack comment limit is invalid");
1287
+ }
1288
+ const source = record(value, "Substack replies response");
1289
+ const branches = boundedArray(
1290
+ source.commentBranches,
1291
+ "Substack replies response.commentBranches",
1292
+ MAX_ITEMS,
1293
+ );
1294
+ const comments: unknown[] = [];
1295
+ for (let branchIndex = 0; branchIndex < branches.length; branchIndex += 1) {
1296
+ const branch = record(
1297
+ branches[branchIndex],
1298
+ `Substack replies response.commentBranches[${branchIndex}]`,
1299
+ );
1300
+ const root = record(
1301
+ branch.comment,
1302
+ `Substack replies response.commentBranches[${branchIndex}].comment`,
1303
+ );
1304
+ if (integerId(root.post_id, "Substack reply post_id") !== postId) {
1305
+ throw new Error("Substack replies response contained a comment for another article");
1306
+ }
1307
+ comments.push(projectedComment(
1308
+ root,
1309
+ `Substack replies response.commentBranches[${branchIndex}].comment`,
1310
+ ));
1311
+ for (const [descendantIndex, descendant] of boundedArray(
1312
+ branch.descendantComments ?? [],
1313
+ `Substack replies response.commentBranches[${branchIndex}].descendantComments`,
1314
+ MAX_ITEMS,
1315
+ ).entries()) {
1316
+ const wrapper = record(
1317
+ descendant,
1318
+ `Substack replies response.commentBranches[${branchIndex}].descendantComments[${descendantIndex}]`,
1319
+ );
1320
+ const comment = record(
1321
+ wrapper.comment,
1322
+ `Substack replies response.commentBranches[${branchIndex}].descendantComments[${descendantIndex}].comment`,
1323
+ );
1324
+ if (integerId(comment.post_id, "Substack descendant reply post_id") !== postId) {
1325
+ throw new Error("Substack replies response contained a descendant for another article");
1326
+ }
1327
+ comments.push(projectedComment(
1328
+ comment,
1329
+ `Substack replies response.commentBranches[${branchIndex}].descendantComments[${descendantIndex}].comment`,
1330
+ ));
1331
+ if (comments.length >= limit) break;
1332
+ }
1333
+ if (comments.length >= limit) break;
1334
+ }
1335
+ return Object.freeze({
1336
+ postId,
1337
+ comments: Object.freeze(comments.slice(0, limit)),
1338
+ nextCursor: optionalString(source.nextCursor, "Substack replies nextCursor", 4_096),
1339
+ moreBranches: optionalFiniteNumber(source.moreBranches, "Substack replies moreBranches"),
1340
+ });
1341
+ }
1342
+
1343
+ export function normalizeSubstackMediaResponse(value: unknown, articleId: number): unknown {
1344
+ const article = normalizeSubstackArticleResponse(value, articleId) as {
1345
+ readonly post: Readonly<{
1346
+ readonly bodyHtml: string | null;
1347
+ readonly coverImage: string | null;
1348
+ readonly podcastUrl: string | null;
1349
+ }>;
1350
+ readonly publication: Readonly<{ readonly baseUrl: string | null }> | null;
1351
+ };
1352
+ const source = record(record(value, "Substack media response").post, "Substack media response.post");
1353
+ const audioItems = boundedArray(source.audio_items ?? [], "Substack article audio_items", 20)
1354
+ .map((item, index) => {
1355
+ const audio = record(item, `Substack article audio_items[${index}]`);
1356
+ return Object.freeze({
1357
+ id: optionalString(
1358
+ typeof audio.id === "number" ? String(audio.id) : audio.id,
1359
+ `Substack article audio_items[${index}].id`,
1360
+ 256,
1361
+ ),
1362
+ url: exactHttpsUrl(
1363
+ audio.audio_url ?? audio.url,
1364
+ `Substack article audio_items[${index}].url`,
1365
+ ),
1366
+ duration: optionalFiniteNumber(
1367
+ audio.duration ?? audio.audio_duration,
1368
+ `Substack article audio_items[${index}].duration`,
1369
+ ),
1370
+ });
1371
+ });
1372
+ const inlineAudioEmbeds = article.post.bodyHtml === null
1373
+ ? Object.freeze([])
1374
+ : parseSubstackInlineAudioEmbeds(
1375
+ article.post.bodyHtml,
1376
+ article.publication?.baseUrl,
1377
+ );
1378
+ return Object.freeze({
1379
+ articleId,
1380
+ publication: article.publication,
1381
+ coverImage: article.post.coverImage ?? null,
1382
+ podcastUrl: article.post.podcastUrl ?? null,
1383
+ videoUploadId: optionalString(
1384
+ typeof source.video_upload_id === "number"
1385
+ ? String(source.video_upload_id)
1386
+ : source.video_upload_id,
1387
+ "Substack article video_upload_id",
1388
+ 256,
1389
+ ),
1390
+ audioItems: Object.freeze(audioItems),
1391
+ inlineAudioEmbeds,
1392
+ });
1393
+ }
1394
+
1395
+ function projectedMessageThread(value: unknown, label: string): unknown {
1396
+ const source = record(value, label);
1397
+ const rawId = typeof source.id === "number" ? String(source.id) : source.id;
1398
+ return Object.freeze({
1399
+ id: boundedString(rawId, `${label}.id`, 512),
1400
+ type: boundedString(source.type, `${label}.type`, 128),
1401
+ title: optionalString(source.title, `${label}.title`, 2_048),
1402
+ subtitle: optionalString(source.subtitleBody, `${label}.subtitleBody`, 16_384),
1403
+ timestamp: optionalString(source.timestamp, `${label}.timestamp`, 128),
1404
+ lastViewedAt: optionalString(source.lastViewedAt, `${label}.lastViewedAt`, 128),
1405
+ user: source.user === null || source.user === undefined
1406
+ ? null
1407
+ : (() => {
1408
+ const user = record(source.user, `${label}.user`);
1409
+ return Object.freeze({
1410
+ id: integerId(user.id, `${label}.user.id`),
1411
+ name: optionalString(user.name, `${label}.user.name`, 512),
1412
+ handle: optionalString(user.handle, `${label}.user.handle`, 128),
1413
+ });
1414
+ })(),
1415
+ publication: projectedPublication(source.publication, `${label}.publication`),
1416
+ });
1417
+ }
1418
+
1419
+ export function normalizeSubstackMessageInbox(
1420
+ value: unknown,
1421
+ folder: "all" | "people" | "unread",
1422
+ limit: number,
1423
+ ): unknown {
1424
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_ITEMS) {
1425
+ throw new Error("Substack message limit is invalid");
1426
+ }
1427
+ const source = record(value, "Substack message inbox response");
1428
+ const threads = boundedArray(source.threads, "Substack message inbox threads", 200)
1429
+ .slice(0, limit)
1430
+ .map((thread, index) => projectedMessageThread(
1431
+ thread,
1432
+ `Substack message inbox threads[${index}]`,
1433
+ ));
1434
+ return Object.freeze({
1435
+ folder,
1436
+ threads: Object.freeze(threads),
1437
+ nextCursor: optionalString(source.nextCursor, "Substack message nextCursor", 4_096),
1438
+ more: optionalBoolean(source.more, "Substack message more"),
1439
+ pendingInviteCount: optionalFiniteNumber(
1440
+ source.pendingInviteCount,
1441
+ "Substack pendingInviteCount",
1442
+ ),
1443
+ directMessagesUnreadCount: optionalFiniteNumber(
1444
+ source.directMessagesUnreadCount,
1445
+ "Substack directMessagesUnreadCount",
1446
+ ),
1447
+ pubChatUnreadCount: optionalFiniteNumber(
1448
+ source.pubChatUnreadCount,
1449
+ "Substack pubChatUnreadCount",
1450
+ ),
1451
+ });
1452
+ }
1453
+
1454
+ /**
1455
+ * Secret-free evidence about current exact first-party exchanges. Values,
1456
+ * cookies, user content, tokens, and publication hostnames are never retained.
1457
+ */
1458
+ export const substackWebEvidenceSnapshot = Object.freeze({
1459
+ schemaVersion: 1,
1460
+ observedOn: "2026-08-23",
1461
+ centralOrigin: SUBSTACK_ORIGIN,
1462
+ authentication: "browser-cookie-session" as const,
1463
+ liveDirectReads: Object.freeze([
1464
+ "GET /api/v1/am_i_logged_in",
1465
+ "GET /",
1466
+ "GET /api/v1/reader/feed",
1467
+ "GET /api/v1/inbox/top",
1468
+ "GET /api/v1/reader/posts",
1469
+ "GET /api/v1/reader/comment/{note-id}",
1470
+ "GET /api/v1/posts/by-id/{post-id}",
1471
+ "GET /api/v1/reader/post/{post-id}/replies?publication_id={publication-id}",
1472
+ "GET /api/v1/messages/inbox?tab={all|people|unread}",
1473
+ ]),
1474
+ liveDirectWrites: Object.freeze([
1475
+ "bodyless DELETE /api/v1/comment/{note-id} after exact current-viewer/body pre-read, accepted with 200 and independently absent with GET /api/v1/reader/comment/{note-id} returning 404",
1476
+ ]),
1477
+ currentBundleOnly: Object.freeze({
1478
+ dmRead: "GET /api/v1/messages/dm/{thread-id}",
1479
+ dmStart: "POST /api/v1/messages/dm/start",
1480
+ dmSend: "POST /api/v1/messages/dm/{thread-id}",
1481
+ postReaction: "POST|DELETE /api/v1/post/{post-id}/reaction",
1482
+ commentReaction: "POST|DELETE /api/v1/comment/{comment-id}/reaction",
1483
+ postSave: "POST|DELETE /api/v1/posts/saved",
1484
+ noteSave: "POST|DELETE /api/v1/note/{entity-key}/save",
1485
+ restack: "POST /api/v1/restack/feed",
1486
+ videoUploadInitialization:
1487
+ "request-shape candidate: POST /api/v1/video/upload?filetype={type}&fileSize={bytes}&fileName={name} with optional post_id and postAsUserId",
1488
+ videoMultipartTransfer:
1489
+ "request-shape candidate: ordered raw PUT byte slicing with credentials and form-data disabled; static code references Etag fields but proves no returned header",
1490
+ videoTranscode:
1491
+ "request-shape candidate: POST /api/v1/video/upload/{media-upload-id}/transcode with duration, multipart_upload_id, and ordered multipart_upload_etags",
1492
+ videoStatus:
1493
+ "request-shape candidate: GET /api/v1/video/upload/{media-upload-id}; bundle lifecycle vocabulary includes created/uploaded/transcoded/error/cancelled",
1494
+ noteDelete: "DELETE /api/v1/comment/{note-id} with an optional publication_id body field",
1495
+ }),
1496
+ unresolvedMutationBindings: Object.freeze([
1497
+ "video initialization acceptance and exact initialization/transcode response key sets",
1498
+ "provider-issued multipart upload hostname family, exact accepted PUT status, and returned ETag binding",
1499
+ "Note video attachment identifier and create-response attachment shape",
1500
+ "independent Note video readback media identity and URL fields",
1501
+ ]),
1502
+ });