@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,1614 @@
1
+ import { canonicalJson } from "../canonical-json";
2
+
3
+ /**
4
+ * Bluesky authenticated first-party API policy and bounded projections.
5
+ *
6
+ * The browser is used only to select the current account from the exact
7
+ * `BSKY_STORAGE` entry. All semantic reads and writes use fixed AT
8
+ * Protocol/XRPC or Bluesky chat procedures through the account's reviewed PDS
9
+ * origin.
10
+ */
11
+
12
+ export const BLUESKY_WEB_OPERATION_NAMES = Object.freeze([
13
+ "comments.read",
14
+ "content.delete",
15
+ "content.save",
16
+ "content.share",
17
+ "feeds.read",
18
+ "likes.set",
19
+ "media.publish",
20
+ "media.read",
21
+ "messaging.list",
22
+ "messaging.read",
23
+ "messaging.send",
24
+ "profiles.read",
25
+ "posts.publish",
26
+ "posts.quote",
27
+ "posts.read",
28
+ "posts.repost",
29
+ "relationships.follow.set",
30
+ "replies.create",
31
+ "threads.publish",
32
+ ] as const);
33
+
34
+ export type BlueskyWebOperationName =
35
+ (typeof BLUESKY_WEB_OPERATION_NAMES)[number];
36
+ export type BlueskyWebContractState = "observed" | "capture-required";
37
+ export type BlueskyWebRisk = "R1" | "R2" | "R3";
38
+
39
+ export type BlueskyWebOperationContract = {
40
+ readonly effect: "read" | "write";
41
+ readonly risk: BlueskyWebRisk;
42
+ readonly state: BlueskyWebContractState;
43
+ readonly reason: string;
44
+ };
45
+
46
+ const captureRequired = (
47
+ effect: "read" | "write",
48
+ risk: BlueskyWebRisk,
49
+ reason: string,
50
+ ): BlueskyWebOperationContract => Object.freeze({
51
+ effect,
52
+ risk,
53
+ state: "capture-required",
54
+ reason,
55
+ });
56
+
57
+ const observed = (
58
+ effect: "read" | "write",
59
+ risk: BlueskyWebRisk,
60
+ reason: string,
61
+ ): BlueskyWebOperationContract => Object.freeze({
62
+ effect,
63
+ risk,
64
+ state: "observed",
65
+ reason,
66
+ });
67
+
68
+ export const BLUESKY_WEB_OPERATIONS = Object.freeze({
69
+ "profiles.read": observed(
70
+ "read",
71
+ "R1",
72
+ "fixed getProfile AppView read binds the exact requested handle and projects exact follower, following, and post counts",
73
+ ),
74
+ "feeds.read": observed(
75
+ "read",
76
+ "R1",
77
+ "fixed getTimeline, listNotifications, and getBookmarks XRPC reads are account-bound and project bounded results",
78
+ ),
79
+ "posts.read": observed(
80
+ "read",
81
+ "R1",
82
+ "fixed getPosts XRPC reads bind the exact requested AT URI and project one bounded post",
83
+ ),
84
+ "comments.read": observed(
85
+ "read",
86
+ "R1",
87
+ "fixed getPostThread XRPC reads bind the exact requested AT URI and bound the reply projection",
88
+ ),
89
+ "media.read": observed(
90
+ "read",
91
+ "R1",
92
+ "fixed getPosts XRPC reads project attachment metadata without returning media URLs",
93
+ ),
94
+ "media.publish": observed(
95
+ "write",
96
+ "R3",
97
+ "the fixed legacy video-service upload, bounded public job poll, response-bound blob, repository record, durable accepted target, and authoritative record plus AppView readback are implemented from the current first-party protocol and verified fixture",
98
+ ),
99
+ "messaging.list": captureRequired(
100
+ "read",
101
+ "R1",
102
+ "the fixed listConvos query needs a live nonempty conversation fixture proving bsky_chat proxy routing and viewer membership projection",
103
+ ),
104
+ "messaging.read": captureRequired(
105
+ "read",
106
+ "R1",
107
+ "the fixed getConvo and getMessages queries need a live conversation fixture proving exact membership, conversation binding, and message projection",
108
+ ),
109
+ "likes.set": captureRequired(
110
+ "write",
111
+ "R2",
112
+ "the code-owned desired-state like exchange needs an authorized live fixture proving create/delete and independent readback",
113
+ ),
114
+ "content.save": captureRequired(
115
+ "write",
116
+ "R2",
117
+ "the code-owned private bookmark exchange needs an authorized live fixture proving create/delete and independent readback",
118
+ ),
119
+ "content.delete": observed(
120
+ "write",
121
+ "R3",
122
+ "exact current-account app.bsky.feed.post deletion uses an authoritative PDS pre-read, CID compare-and-swap, strict commit response, and authoritative RecordNotFound readback",
123
+ ),
124
+ "relationships.follow.set": captureRequired(
125
+ "write",
126
+ "R2",
127
+ "the code-owned desired-state follow exchange needs an authorized live fixture proving create/delete and independent readback",
128
+ ),
129
+ "posts.repost": captureRequired(
130
+ "write",
131
+ "R3",
132
+ "the code-owned repost exchange needs an authorized live fixture proving create/delete and independent readback",
133
+ ),
134
+ "posts.publish": observed(
135
+ "write",
136
+ "R3",
137
+ "current code-owned plain-text and single-image uploadBlob/createRecord path with durable accepted-target evidence, authoritative getRecord binding, and bounded independent getPosts projection readback",
138
+ ),
139
+ "replies.create": captureRequired(
140
+ "write",
141
+ "R3",
142
+ "the code-owned reply path needs an authorized live fixture proving exact root, parent, response, and readback binding",
143
+ ),
144
+ "posts.quote": captureRequired(
145
+ "write",
146
+ "R3",
147
+ "the code-owned quote path needs an authorized live fixture proving the embedded strong reference and independent readback",
148
+ ),
149
+ "threads.publish": captureRequired(
150
+ "write",
151
+ "R3",
152
+ "the code-owned one-to-twenty-five dispatch path needs an authorized live fixture proving every ordered root/reply readback",
153
+ ),
154
+ "messaging.send": captureRequired(
155
+ "write",
156
+ "R3",
157
+ "the code-owned sendMessage path needs an authorized live fixture proving conversation, sender, response, and getMessages readback binding",
158
+ ),
159
+ "content.share": captureRequired(
160
+ "write",
161
+ "R3",
162
+ "Bluesky exposes repost and quote as distinct supported operations; no separate native share mutation is proven",
163
+ ),
164
+ } as const satisfies Readonly<
165
+ Record<BlueskyWebOperationName, BlueskyWebOperationContract>
166
+ >);
167
+
168
+ export const BLUESKY_APP_ORIGIN = "https://bsky.app";
169
+ export const BLUESKY_VIDEO_SERVICE_ORIGIN = "https://video.bsky.app";
170
+ export const BLUESKY_APPVIEW_PROXY =
171
+ "did:web:api.bsky.app#bsky_appview";
172
+ export const BLUESKY_NOTIFICATION_PROXY =
173
+ "did:web:api.bsky.app#bsky_notif";
174
+ export const BLUESKY_CHAT_PROXY =
175
+ "did:web:api.bsky.chat#bsky_chat";
176
+
177
+ const MAX_RESPONSE_ITEMS = 1_000;
178
+ const MAX_TEXT = 32_768;
179
+ const didPattern =
180
+ /^did:(?:plc:[a-z2-7]{24}|web:[A-Za-z0-9._:%-]{1,240})$/u;
181
+ const handlePattern =
182
+ /^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/u;
183
+ const cidPattern = /^b[a-z2-7]{10,200}$/u;
184
+ const rkeyPattern = /^[A-Za-z0-9._~:@!$&'()*+,;=-]{1,512}$/u;
185
+
186
+ type JsonRecord = Record<string, unknown>;
187
+
188
+ function isRecord(value: unknown): value is JsonRecord {
189
+ return typeof value === "object" && value !== null && !Array.isArray(value);
190
+ }
191
+
192
+ function record(value: unknown, label: string): JsonRecord {
193
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
194
+ return value;
195
+ }
196
+
197
+ function exactKeys(
198
+ value: JsonRecord,
199
+ required: readonly string[],
200
+ optional: readonly string[],
201
+ label: string,
202
+ ): void {
203
+ const keys = new Set(Object.keys(value));
204
+ for (const key of required) {
205
+ if (!keys.delete(key)) throw new Error(`${label} omitted ${key}`);
206
+ }
207
+ for (const key of optional) keys.delete(key);
208
+ if (keys.size > 0) {
209
+ throw new Error(`${label} contained unsupported fields`);
210
+ }
211
+ }
212
+
213
+ function string(
214
+ value: unknown,
215
+ label: string,
216
+ maximum = MAX_TEXT,
217
+ allowEmpty = false,
218
+ ): string {
219
+ if (
220
+ typeof value !== "string"
221
+ || (!allowEmpty && value.length < 1)
222
+ || value.length > maximum
223
+ || /[\0\r]/u.test(value)
224
+ ) throw new Error(`${label} must be bounded text`);
225
+ return value;
226
+ }
227
+
228
+ function optionalString(
229
+ value: unknown,
230
+ label: string,
231
+ maximum = MAX_TEXT,
232
+ ): string | null {
233
+ if (value === undefined || value === null || value === "") return null;
234
+ return string(value, label, maximum);
235
+ }
236
+
237
+ function boolean(value: unknown, label: string): boolean {
238
+ if (typeof value !== "boolean") throw new Error(`${label} must be boolean`);
239
+ return value;
240
+ }
241
+
242
+ function integer(
243
+ value: unknown,
244
+ label: string,
245
+ minimum = 0,
246
+ maximum = Number.MAX_SAFE_INTEGER,
247
+ ): number {
248
+ if (
249
+ !Number.isSafeInteger(value)
250
+ || (value as number) < minimum
251
+ || (value as number) > maximum
252
+ ) throw new Error(`${label} must be a bounded integer`);
253
+ return value as number;
254
+ }
255
+
256
+ function nullableInteger(value: unknown, label: string): number | null {
257
+ if (value === undefined || value === null) return null;
258
+ return integer(value, label);
259
+ }
260
+
261
+ function boundedArray(value: unknown, label: string): readonly unknown[] {
262
+ if (!Array.isArray(value) || value.length > MAX_RESPONSE_ITEMS) {
263
+ throw new Error(`${label} must be a bounded array`);
264
+ }
265
+ return value;
266
+ }
267
+
268
+ export function blueskyDid(value: unknown, label = "Bluesky DID"): string {
269
+ const result = string(value, label, 255);
270
+ if (!didPattern.test(result)) throw new Error(`${label} must be an exact DID`);
271
+ return result;
272
+ }
273
+
274
+ export function blueskyCid(value: unknown, label = "Bluesky CID"): string {
275
+ const result = string(value, label, 201);
276
+ if (!cidPattern.test(result)) throw new Error(`${label} must be a CIDv1`);
277
+ return result;
278
+ }
279
+
280
+ export type BlueskyAtUri = {
281
+ readonly uri: string;
282
+ readonly actor: string;
283
+ readonly collection: string;
284
+ readonly rkey: string;
285
+ };
286
+
287
+ export function parseBlueskyAtUri(
288
+ value: unknown,
289
+ label = "Bluesky AT URI",
290
+ expectedCollection?: string,
291
+ ): BlueskyAtUri {
292
+ const uri = string(value, label, 1_024);
293
+ const match = /^at:\/\/([^/]+)\/([a-z][a-z0-9]*(?:\.[a-z][a-z0-9-]*){2,})\/([^/]+)$/u.exec(uri);
294
+ if (match?.[1] === undefined || match[2] === undefined || match[3] === undefined) {
295
+ throw new Error(`${label} must be an exact record AT URI`);
296
+ }
297
+ const actor = blueskyDid(match[1], `${label} actor`);
298
+ const collection = string(match[2], `${label} collection`, 255);
299
+ if (
300
+ expectedCollection !== undefined
301
+ && collection !== expectedCollection
302
+ ) throw new Error(`${label} must identify ${expectedCollection}`);
303
+ if (!rkeyPattern.test(match[3])) throw new Error(`${label} has an invalid record key`);
304
+ return Object.freeze({ uri, actor, collection, rkey: match[3] });
305
+ }
306
+
307
+ export function blueskyPostUri(
308
+ value: unknown,
309
+ label = "Bluesky post URI",
310
+ ): BlueskyAtUri {
311
+ return parseBlueskyAtUri(value, label, "app.bsky.feed.post");
312
+ }
313
+
314
+ function exactHttpsOrigin(value: unknown, label: string): string {
315
+ const source = string(value, label, 2_048);
316
+ let url: URL;
317
+ try {
318
+ url = new URL(source);
319
+ } catch {
320
+ throw new Error(`${label} must be an absolute URL`);
321
+ }
322
+ if (
323
+ url.protocol !== "https:"
324
+ || url.username !== ""
325
+ || url.password !== ""
326
+ || url.pathname !== "/"
327
+ || url.search !== ""
328
+ || url.hash !== ""
329
+ ) throw new Error(`${label} must be an exact canonical HTTPS origin`);
330
+ const hostname = url.hostname.toLowerCase();
331
+ if (
332
+ hostname !== "bsky.social"
333
+ && !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.host\.bsky\.network$/u.test(hostname)
334
+ ) throw new Error(`${label} is outside the reviewed Bluesky PDS host allowlist`);
335
+ return url.origin;
336
+ }
337
+
338
+ function decodeJwtPayload(token: string, label: string): JsonRecord {
339
+ const parts = token.split(".");
340
+ if (
341
+ parts.length !== 3
342
+ || parts.some((part) => !/^[A-Za-z0-9_-]{1,8192}$/u.test(part))
343
+ ) throw new Error(`Bluesky ${label} token is not a bounded JWT`);
344
+ let decoded: string;
345
+ try {
346
+ decoded = Buffer.from(parts[1]!, "base64url").toString("utf8");
347
+ } catch {
348
+ throw new Error(`Bluesky ${label} token payload is invalid`);
349
+ }
350
+ if (decoded.length < 2 || decoded.length > 16_384) {
351
+ throw new Error(`Bluesky ${label} token payload exceeded its reviewed bound`);
352
+ }
353
+ let value: unknown;
354
+ try {
355
+ value = JSON.parse(decoded) as unknown;
356
+ } catch {
357
+ throw new Error(`Bluesky ${label} token payload is malformed`);
358
+ }
359
+ return record(value, `Bluesky ${label} token payload`);
360
+ }
361
+
362
+ export type BlueskySessionMaterial = {
363
+ readonly did: string;
364
+ readonly handle: string;
365
+ readonly accessJwt: string;
366
+ readonly refreshJwt: string;
367
+ readonly accessExpiresAt: number;
368
+ readonly refreshExpiresAt: number;
369
+ readonly pdsOrigin: string;
370
+ };
371
+
372
+ /**
373
+ * Parse the minimal bootstrap envelope produced by the sealed browser
374
+ * evaluator. Refresh material crosses the boundary only so the code-owned PDS
375
+ * refresh procedure can rotate an expired access token into wrench's encrypted
376
+ * session cache; it is never emitted, logged, or stored in the auth locator.
377
+ */
378
+ export function parseBlueskyBootstrapAccount(
379
+ value: unknown,
380
+ nowSeconds = Math.floor(Date.now() / 1_000),
381
+ ): BlueskySessionMaterial {
382
+ const account = record(value, "Bluesky browser bootstrap account");
383
+ exactKeys(
384
+ account,
385
+ ["did", "handle", "accessJwt", "refreshJwt", "service", "pdsUrl"],
386
+ [],
387
+ "Bluesky browser bootstrap account",
388
+ );
389
+ const did = blueskyDid(account.did);
390
+ const handle = string(account.handle, "Bluesky handle", 253);
391
+ if (!handlePattern.test(handle)) throw new Error("Bluesky handle is invalid");
392
+ const accessJwt = string(account.accessJwt, "Bluesky access token", 24_576);
393
+ if (/[\s]/u.test(accessJwt)) throw new Error("Bluesky access token is invalid");
394
+ const refreshJwt = string(account.refreshJwt, "Bluesky refresh token", 24_576);
395
+ if (/[\s]/u.test(refreshJwt)) throw new Error("Bluesky refresh token is invalid");
396
+ const pdsOrigin = exactHttpsOrigin(
397
+ account.pdsUrl ?? account.service,
398
+ "Bluesky PDS origin",
399
+ );
400
+ const accessClaims = decodeJwtPayload(accessJwt, "access");
401
+ const refreshClaims = decodeJwtPayload(refreshJwt, "refresh");
402
+ if (accessClaims.sub !== did) throw new Error("Bluesky access token subject did not match the selected account");
403
+ if (refreshClaims.sub !== did) throw new Error("Bluesky refresh token subject did not match the selected account");
404
+ if (
405
+ !Number.isSafeInteger(nowSeconds)
406
+ || nowSeconds < 0
407
+ || !Number.isSafeInteger(accessClaims.exp)
408
+ || !Number.isSafeInteger(refreshClaims.exp)
409
+ ) throw new Error("Bluesky session tokens lack a valid expiry");
410
+ const accessExpiresAt = accessClaims.exp as number;
411
+ const refreshExpiresAt = refreshClaims.exp as number;
412
+ if (refreshExpiresAt <= nowSeconds) throw new Error("Bluesky refresh token is expired");
413
+ return Object.freeze({
414
+ did,
415
+ handle,
416
+ accessJwt,
417
+ refreshJwt,
418
+ accessExpiresAt,
419
+ refreshExpiresAt,
420
+ pdsOrigin,
421
+ });
422
+ }
423
+
424
+ export function parseBlueskyRefreshSessionResponse(
425
+ value: unknown,
426
+ selected: Pick<BlueskySessionMaterial, "did" | "pdsOrigin">,
427
+ nowSeconds = Math.floor(Date.now() / 1_000),
428
+ ): BlueskySessionMaterial {
429
+ const session = record(value, "Bluesky refresh-session response");
430
+ exactKeys(
431
+ session,
432
+ ["accessJwt", "refreshJwt", "handle", "did"],
433
+ [
434
+ "didDoc",
435
+ "email",
436
+ "emailConfirmed",
437
+ "emailAuthFactor",
438
+ "active",
439
+ "status",
440
+ ],
441
+ "Bluesky refresh-session response",
442
+ );
443
+ const did = blueskyDid(session.did, "Bluesky refreshed DID");
444
+ if (did !== selected.did) {
445
+ throw new Error("Bluesky refresh-session DID did not match the selected account");
446
+ }
447
+ const handle = string(session.handle, "Bluesky refreshed handle", 253);
448
+ if (!handlePattern.test(handle)) throw new Error("Bluesky refreshed handle is invalid");
449
+ if (session.active === false || session.status !== undefined) {
450
+ throw new Error("Bluesky refresh-session response reported an inactive account");
451
+ }
452
+ const accessJwt = string(session.accessJwt, "Bluesky refreshed access token", 24_576);
453
+ const refreshJwt = string(session.refreshJwt, "Bluesky rotated refresh token", 24_576);
454
+ if (/[\s]/u.test(accessJwt) || /[\s]/u.test(refreshJwt)) {
455
+ throw new Error("Bluesky refresh-session response returned an invalid token");
456
+ }
457
+ const accessClaims = decodeJwtPayload(accessJwt, "access");
458
+ const refreshClaims = decodeJwtPayload(refreshJwt, "refresh");
459
+ if (accessClaims.sub !== did || refreshClaims.sub !== did) {
460
+ throw new Error("Bluesky refreshed token subject did not match the selected account");
461
+ }
462
+ if (
463
+ !Number.isSafeInteger(nowSeconds)
464
+ || nowSeconds < 0
465
+ || !Number.isSafeInteger(accessClaims.exp)
466
+ || !Number.isSafeInteger(refreshClaims.exp)
467
+ ) throw new Error("Bluesky refreshed session tokens lack a valid expiry");
468
+ const accessExpiresAt = accessClaims.exp as number;
469
+ const refreshExpiresAt = refreshClaims.exp as number;
470
+ if (accessExpiresAt <= nowSeconds || refreshExpiresAt <= nowSeconds) {
471
+ throw new Error("Bluesky refresh-session response returned an expired token");
472
+ }
473
+ return Object.freeze({
474
+ did,
475
+ handle,
476
+ accessJwt,
477
+ refreshJwt,
478
+ accessExpiresAt,
479
+ refreshExpiresAt,
480
+ pdsOrigin: selected.pdsOrigin,
481
+ });
482
+ }
483
+
484
+ export const BLUESKY_XRPC_METHODS = Object.freeze({
485
+ "com.atproto.server.getSession": "GET",
486
+ "com.atproto.server.refreshSession": "POST",
487
+ "com.atproto.server.getServiceAuth": "GET",
488
+ "app.bsky.feed.getTimeline": "GET",
489
+ "app.bsky.feed.getPosts": "GET",
490
+ "app.bsky.feed.getPostThread": "GET",
491
+ "app.bsky.notification.listNotifications": "GET",
492
+ "app.bsky.bookmark.getBookmarks": "GET",
493
+ "app.bsky.actor.getProfile": "GET",
494
+ "chat.bsky.convo.listConvos": "GET",
495
+ "chat.bsky.convo.getConvo": "GET",
496
+ "chat.bsky.convo.getMessages": "GET",
497
+ "com.atproto.repo.getRecord": "GET",
498
+ "com.atproto.repo.createRecord": "POST",
499
+ "com.atproto.repo.deleteRecord": "POST",
500
+ "com.atproto.repo.uploadBlob": "POST",
501
+ "app.bsky.bookmark.createBookmark": "POST",
502
+ "app.bsky.bookmark.deleteBookmark": "POST",
503
+ "chat.bsky.convo.sendMessage": "POST",
504
+ } as const);
505
+
506
+ export type BlueskyXrpcMethod = keyof typeof BLUESKY_XRPC_METHODS;
507
+
508
+ export type BlueskyRequestBinding = {
509
+ readonly nsid: BlueskyXrpcMethod;
510
+ readonly method: "GET" | "POST";
511
+ readonly path: string;
512
+ readonly queryNames: readonly string[];
513
+ readonly proxy:
514
+ | typeof BLUESKY_APPVIEW_PROXY
515
+ | typeof BLUESKY_NOTIFICATION_PROXY
516
+ | typeof BLUESKY_CHAT_PROXY
517
+ | null;
518
+ };
519
+
520
+ const BLUESKY_XRPC_PROXIES = Object.freeze({
521
+ "com.atproto.server.getSession": null,
522
+ "com.atproto.server.refreshSession": null,
523
+ "com.atproto.server.getServiceAuth": null,
524
+ "app.bsky.feed.getTimeline": BLUESKY_APPVIEW_PROXY,
525
+ "app.bsky.feed.getPosts": BLUESKY_APPVIEW_PROXY,
526
+ "app.bsky.feed.getPostThread": BLUESKY_APPVIEW_PROXY,
527
+ "app.bsky.notification.listNotifications": BLUESKY_NOTIFICATION_PROXY,
528
+ "app.bsky.bookmark.getBookmarks": BLUESKY_APPVIEW_PROXY,
529
+ "app.bsky.actor.getProfile": BLUESKY_APPVIEW_PROXY,
530
+ "chat.bsky.convo.listConvos": BLUESKY_CHAT_PROXY,
531
+ "chat.bsky.convo.getConvo": BLUESKY_CHAT_PROXY,
532
+ "chat.bsky.convo.getMessages": BLUESKY_CHAT_PROXY,
533
+ "com.atproto.repo.getRecord": null,
534
+ "com.atproto.repo.createRecord": null,
535
+ "com.atproto.repo.deleteRecord": null,
536
+ "com.atproto.repo.uploadBlob": null,
537
+ "app.bsky.bookmark.createBookmark": BLUESKY_APPVIEW_PROXY,
538
+ "app.bsky.bookmark.deleteBookmark": BLUESKY_APPVIEW_PROXY,
539
+ "chat.bsky.convo.sendMessage": BLUESKY_CHAT_PROXY,
540
+ } as const satisfies Readonly<
541
+ Record<BlueskyXrpcMethod, BlueskyRequestBinding["proxy"]>
542
+ >);
543
+
544
+ export function authorizeBlueskyXrpcRequest(input: {
545
+ readonly pdsOrigin: string;
546
+ readonly nsid: BlueskyXrpcMethod;
547
+ readonly url: string | URL;
548
+ readonly method: string;
549
+ readonly expectedQuery: Readonly<Record<string, readonly string[]>>;
550
+ readonly hasBody: boolean;
551
+ readonly proxy: BlueskyRequestBinding["proxy"];
552
+ }): BlueskyRequestBinding {
553
+ const pdsOrigin = exactHttpsOrigin(input.pdsOrigin, "Bluesky request PDS origin");
554
+ const url = input.url instanceof URL ? new URL(input.url.href) : new URL(input.url);
555
+ if (
556
+ url.origin !== pdsOrigin
557
+ || url.username !== ""
558
+ || url.password !== ""
559
+ || url.hash !== ""
560
+ || url.pathname !== `/xrpc/${input.nsid}`
561
+ ) throw new Error("Bluesky request escaped its exact reviewed XRPC endpoint");
562
+ const expectedMethod = BLUESKY_XRPC_METHODS[input.nsid];
563
+ const method = input.method.toUpperCase();
564
+ if (method !== expectedMethod) throw new Error("Bluesky request method changed from its reviewed contract");
565
+ if (input.proxy !== BLUESKY_XRPC_PROXIES[input.nsid]) {
566
+ throw new Error("Bluesky request proxy changed from its reviewed contract");
567
+ }
568
+ const expectsBody = method === "POST"
569
+ && input.nsid !== "com.atproto.server.refreshSession";
570
+ if (input.hasBody !== expectsBody) {
571
+ throw new Error("Bluesky request body did not match its reviewed method");
572
+ }
573
+ const actual = new Map<string, string[]>();
574
+ for (const [name, value] of url.searchParams) {
575
+ const values = actual.get(name) ?? [];
576
+ values.push(value);
577
+ actual.set(name, values);
578
+ }
579
+ const expectedNames = Object.keys(input.expectedQuery).sort();
580
+ if (
581
+ [...actual.keys()].sort().join("\0") !== expectedNames.join("\0")
582
+ ) throw new Error("Bluesky request query names changed from their reviewed contract");
583
+ for (const name of expectedNames) {
584
+ const expectedValues = input.expectedQuery[name] ?? [];
585
+ const actualValues = actual.get(name) ?? [];
586
+ if (
587
+ expectedValues.length !== actualValues.length
588
+ || expectedValues.some((value, index) => actualValues[index] !== value)
589
+ ) throw new Error(`Bluesky request query value ${name} changed from its reviewed contract`);
590
+ }
591
+ return Object.freeze({
592
+ nsid: input.nsid,
593
+ method,
594
+ path: url.pathname,
595
+ queryNames: Object.freeze(expectedNames),
596
+ proxy: input.proxy,
597
+ });
598
+ }
599
+
600
+ export type BlueskyVideoRequestBinding = Readonly<{
601
+ method: "GET" | "POST";
602
+ path:
603
+ | "/xrpc/app.bsky.video.getJobStatus"
604
+ | "/xrpc/app.bsky.video.uploadVideo";
605
+ queryNames: readonly string[];
606
+ }>;
607
+
608
+ /** Restrict video-service traffic to the two exact routes used by publishing. */
609
+ export function authorizeBlueskyVideoRequest(input: {
610
+ readonly kind: "job-status" | "upload";
611
+ readonly url: string | URL;
612
+ readonly method: string;
613
+ readonly did?: string;
614
+ readonly jobId?: string;
615
+ }): BlueskyVideoRequestBinding {
616
+ const url = input.url instanceof URL ? new URL(input.url.href) : new URL(input.url);
617
+ if (
618
+ url.origin !== BLUESKY_VIDEO_SERVICE_ORIGIN
619
+ || url.username !== ""
620
+ || url.password !== ""
621
+ || url.hash !== ""
622
+ ) throw new Error("Bluesky video request escaped its exact reviewed service origin");
623
+ const expected: Readonly<{
624
+ method: "GET" | "POST";
625
+ path: BlueskyVideoRequestBinding["path"];
626
+ query: Readonly<Record<string, readonly string[]>>;
627
+ }> = input.kind === "upload"
628
+ ? {
629
+ method: "POST" as const,
630
+ path: "/xrpc/app.bsky.video.uploadVideo" as const,
631
+ query: Object.freeze({
632
+ did: [blueskyDid(input.did, "Bluesky video upload DID")],
633
+ name: ["wrench-video.mp4"],
634
+ }),
635
+ }
636
+ : {
637
+ method: "GET" as const,
638
+ path: "/xrpc/app.bsky.video.getJobStatus" as const,
639
+ query: Object.freeze({
640
+ jobId: [string(input.jobId, "Bluesky video job ID", 1_024)],
641
+ }),
642
+ };
643
+ const method = input.method.toUpperCase();
644
+ if (method !== expected.method || url.pathname !== expected.path) {
645
+ throw new Error("Bluesky video request changed from its reviewed route");
646
+ }
647
+ const actual = new Map<string, string[]>();
648
+ for (const [name, value] of url.searchParams) {
649
+ const values = actual.get(name) ?? [];
650
+ values.push(value);
651
+ actual.set(name, values);
652
+ }
653
+ const expectedNames = Object.keys(expected.query).sort();
654
+ if ([...actual.keys()].sort().join("\0") !== expectedNames.join("\0")) {
655
+ throw new Error("Bluesky video request query names changed from their reviewed contract");
656
+ }
657
+ for (const name of expectedNames) {
658
+ const expectedValues = expected.query[name] ?? [];
659
+ const actualValues = actual.get(name) ?? [];
660
+ if (
661
+ expectedValues.length !== actualValues.length
662
+ || expectedValues.some((value, index) => actualValues[index] !== value)
663
+ ) throw new Error(`Bluesky video request query value ${name} changed from its reviewed contract`);
664
+ }
665
+ return Object.freeze({
666
+ method,
667
+ path: expected.path,
668
+ queryNames: Object.freeze(expectedNames),
669
+ });
670
+ }
671
+
672
+ /** Parse a PDS-minted, method-bound service token before it leaves the PDS realm. */
673
+ export function parseBlueskyServiceAuthResponse(
674
+ value: unknown,
675
+ expected: {
676
+ readonly did: string;
677
+ readonly audience: string;
678
+ readonly lxm: string;
679
+ readonly expiresAt: number;
680
+ readonly nowSeconds: number;
681
+ },
682
+ ): string {
683
+ const response = record(value, "Bluesky service-auth response");
684
+ exactKeys(response, ["token"], [], "Bluesky service-auth response");
685
+ const token = string(response.token, "Bluesky service-auth token", 24_576);
686
+ if (/\s/u.test(token)) throw new Error("Bluesky service-auth token is invalid");
687
+ const claims = decodeJwtPayload(token, "service-auth");
688
+ const did = blueskyDid(expected.did, "Bluesky service-auth expected issuer");
689
+ const audience = blueskyDid(expected.audience, "Bluesky service-auth expected audience");
690
+ if (
691
+ claims.iss !== did
692
+ || claims.aud !== audience
693
+ || claims.lxm !== expected.lxm
694
+ ) throw new Error("Bluesky service-auth token changed its exact issuer, audience, or method binding");
695
+ if (
696
+ !Number.isSafeInteger(expected.nowSeconds)
697
+ || !Number.isSafeInteger(expected.expiresAt)
698
+ || expected.nowSeconds < 0
699
+ || expected.expiresAt <= expected.nowSeconds
700
+ || expected.expiresAt - expected.nowSeconds > 1_800
701
+ || claims.exp !== expected.expiresAt
702
+ ) throw new Error("Bluesky service-auth token changed its reviewed expiry");
703
+ return token;
704
+ }
705
+
706
+ export type BlueskyStrongRef = {
707
+ readonly uri: string;
708
+ readonly cid: string;
709
+ };
710
+
711
+ export function blueskyStrongRef(
712
+ value: unknown,
713
+ label: string,
714
+ expectedUri?: string,
715
+ ): BlueskyStrongRef {
716
+ const candidate = record(value, label);
717
+ const uri = blueskyPostUri(candidate.uri, `${label}.uri`).uri;
718
+ if (expectedUri !== undefined && uri !== expectedUri) {
719
+ throw new Error(`${label} did not match the expected post`);
720
+ }
721
+ return Object.freeze({
722
+ uri,
723
+ cid: blueskyCid(candidate.cid, `${label}.cid`),
724
+ });
725
+ }
726
+
727
+ export type BlueskyProjectedAttachment = {
728
+ readonly kind: "image" | "video" | "external" | "record";
729
+ readonly alt: string | null;
730
+ readonly cid: string | null;
731
+ readonly recordUri: string | null;
732
+ readonly aspectRatio: { readonly width: number; readonly height: number } | null;
733
+ };
734
+
735
+ function aspectRatio(value: unknown, label: string): BlueskyProjectedAttachment["aspectRatio"] {
736
+ if (value === undefined || value === null) return null;
737
+ const ratio = record(value, label);
738
+ return Object.freeze({
739
+ width: integer(ratio.width, `${label}.width`, 1, 100_000),
740
+ height: integer(ratio.height, `${label}.height`, 1, 100_000),
741
+ });
742
+ }
743
+
744
+ function blobCid(value: unknown, label: string): string | null {
745
+ if (!isRecord(value)) return null;
746
+ const ref = isRecord(value.ref) ? value.ref : null;
747
+ if (ref === null || ref.$link === undefined) return null;
748
+ return blueskyCid(ref.$link, `${label}.ref`);
749
+ }
750
+
751
+ export function projectBlueskyAttachments(
752
+ value: unknown,
753
+ ): readonly BlueskyProjectedAttachment[] {
754
+ if (value === undefined || value === null) return Object.freeze([]);
755
+ const embed = record(value, "Bluesky post embed");
756
+ const type = optionalString(embed.$type, "Bluesky post embed type", 128);
757
+ const results: BlueskyProjectedAttachment[] = [];
758
+ const addMedia = (candidate: unknown): void => {
759
+ const media = record(candidate, "Bluesky media embed");
760
+ const mediaType = optionalString(media.$type, "Bluesky media embed type", 128);
761
+ if (mediaType?.includes("images")) {
762
+ for (const item of boundedArray(media.images, "Bluesky image list")) {
763
+ const image = record(item, "Bluesky image");
764
+ results.push(Object.freeze({
765
+ kind: "image",
766
+ alt: optionalString(image.alt, "Bluesky image alt", 10_000),
767
+ cid: blobCid(image.image, "Bluesky image blob"),
768
+ recordUri: null,
769
+ aspectRatio: aspectRatio(image.aspectRatio, "Bluesky image aspect ratio"),
770
+ }));
771
+ }
772
+ } else if (mediaType?.includes("video")) {
773
+ results.push(Object.freeze({
774
+ kind: "video",
775
+ alt: optionalString(media.alt, "Bluesky video alt", 10_000),
776
+ cid: optionalString(media.cid, "Bluesky video CID", 201) === null
777
+ ? blobCid(media.video, "Bluesky video blob")
778
+ : blueskyCid(media.cid, "Bluesky video CID"),
779
+ recordUri: null,
780
+ aspectRatio: aspectRatio(media.aspectRatio, "Bluesky video aspect ratio"),
781
+ }));
782
+ } else if (mediaType?.includes("external")) {
783
+ results.push(Object.freeze({
784
+ kind: "external",
785
+ alt: optionalString(
786
+ isRecord(media.external) ? media.external.description : media.description,
787
+ "Bluesky external description",
788
+ 10_000,
789
+ ),
790
+ cid: null,
791
+ recordUri: null,
792
+ aspectRatio: null,
793
+ }));
794
+ }
795
+ };
796
+ if (type?.includes("recordWithMedia")) {
797
+ addMedia(embed.media);
798
+ results.push(Object.freeze({
799
+ kind: "record",
800
+ alt: null,
801
+ cid: null,
802
+ recordUri: isRecord(embed.record) && typeof embed.record.uri === "string"
803
+ ? blueskyPostUri(embed.record.uri, "Bluesky embedded record URI").uri
804
+ : null,
805
+ aspectRatio: null,
806
+ }));
807
+ } else if (type?.includes("record")) {
808
+ results.push(Object.freeze({
809
+ kind: "record",
810
+ alt: null,
811
+ cid: null,
812
+ recordUri: isRecord(embed.record) && typeof embed.record.uri === "string"
813
+ ? blueskyPostUri(embed.record.uri, "Bluesky embedded record URI").uri
814
+ : typeof embed.uri === "string"
815
+ ? blueskyPostUri(embed.uri, "Bluesky embedded record URI").uri
816
+ : null,
817
+ aspectRatio: null,
818
+ }));
819
+ } else {
820
+ addMedia(embed);
821
+ }
822
+ if (results.length > 4) throw new Error("Bluesky post embed exceeded the reviewed attachment limit");
823
+ return Object.freeze(results);
824
+ }
825
+
826
+ export type BlueskyProjectedPost = {
827
+ readonly uri: string;
828
+ readonly cid: string;
829
+ readonly author: {
830
+ readonly did: string;
831
+ readonly handle: string;
832
+ readonly displayName: string | null;
833
+ };
834
+ readonly text: string;
835
+ readonly createdAt: string;
836
+ readonly indexedAt: string;
837
+ readonly reply: {
838
+ readonly root: BlueskyStrongRef;
839
+ readonly parent: BlueskyStrongRef;
840
+ } | null;
841
+ readonly quote: BlueskyStrongRef | null;
842
+ readonly counts: {
843
+ readonly replies: number | null;
844
+ readonly reposts: number | null;
845
+ readonly likes: number | null;
846
+ readonly quotes: number | null;
847
+ };
848
+ readonly viewer: {
849
+ readonly like: string | null;
850
+ readonly repost: string | null;
851
+ readonly bookmarked: boolean;
852
+ };
853
+ readonly attachments: readonly BlueskyProjectedAttachment[];
854
+ };
855
+
856
+ export function projectBlueskyPost(
857
+ value: unknown,
858
+ expectedUri?: string,
859
+ ): BlueskyProjectedPost {
860
+ const post = record(value, "Bluesky post");
861
+ const uri = blueskyPostUri(post.uri).uri;
862
+ if (expectedUri !== undefined && uri !== expectedUri) {
863
+ throw new Error("Bluesky post response did not bind the requested post");
864
+ }
865
+ const author = record(post.author, "Bluesky post author");
866
+ const authorDid = blueskyDid(author.did, "Bluesky post author DID");
867
+ if (blueskyPostUri(uri).actor !== authorDid) {
868
+ throw new Error("Bluesky post author did not match its AT URI");
869
+ }
870
+ const handle = string(author.handle, "Bluesky post author handle", 253);
871
+ if (!handlePattern.test(handle)) throw new Error("Bluesky post author handle is invalid");
872
+ const postRecord = record(post.record, "Bluesky post record");
873
+ if (postRecord.$type !== "app.bsky.feed.post") {
874
+ throw new Error("Bluesky post record had an unexpected type");
875
+ }
876
+ const replyRecord = postRecord.reply;
877
+ const reply = replyRecord === undefined
878
+ ? null
879
+ : (() => {
880
+ const refs = record(replyRecord, "Bluesky reply reference");
881
+ return Object.freeze({
882
+ root: blueskyStrongRef(refs.root, "Bluesky reply root"),
883
+ parent: blueskyStrongRef(refs.parent, "Bluesky reply parent"),
884
+ });
885
+ })();
886
+ const recordEmbed = isRecord(postRecord.embed) ? postRecord.embed : null;
887
+ const quoteCandidate = recordEmbed?.$type === "app.bsky.embed.record"
888
+ ? recordEmbed.record
889
+ : recordEmbed?.$type === "app.bsky.embed.recordWithMedia"
890
+ && isRecord(recordEmbed.record)
891
+ ? recordEmbed.record.record
892
+ : undefined;
893
+ const quote = quoteCandidate === undefined
894
+ ? null
895
+ : blueskyStrongRef(quoteCandidate, "Bluesky quoted record");
896
+ const viewer = isRecord(post.viewer) ? post.viewer : {};
897
+ const like = optionalString(viewer.like, "Bluesky viewer like URI", 1_024);
898
+ const repost = optionalString(viewer.repost, "Bluesky viewer repost URI", 1_024);
899
+ if (like !== null) parseBlueskyAtUri(like, "Bluesky viewer like URI", "app.bsky.feed.like");
900
+ if (repost !== null) parseBlueskyAtUri(repost, "Bluesky viewer repost URI", "app.bsky.feed.repost");
901
+ return Object.freeze({
902
+ uri,
903
+ cid: blueskyCid(post.cid),
904
+ author: Object.freeze({
905
+ did: authorDid,
906
+ handle,
907
+ displayName: optionalString(author.displayName, "Bluesky author display name", 1_000),
908
+ }),
909
+ text: string(postRecord.text, "Bluesky post text", 3_000, true),
910
+ createdAt: string(postRecord.createdAt, "Bluesky post creation time", 128),
911
+ indexedAt: string(post.indexedAt, "Bluesky post index time", 128),
912
+ reply,
913
+ quote,
914
+ counts: Object.freeze({
915
+ replies: nullableInteger(post.replyCount, "Bluesky reply count"),
916
+ reposts: nullableInteger(post.repostCount, "Bluesky repost count"),
917
+ likes: nullableInteger(post.likeCount, "Bluesky like count"),
918
+ quotes: nullableInteger(post.quoteCount, "Bluesky quote count"),
919
+ }),
920
+ viewer: Object.freeze({
921
+ like,
922
+ repost,
923
+ bookmarked: viewer.bookmarked === true,
924
+ }),
925
+ attachments: projectBlueskyAttachments(post.embed),
926
+ });
927
+ }
928
+
929
+ export function projectBlueskyPostsResponse(
930
+ value: unknown,
931
+ expectedUri: string,
932
+ ): BlueskyProjectedPost {
933
+ const response = record(value, "Bluesky getPosts response");
934
+ const posts = boundedArray(response.posts, "Bluesky getPosts posts");
935
+ if (posts.length !== 1) throw new Error("Bluesky getPosts response did not contain one exact post");
936
+ return projectBlueskyPost(posts[0], expectedUri);
937
+ }
938
+
939
+ export function projectBlueskyFeed(
940
+ value: unknown,
941
+ limit: number,
942
+ ): {
943
+ readonly posts: readonly BlueskyProjectedPost[];
944
+ readonly cursor: string | null;
945
+ readonly truncated: boolean;
946
+ } {
947
+ const response = record(value, "Bluesky timeline response");
948
+ const feed = boundedArray(response.feed, "Bluesky timeline feed");
949
+ const posts = feed.slice(0, limit).map((item) =>
950
+ projectBlueskyPost(record(item, "Bluesky timeline item").post)
951
+ );
952
+ return Object.freeze({
953
+ posts: Object.freeze(posts),
954
+ cursor: optionalString(response.cursor, "Bluesky timeline cursor", 8_192),
955
+ truncated: feed.length > limit || response.cursor !== undefined,
956
+ });
957
+ }
958
+
959
+ export function projectBlueskyNotifications(
960
+ value: unknown,
961
+ limit: number,
962
+ ): {
963
+ readonly notifications: readonly Readonly<Record<string, unknown>>[];
964
+ readonly cursor: string | null;
965
+ readonly seenAt: string | null;
966
+ readonly truncated: boolean;
967
+ } {
968
+ const response = record(value, "Bluesky notifications response");
969
+ const source = boundedArray(response.notifications, "Bluesky notifications");
970
+ const notifications = source.slice(0, limit).map((item) => {
971
+ const notification = record(item, "Bluesky notification");
972
+ const author = record(notification.author, "Bluesky notification author");
973
+ const uri = string(notification.uri, "Bluesky notification URI", 1_024);
974
+ parseBlueskyAtUri(uri, "Bluesky notification URI");
975
+ const reasonSubject = optionalString(
976
+ notification.reasonSubject,
977
+ "Bluesky notification subject",
978
+ 1_024,
979
+ );
980
+ if (reasonSubject !== null) parseBlueskyAtUri(reasonSubject, "Bluesky notification subject");
981
+ return Object.freeze({
982
+ uri,
983
+ cid: blueskyCid(notification.cid, "Bluesky notification CID"),
984
+ author: Object.freeze({
985
+ did: blueskyDid(author.did, "Bluesky notification author DID"),
986
+ handle: string(author.handle, "Bluesky notification author handle", 253),
987
+ displayName: optionalString(author.displayName, "Bluesky notification author display name", 1_000),
988
+ }),
989
+ reason: string(notification.reason, "Bluesky notification reason", 64),
990
+ reasonSubject,
991
+ isRead: boolean(notification.isRead, "Bluesky notification read state"),
992
+ indexedAt: string(notification.indexedAt, "Bluesky notification index time", 128),
993
+ });
994
+ });
995
+ return Object.freeze({
996
+ notifications: Object.freeze(notifications),
997
+ cursor: optionalString(response.cursor, "Bluesky notifications cursor", 8_192),
998
+ seenAt: optionalString(response.seenAt, "Bluesky notifications seen time", 128),
999
+ truncated: source.length > limit || response.cursor !== undefined,
1000
+ });
1001
+ }
1002
+
1003
+ export function projectBlueskyBookmarks(
1004
+ value: unknown,
1005
+ limit: number,
1006
+ ): {
1007
+ readonly posts: readonly BlueskyProjectedPost[];
1008
+ readonly cursor: string | null;
1009
+ readonly truncated: boolean;
1010
+ } {
1011
+ const response = record(value, "Bluesky bookmarks response");
1012
+ const source = boundedArray(response.bookmarks, "Bluesky bookmarks");
1013
+ const posts = source.slice(0, limit).map((item) => {
1014
+ const bookmark = record(item, "Bluesky bookmark");
1015
+ const subject = blueskyStrongRef(bookmark.subject, "Bluesky bookmark subject");
1016
+ return projectBlueskyPost(bookmark.item, subject.uri);
1017
+ });
1018
+ return Object.freeze({
1019
+ posts: Object.freeze(posts),
1020
+ cursor: optionalString(response.cursor, "Bluesky bookmarks cursor", 8_192),
1021
+ truncated: source.length > limit || response.cursor !== undefined,
1022
+ });
1023
+ }
1024
+
1025
+ export function projectBlueskyThread(
1026
+ value: unknown,
1027
+ expectedUri: string,
1028
+ limit: number,
1029
+ ): {
1030
+ readonly post: BlueskyProjectedPost;
1031
+ readonly replies: readonly BlueskyProjectedPost[];
1032
+ readonly truncated: boolean;
1033
+ } {
1034
+ const response = record(value, "Bluesky thread response");
1035
+ const root = record(response.thread, "Bluesky thread");
1036
+ const post = projectBlueskyPost(root.post, expectedUri);
1037
+ const replies: BlueskyProjectedPost[] = [];
1038
+ const stack: { readonly value: unknown; readonly depth: number }[] = [];
1039
+ if (root.replies !== undefined) {
1040
+ for (const item of [...boundedArray(root.replies, "Bluesky thread replies")].reverse()) {
1041
+ stack.push({ value: item, depth: 1 });
1042
+ }
1043
+ }
1044
+ let visited = 0;
1045
+ while (stack.length > 0) {
1046
+ const next = stack.pop()!;
1047
+ visited += 1;
1048
+ if (visited > MAX_RESPONSE_ITEMS || next.depth > 100) {
1049
+ throw new Error("Bluesky thread exceeded its reviewed traversal bounds");
1050
+ }
1051
+ const item = record(next.value, "Bluesky thread reply");
1052
+ if (item.post === undefined) continue;
1053
+ replies.push(projectBlueskyPost(item.post));
1054
+ if (item.replies !== undefined) {
1055
+ for (const child of [...boundedArray(item.replies, "Bluesky nested replies")].reverse()) {
1056
+ stack.push({ value: child, depth: next.depth + 1 });
1057
+ }
1058
+ }
1059
+ }
1060
+ return Object.freeze({
1061
+ post,
1062
+ replies: Object.freeze(replies.slice(0, limit)),
1063
+ truncated: replies.length > limit,
1064
+ });
1065
+ }
1066
+
1067
+ export type BlueskyProjectedProfile = {
1068
+ readonly did: string;
1069
+ readonly handle: string;
1070
+ readonly displayName: string | null;
1071
+ readonly following: string | null;
1072
+ readonly followedBy: string | null;
1073
+ };
1074
+
1075
+ export type BlueskyProfileStats = {
1076
+ readonly schemaVersion: 1;
1077
+ readonly provider: "bluesky";
1078
+ readonly target: {
1079
+ readonly kind: "profile";
1080
+ readonly id: string;
1081
+ readonly url: string;
1082
+ };
1083
+ readonly observedAt: string;
1084
+ readonly completeness: "complete";
1085
+ readonly metrics: {
1086
+ readonly followers: {
1087
+ readonly status: "available";
1088
+ readonly value: number;
1089
+ readonly precision: "exact";
1090
+ readonly unit: "count";
1091
+ };
1092
+ readonly following: {
1093
+ readonly status: "available";
1094
+ readonly value: number;
1095
+ readonly precision: "exact";
1096
+ readonly unit: "count";
1097
+ };
1098
+ readonly posts: {
1099
+ readonly status: "available";
1100
+ readonly value: number;
1101
+ readonly precision: "exact";
1102
+ readonly unit: "count";
1103
+ };
1104
+ };
1105
+ readonly metadata: {
1106
+ readonly handle: string;
1107
+ readonly displayName: string | null;
1108
+ readonly bio: string | null;
1109
+ };
1110
+ };
1111
+
1112
+ function exactObservationTime(value: string, label: string): string {
1113
+ if (
1114
+ !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value)
1115
+ || !Number.isFinite(Date.parse(value))
1116
+ ) throw new Error(`${label} must be an exact UTC observation time`);
1117
+ return value;
1118
+ }
1119
+
1120
+ function exactCountMetric(value: unknown, label: string) {
1121
+ return Object.freeze({
1122
+ status: "available" as const,
1123
+ value: integer(value, label),
1124
+ precision: "exact" as const,
1125
+ unit: "count" as const,
1126
+ });
1127
+ }
1128
+
1129
+ /** Project one exact AppView profile response into the shared profile-stat envelope. */
1130
+ export function projectBlueskyProfileStats(
1131
+ value: unknown,
1132
+ expectedHandle: string,
1133
+ observedAt: string,
1134
+ ): BlueskyProfileStats {
1135
+ if (!handlePattern.test(expectedHandle) || expectedHandle !== expectedHandle.toLowerCase()) {
1136
+ throw new Error("Bluesky profile target must be one canonical lowercase handle");
1137
+ }
1138
+ const profile = record(value, "Bluesky profile stats");
1139
+ const handle = string(profile.handle, "Bluesky profile stats handle", 253).toLowerCase();
1140
+ if (!handlePattern.test(handle) || handle !== expectedHandle) {
1141
+ throw new Error("Bluesky profile stats response did not bind the requested handle");
1142
+ }
1143
+ const did = blueskyDid(profile.did, "Bluesky profile stats DID");
1144
+ const url = `https://bsky.app/profile/${handle}`;
1145
+ return Object.freeze({
1146
+ schemaVersion: 1,
1147
+ provider: "bluesky",
1148
+ target: Object.freeze({ kind: "profile", id: did, url }),
1149
+ observedAt: exactObservationTime(observedAt, "Bluesky profile stats observedAt"),
1150
+ completeness: "complete",
1151
+ metrics: Object.freeze({
1152
+ followers: exactCountMetric(profile.followersCount, "Bluesky followersCount"),
1153
+ following: exactCountMetric(profile.followsCount, "Bluesky followsCount"),
1154
+ posts: exactCountMetric(profile.postsCount, "Bluesky postsCount"),
1155
+ }),
1156
+ metadata: Object.freeze({
1157
+ handle,
1158
+ displayName: optionalString(
1159
+ profile.displayName,
1160
+ "Bluesky profile stats display name",
1161
+ 1_000,
1162
+ ),
1163
+ bio: optionalString(profile.description, "Bluesky profile stats description", 10_000),
1164
+ }),
1165
+ });
1166
+ }
1167
+
1168
+ export function projectBlueskyProfile(
1169
+ value: unknown,
1170
+ expectedDid: string,
1171
+ ): BlueskyProjectedProfile {
1172
+ const profile = record(value, "Bluesky profile");
1173
+ const did = blueskyDid(profile.did, "Bluesky profile DID");
1174
+ if (did !== expectedDid) throw new Error("Bluesky profile response did not bind the requested actor");
1175
+ const viewer = isRecord(profile.viewer) ? profile.viewer : {};
1176
+ const following = optionalString(viewer.following, "Bluesky following URI", 1_024);
1177
+ const followedBy = optionalString(viewer.followedBy, "Bluesky followed-by URI", 1_024);
1178
+ if (following !== null) parseBlueskyAtUri(following, "Bluesky following URI", "app.bsky.graph.follow");
1179
+ if (followedBy !== null) parseBlueskyAtUri(followedBy, "Bluesky followed-by URI", "app.bsky.graph.follow");
1180
+ const handle = string(profile.handle, "Bluesky profile handle", 253);
1181
+ if (!handlePattern.test(handle)) throw new Error("Bluesky profile handle is invalid");
1182
+ return Object.freeze({
1183
+ did,
1184
+ handle,
1185
+ displayName: optionalString(profile.displayName, "Bluesky profile display name", 1_000),
1186
+ following,
1187
+ followedBy,
1188
+ });
1189
+ }
1190
+
1191
+ export type BlueskyProjectedMessage = {
1192
+ readonly id: string;
1193
+ readonly rev: string;
1194
+ readonly senderDid: string | null;
1195
+ readonly text: string | null;
1196
+ readonly sentAt: string;
1197
+ readonly deleted: boolean;
1198
+ readonly system: boolean;
1199
+ };
1200
+
1201
+ export function projectBlueskyMessage(value: unknown): BlueskyProjectedMessage {
1202
+ const message = record(value, "Bluesky chat message");
1203
+ const sender = isRecord(message.sender) ? message.sender : null;
1204
+ const text = optionalString(message.text, "Bluesky chat message text", 10_000);
1205
+ return Object.freeze({
1206
+ id: string(message.id, "Bluesky chat message ID", 512),
1207
+ rev: string(message.rev, "Bluesky chat message revision", 512),
1208
+ senderDid: sender === null ? null : blueskyDid(sender.did, "Bluesky chat sender DID"),
1209
+ text,
1210
+ sentAt: string(message.sentAt, "Bluesky chat sent time", 128),
1211
+ deleted: text === null && sender !== null,
1212
+ system: text === null && sender === null,
1213
+ });
1214
+ }
1215
+
1216
+ export type BlueskyProjectedConvo = {
1217
+ readonly id: string;
1218
+ readonly rev: string;
1219
+ readonly members: readonly {
1220
+ readonly did: string;
1221
+ readonly handle: string;
1222
+ readonly displayName: string | null;
1223
+ }[];
1224
+ readonly muted: boolean;
1225
+ readonly unreadCount: number;
1226
+ readonly status: string | null;
1227
+ readonly lastMessage: BlueskyProjectedMessage | null;
1228
+ };
1229
+
1230
+ export function projectBlueskyConvo(
1231
+ value: unknown,
1232
+ viewerDid: string,
1233
+ expectedId?: string,
1234
+ ): BlueskyProjectedConvo {
1235
+ const convo = record(value, "Bluesky conversation");
1236
+ const id = string(convo.id, "Bluesky conversation ID", 512);
1237
+ if (expectedId !== undefined && id !== expectedId) {
1238
+ throw new Error("Bluesky conversation response did not bind the requested conversation");
1239
+ }
1240
+ const members = boundedArray(convo.members, "Bluesky conversation members").map((item) => {
1241
+ const member = record(item, "Bluesky conversation member");
1242
+ const handle = string(member.handle, "Bluesky conversation member handle", 253);
1243
+ if (!handlePattern.test(handle)) throw new Error("Bluesky conversation member handle is invalid");
1244
+ return Object.freeze({
1245
+ did: blueskyDid(member.did, "Bluesky conversation member DID"),
1246
+ handle,
1247
+ displayName: optionalString(member.displayName, "Bluesky conversation member display name", 1_000),
1248
+ });
1249
+ });
1250
+ if (!members.some((member) => member.did === viewerDid)) {
1251
+ throw new Error("Bluesky conversation did not include the bound viewer");
1252
+ }
1253
+ return Object.freeze({
1254
+ id,
1255
+ rev: string(convo.rev, "Bluesky conversation revision", 512),
1256
+ members: Object.freeze(members),
1257
+ muted: boolean(convo.muted, "Bluesky conversation muted state"),
1258
+ unreadCount: integer(convo.unreadCount, "Bluesky conversation unread count"),
1259
+ status: optionalString(convo.status, "Bluesky conversation status", 64),
1260
+ lastMessage: convo.lastMessage === undefined
1261
+ ? null
1262
+ : projectBlueskyMessage(convo.lastMessage),
1263
+ });
1264
+ }
1265
+
1266
+ export function projectBlueskyConvoList(
1267
+ value: unknown,
1268
+ viewerDid: string,
1269
+ limit: number,
1270
+ ): {
1271
+ readonly conversations: readonly BlueskyProjectedConvo[];
1272
+ readonly cursor: string | null;
1273
+ readonly truncated: boolean;
1274
+ } {
1275
+ const response = record(value, "Bluesky conversation list");
1276
+ const source = boundedArray(response.convos, "Bluesky conversations");
1277
+ return Object.freeze({
1278
+ conversations: Object.freeze(
1279
+ source.slice(0, limit).map((item) => projectBlueskyConvo(item, viewerDid)),
1280
+ ),
1281
+ cursor: optionalString(response.cursor, "Bluesky conversation cursor", 8_192),
1282
+ truncated: source.length > limit || response.cursor !== undefined,
1283
+ });
1284
+ }
1285
+
1286
+ export function projectBlueskyMessages(
1287
+ value: unknown,
1288
+ limit: number,
1289
+ ): {
1290
+ readonly messages: readonly BlueskyProjectedMessage[];
1291
+ readonly cursor: string | null;
1292
+ readonly truncated: boolean;
1293
+ } {
1294
+ const response = record(value, "Bluesky message list");
1295
+ const source = boundedArray(response.messages, "Bluesky messages");
1296
+ return Object.freeze({
1297
+ messages: Object.freeze(source.slice(0, limit).map(projectBlueskyMessage)),
1298
+ cursor: optionalString(response.cursor, "Bluesky message cursor", 8_192),
1299
+ truncated: source.length > limit || response.cursor !== undefined,
1300
+ });
1301
+ }
1302
+
1303
+ export function parseBlueskySessionResponse(
1304
+ value: unknown,
1305
+ ): { readonly did: string; readonly handle: string; readonly active: boolean } {
1306
+ const session = record(value, "Bluesky getSession response");
1307
+ const handle = string(session.handle, "Bluesky session handle", 253);
1308
+ if (!handlePattern.test(handle)) throw new Error("Bluesky session handle is invalid");
1309
+ return Object.freeze({
1310
+ did: blueskyDid(session.did, "Bluesky session DID"),
1311
+ handle,
1312
+ active: session.active !== false,
1313
+ });
1314
+ }
1315
+
1316
+ export function parseBlueskyCreateRecordResponse(
1317
+ value: unknown,
1318
+ actorDid: string,
1319
+ collection: string,
1320
+ ): BlueskyStrongRef {
1321
+ const response = record(value, "Bluesky createRecord response");
1322
+ const parsed = parseBlueskyAtUri(
1323
+ response.uri,
1324
+ "Bluesky created record URI",
1325
+ collection,
1326
+ );
1327
+ if (parsed.actor !== actorDid) throw new Error("Bluesky created record actor did not match the bound viewer");
1328
+ return Object.freeze({
1329
+ uri: parsed.uri,
1330
+ cid: blueskyCid(response.cid, "Bluesky created record CID"),
1331
+ });
1332
+ }
1333
+
1334
+ /**
1335
+ * Bind one authoritative PDS record read to the exact response-bound record
1336
+ * reference and the exact value submitted by the confirmed publish plan.
1337
+ */
1338
+ export function parseBlueskyGetRecordResponse(
1339
+ value: unknown,
1340
+ expected: BlueskyStrongRef,
1341
+ expectedValue: Readonly<Record<string, unknown>>,
1342
+ ): BlueskyStrongRef {
1343
+ const response = record(value, "Bluesky getRecord response");
1344
+ exactKeys(
1345
+ response,
1346
+ ["uri", "cid", "value"],
1347
+ [],
1348
+ "Bluesky getRecord response",
1349
+ );
1350
+ const actual = blueskyStrongRef(
1351
+ response,
1352
+ "Bluesky getRecord response",
1353
+ expected.uri,
1354
+ );
1355
+ if (actual.cid !== expected.cid) {
1356
+ throw new Error("Bluesky getRecord response changed the created record CID");
1357
+ }
1358
+ const recordValue = record(
1359
+ response.value,
1360
+ "Bluesky getRecord response.value",
1361
+ );
1362
+ if (canonicalJson(recordValue) !== canonicalJson(expectedValue)) {
1363
+ throw new Error("Bluesky getRecord response did not bind the confirmed record value");
1364
+ }
1365
+ return actual;
1366
+ }
1367
+
1368
+ /** Bind the current authoritative PDS revision before deleting one post. */
1369
+ export function parseBlueskyCurrentPostRecordResponse(
1370
+ value: unknown,
1371
+ expectedUri: string,
1372
+ expectedCid: string,
1373
+ ): BlueskyStrongRef {
1374
+ const parsedUri = blueskyPostUri(
1375
+ expectedUri,
1376
+ "Bluesky deletion target URI",
1377
+ );
1378
+ const response = record(value, "Bluesky deletion pre-read response");
1379
+ exactKeys(
1380
+ response,
1381
+ ["uri", "cid", "value"],
1382
+ [],
1383
+ "Bluesky deletion pre-read response",
1384
+ );
1385
+ const actual = blueskyStrongRef(
1386
+ response,
1387
+ "Bluesky deletion pre-read response",
1388
+ parsedUri.uri,
1389
+ );
1390
+ if (actual.cid !== blueskyCid(expectedCid, "Bluesky deletion target CID")) {
1391
+ throw new Error("Bluesky deletion target revision changed from the confirmed CID");
1392
+ }
1393
+ const recordValue = record(
1394
+ response.value,
1395
+ "Bluesky deletion pre-read response.value",
1396
+ );
1397
+ if (recordValue.$type !== "app.bsky.feed.post") {
1398
+ throw new Error("Bluesky deletion target was not an app.bsky.feed.post record");
1399
+ }
1400
+ return actual;
1401
+ }
1402
+
1403
+ /** Parse only the documented XRPC absence marker for an authoritative record read. */
1404
+ export function parseBlueskyRecordNotFoundResponse(value: unknown): void {
1405
+ const response = record(value, "Bluesky RecordNotFound response");
1406
+ exactKeys(
1407
+ response,
1408
+ ["error", "message"],
1409
+ [],
1410
+ "Bluesky RecordNotFound response",
1411
+ );
1412
+ if (response.error !== "RecordNotFound") {
1413
+ throw new Error("Bluesky record absence response used an unexpected error code");
1414
+ }
1415
+ string(
1416
+ response.message,
1417
+ "Bluesky RecordNotFound response.message",
1418
+ 1_024,
1419
+ );
1420
+ }
1421
+
1422
+ /** Bind the documented optional commit projection returned by deleteRecord. */
1423
+ export function parseBlueskyDeleteRecordResponse(
1424
+ value: unknown,
1425
+ ): { readonly commit: null | { readonly cid: string; readonly rev: string } } {
1426
+ const response = record(value, "Bluesky deleteRecord response");
1427
+ exactKeys(response, [], ["commit"], "Bluesky deleteRecord response");
1428
+ if (response.commit === undefined) return Object.freeze({ commit: null });
1429
+ const commit = record(response.commit, "Bluesky deleteRecord response.commit");
1430
+ exactKeys(
1431
+ commit,
1432
+ ["cid", "rev"],
1433
+ [],
1434
+ "Bluesky deleteRecord response.commit",
1435
+ );
1436
+ const rev = string(
1437
+ commit.rev,
1438
+ "Bluesky deleteRecord response.commit.rev",
1439
+ 64,
1440
+ );
1441
+ if (!/^[234567abcdefghijklmnopqrstuvwxyz]{13}$/u.test(rev)) {
1442
+ throw new Error("Bluesky deleteRecord response.commit.rev must be a TID");
1443
+ }
1444
+ return Object.freeze({
1445
+ commit: Object.freeze({
1446
+ cid: blueskyCid(
1447
+ commit.cid,
1448
+ "Bluesky deleteRecord response.commit.cid",
1449
+ ),
1450
+ rev,
1451
+ }),
1452
+ });
1453
+ }
1454
+
1455
+ export type BlueskyBlobRef = {
1456
+ readonly $type: "blob";
1457
+ readonly ref: { readonly $link: string };
1458
+ readonly mimeType: string;
1459
+ readonly size: number;
1460
+ };
1461
+
1462
+ function blueskyBlobRef(
1463
+ value: unknown,
1464
+ label: string,
1465
+ expectedMimeType: string,
1466
+ maximumSize: number,
1467
+ expectedSize?: number,
1468
+ ): BlueskyBlobRef {
1469
+ const blob = record(value, label);
1470
+ exactKeys(blob, ["$type", "ref", "mimeType", "size"], [], label);
1471
+ if (blob.$type !== "blob") throw new Error(`${label} had an unexpected type`);
1472
+ const ref = record(blob.ref, `${label} reference`);
1473
+ exactKeys(ref, ["$link"], [], `${label} reference`);
1474
+ const mimeType = string(blob.mimeType, `${label} media type`, 128);
1475
+ const size = integer(blob.size, `${label} size`, 1, maximumSize);
1476
+ if (
1477
+ mimeType !== expectedMimeType
1478
+ || (expectedSize !== undefined && size !== expectedSize)
1479
+ ) throw new Error(`${label} did not match the confirmed file`);
1480
+ return Object.freeze({
1481
+ $type: "blob",
1482
+ ref: Object.freeze({ $link: blueskyCid(ref.$link, `${label} CID`) }),
1483
+ mimeType,
1484
+ size,
1485
+ });
1486
+ }
1487
+
1488
+ export function parseBlueskyUploadBlobResponse(
1489
+ value: unknown,
1490
+ expectedMimeType: string,
1491
+ expectedSize: number,
1492
+ ): BlueskyBlobRef {
1493
+ const response = record(value, "Bluesky uploadBlob response");
1494
+ exactKeys(response, ["blob"], [], "Bluesky uploadBlob response");
1495
+ return blueskyBlobRef(
1496
+ response.blob,
1497
+ "Bluesky uploaded blob",
1498
+ expectedMimeType,
1499
+ 100 * 1024 * 1024,
1500
+ expectedSize,
1501
+ );
1502
+ }
1503
+
1504
+ const BLUESKY_VIDEO_JOB_STATES = Object.freeze([
1505
+ "JOB_STATE_CREATED",
1506
+ "JOB_STATE_ENCODING",
1507
+ "JOB_STATE_ENCODED",
1508
+ "JOB_STATE_SCANNING",
1509
+ "JOB_STATE_SCANNED",
1510
+ "JOB_STATE_UPLOADING",
1511
+ "JOB_STATE_UPLOADED",
1512
+ "JOB_STATE_COMPLETED",
1513
+ "JOB_STATE_FAILED",
1514
+ ] as const);
1515
+
1516
+ export type BlueskyVideoJobStatus = Readonly<{
1517
+ jobId: string;
1518
+ did: string;
1519
+ state: (typeof BLUESKY_VIDEO_JOB_STATES)[number];
1520
+ progress: number | null;
1521
+ blob: BlueskyBlobRef | null;
1522
+ error: string | null;
1523
+ failureCode: string | null;
1524
+ message: string | null;
1525
+ }>;
1526
+
1527
+ function blueskyVideoJobStatus(
1528
+ value: unknown,
1529
+ expectedDid: string,
1530
+ expectedJobId?: string,
1531
+ ): BlueskyVideoJobStatus {
1532
+ const status = record(value, "Bluesky video job status");
1533
+ exactKeys(
1534
+ status,
1535
+ ["jobId", "did", "state"],
1536
+ ["progress", "blob", "error", "failureCode", "message"],
1537
+ "Bluesky video job status",
1538
+ );
1539
+ const jobId = string(status.jobId, "Bluesky video job ID", 1_024);
1540
+ if (expectedJobId !== undefined && jobId !== expectedJobId) {
1541
+ throw new Error("Bluesky video job response switched jobs");
1542
+ }
1543
+ const did = blueskyDid(status.did, "Bluesky video job DID");
1544
+ if (did !== blueskyDid(expectedDid, "Bluesky expected video job DID")) {
1545
+ throw new Error("Bluesky video job response switched accounts");
1546
+ }
1547
+ const rawState = string(status.state, "Bluesky video job state", 64);
1548
+ if (!(BLUESKY_VIDEO_JOB_STATES as readonly string[]).includes(rawState)) {
1549
+ throw new Error("Bluesky video job returned an unreviewed state");
1550
+ }
1551
+ const state = rawState as BlueskyVideoJobStatus["state"];
1552
+ const progress = status.progress === undefined
1553
+ ? null
1554
+ : integer(status.progress, "Bluesky video job progress", 0, 100);
1555
+ const blob = status.blob === undefined
1556
+ ? null
1557
+ : blueskyBlobRef(
1558
+ status.blob,
1559
+ "Bluesky processed video blob",
1560
+ "video/mp4",
1561
+ 100_000_000,
1562
+ );
1563
+ if ((state === "JOB_STATE_COMPLETED") !== (blob !== null)) {
1564
+ throw new Error("Bluesky video job completion did not bind one processed blob");
1565
+ }
1566
+ return Object.freeze({
1567
+ jobId,
1568
+ did,
1569
+ state,
1570
+ progress,
1571
+ blob,
1572
+ error: status.error === undefined
1573
+ ? null
1574
+ : string(status.error, "Bluesky video job error", 2_048),
1575
+ failureCode: status.failureCode === undefined
1576
+ ? null
1577
+ : string(status.failureCode, "Bluesky video job failure code", 256),
1578
+ message: status.message === undefined
1579
+ ? null
1580
+ : string(status.message, "Bluesky video job message", 2_048),
1581
+ });
1582
+ }
1583
+
1584
+ /** The current first-party legacy upload returns a direct JobStatus object. */
1585
+ export function parseBlueskyVideoUploadResponse(
1586
+ value: unknown,
1587
+ expectedDid: string,
1588
+ ): BlueskyVideoJobStatus {
1589
+ return blueskyVideoJobStatus(value, expectedDid);
1590
+ }
1591
+
1592
+ /** The public getJobStatus lexicon wraps the exact job status. */
1593
+ export function parseBlueskyVideoJobStatusResponse(
1594
+ value: unknown,
1595
+ expectedDid: string,
1596
+ expectedJobId: string,
1597
+ ): BlueskyVideoJobStatus {
1598
+ const response = record(value, "Bluesky video getJobStatus response");
1599
+ exactKeys(response, ["jobStatus"], [], "Bluesky video getJobStatus response");
1600
+ return blueskyVideoJobStatus(response.jobStatus, expectedDid, expectedJobId);
1601
+ }
1602
+
1603
+ export function assertBlueskyText(
1604
+ value: unknown,
1605
+ label: string,
1606
+ maximumCodePoints: number,
1607
+ maximumCodeUnits: number,
1608
+ ): string {
1609
+ const text = string(value, label, maximumCodeUnits);
1610
+ if (Array.from(text).length > maximumCodePoints) {
1611
+ throw new Error(`${label} exceeded ${maximumCodePoints} Unicode code points`);
1612
+ }
1613
+ return text;
1614
+ }