@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,1611 @@
1
+ /**
2
+ * Reddit consumer-web API policy and bounded response normalization.
3
+ *
4
+ * This module owns exact request shapes. It has no network access and never
5
+ * exposes a raw endpoint, header, cookie, or modhash as a user-facing input.
6
+ */
7
+
8
+ import { REDDIT_FLAIR_OPERATION_NAMES, redditFlairContracts } from "../plugins/reddit-web/flair";
9
+
10
+ export const REDDIT_WEB_OPERATION_NAMES = Object.freeze([
11
+ ...REDDIT_FLAIR_OPERATION_NAMES,
12
+ "comments.create",
13
+ "comments.read",
14
+ "communities.membership.set",
15
+ "content.delete",
16
+ "content.edit",
17
+ "content.save",
18
+ "feeds.read",
19
+ "media.publish",
20
+ "media.read",
21
+ "messaging.list",
22
+ "messaging.read",
23
+ "messaging.send",
24
+ "posts.publish",
25
+ "profiles.read",
26
+ "posts.read",
27
+ "posts.repost",
28
+ "reactions.set",
29
+ "relationships.follow.set",
30
+ "replies.create",
31
+ ] as const);
32
+
33
+ export type RedditWebOperationName = (typeof REDDIT_WEB_OPERATION_NAMES)[number];
34
+ export type RedditWebContractState = "observed" | "capture-required";
35
+ export type RedditWebRisk = "R1" | "R2" | "R3";
36
+
37
+ export type RedditWebOperationContract = {
38
+ readonly effect: "read" | "write";
39
+ readonly risk: RedditWebRisk;
40
+ readonly state: RedditWebContractState;
41
+ readonly reason: string;
42
+ };
43
+
44
+ const observed = (
45
+ effect: "read" | "write",
46
+ risk: RedditWebRisk,
47
+ reason: string,
48
+ ): RedditWebOperationContract => Object.freeze({
49
+ effect,
50
+ risk,
51
+ state: "observed",
52
+ reason,
53
+ });
54
+
55
+ const captureRequired = (
56
+ effect: "read" | "write",
57
+ risk: RedditWebRisk,
58
+ reason: string,
59
+ ): RedditWebOperationContract => Object.freeze({
60
+ effect,
61
+ risk,
62
+ state: "capture-required",
63
+ reason,
64
+ });
65
+
66
+ export const REDDIT_WEB_OPERATIONS = Object.freeze({
67
+ ...Object.fromEntries(redditFlairContracts.map((contract) => [
68
+ contract.operation,
69
+ (contract.state === "observed" ? observed : captureRequired)(
70
+ contract.risk === "R1" ? "read" : "write",
71
+ contract.risk as RedditWebRisk,
72
+ contract.implementation,
73
+ ),
74
+ ])) as Record<(typeof REDDIT_FLAIR_OPERATION_NAMES)[number], RedditWebOperationContract>,
75
+ "profiles.read": observed(
76
+ "read",
77
+ "R1",
78
+ "viewer-bound profile about JSON plus complete visible overview Listing pagination",
79
+ ),
80
+ "feeds.read": observed(
81
+ "read",
82
+ "R1",
83
+ "signed-in home Listing JSON with explicit raw_json and bounded pagination",
84
+ ),
85
+ "posts.read": observed(
86
+ "read",
87
+ "R1",
88
+ "target-bound comments Listing JSON root projection",
89
+ ),
90
+ "comments.read": observed(
91
+ "read",
92
+ "R1",
93
+ "target-bound comments Listing JSON tree projection",
94
+ ),
95
+ "messaging.list": observed(
96
+ "read",
97
+ "R1",
98
+ "legacy inbox, unread, and sent Listing JSON with mark=false",
99
+ ),
100
+ "messaging.read": observed(
101
+ "read",
102
+ "R1",
103
+ "legacy message Listing JSON filtered by exact mid with mark=false",
104
+ ),
105
+ "reactions.set": captureRequired(
106
+ "write",
107
+ "R2",
108
+ "the exact /api/vote desired-state implementation has deterministic readback tests, but still requires an authorized low-stakes live fixture",
109
+ ),
110
+ "content.save": captureRequired(
111
+ "write",
112
+ "R2",
113
+ "the exact /api/save and /api/unsave desired-state implementation has deterministic readback tests, but still requires an authorized low-stakes live fixture",
114
+ ),
115
+ "communities.membership.set": captureRequired(
116
+ "write",
117
+ "R2",
118
+ "subscribe and unsubscribe form variants and subreddit identity readback need a reviewed fixture",
119
+ ),
120
+ "relationships.follow.set": captureRequired(
121
+ "write",
122
+ "R2",
123
+ "user and post-follow relationships are distinct contracts and need reviewed fixtures",
124
+ ),
125
+ "media.read": observed(
126
+ "read",
127
+ "R1",
128
+ "current-account-bound exact /api/info hosted-video readback with a closed metadata-only projection that omits playback URLs",
129
+ ),
130
+ "media.publish": observed(
131
+ "write",
132
+ "R3",
133
+ "captured old-Reddit cookie-authenticated leases, exact S3 transfers, explicit declarations, response websocket target binding, and independent hosted-video readback",
134
+ ),
135
+ "content.delete": observed(
136
+ "write",
137
+ "R3",
138
+ "exact authored-post pre-read, /api/del dispatch, and independent exact-target absence readback",
139
+ ),
140
+ "comments.create": captureRequired(
141
+ "write",
142
+ "R3",
143
+ "comment publication needs an authorized fixture and exact actor/root response binding",
144
+ ),
145
+ "replies.create": captureRequired(
146
+ "write",
147
+ "R3",
148
+ "comment or legacy-message reply needs an authorized fixture and exact parent binding",
149
+ ),
150
+ "content.edit": captureRequired(
151
+ "write",
152
+ "R3",
153
+ "edit response and independent authored-content readback need an authorized fixture",
154
+ ),
155
+ "messaging.send": captureRequired(
156
+ "write",
157
+ "R3",
158
+ "legacy compose and Reddit Chat are separate transports; neither may be inferred from the other",
159
+ ),
160
+ "posts.publish": captureRequired(
161
+ "write",
162
+ "R3",
163
+ "self/link submission variants and audience fields need an authorized fixture",
164
+ ),
165
+ "posts.repost": captureRequired(
166
+ "write",
167
+ "R3",
168
+ "crosspost submission needs exact source, destination, response, and readback binding",
169
+ ),
170
+ } as const satisfies Readonly<Record<RedditWebOperationName, RedditWebOperationContract>>);
171
+
172
+ type JsonRecord = Record<string, unknown>;
173
+
174
+ function isRecord(value: unknown): value is JsonRecord {
175
+ return typeof value === "object" && value !== null && !Array.isArray(value);
176
+ }
177
+
178
+ function record(value: unknown, label: string): JsonRecord {
179
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
180
+ return value;
181
+ }
182
+
183
+ function exactObjectKeys(
184
+ value: JsonRecord,
185
+ required: readonly string[],
186
+ optional: readonly string[],
187
+ label: string,
188
+ ): void {
189
+ const allowed = new Set([...required, ...optional]);
190
+ const keys = Object.keys(value);
191
+ if (
192
+ required.some((key) => !Object.hasOwn(value, key))
193
+ || keys.some((key) => !allowed.has(key))
194
+ ) throw new Error(`${label} changed its reviewed fields`);
195
+ }
196
+
197
+ function boundedString(
198
+ value: unknown,
199
+ label: string,
200
+ maximum: number,
201
+ allowEmpty = false,
202
+ ): string {
203
+ if (
204
+ typeof value !== "string"
205
+ || (!allowEmpty && value.length < 1)
206
+ || value.length > maximum
207
+ || /[\0\r]/u.test(value)
208
+ ) throw new Error(`${label} must be bounded text`);
209
+ return value;
210
+ }
211
+
212
+ function optionalString(value: unknown, label: string, maximum: number): string | null {
213
+ if (value === undefined || value === null || value === "") return null;
214
+ return boundedString(value, label, maximum);
215
+ }
216
+
217
+ function finiteNumber(value: unknown, label: string): number | null {
218
+ if (value === undefined || value === null) return null;
219
+ if (typeof value !== "number" || !Number.isFinite(value)) {
220
+ throw new Error(`${label} must be a finite number`);
221
+ }
222
+ return value;
223
+ }
224
+
225
+ function safeInteger(
226
+ value: unknown,
227
+ label: string,
228
+ minimum: number,
229
+ maximum: number,
230
+ ): number {
231
+ if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
232
+ throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`);
233
+ }
234
+ return value as number;
235
+ }
236
+
237
+ function nullableBoolean(value: unknown, label: string): boolean | null {
238
+ if (value === null || value === undefined) return null;
239
+ if (typeof value !== "boolean") throw new Error(`${label} must be boolean or null`);
240
+ return value;
241
+ }
242
+
243
+ function boolean(value: unknown, label: string): boolean {
244
+ if (typeof value !== "boolean") throw new Error(`${label} must be boolean`);
245
+ return value;
246
+ }
247
+
248
+ export function redditFullname(
249
+ value: unknown,
250
+ label: string,
251
+ allowedKinds: readonly ("t1" | "t2" | "t3" | "t4" | "t5" | "t6")[] = [
252
+ "t1",
253
+ "t2",
254
+ "t3",
255
+ "t4",
256
+ "t5",
257
+ "t6",
258
+ ],
259
+ ): string {
260
+ const result = boundedString(value, label, 40);
261
+ const match = /^(t[1-6])_([a-z0-9]{1,32})$/u.exec(result);
262
+ if (match === null || !allowedKinds.includes(match[1] as (typeof allowedKinds)[number])) {
263
+ throw new Error(`${label} must be a reviewed Reddit fullname`);
264
+ }
265
+ return result;
266
+ }
267
+
268
+ export function redditPostId(value: unknown, label = "Reddit post ID"): string {
269
+ return redditFullname(value, label, ["t3"]);
270
+ }
271
+
272
+ export function redditBarePostId(value: unknown, label = "Reddit post ID"): string {
273
+ return redditPostId(value, label).slice(3);
274
+ }
275
+
276
+ export function redditCommunity(value: unknown, label = "Reddit community"): string {
277
+ const community = boundedString(value, label, 21);
278
+ if (!/^[A-Za-z0-9_]{2,21}$/u.test(community)) {
279
+ throw new Error(`${label} must be an exact subreddit name`);
280
+ }
281
+ return community;
282
+ }
283
+
284
+ function exactUrl(
285
+ value: string | URL,
286
+ label: string,
287
+ expectedOrigin = "https://www.reddit.com",
288
+ ): URL {
289
+ let url: URL;
290
+ try {
291
+ url = value instanceof URL ? new URL(value.href) : new URL(value);
292
+ } catch {
293
+ throw new Error(`${label} must be an absolute URL`);
294
+ }
295
+ if (
296
+ url.origin !== expectedOrigin
297
+ || url.username !== ""
298
+ || url.password !== ""
299
+ || url.hash !== ""
300
+ ) throw new Error(`${label} must use the exact ${expectedOrigin} origin`);
301
+ return url;
302
+ }
303
+
304
+ function exactRedditUploadUrl(
305
+ value: unknown,
306
+ hostname: "reddit-uploaded-video.s3-accelerate.amazonaws.com"
307
+ | "reddit-uploaded-media.s3-accelerate.amazonaws.com",
308
+ label: string,
309
+ ): URL {
310
+ const text = boundedString(value, label, 4_096);
311
+ let url: URL;
312
+ try {
313
+ url = new URL(text);
314
+ } catch {
315
+ throw new Error(`${label} must be an absolute HTTPS URL`);
316
+ }
317
+ if (
318
+ url.protocol !== "https:"
319
+ || url.hostname !== hostname
320
+ || url.port !== ""
321
+ || url.username !== ""
322
+ || url.password !== ""
323
+ || url.search !== ""
324
+ || url.hash !== ""
325
+ || !/^\/[A-Za-z0-9][A-Za-z0-9/_.-]{0,2047}$/u.test(url.pathname)
326
+ || url.pathname.includes("..")
327
+ ) throw new Error(`${label} escaped its exact reviewed Reddit upload host`);
328
+ return url;
329
+ }
330
+
331
+ function exactParameters(value: URLSearchParams, label: string): ReadonlyMap<string, string> {
332
+ const result = new Map<string, string>();
333
+ for (const [name, item] of value) {
334
+ if (result.has(name)) throw new Error(`${label} repeated ${name}`);
335
+ if (name.length < 1 || name.length > 64 || /[\0\r\n]/u.test(name + item)) {
336
+ throw new Error(`${label} contained an invalid parameter`);
337
+ }
338
+ result.set(name, item);
339
+ }
340
+ return result;
341
+ }
342
+
343
+ function exactNames(
344
+ values: ReadonlyMap<string, string>,
345
+ required: readonly string[],
346
+ optional: readonly string[],
347
+ label: string,
348
+ ): void {
349
+ const allowed = new Set([...required, ...optional]);
350
+ const missing = required.filter((name) => !values.has(name));
351
+ const extra = [...values.keys()].filter((name) => !allowed.has(name));
352
+ if (missing.length > 0) throw new Error(`${label} omitted ${missing.join(", ")}`);
353
+ if (extra.length > 0) throw new Error(`${label} contained unsupported ${extra.join(", ")}`);
354
+ }
355
+
356
+ function decimalParameter(
357
+ values: ReadonlyMap<string, string>,
358
+ name: string,
359
+ minimum: number,
360
+ maximum: number,
361
+ label: string,
362
+ ): number {
363
+ const value = values.get(name);
364
+ if (value === undefined || !/^(?:0|[1-9][0-9]{0,15})$/u.test(value)) {
365
+ throw new Error(`${label}.${name} must be a decimal integer`);
366
+ }
367
+ return safeInteger(Number(value), `${label}.${name}`, minimum, maximum);
368
+ }
369
+
370
+ function optionalAfter(values: ReadonlyMap<string, string>, label: string): void {
371
+ const after = values.get("after");
372
+ if (after !== undefined) redditFullname(after, `${label}.after`, ["t1", "t3", "t4"]);
373
+ }
374
+
375
+ function requireFixed(
376
+ values: ReadonlyMap<string, string>,
377
+ name: string,
378
+ expected: string,
379
+ label: string,
380
+ ): void {
381
+ if (values.get(name) !== expected) throw new Error(`${label}.${name} changed its reviewed value`);
382
+ }
383
+
384
+ export type RedditWebRequestOperation =
385
+ | "viewer.current"
386
+ | "profiles.about"
387
+ | "profiles.overview"
388
+ | "feeds.home"
389
+ | "posts.read"
390
+ | "comments.read"
391
+ | "messages.list"
392
+ | "messages.read"
393
+ | "media.read"
394
+ | "state.readback"
395
+ | "flair.user.choices"
396
+ | "flair.post.choices"
397
+ | "media.lease"
398
+ | "media.publish"
399
+ | "reactions.set"
400
+ | "content.save"
401
+ | "content.delete";
402
+
403
+ export type RedditWebRequestInput = {
404
+ readonly operation: RedditWebRequestOperation;
405
+ readonly url: string | URL;
406
+ readonly method: string;
407
+ readonly body?: string;
408
+ readonly targetId?: string;
409
+ readonly direction?: -1 | 0 | 1;
410
+ readonly saved?: boolean;
411
+ readonly folder?: "inbox" | "unread" | "sent";
412
+ readonly mediaType?: "video/mp4" | "image/jpeg" | "image/png";
413
+ readonly filename?: string;
414
+ readonly community?: string;
415
+ readonly title?: string;
416
+ readonly text?: string;
417
+ readonly nsfw?: boolean;
418
+ readonly spoiler?: boolean;
419
+ readonly sendReplies?: boolean;
420
+ readonly mediaUrl?: string;
421
+ readonly posterUrl?: string;
422
+ readonly profile?: string;
423
+ readonly username?: string;
424
+ };
425
+
426
+ export type RedditWebRequestBinding = {
427
+ readonly operation: RedditWebRequestOperation;
428
+ readonly method: "GET" | "POST";
429
+ readonly path: string;
430
+ readonly queryNames: readonly string[];
431
+ readonly formNames: readonly string[];
432
+ };
433
+
434
+ /**
435
+ * Validate one complete Reddit request without returning form values. In
436
+ * particular, the ephemeral `uh` modhash is accepted only in the request body
437
+ * and is absent from the returned binding.
438
+ */
439
+ export function authorizeRedditWebRequest(
440
+ input: RedditWebRequestInput,
441
+ ): RedditWebRequestBinding {
442
+ const url = exactUrl(
443
+ input.url,
444
+ "Reddit request URL",
445
+ input.operation === "media.lease" || input.operation.startsWith("flair.")
446
+ ? "https://old.reddit.com"
447
+ : "https://www.reddit.com",
448
+ );
449
+ const method = input.method.toUpperCase();
450
+ const query = exactParameters(url.searchParams, "Reddit request query");
451
+ let form = new Map<string, string>();
452
+ if (input.body !== undefined) {
453
+ if (method !== "POST") throw new Error("Reddit request body requires POST");
454
+ form = new Map(exactParameters(new URLSearchParams(input.body), "Reddit request form"));
455
+ }
456
+ if (method !== "GET" && method !== "POST") throw new Error("Reddit request method is not reviewed");
457
+
458
+ const finish = (): RedditWebRequestBinding => Object.freeze({
459
+ operation: input.operation,
460
+ method,
461
+ path: url.pathname,
462
+ queryNames: Object.freeze([...query.keys()].sort()),
463
+ formNames: Object.freeze([...form.keys()].sort()),
464
+ });
465
+
466
+ if (input.operation === "viewer.current") {
467
+ if (method !== "GET" || url.pathname !== "/api/me.json" || query.size !== 0 || form.size !== 0) {
468
+ throw new Error("Reddit viewer request changed its reviewed exchange");
469
+ }
470
+ return finish();
471
+ }
472
+
473
+ if (input.operation === "profiles.about" || input.operation === "profiles.overview") {
474
+ if (method !== "GET" || form.size !== 0 || input.profile === undefined) {
475
+ throw new Error("Reddit profile request changed its reviewed exchange");
476
+ }
477
+ const profile = boundedString(input.profile, "Reddit profile handle", 64);
478
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(profile)) {
479
+ throw new Error("Reddit profile handle is invalid");
480
+ }
481
+ const expectedPath = input.operation === "profiles.about"
482
+ ? `/user/${encodeURIComponent(profile)}/about.json`
483
+ : `/user/${encodeURIComponent(profile)}/overview.json`;
484
+ if (url.pathname !== expectedPath) {
485
+ throw new Error("Reddit profile path did not bind the requested handle");
486
+ }
487
+ if (input.operation === "profiles.about") {
488
+ exactNames(query, ["raw_json"], [], "Reddit profile about query");
489
+ } else {
490
+ exactNames(query, ["limit", "raw_json"], ["after"], "Reddit profile overview query");
491
+ requireFixed(query, "limit", "100", "Reddit profile overview query");
492
+ optionalAfter(query, "Reddit profile overview query");
493
+ }
494
+ requireFixed(query, "raw_json", "1", "Reddit profile query");
495
+ return finish();
496
+ }
497
+
498
+ if (input.operation === "feeds.home") {
499
+ if (method !== "GET" || url.pathname !== "/.json" || form.size !== 0) {
500
+ throw new Error("Reddit home feed request changed its reviewed exchange");
501
+ }
502
+ exactNames(query, ["limit", "raw_json"], ["after"], "Reddit home feed query");
503
+ decimalParameter(query, "limit", 1, 100, "Reddit home feed query");
504
+ requireFixed(query, "raw_json", "1", "Reddit home feed query");
505
+ optionalAfter(query, "Reddit home feed query");
506
+ return finish();
507
+ }
508
+
509
+ if (input.operation === "posts.read" || input.operation === "comments.read") {
510
+ if (method !== "GET" || form.size !== 0 || input.targetId === undefined) {
511
+ throw new Error("Reddit comments-page request changed its reviewed exchange");
512
+ }
513
+ const bare = redditBarePostId(input.targetId, "Reddit request target");
514
+ if (url.pathname !== `/comments/${bare}.json`) {
515
+ throw new Error("Reddit comments-page path did not bind the requested post");
516
+ }
517
+ if (input.operation === "posts.read") {
518
+ exactNames(query, ["limit", "raw_json"], [], "Reddit post query");
519
+ requireFixed(query, "limit", "1", "Reddit post query");
520
+ } else {
521
+ exactNames(query, ["depth", "limit", "raw_json", "sort"], [], "Reddit comments query");
522
+ decimalParameter(query, "limit", 1, 100, "Reddit comments query");
523
+ requireFixed(query, "depth", "10", "Reddit comments query");
524
+ requireFixed(query, "sort", "confidence", "Reddit comments query");
525
+ }
526
+ requireFixed(query, "raw_json", "1", "Reddit comments-page query");
527
+ return finish();
528
+ }
529
+
530
+ if (input.operation === "messages.list" || input.operation === "messages.read") {
531
+ if (method !== "GET" || form.size !== 0 || input.folder === undefined) {
532
+ throw new Error("Reddit message request changed its reviewed exchange");
533
+ }
534
+ if (url.pathname !== `/message/${input.folder}.json`) {
535
+ throw new Error("Reddit message path did not bind the requested folder");
536
+ }
537
+ if (input.operation === "messages.list") {
538
+ exactNames(query, ["limit", "mark", "max_replies", "raw_json"], ["after"], "Reddit message-list query");
539
+ decimalParameter(query, "limit", 1, 100, "Reddit message-list query");
540
+ requireFixed(query, "max_replies", "0", "Reddit message-list query");
541
+ optionalAfter(query, "Reddit message-list query");
542
+ } else {
543
+ exactNames(query, ["limit", "mark", "max_replies", "mid", "raw_json"], [], "Reddit message-read query");
544
+ requireFixed(query, "limit", "1", "Reddit message-read query");
545
+ requireFixed(query, "max_replies", "100", "Reddit message-read query");
546
+ const target = redditFullname(input.targetId, "Reddit requested message", ["t4"]);
547
+ if (query.get("mid") !== target) {
548
+ throw new Error("Reddit message query did not bind the requested message");
549
+ }
550
+ }
551
+ requireFixed(query, "mark", "false", "Reddit message query");
552
+ requireFixed(query, "raw_json", "1", "Reddit message query");
553
+ return finish();
554
+ }
555
+
556
+ if (input.operation === "state.readback" || input.operation === "media.read") {
557
+ if (method !== "GET" || url.pathname !== "/api/info.json" || form.size !== 0) {
558
+ throw new Error(`Reddit ${input.operation} changed its reviewed exchange`);
559
+ }
560
+ exactNames(query, ["id", "raw_json"], [], `Reddit ${input.operation} query`);
561
+ const target = redditFullname(
562
+ input.targetId,
563
+ `Reddit ${input.operation} target`,
564
+ input.operation === "media.read" ? ["t3"] : ["t1", "t3"],
565
+ );
566
+ if (query.get("id") !== target) {
567
+ throw new Error(`Reddit ${input.operation} query did not bind its target`);
568
+ }
569
+ requireFixed(query, "raw_json", "1", `Reddit ${input.operation} query`);
570
+ return finish();
571
+ }
572
+
573
+ if (
574
+ input.operation === "flair.user.choices"
575
+ || input.operation === "flair.post.choices"
576
+ ) {
577
+ if (
578
+ method !== "POST"
579
+ || url.pathname !== "/api/flairselector"
580
+ || input.community === undefined
581
+ ) throw new Error("Reddit flair choices request changed its reviewed exchange");
582
+ exactNames(query, [], [], "Reddit flair choices query");
583
+ const community = redditCommunity(input.community, "Reddit flair community");
584
+ const commonFields = ["r", "uh"] as const;
585
+ exactNames(
586
+ form,
587
+ input.operation === "flair.user.choices"
588
+ ? [...commonFields, "name"]
589
+ : [...commonFields, "is_newlink"],
590
+ [],
591
+ "Reddit flair choices form",
592
+ );
593
+ requireFixed(form, "r", community, "Reddit flair choices form");
594
+ boundedString(form.get("uh"), "Reddit flair choices modhash", 256);
595
+ if (input.operation === "flair.user.choices") {
596
+ const username = boundedString(
597
+ input.username,
598
+ "Reddit flair username",
599
+ 64,
600
+ );
601
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(username)) {
602
+ throw new Error("Reddit flair username is invalid");
603
+ }
604
+ requireFixed(form, "name", username, "Reddit flair choices form");
605
+ } else {
606
+ requireFixed(form, "is_newlink", "true", "Reddit flair choices form");
607
+ }
608
+ return finish();
609
+ }
610
+
611
+ if (input.operation === "media.lease") {
612
+ if (
613
+ method !== "POST"
614
+ || input.mediaType === undefined
615
+ || input.filename === undefined
616
+ ) throw new Error("Reddit media lease request changed its reviewed exchange");
617
+ const expectedPath = input.mediaType === "video/mp4"
618
+ ? "/api/video_upload_s3.json"
619
+ : "/api/image_upload_s3.json";
620
+ if (url.pathname !== expectedPath || query.size !== 0) {
621
+ throw new Error("Reddit media lease request changed its reviewed exchange");
622
+ }
623
+ exactNames(
624
+ form,
625
+ ["filepath", "mimetype", "raw_json"],
626
+ [],
627
+ "Reddit media lease form",
628
+ );
629
+ requireFixed(form, "filepath", input.filename, "Reddit media lease form");
630
+ requireFixed(form, "mimetype", input.mediaType, "Reddit media lease form");
631
+ requireFixed(form, "raw_json", "1", "Reddit media lease form");
632
+ return finish();
633
+ }
634
+
635
+ if (input.operation === "media.publish") {
636
+ if (
637
+ method !== "POST"
638
+ || url.pathname !== "/api/submit"
639
+ || input.community === undefined
640
+ || input.title === undefined
641
+ || typeof input.nsfw !== "boolean"
642
+ || typeof input.spoiler !== "boolean"
643
+ || typeof input.sendReplies !== "boolean"
644
+ || input.mediaUrl === undefined
645
+ || input.posterUrl === undefined
646
+ ) throw new Error("Reddit video submit request changed its reviewed exchange");
647
+ exactNames(query, ["raw_json"], [], "Reddit video submit query");
648
+ requireFixed(query, "raw_json", "1", "Reddit video submit query");
649
+ exactNames(
650
+ form,
651
+ [
652
+ "api_type",
653
+ "kind",
654
+ "nsfw",
655
+ "resubmit",
656
+ "sendreplies",
657
+ "spoiler",
658
+ "sr",
659
+ "title",
660
+ "uh",
661
+ "url",
662
+ "validate_on_submit",
663
+ "video_poster_url",
664
+ ],
665
+ input.text === undefined ? [] : ["text"],
666
+ "Reddit video submit form",
667
+ );
668
+ requireFixed(form, "api_type", "json", "Reddit video submit form");
669
+ requireFixed(form, "kind", "video", "Reddit video submit form");
670
+ requireFixed(form, "nsfw", String(input.nsfw), "Reddit video submit form");
671
+ requireFixed(form, "resubmit", "false", "Reddit video submit form");
672
+ requireFixed(form, "sendreplies", String(input.sendReplies), "Reddit video submit form");
673
+ requireFixed(form, "spoiler", String(input.spoiler), "Reddit video submit form");
674
+ requireFixed(form, "sr", redditCommunity(input.community), "Reddit video submit form");
675
+ requireFixed(
676
+ form,
677
+ "title",
678
+ boundedString(input.title, "Reddit video title", 280),
679
+ "Reddit video submit form",
680
+ );
681
+ if (input.text !== undefined) {
682
+ requireFixed(
683
+ form,
684
+ "text",
685
+ boundedString(input.text, "Reddit video body", 10_000),
686
+ "Reddit video submit form",
687
+ );
688
+ }
689
+ const mediaUrl = exactRedditUploadUrl(
690
+ input.mediaUrl,
691
+ "reddit-uploaded-video.s3-accelerate.amazonaws.com",
692
+ "Reddit uploaded video URL",
693
+ );
694
+ const posterUrl = exactRedditUploadUrl(
695
+ input.posterUrl,
696
+ "reddit-uploaded-media.s3-accelerate.amazonaws.com",
697
+ "Reddit uploaded poster URL",
698
+ );
699
+ requireFixed(form, "url", mediaUrl.href, "Reddit video submit form");
700
+ requireFixed(form, "video_poster_url", posterUrl.href, "Reddit video submit form");
701
+ requireFixed(form, "validate_on_submit", "true", "Reddit video submit form");
702
+ boundedString(form.get("uh"), "Reddit video submit modhash", 256);
703
+ return finish();
704
+ }
705
+
706
+ if (input.operation === "content.delete") {
707
+ if (method !== "POST" || url.pathname !== "/api/del" || query.size !== 0) {
708
+ throw new Error("Reddit delete request changed its reviewed exchange");
709
+ }
710
+ exactNames(form, ["id", "uh"], [], "Reddit delete form");
711
+ const target = redditPostId(input.targetId, "Reddit delete target");
712
+ if (form.get("id") !== target) throw new Error("Reddit delete form did not bind its target");
713
+ boundedString(form.get("uh"), "Reddit delete modhash", 256);
714
+ return finish();
715
+ }
716
+
717
+ if (input.operation === "reactions.set") {
718
+ if (method !== "POST" || url.pathname !== "/api/vote" || query.size !== 0) {
719
+ throw new Error("Reddit vote request changed its reviewed exchange");
720
+ }
721
+ exactNames(form, ["dir", "id", "uh"], [], "Reddit vote form");
722
+ const target = redditFullname(input.targetId, "Reddit vote target", ["t1", "t3"]);
723
+ if (form.get("id") !== target) throw new Error("Reddit vote form did not bind its target");
724
+ if (input.direction !== -1 && input.direction !== 0 && input.direction !== 1) {
725
+ throw new Error("Reddit vote direction is not reviewed");
726
+ }
727
+ if (form.get("dir") !== String(input.direction)) {
728
+ throw new Error("Reddit vote form did not bind the desired direction");
729
+ }
730
+ } else if (input.operation === "content.save") {
731
+ if (
732
+ method !== "POST"
733
+ || (url.pathname !== "/api/save" && url.pathname !== "/api/unsave")
734
+ || query.size !== 0
735
+ || typeof input.saved !== "boolean"
736
+ ) throw new Error("Reddit save request changed its reviewed exchange");
737
+ exactNames(form, ["id", "uh"], [], "Reddit save form");
738
+ const target = redditFullname(input.targetId, "Reddit save target", ["t1", "t3"]);
739
+ if (form.get("id") !== target) throw new Error("Reddit save form did not bind its target");
740
+ if (url.pathname !== (input.saved ? "/api/save" : "/api/unsave")) {
741
+ throw new Error("Reddit save path did not bind the desired state");
742
+ }
743
+ } else throw new Error("Reddit request operation is not reviewed");
744
+ boundedString(form.get("uh"), "Reddit request modhash", 256);
745
+ return finish();
746
+ }
747
+
748
+ export type RedditWebViewer = {
749
+ readonly id: string;
750
+ readonly username: string;
751
+ readonly modhash: string;
752
+ };
753
+
754
+ export function parseRedditWebViewerResponse(value: unknown): RedditWebViewer {
755
+ const root = record(value, "Reddit viewer response");
756
+ if (root.kind !== "t2") throw new Error("Reddit viewer response did not contain an account thing");
757
+ const data = record(root.data, "Reddit viewer response.data");
758
+ const rawId = boundedString(data.id, "Reddit viewer account ID", 32);
759
+ if (!/^[a-z0-9]{1,32}$/u.test(rawId)) throw new Error("Reddit viewer account ID must be base36");
760
+ const username = boundedString(data.name, "Reddit viewer username", 64);
761
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(username)) throw new Error("Reddit viewer username is invalid");
762
+ const modhash = boundedString(data.modhash, "Reddit viewer modhash", 256);
763
+ return Object.freeze({ id: `t2_${rawId}`, username, modhash });
764
+ }
765
+
766
+ export type RedditMediaType = "video/mp4" | "image/jpeg" | "image/png";
767
+
768
+ export type RedditMediaLease = {
769
+ readonly uploadOrigin: string;
770
+ readonly fields: readonly Readonly<{ name: string; value: string }>[];
771
+ readonly key: string;
772
+ };
773
+
774
+ const redditLeaseFieldNames = Object.freeze([
775
+ "x-amz-algorithm",
776
+ "x-amz-security-token",
777
+ "x-amz-storage-class",
778
+ "success_action_status",
779
+ "bucket",
780
+ "acl",
781
+ "key",
782
+ "x-amz-signature",
783
+ "x-amz-date",
784
+ "x-amz-meta-ext",
785
+ "policy",
786
+ "x-amz-credential",
787
+ "Content-Type",
788
+ ] as const);
789
+
790
+ function checkedRedditWebSocketUrl(value: unknown, label: string): URL {
791
+ const text = boundedString(value, label, 8_192);
792
+ let url: URL;
793
+ try {
794
+ url = new URL(text);
795
+ } catch {
796
+ throw new Error(`${label} must be an absolute WSS URL`);
797
+ }
798
+ const query = exactParameters(url.searchParams, `${label} query`);
799
+ if (
800
+ url.protocol !== "wss:"
801
+ || url.username !== ""
802
+ || url.password !== ""
803
+ || url.hash !== ""
804
+ || !/^(?:[a-z0-9-]{1,64}\.)?wss\.redditmedia\.com$/u.test(url.hostname)
805
+ || !/^\/[A-Za-z0-9/_-]{1,2048}$/u.test(url.pathname)
806
+ || url.pathname.includes("..")
807
+ || query.size !== 1
808
+ || !query.has("m")
809
+ || !/^[A-Za-z0-9_-]{20,2048}$/u.test(query.get("m") ?? "")
810
+ ) throw new Error(`${label} escaped the reviewed Reddit websocket family`);
811
+ return url;
812
+ }
813
+
814
+ export function parseRedditMediaLeaseResponse(
815
+ value: unknown,
816
+ expected: {
817
+ readonly mediaType: RedditMediaType;
818
+ readonly filename: string;
819
+ },
820
+ ): RedditMediaLease {
821
+ const expectedHostname = expected.mediaType === "video/mp4"
822
+ ? "reddit-uploaded-video.s3-accelerate.amazonaws.com"
823
+ : "reddit-uploaded-media.s3-accelerate.amazonaws.com";
824
+ const filename = boundedString(expected.filename, "Reddit lease filename", 128);
825
+ if (!/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u.test(filename)) {
826
+ throw new Error("Reddit lease filename is not a safe fixed name");
827
+ }
828
+ const root = record(value, "Reddit media lease response");
829
+ exactObjectKeys(root, ["action", "fields"], [], "Reddit media lease response");
830
+ if (root.action !== `//${expectedHostname}`) {
831
+ throw new Error("Reddit media lease changed its exact upload host");
832
+ }
833
+ if (!Array.isArray(root.fields) || root.fields.length !== redditLeaseFieldNames.length) {
834
+ throw new Error("Reddit media lease changed its upload field count");
835
+ }
836
+ const allowedNames: ReadonlySet<string> = new Set(redditLeaseFieldNames);
837
+ const seenNames = new Set<string>();
838
+ const fields: { name: string; value: string }[] = [];
839
+ let totalValueBytes = 0;
840
+ for (const [index, rawField] of root.fields.entries()) {
841
+ const field = record(rawField, `Reddit media lease field ${index}`);
842
+ exactObjectKeys(field, ["name", "value"], [], `Reddit media lease field ${index}`);
843
+ const fieldName = boundedString(field.name, `Reddit media lease field ${index} name`, 64);
844
+ if (!allowedNames.has(fieldName) || seenNames.has(fieldName)) {
845
+ throw new Error("Reddit media lease changed its exact upload field set");
846
+ }
847
+ seenNames.add(fieldName);
848
+ const fieldValue = boundedString(field.value, `Reddit media lease field ${fieldName}`, 65_536);
849
+ if (/\n/u.test(fieldValue)) throw new Error("Reddit media lease field contained a line break");
850
+ totalValueBytes += new TextEncoder().encode(fieldValue).byteLength;
851
+ if (totalValueBytes > 160 * 1024) throw new Error("Reddit media lease fields exceeded their reviewed bound");
852
+ fields.push(Object.freeze({ name: fieldName, value: fieldValue }));
853
+ }
854
+ const byName = new Map(fields.map((field) => [field.name, field.value]));
855
+ const extension = expected.mediaType === "video/mp4"
856
+ ? "mp4"
857
+ : expected.mediaType === "image/png" ? "png" : "jpg";
858
+ const bucket = expected.mediaType === "video/mp4"
859
+ ? "reddit-uploaded-video"
860
+ : "reddit-uploaded-media";
861
+ if (
862
+ byName.get("acl") !== "private"
863
+ || byName.get("x-amz-algorithm") !== "AWS4-HMAC-SHA256"
864
+ || byName.get("success_action_status") !== "201"
865
+ || byName.get("bucket") !== bucket
866
+ || byName.get("Content-Type") !== expected.mediaType
867
+ || byName.get("x-amz-storage-class") !== "STANDARD"
868
+ || byName.get("x-amz-meta-ext") !== extension
869
+ ) throw new Error("Reddit media lease changed a fixed upload declaration");
870
+ const key = boundedString(byName.get("key"), "Reddit media lease key", 2_048);
871
+ if (!/^[A-Za-z0-9][A-Za-z0-9/_.-]{0,2047}$/u.test(key) || key.includes("..")) {
872
+ throw new Error("Reddit media lease key escaped its reviewed path shape");
873
+ }
874
+ return Object.freeze({
875
+ uploadOrigin: `https://${expectedHostname}`,
876
+ fields: Object.freeze(fields),
877
+ key,
878
+ });
879
+ }
880
+
881
+ export function redditMediaAssetUrl(lease: RedditMediaLease): string {
882
+ const origin = lease.uploadOrigin === "https://reddit-uploaded-video.s3-accelerate.amazonaws.com"
883
+ ? lease.uploadOrigin
884
+ : lease.uploadOrigin === "https://reddit-uploaded-media.s3-accelerate.amazonaws.com"
885
+ ? lease.uploadOrigin
886
+ : (() => {
887
+ throw new Error("Reddit media lease upload origin is not reviewed");
888
+ })();
889
+ return new URL(`/${lease.key}`, origin).href;
890
+ }
891
+
892
+ export function parseRedditVideoSubmitResponse(value: unknown): string {
893
+ const root = record(value, "Reddit video submit response");
894
+ exactObjectKeys(root, ["json"], [], "Reddit video submit response");
895
+ const json = record(root.json, "Reddit video submit response.json");
896
+ exactObjectKeys(json, ["data", "errors"], [], "Reddit video submit response.json");
897
+ if (!Array.isArray(json.errors) || json.errors.length !== 0) {
898
+ throw new Error("Reddit video submit response contained provider errors");
899
+ }
900
+ const data = record(json.data, "Reddit video submit response.json.data");
901
+ exactObjectKeys(
902
+ data,
903
+ ["websocket_url"],
904
+ ["user_submitted_page"],
905
+ "Reddit video submit response.json.data",
906
+ );
907
+ if (data.user_submitted_page !== undefined) {
908
+ const page = exactUrl(
909
+ boundedString(data.user_submitted_page, "Reddit submitted-page URL", 2_048),
910
+ "Reddit submitted-page URL",
911
+ );
912
+ if (!/^\/user\/[A-Za-z0-9_-]{1,64}\/submitted\/$/u.test(page.pathname) || page.search !== "") {
913
+ throw new Error("Reddit submitted-page URL changed shape");
914
+ }
915
+ }
916
+ return checkedRedditWebSocketUrl(
917
+ data.websocket_url,
918
+ "Reddit video submit websocket URL",
919
+ ).href;
920
+ }
921
+
922
+ export function parseRedditVideoWebSocketMessage(
923
+ value: unknown,
924
+ expectedCommunity: string,
925
+ ): Readonly<{ postId: string; url: string }> {
926
+ let parsed: unknown = value;
927
+ if (typeof value === "string") {
928
+ if (new TextEncoder().encode(value).byteLength > 64 * 1024) {
929
+ throw new Error("Reddit video websocket message exceeded its reviewed bound");
930
+ }
931
+ try {
932
+ parsed = JSON.parse(value) as unknown;
933
+ } catch {
934
+ throw new Error("Reddit video websocket returned malformed JSON");
935
+ }
936
+ }
937
+ const root = record(parsed, "Reddit video websocket message");
938
+ exactObjectKeys(root, ["payload"], ["type"], "Reddit video websocket message");
939
+ if (root.type !== undefined && root.type !== "success") {
940
+ throw new Error("Reddit video processing did not succeed");
941
+ }
942
+ const payload = record(root.payload, "Reddit video websocket message.payload");
943
+ exactObjectKeys(payload, ["redirect"], [], "Reddit video websocket message.payload");
944
+ const redirectText = boundedString(payload.redirect, "Reddit video redirect", 2_048);
945
+ let redirect: URL;
946
+ try {
947
+ redirect = new URL(redirectText);
948
+ } catch {
949
+ throw new Error("Reddit video redirect must be an absolute URL");
950
+ }
951
+ const community = redditCommunity(expectedCommunity);
952
+ const match = /^\/r\/([^/]+)\/comments\/([a-z0-9]{1,32})\/[^/?#]+\/$/u.exec(redirect.pathname);
953
+ if (
954
+ redirect.protocol !== "https:"
955
+ || (redirect.hostname !== "www.reddit.com" && redirect.hostname !== "reddit.com")
956
+ || redirect.username !== ""
957
+ || redirect.password !== ""
958
+ || redirect.port !== ""
959
+ || redirect.search !== ""
960
+ || redirect.hash !== ""
961
+ || match === null
962
+ || match[1]?.toLowerCase() !== community.toLowerCase()
963
+ ) throw new Error("Reddit video redirect escaped its confirmed community");
964
+ const postId = redditPostId(`t3_${match[2]}`, "Reddit video redirect post ID");
965
+ return Object.freeze({
966
+ postId,
967
+ url: `https://www.reddit.com${redirect.pathname}`,
968
+ });
969
+ }
970
+
971
+ export type RedditWebProfile = {
972
+ readonly username: string;
973
+ readonly displayName: string | null;
974
+ readonly bio: string | null;
975
+ readonly followers: number;
976
+ readonly karma: number;
977
+ };
978
+
979
+ /** Project exact public profile counts from Reddit's target-bound about thing. */
980
+ export function parseRedditWebProfileResponse(
981
+ value: unknown,
982
+ expectedUsername: string,
983
+ ): RedditWebProfile {
984
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(expectedUsername)) {
985
+ throw new Error("Expected Reddit profile handle is invalid");
986
+ }
987
+ const root = record(value, "Reddit profile response");
988
+ if (root.kind !== "t2") throw new Error("Reddit profile response did not contain an account thing");
989
+ const data = record(root.data, "Reddit profile response.data");
990
+ const username = boundedString(data.name, "Reddit profile response.data.name", 64);
991
+ if (username.toLocaleLowerCase("en-US") !== expectedUsername.toLocaleLowerCase("en-US")) {
992
+ throw new Error("Reddit profile response did not bind the requested handle");
993
+ }
994
+ const subreddit = record(data.subreddit, "Reddit profile response.data.subreddit");
995
+ const prefixedName = optionalString(
996
+ subreddit.display_name_prefixed,
997
+ "Reddit profile response.data.subreddit.display_name_prefixed",
998
+ 66,
999
+ );
1000
+ if (
1001
+ prefixedName !== null
1002
+ && prefixedName.toLocaleLowerCase("en-US") !== `u/${username}`.toLocaleLowerCase("en-US")
1003
+ ) throw new Error("Reddit profile subreddit did not bind the requested handle");
1004
+ return Object.freeze({
1005
+ username,
1006
+ displayName: optionalString(subreddit.title, "Reddit profile response.data.subreddit.title", 256),
1007
+ bio: optionalString(
1008
+ subreddit.public_description,
1009
+ "Reddit profile response.data.subreddit.public_description",
1010
+ 4096,
1011
+ ),
1012
+ followers: safeInteger(
1013
+ subreddit.subscribers,
1014
+ "Reddit profile response.data.subreddit.subscribers",
1015
+ 0,
1016
+ Number.MAX_SAFE_INTEGER,
1017
+ ),
1018
+ karma: safeInteger(
1019
+ data.total_karma,
1020
+ "Reddit profile response.data.total_karma",
1021
+ 0,
1022
+ Number.MAX_SAFE_INTEGER,
1023
+ ),
1024
+ });
1025
+ }
1026
+
1027
+ type RedditListing = {
1028
+ readonly children: readonly JsonRecord[];
1029
+ readonly after: string | null;
1030
+ readonly before: string | null;
1031
+ };
1032
+
1033
+ function listing(value: unknown, label: string, maximumChildren: number): RedditListing {
1034
+ const root = record(value, label);
1035
+ if (root.kind !== "Listing") throw new Error(`${label}.kind must be Listing`);
1036
+ const data = record(root.data, `${label}.data`);
1037
+ if (!Array.isArray(data.children) || data.children.length > maximumChildren) {
1038
+ throw new Error(`${label}.data.children exceeded its reviewed bound`);
1039
+ }
1040
+ const children = data.children.map((child, index) =>
1041
+ record(child, `${label}.data.children[${index}]`));
1042
+ const after = optionalString(data.after, `${label}.data.after`, 64);
1043
+ const before = optionalString(data.before, `${label}.data.before`, 64);
1044
+ if (after !== null) redditFullname(after, `${label}.data.after`, ["t1", "t3", "t4"]);
1045
+ if (before !== null) redditFullname(before, `${label}.data.before`, ["t1", "t3", "t4"]);
1046
+ return Object.freeze({
1047
+ children: Object.freeze(children),
1048
+ after,
1049
+ before,
1050
+ });
1051
+ }
1052
+
1053
+ export type RedditProfileContributionPage = {
1054
+ readonly ids: readonly string[];
1055
+ readonly after: string | null;
1056
+ };
1057
+
1058
+ /**
1059
+ * Count only distinct post and comment things authored by the requested user.
1060
+ * Runtime pagination defines the metric window as the complete visible profile
1061
+ * overview Listing, rather than claiming an unavailable lifetime total.
1062
+ */
1063
+ export function parseRedditProfileContributionPage(
1064
+ value: unknown,
1065
+ expectedUsername: string,
1066
+ ): RedditProfileContributionPage {
1067
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(expectedUsername)) {
1068
+ throw new Error("Expected Reddit profile handle is invalid");
1069
+ }
1070
+ const page = listing(value, "Reddit profile overview response", 100);
1071
+ const ids = page.children.map((child, index) => {
1072
+ const label = `Reddit profile overview response.data.children[${index}]`;
1073
+ if (child.kind !== "t1" && child.kind !== "t3") {
1074
+ throw new Error(`${label}.kind must be t1 or t3`);
1075
+ }
1076
+ const data = record(child.data, `${label}.data`);
1077
+ const id = redditFullname(data.name, `${label}.data.name`, [child.kind]);
1078
+ const author = boundedString(data.author, `${label}.data.author`, 64);
1079
+ if (author.toLocaleLowerCase("en-US") !== expectedUsername.toLocaleLowerCase("en-US")) {
1080
+ throw new Error("Reddit profile overview response contained another author");
1081
+ }
1082
+ return id;
1083
+ });
1084
+ if (new Set(ids).size !== ids.length) {
1085
+ throw new Error("Reddit profile overview response repeated a contribution");
1086
+ }
1087
+ if (page.after !== null) redditFullname(page.after, "Reddit profile overview response.data.after", ["t1", "t3"]);
1088
+ return Object.freeze({ ids: Object.freeze(ids), after: page.after });
1089
+ }
1090
+
1091
+ function thingData(
1092
+ value: JsonRecord,
1093
+ expectedKind: "t1" | "t3" | "t4",
1094
+ label: string,
1095
+ ): JsonRecord {
1096
+ if (value.kind !== expectedKind) throw new Error(`${label}.kind must be ${expectedKind}`);
1097
+ return record(value.data, `${label}.data`);
1098
+ }
1099
+
1100
+ export type RedditProjectedPost = {
1101
+ readonly id: string;
1102
+ readonly title: string;
1103
+ readonly body: string;
1104
+ readonly author: string | null;
1105
+ readonly subreddit: string;
1106
+ readonly createdUtc: number | null;
1107
+ readonly score: number | null;
1108
+ readonly commentCount: number;
1109
+ readonly liked: boolean | null;
1110
+ readonly saved: boolean;
1111
+ readonly externalUrl: string | null;
1112
+ readonly permalink: string;
1113
+ };
1114
+
1115
+ function safeExternalUrl(value: unknown, label: string): string | null {
1116
+ if (value === undefined || value === null || value === "") return null;
1117
+ const text = boundedString(value, label, 4096);
1118
+ let url: URL;
1119
+ try {
1120
+ url = new URL(text);
1121
+ } catch {
1122
+ throw new Error(`${label} must be an absolute HTTP URL`);
1123
+ }
1124
+ if (
1125
+ (url.protocol !== "https:" && url.protocol !== "http:")
1126
+ || url.username !== ""
1127
+ || url.password !== ""
1128
+ ) throw new Error(`${label} must be an absolute HTTP URL without credentials`);
1129
+ return url.href;
1130
+ }
1131
+
1132
+ function permalink(value: unknown, label: string): string {
1133
+ const path = boundedString(value, label, 2048);
1134
+ if (!path.startsWith("/") || path.startsWith("//") || /[?#]/u.test(path)) {
1135
+ throw new Error(`${label} must be a Reddit path`);
1136
+ }
1137
+ return `https://www.reddit.com${path}`;
1138
+ }
1139
+
1140
+ function projectedPost(value: JsonRecord, label: string): RedditProjectedPost {
1141
+ const data = thingData(value, "t3", label);
1142
+ const id = redditFullname(data.name, `${label}.data.name`, ["t3"]);
1143
+ const commentCount = safeInteger(data.num_comments, `${label}.data.num_comments`, 0, Number.MAX_SAFE_INTEGER);
1144
+ return Object.freeze({
1145
+ id,
1146
+ title: boundedString(data.title, `${label}.data.title`, 1_000, true),
1147
+ body: boundedString(data.selftext ?? "", `${label}.data.selftext`, 100_000, true),
1148
+ author: optionalString(data.author, `${label}.data.author`, 64),
1149
+ subreddit: boundedString(data.subreddit, `${label}.data.subreddit`, 64),
1150
+ createdUtc: finiteNumber(data.created_utc, `${label}.data.created_utc`),
1151
+ score: finiteNumber(data.score, `${label}.data.score`),
1152
+ commentCount,
1153
+ liked: nullableBoolean(data.likes, `${label}.data.likes`),
1154
+ saved: boolean(data.saved, `${label}.data.saved`),
1155
+ externalUrl: safeExternalUrl(data.url, `${label}.data.url`),
1156
+ permalink: permalink(data.permalink, `${label}.data.permalink`),
1157
+ });
1158
+ }
1159
+
1160
+ export type RedditAuthoredPostPresence = Readonly<{
1161
+ present: boolean;
1162
+ post: RedditProjectedPost | null;
1163
+ authorFullname: string | null;
1164
+ }>;
1165
+
1166
+ export function parseRedditAuthoredPostPresence(
1167
+ value: unknown,
1168
+ expectedPostId: string,
1169
+ ): RedditAuthoredPostPresence {
1170
+ const target = redditPostId(expectedPostId);
1171
+ const page = listing(value, "Reddit authored-post presence Listing", 1);
1172
+ if (page.children.length === 0) {
1173
+ return Object.freeze({ present: false, post: null, authorFullname: null });
1174
+ }
1175
+ if (page.children.length !== 1) {
1176
+ throw new Error("Reddit authored-post presence returned multiple targets");
1177
+ }
1178
+ const thing = page.children[0]!;
1179
+ const data = thingData(thing, "t3", "Reddit authored-post presence");
1180
+ const id = redditPostId(data.name, "Reddit authored-post presence ID");
1181
+ if (id !== target) throw new Error("Reddit authored-post presence changed its exact target");
1182
+ const rawAuthor = data.author;
1183
+ if (rawAuthor === undefined || rawAuthor === null || rawAuthor === "[deleted]") {
1184
+ return Object.freeze({ present: false, post: null, authorFullname: null });
1185
+ }
1186
+ const post = projectedPost(thing, "Reddit authored-post presence");
1187
+ const authorFullname = data.author_fullname === undefined || data.author_fullname === null
1188
+ ? null
1189
+ : redditFullname(
1190
+ data.author_fullname,
1191
+ "Reddit authored-post presence author fullname",
1192
+ ["t2"],
1193
+ );
1194
+ return Object.freeze({ present: true, post, authorFullname });
1195
+ }
1196
+
1197
+ export type RedditVideoPostReadback = Readonly<{
1198
+ post: RedditProjectedPost;
1199
+ authorFullname: string | null;
1200
+ videoUrl: string;
1201
+ fallbackUrl: string;
1202
+ durationSeconds: number;
1203
+ width: number;
1204
+ height: number;
1205
+ nsfw: boolean;
1206
+ spoiler: boolean;
1207
+ }>;
1208
+
1209
+ function exactRedditVideoUrl(value: unknown, label: string): URL {
1210
+ const text = boundedString(value, label, 4_096);
1211
+ let url: URL;
1212
+ try {
1213
+ url = new URL(text);
1214
+ } catch {
1215
+ throw new Error(`${label} must be an absolute URL`);
1216
+ }
1217
+ if (
1218
+ url.protocol !== "https:"
1219
+ || url.hostname !== "v.redd.it"
1220
+ || url.username !== ""
1221
+ || url.password !== ""
1222
+ || url.port !== ""
1223
+ || url.hash !== ""
1224
+ || !/^\/[A-Za-z0-9_-]{1,128}(?:\/[A-Za-z0-9_.-]{1,128})?$/u.test(url.pathname)
1225
+ ) throw new Error(`${label} escaped the exact Reddit video host`);
1226
+ return url;
1227
+ }
1228
+
1229
+ export function parseRedditVideoPostPresence(
1230
+ value: unknown,
1231
+ expectedPostId: string,
1232
+ ): RedditVideoPostReadback | null {
1233
+ const target = redditPostId(expectedPostId);
1234
+ const page = listing(value, "Reddit video-post presence Listing", 1);
1235
+ if (page.children.length === 0) return null;
1236
+ if (page.children.length !== 1) {
1237
+ throw new Error("Reddit video-post presence returned multiple targets");
1238
+ }
1239
+ const thing = page.children[0]!;
1240
+ const data = thingData(thing, "t3", "Reddit video-post presence");
1241
+ if (redditPostId(data.name, "Reddit video-post presence ID") !== target) {
1242
+ throw new Error("Reddit video-post presence changed its exact target");
1243
+ }
1244
+ if (data.is_video !== true || data.post_hint !== "hosted:video" || data.domain !== "v.redd.it") {
1245
+ throw new Error("Reddit video-post readback did not contain one hosted video");
1246
+ }
1247
+ const post = projectedPost(thing, "Reddit video-post presence");
1248
+ const videoUrl = exactRedditVideoUrl(data.url, "Reddit video-post URL");
1249
+ if (videoUrl.search !== "") throw new Error("Reddit video-post URL changed shape");
1250
+ const media = record(data.media, "Reddit video-post media");
1251
+ const video = record(media.reddit_video, "Reddit video-post media.reddit_video");
1252
+ if (video.is_gif !== false || video.transcoding_status !== "completed") {
1253
+ throw new Error("Reddit video-post processing did not complete as a normal video");
1254
+ }
1255
+ const durationSeconds = safeInteger(
1256
+ video.duration,
1257
+ "Reddit video-post duration",
1258
+ 1,
1259
+ 3_600,
1260
+ );
1261
+ const width = safeInteger(video.width, "Reddit video-post width", 1, 16_384);
1262
+ const height = safeInteger(video.height, "Reddit video-post height", 1, 16_384);
1263
+ const nsfw = boolean(data.over_18, "Reddit video-post NSFW declaration");
1264
+ const spoiler = boolean(data.spoiler, "Reddit video-post spoiler declaration");
1265
+ const fallbackUrl = exactRedditVideoUrl(
1266
+ video.fallback_url,
1267
+ "Reddit video-post fallback URL",
1268
+ );
1269
+ const rootSegment = videoUrl.pathname.split("/")[1];
1270
+ if (fallbackUrl.pathname.split("/")[1] !== rootSegment) {
1271
+ throw new Error("Reddit video-post fallback URL changed the video identity");
1272
+ }
1273
+ const authorFullname = data.author_fullname === undefined || data.author_fullname === null
1274
+ ? null
1275
+ : redditFullname(
1276
+ data.author_fullname,
1277
+ "Reddit video-post author fullname",
1278
+ ["t2"],
1279
+ );
1280
+ return Object.freeze({
1281
+ post,
1282
+ authorFullname,
1283
+ videoUrl: videoUrl.href,
1284
+ fallbackUrl: fallbackUrl.href,
1285
+ durationSeconds,
1286
+ width,
1287
+ height,
1288
+ nsfw,
1289
+ spoiler,
1290
+ });
1291
+ }
1292
+
1293
+ export type RedditHostedVideoMetadata = Readonly<{
1294
+ provider: "reddit";
1295
+ operation: "media.read";
1296
+ post: Readonly<{
1297
+ id: string;
1298
+ title: string;
1299
+ author: string | null;
1300
+ subreddit: string;
1301
+ createdUtc: number | null;
1302
+ permalink: string;
1303
+ }>;
1304
+ media: Readonly<{
1305
+ kind: "hosted-video";
1306
+ mediaType: "video/mp4";
1307
+ durationSeconds: number;
1308
+ width: number;
1309
+ height: number;
1310
+ nsfw: boolean;
1311
+ spoiler: boolean;
1312
+ transcodingStatus: "completed";
1313
+ }>;
1314
+ }>;
1315
+
1316
+ /**
1317
+ * Project only durable hosted-video metadata. The parser must observe and bind
1318
+ * Reddit's playback locations, but this closed return shape intentionally
1319
+ * omits both the canonical video URL and the expiring fallback URL.
1320
+ */
1321
+ export function projectRedditHostedVideoMetadata(
1322
+ value: unknown,
1323
+ expectedPostId: string,
1324
+ ): RedditHostedVideoMetadata {
1325
+ const readback = parseRedditVideoPostPresence(value, expectedPostId);
1326
+ if (readback === null) {
1327
+ throw new Error("Reddit media.read did not return the exact hosted-video post");
1328
+ }
1329
+ return Object.freeze({
1330
+ provider: "reddit",
1331
+ operation: "media.read",
1332
+ post: Object.freeze({
1333
+ id: readback.post.id,
1334
+ title: readback.post.title,
1335
+ author: readback.post.author,
1336
+ subreddit: readback.post.subreddit,
1337
+ createdUtc: readback.post.createdUtc,
1338
+ permalink: readback.post.permalink,
1339
+ }),
1340
+ media: Object.freeze({
1341
+ kind: "hosted-video",
1342
+ mediaType: "video/mp4",
1343
+ durationSeconds: readback.durationSeconds,
1344
+ width: readback.width,
1345
+ height: readback.height,
1346
+ nsfw: readback.nsfw,
1347
+ spoiler: readback.spoiler,
1348
+ transcodingStatus: "completed",
1349
+ }),
1350
+ });
1351
+ }
1352
+
1353
+ export function normalizeRedditFeedResponse(
1354
+ value: unknown,
1355
+ limit: number,
1356
+ ): Readonly<{
1357
+ posts: readonly RedditProjectedPost[];
1358
+ after: string | null;
1359
+ before: string | null;
1360
+ }> {
1361
+ safeInteger(limit, "Reddit feed limit", 1, 100);
1362
+ const page = listing(value, "Reddit feed", limit);
1363
+ const posts = page.children.map((child, index) =>
1364
+ projectedPost(child, `Reddit feed child ${index}`));
1365
+ return Object.freeze({
1366
+ posts: Object.freeze(posts),
1367
+ after: page.after,
1368
+ before: page.before,
1369
+ });
1370
+ }
1371
+
1372
+ function commentsPage(
1373
+ value: unknown,
1374
+ expectedPostId: string,
1375
+ ): {
1376
+ readonly post: RedditProjectedPost;
1377
+ readonly comments: RedditListing;
1378
+ } {
1379
+ if (!Array.isArray(value) || value.length !== 2) {
1380
+ throw new Error("Reddit comments response must contain exact post and comment Listings");
1381
+ }
1382
+ const posts = listing(value[0], "Reddit comments post Listing", 1);
1383
+ if (posts.children.length !== 1) throw new Error("Reddit comments response omitted its root post");
1384
+ const post = projectedPost(posts.children[0]!, "Reddit comments root");
1385
+ if (post.id !== expectedPostId) {
1386
+ throw new Error("Reddit comments response did not bind the requested post");
1387
+ }
1388
+ return {
1389
+ post,
1390
+ comments: listing(value[1], "Reddit comments Listing", 500),
1391
+ };
1392
+ }
1393
+
1394
+ export function normalizeRedditPostResponse(
1395
+ value: unknown,
1396
+ expectedPostId: string,
1397
+ ): Readonly<{ post: RedditProjectedPost }> {
1398
+ const target = redditPostId(expectedPostId);
1399
+ return Object.freeze({ post: commentsPage(value, target).post });
1400
+ }
1401
+
1402
+ export type RedditProjectedComment = {
1403
+ readonly id: string;
1404
+ readonly postId: string;
1405
+ readonly parentId: string;
1406
+ readonly author: string | null;
1407
+ readonly body: string;
1408
+ readonly createdUtc: number | null;
1409
+ readonly score: number | null;
1410
+ readonly depth: number | null;
1411
+ readonly liked: boolean | null;
1412
+ readonly saved: boolean;
1413
+ readonly permalink: string;
1414
+ };
1415
+
1416
+ function projectedComment(
1417
+ value: JsonRecord,
1418
+ expectedPostId: string,
1419
+ label: string,
1420
+ ): RedditProjectedComment {
1421
+ const data = thingData(value, "t1", label);
1422
+ const postId = redditFullname(data.link_id, `${label}.data.link_id`, ["t3"]);
1423
+ if (postId !== expectedPostId) throw new Error("Reddit comment did not bind the requested post");
1424
+ return Object.freeze({
1425
+ id: redditFullname(data.name, `${label}.data.name`, ["t1"]),
1426
+ postId,
1427
+ parentId: redditFullname(data.parent_id, `${label}.data.parent_id`, ["t1", "t3"]),
1428
+ author: optionalString(data.author, `${label}.data.author`, 64),
1429
+ body: boundedString(data.body ?? "", `${label}.data.body`, 100_000, true),
1430
+ createdUtc: finiteNumber(data.created_utc, `${label}.data.created_utc`),
1431
+ score: finiteNumber(data.score, `${label}.data.score`),
1432
+ depth: data.depth === undefined || data.depth === null
1433
+ ? null
1434
+ : safeInteger(data.depth, `${label}.data.depth`, 0, 100),
1435
+ liked: nullableBoolean(data.likes, `${label}.data.likes`),
1436
+ saved: boolean(data.saved ?? false, `${label}.data.saved`),
1437
+ permalink: permalink(data.permalink, `${label}.data.permalink`),
1438
+ });
1439
+ }
1440
+
1441
+ export function normalizeRedditCommentsResponse(
1442
+ value: unknown,
1443
+ expectedPostId: string,
1444
+ limit: number,
1445
+ ): Readonly<{
1446
+ post: RedditProjectedPost;
1447
+ comments: readonly RedditProjectedComment[];
1448
+ truncated: boolean;
1449
+ hasMore: boolean;
1450
+ }> {
1451
+ const target = redditPostId(expectedPostId);
1452
+ safeInteger(limit, "Reddit comment limit", 1, 100);
1453
+ const page = commentsPage(value, target);
1454
+ const projected: RedditProjectedComment[] = [];
1455
+ let visited = 0;
1456
+ let hasMore = false;
1457
+ const visit = (children: readonly JsonRecord[]): void => {
1458
+ for (const child of children) {
1459
+ visited += 1;
1460
+ if (visited > 500) throw new Error("Reddit comment tree exceeded its reviewed node bound");
1461
+ if (child.kind === "more") {
1462
+ hasMore = true;
1463
+ continue;
1464
+ }
1465
+ const comment = projectedComment(child, target, `Reddit comment ${visited}`);
1466
+ if (projected.length < limit) projected.push(comment);
1467
+ const data = record(child.data, `Reddit comment ${visited}.data`);
1468
+ if (data.replies === "" || data.replies === undefined || data.replies === null) continue;
1469
+ visit(listing(data.replies, `Reddit comment ${visited}.replies`, 500).children);
1470
+ }
1471
+ };
1472
+ visit(page.comments.children);
1473
+ return Object.freeze({
1474
+ post: page.post,
1475
+ comments: Object.freeze(projected),
1476
+ truncated: visited > limit,
1477
+ hasMore,
1478
+ });
1479
+ }
1480
+
1481
+ export type RedditProjectedMessage = {
1482
+ readonly kind: "message" | "notification";
1483
+ readonly id: string;
1484
+ readonly author: string | null;
1485
+ readonly recipient: string | null;
1486
+ readonly subject: string;
1487
+ readonly body: string;
1488
+ readonly createdUtc: number | null;
1489
+ readonly unread: boolean;
1490
+ readonly parentId: string | null;
1491
+ readonly context: string | null;
1492
+ };
1493
+
1494
+ function projectedMessage(value: JsonRecord, label: string): RedditProjectedMessage {
1495
+ if (value.kind !== "t4" && value.kind !== "t1") {
1496
+ throw new Error(`${label}.kind must be a legacy message or inbox notification`);
1497
+ }
1498
+ const data = record(value.data, `${label}.data`);
1499
+ const kind = value.kind === "t4" ? "message" : "notification";
1500
+ const id = redditFullname(data.name, `${label}.data.name`, [value.kind]);
1501
+ const contextValue = optionalString(data.context, `${label}.data.context`, 2048);
1502
+ if (contextValue !== null && (!contextValue.startsWith("/") || contextValue.startsWith("//"))) {
1503
+ throw new Error(`${label}.data.context must be a Reddit path`);
1504
+ }
1505
+ return Object.freeze({
1506
+ kind,
1507
+ id,
1508
+ author: optionalString(data.author, `${label}.data.author`, 64),
1509
+ recipient: optionalString(data.dest, `${label}.data.dest`, 64),
1510
+ subject: boundedString(data.subject ?? "", `${label}.data.subject`, 1_000, true),
1511
+ body: boundedString(data.body ?? "", `${label}.data.body`, 100_000, true),
1512
+ createdUtc: finiteNumber(data.created_utc, `${label}.data.created_utc`),
1513
+ unread: boolean(data.new ?? false, `${label}.data.new`),
1514
+ parentId: data.parent_id === undefined || data.parent_id === null || data.parent_id === ""
1515
+ ? null
1516
+ : redditFullname(data.parent_id, `${label}.data.parent_id`, ["t1", "t3", "t4"]),
1517
+ context: contextValue === null ? null : `https://www.reddit.com${contextValue}`,
1518
+ });
1519
+ }
1520
+
1521
+ function nestedMessageThings(value: JsonRecord, maximum: number): readonly JsonRecord[] {
1522
+ const result: JsonRecord[] = [value];
1523
+ const data = record(value.data, "Reddit message thing.data");
1524
+ if (data.replies === undefined || data.replies === null || data.replies === "") {
1525
+ return Object.freeze(result);
1526
+ }
1527
+ const replies = listing(data.replies, "Reddit message replies", maximum);
1528
+ for (const reply of replies.children) {
1529
+ if (result.length >= maximum) throw new Error("Reddit message thread exceeded its reviewed bound");
1530
+ result.push(...nestedMessageThings(reply, maximum - result.length));
1531
+ }
1532
+ return Object.freeze(result);
1533
+ }
1534
+
1535
+ export function normalizeRedditMessageListing(
1536
+ value: unknown,
1537
+ limit: number,
1538
+ requestedMessageId: string | null = null,
1539
+ ): Readonly<{
1540
+ messages: readonly RedditProjectedMessage[];
1541
+ after: string | null;
1542
+ before: string | null;
1543
+ requested: RedditProjectedMessage | null;
1544
+ }> {
1545
+ safeInteger(limit, "Reddit message limit", 1, 100);
1546
+ const target = requestedMessageId === null
1547
+ ? null
1548
+ : redditFullname(requestedMessageId, "Reddit requested message", ["t4"]);
1549
+ const page = listing(value, "Reddit message Listing", target === null ? limit : 1);
1550
+ const things = target === null
1551
+ ? page.children
1552
+ : Object.freeze(page.children.flatMap((child) => nestedMessageThings(child, 101)));
1553
+ if (things.length > (target === null ? limit : 101)) {
1554
+ throw new Error("Reddit message projection exceeded its reviewed bound");
1555
+ }
1556
+ const messages = things.map((thing, index) =>
1557
+ projectedMessage(thing, `Reddit message ${index}`));
1558
+ const requested = target === null
1559
+ ? null
1560
+ : messages.find((message) => message.id === target) ?? null;
1561
+ if (target !== null && requested === null) {
1562
+ throw new Error("Reddit message response did not bind the requested message");
1563
+ }
1564
+ return Object.freeze({
1565
+ messages: Object.freeze(messages),
1566
+ after: page.after,
1567
+ before: page.before,
1568
+ requested,
1569
+ });
1570
+ }
1571
+
1572
+ export type RedditThingState = {
1573
+ readonly id: string;
1574
+ readonly liked: boolean | null;
1575
+ readonly saved: boolean;
1576
+ };
1577
+
1578
+ export function parseRedditThingState(
1579
+ value: unknown,
1580
+ expectedThingId: string,
1581
+ ): RedditThingState {
1582
+ const target = redditFullname(expectedThingId, "Reddit state target", ["t1", "t3"]);
1583
+ const page = listing(value, "Reddit state Listing", 1);
1584
+ if (page.children.length !== 1) throw new Error("Reddit state readback must contain exactly one thing");
1585
+ const child = page.children[0]!;
1586
+ if (child.kind !== "t1" && child.kind !== "t3") {
1587
+ throw new Error("Reddit state readback returned an unsupported thing kind");
1588
+ }
1589
+ const data = record(child.data, "Reddit state thing.data");
1590
+ const id = redditFullname(data.name, "Reddit state thing.name", ["t1", "t3"]);
1591
+ if (id !== target) throw new Error("Reddit state readback did not bind the requested thing");
1592
+ return Object.freeze({
1593
+ id,
1594
+ liked: nullableBoolean(data.likes, "Reddit state thing.likes"),
1595
+ saved: boolean(data.saved, "Reddit state thing.saved"),
1596
+ });
1597
+ }
1598
+
1599
+ export function assertRedditMutationSuccess(value: unknown): void {
1600
+ const root = record(value, "Reddit mutation response");
1601
+ if (root.json === undefined) {
1602
+ if (Object.keys(root).length !== 0) {
1603
+ throw new Error("Reddit mutation response contained an unreviewed variant");
1604
+ }
1605
+ return;
1606
+ }
1607
+ const json = record(root.json, "Reddit mutation response.json");
1608
+ if (!Array.isArray(json.errors) || json.errors.length !== 0) {
1609
+ throw new Error("Reddit mutation response contained provider errors");
1610
+ }
1611
+ }